Phase 3.1: Enhanced Chore Logging and Reporting System

This commit is contained in:
2026-02-05 12:33:51 +11:00
commit e3cae7bfbb
178 changed files with 30105 additions and 0 deletions

View 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