diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..0b94288949188660a4e1d383ed649fbc1f457a54 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.11.7-bookworm + +WORKDIR /app + +COPY ./app/ . + +RUN pip install --no-cache-dir --upgrade -r /app/requirements.txt + +ENV SUPABASE_URL='https://lakewjtjnwmihctodrow.supabase.co' +ENV SUPABASE_SECRET_KEY='eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imxha2V3anRqbndtaWhjdG9kcm93Iiwicm9sZSI6ImFub24iLCJpYXQiOjE2OTg0MTgwMTMsImV4cCI6MjAxMzk5NDAxM30.6UI7vAzZXZFoSXPaJ2v8LQYpHmzAAuBVykWXV8lMLEA' +EXPOSE 5000 +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"] \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..2c49b41d7a0a344d16c6fc60e0015b9c2c5920dd --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +# This file is intentionally left blank \ No newline at end of file diff --git a/app/__pycache__/__init__.cpython-312.pyc b/app/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..af5464bf42f2dad866cd3a94a5a9444c498443d3 Binary files /dev/null and b/app/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/__pycache__/main.cpython-312.pyc b/app/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0b15999d0b67d07fd2335c57e8c17b2cc18a5fbe Binary files /dev/null and b/app/__pycache__/main.cpython-312.pyc differ diff --git a/app/crud/__init__.py b/app/crud/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/crud/__pycache__/__init__.cpython-312.pyc b/app/crud/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7e760697c7c83b49d73d4b3912f0446dee00c13e Binary files /dev/null and b/app/crud/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/crud/__pycache__/song.cpython-312.pyc b/app/crud/__pycache__/song.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2ed534a955aab288e043946cf510308070662801 Binary files /dev/null and b/app/crud/__pycache__/song.cpython-312.pyc differ diff --git a/app/crud/__pycache__/user.cpython-312.pyc b/app/crud/__pycache__/user.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..de2c3e1ba3e5ad577ea9308de115209fbb06a12d Binary files /dev/null and b/app/crud/__pycache__/user.cpython-312.pyc differ diff --git a/app/crud/user.py b/app/crud/user.py new file mode 100644 index 0000000000000000000000000000000000000000..dde53b859f62333fb48b9ae401cac752be76e4ac --- /dev/null +++ b/app/crud/user.py @@ -0,0 +1,25 @@ +from supabase import Client +from utils.auth import get_user_response +from models.user import User + + +async def update_user( + supabase: Client, email: str = None, password: str = None, data: dict = None +): + info = {} + if email: + info["email"] = email + if password: + info["password"] = password + if data: + info["data"] = data + supabase.auth.update_user(info) + return True + + +async def get_user(supabase: Client, token: str): + user_response = get_user_response(token, supabase) + if user_response: + return User(email=user_response.user.email, **user_response.user.user_metadata) + else: + return None diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..2c49b41d7a0a344d16c6fc60e0015b9c2c5920dd --- /dev/null +++ b/app/db/__init__.py @@ -0,0 +1 @@ +# This file is intentionally left blank \ No newline at end of file diff --git a/app/db/__pycache__/__init__.cpython-312.pyc b/app/db/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ff02bf92be990ef9a83838574665cb6c9e8de2fa Binary files /dev/null and b/app/db/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/db/__pycache__/supabase_service.cpython-312.pyc b/app/db/__pycache__/supabase_service.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1bc9162a8ea710a41c0fca46b631723096cdc336 Binary files /dev/null and b/app/db/__pycache__/supabase_service.cpython-312.pyc differ diff --git a/app/db/supabase_service.py b/app/db/supabase_service.py new file mode 100644 index 0000000000000000000000000000000000000000..e02480435730bbb8ea38ba80f0a444b57e1266a5 --- /dev/null +++ b/app/db/supabase_service.py @@ -0,0 +1,13 @@ +from supabase import create_client +from dotenv import load_dotenv +import os + +load_dotenv() + +SUPABASE_URL = os.getenv("SUPABASE_URL") +SUPABASE_SECRET_KEY = os.getenv("SUPABASE_SECRET_KEY") +SUPABASE = create_client(SUPABASE_URL, SUPABASE_SECRET_KEY) + + +def get_supabase(): + return SUPABASE diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000000000000000000000000000000000000..aff30c49fd2a66e64dd371a29696899c6e28c759 --- /dev/null +++ b/app/main.py @@ -0,0 +1,26 @@ +from fastapi.responses import RedirectResponse +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from routers import authentication, users, group_admin, song, album, concert, playlist + +app = FastAPI() + +app.include_router(authentication.router) +app.include_router(users.router) +app.include_router(group_admin.router) +app.include_router(song.router) +app.include_router(album.router) +app.include_router(concert.router) +app.include_router(playlist.router) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/") +def read_root(): + return RedirectResponse(url="/docs") diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..2c49b41d7a0a344d16c6fc60e0015b9c2c5920dd --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1 @@ +# This file is intentionally left blank \ No newline at end of file diff --git a/app/models/__pycache__/__init__.cpython-312.pyc b/app/models/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d26b3963903b7f7a7f1fe7c0bf433423a0c4d922 Binary files /dev/null and b/app/models/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/models/__pycache__/concert.cpython-312.pyc b/app/models/__pycache__/concert.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4ce813d133f3ede2eb4545162c20052048cf6824 Binary files /dev/null and b/app/models/__pycache__/concert.cpython-312.pyc differ diff --git a/app/models/__pycache__/enums.cpython-312.pyc b/app/models/__pycache__/enums.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b6e1319af300d49fb488ed3956bd5f751a27c174 Binary files /dev/null and b/app/models/__pycache__/enums.cpython-312.pyc differ diff --git a/app/models/__pycache__/group.cpython-312.pyc b/app/models/__pycache__/group.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c921eaab130985989dff6ff2e924fa33df15d701 Binary files /dev/null and b/app/models/__pycache__/group.cpython-312.pyc differ diff --git a/app/models/__pycache__/music.cpython-312.pyc b/app/models/__pycache__/music.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ac1aaf8e5c302c50da7b6cd7a5dbb343b976ddbf Binary files /dev/null and b/app/models/__pycache__/music.cpython-312.pyc differ diff --git a/app/models/__pycache__/song.cpython-312.pyc b/app/models/__pycache__/song.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..47a9150fc0c927f57cd334cc08e92e8f02fbb3da Binary files /dev/null and b/app/models/__pycache__/song.cpython-312.pyc differ diff --git a/app/models/__pycache__/user.cpython-312.pyc b/app/models/__pycache__/user.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f7787a1209c6a9975aac8d2ce5be130a403f454e Binary files /dev/null and b/app/models/__pycache__/user.cpython-312.pyc differ diff --git a/app/models/concert.py b/app/models/concert.py new file mode 100644 index 0000000000000000000000000000000000000000..fbcf0d5410d2353179e1d3ad1d94056e6259e689 --- /dev/null +++ b/app/models/concert.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel + + +class ConcertInfo(BaseModel): + name: str + location: str + description: str | None = None + start_at: str + number_of_tickets: int = 0 + + +class Concert(ConcertInfo): + id: int + created_at: str + group_id: int diff --git a/app/models/enums.py b/app/models/enums.py new file mode 100644 index 0000000000000000000000000000000000000000..3b2878a7e8cbed148e45212c11f6de07a10796f7 --- /dev/null +++ b/app/models/enums.py @@ -0,0 +1,13 @@ +from enum import Enum + + +class Role(str, Enum): + user = "user" + admin = "admin" + group_admin = "group_admin" + + +class Gender(Enum): + male = 0 + female = 1 + other = 2 diff --git a/app/models/group.py b/app/models/group.py new file mode 100644 index 0000000000000000000000000000000000000000..5ae3e8ec4b3bcf61ac12e3d16fcc0a9172ec0bcb --- /dev/null +++ b/app/models/group.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel + + +class GroupBase(BaseModel): + id: int + created_at: str + + +class GroupInfo(BaseModel): + name: str + description: str | None = None + + +class Group(GroupBase, GroupInfo): + admin_id: str + members_id: list[str] | None = None diff --git a/app/models/music.py b/app/models/music.py new file mode 100644 index 0000000000000000000000000000000000000000..51f7c14ea432f65f6f5affd8c77b0560a2ca9746 --- /dev/null +++ b/app/models/music.py @@ -0,0 +1,30 @@ +from pydantic import BaseModel + + +class Song(BaseModel): + id: int + name: str + created_at: str + user_id: str + name_in_storage: str + description: str | None + lyric: str | None + + +class BaseAlbum(BaseModel): + name: str + description: str | None + + +class Album(BaseAlbum): + id: int + created_at: str + user_id: str + +class BasePlaylist(BaseModel): + name: str + +class Playlist(BasePlaylist): + id: int + created_at: str + user_id: str diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000000000000000000000000000000000000..290a0e83509ead0454744cd2d358504b4b0b9e54 --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,28 @@ +from typing import Optional +from pydantic import BaseModel, Field +from datetime import date +from .enums import Gender, Role + + +class UserBase(BaseModel): + email: str = Field(description="Email of user", example="datvip@gmail.com") + + +class UserLogin(UserBase): + password: str + + +class UserInfo(BaseModel): + name: str + birthdate: date | None = Field(example="2000-01-01") + phone_number: str | None = Field(example="0382929292") + gender: Gender + role: Role = Role.user + + +class UserSignup(UserLogin, UserInfo): + pass + + +class User(UserBase, UserInfo): + pass diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..348893f3682010267bff77507c3bc27d470cad3d Binary files /dev/null and b/app/requirements.txt differ diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..2c49b41d7a0a344d16c6fc60e0015b9c2c5920dd --- /dev/null +++ b/app/routers/__init__.py @@ -0,0 +1 @@ +# This file is intentionally left blank \ No newline at end of file diff --git a/app/routers/__pycache__/__init__.cpython-312.pyc b/app/routers/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..658deb4c1a5b8b8277cd5dbc1b59d1793f80c44e Binary files /dev/null and b/app/routers/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/routers/__pycache__/album.cpython-312.pyc b/app/routers/__pycache__/album.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b827beeb18e3256cf6423a6360a105ce20694542 Binary files /dev/null and b/app/routers/__pycache__/album.cpython-312.pyc differ diff --git a/app/routers/__pycache__/authentication.cpython-312.pyc b/app/routers/__pycache__/authentication.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a84e41f9b0522b55531fd5cc44ee8b10efd6fa43 Binary files /dev/null and b/app/routers/__pycache__/authentication.cpython-312.pyc differ diff --git a/app/routers/__pycache__/concert.cpython-312.pyc b/app/routers/__pycache__/concert.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bf2480e9903b77e7960e8c46607ea526b0da8a4b Binary files /dev/null and b/app/routers/__pycache__/concert.cpython-312.pyc differ diff --git a/app/routers/__pycache__/group_admin.cpython-312.pyc b/app/routers/__pycache__/group_admin.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..496bd2e46c8b9d5e1eeab43ad558c012747bea4e Binary files /dev/null and b/app/routers/__pycache__/group_admin.cpython-312.pyc differ diff --git a/app/routers/__pycache__/playlist.cpython-312.pyc b/app/routers/__pycache__/playlist.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c2eb33082ec3fa4223def83a3f982353b93fb72e Binary files /dev/null and b/app/routers/__pycache__/playlist.cpython-312.pyc differ diff --git a/app/routers/__pycache__/song.cpython-312.pyc b/app/routers/__pycache__/song.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ffb4cd7be692b18e411e1e1e21d88c85ca5553cc Binary files /dev/null and b/app/routers/__pycache__/song.cpython-312.pyc differ diff --git a/app/routers/__pycache__/users.cpython-312.pyc b/app/routers/__pycache__/users.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..37ba36db46593bf6494f3ce72f9a7e1e66654492 Binary files /dev/null and b/app/routers/__pycache__/users.cpython-312.pyc differ diff --git a/app/routers/album.py b/app/routers/album.py new file mode 100644 index 0000000000000000000000000000000000000000..bab17fdda8be40d953f2ebe8847c0e6a32d8374e --- /dev/null +++ b/app/routers/album.py @@ -0,0 +1,106 @@ +from fastapi import ( + APIRouter, + Depends, + HTTPException, + status, + Security, +) +from models.music import Album, BaseAlbum +from db.supabase_service import get_supabase +from typing import Annotated, List, Tuple +from supabase import Client +from utils.auth import get_id +from fastapi.encoders import jsonable_encoder +from utils.exceptions import BAD_REQUEST + +router = APIRouter(tags=["Album"], prefix="/album") + + +@router.post("", description="Create album") +async def create_album( + supabase: Annotated[Client, Depends(get_supabase)], + user_id: Annotated[str, Security(get_id)], + album: BaseAlbum, +): + try: + supabase.table("albums").insert( + {"name": album.name, "description": album.description, "user_id": user_id} + ).execute() + return {"detail": "Album created"} + except: + raise BAD_REQUEST + + +# delete album +@router.delete("", description="Delete album") +async def delete_album( + supabase: Annotated[Client, Depends(get_supabase)], + user_id: Annotated[str, Security(get_id)], + album_id: int, +): + try: + supabase.table("albums").delete().eq("id", album_id).execute() + return {"detail": "Album deleted"} + except: + raise BAD_REQUEST + + +# get list of all album +@router.get("/all", description="Get list of all album", response_model=List[Album]) +async def get_all_album( + supabase: Annotated[Client, Depends(get_supabase)], + user_id: Annotated[str, Security(get_id)], +) -> List[Album]: + try: + res = supabase.table("albums").select("*").execute().dict()["data"] + return res + except: + raise BAD_REQUEST + + +# modify album +@router.patch("", description="Modify album") +async def modify_album( + supabase: Annotated[Client, Depends(get_supabase)], + user_id: Annotated[str, Security(get_id)], + album: Album, + songs: List[int], +): + try: + if album.user_id != user_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="You are not the owner of this album", + ) + supabase.table("albums").update( + jsonable_encoder(album, exclude={"id", "user_id", "created_at"}) + ).eq("id", album.id).execute() + supabase.table("songs").update({"album_id": None}).eq( + "album_id", album.id + ).execute() + supabase.table("songs").update({"album_id": album.id}).in_( + "id", songs + ).execute() + return {"detail": "Album modified"} + except: + raise BAD_REQUEST + + +# get album and its songs +@router.get("", description="Get album and its songs") +async def get_album( + supabase: Annotated[Client, Depends(get_supabase)], + user_id: Annotated[str, Security(get_id)], + album_id: int, +): + try: + res = ( + supabase.table("albums") + .select("*, songs(*)") + .eq("id", album_id) + .execute() + .dict()["data"][0] + ) + return res + except: + raise BAD_REQUEST diff --git a/app/routers/authentication.py b/app/routers/authentication.py new file mode 100644 index 0000000000000000000000000000000000000000..79e32a9fcf8a66e61d7475dae379ab913b9ef5a0 --- /dev/null +++ b/app/routers/authentication.py @@ -0,0 +1,114 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Security, Body, Query +from fastapi.security import OAuth2PasswordRequestForm +from models.user import UserSignup +from db.supabase_service import get_supabase +from typing import Annotated +from supabase import Client +from utils.auth import get_id +from fastapi.encoders import jsonable_encoder +from models.enums import Role +from gotrue.errors import AuthApiError +from crud.user import update_user +from utils.exceptions import BAD_REQUEST, CONFLICT, NOT_FOUND + +router = APIRouter(tags=["Authentication"]) + + +@router.post("/login", description="Login user using email and password") +async def login( + form_data: Annotated[OAuth2PasswordRequestForm, Depends()], + supabase: Annotated[Client, Depends(get_supabase)], +): + email = form_data.username + password = form_data.password + try: + user = supabase.auth.sign_in_with_password( + {"email": email, "password": password} + ) + return { + "access_token": user.session.access_token, + "token_type": "bearer", + } + except: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Incorrect email or password", + ) + + +@router.post( + "/signup", description="Sign up new user", status_code=status.HTTP_201_CREATED +) +async def signup( + user: Annotated[UserSignup, Body()], + supabase: Annotated[Client, Depends(get_supabase)], +): + email = user.email + password = user.password + if len(password) < 6: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Password should be at least 6 characters", + ) + try: + user.role = Role.user + res = supabase.auth.sign_up( + { + "email": email, + "password": password, + "options": { + "data": jsonable_encoder(user, exclude=("password", "email")) + }, + } + ) + return {"detail": "User created"} + except AuthApiError: + raise CONFLICT + except: + raise BAD_REQUEST + + +@router.post("/reset_password", description="Send reset password email") +async def reset_password( + supabase: Annotated[Client, Depends(get_supabase)], + email: str, +): + try: + if ( + len(supabase.rpc("get_user_id_by_email", {"email": email}).execute().data) + > 0 + ): + supabase.auth.reset_password_email(email) + return {"detail": "Reset password email sent"} + else: + raise NOT_FOUND + except: + raise NOT_FOUND + + +@router.post("/reset_password_confirm", description="Reset password") +async def reset_password_confirm( + supabase: Annotated[Client, Depends(get_supabase)], + email: Annotated[str, Query(description="Email", title="Email")], + new_password: Annotated[ + str, Query(min_length=6, description="New password", title="New password") + ], + token: Annotated[ + str, Query(description="Reset password token", title="Reset password token") + ], +): + try: + supabase.auth.verify_otp( + { + "email": email, + "token": token, + "type": "email", + } + ) + await update_user(supabase, password=new_password) + return {"detail": "Your password has been reset"} + except: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Incorrect token", + ) diff --git a/app/routers/concert.py b/app/routers/concert.py new file mode 100644 index 0000000000000000000000000000000000000000..42b35f11135d0e4bd1a8095d9bea42b4cead959b --- /dev/null +++ b/app/routers/concert.py @@ -0,0 +1,69 @@ +from fastapi import APIRouter, Depends, Security +from typing import Annotated +from fastapi.encoders import jsonable_encoder +from supabase import Client +from db.supabase_service import get_supabase +from typing import Annotated +from supabase import Client +from utils.auth import get_id +from utils.exceptions import BAD_REQUEST +from models.concert import ConcertInfo + +router = APIRouter(tags=["Concert"], prefix="/concert") + + +# create concert +@router.post("", description="Create concert") +async def create_concert( + supabase: Annotated[Client, Depends(get_supabase)], + user_id: Annotated[str, Security(get_id)], + concert_info: ConcertInfo, +): + try: + supabase.table("concerts").insert(jsonable_encoder(concert_info)).execute() + return {"detail": "Concert created"} + except: + raise BAD_REQUEST + + +# get list of all concert +@router.get("/all", description="Get list of all concert") +async def get_all_concert( + supabase: Annotated[Client, Depends(get_supabase)], + user_id: Annotated[str, Security(get_id)], +): + try: + res = supabase.table("concerts").select("*").execute().dict()["data"] + return res + except: + raise BAD_REQUEST + + +# modify concert +@router.put("", description="Modify concert") +async def modify_concert( + supabase: Annotated[Client, Depends(get_supabase)], + user_id: Annotated[str, Security(get_id)], + concert_info: ConcertInfo, +): + try: + supabase.table("concerts").update(jsonable_encoder(concert_info)).eq( + "id", concert_info.id + ).execute() + return {"detail": "Concert modified"} + except: + raise BAD_REQUEST + + +# delete concert +@router.delete("", description="Delete concert") +async def delete_concert( + supabase: Annotated[Client, Depends(get_supabase)], + user_id: Annotated[str, Security(get_id)], + concert_id: int, +): + try: + supabase.table("concerts").delete().eq("id", concert_id).execute() + return {"detail": "Concert deleted"} + except: + raise BAD_REQUEST diff --git a/app/routers/group_admin.py b/app/routers/group_admin.py new file mode 100644 index 0000000000000000000000000000000000000000..d9e0c244fc98882f9155cdc5f2f34708b85762c6 --- /dev/null +++ b/app/routers/group_admin.py @@ -0,0 +1,107 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Security +from db.supabase_service import get_supabase +from typing import Annotated +from supabase import Client +from utils.auth import get_role, get_id, oauth2_scheme +from models.enums import Role +from models.group import GroupInfo +from utils.exceptions import UNAUTHORIZED +from typing import List + +router = APIRouter(tags=["Group"], prefix="/group") + + +@router.post("", description="Create group") +async def create_group( + supabase: Annotated[Client, Depends(get_supabase)], + role: Annotated[str, Security(get_role)], + id: Annotated[str, Security(get_id)], + group_info: GroupInfo, +): + if role != Role.group_admin: + raise UNAUTHORIZED + supabase.table("groups").insert( + { + "name": group_info.name, + "description": group_info.description, + "admin_id": id, + } + ).execute() + return {"detail": "Group created"} + + +@router.post("/user", description="Modify group") +async def add_user( + token: Annotated[str, Depends(oauth2_scheme)], + supabase: Annotated[Client, Depends(get_supabase)], + role: Annotated[str, Security(get_role)], + id: Annotated[str, Security(get_id)], + email: str, + group_id: int, +): + if role != Role.group_admin: + raise UNAUTHORIZED + user_id = supabase.rpc("get_user_id_by_email", {"email": email}).execute().data + if len(user_id) == 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="User does not exist", + ) + user_id = user_id[0]["id"] + members_id: List = ( + supabase.table("groups") + .select("members_id") + .match({"admin_id": id, "id": group_id}) + .execute() + .data[0]["members_id"] + ) + if not members_id: + members_id = [] + if user_id in members_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="User already in group", + ) + members_id.append(user_id) + supabase.table("groups").update({"members_id": members_id}).match( + {"admin_id": id, "id": group_id} + ).execute() + return {"detail": "User added to group"} + + +@router.delete("/user", description="Delete user from group") +async def delete_user( + supabase: Annotated[Client, Depends(get_supabase)], + role: Annotated[str, Security(get_role)], + id: Annotated[str, Security(get_id)], + group_id: int, + email: str, +): + if role != Role.group_admin: + raise UNAUTHORIZED + user_id = supabase.rpc("get_user_id_by_email", {"email": email}).execute().data + if len(user_id) == 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="User does not exist", + ) + user_id = user_id[0]["id"] + members_id: List = ( + supabase.table("groups") + .select("members_id") + .match({"admin_id": id, "id": group_id}) + .execute() + .data[0]["members_id"] + ) + if not members_id: + members_id = [] + if user_id not in members_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="User not in group", + ) + members_id.remove(user_id) + supabase.table("groups").update({"members_id": members_id}).match( + {"admin_id": id, "id": group_id} + ).execute() + return {"detail": "User deleted from group"} diff --git a/app/routers/playlist.py b/app/routers/playlist.py new file mode 100644 index 0000000000000000000000000000000000000000..a7a5096d03c73139a15469da2040731baa6ceab6 --- /dev/null +++ b/app/routers/playlist.py @@ -0,0 +1,104 @@ +from fastapi import ( + APIRouter, + Depends, + HTTPException, + status, + Security, +) +from models.music import Playlist, BasePlaylist +from db.supabase_service import get_supabase +from typing import Annotated, List +from supabase import Client +from utils.auth import get_id +from fastapi.encoders import jsonable_encoder +from utils.exceptions import BAD_REQUEST + +router = APIRouter(tags=["Playlist"], prefix="/playlist") + + +@router.post("", description="Create playlist") +async def create_playlist( + supabase: Annotated[Client, Depends(get_supabase)], + user_id: Annotated[str, Security(get_id)], + playlist: BasePlaylist, +): + try: + supabase.table("playlist").insert( + {"name": playlist.name, "song_id" : [], "user_id": user_id} + ).execute() + return {"detail": "Playlist created"} + except: + raise BAD_REQUEST + + +@router.delete("", description="Delete playlist") +async def delete_playlist( + supabase: Annotated[Client, Depends(get_supabase)], + user_id: Annotated[str, Security(get_id)], + playlist_id: int, +): + try: + supabase.table("playlist").delete().eq("id", playlist_id).execute() + return {"detail": "Album deleted"} + except: + raise BAD_REQUEST + +# get list of all your playlist +@router.get("/all", description="Get list of your playlist", response_model=List[Playlist]) +async def get_all_album( + supabase: Annotated[Client, Depends(get_supabase)], + user_id: Annotated[str, Security(get_id)], +) -> List[Playlist]: + try: + + res = supabase.table("playlist").select("*").match({"user_id" : user_id}).execute().data + return res + except: + raise BAD_REQUEST + +# modify playlist +@router.patch("", description="Modify playlist") +async def modify_playlist( + supabase: Annotated[Client, Depends(get_supabase)], + user_id: Annotated[str, Security(get_id)], + playlist: Playlist, + songs: List[int], +): + + if playlist.user_id != user_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="You are not the owner of this playlist", + ) + try: + supabase.table("playlist").update( + jsonable_encoder(playlist, exclude={"id", "user_id", "created_at"}) + ).eq("id", playlist.id).execute() + + supabase.table("playlist").update({ "song_id": songs }).eq("id", playlist.id).execute() + + return {"detail": "Playlist modified"} + except: + raise BAD_REQUEST + + + + +# get playlist and its songs +@router.get("/{playlist_id}", description="Get playlist and its songs") +async def get_playlist( + supabase: Annotated[Client, Depends(get_supabase)], + user_id: Annotated[str, Security(get_id)], + playlist_id: int, +): + try: + res = ( + supabase.table("playlist") + .select("*") + .eq("id", playlist_id) + .execute() + .dict()["data"] + ) + return res + except: + raise BAD_REQUEST diff --git a/app/routers/song.py b/app/routers/song.py new file mode 100644 index 0000000000000000000000000000000000000000..c5695806230c3eefc71a7fd417ea27b0076903c7 --- /dev/null +++ b/app/routers/song.py @@ -0,0 +1,129 @@ +from fastapi import ( + APIRouter, + Depends, + HTTPException, + UploadFile, + status, + Security, +) +from models.music import Song +from db.supabase_service import get_supabase +from typing import Annotated, List, Tuple +from supabase import Client +from utils.auth import get_id +from fastapi.encoders import jsonable_encoder +from utils.exceptions import BAD_REQUEST + +router = APIRouter(tags=["Song"], prefix="/song") + + +# upload mp3 file to supabase storage +@router.post("", description="Upload music file") +async def upload_music( + supabase: Annotated[Client, Depends(get_supabase)], + id: Annotated[str, Security(get_id)], + music: UploadFile, + name: str | None = None, +): + if music.content_type != "audio/mpeg": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Only mp3 file is accepted", + ) + try: + if not name: + name = music.filename + f = await music.read() + supabase.storage.from_("songs").upload( + path=music.filename, file=f, file_options={"content-type": "audio/mpeg"} + ) + supabase.table("songs").insert( + {"name": name, "name_in_storage": music.filename, "user_id": id} + ).execute() + return {"detail": "Music uploaded"} + except: + raise BAD_REQUEST + + +# get url of music file from supabase storage +@router.get("/link", description="Get url of music file") +async def get_link_to_music( + supabase: Annotated[Client, Depends(get_supabase)], + id: Annotated[str, Security(get_id)], + name_in_storage: str, +): + try: + res = supabase.storage.from_("songs").create_signed_url( + path=name_in_storage, expires_in=3600 + ) + return res + except: + raise BAD_REQUEST + + +# get list of all song from search +@router.get( + "/search", description="Get list of all music", response_model=List[Song] +) +async def get_music( + supabase: Annotated[Client, Depends(get_supabase)], + id: Annotated[str, Security(get_id)], + query: str | None = None, +) -> List[Song]: + try: + if query: + res = ( + supabase.table("songs") + .select("*") + .ilike("name", f"%{query}%") + .execute() + .dict()["data"] + ) + else: + res = supabase.table("songs").select("*").execute().dict()["data"] + return res + except: + raise BAD_REQUEST + + +# delete song +@router.delete("", description="Delete music") +async def delete_music( + supabase: Annotated[Client, Depends(get_supabase)], + user_id: Annotated[str, Security(get_id)], + song: Song, +): + try: + if song.user_id != user_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="You are not the owner of this music", + ) + supabase.storage.from_("songs").remove([song.name_in_storage]) + supabase.table("songs").delete().eq("id", song.id).execute() + return {"detail": "Music deleted"} + except: + raise + + +# change music info +@router.patch("/info", description="Change music info") +async def change_music_info( + supabase: Annotated[Client, Depends(get_supabase)], + user_id: Annotated[str, Security(get_id)], + song: Song, +): + try: + if song.user_id != user_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="You are not the owner of this music", + ) + supabase.table("songs").update( + jsonable_encoder( + song, exclude={"id", "user_id", "created_at", "name_in_storage"} + ) + ).eq("id", song.id).execute() + return {"detail": "Music info updated"} + except: + raise BAD_REQUEST diff --git a/app/routers/users.py b/app/routers/users.py new file mode 100644 index 0000000000000000000000000000000000000000..c23f9c670c448d330fac3647cf22cbde0c802560 --- /dev/null +++ b/app/routers/users.py @@ -0,0 +1,134 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Security, Body, Query +from db.supabase_service import get_supabase +from typing import Annotated +from supabase import Client +from utils.auth import get_role, get_id, oauth2_scheme +from models.enums import Role +from models.user import UserInfo +from models.group import GroupInfo +from fastapi.encoders import jsonable_encoder +from crud.user import get_user, update_user +from utils.exceptions import BAD_REQUEST + +router = APIRouter(tags=["User"], prefix="/user") + + +@router.get("/info", description="Get information of logged in user") +async def get_user_info( + token: Annotated[str, Depends(oauth2_scheme)], + supabase: Annotated[Client, Depends(get_supabase)], +): + return await get_user(supabase, token) + + +@router.post( + "/signup_group_admin", + description="Sign up to be a group admin", + status_code=status.HTTP_201_CREATED, +) +async def signup_as_group_admin( + supabase: Annotated[Client, Depends(get_supabase)], + role: Annotated[str, Security(get_role)], + id: Annotated[str, Security(get_id)], + group_info: GroupInfo, +): + if role != Role.user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="You are already a group admin or an admin", + ) + try: + supabase.auth.update_user({"data": {"role": Role.group_admin}}) + supabase.table("groups").insert( + { + "name": group_info.name, + "description": group_info.description, + "admin_id": id, + } + ).execute() + return {"detail": "You are now a group admin"} + except: + raise BAD_REQUEST + + +@router.put("/password", description="Change password of logged in user") +async def change_password( + supabase: Annotated[Client, Depends(get_supabase)], + id: Annotated[str, Security(get_id)], + new_password: Annotated[ + str, Query(min_length=6, description="New password", title="New password") + ], +): + try: + await update_user(supabase, password=new_password) + return {"detail": "Password updated"} + except: + raise + + +@router.patch("/info", description="Update user info") +async def update_user_info( + supabase: Annotated[Client, Depends(get_supabase)], + id: Annotated[str, Security(get_id)], + new_user_info: Annotated[UserInfo, Body()], +): + try: + if new_user_info.role: + del new_user_info.role + await update_user(supabase, data=jsonable_encoder(new_user_info)) + return {"detail": "User info updated"} + except: + raise BAD_REQUEST + + +# buy ticket +@router.post("/buy_ticket", description="Buy ticket") +async def buy_ticket( + supabase: Annotated[Client, Depends(get_supabase)], + user_id: Annotated[str, Security(get_id)], + concert_id: int, +): + try: + number_of_tickets = ( + supabase.table("concerts") + .select("number_of_tickets") + .eq("id", concert_id) + .execute() + .dict()["data"][0]["number_of_tickets"] + ) + if number_of_tickets == 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="There are no tickets left", + ) + return {"detail": "Please navigate to the payment page"} + except: + raise BAD_REQUEST + + +# Confirm payment +@router.post("/confirm_payment", description="Confirm payment") +async def confirm_payment( + supabase: Annotated[Client, Depends(get_supabase)], + user_id: Annotated[str, Security(get_id)], + concert_id: int, +): + try: + number_of_tickets = ( + supabase.table("concerts") + .select("number_of_tickets") + .eq("id", concert_id) + .execute() + .dict()["data"][0]["number_of_tickets"] + ) + if number_of_tickets == 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="There are no tickets left", + ) + supabase.table("concerts").update( + {"number_of_tickets": number_of_tickets - 1} + ).eq("id", concert_id).execute() + return {"detail": "Payment successful"} + except: + raise BAD_REQUEST diff --git a/app/tests/__init__.py b/app/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..82789f20754bd0ade8b65479724d6087f3c4c1f3 --- /dev/null +++ b/app/tests/__init__.py @@ -0,0 +1 @@ +# This file is intentionally left blank. \ No newline at end of file diff --git a/app/tests/__pycache__/__init__.cpython-312.pyc b/app/tests/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1c19ea3cce48b44a4ab86d562fa9800ae8f73602 Binary files /dev/null and b/app/tests/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/tests/__pycache__/test_auth.cpython-312-pytest-7.4.3.pyc b/app/tests/__pycache__/test_auth.cpython-312-pytest-7.4.3.pyc new file mode 100644 index 0000000000000000000000000000000000000000..28d900a411dd7614a61b001887f2ca1f7d69e88b Binary files /dev/null and b/app/tests/__pycache__/test_auth.cpython-312-pytest-7.4.3.pyc differ diff --git a/app/tests/test_auth.py b/app/tests/test_auth.py new file mode 100644 index 0000000000000000000000000000000000000000..757ec8e9df0b18bd32e15613130e6a96a42e5d91 --- /dev/null +++ b/app/tests/test_auth.py @@ -0,0 +1,2 @@ +import pytest +from fastapi.testclient import TestClient \ No newline at end of file diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/utils/__pycache__/__init__.cpython-312.pyc b/app/utils/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..531428c8626ecb288512d93f61353d4dd3f9774c Binary files /dev/null and b/app/utils/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/utils/__pycache__/auth.cpython-312.pyc b/app/utils/__pycache__/auth.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f93448ff7936e0e62e37c7edae0459012e11d92b Binary files /dev/null and b/app/utils/__pycache__/auth.cpython-312.pyc differ diff --git a/app/utils/__pycache__/exceptions.cpython-312.pyc b/app/utils/__pycache__/exceptions.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7b8d616136168c67f76e9bea5c1a8494395a02fd Binary files /dev/null and b/app/utils/__pycache__/exceptions.cpython-312.pyc differ diff --git a/app/utils/auth.py b/app/utils/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..209f0351059decbae5c0de2a13c42d34fa53bff0 --- /dev/null +++ b/app/utils/auth.py @@ -0,0 +1,45 @@ +from fastapi import Depends +from fastapi.security import OAuth2PasswordBearer +from typing import Annotated +from db.supabase_service import get_supabase +from supabase import Client +from utils.exceptions import BAD_REQUEST, UNAUTHORIZED + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login") + + +def get_user_response(token: str, supabase: Client): + try: + return supabase.auth.get_user(token) + except: + raise BAD_REQUEST + + +async def get_id( + token: Annotated[str, Depends(oauth2_scheme)], + supabase: Annotated[Client, Depends(get_supabase)], +): + try: + res = get_user_response(token, supabase) + if res is None: + raise UNAUTHORIZED + email: str = res.user.id + return email + except: + raise UNAUTHORIZED + + +async def get_role( + token: Annotated[str, Depends(oauth2_scheme)], + supabase: Annotated[Client, Depends(get_supabase)], +): + try: + res = get_user_response(token, supabase) + if res is None: + raise UNAUTHORIZED + role: str = res.user.user_metadata.get("role", None) + if not role: + raise UNAUTHORIZED + return role + except: + raise UNAUTHORIZED diff --git a/app/utils/exceptions.py b/app/utils/exceptions.py new file mode 100644 index 0000000000000000000000000000000000000000..102a06dc9c9b9f11163e135b405494f7eaa49972 --- /dev/null +++ b/app/utils/exceptions.py @@ -0,0 +1,27 @@ +from fastapi import HTTPException, status + +UNAUTHORIZED = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, +) + +BAD_REQUEST = HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Something went wrong", +) + +NOT_FOUND = HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Email not found", +) + +CONFLICT = HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="User already exists", +) + +FORBIDDEN = HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have permission to access this resource", +)