Phase 3.1: Enhanced Chore Logging and Reporting System
This commit is contained in:
322
backend/app/api/v1/uploads.py
Normal file
322
backend/app/api/v1/uploads.py
Normal file
@@ -0,0 +1,322 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user