![google-labs-jules[bot]](/assets/img/avatar_default.png)
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.
211 lines
9.2 KiB
Python
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)}")
|