Merge pull request #1 from whtvrboo/feat/chore-management-backend-core
feat: Initial backend setup for Chore Management (Models, Migrations,…
This commit is contained in:
commit
04b0ad7059
@ -13,7 +13,8 @@ from alembic import context
|
||||
sys.path.insert(0, os.path.realpath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
|
||||
# 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
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
@ -36,7 +37,7 @@ if config.config_file_name is not None:
|
||||
# for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
# 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,
|
||||
# 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, # <-- Add Text for description
|
||||
Numeric, # <-- Add Numeric for price
|
||||
CheckConstraint
|
||||
CheckConstraint,
|
||||
Date # Added Date for Chore model
|
||||
)
|
||||
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
|
||||
# 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 ---
|
||||
class User(Base):
|
||||
__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")
|
||||
# --- 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 ---
|
||||
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")
|
||||
# --- 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 ---
|
||||
class UserGroup(Base):
|
||||
@ -267,4 +285,49 @@ class Settlement(Base):
|
||||
CheckConstraint('paid_by_user_id != paid_to_user_id', name='chk_settlement_different_users'),
|
||||
)
|
||||
|
||||
# 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