mitlist/be/app/api/v1/endpoints/chores.py
mohamad 944976b1cc
All checks were successful
Deploy to Production, build images and push to Gitea Registry / build_and_push (pull_request) Successful in 1m47s
Update logging level to INFO, refine chore update logic, and enhance invite acceptance flow
- Changed logging level from WARNING to INFO in config.py for better visibility during development.
- Adjusted chore update logic in chores.py to ensure correct payload structure.
- Improved invite acceptance process in invites.py by refining error handling and updating response models for better clarity.
- Updated API endpoint configurations in api-config.ts for consistency and added new endpoints for list statuses.
- Enhanced UI components in ChoresPage.vue and GroupsPage.vue for improved user experience and accessibility.
2025-06-07 22:07:35 +02:00

453 lines
24 KiB
Python

# app/api/v1/endpoints/chores.py
import logging
from typing import List as PyList, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_transactional_session, get_session
from app.auth import current_active_user
from app.models import User as UserModel, Chore as ChoreModel, ChoreTypeEnum
from app.schemas.chore import ChoreCreate, ChoreUpdate, ChorePublic, ChoreAssignmentCreate, ChoreAssignmentUpdate, ChoreAssignmentPublic
from app.crud import chore as crud_chore
from app.core.exceptions import ChoreNotFoundError, PermissionDeniedError, GroupNotFoundError, DatabaseIntegrityError
logger = logging.getLogger(__name__)
router = APIRouter()
# Add this new endpoint before the personal chores section
@router.get(
"/all",
response_model=PyList[ChorePublic],
summary="List All Chores",
tags=["Chores"]
)
async def list_all_chores(
db: AsyncSession = Depends(get_session), # Use read-only session for GET
current_user: UserModel = Depends(current_active_user),
):
"""Retrieves all chores (personal and group) for the current user in a single optimized request."""
logger.info(f"User {current_user.email} listing all their chores")
# Use the optimized function that reduces database queries
all_chores = await crud_chore.get_all_user_chores(db=db, user_id=current_user.id)
return all_chores
# --- Personal Chores Endpoints ---
@router.post(
"/personal",
response_model=ChorePublic,
status_code=status.HTTP_201_CREATED,
summary="Create Personal Chore",
tags=["Chores", "Personal Chores"]
)
async def create_personal_chore(
chore_in: ChoreCreate,
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user),
):
"""Creates a new personal chore for the current user."""
logger.info(f"User {current_user.email} creating personal chore: {chore_in.name}")
if chore_in.type != ChoreTypeEnum.personal:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Chore type must be personal.")
if chore_in.group_id is not None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="group_id must be null for personal chores.")
try:
return await crud_chore.create_chore(db=db, chore_in=chore_in, user_id=current_user.id)
except ValueError as e:
logger.warning(f"ValueError creating personal chore for user {current_user.email}: {str(e)}")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except DatabaseIntegrityError as e:
logger.error(f"DatabaseIntegrityError creating personal chore for {current_user.email}: {e.detail}", exc_info=True)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=e.detail)
@router.get(
"/personal",
response_model=PyList[ChorePublic],
summary="List Personal Chores",
tags=["Chores", "Personal Chores"]
)
async def list_personal_chores(
db: AsyncSession = Depends(get_session),
current_user: UserModel = Depends(current_active_user),
):
"""Retrieves all personal chores for the current user."""
logger.info(f"User {current_user.email} listing their personal chores")
return await crud_chore.get_personal_chores(db=db, user_id=current_user.id)
@router.put(
"/personal/{chore_id}",
response_model=ChorePublic,
summary="Update Personal Chore",
tags=["Chores", "Personal Chores"]
)
async def update_personal_chore(
chore_id: int,
chore_in: ChoreUpdate,
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user),
):
"""Updates a personal chore for the current user."""
logger.info(f"User {current_user.email} updating personal chore ID: {chore_id}")
if chore_in.type is not None and chore_in.type != ChoreTypeEnum.personal:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot change chore type to group via this endpoint.")
if chore_in.group_id is not None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="group_id must be null for personal chores.")
try:
updated_chore = await crud_chore.update_chore(db=db, chore_id=chore_id, chore_in=chore_in, user_id=current_user.id, group_id=None)
if not updated_chore:
raise ChoreNotFoundError(chore_id=chore_id)
if updated_chore.type != ChoreTypeEnum.personal or updated_chore.created_by_id != current_user.id:
# This should ideally be caught by the CRUD layer permission checks
raise PermissionDeniedError(detail="Chore is not a personal chore of the current user or does not exist.")
return updated_chore
except ChoreNotFoundError as e:
logger.warning(f"Personal chore {e.chore_id} not found for user {current_user.email} during update.")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=e.detail)
except PermissionDeniedError as e:
logger.warning(f"Permission denied for user {current_user.email} updating personal chore {chore_id}: {e.detail}")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=e.detail)
except ValueError as e:
logger.warning(f"ValueError updating personal chore {chore_id} for user {current_user.email}: {str(e)}")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except DatabaseIntegrityError as e:
logger.error(f"DatabaseIntegrityError updating personal chore {chore_id} for {current_user.email}: {e.detail}", exc_info=True)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=e.detail)
@router.delete(
"/personal/{chore_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete Personal Chore",
tags=["Chores", "Personal Chores"]
)
async def delete_personal_chore(
chore_id: int,
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user),
):
"""Deletes a personal chore for the current user."""
logger.info(f"User {current_user.email} deleting personal chore ID: {chore_id}")
try:
# First, verify it's a personal chore belonging to the user
chore_to_delete = await crud_chore.get_chore_by_id(db, chore_id)
if not chore_to_delete or chore_to_delete.type != ChoreTypeEnum.personal or chore_to_delete.created_by_id != current_user.id:
raise ChoreNotFoundError(chore_id=chore_id, detail="Personal chore not found or not owned by user.")
success = await crud_chore.delete_chore(db=db, chore_id=chore_id, user_id=current_user.id, group_id=None)
if not success:
# This case should be rare if the above check passes and DB is consistent
raise ChoreNotFoundError(chore_id=chore_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
except ChoreNotFoundError as e:
logger.warning(f"Personal chore {e.chore_id} not found for user {current_user.email} during delete.")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=e.detail)
except PermissionDeniedError as e: # Should be caught by the check above
logger.warning(f"Permission denied for user {current_user.email} deleting personal chore {chore_id}: {e.detail}")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=e.detail)
except DatabaseIntegrityError as e:
logger.error(f"DatabaseIntegrityError deleting personal chore {chore_id} for {current_user.email}: {e.detail}", exc_info=True)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=e.detail)
# --- Group Chores Endpoints ---
# (These would be similar to what you might have had before, but now explicitly part of this router)
@router.post(
"/groups/{group_id}/chores",
response_model=ChorePublic,
status_code=status.HTTP_201_CREATED,
summary="Create Group Chore",
tags=["Chores", "Group Chores"]
)
async def create_group_chore(
group_id: int,
chore_in: ChoreCreate,
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user),
):
"""Creates a new chore within a specific group."""
logger.info(f"User {current_user.email} creating chore in group {group_id}: {chore_in.name}")
if chore_in.type != ChoreTypeEnum.group:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Chore type must be group.")
if chore_in.group_id != group_id and chore_in.group_id is not None: # Make sure chore_in.group_id matches path if provided
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Chore's group_id ({chore_in.group_id}) must match path group_id ({group_id}) or be omitted.")
# Ensure chore_in has the correct group_id and type for the CRUD operation
chore_payload = chore_in.model_copy(update={"group_id": group_id, "type": ChoreTypeEnum.group})
try:
return await crud_chore.create_chore(db=db, chore_in=chore_payload, user_id=current_user.id, group_id=group_id)
except GroupNotFoundError as e:
logger.warning(f"Group {e.group_id} not found for chore creation by user {current_user.email}.")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=e.detail)
except PermissionDeniedError as e:
logger.warning(f"Permission denied for user {current_user.email} in group {group_id} for chore creation: {e.detail}")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=e.detail)
except ValueError as e:
logger.warning(f"ValueError creating group chore for user {current_user.email} in group {group_id}: {str(e)}")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except DatabaseIntegrityError as e:
logger.error(f"DatabaseIntegrityError creating group chore for {current_user.email} in group {group_id}: {e.detail}", exc_info=True)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=e.detail)
@router.get(
"/groups/{group_id}/chores",
response_model=PyList[ChorePublic],
summary="List Group Chores",
tags=["Chores", "Group Chores"]
)
async def list_group_chores(
group_id: int,
db: AsyncSession = Depends(get_session),
current_user: UserModel = Depends(current_active_user),
):
"""Retrieves all chores for a specific group, if the user is a member."""
logger.info(f"User {current_user.email} listing chores for group {group_id}")
try:
return await crud_chore.get_chores_by_group_id(db=db, group_id=group_id, user_id=current_user.id)
except PermissionDeniedError as e:
logger.warning(f"Permission denied for user {current_user.email} accessing chores for group {group_id}: {e.detail}")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=e.detail)
@router.put(
"/groups/{group_id}/chores/{chore_id}",
response_model=ChorePublic,
summary="Update Group Chore",
tags=["Chores", "Group Chores"]
)
async def update_group_chore(
group_id: int,
chore_id: int,
chore_in: ChoreUpdate,
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user),
):
"""Updates a chore's details within a specific group."""
logger.info(f"User {current_user.email} updating chore ID {chore_id} in group {group_id}")
if chore_in.type is not None and chore_in.type != ChoreTypeEnum.group:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot change chore type to personal via this endpoint.")
if chore_in.group_id is not None and chore_in.group_id != group_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Chore's group_id if provided must match path group_id ({group_id}).")
# Ensure chore_in has the correct type for the CRUD operation
chore_payload = chore_in.model_copy(update={"type": ChoreTypeEnum.group, "group_id": group_id} if chore_in.type is None else {"group_id": group_id})
try:
updated_chore = await crud_chore.update_chore(db=db, chore_id=chore_id, chore_in=chore_payload, user_id=current_user.id, group_id=group_id)
if not updated_chore:
raise ChoreNotFoundError(chore_id=chore_id, group_id=group_id)
return updated_chore
except ChoreNotFoundError as e:
logger.warning(f"Chore {e.chore_id} in group {e.group_id} not found for user {current_user.email} during update.")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=e.detail)
except PermissionDeniedError as e:
logger.warning(f"Permission denied for user {current_user.email} updating chore {chore_id} in group {group_id}: {e.detail}")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=e.detail)
except ValueError as e:
logger.warning(f"ValueError updating group chore {chore_id} for user {current_user.email} in group {group_id}: {str(e)}")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except DatabaseIntegrityError as e:
logger.error(f"DatabaseIntegrityError updating group chore {chore_id} for {current_user.email}: {e.detail}", exc_info=True)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=e.detail)
@router.delete(
"/groups/{group_id}/chores/{chore_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete Group Chore",
tags=["Chores", "Group Chores"]
)
async def delete_group_chore(
group_id: int,
chore_id: int,
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user),
):
"""Deletes a chore from a group, ensuring user has permission."""
logger.info(f"User {current_user.email} deleting chore ID {chore_id} from group {group_id}")
try:
# Verify chore exists and belongs to the group before attempting deletion via CRUD
# This gives a more precise error if the chore exists but isn't in this group.
chore_to_delete = await crud_chore.get_chore_by_id_and_group(db, chore_id, group_id, current_user.id) # checks permission too
if not chore_to_delete : # get_chore_by_id_and_group will raise PermissionDeniedError if user not member
raise ChoreNotFoundError(chore_id=chore_id, group_id=group_id)
success = await crud_chore.delete_chore(db=db, chore_id=chore_id, user_id=current_user.id, group_id=group_id)
if not success:
# This case should be rare if the above check passes and DB is consistent
raise ChoreNotFoundError(chore_id=chore_id, group_id=group_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
except ChoreNotFoundError as e:
logger.warning(f"Chore {e.chore_id} in group {e.group_id} not found for user {current_user.email} during delete.")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=e.detail)
except PermissionDeniedError as e:
logger.warning(f"Permission denied for user {current_user.email} deleting chore {chore_id} in group {group_id}: {e.detail}")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=e.detail)
except DatabaseIntegrityError as e:
logger.error(f"DatabaseIntegrityError deleting group chore {chore_id} for {current_user.email}: {e.detail}", exc_info=True)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=e.detail)
# === CHORE ASSIGNMENT ENDPOINTS ===
@router.post(
"/assignments",
response_model=ChoreAssignmentPublic,
status_code=status.HTTP_201_CREATED,
summary="Create Chore Assignment",
tags=["Chore Assignments"]
)
async def create_chore_assignment(
assignment_in: ChoreAssignmentCreate,
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user),
):
"""Creates a new chore assignment. User must have permission to manage the chore."""
logger.info(f"User {current_user.email} creating assignment for chore {assignment_in.chore_id}")
try:
return await crud_chore.create_chore_assignment(db=db, assignment_in=assignment_in, user_id=current_user.id)
except ChoreNotFoundError as e:
logger.warning(f"Chore {e.chore_id} not found for assignment creation by user {current_user.email}.")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=e.detail)
except PermissionDeniedError as e:
logger.warning(f"Permission denied for user {current_user.email} creating assignment: {e.detail}")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=e.detail)
except ValueError as e:
logger.warning(f"ValueError creating assignment for user {current_user.email}: {str(e)}")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except DatabaseIntegrityError as e:
logger.error(f"DatabaseIntegrityError creating assignment for {current_user.email}: {e.detail}", exc_info=True)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=e.detail)
@router.get(
"/assignments/my",
response_model=PyList[ChoreAssignmentPublic],
summary="List My Chore Assignments",
tags=["Chore Assignments"]
)
async def list_my_assignments(
include_completed: bool = False,
db: AsyncSession = Depends(get_session), # Use read-only session for GET
current_user: UserModel = Depends(current_active_user),
):
"""Retrieves all chore assignments for the current user."""
logger.info(f"User {current_user.email} listing their assignments (include_completed={include_completed})")
try:
return await crud_chore.get_user_assignments(db=db, user_id=current_user.id, include_completed=include_completed)
except Exception as e:
logger.error(f"Error listing assignments for user {current_user.email}: {e}", exc_info=True)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve assignments")
@router.get(
"/chores/{chore_id}/assignments",
response_model=PyList[ChoreAssignmentPublic],
summary="List Chore Assignments",
tags=["Chore Assignments"]
)
async def list_chore_assignments(
chore_id: int,
db: AsyncSession = Depends(get_session), # Use read-only session for GET
current_user: UserModel = Depends(current_active_user),
):
"""Retrieves all assignments for a specific chore."""
logger.info(f"User {current_user.email} listing assignments for chore {chore_id}")
try:
return await crud_chore.get_chore_assignments(db=db, chore_id=chore_id, user_id=current_user.id)
except ChoreNotFoundError as e:
logger.warning(f"Chore {e.chore_id} not found for assignment listing by user {current_user.email}.")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=e.detail)
except PermissionDeniedError as e:
logger.warning(f"Permission denied for user {current_user.email} listing assignments for chore {chore_id}: {e.detail}")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=e.detail)
@router.put(
"/assignments/{assignment_id}",
response_model=ChoreAssignmentPublic,
summary="Update Chore Assignment",
tags=["Chore Assignments"]
)
async def update_chore_assignment(
assignment_id: int,
assignment_in: ChoreAssignmentUpdate,
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user),
):
"""Updates a chore assignment. Only assignee can mark complete, managers can reschedule."""
logger.info(f"User {current_user.email} updating assignment {assignment_id}")
try:
updated_assignment = await crud_chore.update_chore_assignment(
db=db, assignment_id=assignment_id, assignment_in=assignment_in, user_id=current_user.id
)
if not updated_assignment:
raise ChoreNotFoundError(assignment_id=assignment_id)
return updated_assignment
except ChoreNotFoundError as e:
logger.warning(f"Assignment {assignment_id} not found for user {current_user.email} during update.")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=e.detail)
except PermissionDeniedError as e:
logger.warning(f"Permission denied for user {current_user.email} updating assignment {assignment_id}: {e.detail}")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=e.detail)
except ValueError as e:
logger.warning(f"ValueError updating assignment {assignment_id} for user {current_user.email}: {str(e)}")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except DatabaseIntegrityError as e:
logger.error(f"DatabaseIntegrityError updating assignment {assignment_id} for {current_user.email}: {e.detail}", exc_info=True)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=e.detail)
@router.delete(
"/assignments/{assignment_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete Chore Assignment",
tags=["Chore Assignments"]
)
async def delete_chore_assignment(
assignment_id: int,
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user),
):
"""Deletes a chore assignment. User must have permission to manage the chore."""
logger.info(f"User {current_user.email} deleting assignment {assignment_id}")
try:
success = await crud_chore.delete_chore_assignment(db=db, assignment_id=assignment_id, user_id=current_user.id)
if not success:
raise ChoreNotFoundError(assignment_id=assignment_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
except ChoreNotFoundError as e:
logger.warning(f"Assignment {assignment_id} not found for user {current_user.email} during delete.")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=e.detail)
except PermissionDeniedError as e:
logger.warning(f"Permission denied for user {current_user.email} deleting assignment {assignment_id}: {e.detail}")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=e.detail)
except DatabaseIntegrityError as e:
logger.error(f"DatabaseIntegrityError deleting assignment {assignment_id} for {current_user.email}: {e.detail}", exc_info=True)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=e.detail)
@router.patch(
"/assignments/{assignment_id}/complete",
response_model=ChoreAssignmentPublic,
summary="Mark Assignment Complete",
tags=["Chore Assignments"]
)
async def complete_chore_assignment(
assignment_id: int,
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user),
):
"""Convenience endpoint to mark an assignment as complete."""
logger.info(f"User {current_user.email} marking assignment {assignment_id} as complete")
assignment_update = ChoreAssignmentUpdate(is_complete=True)
try:
updated_assignment = await crud_chore.update_chore_assignment(
db=db, assignment_id=assignment_id, assignment_in=assignment_update, user_id=current_user.id
)
if not updated_assignment:
raise ChoreNotFoundError(assignment_id=assignment_id)
return updated_assignment
except ChoreNotFoundError as e:
logger.warning(f"Assignment {assignment_id} not found for user {current_user.email} during completion.")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=e.detail)
except PermissionDeniedError as e:
logger.warning(f"Permission denied for user {current_user.email} completing assignment {assignment_id}: {e.detail}")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=e.detail)
except DatabaseIntegrityError as e:
logger.error(f"DatabaseIntegrityError completing assignment {assignment_id} for {current_user.email}: {e.detail}", exc_info=True)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=e.detail)