from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from sqlalchemy.orm import selectinload from typing import List, Optional import logging from datetime import date from app.models import Chore, Group, User, ChoreFrequencyEnum from app.schemas.chore import ChoreCreate, ChoreUpdate from app.core.chore_utils import calculate_next_due_date from app.crud.group import get_group_by_id, is_user_member # For permission checks from app.core.exceptions import ChoreNotFoundError, GroupNotFoundError, PermissionDeniedError, DatabaseIntegrityError logger = logging.getLogger(__name__) async def create_chore( db: AsyncSession, chore_in: ChoreCreate, user_id: int, group_id: int ) -> Chore: """Creates a new chore within a specific group.""" # Validate group existence and user membership (basic check) group = await get_group_by_id(db, group_id) if not group: raise GroupNotFoundError(group_id) if not await is_user_member(db, group_id, user_id): raise PermissionDeniedError(detail=f"User {user_id} not a member of group {group_id}") # Calculate initial next_due_date using the utility function # chore_in.next_due_date is the user-provided *initial* due date for the chore. # For recurring chores, this might also be the day it effectively starts. initial_due_date = chore_in.next_due_date # If it's a recurring chore and last_completed_at is not set (which it won't be on creation), # calculate_next_due_date will use current_due_date (which is initial_due_date here) # to project the *first actual* due date if the initial_due_date itself is in the past. # However, for creation, we typically trust the user-provided 'next_due_date' as the first one. # The utility function's logic to advance past dates is more for when a chore is *completed*. # So, for creation, the `next_due_date` from input is taken as is. # If a stricter rule (e.g., must be in future) is needed, it can be added here. db_chore = Chore( **chore_in.model_dump(exclude_unset=True), # Use model_dump for Pydantic v2 # Ensure next_due_date from chore_in is used directly for creation # The chore_in schema should already have next_due_date group_id=group_id, created_by_id=user_id, # last_completed_at is None by default ) # Specific check for custom frequency if chore_in.frequency == ChoreFrequencyEnum.custom and chore_in.custom_interval_days is None: raise ValueError("custom_interval_days must be set for custom frequency chores.") db.add(db_chore) try: await db.commit() await db.refresh(db_chore) # Eager load relationships for the returned object result = await db.execute( select(Chore) .where(Chore.id == db_chore.id) .options(selectinload(Chore.creator), selectinload(Chore.group)) ) return result.scalar_one() except Exception as e: # Catch generic exception for now, refine later await db.rollback() logger.error(f"Error creating chore: {e}", exc_info=True) raise DatabaseIntegrityError(f"Could not create chore. Error: {str(e)}") async def get_chore_by_id( db: AsyncSession, chore_id: int, ) -> Optional[Chore]: """Gets a chore by its ID with creator and group info.""" result = await db.execute( select(Chore) .where(Chore.id == chore_id) .options(selectinload(Chore.creator), selectinload(Chore.group)) ) return result.scalar_one_or_none() async def get_chore_by_id_and_group( db: AsyncSession, chore_id: int, group_id: int, user_id: int # For permission check ) -> Optional[Chore]: """Gets a specific chore by ID, ensuring it belongs to the group and user is a member.""" if not await is_user_member(db, group_id, user_id): raise PermissionDeniedError(detail=f"User {user_id} not a member of group {group_id}") chore = await get_chore_by_id(db, chore_id) if chore and chore.group_id == group_id: return chore return None async def get_chores_by_group_id( db: AsyncSession, group_id: int, user_id: int # For permission check ) -> List[Chore]: """Gets all chores for a specific group, if the user is a member.""" if not await is_user_member(db, group_id, user_id): raise PermissionDeniedError(detail=f"User {user_id} not a member of group {group_id}") result = await db.execute( select(Chore) .where(Chore.group_id == group_id) .options(selectinload(Chore.creator), selectinload(Chore.assignments)) .order_by(Chore.next_due_date, Chore.name) ) return result.scalars().all() async def update_chore( db: AsyncSession, chore_id: int, chore_in: ChoreUpdate, group_id: int, user_id: int ) -> Optional[Chore]: """Updates a chore's details.""" db_chore = await get_chore_by_id_and_group(db, chore_id, group_id, user_id) if not db_chore: # get_chore_by_id_and_group already raises PermissionDeniedError if not member # If it returns None here, it means chore not found in that group raise ChoreNotFoundError(chore_id, group_id) update_data = chore_in.model_dump(exclude_unset=True) # Recalculate next_due_date if frequency or custom_interval_days changes # Or if next_due_date is explicitly being set and is different from current one recalculate = False if 'frequency' in update_data and update_data['frequency'] != db_chore.frequency: recalculate = True if 'custom_interval_days' in update_data and update_data['custom_interval_days'] != db_chore.custom_interval_days: recalculate = True # If next_due_date is provided in update_data, it means a manual override of the due date. # In this case, we usually don't need to run the full calculation logic unless other frequency params also change. # If 'next_due_date' is the *only* thing changing, we honor it. # If frequency changes, then 'next_due_date' (if provided) acts as the new 'current_due_date' for calculation. current_next_due_date_for_calc = db_chore.next_due_date if 'next_due_date' in update_data: current_next_due_date_for_calc = update_data['next_due_date'] # If only next_due_date is changing, no need to recalculate based on frequency, just apply it. if not ('frequency' in update_data or 'custom_interval_days' in update_data): recalculate = False # User is manually setting the date for field, value in update_data.items(): setattr(db_chore, field, value) if recalculate: # Use the potentially updated chore attributes for calculation db_chore.next_due_date = calculate_next_due_date( current_due_date=current_next_due_date_for_calc, # Use the new due date from input if provided, else old frequency=db_chore.frequency, custom_interval_days=db_chore.custom_interval_days, last_completed_date=db_chore.last_completed_at # This helps if frequency changes after a completion ) # Specific check for custom frequency on update if db_chore.frequency == ChoreFrequencyEnum.custom and db_chore.custom_interval_days is None: raise ValueError("custom_interval_days must be set for custom frequency chores.") try: await db.commit() await db.refresh(db_chore) # Eager load relationships for the returned object result = await db.execute( select(Chore) .where(Chore.id == db_chore.id) .options(selectinload(Chore.creator), selectinload(Chore.group)) ) return result.scalar_one() except Exception as e: await db.rollback() logger.error(f"Error updating chore {chore_id}: {e}", exc_info=True) raise DatabaseIntegrityError(f"Could not update chore {chore_id}. Error: {str(e)}") async def delete_chore( db: AsyncSession, chore_id: int, group_id: int, user_id: int ) -> bool: """Deletes a chore and its assignments, ensuring user has permission.""" db_chore = await get_chore_by_id_and_group(db, chore_id, group_id, user_id) if not db_chore: # Similar to update, permission/existence check is done by get_chore_by_id_and_group raise ChoreNotFoundError(chore_id, group_id) # Check if user is group owner or chore creator to delete (example policy) # More granular role checks can be added here or in the endpoint. # For now, let's assume being a group member (checked by get_chore_by_id_and_group) is enough # or that specific role checks (e.g. owner) would be in the API layer. # If creator_id or specific role is required: # group_user_role = await get_user_role_in_group(db, group_id, user_id) # if not (db_chore.created_by_id == user_id or group_user_role == UserRoleEnum.owner): # raise PermissionDeniedError(detail="Only chore creator or group owner can delete.") await db.delete(db_chore) # Chore model has cascade delete for assignments try: await db.commit() return True except Exception as e: await db.rollback() logger.error(f"Error deleting chore {chore_id}: {e}", exc_info=True) raise DatabaseIntegrityError(f"Could not delete chore {chore_id}. Error: {str(e)}")