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, ChoreTypeEnum 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 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: Optional[int] = None ) -> Chore: """Creates a new chore, either personal or within a specific group.""" if chore_in.type == ChoreTypeEnum.group: if not group_id: raise ValueError("group_id is required for group chores") # Validate group existence and user membership 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}") else: # personal chore if group_id: raise ValueError("group_id must be None for personal chores") db_chore = Chore( **chore_in.model_dump(exclude_unset=True), group_id=group_id, created_by_id=user_id, ) # 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) 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 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 ) -> Optional[Chore]: """Gets a specific group 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 and chore.type == ChoreTypeEnum.group: return chore return None async def get_personal_chores( db: AsyncSession, user_id: int ) -> List[Chore]: """Gets all personal chores for a user.""" result = await db.execute( select(Chore) .where( Chore.created_by_id == user_id, Chore.type == ChoreTypeEnum.personal ) .options(selectinload(Chore.creator), selectinload(Chore.assignments)) .order_by(Chore.next_due_date, Chore.name) ) return result.scalars().all() async def get_chores_by_group_id( db: AsyncSession, group_id: int, user_id: int ) -> 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, Chore.type == ChoreTypeEnum.group ) .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, user_id: int, group_id: Optional[int] = None ) -> Optional[Chore]: """Updates a chore's details.""" db_chore = await get_chore_by_id(db, chore_id) if not db_chore: raise ChoreNotFoundError(chore_id, group_id) # Check permissions if db_chore.type == ChoreTypeEnum.group: if not group_id: raise ValueError("group_id is required for group chores") 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}") if db_chore.group_id != group_id: raise ChoreNotFoundError(chore_id, group_id) else: # personal chore if group_id: raise ValueError("group_id must be None for personal chores") if db_chore.created_by_id != user_id: raise PermissionDeniedError(detail="Only the creator can update personal chores") update_data = chore_in.model_dump(exclude_unset=True) # Handle type change if 'type' in update_data: new_type = update_data['type'] if new_type == ChoreTypeEnum.group and not group_id: raise ValueError("group_id is required for group chores") if new_type == ChoreTypeEnum.personal and group_id: raise ValueError("group_id must be None for personal chores") # Recalculate next_due_date if needed 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 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 not ('frequency' in update_data or 'custom_interval_days' in update_data): recalculate = False for field, value in update_data.items(): setattr(db_chore, field, value) if recalculate: db_chore.next_due_date = calculate_next_due_date( current_due_date=current_next_due_date_for_calc, frequency=db_chore.frequency, custom_interval_days=db_chore.custom_interval_days, last_completed_date=db_chore.last_completed_at ) 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) 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, user_id: int, group_id: Optional[int] = None ) -> bool: """Deletes a chore and its assignments, ensuring user has permission.""" db_chore = await get_chore_by_id(db, chore_id) if not db_chore: raise ChoreNotFoundError(chore_id, group_id) # Check permissions if db_chore.type == ChoreTypeEnum.group: if not group_id: raise ValueError("group_id is required for group chores") 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}") if db_chore.group_id != group_id: raise ChoreNotFoundError(chore_id, group_id) else: # personal chore if group_id: raise ValueError("group_id must be None for personal chores") if db_chore.created_by_id != user_id: raise PermissionDeniedError(detail="Only the creator can delete personal chores") await db.delete(db_chore) 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)}")