"""Upload API endpoints for images.""" from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File from sqlalchemy.orm import Session from pathlib import Path import uuid import shutil from app.core.database import get_db from app.api.v1.auth import get_current_user from app.models.user import User from app.models.chore import Chore router = APIRouter() # Configure upload directories UPLOAD_DIR = Path(__file__).parent.parent.parent / "static" / "uploads" USER_UPLOAD_DIR = UPLOAD_DIR / "users" CHORE_UPLOAD_DIR = UPLOAD_DIR / "chores" # Ensure directories exist USER_UPLOAD_DIR.mkdir(parents=True, exist_ok=True) CHORE_UPLOAD_DIR.mkdir(parents=True, exist_ok=True) # Allowed image extensions ALLOWED_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.webp'} def validate_image(filename: str) -> bool: """Check if file extension is allowed""" ext = Path(filename).suffix.lower() return ext in ALLOWED_EXTENSIONS def save_upload_file(upload_file: UploadFile, destination: Path) -> str: """Save uploaded file and return filename""" try: with destination.open("wb") as buffer: shutil.copyfileobj(upload_file.file, buffer) return destination.name finally: upload_file.file.close() @router.post("/users/avatar", status_code=status.HTTP_200_OK) async def upload_user_avatar( file: UploadFile = File(...), db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """ Upload avatar for current user. - Accepts: JPG, JPEG, PNG, GIF, WEBP - Max size: 5MB (handled by FastAPI) - Overwrites existing avatar """ if not validate_image(file.filename): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid file type. Allowed: {', '.join(ALLOWED_EXTENSIONS)}" ) # Generate unique filename file_ext = Path(file.filename).suffix.lower() filename = f"user_{current_user.id}_{uuid.uuid4().hex[:8]}{file_ext}" file_path = USER_UPLOAD_DIR / filename # Delete old avatar if exists if current_user.avatar_url: old_file = USER_UPLOAD_DIR / Path(current_user.avatar_url).name if old_file.exists(): old_file.unlink() # Save new avatar save_upload_file(file, file_path) # Update user record avatar_url = f"/static/uploads/users/{filename}" current_user.avatar_url = avatar_url db.commit() db.refresh(current_user) return { "message": "Avatar uploaded successfully", "avatar_url": avatar_url } @router.delete("/users/avatar", status_code=status.HTTP_204_NO_CONTENT) async def delete_user_avatar( db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """Delete current user's avatar""" if not current_user.avatar_url: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="No avatar to delete" ) # Delete file old_file = USER_UPLOAD_DIR / Path(current_user.avatar_url).name if old_file.exists(): old_file.unlink() # Update database current_user.avatar_url = None db.commit() return None @router.post("/chores/{chore_id}/image", status_code=status.HTTP_200_OK) async def upload_chore_image( chore_id: int, file: UploadFile = File(...), db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """ Upload image for a chore (admin or assigned user only). - Accepts: JPG, JPEG, PNG, GIF, WEBP - Max size: 5MB (handled by FastAPI) - Overwrites existing image """ # Get chore chore = db.query(Chore).filter(Chore.id == chore_id).first() if not chore: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Chore not found" ) # Check permissions (admin or assigned user) from app.models.chore_assignment import ChoreAssignment is_assigned = db.query(ChoreAssignment).filter( ChoreAssignment.chore_id == chore_id, ChoreAssignment.user_id == current_user.id ).first() is not None if not current_user.is_admin and not is_assigned: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized to upload image for this chore" ) if not validate_image(file.filename): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid file type. Allowed: {', '.join(ALLOWED_EXTENSIONS)}" ) # Generate unique filename file_ext = Path(file.filename).suffix.lower() filename = f"chore_{chore_id}_{uuid.uuid4().hex[:8]}{file_ext}" file_path = CHORE_UPLOAD_DIR / filename # Delete old image if exists if chore.image_url: old_file = CHORE_UPLOAD_DIR / Path(chore.image_url).name if old_file.exists(): old_file.unlink() # Save new image save_upload_file(file, file_path) # Update chore record image_url = f"/static/uploads/chores/{filename}" chore.image_url = image_url db.commit() db.refresh(chore) return { "message": "Image uploaded successfully", "image_url": image_url } @router.delete("/chores/{chore_id}/image", status_code=status.HTTP_204_NO_CONTENT) async def delete_chore_image( chore_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """Delete chore image (admin or assigned user only)""" # Get chore chore = db.query(Chore).filter(Chore.id == chore_id).first() if not chore: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Chore not found" ) # Check permissions from app.models.chore_assignment import ChoreAssignment is_assigned = db.query(ChoreAssignment).filter( ChoreAssignment.chore_id == chore_id, ChoreAssignment.user_id == current_user.id ).first() is not None if not current_user.is_admin and not is_assigned: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized to delete image for this chore" ) if not chore.image_url: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="No image to delete" ) # Delete file old_file = CHORE_UPLOAD_DIR / Path(chore.image_url).name if old_file.exists(): old_file.unlink() # Update database chore.image_url = None db.commit() return None @router.post("/admin/users/{user_id}/avatar", status_code=status.HTTP_200_OK) async def admin_upload_user_avatar( user_id: int, file: UploadFile = File(...), db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """ Upload avatar for any user (admin only). - Accepts: JPG, JPEG, PNG, GIF, WEBP - Max size: 5MB - Admin only """ # Check if current user is admin if not current_user.is_admin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can upload avatars for other users" ) # Get target user target_user = db.query(User).filter(User.id == user_id).first() if not target_user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found" ) if not validate_image(file.filename): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid file type. Allowed: {', '.join(ALLOWED_EXTENSIONS)}" ) # Generate unique filename file_ext = Path(file.filename).suffix.lower() filename = f"user_{user_id}_{uuid.uuid4().hex[:8]}{file_ext}" file_path = USER_UPLOAD_DIR / filename # Delete old avatar if exists if target_user.avatar_url: old_file = USER_UPLOAD_DIR / Path(target_user.avatar_url).name if old_file.exists(): old_file.unlink() # Save new avatar save_upload_file(file, file_path) # Update user record avatar_url = f"/static/uploads/users/{filename}" target_user.avatar_url = avatar_url db.commit() db.refresh(target_user) return { "message": "Avatar uploaded successfully", "avatar_url": avatar_url } @router.delete("/admin/users/{user_id}/avatar", status_code=status.HTTP_204_NO_CONTENT) async def admin_delete_user_avatar( user_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """Delete avatar for any user (admin only)""" # Check if current user is admin if not current_user.is_admin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can delete avatars for other users" ) # Get target user target_user = db.query(User).filter(User.id == user_id).first() if not target_user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found" ) if not target_user.avatar_url: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="No avatar to delete" ) # Delete file old_file = USER_UPLOAD_DIR / Path(target_user.avatar_url).name if old_file.exists(): old_file.unlink() # Update database target_user.avatar_url = None db.commit() return None