From 4ef202cc5adeaeeb83fdc39d8648fb759c8e643e Mon Sep 17 00:00:00 2001 From: Felipe Cardoso Date: Sun, 16 Mar 2025 14:51:04 +0100 Subject: [PATCH] Big refactor of gift categories model --- ...16_refactor_gift_categories_to_support_.py | 87 ++++ backend/app/api/routes/events/gifts.py | 441 ++++++++++++++++-- backend/app/api/routes/events/router.py | 2 + backend/app/crud/gift.py | 138 ++++-- backend/app/models/event.py | 2 +- backend/app/models/gift.py | 29 +- backend/app/schemas/gifts.py | 41 +- 7 files changed, 643 insertions(+), 97 deletions(-) create mode 100644 backend/app/alembic/versions/60c6bfef5416_refactor_gift_categories_to_support_.py diff --git a/backend/app/alembic/versions/60c6bfef5416_refactor_gift_categories_to_support_.py b/backend/app/alembic/versions/60c6bfef5416_refactor_gift_categories_to_support_.py new file mode 100644 index 0000000..7e3ca22 --- /dev/null +++ b/backend/app/alembic/versions/60c6bfef5416_refactor_gift_categories_to_support_.py @@ -0,0 +1,87 @@ +"""refactor_gift_categories_to_support_multiple_events + +Revision ID: 60c6bfef5416 +Revises: 38bf9e7e74b3 +Create Date: 2025-03-16 14:48:08.137051 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql +from sqlalchemy import text +from datetime import datetime + +# revision identifiers, used by Alembic. +revision: str = '60c6bfef5416' +down_revision: Union[str, None] = '38bf9e7e74b3' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('event_gift_categories', + sa.Column('event_id', sa.UUID(), nullable=False), + sa.Column('category_id', sa.UUID(), nullable=False), + sa.Column('display_order', sa.Integer(), nullable=True), + sa.Column('is_visible', sa.Boolean(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['category_id'], ['gift_categories.id'], ), + sa.ForeignKeyConstraint(['event_id'], ['events.id'], ), + sa.PrimaryKeyConstraint('event_id', 'category_id') + ) + + # Migrate existing data from gift_categories to event_gift_categories + # Get connection + connection = op.get_bind() + + # Get all existing gift categories + categories = connection.execute( + text("SELECT id, event_id, display_order, is_visible FROM gift_categories") + ).fetchall() + + # Insert data into the new association table + for category in categories: + category_id = category[0] + event_id = category[1] + display_order = category[2] if category[2] is not None else 0 + is_visible = category[3] if category[3] is not None else True + now = datetime.now().isoformat() + + connection.execute( + text(f""" + INSERT INTO event_gift_categories + (event_id, category_id, display_order, is_visible, created_at) + VALUES + ('{event_id}', '{category_id}', {display_order}, {is_visible}, '{now}') + """) + ) + + # Now we can safely modify the gift_categories table + op.alter_column('event_themes', 'asset_image_urls', + existing_type=postgresql.JSON(astext_type=sa.Text()), + nullable=True) + op.drop_constraint('uq_event_category_name', 'gift_categories', type_='unique') + op.create_unique_constraint(None, 'gift_categories', ['name']) + op.drop_constraint('gift_categories_event_id_fkey', 'gift_categories', type_='foreignkey') + op.drop_column('gift_categories', 'display_order') + op.drop_column('gift_categories', 'event_id') + op.drop_column('gift_categories', 'is_visible') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('gift_categories', sa.Column('is_visible', sa.BOOLEAN(), autoincrement=False, nullable=True)) + op.add_column('gift_categories', sa.Column('event_id', sa.UUID(), autoincrement=False, nullable=False)) + op.add_column('gift_categories', sa.Column('display_order', sa.INTEGER(), autoincrement=False, nullable=True)) + op.create_foreign_key('gift_categories_event_id_fkey', 'gift_categories', 'events', ['event_id'], ['id']) + op.drop_constraint(None, 'gift_categories', type_='unique') + op.create_unique_constraint('uq_event_category_name', 'gift_categories', ['event_id', 'name']) + op.alter_column('event_themes', 'asset_image_urls', + existing_type=postgresql.JSON(astext_type=sa.Text()), + nullable=False) + op.drop_table('event_gift_categories') + # ### end Alembic commands ### diff --git a/backend/app/api/routes/events/gifts.py b/backend/app/api/routes/events/gifts.py index e50d03c..64517c0 100644 --- a/backend/app/api/routes/events/gifts.py +++ b/backend/app/api/routes/events/gifts.py @@ -1,19 +1,20 @@ -from typing import List, Optional, Dict, Any +from typing import List, Optional, Dict, Any, Union from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, Path from sqlalchemy.orm import Session from app.api.dependencies.auth import get_current_active_user, get_current_user -from app.crud.gift import gift_item_crud, gift_category_crud, gift_purchase_crud +from app.crud.gift import gift_item_crud, gift_category_crud, gift_purchase_crud, event_gift_category_crud from app.crud.guest import guest_crud from app.crud.event import event_crud -from app.models.gift import GiftStatus, GiftPriority +from app.models.gift import GiftStatus, GiftPriority, EventGiftCategory, GiftItem from app.models.user import User from app.schemas.gifts import ( GiftItem, GiftItemCreate, GiftItemUpdate, GiftCategory, GiftCategoryCreate, GiftCategoryUpdate, - GiftPurchase, GiftPurchaseCreate, GiftPurchaseUpdate + GiftPurchase, GiftPurchaseCreate, GiftPurchaseUpdate, + EventGiftCategoryCreate, EventGiftCategoryUpdate, EventGiftCategoryInDB ) from app.core.database import get_db @@ -26,13 +27,14 @@ def create_gift_category( *, db: Session = Depends(get_db), category_in: GiftCategoryCreate, + event_id: UUID, current_user: User = Depends(get_current_active_user) ) -> Any: """ - Create new gift category. + Create new gift category and associate it with an event. """ # Check if user has permission to manage this event - event = event_crud.get(db, category_in.event_id) + event = event_crud.get(db, event_id) if not event: raise HTTPException(status_code=404, detail="Event not found") @@ -41,7 +43,23 @@ def create_gift_category( if event.created_by != current_user.id: raise HTTPException(status_code=403, detail="Not enough permissions") - return gift_category_crud.create(db, obj_in=category_in) + # Create the category + category = gift_category_crud.create(db, obj_in=category_in) + + # Create the association between the category and the event + association_data = EventGiftCategoryCreate( + event_id=event_id, + category_id=category.id, + display_order=0, # Default display order + is_visible=True # Default visibility + ) + event_gift_category_crud.create(db, obj_in=association_data) + + # Set display properties for the response + category.display_order = 0 + category.is_visible = True + + return category @router.get("/categories/event/{event_id}", response_model=List[GiftCategory]) @@ -67,11 +85,34 @@ def read_gift_categories( if not current_user and not event.is_public: raise HTTPException(status_code=403, detail="Not enough permissions") - # Get categories - categories = gift_category_crud.get_multi_by_event( + # Get categories for this event using the association table + categories = event_gift_category_crud.get_categories_by_event( db, event_id=event_id, skip=skip, limit=limit, include_hidden=include_hidden ) + # Enhance categories with display information from the association + for category in categories: + # Get the association to access display_order and is_visible + association = event_gift_category_crud.get( + db, event_id=event_id, category_id=category.id + ) + if association: + category.display_order = association.display_order + category.is_visible = association.is_visible + + # Calculate statistics for this event + total_gifts = 0 + available_gifts = 0 + if category.gifts: + for gift in category.gifts: + if gift.event_id == event_id: + total_gifts += 1 + if gift.status == GiftStatus.AVAILABLE and gift.is_visible: + available_gifts += 1 + + category.total_gifts = total_gifts + category.available_gifts = available_gifts + # If include_gifts is true, fetch gift items for each category if include_gifts: for category in categories: @@ -89,31 +130,79 @@ def read_gift_category( *, db: Session = Depends(get_db), category_id: UUID = Path(...), + event_id: Optional[UUID] = None, include_gifts: bool = False, current_user: Optional[User] = Depends(get_current_user) ) -> Any: """ - Get gift category by ID. + Get gift category by ID. If event_id is provided, includes event-specific display settings. """ category = gift_category_crud.get(db, id=category_id) if not category: raise HTTPException(status_code=404, detail="Gift category not found") - # Check if event is public or user is authorized - event = event_crud.get(db, category.event_id) - if not event: - raise HTTPException(status_code=404, detail="Event not found") + # Initialize event-specific properties + category.display_order = None + category.is_visible = None + category.total_gifts = None + category.available_gifts = None - if not event.is_public and not current_user: - raise HTTPException(status_code=403, detail="Not enough permissions") + # If event_id is provided, get event-specific information + if event_id: + # Check if event exists and is accessible + event = event_crud.get(db, event_id) + if not event: + raise HTTPException(status_code=404, detail="Event not found") - # If include_gifts is true, fetch gift items for the category - if include_gifts: - gifts = gift_item_crud.get_multi_by_event( - db, event_id=category.event_id, category_id=category.id, - include_hidden=current_user is not None # Only include hidden for logged-in users - ) - # Set the gifts attribute which is initially None in the model + # For public access, ensure event is public + if not event.is_public and not current_user: + raise HTTPException(status_code=403, detail="Not enough permissions") + + # Check if this category is associated with the event + association = event_gift_category_crud.get(db, event_id=event_id, category_id=category_id) + if not association: + raise HTTPException(status_code=404, detail="Category not associated with this event") + + # Set event-specific display properties + category.display_order = association.display_order + category.is_visible = association.is_visible + + # Calculate statistics for this event + total_gifts = 0 + available_gifts = 0 + + # If include_gifts is true, fetch gift items for the category in this event + if include_gifts: + gifts = gift_item_crud.get_multi_by_event( + db, event_id=event_id, category_id=category.id, + include_hidden=current_user is not None # Only include hidden for logged-in users + ) + # Set the gifts attribute + setattr(category, "gifts", gifts) + + # Calculate statistics + for gift in gifts: + total_gifts += 1 + if gift.status == GiftStatus.AVAILABLE and gift.is_visible: + available_gifts += 1 + else: + # Calculate statistics without fetching all gifts + gifts_query = db.query(GiftItem).filter( + GiftItem.event_id == event_id, + GiftItem.category_id == category_id + ) + total_gifts = gifts_query.count() + available_gifts = gifts_query.filter( + GiftItem.status == GiftStatus.AVAILABLE, + GiftItem.is_visible == True + ).count() + + category.total_gifts = total_gifts + category.available_gifts = available_gifts + elif include_gifts: + # If no event_id but include_gifts is true, just get all gifts for this category + # This is less useful without event context but included for completeness + gifts = db.query(GiftItem).filter(GiftItem.category_id == category_id).all() setattr(category, "gifts", gifts) return category @@ -125,25 +214,76 @@ def update_gift_category( db: Session = Depends(get_db), category_id: UUID = Path(...), category_in: GiftCategoryUpdate, + event_id: Optional[UUID] = None, current_user: User = Depends(get_current_active_user) ) -> Any: """ - Update a gift category. + Update a gift category. If event_id is provided, also updates the event-specific settings. """ category = gift_category_crud.get(db, id=category_id) if not category: raise HTTPException(status_code=404, detail="Gift category not found") - # Check if user has permission to manage this event - event = event_crud.get(db, category.event_id) - if not event: - raise HTTPException(status_code=404, detail="Event not found") + # Update the category itself + updated_category = gift_category_crud.update(db, db_obj=category, obj_in=category_in) - # Check permissions (basic implementation) - if event.created_by != current_user.id: - raise HTTPException(status_code=403, detail="Not enough permissions") + # Initialize event-specific properties for the response + updated_category.display_order = None + updated_category.is_visible = None + updated_category.total_gifts = None + updated_category.available_gifts = None - return gift_category_crud.update(db, db_obj=category, obj_in=category_in) + # If event_id is provided, update the event-specific settings + if event_id: + # Check if event exists + event = event_crud.get(db, event_id) + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + # Check permissions (basic implementation) + if event.created_by != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + # Check if this category is associated with the event + association = event_gift_category_crud.get(db, event_id=event_id, category_id=category_id) + if not association: + # If not associated, create the association + association_data = EventGiftCategoryCreate( + event_id=event_id, + category_id=category_id, + display_order=0, # Default display order + is_visible=True # Default visibility + ) + association = event_gift_category_crud.create(db, obj_in=association_data) + else: + # If display_order or is_visible are in the update data, update the association + association_update = {} + if hasattr(category_in, 'display_order') and category_in.display_order is not None: + association_update['display_order'] = category_in.display_order + if hasattr(category_in, 'is_visible') and category_in.is_visible is not None: + association_update['is_visible'] = category_in.is_visible + + if association_update: + association = event_gift_category_crud.update( + db, db_obj=association, obj_in=association_update + ) + + # Set event-specific properties for the response + updated_category.display_order = association.display_order + updated_category.is_visible = association.is_visible + + # Calculate statistics for this event + gifts_query = db.query(GiftItem).filter( + GiftItem.event_id == event_id, + GiftItem.category_id == category_id + ) + updated_category.total_gifts = gifts_query.count() + updated_category.available_gifts = gifts_query.filter( + GiftItem.status == GiftStatus.AVAILABLE, + GiftItem.is_visible == True + ).count() + + return updated_category @router.delete("/categories/{category_id}", response_model=GiftCategory) @@ -151,25 +291,242 @@ def delete_gift_category( *, db: Session = Depends(get_db), category_id: UUID = Path(...), + event_id: Optional[UUID] = None, + force: bool = False, current_user: User = Depends(get_current_active_user) ) -> Any: """ - Delete a gift category. + Delete a gift category. If event_id is provided, only removes the association with that event. + If force=True and no event_id is provided, deletes the category completely. """ category = gift_category_crud.get(db, id=category_id) if not category: raise HTTPException(status_code=404, detail="Gift category not found") - # Check if user has permission to manage this event - event = event_crud.get(db, category.event_id) + # Make a copy of the category for the response + category_copy = GiftCategory.model_validate(category) + + if event_id: + # Check if event exists + event = event_crud.get(db, event_id) + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + # Check permissions (basic implementation) + if event.created_by != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + # Check if this category is associated with the event + association = event_gift_category_crud.get(db, event_id=event_id, category_id=category_id) + if not association: + raise HTTPException(status_code=404, detail="Category not associated with this event") + + # Remove the association + event_gift_category_crud.remove(db, event_id=event_id, category_id=category_id) + + # Return the category with event-specific properties set to None + category_copy.display_order = None + category_copy.is_visible = None + category_copy.total_gifts = None + category_copy.available_gifts = None + + return category_copy + elif force: + # Check if the user has permission to delete the category + # This is a more restrictive operation, so we might want to add additional checks + + # Check if the category is used by any events + # Get all associations for this category + associations = db.query(EventGiftCategory).filter( + EventGiftCategory.category_id == category_id + ).all() + + if associations and len(associations) > 0: + # If there are associations, we need to check if the user has permission to manage all events + for assoc in associations: + event = event_crud.get(db, assoc.event_id) + if not event: + continue + + # If the user doesn't have permission for any of the events, deny the operation + if event.created_by != current_user.id: + raise HTTPException( + status_code=403, + detail="Not enough permissions. Category is used by events you don't manage." + ) + + # Remove all associations + for assoc in associations: + event_gift_category_crud.remove(db, event_id=assoc.event_id, category_id=category_id) + + # Now delete the category itself + return gift_category_crud.remove(db, id=category_id) + else: + # If no event_id and not force, raise an error + raise HTTPException( + status_code=400, + detail="Must provide event_id to remove association or set force=True to delete category completely" + ) + + +# ===== EVENT-CATEGORY ASSOCIATIONS ===== # + +@router.post("/events/{event_id}/categories/{category_id}", response_model=GiftCategory) +def associate_category_with_event( + *, + db: Session = Depends(get_db), + event_id: UUID = Path(...), + category_id: UUID = Path(...), + display_order: int = 0, + is_visible: bool = True, + current_user: User = Depends(get_current_active_user) +) -> Any: + """ + Associate an existing category with an event. + """ + # Check if event exists + event = event_crud.get(db, event_id) if not event: raise HTTPException(status_code=404, detail="Event not found") - # Check permissions (basic implementation) + # Check permissions if event.created_by != current_user.id: raise HTTPException(status_code=403, detail="Not enough permissions") - return gift_category_crud.remove(db, id=category_id) + # Check if category exists + category = gift_category_crud.get(db, id=category_id) + if not category: + raise HTTPException(status_code=404, detail="Gift category not found") + + # Check if association already exists + existing_association = event_gift_category_crud.get(db, event_id=event_id, category_id=category_id) + if existing_association: + raise HTTPException(status_code=400, detail="Category already associated with this event") + + # Create the association + association_data = EventGiftCategoryCreate( + event_id=event_id, + category_id=category_id, + display_order=display_order, + is_visible=is_visible + ) + event_gift_category_crud.create(db, obj_in=association_data) + + # Set display properties for the response + category.display_order = display_order + category.is_visible = is_visible + + # Calculate statistics for this event + gifts_query = db.query(GiftItem).filter( + GiftItem.event_id == event_id, + GiftItem.category_id == category_id + ) + category.total_gifts = gifts_query.count() + category.available_gifts = gifts_query.filter( + GiftItem.status == GiftStatus.AVAILABLE, + GiftItem.is_visible == True + ).count() + + return category + + +@router.put("/events/{event_id}/categories/{category_id}", response_model=GiftCategory) +def update_category_event_settings( + *, + db: Session = Depends(get_db), + event_id: UUID = Path(...), + category_id: UUID = Path(...), + display_order: Optional[int] = None, + is_visible: Optional[bool] = None, + current_user: User = Depends(get_current_active_user) +) -> Any: + """ + Update the display settings for a category in an event. + """ + # Check if event exists + event = event_crud.get(db, event_id) + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + # Check permissions + if event.created_by != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + # Check if category exists + category = gift_category_crud.get(db, id=category_id) + if not category: + raise HTTPException(status_code=404, detail="Gift category not found") + + # Check if association exists + association = event_gift_category_crud.get(db, event_id=event_id, category_id=category_id) + if not association: + raise HTTPException(status_code=404, detail="Category not associated with this event") + + # Update the association + update_data = {} + if display_order is not None: + update_data['display_order'] = display_order + if is_visible is not None: + update_data['is_visible'] = is_visible + + if update_data: + association = event_gift_category_crud.update(db, db_obj=association, obj_in=update_data) + + # Set display properties for the response + category.display_order = association.display_order + category.is_visible = association.is_visible + + # Calculate statistics for this event + gifts_query = db.query(GiftItem).filter( + GiftItem.event_id == event_id, + GiftItem.category_id == category_id + ) + category.total_gifts = gifts_query.count() + category.available_gifts = gifts_query.filter( + GiftItem.status == GiftStatus.AVAILABLE, + GiftItem.is_visible == True + ).count() + + return category + + +@router.get("/categories/{category_id}/events", response_model=List[Dict[str, Any]]) +def get_events_for_category( + *, + db: Session = Depends(get_db), + category_id: UUID = Path(...), + current_user: User = Depends(get_current_active_user) +) -> Any: + """ + Get all events that use a specific category. + """ + # Check if category exists + category = gift_category_crud.get(db, id=category_id) + if not category: + raise HTTPException(status_code=404, detail="Gift category not found") + + # Get all events for this category + events = event_gift_category_crud.get_events_by_category(db, category_id=category_id) + + # Filter events that the user has permission to see + result = [] + for event in events: + # Check if user has permission to see this event + if event.created_by == current_user.id or event.is_public: + # Get the association to access display_order and is_visible + association = event_gift_category_crud.get(db, event_id=event.id, category_id=category_id) + if association: + # Create a response with event details and association settings + event_data = { + "event_id": event.id, + "event_title": event.title, + "event_date": event.event_date, + "display_order": association.display_order, + "is_visible": association.is_visible + } + result.append(event_data) + + return result @router.put("/categories/{category_id}/reorder", response_model=GiftCategory) @@ -404,7 +761,6 @@ def reserve_gift_item( Reserve a gift item for a guest. """ gift = gift_item_crud.get(db, id=item_id) - print(f"Gift {gift}") if not gift: raise HTTPException(status_code=404, detail="Gift item not found") @@ -581,12 +937,3 @@ def read_gift_purchases_by_guest( raise HTTPException(status_code=403, detail="Not enough permissions") return gift_purchase_crud.get_by_guest(db, guest_id=guest_id) - - - # For public users, additional validation - # Check if event is public - event = event_crud.get(db, gift.event_id) - if not event or not event.is_public: - raise HTTPException(status_code=403, detail="Not enough permissions") - - return gift_item_crud.cancel_reservation(db, gift_id=item_id, guest_id=guest_id) \ No newline at end of file diff --git a/backend/app/api/routes/events/router.py b/backend/app/api/routes/events/router.py index d02c209..2009a5e 100644 --- a/backend/app/api/routes/events/router.py +++ b/backend/app/api/routes/events/router.py @@ -23,11 +23,13 @@ from app.schemas.events import ( from app.api.routes.events import guests from app.api.routes.events import rsvps +from app.api.routes.events import gifts logger = logging.getLogger(__name__) events_router = APIRouter() events_router.include_router(guests.router, prefix="/guests", tags=["guests"]) events_router.include_router(rsvps.router, prefix="/rsvps", tags=["rsvps"]) +events_router.include_router(gifts.router, prefix="/gifts", tags=["gifts"]) def validate_event_access( *, diff --git a/backend/app/crud/gift.py b/backend/app/crud/gift.py index 2c27b0b..b03945e 100644 --- a/backend/app/crud/gift.py +++ b/backend/app/crud/gift.py @@ -6,12 +6,16 @@ from sqlalchemy import asc, desc from sqlalchemy.orm import Session from app.crud.base import CRUDBase -from app.models.gift import GiftItem, GiftCategory, GiftPurchase, GiftStatus +from app.models import gift as gift_models +from app.models.gift import GiftItem, GiftCategory, GiftPurchase, GiftStatus, EventGiftCategory from app.models.guest import Guest +from app.models.event import Event +from app.schemas import gifts as gift_schemas from app.schemas.gifts import ( GiftItemCreate, GiftItemUpdate, GiftCategoryCreate, GiftCategoryUpdate, - GiftPurchaseCreate, GiftPurchaseUpdate + GiftPurchaseCreate, GiftPurchaseUpdate, + EventGiftCategoryCreate, EventGiftCategoryUpdate ) @@ -130,13 +134,110 @@ class CRUDGiftItem(CRUDBase[GiftItem, GiftItemCreate, GiftItemUpdate]): return gift +class CRUDEventGiftCategory: + def create(self, db: Session, *, obj_in: EventGiftCategoryCreate) -> EventGiftCategory: + """Create a new event-category association""" + obj_data = obj_in.model_dump(exclude_unset=True) + + # Ensure UUID fields are properly converted if they're strings + for field in ["event_id", "category_id"]: + if field in obj_data and obj_data[field] is not None: + if isinstance(obj_data[field], str): + obj_data[field] = UUID(obj_data[field]) + + db_obj = EventGiftCategory(**obj_data) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def get(self, db: Session, *, event_id: UUID, category_id: UUID) -> Optional[EventGiftCategory]: + """Get a specific event-category association""" + return db.query(EventGiftCategory).filter( + EventGiftCategory.event_id == event_id, + EventGiftCategory.category_id == category_id + ).first() + + def update( + self, db: Session, *, db_obj: EventGiftCategory, obj_in: Union[EventGiftCategoryUpdate, Dict[str, Any]] + ) -> EventGiftCategory: + """Update an event-category association""" + if isinstance(obj_in, dict): + update_data = obj_in + else: + update_data = obj_in.model_dump(exclude_unset=True) + + for field in update_data: + setattr(db_obj, field, update_data[field]) + + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def remove(self, db: Session, *, event_id: UUID, category_id: UUID) -> Optional[EventGiftCategory]: + """Remove an event-category association""" + obj = self.get(db, event_id=event_id, category_id=category_id) + if obj: + db.delete(obj) + db.commit() + return obj + + def get_categories_by_event( + self, db: Session, *, event_id: UUID, skip: int = 0, limit: int = 100, + include_hidden: bool = False + ) -> List[GiftCategory]: + """Get categories for a specific event with filtering options""" + query = db.query(GiftCategory).join( + EventGiftCategory, + GiftCategory.id == EventGiftCategory.category_id + ).filter(EventGiftCategory.event_id == event_id) + + if not include_hidden: + query = query.filter(EventGiftCategory.is_visible == True) + + # Order by display_order then name + query = query.order_by( + asc(EventGiftCategory.display_order), + asc(GiftCategory.name) + ) + + return query.offset(skip).limit(limit).all() + + def get_events_by_category( + self, db: Session, *, category_id: UUID, skip: int = 0, limit: int = 100 + ) -> List[Event]: + """Get events for a specific category""" + query = db.query(Event).join( + EventGiftCategory, + Event.id == EventGiftCategory.event_id + ).filter(EventGiftCategory.category_id == category_id) + + return query.offset(skip).limit(limit).all() + + def reorder_categories( + self, db: Session, *, event_id: UUID, category_orders: Dict[UUID, int] + ) -> List[EventGiftCategory]: + """Update display order of multiple categories for an event""" + associations = db.query(EventGiftCategory).filter( + EventGiftCategory.event_id == event_id + ).all() + + for assoc in associations: + if assoc.category_id in category_orders: + assoc.display_order = category_orders[assoc.category_id] + + db.commit() + return associations + + class CRUDGiftCategory(CRUDBase[GiftCategory, GiftCategoryCreate, GiftCategoryUpdate]): def create(self, db: Session, *, obj_in: GiftCategoryCreate) -> GiftCategory: """Create a new gift category with appropriate type conversions""" obj_data = obj_in.model_dump(exclude_unset=True) # Ensure UUID fields are properly converted if they're strings - for field in ["event_id", "created_by"]: + for field in ["created_by"]: if field in obj_data and obj_data[field] is not None: if isinstance(obj_data[field], str): obj_data[field] = UUID(obj_data[field]) @@ -147,34 +248,6 @@ class CRUDGiftCategory(CRUDBase[GiftCategory, GiftCategoryCreate, GiftCategoryUp db.refresh(db_obj) return db_obj - def get_multi_by_event( - self, db: Session, event_id: UUID, skip: int = 0, limit: int = 100, - include_hidden: bool = False - ) -> List[GiftCategory]: - """Get categories for a specific event with filtering options""" - query = db.query(self.model).filter(GiftCategory.event_id == event_id) - - if not include_hidden: - query = query.filter(GiftCategory.is_visible == True) - - # Order by display_order then name - query = query.order_by(asc(GiftCategory.display_order), asc(GiftCategory.name)) - - return query.offset(skip).limit(limit).all() - - def reorder_categories( - self, db: Session, *, event_id: UUID, category_orders: Dict[UUID, int] - ) -> List[GiftCategory]: - """Update display order of multiple categories at once""" - categories = self.get_multi_by_event(db, event_id, include_hidden=True) - - for category in categories: - if category.id in category_orders: - category.display_order = category_orders[category.id] - - db.commit() - return categories - def reorder_gifts( self, db: Session, *, category_id: UUID, gift_orders: Dict[UUID, int] ) -> GiftCategory: @@ -241,4 +314,5 @@ class CRUDGiftPurchase(CRUDBase[GiftPurchase, GiftPurchaseCreate, GiftPurchaseUp # Create CRUD instances gift_item_crud = CRUDGiftItem(GiftItem) gift_category_crud = CRUDGiftCategory(GiftCategory) -gift_purchase_crud = CRUDGiftPurchase(GiftPurchase) \ No newline at end of file +gift_purchase_crud = CRUDGiftPurchase(GiftPurchase) +event_gift_category_crud = CRUDEventGiftCategory() diff --git a/backend/app/models/event.py b/backend/app/models/event.py index e8bf681..ff9902c 100644 --- a/backend/app/models/event.py +++ b/backend/app/models/event.py @@ -49,7 +49,7 @@ class Event(Base, UUIDMixin, TimestampMixin): managers = relationship("EventManager", back_populates="event") guests = relationship("Guest", back_populates="event") gifts = relationship("GiftItem", back_populates="event") - gift_categories = relationship("GiftCategory", back_populates="event") + category_associations = relationship("EventGiftCategory", back_populates="event") media = relationship("EventMedia", back_populates="event") # updates = relationship("EventUpdate", back_populates="event") # Keep commented out rsvps = relationship("RSVP", back_populates="event") diff --git a/backend/app/models/gift.py b/backend/app/models/gift.py index ec85184..d167e4b 100644 --- a/backend/app/models/gift.py +++ b/backend/app/models/gift.py @@ -121,38 +121,47 @@ class GiftPurchase(Base, UUIDMixin): return f"" +class EventGiftCategory(Base): + __tablename__ = 'event_gift_categories' + + event_id = Column(UUID(as_uuid=True), ForeignKey('events.id'), primary_key=True) + category_id = Column(UUID(as_uuid=True), ForeignKey('gift_categories.id'), primary_key=True) + display_order = Column(Integer, default=0) + is_visible = Column(Boolean, default=True) + created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) + + # Relationships + event = relationship("Event", back_populates="category_associations") + category = relationship("GiftCategory", back_populates="event_associations") + + def __repr__(self): + return f"" + + class GiftCategory(Base, UUIDMixin, TimestampMixin): __tablename__ = 'gift_categories' # Foreign Keys - event_id = Column(UUID(as_uuid=True), ForeignKey('events.id'), nullable=False) created_by = Column(UUID(as_uuid=True), ForeignKey('users.id'), nullable=False) # Category Details - name = Column(String, nullable=False) + name = Column(String, nullable=False, unique=True) description = Column(String) # Display icon = Column(String) # Icon identifier or URL color = Column(String) # Color code (hex/rgb) - display_order = Column(Integer, default=0) - is_visible = Column(Boolean, default=True) # Additional Settings custom_fields = Column(JSONB) # Relationships - event = relationship("Event", back_populates="gift_categories") + event_associations = relationship("EventGiftCategory", back_populates="category") gifts = relationship("GiftItem", back_populates="category", order_by="GiftItem.display_order") created_by_user = relationship("User", foreign_keys=[created_by]) - # Ensure unique category names within an event - __table_args__ = ( - UniqueConstraint('event_id', 'name', name='uq_event_category_name'), - ) - def __repr__(self): return f"" diff --git a/backend/app/schemas/gifts.py b/backend/app/schemas/gifts.py index 56670d7..99e20a0 100644 --- a/backend/app/schemas/gifts.py +++ b/backend/app/schemas/gifts.py @@ -123,7 +123,6 @@ class GiftCategoryBase(BaseModel): # Schema for creating a category class GiftCategoryCreate(GiftCategoryBase): - event_id: UUID created_by: UUID @@ -133,15 +132,12 @@ class GiftCategoryUpdate(BaseModel): description: Optional[str] = None icon: Optional[str] = None color: Optional[str] = None - display_order: Optional[int] = None - is_visible: Optional[bool] = None custom_fields: Optional[Dict[str, Any]] = None # Schema for reading a category class GiftCategoryInDB(GiftCategoryBase): id: UUID - event_id: UUID created_by: UUID created_at: datetime updated_at: datetime @@ -150,11 +146,42 @@ class GiftCategoryInDB(GiftCategoryBase): from_attributes = True +# Event-Category association schemas +class EventGiftCategoryBase(BaseModel): + event_id: UUID + category_id: UUID + display_order: int = 0 + is_visible: bool = True + + +# Schema for creating an event-category association +class EventGiftCategoryCreate(EventGiftCategoryBase): + pass + + +# Schema for updating an event-category association +class EventGiftCategoryUpdate(BaseModel): + display_order: Optional[int] = None + is_visible: Optional[bool] = None + + +# Schema for reading an event-category association +class EventGiftCategoryInDB(EventGiftCategoryBase): + created_at: datetime + + class Config: + from_attributes = True + + # Public category response with statistics class GiftCategory(GiftCategoryInDB): - total_gifts: int - available_gifts: int + # These properties will be calculated for a specific event in the API + total_gifts: Optional[int] = None + available_gifts: Optional[int] = None gifts: Optional[List[GiftItem]] = None + # For display in a specific event context + display_order: Optional[int] = None + is_visible: Optional[bool] = None class Config: from_attributes = True @@ -195,4 +222,4 @@ class GiftPurchaseInDB(GiftPurchaseBase): # Public gift purchase response class GiftPurchase(GiftPurchaseInDB): class Config: - from_attributes = True \ No newline at end of file + from_attributes = True