
All checks were successful
Deploy to Production, build images and push to Gitea Registry / build_and_push (pull_request) Successful in 1m37s
This commit introduces significant updates to the chore management system, including: - Refactoring the `create_chore` function to streamline the creation process and enforce assignment rules based on chore type. - Adding support for assigning chores to users, ensuring that group chores can only be assigned to group members and personal chores can only be assigned to the creator. - Updating the API endpoints and frontend components to accommodate the new assignment features, including loading group members for assignment selection. - Enhancing the user interface to display assignment options and manage chore assignments effectively. These changes aim to improve the functionality and user experience of the chore management system.
691 lines
33 KiB
Python
691 lines
33 KiB
Python
# app/api/v1/endpoints/chores.py
|
|
import logging
|
|
from typing import List as PyList, Optional
|
|
from datetime import datetime, timezone
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status, Response
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select
|
|
|
|
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, TimeEntry
|
|
from app.schemas.chore import (
|
|
ChoreCreate, ChoreUpdate, ChorePublic,
|
|
ChoreAssignmentCreate, ChoreAssignmentUpdate, ChoreAssignmentPublic,
|
|
ChoreHistoryPublic, ChoreAssignmentHistoryPublic
|
|
)
|
|
from app.schemas.time_entry import TimeEntryPublic
|
|
from app.crud import chore as crud_chore
|
|
from app.crud import history as crud_history
|
|
from app.crud import group as crud_group
|
|
from app.core.exceptions import ChoreNotFoundError, PermissionDeniedError, GroupNotFoundError, DatabaseIntegrityError
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter()
|
|
|
|
@router.get(
|
|
"/all",
|
|
response_model=PyList[ChorePublic],
|
|
summary="List All Chores",
|
|
tags=["Chores"]
|
|
)
|
|
async def list_all_chores(
|
|
db: AsyncSession = Depends(get_session),
|
|
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")
|
|
|
|
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.put(
|
|
"/{chore_id}",
|
|
response_model=ChorePublic,
|
|
summary="Update Chore (Any Type)",
|
|
tags=["Chores"]
|
|
)
|
|
async def update_chore_any_type(
|
|
chore_id: int,
|
|
chore_in: ChoreUpdate,
|
|
db: AsyncSession = Depends(get_transactional_session),
|
|
current_user: UserModel = Depends(current_active_user),
|
|
):
|
|
"""Updates a chore of any type, including conversions between personal and group chores."""
|
|
logger.info(f"User {current_user.email} updating chore ID: {chore_id}")
|
|
|
|
# Get the current chore to determine its type and group
|
|
current_chore = await crud_chore.get_chore_by_id(db, chore_id)
|
|
if not current_chore:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Chore {chore_id} not found")
|
|
|
|
# Check permissions on the current chore
|
|
if current_chore.type == ChoreTypeEnum.personal:
|
|
if current_chore.created_by_id != current_user.id:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only update your own personal chores")
|
|
else: # group chore
|
|
if not await crud_group.is_user_member(db, current_chore.group_id, current_user.id):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"You are not a member of group {current_chore.group_id}")
|
|
|
|
# If converting to group chore, validate the target group
|
|
if chore_in.type == ChoreTypeEnum.group and chore_in.group_id:
|
|
if not await crud_group.is_user_member(db, chore_in.group_id, current_user.id):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"You are not a member of target group {chore_in.group_id}")
|
|
|
|
try:
|
|
# Use the current group_id for the update call if not changing groups
|
|
group_id_for_update = current_chore.group_id if current_chore.type == ChoreTypeEnum.group else None
|
|
|
|
updated_chore = await crud_chore.update_chore(
|
|
db=db,
|
|
chore_id=chore_id,
|
|
chore_in=chore_in,
|
|
user_id=current_user.id,
|
|
group_id=group_id_for_update
|
|
)
|
|
if not updated_chore:
|
|
raise ChoreNotFoundError(chore_id=chore_id)
|
|
return updated_chore
|
|
except ChoreNotFoundError as e:
|
|
logger.warning(f"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 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 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 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:
|
|
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:
|
|
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 ---
|
|
|
|
@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)
|
|
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. The group_id in path is the current group of the chore."""
|
|
logger.info(f"User {current_user.email} updating chore ID {chore_id} in group {group_id}")
|
|
|
|
# Validate that the chore is in the specified group
|
|
chore_to_update = await crud_chore.get_chore_by_id_and_group(db, chore_id, group_id, current_user.id)
|
|
if not chore_to_update:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Chore {chore_id} not found in group {group_id}")
|
|
|
|
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=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:
|
|
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:
|
|
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),
|
|
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(
|
|
"/{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),
|
|
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)
|
|
|
|
# === CHORE HISTORY ENDPOINTS ===
|
|
|
|
@router.get(
|
|
"/{chore_id}/history",
|
|
response_model=PyList[ChoreHistoryPublic],
|
|
summary="Get Chore History",
|
|
tags=["Chores", "History"]
|
|
)
|
|
async def get_chore_history(
|
|
chore_id: int,
|
|
db: AsyncSession = Depends(get_session),
|
|
current_user: UserModel = Depends(current_active_user),
|
|
):
|
|
"""Retrieves the history of a specific chore."""
|
|
chore = await crud_chore.get_chore_by_id(db, chore_id)
|
|
if not chore:
|
|
raise ChoreNotFoundError(chore_id=chore_id)
|
|
|
|
if chore.type == ChoreTypeEnum.personal and chore.created_by_id != current_user.id:
|
|
raise PermissionDeniedError("You can only view history for your own personal chores.")
|
|
|
|
if chore.type == ChoreTypeEnum.group:
|
|
is_member = await crud_chore.is_user_member(db, chore.group_id, current_user.id)
|
|
if not is_member:
|
|
raise PermissionDeniedError("You must be a member of the group to view this chore's history.")
|
|
|
|
logger.info(f"User {current_user.email} getting history for chore {chore_id}")
|
|
return await crud_history.get_chore_history(db=db, chore_id=chore_id)
|
|
|
|
@router.get(
|
|
"/assignments/{assignment_id}/history",
|
|
response_model=PyList[ChoreAssignmentHistoryPublic],
|
|
summary="Get Chore Assignment History",
|
|
tags=["Chore Assignments", "History"]
|
|
)
|
|
async def get_chore_assignment_history(
|
|
assignment_id: int,
|
|
db: AsyncSession = Depends(get_session),
|
|
current_user: UserModel = Depends(current_active_user),
|
|
):
|
|
"""Retrieves the history of a specific chore assignment."""
|
|
assignment = await crud_chore.get_chore_assignment_by_id(db, assignment_id)
|
|
if not assignment:
|
|
raise ChoreNotFoundError(assignment_id=assignment_id)
|
|
|
|
chore = await crud_chore.get_chore_by_id(db, assignment.chore_id)
|
|
if not chore:
|
|
raise ChoreNotFoundError(chore_id=assignment.chore_id)
|
|
|
|
if chore.type == ChoreTypeEnum.personal and chore.created_by_id != current_user.id:
|
|
raise PermissionDeniedError("You can only view history for assignments of your own personal chores.")
|
|
|
|
if chore.type == ChoreTypeEnum.group:
|
|
is_member = await crud_chore.is_user_member(db, chore.group_id, current_user.id)
|
|
if not is_member:
|
|
raise PermissionDeniedError("You must be a member of the group to view this assignment's history.")
|
|
|
|
logger.info(f"User {current_user.email} getting history for assignment {assignment_id}")
|
|
return await crud_history.get_assignment_history(db=db, assignment_id=assignment_id)
|
|
|
|
# === TIME ENTRY ENDPOINTS ===
|
|
|
|
@router.get(
|
|
"/assignments/{assignment_id}/time-entries",
|
|
response_model=PyList[TimeEntryPublic],
|
|
summary="Get Time Entries",
|
|
tags=["Time Tracking"]
|
|
)
|
|
async def get_time_entries_for_assignment(
|
|
assignment_id: int,
|
|
db: AsyncSession = Depends(get_session),
|
|
current_user: UserModel = Depends(current_active_user),
|
|
):
|
|
"""Retrieves all time entries for a specific chore assignment."""
|
|
assignment = await crud_chore.get_chore_assignment_by_id(db, assignment_id)
|
|
if not assignment:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Assignment not found")
|
|
|
|
chore = await crud_chore.get_chore_by_id(db, assignment.chore_id)
|
|
if not chore:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chore not found")
|
|
|
|
# Permission check
|
|
if chore.type == ChoreTypeEnum.personal and chore.created_by_id != current_user.id:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied")
|
|
|
|
if chore.type == ChoreTypeEnum.group:
|
|
is_member = await crud_chore.is_user_member(db, chore.group_id, current_user.id)
|
|
if not is_member:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied")
|
|
|
|
# For now, return time entries for the current user only
|
|
time_entries = await db.execute(
|
|
select(TimeEntry)
|
|
.where(TimeEntry.chore_assignment_id == assignment_id)
|
|
.where(TimeEntry.user_id == current_user.id)
|
|
.order_by(TimeEntry.start_time.desc())
|
|
)
|
|
return time_entries.scalars().all()
|
|
|
|
@router.post(
|
|
"/assignments/{assignment_id}/time-entries",
|
|
response_model=TimeEntryPublic,
|
|
status_code=status.HTTP_201_CREATED,
|
|
summary="Start Time Entry",
|
|
tags=["Time Tracking"]
|
|
)
|
|
async def start_time_entry(
|
|
assignment_id: int,
|
|
db: AsyncSession = Depends(get_transactional_session),
|
|
current_user: UserModel = Depends(current_active_user),
|
|
):
|
|
"""Starts a new time entry for a chore assignment."""
|
|
assignment = await crud_chore.get_chore_assignment_by_id(db, assignment_id)
|
|
if not assignment:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Assignment not found")
|
|
|
|
chore = await crud_chore.get_chore_by_id(db, assignment.chore_id)
|
|
if not chore:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chore not found")
|
|
|
|
# Permission check - only assigned user can track time
|
|
if assignment.assigned_to_user_id != current_user.id:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only assigned user can track time")
|
|
|
|
# Check if there's already an active time entry
|
|
existing_active = await db.execute(
|
|
select(TimeEntry)
|
|
.where(TimeEntry.chore_assignment_id == assignment_id)
|
|
.where(TimeEntry.user_id == current_user.id)
|
|
.where(TimeEntry.end_time.is_(None))
|
|
)
|
|
if existing_active.scalar_one_or_none():
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Time entry already active")
|
|
|
|
# Create new time entry
|
|
time_entry = TimeEntry(
|
|
chore_assignment_id=assignment_id,
|
|
user_id=current_user.id,
|
|
start_time=datetime.now(timezone.utc)
|
|
)
|
|
db.add(time_entry)
|
|
await db.commit()
|
|
await db.refresh(time_entry)
|
|
|
|
return time_entry
|
|
|
|
@router.put(
|
|
"/time-entries/{time_entry_id}",
|
|
response_model=TimeEntryPublic,
|
|
summary="Stop Time Entry",
|
|
tags=["Time Tracking"]
|
|
)
|
|
async def stop_time_entry(
|
|
time_entry_id: int,
|
|
db: AsyncSession = Depends(get_transactional_session),
|
|
current_user: UserModel = Depends(current_active_user),
|
|
):
|
|
"""Stops an active time entry."""
|
|
time_entry = await db.get(TimeEntry, time_entry_id)
|
|
if not time_entry:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Time entry not found")
|
|
|
|
if time_entry.user_id != current_user.id:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied")
|
|
|
|
if time_entry.end_time:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Time entry already stopped")
|
|
|
|
# Stop the time entry
|
|
end_time = datetime.now(timezone.utc)
|
|
time_entry.end_time = end_time
|
|
time_entry.duration_seconds = int((end_time - time_entry.start_time).total_seconds())
|
|
|
|
await db.commit()
|
|
await db.refresh(time_entry)
|
|
|
|
return time_entry |