From 81577ac7e8f80a50bcc321a031385a370379f6ca Mon Sep 17 00:00:00 2001 From: mohamad Date: Fri, 23 May 2025 21:01:37 +0200 Subject: [PATCH] feat: Add Recurrence Pattern and Update Expense Schema - Introduced a new `RecurrencePattern` model to manage recurrence details for expenses, allowing for daily, weekly, monthly, and yearly patterns. - Updated the `Expense` model to include fields for recurrence management, such as `is_recurring`, `recurrence_pattern_id`, and `next_occurrence`. - Modified the database schema to reflect these changes, including alterations to existing columns and the removal of obsolete fields. - Enhanced the expense creation logic to accommodate recurring expenses and updated related CRUD operations accordingly. - Implemented necessary migrations to ensure database integrity and support for the new features. --- .../295cb070f266_add_recurrence_pattern.py | 90 ++ be/app/api/v1/endpoints/groups.py | 13 +- be/app/core/scheduler.py | 8 +- be/app/crud/chore.py | 2 +- be/app/crud/expense.py | 2 +- be/app/crud/group.py | 29 +- be/app/db/__init__.py | 3 + be/app/db/session.py | 4 + be/app/jobs/recurring_expenses.py | 5 +- be/app/models.py | 39 + be/app/schemas/chore.py | 8 +- fe/package-lock.json | 8 + fe/package.json | 1 + fe/src/pages/ChoresPage.vue | 1077 +++++++++++++---- fe/src/pages/GroupDetailPage.vue | 160 +++ 15 files changed, 1200 insertions(+), 249 deletions(-) create mode 100644 be/alembic/versions/295cb070f266_add_recurrence_pattern.py create mode 100644 be/app/db/__init__.py create mode 100644 be/app/db/session.py diff --git a/be/alembic/versions/295cb070f266_add_recurrence_pattern.py b/be/alembic/versions/295cb070f266_add_recurrence_pattern.py new file mode 100644 index 0000000..31fb047 --- /dev/null +++ b/be/alembic/versions/295cb070f266_add_recurrence_pattern.py @@ -0,0 +1,90 @@ +"""add_recurrence_pattern + +Revision ID: 295cb070f266 +Revises: 7cc1484074eb +Create Date: 2025-05-22 19:55:24.650524 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '295cb070f266' +down_revision: Union[str, None] = '7cc1484074eb' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('expenses', 'next_occurrence', + existing_type=postgresql.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=True) + op.drop_index('ix_expenses_recurring_next_occurrence', table_name='expenses', postgresql_where='(is_recurring = true)') + op.drop_constraint('fk_expenses_recurrence_pattern_id', 'expenses', type_='foreignkey') + op.drop_constraint('fk_expenses_parent_expense_id', 'expenses', type_='foreignkey') + op.drop_column('expenses', 'recurrence_pattern_id') + op.drop_column('expenses', 'last_occurrence') + op.drop_column('expenses', 'parent_expense_id') + op.alter_column('recurrence_patterns', 'days_of_week', + existing_type=postgresql.JSON(astext_type=sa.Text()), + type_=sa.String(), + existing_nullable=True) + op.alter_column('recurrence_patterns', 'end_date', + existing_type=postgresql.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=True) + op.alter_column('recurrence_patterns', 'created_at', + existing_type=postgresql.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=False) + op.alter_column('recurrence_patterns', 'updated_at', + existing_type=postgresql.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + existing_nullable=False) + op.create_index(op.f('ix_settlement_activities_created_by_user_id'), 'settlement_activities', ['created_by_user_id'], unique=False) + op.create_index(op.f('ix_settlement_activities_expense_split_id'), 'settlement_activities', ['expense_split_id'], unique=False) + op.create_index(op.f('ix_settlement_activities_id'), 'settlement_activities', ['id'], unique=False) + op.create_index(op.f('ix_settlement_activities_paid_by_user_id'), 'settlement_activities', ['paid_by_user_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_settlement_activities_paid_by_user_id'), table_name='settlement_activities') + op.drop_index(op.f('ix_settlement_activities_id'), table_name='settlement_activities') + op.drop_index(op.f('ix_settlement_activities_expense_split_id'), table_name='settlement_activities') + op.drop_index(op.f('ix_settlement_activities_created_by_user_id'), table_name='settlement_activities') + op.alter_column('recurrence_patterns', 'updated_at', + existing_type=sa.DateTime(timezone=True), + type_=postgresql.TIMESTAMP(), + existing_nullable=False) + op.alter_column('recurrence_patterns', 'created_at', + existing_type=sa.DateTime(timezone=True), + type_=postgresql.TIMESTAMP(), + existing_nullable=False) + op.alter_column('recurrence_patterns', 'end_date', + existing_type=sa.DateTime(timezone=True), + type_=postgresql.TIMESTAMP(), + existing_nullable=True) + op.alter_column('recurrence_patterns', 'days_of_week', + existing_type=sa.String(), + type_=postgresql.JSON(astext_type=sa.Text()), + existing_nullable=True) + op.add_column('expenses', sa.Column('parent_expense_id', sa.INTEGER(), autoincrement=False, nullable=True)) + op.add_column('expenses', sa.Column('last_occurrence', postgresql.TIMESTAMP(), autoincrement=False, nullable=True)) + op.add_column('expenses', sa.Column('recurrence_pattern_id', sa.INTEGER(), autoincrement=False, nullable=True)) + op.create_foreign_key('fk_expenses_parent_expense_id', 'expenses', 'expenses', ['parent_expense_id'], ['id'], ondelete='SET NULL') + op.create_foreign_key('fk_expenses_recurrence_pattern_id', 'expenses', 'recurrence_patterns', ['recurrence_pattern_id'], ['id'], ondelete='SET NULL') + op.create_index('ix_expenses_recurring_next_occurrence', 'expenses', ['is_recurring', 'next_occurrence'], unique=False, postgresql_where='(is_recurring = true)') + op.alter_column('expenses', 'next_occurrence', + existing_type=sa.DateTime(timezone=True), + type_=postgresql.TIMESTAMP(), + existing_nullable=True) + # ### end Alembic commands ### diff --git a/be/app/api/v1/endpoints/groups.py b/be/app/api/v1/endpoints/groups.py index cbef763..185292a 100644 --- a/be/app/api/v1/endpoints/groups.py +++ b/be/app/api/v1/endpoints/groups.py @@ -172,22 +172,23 @@ async def leave_group( db: AsyncSession = Depends(get_transactional_session), current_user: UserModel = Depends(current_active_user), ): - """Removes the current user from the specified group.""" + """Removes the current user from the specified group. If the owner is the last member, the group will be deleted.""" logger.info(f"User {current_user.email} attempting to leave group {group_id}") user_role = await crud_group.get_user_role_in_group(db, group_id=group_id, user_id=current_user.id) if user_role is None: raise GroupMembershipError(group_id, "leave (you are not a member)") - # --- MVP: Prevent owner leaving if they are the last member/owner --- + # Check if owner is the last member if user_role == UserRoleEnum.owner: member_count = await crud_group.get_group_member_count(db, group_id) - # More robust check: count owners. For now, just check member count. if member_count <= 1: - logger.warning(f"Owner {current_user.email} attempted to leave group {group_id} as last member.") - raise GroupValidationError("Owner cannot leave the group as the last member. Delete the group or transfer ownership.") + # Delete the group since owner is the last member + logger.info(f"Owner {current_user.email} is the last member. Deleting group {group_id}") + await crud_group.delete_group(db, group_id) + return Message(detail="Group deleted as you were the last member") - # Proceed with removal + # Proceed with removal for non-owner or if there are other members deleted = await crud_group.remove_user_from_group(db, group_id=group_id, user_id=current_user.id) if not deleted: diff --git a/be/app/core/scheduler.py b/be/app/core/scheduler.py index 89eb9ae..9227cf2 100644 --- a/be/app/core/scheduler.py +++ b/be/app/core/scheduler.py @@ -3,16 +3,20 @@ from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore from apscheduler.executors.pool import ThreadPoolExecutor from apscheduler.triggers.cron import CronTrigger from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession -from app.core.config import settings +from app.config import settings from app.jobs.recurring_expenses import generate_recurring_expenses from app.db.session import async_session import logging logger = logging.getLogger(__name__) +# Convert async database URL to sync URL for APScheduler +# Replace postgresql+asyncpg:// with postgresql:// +sync_db_url = settings.DATABASE_URL.replace('postgresql+asyncpg://', 'postgresql://') + # Configure the scheduler jobstores = { - 'default': SQLAlchemyJobStore(url=settings.SQLALCHEMY_DATABASE_URI) + 'default': SQLAlchemyJobStore(url=sync_db_url) } executors = { diff --git a/be/app/crud/chore.py b/be/app/crud/chore.py index f6a5ea6..a7e1540 100644 --- a/be/app/crud/chore.py +++ b/be/app/crud/chore.py @@ -34,7 +34,7 @@ async def create_chore( raise ValueError("group_id must be None for personal chores") db_chore = Chore( - **chore_in.model_dump(exclude_unset=True), + **chore_in.model_dump(exclude_unset=True, exclude={'group_id'}), group_id=group_id, created_by_id=user_id, ) diff --git a/be/app/crud/expense.py b/be/app/crud/expense.py index 10f7627..5af9656 100644 --- a/be/app/crud/expense.py +++ b/be/app/crud/expense.py @@ -19,7 +19,6 @@ from app.models import ( Item as ItemModel, ExpenseOverallStatusEnum, # Added ExpenseSplitStatusEnum, # Added - RecurrencePattern, ) from app.schemas.expense import ExpenseCreate, ExpenseSplitCreate, ExpenseUpdate # Removed unused ExpenseUpdate from app.core.exceptions import ( @@ -34,6 +33,7 @@ from app.core.exceptions import ( DatabaseTransactionError,# Added ExpenseOperationError # Added specific exception ) +from app.models import RecurrencePattern # Placeholder for InvalidOperationError if not defined in app.core.exceptions # This should be a proper HTTPException subclass if used in API layer diff --git a/be/app/crud/group.py b/be/app/crud/group.py index 5df3e4a..aea3773 100644 --- a/be/app/crud/group.py +++ b/be/app/crud/group.py @@ -267,4 +267,31 @@ async def check_user_role_in_group( action=f"{action} (requires at least '{required_role.value}' role)" ) # If role is sufficient, return None - return None \ No newline at end of file + return None + +async def delete_group(db: AsyncSession, group_id: int) -> None: + """ + Deletes a group and all its associated data (members, invites, lists, etc.). + The cascade delete in the models will handle the deletion of related records. + + Raises: + GroupNotFoundError: If the group doesn't exist. + DatabaseError: If there's an error during deletion. + """ + try: + # Get the group first to ensure it exists + group = await get_group_by_id(db, group_id) + if not group: + raise GroupNotFoundError(group_id) + + # Delete the group - cascading delete will handle related records + await db.delete(group) + await db.flush() + + logger.info(f"Group {group_id} deleted successfully") + except OperationalError as e: + logger.error(f"Database connection error while deleting group {group_id}: {str(e)}", exc_info=True) + raise DatabaseConnectionError(f"Database connection error: {str(e)}") + except SQLAlchemyError as e: + logger.error(f"Unexpected SQLAlchemy error while deleting group {group_id}: {str(e)}", exc_info=True) + raise DatabaseTransactionError(f"Failed to delete group: {str(e)}") \ No newline at end of file diff --git a/be/app/db/__init__.py b/be/app/db/__init__.py new file mode 100644 index 0000000..f83198a --- /dev/null +++ b/be/app/db/__init__.py @@ -0,0 +1,3 @@ +from app.db.session import async_session + +__all__ = ["async_session"] \ No newline at end of file diff --git a/be/app/db/session.py b/be/app/db/session.py new file mode 100644 index 0000000..959b962 --- /dev/null +++ b/be/app/db/session.py @@ -0,0 +1,4 @@ +from app.database import AsyncSessionLocal + +# Export the async session factory +async_session = AsyncSessionLocal \ No newline at end of file diff --git a/be/app/jobs/recurring_expenses.py b/be/app/jobs/recurring_expenses.py index c5381d8..96f9026 100644 --- a/be/app/jobs/recurring_expenses.py +++ b/be/app/jobs/recurring_expenses.py @@ -4,7 +4,10 @@ from sqlalchemy import select, and_ from app.models import Expense, RecurrencePattern from app.crud.expense import create_expense from app.schemas.expense import ExpenseCreate -from app.core.logging import logger +import logging +from typing import Optional + +logger = logging.getLogger(__name__) async def generate_recurring_expenses(db: AsyncSession) -> None: """ diff --git a/be/app/models.py b/be/app/models.py index 182a4ad..74fbba1 100644 --- a/be/app/models.py +++ b/be/app/models.py @@ -50,6 +50,13 @@ class ExpenseOverallStatusEnum(enum.Enum): partially_paid = "partially_paid" paid = "paid" +class RecurrenceTypeEnum(enum.Enum): + DAILY = "DAILY" + WEEKLY = "WEEKLY" + MONTHLY = "MONTHLY" + YEARLY = "YEARLY" + # Add more types as needed + # Define ChoreFrequencyEnum class ChoreFrequencyEnum(enum.Enum): one_time = "one_time" @@ -245,6 +252,11 @@ class Expense(Base): item = relationship("Item", foreign_keys=[item_id], back_populates="expenses") splits = relationship("ExpenseSplit", back_populates="expense", cascade="all, delete-orphan") overall_settlement_status = Column(SAEnum(ExpenseOverallStatusEnum, name="expenseoverallstatusenum", create_type=True), nullable=False, server_default=ExpenseOverallStatusEnum.unpaid.value, default=ExpenseOverallStatusEnum.unpaid) + # --- Recurrence fields --- + is_recurring = Column(Boolean, default=False, nullable=False) + recurrence_pattern_id = Column(Integer, ForeignKey("recurrence_patterns.id"), nullable=True) + recurrence_pattern = relationship("RecurrencePattern", back_populates="expenses", uselist=False) # One-to-one + next_occurrence = Column(DateTime(timezone=True), nullable=True) # For recurring expenses __table_args__ = ( # Ensure at least one context is provided @@ -376,3 +388,30 @@ class ChoreAssignment(Base): # --- Relationships --- chore = relationship("Chore", back_populates="assignments") assigned_user = relationship("User", back_populates="assigned_chores") + + +# === NEW: RecurrencePattern Model === +class RecurrencePattern(Base): + __tablename__ = "recurrence_patterns" + + id = Column(Integer, primary_key=True, index=True) + type = Column(SAEnum(RecurrenceTypeEnum, name="recurrencetypeenum", create_type=True), nullable=False) + interval = Column(Integer, default=1, nullable=False) # e.g., every 1 day, every 2 weeks + days_of_week = Column(String, nullable=True) # For weekly recurrences, e.g., "MON,TUE,FRI" + # day_of_month = Column(Integer, nullable=True) # For monthly on a specific day + # week_of_month = Column(Integer, nullable=True) # For monthly on a specific week (e.g., 2nd week) + # month_of_year = Column(Integer, nullable=True) # For yearly recurrences + end_date = Column(DateTime(timezone=True), nullable=True) + max_occurrences = Column(Integer, 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) + + # Relationship back to Expenses that use this pattern (could be one-to-many if patterns are shared) + # However, the current CRUD implies one RecurrencePattern per Expense if recurring. + # If a pattern can be shared, this would be a one-to-many (RecurrencePattern to many Expenses). + # For now, assuming one-to-one as implied by current Expense.recurrence_pattern relationship setup. + expenses = relationship("Expense", back_populates="recurrence_pattern") + + +# === END: RecurrencePattern Model === diff --git a/be/app/schemas/chore.py b/be/app/schemas/chore.py index da316fa..7ba70f1 100644 --- a/be/app/schemas/chore.py +++ b/be/app/schemas/chore.py @@ -42,9 +42,9 @@ class ChoreCreate(ChoreBase): @field_validator('group_id') @classmethod def validate_group_id(cls, v, values): - if values.get('type') == ChoreTypeEnum.group and v is None: + if values.data.get('type') == ChoreTypeEnum.group and v is None: raise ValueError("group_id is required for group chores") - if values.get('type') == ChoreTypeEnum.personal and v is not None: + if values.data.get('type') == ChoreTypeEnum.personal and v is not None: raise ValueError("group_id must be None for personal chores") return v @@ -61,9 +61,9 @@ class ChoreUpdate(BaseModel): @field_validator('group_id') @classmethod def validate_group_id(cls, v, values): - if values.get('type') == ChoreTypeEnum.group and v is None: + if values.data.get('type') == ChoreTypeEnum.group and v is None: raise ValueError("group_id is required for group chores") - if values.get('type') == ChoreTypeEnum.personal and v is not None: + if values.data.get('type') == ChoreTypeEnum.personal and v is not None: raise ValueError("group_id must be None for personal chores") return v diff --git a/fe/package-lock.json b/fe/package-lock.json index 7f9a258..13133d2 100644 --- a/fe/package-lock.json +++ b/fe/package-lock.json @@ -25,6 +25,7 @@ "@intlify/unplugin-vue-i18n": "^6.0.8", "@playwright/test": "^1.51.1", "@tsconfig/node22": "^22.0.1", + "@types/date-fns": "^2.5.3", "@types/jsdom": "^21.1.7", "@types/node": "^22.15.17", "@vitejs/plugin-vue": "^5.2.3", @@ -4124,6 +4125,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/date-fns": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/@types/date-fns/-/date-fns-2.5.3.tgz", + "integrity": "sha512-4KVPD3g5RjSgZtdOjvI/TDFkLNUHhdoWxmierdQbDeEg17Rov0hbBYtIzNaQA67ORpteOhvR9YEMTb6xeDCang==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", diff --git a/fe/package.json b/fe/package.json index 272c4ed..0d9febe 100644 --- a/fe/package.json +++ b/fe/package.json @@ -34,6 +34,7 @@ "@intlify/unplugin-vue-i18n": "^6.0.8", "@playwright/test": "^1.51.1", "@tsconfig/node22": "^22.0.1", + "@types/date-fns": "^2.5.3", "@types/jsdom": "^21.1.7", "@types/node": "^22.15.17", "@vitejs/plugin-vue": "^5.2.3", diff --git a/fe/src/pages/ChoresPage.vue b/fe/src/pages/ChoresPage.vue index fd26ee1..5b5ace0 100644 --- a/fe/src/pages/ChoresPage.vue +++ b/fe/src/pages/ChoresPage.vue @@ -1,118 +1,301 @@