diff --git a/backend/.env.example b/backend/.env.example index 3a45a8b..279d84c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,21 +1,41 @@ # App Settings -app_name= -app_env= -app_version= - +APP_NAME=musicstreamer +APP_ENV=development +APP_VERSION=1.0.0 # Database Settings -postgres_db= -postgres_user= -postgres_password= -postgres_server= -postgres_port= +POSTGRES_DB=music_stream_secure +POSTGRES_USER=music_admin +POSTGRES_PASSWORD=your_secure_password_here +POSTGRES_SERVER=localhost +POSTGRES_PORT=5432 # JWT Authentication -jwt_secret_key= -jwt_algorithm= -access_token_expire_minutes= -refresh_token_expire_minutes= +JWT_SECRET_KEY=your_jwt_secret_key_here +JWT_ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=60 +REFRESH_TOKEN_EXPIRE_MINUTES=43200 # Password Security -password_pepper= +PASSWORD_PEPPER=password_pepper_here + +# Test User Credentials +TEST_ADMIN_USERNAME=test_admin +TEST_ADMIN_EMAIL=admin@test.com +TEST_ADMIN_PASSWORD=AdminPass123! +TEST_ADMIN_FIRST_NAME=Test +TEST_ADMIN_LAST_NAME=Admin + +TEST_MUSICIAN_USERNAME=test_musician +TEST_MUSICIAN_EMAIL=musician@test.com +TEST_MUSICIAN_PASSWORD=MusicianPass123! +TEST_MUSICIAN_FIRST_NAME=Test +TEST_MUSICIAN_LAST_NAME=Musician +TEST_MUSICIAN_STAGE_NAME=Test Musician +TEST_MUSICIAN_BIO=A test musician for development + +TEST_LISTENER_USERNAME=test_listener +TEST_LISTENER_EMAIL=listener@test.com +TEST_LISTENER_PASSWORD=ListenerPass123! +TEST_LISTENER_FIRST_NAME=Test +TEST_LISTENER_LAST_NAME=Listener diff --git a/backend/app/api/v1/artist.py b/backend/app/api/v1/artist.py index e118b3b..b9f5c33 100644 --- a/backend/app/api/v1/artist.py +++ b/backend/app/api/v1/artist.py @@ -17,7 +17,7 @@ disable_artist, enable_artist, delete_artist, artist_exists, get_artist_with_related_entities, get_artists_followed_by_user ) -from app.api.v1.deps import ( +from app.core.deps import ( get_current_active_user, get_current_admin, get_current_musician ) diff --git a/backend/app/api/v1/artist_band_member.py b/backend/app/api/v1/artist_band_member.py index 65041c7..d15fb98 100644 --- a/backend/app/api/v1/artist_band_member.py +++ b/backend/app/api/v1/artist_band_member.py @@ -2,7 +2,7 @@ from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session -from app.api.v1.deps import get_db, get_current_musician, get_current_admin +from app.core.deps import get_db, get_current_musician, get_current_admin from app.crud.artist_band_member import ( create_artist_band_member, get_artist_band_member_by_id, diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index 93f4173..81904b8 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -6,7 +6,7 @@ from app.schemas.user import UserLogin, UserOut from app.schemas.token import TokenResponse, TokenRefresh from app.services.auth import AuthService -from app.api.v1.deps import get_current_active_user, get_current_admin, get_auth_service +from app.core.deps import get_current_active_user, get_current_admin, get_auth_service router = APIRouter() @@ -119,6 +119,9 @@ async def get_current_user_info( """ return current_user +''' + +TODO: use cron job -- refer to issues for assistance @router.post("/cleanup-expired") async def cleanup_expired_tokens( @@ -136,3 +139,4 @@ async def cleanup_expired_tokens( "message": f"Cleaned up {cleaned_count} expired tokens", "tokens_removed": cleaned_count } +''' diff --git a/backend/app/api/v1/band.py b/backend/app/api/v1/band.py index fdb3f6f..0ae4e5d 100644 --- a/backend/app/api/v1/band.py +++ b/backend/app/api/v1/band.py @@ -9,15 +9,17 @@ BandCreate, BandOut, BandUpdate, BandStats, BandWithRelations ) from app.crud.band import ( - create_band, get_band_by_id, get_band_by_name, get_all_bands, + create_band, get_band_by_id, get_all_bands, get_active_bands, search_bands_by_name, update_band, disable_band, enable_band, delete_band_permanently, get_band_statistics ) -from app.api.v1.deps import ( +from app.core.deps import ( get_current_active_user, get_current_admin, get_current_musician ) router = APIRouter() +# TODO: add slug later on; +# resource: https://stackoverflow.com/questions/10018100/identify-item-by-either-an-id-or-a-slug-in-a-restful-api """ AUTHENTICATION LEVELS: - None: Public endpoint, no authentication required @@ -61,7 +63,7 @@ async def get_bands_public( db: Session = Depends(get_db), skip: int = Query(0, ge=0, description="Number of records to skip"), limit: int = Query(20, ge=1, le=100, description="Maximum number of records to return"), - search: Optional[str] = Query(None, min_length=1, description="Search bands by name"), + name: Optional[str] = Query(None, min_length=1, description="Filter bands by name (case-insensitive, partial)"), active_only: bool = Query(True, description="Return only active bands") ): """ @@ -70,13 +72,13 @@ async def get_bands_public( Query Parameters: - skip: Number of records to skip (pagination) - limit: Maximum number of records to return (pagination) - - search: Search bands by name + - name: Filter bands by name (case-insensitive, partial) - active_only: Return only active bands (default: True) Returns: 200 OK - List of bands """ - if search: - bands = search_bands_by_name(db, search, skip=skip, limit=limit) + if name: + bands = search_bands_by_name(db, name, skip=skip, limit=limit) elif active_only: bands = get_active_bands(db, skip=skip, limit=limit) else: @@ -105,28 +107,8 @@ async def get_band_public( ) return band - - -@router.get("/name/{name}", response_model=BandOut) -async def get_band_by_name_public( - name: str, - db: Session = Depends(get_db) -): - """ - Get public band profile by name. - Returns basic band information for public viewing. - Only active bands are returned. - Returns: 200 OK - Band profile found - Returns: 404 Not Found - Band not found or inactive - """ - band = get_band_by_name(db, name) - if not band or band.is_disabled: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Band not found" - ) - - return band +# removed {name} function as it can be fetched alrdy via query param in get_bands_public + @router.get("/me/bands", response_model=List[BandOut]) diff --git a/backend/app/api/v1/genre.py b/backend/app/api/v1/genre.py new file mode 100644 index 0000000..c3d09ec --- /dev/null +++ b/backend/app/api/v1/genre.py @@ -0,0 +1,158 @@ +from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.orm import Session +from app.db.session import get_db +from app.core.deps import get_current_admin, get_current_user_optional +from app.schemas.genre import GenreCreate, GenreUpdate, GenreOut, GenreStats +from app.crud.genre import ( + create_genre, genre_exists, get_genre_by_id, get_genre_by_name, get_all_genres, + get_all_active_genres, get_genres_by_fuzzy_name, get_genres_by_partial_name, update_genre, disable_genre, enable_genre, + genre_name_taken, get_genre_statistics, + get_genre_by_name_any, get_genres_by_partial_name_any, get_genres_by_fuzzy_name_any +) + +router = APIRouter() +# TODO: add slug later on; +# https://stackoverflow.com/questions/10018100/identify-item-by-either-an-id-or-a-slug-in-a-restful-api + +@router.get("/", response_model=List[GenreOut]) +async def list_genres( + name: Optional[str] = Query(None, description="Exact genre name to filter"), + q: Optional[str] = Query(None, description="Partial/fuzzy name search"), + db: Session = Depends(get_db), + current_user = Depends(get_current_user_optional) +): + """List genres with rbac: admins see all; others see active only.""" + is_admin = bool(current_user and getattr(current_user, "role", None) == "admin") + + if name: + genre = get_genre_by_name_any(db, name) if is_admin else get_genre_by_name(db, name) + if not genre: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Genre not found") + return [genre] + if q: + if is_admin: + partial = get_genres_by_partial_name_any(db, q) + if partial: + return partial + return get_genres_by_fuzzy_name_any(db, q) + else: + partial = get_genres_by_partial_name(db, q) + if partial: + return partial + return get_genres_by_fuzzy_name(db, q) + return get_all_genres(db) if is_admin else get_all_active_genres(db) + + +@router.get("/{genre_id}", response_model=GenreOut) +async def get_genre(genre_id: int, db: Session = Depends(get_db)): + """Get a specific genre by ID - public access""" + genre = get_genre_by_id(db, genre_id) + if not genre: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Genre not found" + ) + return genre + + +@router.post("/", response_model=GenreOut, status_code=status.HTTP_201_CREATED) +async def create_new_genre( + genre_data: GenreCreate, + db: Session = Depends(get_db), + current_admin: dict = Depends(get_current_admin) +): + """Create a new genre - admin only""" + created = create_genre(db, genre_data) + if created is None: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Genre name already exists") + return created + + +@router.put("/{genre_id}", response_model=GenreOut) +async def update_genre_endpoint( + genre_id: int, + genre_data: GenreUpdate, + db: Session = Depends(get_db), + current_admin: dict = Depends(get_current_admin) +): + """Update a genre - admin only""" + if genre_data.name and genre_name_taken(db, genre_data.name, exclude_genre_id=genre_id): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Genre name already exists" + ) + + updated_genre = update_genre(db, genre_id, genre_data) + if not updated_genre: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Genre not found" + ) + + return updated_genre + + +@router.patch("/{genre_id}", response_model=GenreOut) +async def partial_update_genre( + genre_id: int, + genre_data: GenreUpdate, + db: Session = Depends(get_db), + current_admin: dict = Depends(get_current_admin) +): + """Partially update a genre - admin only""" + if genre_data.name and genre_name_taken(db, genre_data.name, exclude_genre_id=genre_id): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Genre name already exists" + ) + + updated_genre = update_genre(db, genre_id, genre_data) + if not updated_genre: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Genre not found" + ) + + return updated_genre + + +@router.post("/{genre_id}/disable") +async def disable_genre_endpoint( + genre_id: int, + db: Session = Depends(get_db), + current_admin: dict = Depends(get_current_admin) +): + """Disable a genre - admin only""" + success = disable_genre(db, genre_id) + if not success: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Genre not found") + return {"message": "Genre disabled successfully"} + + +@router.post("/{genre_id}/enable") +async def enable_genre_endpoint( + genre_id: int, + db: Session = Depends(get_db), + current_admin: dict = Depends(get_current_admin) +): + """Enable a genre - admin only""" + success = enable_genre(db, genre_id) + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Genre not found" + ) + + return {"message": "Genre enabled successfully"} + + +@router.get("/statistics", response_model=GenreStats) +async def get_genre_statistics_endpoint( + db: Session = Depends(get_db), + current_admin: dict = Depends(get_current_admin) +): + """Get genre statistics""" + stats = get_genre_statistics(db) + return GenreStats(**stats) + diff --git a/backend/app/api/v1/song.py b/backend/app/api/v1/song.py new file mode 100644 index 0000000..4079dc3 --- /dev/null +++ b/backend/app/api/v1/song.py @@ -0,0 +1,231 @@ +from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.orm import Session +from app.db.session import get_db +from app.core.deps import get_current_active_user, get_current_admin, get_current_musician +from app.schemas.song import ( + SongUploadByArtist, SongUploadByBand, SongUploadByAdmin, SongUpdate, + SongOut, SongWithRelations, SongStats +) +from app.crud.song import ( + create_song_by_artist, create_song_by_band, create_song_by_admin, + get_song_by_id, get_all_songs_paginated, search_songs, search_songs_fuzzy, + get_songs_by_artist, get_songs_by_band, get_songs_by_genre, song_exists, + update_song_file_path, update_song_metadata, disable_song, enable_song, + can_user_upload_for_band, get_song_statistics +) +from app.crud.user import get_user_by_id +from app.db.models.user import User + +router = APIRouter() + + +@router.get("/", response_model=List[SongOut]) +async def get_all_songs( + skip: int = Query(0, ge=0, description="Number of records to skip"), + limit: int = Query(20, ge=1, le=100, description="Maximum number of records to return"), + q: Optional[str] = Query(None, min_length=1, description="Search by title/artist/band"), + db: Session = Depends(get_db) +): + """List songs: when query param is provided, performs search; otherwise paginated list.""" + if q: + results = search_songs(db, q, skip=skip, limit=limit) + if results: + return results + return search_songs_fuzzy(db, q, skip=skip, limit=limit) + return get_all_songs_paginated(db, skip=skip, limit=limit) + + +@router.get("/{song_id}", response_model=SongOut) +async def get_song(song_id: int, db: Session = Depends(get_db)): + """Get a specific song by ID - public access""" + song = get_song_by_id(db, song_id) + if not song or song.is_disabled: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Song not found" + ) + return song + + +@router.get("/artist/{artist_id}", response_model=List[SongOut]) +async def get_songs_by_artist_endpoint( + artist_id: int, + skip: int = Query(0, ge=0, description="Number of records to skip"), + limit: int = Query(20, ge=1, le=100, description="Maximum number of records to return"), + db: Session = Depends(get_db) +): + """Get songs by artist ID - public access""" + return get_songs_by_artist(db, artist_id, skip=skip, limit=limit) + + +@router.get("/band/{band_id}", response_model=List[SongOut]) +async def get_songs_by_band_endpoint( + band_id: int, + skip: int = Query(0, ge=0, description="Number of records to skip"), + limit: int = Query(20, ge=1, le=100, description="Maximum number of records to return"), + db: Session = Depends(get_db) +): + """Get songs by band ID - public access""" + return get_songs_by_band(db, band_id, skip=skip, limit=limit) + + +@router.get("/genre/{genre_id}", response_model=List[SongOut]) +async def get_songs_by_genre_endpoint( + genre_id: int, + skip: int = Query(0, ge=0, description="Number of records to skip"), + limit: int = Query(20, ge=1, le=100, description="Maximum number of records to return"), + db: Session = Depends(get_db) +): + """Get songs by genre ID - public access""" + return get_songs_by_genre(db, genre_id, skip=skip, limit=limit) + + +@router.post("/artist/upload", response_model=SongOut, status_code=status.HTTP_201_CREATED) +async def upload_song_by_artist( + song_data: SongUploadByArtist, + current_user: User = Depends(get_current_musician), + db: Session = Depends(get_db) +): + """Upload a song as an artist - artist only""" + # Verify the artist_id belongs to the current user + from app.crud.artist import get_artist_by_user_id + artist = get_artist_by_user_id(db, current_user.id) + if not artist or artist.id != song_data.artist_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You can only upload songs for your own artist profile" + ) + + return create_song_by_artist(db, song_data, current_user.id) + + +@router.post("/band/upload", response_model=SongOut, status_code=status.HTTP_201_CREATED) +async def upload_song_by_band( + song_data: SongUploadByBand, + current_user: User = Depends(get_current_musician), + db: Session = Depends(get_db) +): + """Upload a song as a band member - band member only""" + # Check if user can upload for this band + if not can_user_upload_for_band(db, current_user.id, song_data.band_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You can only upload songs for bands you are a member of" + ) + + return create_song_by_band(db, song_data, current_user.id) + + +@router.post("/admin/upload", response_model=SongOut, status_code=status.HTTP_201_CREATED) +async def upload_song_by_admin( + song_data: SongUploadByAdmin, + current_admin: User = Depends(get_current_admin), + db: Session = Depends(get_db) +): + """Upload a song as admin (for any artist/band including dead artists) - admin only""" + return create_song_by_admin(db, song_data, current_admin.id) + + +@router.put("/{song_id}/file-path", response_model=SongOut) +async def update_song_file_path_endpoint( + song_id: int, + file_path: str, + current_admin: User = Depends(get_current_admin), + db: Session = Depends(get_db) +): + """Update song file path - admin only""" + if not song_exists(db, song_id): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Song not found" + ) + + updated_song = update_song_file_path(db, song_id, file_path) + if not updated_song: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Song not found" + ) + + return updated_song + + +@router.patch("/{song_id}/metadata", response_model=SongOut) +async def update_song_metadata_endpoint( + song_id: int, + song_data: SongUpdate, + current_admin: User = Depends(get_current_admin), + db: Session = Depends(get_db) +): + """Update song metadata - admin only""" + if not song_exists(db, song_id): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Song not found" + ) + + updated_song = update_song_metadata(db, song_id, song_data) + if not updated_song: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Song not found" + ) + + return updated_song + + +@router.post("/{song_id}/disable") +async def disable_song_endpoint( + song_id: int, + current_admin: User = Depends(get_current_admin), + db: Session = Depends(get_db) +): + """Disable a song - admin only""" + if not song_exists(db, song_id): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Song not found" + ) + + success = disable_song(db, song_id) + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Song not found" + ) + + return {"message": "Song disabled successfully"} + + +@router.post("/{song_id}/enable") +async def enable_song_endpoint( + song_id: int, + current_admin: User = Depends(get_current_admin), + db: Session = Depends(get_db) +): + """Enable a song - admin only""" + if not song_exists(db, song_id): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Song not found" + ) + + success = enable_song(db, song_id) + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Song not found" + ) + + return {"message": "Song enabled successfully"} + + +@router.get("/admin/statistics", response_model=SongStats) +async def get_song_statistics_endpoint( + current_admin: User = Depends(get_current_admin), + db: Session = Depends(get_db) +): + """Get song statistics - admin only""" + stats = get_song_statistics(db) + return SongStats(**stats) diff --git a/backend/app/api/v1/user.py b/backend/app/api/v1/user.py index e2f8bc4..a16bb81 100644 --- a/backend/app/api/v1/user.py +++ b/backend/app/api/v1/user.py @@ -18,7 +18,7 @@ bulk_update_user_status, get_user_count_by_role, get_active_user_count, ) -from app.api.v1.deps import ( +from app.core.deps import ( get_current_active_user, get_current_admin ) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index e8776df..2789401 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -29,6 +29,27 @@ class Settings(BaseSettings): # Password pepper PASSWORD_PEPPER: str + # Test User Credentials (optional, only for development) + TEST_ADMIN_USERNAME: Optional[str] = None + TEST_ADMIN_EMAIL: Optional[str] = None + TEST_ADMIN_PASSWORD: Optional[str] = None + TEST_ADMIN_FIRST_NAME: Optional[str] = None + TEST_ADMIN_LAST_NAME: Optional[str] = None + + TEST_MUSICIAN_USERNAME: Optional[str] = None + TEST_MUSICIAN_EMAIL: Optional[str] = None + TEST_MUSICIAN_PASSWORD: Optional[str] = None + TEST_MUSICIAN_FIRST_NAME: Optional[str] = None + TEST_MUSICIAN_LAST_NAME: Optional[str] = None + TEST_MUSICIAN_STAGE_NAME: Optional[str] = None + TEST_MUSICIAN_BIO: Optional[str] = None + + TEST_LISTENER_USERNAME: Optional[str] = None + TEST_LISTENER_EMAIL: Optional[str] = None + TEST_LISTENER_PASSWORD: Optional[str] = None + TEST_LISTENER_FIRST_NAME: Optional[str] = None + TEST_LISTENER_LAST_NAME: Optional[str] = None + @property def DATABASE_URL(self) -> PostgresDsn: return ( diff --git a/backend/app/api/v1/deps.py b/backend/app/core/deps.py similarity index 100% rename from backend/app/api/v1/deps.py rename to backend/app/core/deps.py diff --git a/backend/app/crud/genre.py b/backend/app/crud/genre.py index 896f102..7561e00 100644 --- a/backend/app/crud/genre.py +++ b/backend/app/crud/genre.py @@ -1,27 +1,195 @@ -# TODO: GENRE CRUD IMPLEMENTATION - -# CREATE -# [ ] create_genre(genre_data: GenreCreate) -> Genre -# - Ensures unique genre name before insert -# - Set created_at, is_active=True by default - -# READ / GET -# [ ] get_genre_by_id(genre_id: int) -> Optional[Genre] -# [ ] get_genre_by_name(name: str) -> Optional[Genre] -# [ ] get_all_genres() -> List[Genre] # no pagination needed, small list -# [ ] get_all_active_genres() -> List[Genre] - -# UPDATE -# [ ] update_genre(genre_id: int, data: GenreUpdate) -> Optional[Genre] -# - Allows updating name and description -# - Check uniqueness of name on update - -# DEACTIVATION -# [ ] disable_genre(genre_id: int) -> bool -# - Set is_active=False, disabled_at=datetime.utcnow() -# [ ] enable_genre(genre_id: int) -> bool -# - Set is_active=True, disabled_at=None - -# HELPERS -# [ ] genre_exists(genre_id: int) -> bool -# [ ] genre_name_taken(name: str, exclude_genre_id: Optional[int] = None) -> bool +from typing import List, Optional, Tuple +from sqlalchemy.orm import Session +from sqlalchemy import func +import difflib +from sqlalchemy.exc import IntegrityError +from datetime import datetime, timezone +from app.db.models.genre import Genre +from app.schemas.genre import GenreCreate, GenreUpdate + + +def create_genre(db: Session, genre_data: GenreCreate) -> Optional[Genre]: + """Create a new genre in the database""" + db_genre = Genre( + name=genre_data.name, + description=genre_data.description, + is_active=True + ) + try: + db.add(db_genre) + db.commit() + db.refresh(db_genre) + return db_genre + except IntegrityError: # this does the same job as checking if name exists in raw sql + db.rollback() + return None + + +def get_genre_by_id(db: Session, genre_id: int) -> Optional[Genre]: + """Get a genre by its ID""" + return db.query(Genre).filter(Genre.id == genre_id).first() + + +def get_genre_by_name(db: Session, name: str) -> Optional[Genre]: + """Get an active genre by its name case-insensitive exact match""" + return ( + db.query(Genre) + .filter(func.lower(Genre.name) == name.lower(), Genre.is_active == True) + .first() + ) + + +def get_genre_by_name_any(db: Session, name: str) -> Optional[Genre]: + """Get a genre by its name (case-insensitive), regardless of active status.""" + return ( + db.query(Genre) + .filter(func.lower(Genre.name) == name.lower()) + .first() + ) + + +def get_all_genres(db: Session) -> List[Genre]: + """Get all genres (active and inactive)""" + return db.query(Genre).all() + + +def get_all_active_genres(db: Session) -> List[Genre]: + """Get all active genres only""" + return db.query(Genre).filter(Genre.is_active == True).all() + + +def get_genres_by_partial_name(db: Session, query_text: str) -> List[Genre]: + """Get active genres whose names partially match the query """ + like_pattern = f"%{query_text}%" + return ( + db.query(Genre) + .filter(Genre.is_active == True) + .filter(Genre.name.ilike(like_pattern)) + .all() + ) + + +def get_genres_by_partial_name_any(db: Session, query_text: str) -> List[Genre]: + """Get genres whose names partially match the query (any status).""" + like_pattern = f"%{query_text}%" + return ( + db.query(Genre) + .filter(Genre.name.ilike(like_pattern)) + .all() + ) + + +def get_genres_by_fuzzy_name( + db: Session,query_text: str, + max_results: int = 10, min_ratio: float = 0.6, +) -> List[Genre]: + """Fuzzy search, time consuming + """ + active_genres: List[Genre] = get_all_active_genres(db) + scored: List[Tuple[float, Genre]] = [] + for genre in active_genres: + ratio = difflib.SequenceMatcher(None, query_text.lower(), genre.name.lower()).ratio() + if ratio >= min_ratio: + scored.append((ratio, genre)) + + scored.sort(key=lambda x: x[0], reverse=True) + return [g for _, g in scored[:max_results]] + + +def get_genres_by_fuzzy_name_any( + db: Session, query_text: str, + max_results: int = 10, min_ratio: float = 0.6, +) -> List[Genre]: + """Fuzzy search across all genres (any status).""" + all_genres: List[Genre] = get_all_genres(db) + scored: List[Tuple[float, Genre]] = [] + for genre in all_genres: + ratio = difflib.SequenceMatcher(None, query_text.lower(), genre.name.lower()).ratio() + if ratio >= min_ratio: + scored.append((ratio, genre)) + + scored.sort(key=lambda x: x[0], reverse=True) + return [g for _, g in scored[:max_results]] + + +def update_genre(db: Session, genre_id: int, genre_data: GenreUpdate) -> Optional[Genre]: + """Update a genre with new data""" + db_genre = get_genre_by_id(db, genre_id) + if not db_genre: + return None + + update_data = genre_data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(db_genre, field, value) + + db.commit() + db.refresh(db_genre) + return db_genre + + +def disable_genre(db: Session, genre_id: int) -> bool: + """Disable a genre by setting is_active to False""" + db_genre = get_genre_by_id(db, genre_id) + if not db_genre: + return False + + db_genre.is_active = False + db_genre.disabled_at = datetime.now(timezone.utc) + db.commit() + return True + + +def enable_genre(db: Session, genre_id: int) -> bool: + """Enable a genre by setting is_active to True""" + db_genre = get_genre_by_id(db, genre_id) + if not db_genre: + return False + + db_genre.is_active = True + db_genre.disabled_at = None + db.commit() + return True + + +def genre_exists(db: Session, genre_id: int) -> bool: + """Check if a genre exists by ID""" + return db.query(Genre).filter(Genre.id == genre_id).first() is not None + + +def genre_name_taken(db: Session, name: str, exclude_genre_id: Optional[int] = None) -> bool: + """Check if a genre name is already taken (case-insensitive)""" + query = db.query(Genre).filter(func.lower(Genre.name) == name.lower()) + if exclude_genre_id: + query = query.filter(Genre.id != exclude_genre_id) + return query.first() is not None + + +def get_genre_statistics(db: Session) -> dict: + """Get comprehensive statistics about genres""" + total_genres = db.query(Genre).count() + active_genres = db.query(Genre).filter(Genre.is_active == True).count() + inactive_genres = total_genres - active_genres + + genres_with_songs = db.query(Genre).join(Genre.songs).distinct().count() + + genre_usage = db.query( + Genre.name, + func.count(Genre.songs).label('song_count') + ).outerjoin(Genre.songs).group_by(Genre.name).all() + + most_used = None + least_used = None + + if genre_usage: + sorted_usage = sorted(genre_usage, key=lambda x: x.song_count, reverse=True) + most_used = sorted_usage[0].name if sorted_usage[0].song_count > 0 else None + least_used = sorted_usage[-1].name if sorted_usage[-1].song_count > 0 else None + + return { + "total_genres": total_genres, + "active_genres": active_genres, + "inactive_genres": inactive_genres, + "genres_with_songs": genres_with_songs, + "most_used_genre": most_used, + "least_used_genre": least_used + } diff --git a/backend/app/crud/song.py b/backend/app/crud/song.py index 4a0f0dd..e2c6baa 100644 --- a/backend/app/crud/song.py +++ b/backend/app/crud/song.py @@ -1,37 +1,285 @@ -# TODO: SONG CRUD IMPLEMENTATION - -# CREATE -# [ ] create_song(song_data: SongCreate, uploaded_by_user_id: int) -> Song -# - Validate genre, artist/band IDs/User ID (admin) exist -# - Check user permissions (e.g., only verified users can upload?) -# - Enforce required logic: -# - Either artist_id or band_id or admins user ID should be set -# - artist_name and band_name should be fetched from related tables. if admin is uploading either artist_name or band_name should be set -# - Auto-fill `uploaded_at` as current UTC time - - -# READ -# [ ] get_song_by_id(song_id: int) -> Optional[Song] -# - Include relationships (genre, artist, band) -# -# [ ] get_all_songs_paginated(skip: int = 0, limit: int = 20) -> List[Song] -# -# [ ] search_songs_by_title(title: str, skip: int = 0, limit: int = 20) -> List[Song] -# - `ilike` for case-insensitive partial search -# -# [ ] get_songs_by_artist(artist_id: int, skip: int = 0, limit: int = 20) -> List[Song] -# [ ] get_songs_by_band(band_id: int, skip: int = 0, limit: int = 20) -> List[Song] -# [ ] get_songs_by_genre(genre_id: int, skip: int = 0, limit: int = 20) -> List[Song] - - -# UPDATE -# song update not allowed, only admin can update song file_path -# [ ] update_song_file_path(song_id: int, new_file_path: str, by_user_id: int) -> Song -# - Only admin can update file_path -# - Check if song exists -# - Update `file_path` and `updated_at` timestamp - - -# HARD DELETE -# [ ] delete_song_permanently(song_id: int) -> bool +from typing import List, Optional, Dict, Any, Tuple +from sqlalchemy.orm import Session +from sqlalchemy import func +from datetime import datetime, timezone +from app.db.models.song import Song +from app.db.models.artist import Artist +from app.db.models.band import Band +from app.db.models.genre import Genre +from app.db.models.user import User +from app.schemas.song import SongUploadByArtist, SongUploadByBand, SongUploadByAdmin, SongUpdate +import difflib + + +def create_song_by_artist(db: Session, song_data: SongUploadByArtist, uploaded_by_user_id: int) -> Song: + """Create a song uploaded by an artist""" + # Auto-fill artist_name from artist_id + artist = db.query(Artist).filter(Artist.id == song_data.artist_id).first() + if not artist: + raise ValueError("Artist not found") + + db_song = Song( + title=song_data.title, + genre_id=song_data.genre_id, + artist_id=song_data.artist_id, + band_id=None, + release_date=song_data.release_date, + song_duration=song_data.song_duration, + file_path=song_data.file_path, + cover_image=song_data.cover_image, + artist_name=artist.artist_stage_name, + band_name=None, + uploaded_by_user_id=uploaded_by_user_id + ) + + db.add(db_song) + db.commit() + db.refresh(db_song) + return db_song + + +def create_song_by_band(db: Session, song_data: SongUploadByBand, uploaded_by_user_id: int) -> Song: + """Create a song uploaded by a band member""" + # Auto-fill band_name from band_id + band = db.query(Band).filter(Band.id == song_data.band_id).first() + if not band: + raise ValueError("Band not found") + + db_song = Song( + title=song_data.title, + genre_id=song_data.genre_id, + artist_id=None, + band_id=song_data.band_id, + release_date=song_data.release_date, + song_duration=song_data.song_duration, + file_path=song_data.file_path, + cover_image=song_data.cover_image, + artist_name=None, + band_name=band.name, + uploaded_by_user_id=uploaded_by_user_id + ) + + db.add(db_song) + db.commit() + db.refresh(db_song) + return db_song + + +def create_song_by_admin(db: Session, song_data: SongUploadByAdmin, uploaded_by_user_id: int) -> Song: + """Create a song uploaded by admin (for any artist/band including dead artists)""" + db_song = Song( + title=song_data.title, + genre_id=song_data.genre_id, + artist_id=song_data.artist_id, + band_id=song_data.band_id, + release_date=song_data.release_date, + song_duration=song_data.song_duration, + file_path=song_data.file_path, + cover_image=song_data.cover_image, + artist_name=song_data.artist_name, + band_name=song_data.band_name, + uploaded_by_user_id=uploaded_by_user_id + ) + + if song_data.artist_id: + artist = db.query(Artist).filter(Artist.id == song_data.artist_id).first() + if artist: + db_song.artist_name = artist.artist_stage_name + + if song_data.band_id: + band = db.query(Band).filter(Band.id == song_data.band_id).first() + if band: + db_song.band_name = band.name + + db.add(db_song) + db.commit() + db.refresh(db_song) + return db_song + + +def get_song_by_id(db: Session, song_id: int) -> Optional[Song]: + """Get a song by its ID""" + return db.query(Song).filter(Song.id == song_id).first() + + +def get_all_songs_paginated(db: Session, skip: int = 0, limit: int = 20) -> List[Song]: + """Get all songs with pagination""" + return db.query(Song).filter(Song.is_disabled == False).offset(skip).limit(limit).all() + + +def search_songs(db: Session, query: str, skip: int = 0, limit: int = 20) -> List[Song]: + """Search songs by title, artist name, or band name""" + return db.query(Song).filter( + Song.is_disabled == False, + ( + Song.title.ilike(f"%{query}%") | + Song.artist_name.ilike(f"%{query}%") | + Song.band_name.ilike(f"%{query}%") + ) + ).offset(skip).limit(limit).all() + + +def search_songs_fuzzy( + db: Session, + query: str, + skip: int = 0, + limit: int = 20, + min_ratio: float = 0.6, +) -> List[Song]: + """Fuzzy search songs by comparing query with title/artist_name/bandname + """ + active_songs: List[Song] = db.query(Song).filter(Song.is_disabled == False).all() + + scored: List[Tuple[float, Song]] = [] + q = query.lower() + for song in active_songs: + candidates = [ + (song.title or ""), + (song.artist_name or ""), + (song.band_name or ""), + ] + best = 0.0 + for text in candidates: + if not text: + continue + r = difflib.SequenceMatcher(None, q, text.lower()).ratio() + if r > best: + best = r + if best >= min_ratio: + scored.append((best, song)) + + scored.sort(key=lambda x: x[0], reverse=True) + sliced = scored[skip: skip + limit] if limit is not None else scored[skip:] + return [s for _, s in sliced] + + +def get_songs_by_artist(db: Session, artist_id: int, skip: int = 0, limit: int = 20) -> List[Song]: + """Get songs by artist ID""" + return db.query(Song).filter( + Song.artist_id == artist_id, + Song.is_disabled == False + ).offset(skip).limit(limit).all() + + +def get_songs_by_band(db: Session, band_id: int, skip: int = 0, limit: int = 20) -> List[Song]: + """Get songs by band ID""" + return db.query(Song).filter( + Song.band_id == band_id, + Song.is_disabled == False + ).offset(skip).limit(limit).all() + + +def get_songs_by_genre(db: Session, genre_id: int, skip: int = 0, limit: int = 20) -> List[Song]: + """Get songs by genre ID""" + return db.query(Song).filter( + Song.genre_id == genre_id, + Song.is_disabled == False + ).offset(skip).limit(limit).all() + + +def update_song_file_path(db: Session, song_id: int, new_file_path: str) -> Optional[Song]: + """Update song file path (admin only)""" + db_song = get_song_by_id(db, song_id) + if not db_song: + return None + + db_song.file_path = new_file_path + db.commit() + db.refresh(db_song) + return db_song + + +def update_song_metadata(db: Session, song_id: int, song_data: SongUpdate) -> Optional[Song]: + """Update song metadata (admin only)""" + db_song = get_song_by_id(db, song_id) + if not db_song: + return None + + update_data = song_data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(db_song, field, value) + + db.commit() + db.refresh(db_song) + return db_song + + +def disable_song(db: Session, song_id: int) -> bool: + """Disable a song (soft delete)""" + db_song = get_song_by_id(db, song_id) + if not db_song: + return False + + db_song.is_disabled = True + db_song.disabled_at = datetime.now(timezone.utc) + db.commit() + return True + + +def enable_song(db: Session, song_id: int) -> bool: + """Enable a song (re-enable)""" + db_song = get_song_by_id(db, song_id) + if not db_song: + return False + + db_song.is_disabled = False + db_song.disabled_at = None + db.commit() + return True + + +def song_exists(db: Session, song_id: int) -> bool: + """Check if a song exists by ID""" + return db.query(Song).filter(Song.id == song_id).first() is not None + + +def can_user_upload_for_band(db: Session, user_id: int, band_id: int) -> bool: + """Check if user can upload songs for a band (must be band member)""" + from app.crud.artist_band_member import is_current_member + return is_current_member(db, user_id, band_id) + + +def get_song_statistics(db: Session) -> Dict[str, Any]: + """Get comprehensive statistics about songs""" + total_songs = db.query(Song).count() + active_songs = db.query(Song).filter(Song.is_disabled == False).count() + disabled_songs = total_songs - active_songs + + songs_by_artist = db.query(Song).filter( + Song.artist_id.isnot(None), + Song.is_disabled == False + ).count() + + songs_by_band = db.query(Song).filter( + Song.band_id.isnot(None), + Song.is_disabled == False + ).count() + + # Find most uploaded artist + most_uploaded_artist = db.query( + Song.artist_name, + func.count(Song.id).label('song_count') + ).filter( + Song.artist_id.isnot(None), + Song.is_disabled == False + ).group_by(Song.artist_name).order_by(func.count(Song.id).desc()).first() + + # Find most uploaded band + most_uploaded_band = db.query( + Song.band_name, + func.count(Song.id).label('song_count') + ).filter( + Song.band_id.isnot(None), + Song.is_disabled == False + ).group_by(Song.band_name).order_by(func.count(Song.id).desc()).first() + + return { + "total_songs": total_songs, + "active_songs": active_songs, + "disabled_songs": disabled_songs, + "songs_by_artist": songs_by_artist, + "songs_by_band": songs_by_band, + "most_uploaded_artist": most_uploaded_artist.artist_name if most_uploaded_artist else None, + "most_uploaded_band": most_uploaded_band.band_name if most_uploaded_band else None + } diff --git a/backend/app/db/base.py b/backend/app/db/base.py index cd75cf8..e45f802 100644 --- a/backend/app/db/base.py +++ b/backend/app/db/base.py @@ -18,7 +18,7 @@ from app.db.models.playlist import Playlist #15 from app.db.models.song import Song #16 from app.db.models.subscription_plan import SubscriptionPlan #17 -from app.db.models.system_config import SystemConfig #18 +from app.db.models.user import User #18 from app.db.models.user_subscription import UserSubscription #19 -from app.db.models.user import User #20 +from app.db.models.system_config import SystemConfig #20 from app.db.models.refresh_token import RefreshToken #21 diff --git a/backend/app/main.py b/backend/app/main.py index f62c76a..50359e3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -35,51 +35,17 @@ from app.api.v1.artist import router as artist_router from app.api.v1.band import router as band_router from app.api.v1.artist_band_member import router as artist_band_member_router +from app.api.v1.genre import router as genre_router +from app.api.v1.song import router as song_router # Include routers with proper prefixes and tags -app.include_router( - auth_router, prefix="/auth", tags=["authentication"], - responses={401: {"description": "Unauthorized"}} -) - -app.include_router( - user_router, tags=["users"], prefix="/user", - responses={ - 401: {"description": "Unauthorized"}, - 403: {"description": "Forbidden - Insufficient permissions"}, - 404: {"description": "User not found"} - } -) - -app.include_router( - artist_router, tags=["artists"], prefix="/artist", - responses={ - 401: {"description": "Unauthorized"}, - 403: {"description": "Forbidden - Insufficient permissions"}, - 404: {"description": "Artist not found"}, - 409: {"description": "Conflict - Resource already exists"} - } -) - -app.include_router( - band_router, tags=["bands"], prefix="/band", - responses={ - 401: {"description": "Unauthorized"}, - 403: {"description": "Forbidden - Insufficient permissions"}, - 404: {"description": "Band not found"}, - 409: {"description": "Conflict - Resource already exists"} - } -) - -app.include_router( - artist_band_member_router, tags=["artist-band-members"], prefix="/artist-band-member", - responses={ - 401: {"description": "Unauthorized"}, - 403: {"description": "Forbidden - Insufficient permissions"}, - 404: {"description": "Membership not found"}, - 409: {"description": "Conflict - Resource already exists"} - } -) +app.include_router(auth_router, prefix="/auth", tags=["authentication"]) +app.include_router(user_router, tags=["users"], prefix="/user") +app.include_router(artist_router, tags=["artists"], prefix="/artist") +app.include_router(band_router, tags=["bands"], prefix="/band") +app.include_router(artist_band_member_router, tags=["artist-band-members"], prefix="/artist-band-member") +app.include_router(genre_router, tags=["genres"], prefix="/genre") +app.include_router(song_router, tags=["songs"], prefix="/song") # CORS configuration app.add_middleware( @@ -119,6 +85,8 @@ async def root(): "artists": "/artist", "bands": "/band", "artist-band-members": "/artist-band-member", + "genres": "/genre", + "songs": "/song", "health": "/health" } } diff --git a/backend/app/schemas/genre.py b/backend/app/schemas/genre.py index 1309382..ea949b0 100644 --- a/backend/app/schemas/genre.py +++ b/backend/app/schemas/genre.py @@ -1,30 +1,30 @@ from typing import Optional, Annotated from datetime import datetime -from pydantic import BaseModel, StringConstraints, model_validator - +from pydantic import BaseModel, StringConstraints, model_validator class GenreBase(BaseModel): + """Base schema for genre data with common fields""" name: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=255)] description: Optional[str] = None class Config: - from_attributes = True # enables ORM mode with SQLAlchemy models + from_attributes = True class GenreCreate(GenreBase): - pass # no extra fields for creation after base + """Schema for creating a new genre""" + pass class GenreUpdate(BaseModel): + """Schema for updating a genre""" name: Optional[Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=255)]] = None description: Optional[str] = None @model_validator(mode="after") def check_at_least_one_field(self) -> "GenreUpdate": - """ - Ensures that at least one of the optional fields is provided. - """ + """Ensures that at least one of the optional fields is provided""" if self.name is None and self.description is None: raise ValueError("At least one field ('name' or 'description') must be provided.") return self @@ -34,6 +34,7 @@ class Config: class GenreOut(GenreBase): + """Schema for genre output with all fields""" id: int is_active: bool created_at: datetime @@ -43,9 +44,14 @@ class Config: from_attributes = True -class GenreStatus(BaseModel): - is_active: bool - disabled_at: Optional[datetime] = None +class GenreStats(BaseModel): + """Schema for genre statistics""" + total_genres: int + active_genres: int + inactive_genres: int + genres_with_songs: int + most_used_genre: Optional[str] = None + least_used_genre: Optional[str] = None class Config: from_attributes = True diff --git a/backend/app/schemas/song.py b/backend/app/schemas/song.py index e8abe05..038088c 100644 --- a/backend/app/schemas/song.py +++ b/backend/app/schemas/song.py @@ -3,8 +3,8 @@ from pydantic import BaseModel, StringConstraints, Field, model_validator -# Base schema for song class SongBase(BaseModel): + """Base schema for song data with common fields""" title: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=150)] genre_id: int band_id: Optional[int] = None @@ -29,27 +29,44 @@ class Config: from_attributes = True -class SongCreate(SongBase): - uploaded_by_user_id: int +class SongUploadByArtist(SongBase): + """Schema for artist uploading their own song""" + pass + + +class SongUploadByBand(SongBase): + """Schema for band member uploading band song""" + pass + + +class SongUploadByAdmin(SongBase): + """Schema for admin uploading for any artist/band (including dead artists)""" + artist_name: Optional[Annotated[str, StringConstraints(max_length=100)]] = None + band_name: Optional[Annotated[str, StringConstraints(max_length=100)]] = None + + @model_validator(mode="after") + def validate_admin_upload(self) -> "SongUploadByAdmin": + """For admin uploads, either artist_name or band_name must be provided if no IDs""" + if self.artist_id is None and self.band_id is None: + if not self.artist_name and not self.band_name: + raise ValueError("Admin upload must specify either artist_name or band_name when no IDs provided") + return self class SongUpdate(BaseModel): + """Schema for updating song metadata""" title: Optional[Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=150)]] = None genre_id: Optional[int] = None - band_id: Optional[int] = None - artist_id: Optional[int] = None release_date: Optional[datetime] = None song_duration: Optional[Annotated[int, Field(gt=0)]] = None - file_path: Optional[Annotated[str, StringConstraints(strip_whitespace=True, max_length=255)]] = None cover_image: Optional[Annotated[str, StringConstraints(max_length=255)]] = None - artist_name: Optional[Annotated[str, StringConstraints(max_length=100)]] = None - band_name: Optional[Annotated[str, StringConstraints(max_length=100)]] = None class Config: from_attributes = True class SongOut(SongBase): + """Schema for song output with all fields""" id: int uploaded_by_user_id: int created_at: datetime @@ -60,8 +77,19 @@ class Config: from_attributes = True -# Schema for relationships +class SongWithRelations(SongOut): + """Schema for song output with relationships""" + genre: "GenreMinimal" + artist: Optional["ArtistMinimal"] = None + band: Optional["BandMinimal"] = None + uploaded_by: "UserMinimal" + + class Config: + from_attributes = True + + class GenreMinimal(BaseModel): + """Minimal genre schema for relationships""" id: int name: str @@ -70,6 +98,7 @@ class Config: class ArtistMinimal(BaseModel): + """Minimal artist schema for relationships""" id: int artist_stage_name: str artist_profile_image: Optional[str] = None @@ -79,6 +108,7 @@ class Config: class BandMinimal(BaseModel): + """Minimal band schema for relationships""" id: int name: str profile_picture: Optional[str] = None @@ -88,6 +118,7 @@ class Config: class UserMinimal(BaseModel): + """Minimal user schema for relationships""" id: int username: str first_name: str @@ -97,18 +128,15 @@ class Config: from_attributes = True -# song output with relationships -class SongWithRelations(SongOut): - genre: GenreMinimal - artist: Optional[ArtistMinimal] = None - band: Optional[BandMinimal] = None - uploaded_by: UserMinimal - - -# Song status update -class SongStatus(BaseModel): - is_disabled: bool - disabled_at: Optional[datetime] = None +class SongStats(BaseModel): + """Schema for song statistics""" + total_songs: int + active_songs: int + disabled_songs: int + songs_by_artist: int + songs_by_band: int + most_uploaded_artist: Optional[str] = None + most_uploaded_band: Optional[str] = None class Config: from_attributes = True diff --git a/backend/docker/Dockerfile b/backend/docker/Dockerfile index 8806b08..6297539 100644 --- a/backend/docker/Dockerfile +++ b/backend/docker/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.11-slim +FROM python:3.11-slim AS builder WORKDIR /app @@ -8,17 +8,40 @@ RUN apt-get update && apt-get install -y \ curl \ && rm -rf /var/lib/apt/lists/* + RUN pip install poetry + COPY pyproject.toml poetry.lock ./ + RUN poetry config virtualenvs.create false \ - && poetry install --no-dev --no-interaction --no-ansi + && poetry install --no-root --no-interaction --no-ansi + +# stage 2 : runtime + +FROM python:3.11-slim AS runtime + +WORKDIR /app + +RUN apt-get update && apt-get install -y \ + libpq5 \ + curl \ + && rm -rf /var/lib/apt/lists/* \ + && apt-get clean + +COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages +COPY --from=builder /usr/local/bin /usr/local/bin COPY . . +COPY docker/start.sh /app/start.sh RUN chmod +x /app/start.sh +RUN groupadd -r appuser && useradd -r -g appuser appuser +RUN chown -R appuser:appuser /app +USER appuser + EXPOSE 8000 CMD ["bash", "/app/start.sh"] diff --git a/backend/docker/docker-compose.yml b/backend/docker/docker-compose.yml index 55820ff..4efb108 100644 --- a/backend/docker/docker-compose.yml +++ b/backend/docker/docker-compose.yml @@ -1,10 +1,10 @@ -version: '3.8' - services: db: image: postgres:15 container_name: music-db-1 restart: unless-stopped + env_file: + - ../.env environment: POSTGRES_DB: ${POSTGRES_DB} POSTGRES_USER: ${POSTGRES_USER} diff --git a/backend/docker/start.sh b/backend/docker/start.sh index ae03f57..c03a17b 100644 --- a/backend/docker/start.sh +++ b/backend/docker/start.sh @@ -5,12 +5,11 @@ echo "🎵Music Player Backend Starting..." echo "🎵========================================" echo "" -echo "📦 Installing Python dependencies..." -poetry install --no-dev --no-interaction --no-ansi +echo "📦 Dependencies already installed in Docker build..." echo "" echo "Waiting for PostgreSQL database to be ready..." -until poetry run python -c " +until python -c " import psycopg2 import os try: @@ -34,7 +33,7 @@ done echo "" echo "Running Alembic database migrations..." -poetry run alembic upgrade head +alembic upgrade head echo "" echo "Database migrations completed successfully!" @@ -50,4 +49,4 @@ echo "========================================" echo "Music Player Backend is ready!" echo "========================================" -poetry run uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload +uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload diff --git a/backend/seed_users_sql.py b/backend/seed_users_sql.py new file mode 100644 index 0000000..11aa278 --- /dev/null +++ b/backend/seed_users_sql.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +""" +SQL-based User Seeding Script for Music Player API + +This script creates test users (admin, musician, listener) for development and testing. +Users are created with credentials from the .env file using raw SQL to avoid model issues. +""" + +import os +import sys +from pathlib import Path +from datetime import datetime, timezone + +# Add the backend directory to Python path +backend_dir = Path(__file__).parent +sys.path.insert(0, str(backend_dir)) + +from sqlalchemy.orm import Session +from sqlalchemy import text +from app.db.session import get_db +from app.core.config import settings +from app.core.security import hash_password + + +def load_env_vars(): + """Load environment variables from settings with fallbacks""" + # Check if test credentials are configured + if not settings.TEST_ADMIN_USERNAME: + print("Test credentials not found in .env file") + print("Please add test credentials to your .env file:") + print(""" +# Test User Credentials +TEST_ADMIN_USERNAME=test_admin +TEST_ADMIN_EMAIL=admin@test.com +TEST_ADMIN_PASSWORD=AdminPass123! +TEST_ADMIN_FIRST_NAME=Test +TEST_ADMIN_LAST_NAME=Admin + +TEST_MUSICIAN_USERNAME=test_musician +TEST_MUSICIAN_EMAIL=musician@test.com +TEST_MUSICIAN_PASSWORD=MusicianPass123! +TEST_MUSICIAN_FIRST_NAME=Test +TEST_MUSICIAN_LAST_NAME=Musician +TEST_MUSICIAN_STAGE_NAME=Test Musician +TEST_MUSICIAN_BIO=A test musician for development + +TEST_LISTENER_USERNAME=test_listener +TEST_LISTENER_EMAIL=listener@test.com +TEST_LISTENER_PASSWORD=ListenerPass123! +TEST_LISTENER_FIRST_NAME=Test +TEST_LISTENER_LAST_NAME=Listener + """) + return None + + return { + 'admin': { + 'username': settings.TEST_ADMIN_USERNAME, + 'email': settings.TEST_ADMIN_EMAIL, + 'password': settings.TEST_ADMIN_PASSWORD, + 'first_name': settings.TEST_ADMIN_FIRST_NAME, + 'last_name': settings.TEST_ADMIN_LAST_NAME, + 'role': 'admin' + }, + 'musician': { + 'username': settings.TEST_MUSICIAN_USERNAME, + 'email': settings.TEST_MUSICIAN_EMAIL, + 'password': settings.TEST_MUSICIAN_PASSWORD, + 'first_name': settings.TEST_MUSICIAN_FIRST_NAME, + 'last_name': settings.TEST_MUSICIAN_LAST_NAME, + 'role': 'musician', + 'stage_name': settings.TEST_MUSICIAN_STAGE_NAME, + 'bio': settings.TEST_MUSICIAN_BIO + }, + 'listener': { + 'username': settings.TEST_LISTENER_USERNAME, + 'email': settings.TEST_LISTENER_EMAIL, + 'password': settings.TEST_LISTENER_PASSWORD, + 'first_name': settings.TEST_LISTENER_FIRST_NAME, + 'last_name': settings.TEST_LISTENER_LAST_NAME, + 'role': 'listener' + } + } + + +def create_test_user_sql(db: Session, user_data: dict, user_type: str): + """Create a test user using raw SQL""" + # Check if user already exists + result = db.execute( + text("SELECT id, username, role FROM users WHERE username = :username OR email = :email"), + {"username": user_data['username'], "email": user_data['email']} + ).first() + + if result: + # If user exists but has wrong role, update it + if result.role != user_data['role']: + db.execute( + text("UPDATE users SET role = :role WHERE id = :id"), + {"role": user_data['role'], "id": result.id} + ) + db.commit() + print(f"Updated {user_type.capitalize()} user '{user_data['username']}' role from '{result.role}' to '{user_data['role']}' (ID: {result.id})") + else: + print(f"{user_type.capitalize()} user '{user_data['username']}' already exists with correct role (ID: {result.id})") + return result.id + + # Hash password + hashed_password = hash_password(user_data['password']) + now = datetime.now(timezone.utc) + + # Create user using SQL + result = db.execute( + text(""" + INSERT INTO users (username, email, password, first_name, last_name, role, created_at, is_active) + VALUES (:username, :email, :password, :first_name, :last_name, :role, :created_at, :is_active) + RETURNING id + """), + { + "username": user_data['username'], + "email": user_data['email'], + "password": hashed_password, + "first_name": user_data['first_name'], + "last_name": user_data['last_name'], + "role": user_data['role'], + "created_at": now, + "is_active": True + } + ) + + user_id = result.scalar() + db.commit() + print(f"Created {user_type} user: {user_data['username']} (ID: {user_id})") + return user_id + + +def create_test_artist_sql(db: Session, user_id: int, artist_data: dict): + """Create a test artist profile using raw SQL""" + # Check if artist profile already exists + result = db.execute( + text("SELECT id FROM artists WHERE linked_user_account = :user_id"), + {"user_id": user_id} + ).first() + + if result: + print(f"Artist profile for user ID {user_id} already exists (ID: {result.id})") + return result.id + + now = datetime.now(timezone.utc) + + # Create artist using SQL + result = db.execute( + text(""" + INSERT INTO artists (artist_stage_name, artist_bio, artist_profile_image, artist_social_link, + linked_user_account, created_at, is_disabled) + VALUES (:stage_name, :bio, :profile_image, :social_link, :user_id, :created_at, :is_disabled) + RETURNING id + """), + { + "stage_name": artist_data['stage_name'], + "bio": artist_data['bio'], + "profile_image": None, + "social_link": None, + "user_id": user_id, + "created_at": now, + "is_disabled": False + } + ) + + artist_id = result.scalar() + db.commit() + print(f"Created artist profile: {artist_data['stage_name']} (ID: {artist_id})") + return artist_id + + +def main(): + """Main seeding function""" + print("Music Player API - User Seeding Script") + print("=" * 50) + + # Load environment variables + try: + env_vars = load_env_vars() + if env_vars is None: + return + print("Loaded environment variables from settings") + except Exception as e: + print(f"Error loading environment variables: {e}") + return + + # Get database session + db = next(get_db()) + + try: + # Create admin user + print("\nCreating admin user...") + admin_user_id = create_test_user_sql(db, env_vars['admin'], 'admin') + + # Create musician user + print("\nCreating musician user...") + musician_user_id = create_test_user_sql(db, env_vars['musician'], 'musician') + + # Create artist profile for musician + print("\nCreating artist profile...") + create_test_artist_sql(db, musician_user_id, env_vars['musician']) + + # Create listener user + print("\nCreating listener user...") + listener_user_id = create_test_user_sql(db, env_vars['listener'], 'listener') + + print("\n" + "=" * 50) + print("Seeding completed successfully!") + print("\nTest User Credentials:") + print(f"Admin: {env_vars['admin']['username']} / {env_vars['admin']['password']}") + print(f"Musician: {env_vars['musician']['username']} / {env_vars['musician']['password']}") + print(f"Listener: {env_vars['listener']['username']} / {env_vars['listener']['password']}") + print("\nUse these credentials to test the API endpoints!") + + except Exception as e: + print(f"Error during seeding: {e}") + import traceback + traceback.print_exc() + db.rollback() + finally: + db.close() + + +if __name__ == "__main__": + main()