diff --git a/backend/app/api/v1/chores.py b/backend/app/api/v1/chores.py index f5799df..c3caf87 100644 --- a/backend/app/api/v1/chores.py +++ b/backend/app/api/v1/chores.py @@ -1,29 +1,118 @@ """Chores API endpoints.""" -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status, Query from sqlalchemy.orm import Session -from typing import List -from datetime import datetime +from typing import List, Optional +from datetime import datetime, date 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, ChoreStatus +from app.models.chore_assignment import ChoreAssignment from app.schemas import chore as chore_schemas router = APIRouter() +def is_birthday_today(user: User) -> bool: + """Check if today is the user's birthday.""" + if not user.birthday: + return False + + today = date.today() + return user.birthday.month == today.month and user.birthday.day == today.day + + +def get_chore_with_assignments(db: Session, chore: Chore) -> dict: + """Convert chore to dict with assignment details.""" + # Get all assignments for this chore + assignments = db.query(ChoreAssignment).filter( + ChoreAssignment.chore_id == chore.id + ).all() + + # Build assigned users list with completion status + assigned_users = [] + for assignment in assignments: + user = db.query(User).filter(User.id == assignment.user_id).first() + if user: + assigned_users.append({ + "id": user.id, + "username": user.username, + "full_name": user.full_name, + "birthday": user.birthday, + "completed_at": assignment.completed_at + }) + + # Convert chore to dict + chore_dict = { + "id": chore.id, + "title": chore.title, + "description": chore.description, + "room": chore.room, + "frequency": chore.frequency, + "points": chore.points, + "status": chore.status, + "due_date": chore.due_date, + "completed_at": chore.completed_at, + "created_at": chore.created_at, + "updated_at": chore.updated_at, + "assigned_users": assigned_users, + "assigned_user_id": chore.assigned_user_id # Legacy compatibility + } + + return chore_dict + + @router.get("", response_model=List[chore_schemas.Chore]) def get_chores( skip: int = 0, limit: int = 100, + user_id: Optional[int] = Query(None, description="Filter by assigned user ID"), + exclude_birthdays: bool = Query(False, description="Exclude chores for users with birthdays today"), db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): - """Get all chores.""" - chores = db.query(Chore).offset(skip).limit(limit).all() - return chores + """ + Get all chores. + + - **user_id**: Filter chores assigned to specific user + - **exclude_birthdays**: Skip chores for users with birthdays today + """ + query = db.query(Chore) + + # Apply user filter if specified + if user_id: + # Find chores assigned to this user through the assignments table + assignment_ids = db.query(ChoreAssignment.chore_id).filter( + ChoreAssignment.user_id == user_id + ).all() + chore_ids = [aid[0] for aid in assignment_ids] + query = query.filter(Chore.id.in_(chore_ids)) + + chores = query.offset(skip).limit(limit).all() + + # Build response with assignments + result = [] + for chore in chores: + chore_data = get_chore_with_assignments(db, chore) + + # Filter out if birthday exclusion is enabled + if exclude_birthdays: + # Skip if any assigned user has birthday today + skip_chore = False + for assigned_user in chore_data["assigned_users"]: + user = db.query(User).filter(User.id == assigned_user["id"]).first() + if user and is_birthday_today(user): + skip_chore = True + break + + if skip_chore: + continue + + result.append(chore_data) + + return result @router.get("/{chore_id}", response_model=chore_schemas.Chore) @@ -39,7 +128,8 @@ def get_chore( status_code=status.HTTP_404_NOT_FOUND, detail="Chore not found" ) - return chore + + return get_chore_with_assignments(db, chore) @router.post("", response_model=chore_schemas.Chore, status_code=status.HTTP_201_CREATED) @@ -48,12 +138,34 @@ def create_chore( db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): - """Create a new chore.""" - chore = Chore(**chore_in.model_dump()) + """Create a new chore with multiple user assignments.""" + # Extract user IDs before creating chore + assigned_user_ids = chore_in.assigned_user_ids or [] + + # Create chore without assigned_user_ids (not a DB column) + chore_data = chore_in.model_dump(exclude={'assigned_user_ids'}) + chore = Chore(**chore_data) + db.add(chore) db.commit() db.refresh(chore) - return chore + + # Create assignments for each user + for user_id in assigned_user_ids: + # Verify user exists + user = db.query(User).filter(User.id == user_id).first() + if not user: + continue + + assignment = ChoreAssignment( + chore_id=chore.id, + user_id=user_id + ) + db.add(assignment) + + db.commit() + + return get_chore_with_assignments(db, chore) @router.put("/{chore_id}", response_model=chore_schemas.Chore) @@ -63,7 +175,12 @@ def update_chore( db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): - """Update a chore.""" + """ + Update a chore (admin only or assigned user for status updates). + + Admin users can update all fields. + Non-admin users can only update status for chores assigned to them. + """ chore = db.query(Chore).filter(Chore.id == chore_id).first() if not chore: raise HTTPException( @@ -71,19 +188,79 @@ def update_chore( detail="Chore not found" ) - # Update fields - update_data = chore_in.model_dump(exclude_unset=True) + update_data = chore_in.model_dump(exclude_unset=True, exclude={'assigned_user_ids'}) - # If status is being changed to completed, set completed_at - if update_data.get("status") == ChoreStatus.COMPLETED and chore.status != ChoreStatus.COMPLETED: - update_data["completed_at"] = datetime.utcnow() + # Check permissions + is_admin = current_user.is_admin + is_assigned = db.query(ChoreAssignment).filter( + ChoreAssignment.chore_id == chore_id, + ChoreAssignment.user_id == current_user.id + ).first() is not None - for field, value in update_data.items(): - setattr(chore, field, value) + if not is_admin and not is_assigned: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not authorized to update this chore" + ) + + # Non-admins can only update status + if not is_admin: + allowed_fields = {'status'} + update_data = {k: v for k, v in update_data.items() if k in allowed_fields} + + # Handle status change to completed + if update_data.get("status") == ChoreStatus.COMPLETED: + # Mark assignment as completed for current user + assignment = db.query(ChoreAssignment).filter( + ChoreAssignment.chore_id == chore_id, + ChoreAssignment.user_id == current_user.id + ).first() + + if assignment: + assignment.completed_at = datetime.utcnow() + + # If all assignments are completed, mark chore as completed + all_assignments = db.query(ChoreAssignment).filter( + ChoreAssignment.chore_id == chore_id + ).all() + + if all(a.completed_at is not None for a in all_assignments): + chore.completed_at = datetime.utcnow() + chore.status = ChoreStatus.COMPLETED + + # Update chore fields (admins only) + if is_admin: + for field, value in update_data.items(): + # Convert empty string to None for datetime fields + if field == 'due_date' and value == '': + value = None + setattr(chore, field, value) + + # Handle user assignments update + if 'assigned_user_ids' in chore_in.model_dump(exclude_unset=True): + assigned_user_ids = chore_in.assigned_user_ids or [] + + # Remove old assignments + db.query(ChoreAssignment).filter( + ChoreAssignment.chore_id == chore_id + ).delete() + + # Add new assignments + for user_id in assigned_user_ids: + user = db.query(User).filter(User.id == user_id).first() + if not user: + continue + + assignment = ChoreAssignment( + chore_id=chore.id, + user_id=user_id + ) + db.add(assignment) db.commit() db.refresh(chore) - return chore + + return get_chore_with_assignments(db, chore) @router.delete("/{chore_id}", status_code=status.HTTP_204_NO_CONTENT) @@ -92,7 +269,13 @@ def delete_chore( db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): - """Delete a chore.""" + """Delete a chore (admin only).""" + if not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only admins can delete chores" + ) + chore = db.query(Chore).filter(Chore.id == chore_id).first() if not chore: raise HTTPException( @@ -103,3 +286,48 @@ def delete_chore( db.delete(chore) db.commit() return None + + +@router.post("/{chore_id}/assign", response_model=chore_schemas.Chore) +def assign_users_to_chore( + chore_id: int, + user_ids: List[int], + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Assign multiple users to a chore (admin only). + Replaces existing assignments. + """ + if not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only admins can assign chores" + ) + + 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" + ) + + # Remove existing assignments + db.query(ChoreAssignment).filter(ChoreAssignment.chore_id == chore_id).delete() + + # Add new assignments + for user_id in user_ids: + user = db.query(User).filter(User.id == user_id).first() + if not user: + continue + + assignment = ChoreAssignment( + chore_id=chore.id, + user_id=user_id + ) + db.add(assignment) + + db.commit() + db.refresh(chore) + + return get_chore_with_assignments(db, chore)