-
-
Notifications
You must be signed in to change notification settings - Fork 601
feat: implement albums feature with create, edit, delete, and image management UI #610
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 5 commits
afcecea
5841ada
67e9937
634140f
3078e9d
6ab8ede
a25088f
262874a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -50,8 +50,9 @@ def db_create_albums_table() -> None: | |
| album_id TEXT PRIMARY KEY, | ||
| album_name TEXT UNIQUE, | ||
| description TEXT, | ||
| is_hidden BOOLEAN DEFAULT 0, | ||
| password_hash TEXT | ||
| is_locked BOOLEAN DEFAULT 0, | ||
| password_hash TEXT, | ||
| cover_image_path TEXT | ||
| ) | ||
| """ | ||
| ) | ||
|
|
@@ -61,6 +62,26 @@ def db_create_albums_table() -> None: | |
| conn.close() | ||
|
|
||
|
|
||
| def db_migrate_add_cover_image_column() -> None: | ||
| """Add cover_image_path column to existing albums table if it doesn't exist""" | ||
| conn = None | ||
| try: | ||
| conn = sqlite3.connect(DATABASE_PATH) | ||
| cursor = conn.cursor() | ||
|
|
||
| # Check if column exists | ||
| cursor.execute("PRAGMA table_info(albums)") | ||
| columns = [column[1] for column in cursor.fetchall()] | ||
|
|
||
| if "cover_image_path" not in columns: | ||
| cursor.execute("ALTER TABLE albums ADD COLUMN cover_image_path TEXT") | ||
| conn.commit() | ||
| print("Added cover_image_path column to albums table") | ||
| finally: | ||
| if conn is not None: | ||
| conn.close() | ||
|
Comment on lines
+65
to
+82
|
||
|
|
||
|
|
||
| def db_create_album_images_table() -> None: | ||
| conn = None | ||
| try: | ||
|
|
@@ -83,14 +104,14 @@ def db_create_album_images_table() -> None: | |
| conn.close() | ||
|
|
||
|
|
||
| def db_get_all_albums(show_hidden: bool = False): | ||
| def db_get_all_albums(): | ||
| """Get all albums (both locked and unlocked).""" | ||
| conn = sqlite3.connect(DATABASE_PATH) | ||
| cursor = conn.cursor() | ||
| try: | ||
| if show_hidden: | ||
| cursor.execute("SELECT * FROM albums") | ||
| else: | ||
| cursor.execute("SELECT * FROM albums WHERE is_hidden = 0") | ||
| cursor.execute( | ||
| "SELECT album_id, album_name, description, is_locked, password_hash, cover_image_path FROM albums" | ||
| ) | ||
| albums = cursor.fetchall() | ||
| return albums | ||
| finally: | ||
|
|
@@ -101,7 +122,10 @@ def db_get_album_by_name(name: str): | |
| conn = sqlite3.connect(DATABASE_PATH) | ||
| cursor = conn.cursor() | ||
| try: | ||
| cursor.execute("SELECT * FROM albums WHERE album_name = ?", (name,)) | ||
| cursor.execute( | ||
| "SELECT album_id, album_name, description, is_locked, password_hash, cover_image_path FROM albums WHERE album_name = ?", | ||
| (name,), | ||
| ) | ||
| album = cursor.fetchone() | ||
| return album if album else None | ||
| finally: | ||
|
|
@@ -112,7 +136,10 @@ def db_get_album(album_id: str): | |
| conn = sqlite3.connect(DATABASE_PATH) | ||
| cursor = conn.cursor() | ||
| try: | ||
| cursor.execute("SELECT * FROM albums WHERE album_id = ?", (album_id,)) | ||
| cursor.execute( | ||
| "SELECT album_id, album_name, description, is_locked, password_hash, cover_image_path FROM albums WHERE album_id = ?", | ||
| (album_id,), | ||
| ) | ||
| album = cursor.fetchone() | ||
| return album if album else None | ||
| finally: | ||
|
|
@@ -123,7 +150,7 @@ def db_insert_album( | |
| album_id: str, | ||
| album_name: str, | ||
| description: str = "", | ||
| is_hidden: bool = False, | ||
| is_locked: bool = False, | ||
| password: str = None, | ||
| ): | ||
| conn = sqlite3.connect(DATABASE_PATH) | ||
|
|
@@ -136,10 +163,10 @@ def db_insert_album( | |
| ).decode("utf-8") | ||
| cursor.execute( | ||
| """ | ||
| INSERT INTO albums (album_id, album_name, description, is_hidden, password_hash) | ||
| INSERT INTO albums (album_id, album_name, description, is_locked, password_hash) | ||
| VALUES (?, ?, ?, ?, ?) | ||
| """, | ||
| (album_id, album_name, description, int(is_hidden), password_hash), | ||
| (album_id, album_name, description, int(is_locked), password_hash), | ||
| ) | ||
| conn.commit() | ||
| finally: | ||
|
|
@@ -150,7 +177,7 @@ def db_update_album( | |
| album_id: str, | ||
| album_name: str, | ||
| description: str, | ||
| is_hidden: bool, | ||
| is_locked: bool, | ||
| password: str = None, | ||
| ): | ||
| conn = sqlite3.connect(DATABASE_PATH) | ||
|
|
@@ -164,20 +191,20 @@ def db_update_album( | |
| cursor.execute( | ||
| """ | ||
| UPDATE albums | ||
| SET album_name = ?, description = ?, is_hidden = ?, password_hash = ? | ||
| SET album_name = ?, description = ?, is_locked = ?, password_hash = ? | ||
| WHERE album_id = ? | ||
| """, | ||
| (album_name, description, int(is_hidden), password_hash, album_id), | ||
| (album_name, description, int(is_locked), password_hash, album_id), | ||
| ) | ||
| else: | ||
| # Update without changing password | ||
| cursor.execute( | ||
| """ | ||
| UPDATE albums | ||
| SET album_name = ?, description = ?, is_hidden = ? | ||
| SET album_name = ?, description = ?, is_locked = ? | ||
| WHERE album_id = ? | ||
| """, | ||
| (album_name, description, int(is_hidden), album_id), | ||
| (album_name, description, int(is_locked), album_id), | ||
| ) | ||
| conn.commit() | ||
| finally: | ||
|
|
@@ -190,6 +217,20 @@ def db_delete_album(album_id: str): | |
| cursor.execute("DELETE FROM albums WHERE album_id = ?", (album_id,)) | ||
|
|
||
|
|
||
| def db_update_album_cover_image(album_id: str, cover_image_path: str): | ||
| """Update the cover image path for an album""" | ||
| conn = sqlite3.connect(DATABASE_PATH) | ||
| cursor = conn.cursor() | ||
| try: | ||
| cursor.execute( | ||
| "UPDATE albums SET cover_image_path = ? WHERE album_id = ?", | ||
| (cover_image_path, album_id), | ||
| ) | ||
| conn.commit() | ||
| finally: | ||
| conn.close() | ||
|
|
||
|
|
||
| def db_get_album_images(album_id: str): | ||
| conn = sqlite3.connect(DATABASE_PATH) | ||
| cursor = conn.cursor() | ||
|
|
@@ -267,3 +308,15 @@ def verify_album_password(album_id: str, password: str) -> bool: | |
| return bcrypt.checkpw(password.encode("utf-8"), row[0].encode("utf-8")) | ||
| finally: | ||
| conn.close() | ||
|
|
||
|
|
||
| def db_get_image_path(image_id: str) -> str | None: | ||
| """Get the path of an image by its ID.""" | ||
| conn = sqlite3.connect(DATABASE_PATH) | ||
| cursor = conn.cursor() | ||
| try: | ||
| cursor.execute("SELECT path FROM images WHERE id = ?", (image_id,)) | ||
| result = cursor.fetchone() | ||
| return result[0] if result else None | ||
| finally: | ||
| conn.close() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,4 @@ | ||
| from fastapi import APIRouter, HTTPException, status, Query, Body, Path | ||
| from fastapi import APIRouter, HTTPException, status, Body, Path | ||
| import uuid | ||
| from app.schemas.album import ( | ||
| GetAlbumsResponse, | ||
|
|
@@ -11,6 +11,7 @@ | |
| SuccessResponse, | ||
| ErrorResponse, | ||
| ImageIdsRequest, | ||
| SetCoverImageRequest, | ||
| Album, | ||
| ) | ||
| from app.database.albums import ( | ||
|
|
@@ -24,24 +25,33 @@ | |
| db_add_images_to_album, | ||
| db_remove_image_from_album, | ||
| db_remove_images_from_album, | ||
| db_update_album_cover_image, | ||
| verify_album_password, | ||
| db_get_image_path, | ||
| ) | ||
|
|
||
| router = APIRouter() | ||
|
|
||
|
|
||
| # GET /albums/ - Get all albums | ||
| # GET /albums/ - Get all albums (including locked ones) | ||
| @router.get("/", response_model=GetAlbumsResponse) | ||
| def get_albums(show_hidden: bool = Query(False)): | ||
| albums = db_get_all_albums(show_hidden) | ||
| def get_albums(): | ||
| """Get all albums. Always returns both locked and unlocked albums.""" | ||
| albums = db_get_all_albums() | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| album_list = [] | ||
| for album in albums: | ||
| # Get image count for each album | ||
| image_ids = db_get_album_images(album[0]) | ||
| image_count = len(image_ids) | ||
|
|
||
| album_list.append( | ||
| Album( | ||
| album_id=album[0], | ||
| album_name=album[1], | ||
| description=album[2] or "", | ||
| is_hidden=bool(album[3]), | ||
| is_locked=bool(album[3]), | ||
| cover_image_path=album[5] if len(album) > 5 else None, | ||
| image_count=image_count, | ||
| ) | ||
| ) | ||
|
Comment on lines
42
to
56
|
||
| return GetAlbumsResponse(success=True, albums=album_list) | ||
|
|
@@ -64,7 +74,7 @@ def create_album(body: CreateAlbumRequest): | |
| album_id = str(uuid.uuid4()) | ||
| try: | ||
| db_insert_album( | ||
| album_id, body.name, body.description, body.is_hidden, body.password | ||
| album_id, body.name, body.description, body.is_locked, body.password | ||
| ) | ||
| return CreateAlbumResponse(success=True, album_id=album_id) | ||
| except Exception as e: | ||
|
|
@@ -91,11 +101,17 @@ def get_album(album_id: str = Path(...)): | |
| ) | ||
|
|
||
| try: | ||
| # Get image count for the album | ||
| image_ids = db_get_album_images(album_id) | ||
| image_count = len(image_ids) | ||
|
|
||
| album_obj = Album( | ||
| album_id=album[0], | ||
| album_name=album[1], | ||
| description=album[2] or "", | ||
| is_hidden=bool(album[3]), | ||
| is_locked=bool(album[3]), | ||
| cover_image_path=album[5] if len(album) > 5 else None, | ||
| image_count=image_count, | ||
| ) | ||
| return GetAlbumResponse(success=True, data=album_obj) | ||
| except Exception as e: | ||
|
|
@@ -127,11 +143,11 @@ def update_album(album_id: str = Path(...), body: UpdateAlbumRequest = Body(...) | |
| "album_id": album[0], | ||
| "album_name": album[1], | ||
| "description": album[2], | ||
| "is_hidden": bool(album[3]), | ||
| "is_locked": bool(album[3]), | ||
| "password_hash": album[4], | ||
| } | ||
|
|
||
| if album_dict["password_hash"]: | ||
| if album_dict["is_locked"]: | ||
| if not body.current_password: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_401_UNAUTHORIZED, | ||
|
|
@@ -154,7 +170,7 @@ def update_album(album_id: str = Path(...), body: UpdateAlbumRequest = Body(...) | |
|
|
||
| try: | ||
| db_update_album( | ||
| album_id, body.name, body.description, body.is_hidden, body.password | ||
| album_id, body.name, body.description, body.is_locked, body.password | ||
| ) | ||
| return SuccessResponse(success=True, msg="Album updated successfully") | ||
| except Exception as e: | ||
|
|
@@ -215,18 +231,18 @@ def get_album_images( | |
| "album_id": album[0], | ||
| "album_name": album[1], | ||
| "description": album[2], | ||
| "is_hidden": bool(album[3]), | ||
| "is_locked": bool(album[3]), | ||
| "password_hash": album[4], | ||
| } | ||
|
|
||
| if album_dict["is_hidden"]: | ||
| if album_dict["is_locked"]: | ||
| if not body.password: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_401_UNAUTHORIZED, | ||
| detail=ErrorResponse( | ||
| success=False, | ||
| error="Password Required", | ||
| message="Password is required to access this hidden album.", | ||
| message="Password is required to access this locked album.", | ||
| ).model_dump(), | ||
| ) | ||
| if not verify_album_password(album_id, body.password): | ||
|
|
@@ -355,3 +371,60 @@ def remove_images_from_album( | |
| success=False, error="Failed to Remove Images", message=str(e) | ||
| ).model_dump(), | ||
| ) | ||
|
|
||
|
|
||
| # PUT /albums/{album_id}/cover - Set album cover image | ||
| @router.put("/{album_id}/cover", response_model=SuccessResponse) | ||
| def set_album_cover_image( | ||
| album_id: str = Path(...), body: SetCoverImageRequest = Body(...) | ||
| ): | ||
| """Set or update the cover image for an album""" | ||
| album = db_get_album(album_id) | ||
| if not album: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_404_NOT_FOUND, | ||
| detail=ErrorResponse( | ||
| success=False, | ||
| error="Album Not Found", | ||
| message="No album exists with the provided ID.", | ||
| ).model_dump(), | ||
| ) | ||
|
|
||
| # Verify the image exists in the album | ||
| album_image_ids = db_get_album_images(album_id) | ||
| if body.image_id not in album_image_ids: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_400_BAD_REQUEST, | ||
| detail=ErrorResponse( | ||
| success=False, | ||
| error="Image Not In Album", | ||
| message="The specified image is not in this album.", | ||
| ).model_dump(), | ||
| ) | ||
|
|
||
| try: | ||
| # Get the image path from the database | ||
| image_path = db_get_image_path(body.image_id) | ||
|
|
||
| if not image_path: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_404_NOT_FOUND, | ||
| detail=ErrorResponse( | ||
| success=False, | ||
| error="Image Not Found", | ||
| message="The specified image does not exist.", | ||
| ).model_dump(), | ||
| ) | ||
|
|
||
| db_update_album_cover_image(album_id, image_path) | ||
SiddharthJiyani marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| return SuccessResponse( | ||
| success=True, msg="Album cover image updated successfully" | ||
| ) | ||
| except Exception as e: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, | ||
| detail=ErrorResponse( | ||
| success=False, error="Failed to Set Cover Image", message=str(e) | ||
| ).model_dump(), | ||
| ) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
Verify migration function is invoked.
The migration function logic is correct, but ensure it's being called during application initialization or in a migration script to actually apply the schema change to existing databases.
Run this script to check if the migration function is invoked:
🏁 Script executed:
Length of output: 421
Length of output: 423
Length of output: 1412
Length of output: 921
Add migration function to application startup sequence.
The
db_migrate_add_cover_image_columnfunction is defined but never called, so thecover_image_pathcolumn won't be added to existing databases. Import and invoke it during application initialization inbackend/main.pywithin thelifespancontext manager, afterdb_create_albums_table()is called (around line 54).🤖 Prompt for AI Agents