mitlist/be/app/crud/chore.py
google-labs-jules[bot] 16c9abb16a feat: Initial backend setup for Chore Management (Models, Migrations, Schemas, Chore CRUD)
I've implemented the foundational backend components for the chore management feature.

Key changes include:
- Definition of `Chore` and `ChoreAssignment` SQLAlchemy models in `be/app/models.py`.
- Addition of corresponding relationships to `User` and `Group` models.
- Creation of an Alembic migration script (`manual_0001_add_chore_tables.py`) for the new database tables. (Note: Migration not applied in sandbox).
- Implementation of a utility function `calculate_next_due_date` in `be/app/core/chore_utils.py` for determining chore due dates based on recurrence rules.
- Definition of Pydantic schemas (`ChoreCreate`, `ChorePublic`, `ChoreAssignmentCreate`, `ChoreAssignmentPublic`, etc.) in `be/app/schemas/chore.py` for API data validation.
- Implementation of CRUD operations (create, read, update, delete) for Chores in `be/app/crud/chore.py`.

This commit lays the groundwork for adding Chore Assignment CRUD operations and the API endpoints for both chores and their assignments.
2025-05-21 09:28:38 +00:00

211 lines
9.2 KiB
Python

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)}")