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.
This commit is contained in:
parent
185e89351e
commit
16c9abb16a
@ -13,7 +13,8 @@ from alembic import context
|
|||||||
sys.path.insert(0, os.path.realpath(os.path.join(os.path.dirname(__file__), '..')))
|
sys.path.insert(0, os.path.realpath(os.path.join(os.path.dirname(__file__), '..')))
|
||||||
|
|
||||||
# Import your app's Base and settings
|
# Import your app's Base and settings
|
||||||
from app.models import Base # Import Base from your models module
|
import app.models # Ensure all models are loaded and registered to app.database.Base
|
||||||
|
from app.database import Base as DatabaseBase # Explicitly get Base from database.py
|
||||||
from app.config import settings # Import settings to get DATABASE_URL
|
from app.config import settings # Import settings to get DATABASE_URL
|
||||||
|
|
||||||
# this is the Alembic Config object, which provides
|
# this is the Alembic Config object, which provides
|
||||||
@ -36,7 +37,7 @@ if config.config_file_name is not None:
|
|||||||
# for 'autogenerate' support
|
# for 'autogenerate' support
|
||||||
# from myapp import mymodel
|
# from myapp import mymodel
|
||||||
# target_metadata = mymodel.Base.metadata
|
# target_metadata = mymodel.Base.metadata
|
||||||
target_metadata = Base.metadata
|
target_metadata = DatabaseBase.metadata # Use metadata from app.database.Base
|
||||||
|
|
||||||
# other values from the config, defined by the needs of env.py,
|
# other values from the config, defined by the needs of env.py,
|
||||||
# can be acquired:
|
# can be acquired:
|
||||||
|
@ -0,0 +1,28 @@
|
|||||||
|
"""add_chore_related_tables_and_enums
|
||||||
|
|
||||||
|
Revision ID: 149e9bd58a80
|
||||||
|
Revises: d066f78fab40
|
||||||
|
Create Date: 2025-05-21 07:52:33.382555
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '149e9bd58a80'
|
||||||
|
down_revision: Union[str, None] = 'd066f78fab40'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
pass
|
@ -0,0 +1,28 @@
|
|||||||
|
"""add_chore_tables_final_attempt
|
||||||
|
|
||||||
|
Revision ID: 5e410b2b650c
|
||||||
|
Revises: 149e9bd58a80
|
||||||
|
Create Date: 2025-05-21 07:53:12.492411
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '5e410b2b650c'
|
||||||
|
down_revision: Union[str, None] = '149e9bd58a80'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
pass
|
@ -0,0 +1,28 @@
|
|||||||
|
"""add_chores_and_chore_assignments_tables_v2
|
||||||
|
|
||||||
|
Revision ID: d066f78fab40
|
||||||
|
Revises: e4c462d43f5e
|
||||||
|
Create Date: 2025-05-21 07:51:58.734001
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'd066f78fab40'
|
||||||
|
down_revision: Union[str, None] = 'e4c462d43f5e'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
pass
|
@ -0,0 +1,28 @@
|
|||||||
|
"""add_chores_and_chore_assignments_tables
|
||||||
|
|
||||||
|
Revision ID: e4c462d43f5e
|
||||||
|
Revises: 8efbdc779a76
|
||||||
|
Create Date: 2025-05-21 07:51:05.985785
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'e4c462d43f5e'
|
||||||
|
down_revision: Union[str, None] = '8efbdc779a76'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
pass
|
71
be/alembic/versions/manual_0001_add_chore_tables.py
Normal file
71
be/alembic/versions/manual_0001_add_chore_tables.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
"""manual_0001_add_chore_tables
|
||||||
|
|
||||||
|
Revision ID: manual_0001
|
||||||
|
Revises: 8efbdc779a76
|
||||||
|
Create Date: 2025-05-21 08:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'manual_0001'
|
||||||
|
down_revision: Union[str, None] = '8efbdc779a76' # Last real migration
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
# Enum definition for ChoreFrequencyEnum
|
||||||
|
chore_frequency_enum = sa.Enum('one_time', 'daily', 'weekly', 'monthly', 'custom', name='chorefrequencyenum')
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# Create chorefrequencyenum type
|
||||||
|
chore_frequency_enum.create(op.get_bind(), checkfirst=True)
|
||||||
|
|
||||||
|
# Create chores table
|
||||||
|
op.create_table('chores',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('group_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_by_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('frequency', chore_frequency_enum, nullable=False),
|
||||||
|
sa.Column('custom_interval_days', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('next_due_date', sa.Date(), nullable=False),
|
||||||
|
sa.Column('last_completed_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), onupdate=sa.text('now()'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ondelete='CASCADE'),
|
||||||
|
sa.Index(op.f('ix_chores_created_by_id'), ['created_by_id'], unique=False),
|
||||||
|
sa.Index(op.f('ix_chores_group_id'), ['group_id'], unique=False),
|
||||||
|
sa.Index(op.f('ix_chores_id'), ['id'], unique=False),
|
||||||
|
sa.Index(op.f('ix_chores_name'), ['name'], unique=False)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create chore_assignments table
|
||||||
|
op.create_table('chore_assignments',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('chore_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('assigned_to_user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('due_date', sa.Date(), nullable=False),
|
||||||
|
sa.Column('is_complete', sa.Boolean(), server_default=sa.false(), nullable=False),
|
||||||
|
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), onupdate=sa.text('now()'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.ForeignKeyConstraint(['assigned_to_user_id'], ['users.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['chore_id'], ['chores.id'], ondelete='CASCADE'),
|
||||||
|
sa.Index(op.f('ix_chore_assignments_assigned_to_user_id'), ['assigned_to_user_id'], unique=False),
|
||||||
|
sa.Index(op.f('ix_chore_assignments_chore_id'), ['chore_id'], unique=False),
|
||||||
|
sa.Index(op.f('ix_chore_assignments_id'), ['id'], unique=False)
|
||||||
|
)
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
op.drop_table('chore_assignments')
|
||||||
|
op.drop_table('chores')
|
||||||
|
chore_frequency_enum.drop(op.get_bind(), checkfirst=True)
|
79
be/app/core/chore_utils.py
Normal file
79
be/app/core/chore_utils.py
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
from datetime import date, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
from app.models import ChoreFrequencyEnum
|
||||||
|
|
||||||
|
def calculate_next_due_date(
|
||||||
|
current_due_date: date,
|
||||||
|
frequency: ChoreFrequencyEnum,
|
||||||
|
custom_interval_days: Optional[int] = None,
|
||||||
|
last_completed_date: Optional[date] = None
|
||||||
|
) -> date:
|
||||||
|
"""
|
||||||
|
Calculates the next due date for a chore.
|
||||||
|
Uses current_due_date as a base if last_completed_date is not provided.
|
||||||
|
Calculates from last_completed_date if provided.
|
||||||
|
"""
|
||||||
|
if frequency == ChoreFrequencyEnum.one_time:
|
||||||
|
if last_completed_date:
|
||||||
|
raise ValueError("Cannot calculate next due date for a completed one-time chore.")
|
||||||
|
return current_due_date
|
||||||
|
|
||||||
|
base_date = last_completed_date if last_completed_date else current_due_date
|
||||||
|
|
||||||
|
if hasattr(base_date, 'date') and callable(getattr(base_date, 'date')):
|
||||||
|
base_date = base_date.date() # type: ignore
|
||||||
|
|
||||||
|
next_due: date
|
||||||
|
|
||||||
|
if frequency == ChoreFrequencyEnum.daily:
|
||||||
|
next_due = base_date + timedelta(days=1)
|
||||||
|
elif frequency == ChoreFrequencyEnum.weekly:
|
||||||
|
next_due = base_date + timedelta(weeks=1)
|
||||||
|
elif frequency == ChoreFrequencyEnum.monthly:
|
||||||
|
month = base_date.month + 1
|
||||||
|
year = base_date.year + (month - 1) // 12
|
||||||
|
month = (month - 1) % 12 + 1
|
||||||
|
|
||||||
|
day_of_target_month_last = (date(year, month % 12 + 1, 1) - timedelta(days=1)).day if month < 12 else 31
|
||||||
|
day = min(base_date.day, day_of_target_month_last)
|
||||||
|
|
||||||
|
next_due = date(year, month, day)
|
||||||
|
elif frequency == ChoreFrequencyEnum.custom:
|
||||||
|
if not custom_interval_days or custom_interval_days <= 0:
|
||||||
|
raise ValueError("Custom frequency requires a positive custom_interval_days.")
|
||||||
|
next_due = base_date + timedelta(days=custom_interval_days)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown or unsupported chore frequency: {frequency}")
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
reference_future_date = max(today, base_date)
|
||||||
|
|
||||||
|
# This loop ensures the next_due date is always in the future relative to the reference_future_date.
|
||||||
|
while next_due <= reference_future_date:
|
||||||
|
current_base_for_recalc = next_due
|
||||||
|
|
||||||
|
if frequency == ChoreFrequencyEnum.daily:
|
||||||
|
next_due = current_base_for_recalc + timedelta(days=1)
|
||||||
|
elif frequency == ChoreFrequencyEnum.weekly:
|
||||||
|
next_due = current_base_for_recalc + timedelta(weeks=1)
|
||||||
|
elif frequency == ChoreFrequencyEnum.monthly:
|
||||||
|
month = current_base_for_recalc.month + 1
|
||||||
|
year = current_base_for_recalc.year + (month - 1) // 12
|
||||||
|
month = (month - 1) % 12 + 1
|
||||||
|
day_of_target_month_last = (date(year, month % 12 + 1, 1) - timedelta(days=1)).day if month < 12 else 31
|
||||||
|
day = min(current_base_for_recalc.day, day_of_target_month_last)
|
||||||
|
next_due = date(year, month, day)
|
||||||
|
elif frequency == ChoreFrequencyEnum.custom:
|
||||||
|
if not custom_interval_days or custom_interval_days <= 0: # Should have been validated
|
||||||
|
raise ValueError("Custom frequency requires positive interval during recalc.")
|
||||||
|
next_due = current_base_for_recalc + timedelta(days=custom_interval_days)
|
||||||
|
else: # Should not be reached
|
||||||
|
break
|
||||||
|
|
||||||
|
# Safety break: if date hasn't changed, interval is zero or logic error.
|
||||||
|
if next_due == current_base_for_recalc:
|
||||||
|
# Log error ideally, then advance by one day to prevent infinite loop.
|
||||||
|
next_due += timedelta(days=1)
|
||||||
|
break
|
||||||
|
|
||||||
|
return next_due
|
210
be/app/crud/chore.py
Normal file
210
be/app/crud/chore.py
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
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)}")
|
@ -20,7 +20,8 @@ from sqlalchemy import (
|
|||||||
text as sa_text,
|
text as sa_text,
|
||||||
Text, # <-- Add Text for description
|
Text, # <-- Add Text for description
|
||||||
Numeric, # <-- Add Numeric for price
|
Numeric, # <-- Add Numeric for price
|
||||||
CheckConstraint
|
CheckConstraint,
|
||||||
|
Date # Added Date for Chore model
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import relationship, backref
|
from sqlalchemy.orm import relationship, backref
|
||||||
|
|
||||||
@ -39,6 +40,14 @@ class SplitTypeEnum(enum.Enum):
|
|||||||
ITEM_BASED = "ITEM_BASED" # If an expense is derived directly from item prices and who added them
|
ITEM_BASED = "ITEM_BASED" # If an expense is derived directly from item prices and who added them
|
||||||
# Add more types as needed, e.g., UNPAID (for tracking debts not part of a formal expense)
|
# Add more types as needed, e.g., UNPAID (for tracking debts not part of a formal expense)
|
||||||
|
|
||||||
|
# Define ChoreFrequencyEnum
|
||||||
|
class ChoreFrequencyEnum(enum.Enum):
|
||||||
|
one_time = "one_time"
|
||||||
|
daily = "daily"
|
||||||
|
weekly = "weekly"
|
||||||
|
monthly = "monthly"
|
||||||
|
custom = "custom"
|
||||||
|
|
||||||
# --- User Model ---
|
# --- User Model ---
|
||||||
class User(Base):
|
class User(Base):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
@ -72,6 +81,11 @@ class User(Base):
|
|||||||
settlements_created = relationship("Settlement", foreign_keys="Settlement.created_by_user_id", back_populates="created_by_user", cascade="all, delete-orphan")
|
settlements_created = relationship("Settlement", foreign_keys="Settlement.created_by_user_id", back_populates="created_by_user", cascade="all, delete-orphan")
|
||||||
# --- End Relationships for Cost Splitting ---
|
# --- End Relationships for Cost Splitting ---
|
||||||
|
|
||||||
|
# --- Relationships for Chores ---
|
||||||
|
created_chores = relationship("Chore", foreign_keys="[Chore.created_by_id]", back_populates="creator")
|
||||||
|
assigned_chores = relationship("ChoreAssignment", back_populates="assigned_user", cascade="all, delete-orphan")
|
||||||
|
# --- End Relationships for Chores ---
|
||||||
|
|
||||||
|
|
||||||
# --- Group Model ---
|
# --- Group Model ---
|
||||||
class Group(Base):
|
class Group(Base):
|
||||||
@ -96,6 +110,10 @@ class Group(Base):
|
|||||||
settlements = relationship("Settlement", foreign_keys="Settlement.group_id", back_populates="group", cascade="all, delete-orphan")
|
settlements = relationship("Settlement", foreign_keys="Settlement.group_id", back_populates="group", cascade="all, delete-orphan")
|
||||||
# --- End Relationships for Cost Splitting ---
|
# --- End Relationships for Cost Splitting ---
|
||||||
|
|
||||||
|
# --- Relationship for Chores ---
|
||||||
|
chores = relationship("Chore", back_populates="group", cascade="all, delete-orphan")
|
||||||
|
# --- End Relationship for Chores ---
|
||||||
|
|
||||||
|
|
||||||
# --- UserGroup Association Model ---
|
# --- UserGroup Association Model ---
|
||||||
class UserGroup(Base):
|
class UserGroup(Base):
|
||||||
@ -268,3 +286,48 @@ class Settlement(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Potential future: PaymentMethod model, etc.
|
# Potential future: PaymentMethod model, etc.
|
||||||
|
|
||||||
|
|
||||||
|
# --- Chore Model ---
|
||||||
|
class Chore(Base):
|
||||||
|
__tablename__ = "chores"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
group_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
name = Column(String, nullable=False, index=True)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||||
|
|
||||||
|
frequency = Column(SAEnum(ChoreFrequencyEnum, name="chorefrequencyenum", create_type=True), nullable=False)
|
||||||
|
custom_interval_days = Column(Integer, nullable=True) # Only if frequency is 'custom'
|
||||||
|
|
||||||
|
next_due_date = Column(Date, nullable=False) # Changed to Date
|
||||||
|
last_completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||||
|
|
||||||
|
# --- Relationships ---
|
||||||
|
group = relationship("Group", back_populates="chores")
|
||||||
|
creator = relationship("User", back_populates="created_chores")
|
||||||
|
assignments = relationship("ChoreAssignment", back_populates="chore", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
# --- ChoreAssignment Model ---
|
||||||
|
class ChoreAssignment(Base):
|
||||||
|
__tablename__ = "chore_assignments"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
chore_id = Column(Integer, ForeignKey("chores.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
assigned_to_user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
|
||||||
|
due_date = Column(Date, nullable=False) # Specific due date for this instance, changed to Date
|
||||||
|
is_complete = Column(Boolean, default=False, nullable=False)
|
||||||
|
completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||||
|
|
||||||
|
# --- Relationships ---
|
||||||
|
chore = relationship("Chore", back_populates="assignments")
|
||||||
|
assigned_user = relationship("User", back_populates="assigned_chores")
|
||||||
|
90
be/app/schemas/chore.py
Normal file
90
be/app/schemas/chore.py
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
from pydantic import BaseModel, ConfigDict, field_validator
|
||||||
|
|
||||||
|
# Assuming ChoreFrequencyEnum is imported from models
|
||||||
|
# Adjust the import path if necessary based on your project structure.
|
||||||
|
# e.g., from app.models import ChoreFrequencyEnum
|
||||||
|
from ..models import ChoreFrequencyEnum, User as UserModel # For UserPublic relation
|
||||||
|
from .user import UserPublic # For embedding user information
|
||||||
|
|
||||||
|
# Chore Schemas
|
||||||
|
class ChoreBase(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
frequency: ChoreFrequencyEnum
|
||||||
|
custom_interval_days: Optional[int] = None
|
||||||
|
next_due_date: date # For creation, this will be the initial due date
|
||||||
|
|
||||||
|
@field_validator('custom_interval_days', mode='before')
|
||||||
|
@classmethod
|
||||||
|
def check_custom_interval_days(cls, value, values):
|
||||||
|
# Pydantic v2 uses `values.data` to get all fields
|
||||||
|
# For older Pydantic, it might just be `values`
|
||||||
|
# This is a simplified check; actual access might differ slightly
|
||||||
|
# based on Pydantic version context within the validator.
|
||||||
|
# The goal is to ensure custom_interval_days is present if frequency is 'custom'.
|
||||||
|
# This validator might be more complex in a real Pydantic v2 setup.
|
||||||
|
|
||||||
|
# A more direct way if 'frequency' is already parsed into values.data:
|
||||||
|
# freq = values.data.get('frequency')
|
||||||
|
# For this example, we'll assume 'frequency' might not be in 'values.data' yet
|
||||||
|
# if 'custom_interval_days' is validated 'before' 'frequency'.
|
||||||
|
# A truly robust validator might need to be on the whole model or run 'after'.
|
||||||
|
# For now, this is a placeholder for the logic.
|
||||||
|
# Consider if this validation is better handled at the service/CRUD layer for complex cases.
|
||||||
|
return value
|
||||||
|
|
||||||
|
class ChoreCreate(ChoreBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class ChoreUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
frequency: Optional[ChoreFrequencyEnum] = None
|
||||||
|
custom_interval_days: Optional[int] = None
|
||||||
|
next_due_date: Optional[date] = None # Allow updating next_due_date directly if needed
|
||||||
|
# last_completed_at should generally not be updated directly by user
|
||||||
|
|
||||||
|
class ChorePublic(ChoreBase):
|
||||||
|
id: int
|
||||||
|
group_id: int
|
||||||
|
created_by_id: int
|
||||||
|
last_completed_at: Optional[datetime] = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
creator: Optional[UserPublic] = None # Embed creator UserPublic schema
|
||||||
|
# group: Optional[GroupPublic] = None # Embed GroupPublic schema if needed
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
# Chore Assignment Schemas
|
||||||
|
class ChoreAssignmentBase(BaseModel):
|
||||||
|
chore_id: int
|
||||||
|
assigned_to_user_id: int
|
||||||
|
due_date: date
|
||||||
|
|
||||||
|
class ChoreAssignmentCreate(ChoreAssignmentBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class ChoreAssignmentUpdate(BaseModel):
|
||||||
|
# Only completion status and perhaps due_date can be updated for an assignment
|
||||||
|
is_complete: Optional[bool] = None
|
||||||
|
due_date: Optional[date] = None # If rescheduling an existing assignment is allowed
|
||||||
|
|
||||||
|
class ChoreAssignmentPublic(ChoreAssignmentBase):
|
||||||
|
id: int
|
||||||
|
is_complete: bool
|
||||||
|
completed_at: Optional[datetime] = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
# Embed ChorePublic and UserPublic for richer responses
|
||||||
|
chore: Optional[ChorePublic] = None
|
||||||
|
assigned_user: Optional[UserPublic] = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
# To handle potential circular imports if ChorePublic needs GroupPublic and GroupPublic needs ChorePublic
|
||||||
|
# We can update forward refs after all models are defined.
|
||||||
|
# ChorePublic.model_rebuild() # If using Pydantic v2 and forward refs were used with strings
|
||||||
|
# ChoreAssignmentPublic.model_rebuild()
|
Loading…
Reference in New Issue
Block a user