diff --git a/backend/app/api/routes/events/gifts.py b/backend/app/api/routes/events/gifts.py index 64517c0..f6dfcbf 100644 --- a/backend/app/api/routes/events/gifts.py +++ b/backend/app/api/routes/events/gifts.py @@ -1,25 +1,25 @@ -from typing import List, Optional, Dict, Any, Union +from typing import List, Optional, Dict, Any from uuid import UUID -from fastapi import APIRouter, Depends, HTTPException, Query, Path +from fastapi import APIRouter, Depends, HTTPException, Path from sqlalchemy.orm import Session from app.api.dependencies.auth import get_current_active_user, get_current_user +from app.core.database import get_db +from app.crud.event import event_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, EventGiftCategory, GiftItem +from app.models.gift import GiftStatus, EventGiftCategory, GiftItem as GiftItemModel from app.models.user import User from app.schemas.gifts import ( GiftItem, GiftItemCreate, GiftItemUpdate, GiftCategory, GiftCategoryCreate, GiftCategoryUpdate, - GiftPurchase, GiftPurchaseCreate, GiftPurchaseUpdate, - EventGiftCategoryCreate, EventGiftCategoryUpdate, EventGiftCategoryInDB + GiftPurchase, GiftPurchaseCreate, EventGiftCategoryCreate ) -from app.core.database import get_db router = APIRouter() + # ===== GIFT CATEGORIES ===== # @router.post("/categories/", response_model=GiftCategory) @@ -51,7 +51,7 @@ def create_gift_category( event_id=event_id, category_id=category.id, display_order=0, # Default display order - is_visible=True # Default visibility + is_visible=True # Default visibility ) event_gift_category_crud.create(db, obj_in=association_data) @@ -90,37 +90,70 @@ def read_gift_categories( db, event_id=event_id, skip=skip, limit=limit, include_hidden=include_hidden ) - # Enhance categories with display information from the association + # Create a list to hold the enhanced category responses + enhanced_categories = [] + 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 ) + + # Default values + display_order = None + is_visible = None if association: - category.display_order = association.display_order - category.is_visible = association.is_visible + display_order = association.display_order + is_visible = association.is_visible # Calculate statistics for this event total_gifts = 0 available_gifts = 0 - if category.gifts: - for gift in category.gifts: + gifts_list = None + + # 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=event_id, category_id=category.id, include_hidden=include_hidden + ) + gifts_list = gifts + + # Calculate statistics + for gift in gifts: if gift.event_id == event_id: total_gifts += 1 if gift.status == GiftStatus.AVAILABLE and gift.is_visible: available_gifts += 1 + else: + # Calculate statistics without fetching all gifts + 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 + # Create a new category response with the calculated values + category_data = { + **category.__dict__, + "display_order": display_order, + "is_visible": is_visible, + "total_gifts": total_gifts, + "available_gifts": available_gifts + } - # If include_gifts is true, fetch gift items for each category - if include_gifts: - for category in categories: - gifts = gift_item_crud.get_multi_by_event( - db, event_id=event_id, category_id=category.id, include_hidden=include_hidden - ) - # Set the gifts attribute which is initially None in the model - setattr(category, "gifts", gifts) + if gifts_list is not None: + category_data["gifts"] = gifts_list + + # Remove SQLAlchemy state attributes + if "_sa_instance_state" in category_data: + del category_data["_sa_instance_state"] + + enhanced_category = GiftCategory(**category_data) + enhanced_categories.append(enhanced_category) + + # Replace the original categories list with the enhanced one + categories = enhanced_categories return categories @@ -141,11 +174,12 @@ def read_gift_category( if not category: raise HTTPException(status_code=404, detail="Gift category not found") - # Initialize event-specific properties - category.display_order = None - category.is_visible = None - category.total_gifts = None - category.available_gifts = None + # Default values for event-specific properties + display_order = None + is_visible = None + total_gifts = None + available_gifts = None + gifts_list = None # If event_id is provided, get event-specific information if event_id: @@ -163,9 +197,9 @@ def read_gift_category( 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 + # Get event-specific display properties + display_order = association.display_order + is_visible = association.is_visible # Calculate statistics for this event total_gifts = 0 @@ -177,8 +211,7 @@ def read_gift_category( 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) + gifts_list = gifts # Calculate statistics for gift in gifts: @@ -187,23 +220,39 @@ def read_gift_category( 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 + gifts_query = db.query(GiftItemModel).filter( + GiftItemModel.event_id == event_id, + GiftItemModel.category_id == category_id ) total_gifts = gifts_query.count() available_gifts = gifts_query.filter( - GiftItem.status == GiftStatus.AVAILABLE, - GiftItem.is_visible == True + GiftItemModel.status == GiftStatus.AVAILABLE, + GiftItemModel.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) + gifts_list = gifts + + # Create a new category response with the calculated values + category_data = { + **category.__dict__, + "display_order": display_order, + "is_visible": is_visible, + "total_gifts": total_gifts, + "available_gifts": available_gifts + } + + if gifts_list is not None: + category_data["gifts"] = gifts_list + + # Remove SQLAlchemy state attributes + if "_sa_instance_state" in category_data: + del category_data["_sa_instance_state"] + + # Create a new category instance with the enhanced data + category = GiftCategory(**category_data) return category @@ -227,11 +276,11 @@ def update_gift_category( # Update the category itself updated_category = gift_category_crud.update(db, db_obj=category, obj_in=category_in) - # 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 + # Default values for event-specific properties + display_order = None + is_visible = None + total_gifts = None + available_gifts = None # If event_id is provided, update the event-specific settings if event_id: @@ -252,7 +301,7 @@ def update_gift_category( event_id=event_id, category_id=category_id, display_order=0, # Default display order - is_visible=True # Default visibility + is_visible=True # Default visibility ) association = event_gift_category_crud.create(db, obj_in=association_data) else: @@ -268,21 +317,37 @@ def update_gift_category( 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 + # Get event-specific properties for the response + display_order = association.display_order + 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( + total_gifts = gifts_query.count() + available_gifts = gifts_query.filter( GiftItem.status == GiftStatus.AVAILABLE, GiftItem.is_visible == True ).count() + # Create a new category response with the calculated values + category_data = { + **updated_category.__dict__, + "display_order": display_order, + "is_visible": is_visible, + "total_gifts": total_gifts, + "available_gifts": available_gifts + } + + # Remove SQLAlchemy state attributes + if "_sa_instance_state" in category_data: + del category_data["_sa_instance_state"] + + # Create a new category instance with the enhanced data + updated_category = GiftCategory(**category_data) + return updated_category @@ -303,8 +368,12 @@ def delete_gift_category( if not category: raise HTTPException(status_code=404, detail="Gift category not found") - # Make a copy of the category for the response - category_copy = GiftCategory.model_validate(category) + # Default values for event-specific properties + display_order = None + is_visible = None + total_gifts = None + available_gifts = None + gifts_list = None if event_id: # Check if event exists @@ -324,13 +393,23 @@ def delete_gift_category( # 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 + # Create a new category response with the calculated values + category_data = { + **category.__dict__, + "display_order": display_order, + "is_visible": is_visible, + "total_gifts": total_gifts, + "available_gifts": available_gifts + } - return category_copy + # Remove SQLAlchemy state attributes + if "_sa_instance_state" in category_data: + del category_data["_sa_instance_state"] + + # Create a new category instance with the enhanced data + category_response = GiftCategory(**category_data) + + return category_response 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 @@ -351,7 +430,7 @@ def delete_gift_category( # 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, + status_code=403, detail="Not enough permissions. Category is used by events you don't manage." ) @@ -360,11 +439,29 @@ def delete_gift_category( 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) + deleted_category = gift_category_crud.remove(db, id=category_id) + + # Create a new category response with the calculated values + category_data = { + **deleted_category.__dict__, + "display_order": display_order, + "is_visible": is_visible, + "total_gifts": total_gifts, + "available_gifts": available_gifts + } + + # Remove SQLAlchemy state attributes + if "_sa_instance_state" in category_data: + del category_data["_sa_instance_state"] + + # Create a new category instance with the enhanced data + category_response = GiftCategory(**category_data) + + return category_response else: # If no event_id and not force, raise an error raise HTTPException( - status_code=400, + status_code=400, detail="Must provide event_id to remove association or set force=True to delete category completely" ) @@ -583,8 +680,9 @@ def create_gift_item( if not category: raise HTTPException(status_code=404, detail="Gift category not found") - # Check category belongs to the same event - if category.event_id != item_in.event_id: + # Check category belongs to the same event by checking the association + association = event_gift_category_crud.get(db, event_id=item_in.event_id, category_id=item_in.category_id) + if not association: raise HTTPException(status_code=400, detail="Category does not belong to this event") return gift_item_crud.create(db, obj_in=item_in) @@ -619,8 +717,9 @@ def read_gift_items( if not category: raise HTTPException(status_code=404, detail="Gift category not found") - # Check category belongs to the requested event - if category.event_id != event_id: + # Check category belongs to the requested event by checking the association + association = event_gift_category_crud.get(db, event_id=event_id, category_id=category_id) + if not association: raise HTTPException(status_code=400, detail="Category does not belong to this event") return gift_item_crud.get_multi_by_event( @@ -688,8 +787,9 @@ def update_gift_item( if not category: raise HTTPException(status_code=404, detail="Gift category not found") - # Check category belongs to the same event - if category.event_id != gift.event_id: + # Check category belongs to the same event by checking the association + association = event_gift_category_crud.get(db, event_id=gift.event_id, category_id=item_in.category_id) + if not association: raise HTTPException(status_code=400, detail="Category does not belong to this event") return gift_item_crud.update(db, db_obj=gift, obj_in=item_in) diff --git a/backend/tests/api/routes/events/test_gifts.py b/backend/tests/api/routes/events/test_gifts.py index 29e415e..8364ef4 100644 --- a/backend/tests/api/routes/events/test_gifts.py +++ b/backend/tests/api/routes/events/test_gifts.py @@ -27,7 +27,6 @@ def gift_category_data(mock_event, mock_user): "description": "Animal-themed toys for Emma", "icon": "toy-icon", "color": "#FF5733", - "event_id": str(mock_event.id), "created_by": str(mock_user.id) } @@ -130,13 +129,21 @@ class TestGiftsRouter: assert any(gift["event_id"] == str(mock_event.id) for gift in response.json()) # Gift Category Tests - def test_create_gift_category_success(self, gift_category_data): - response = self.client.post(f"{self.endpoint}/categories/", json=gift_category_data) + def test_create_gift_category_success(self, gift_category_data, mock_event): + # Need to provide event_id as a query parameter + response = self.client.post( + f"{self.endpoint}/categories/", + json=gift_category_data, + params={"event_id": str(mock_event.id)} + ) assert response.status_code == 200 data = response.json() assert data["name"] == gift_category_data["name"] assert data["icon"] == gift_category_data["icon"] assert data["color"] == gift_category_data["color"] + # Check that display_order and is_visible are set with default values + assert data["display_order"] == 0 + assert data["is_visible"] == True def test_read_gift_category_success(self): response = self.client.get(f"{self.endpoint}/categories/{self.gift_category.id}") @@ -174,21 +181,33 @@ class TestGiftsRouter: assert response.json()["color"] == "#00FF00" def test_delete_gift_category_success(self): - response = self.client.delete(f"{self.endpoint}/categories/{self.gift_category.id}") + response = self.client.delete( + f"{self.endpoint}/categories/{self.gift_category.id}", + params={"force": True} + ) assert response.status_code == 200 assert response.json()["id"] == str(self.gift_category.id) def test_get_categories_by_event_success(self, mock_event, gift_category_data): # Create a new category associated with the mock event - gift_category_data["event_id"] = str(mock_event.id) - self.client.post(f"{self.endpoint}/categories/", json=gift_category_data) + self.client.post( + f"{self.endpoint}/categories/", + json=gift_category_data, + params={"event_id": str(mock_event.id)} + ) # Get categories for the event response = self.client.get(f"{self.endpoint}/categories/event/{mock_event.id}") assert response.status_code == 200 assert isinstance(response.json(), list) assert len(response.json()) >= 1 - assert any(cat["event_id"] == str(mock_event.id) for cat in response.json()) + + # Check that at least one category has display_order and is_visible set + # These properties come from the EventGiftCategory association + assert any( + cat.get("display_order") is not None and cat.get("is_visible") is not None + for cat in response.json() + ) # Gift Purchase Tests def test_create_gift_purchase_success(self, gift_purchase_data): @@ -268,4 +287,4 @@ class TestGiftsRouter: params=reservation_data ) assert response.status_code == 400 - assert response.json()["detail"] == "Gift is not available for reservation" \ No newline at end of file + assert response.json()["detail"] == "Gift is not available for reservation" diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index e23744f..3a70159 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -25,7 +25,7 @@ pytest_plugins = ["pytest_asyncio"] def db_session(): """ Creates a fresh SQLite in-memory database for each test function. - + Yields a SQLAlchemy session that can be used for testing. """ # Set up the database @@ -340,17 +340,27 @@ def gift_category_fixture(db_session, mock_user, mock_event): """ Fixture to create and return a GiftCategory instance. """ + # Create the category without event_id and display_order gift_category = GiftCategory( id=uuid.uuid4(), name="Electronics", description="Category for electronic gifts", - event_id=mock_event.id, - created_by=mock_user.id, - display_order=0, - is_visible=True + created_by=mock_user.id ) db_session.add(gift_category) db_session.commit() + + # Create the association between the category and the event + from app.models.gift import EventGiftCategory + event_gift_category = EventGiftCategory( + event_id=mock_event.id, + category_id=gift_category.id, + display_order=0, + is_visible=True + ) + db_session.add(event_gift_category) + db_session.commit() + return gift_category