diff --git a/app/modules/ticketing/__init__.py b/app/modules/ticketing/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/modules/ticketing/cache_ticketing.py b/app/modules/ticketing/cache_ticketing.py new file mode 100644 index 0000000000..f19bf642f5 --- /dev/null +++ b/app/modules/ticketing/cache_ticketing.py @@ -0,0 +1,214 @@ +# Redis Cache for Ticketing Module + +import logging +from collections.abc import Awaitable, Callable, Coroutine +from typing import Any, ParamSpec, TypeVar +from uuid import UUID + +from pydantic import BaseModel +from redis import Redis +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.ticketing import cruds_ticketing + +hyperion_error_logger = logging.getLogger("hyperion.error") + +SchemaT = TypeVar("SchemaT", bound=BaseModel) +CrudFuncT = ParamSpec("CrudFuncT") + + +class RedisKeysList: + """List of Redis keys used in the ticketing module.""" + + @staticmethod + def event_remaining_quota(event_id: UUID) -> str: + return f"ticketing:event:{event_id}:quota" + + @staticmethod + def category_remaining_quota(category_id: UUID) -> str: + return f"ticketing:category:{category_id}:quota" + + @staticmethod + def session_remaining_quota(session_id: UUID) -> str: + return f"ticketing:session:{session_id}:quota" + + @staticmethod + def events() -> str: + return "ticketing:events" + + @staticmethod + def event(event_id: UUID) -> str: + return f"ticketing:event:{event_id}" + + # @staticmethod + # def categories(event_id: UUID) -> str: + # return f"ticketing:event:{event_id}:categories" + + # @staticmethod + # def category(category_id: UUID) -> str: + # return f"ticketing:category:{category_id}" + + # @staticmethod + # def sessions(category_id: UUID) -> str: + # return f"ticketing:category:{category_id}:sessions" + + # @staticmethod + # def session(session_id: UUID) -> str: + # return f"ticketing:session:{session_id}" + + +async def use_or_set_cache_with_crud( + redis: Redis | None, + key: str, + crud_func: Callable[CrudFuncT, Awaitable[SchemaT]], + schema_class: type[SchemaT], + expire: int | None = 300, + *args: CrudFuncT.args, + **kwargs: CrudFuncT.kwargs, +) -> SchemaT: + """Use cache if available, otherwise call the database function.""" + # If redis is not available, call the crud directly + if redis is None or not isinstance(redis, Redis): + return await crud_func(*args, **kwargs) + cached_value: str | bytes | None = redis.get(key) + if cached_value is not None: + try: + return schema_class.model_validate_json(cached_value) + except Exception: + # If cache is corrupted, delete it and call the crud function + hyperion_error_logger.exception( + "Error parsing cache for key %s, deleting it. Value: %r", + key, + cached_value, + ) + redis.delete(key) + + value = await crud_func(*args, **kwargs) + redis.set(key, value.model_dump_json(), ex=expire) + return value + + +async def use_or_set_cache_with_crud_int( + redis: Redis | None, + key: str, + crud_func: Callable[CrudFuncT, Coroutine[Any, Any, int | None]], + expire: int | None = 300, + *args: CrudFuncT.args, + **kwargs: CrudFuncT.kwargs, +) -> int | None: + """Use cache if available, otherwise call the database function.""" + # If redis is not available, call the crud directly + if redis is None or not isinstance(redis, Redis): + return await crud_func(*args, **kwargs) + cached_value: str | bytes | None = redis.get(key) + if cached_value is not None: + try: + return int(cached_value) + except Exception: + # If cache is corrupted, delete it and call the crud function + hyperion_error_logger.exception( + "Error parsing cache for key %s, deleting it. Value: %r", + key, + cached_value, + ) + redis.delete(key) + + value = await crud_func(*args, **kwargs) + redis.set(key, str(value), ex=expire) + return value + + +def increment_key_cache(redis: Redis, key: str, amount: int = 1): + """Increment a Redis key by a given amount.""" + if redis is not None and isinstance(redis, Redis): + redis.incrby(key, amount) + + +def invalidate_key_cache(redis: Redis | None, key: str): + """Invalidate a Redis cache key.""" + if redis is not None and isinstance(redis, Redis): + redis.delete(key) + + +def update_cache_for_new_ticket( + redis: Redis | None, + event_id: UUID, + category_id: UUID, + session_id: UUID | None, + amount: int = 1, # Increase the used quota by this amount (default is 1 for a single ticket) +): + """Update the cache for a new ticket.""" + if redis is not None and isinstance(redis, Redis): + # Increment the used quota for the event, category, and session + increment_key_cache( + redis, + RedisKeysList.event_remaining_quota(event_id), + -amount, + ) + increment_key_cache( + redis, + RedisKeysList.category_remaining_quota(category_id), + -amount, + ) + if session_id is not None: + increment_key_cache( + redis, + RedisKeysList.session_remaining_quota(session_id), + -amount, + ) + # Invalidate the cache for the event, category, and session to ensure consistency + # invalidate_key_cache(redis, RedisKeysList.events()) + # invalidate_key_cache(redis, RedisKeysList.event(event_id)) + # invalidate_key_cache(redis, RedisKeysList.categories(event_id)) + # invalidate_key_cache(redis, RedisKeysList.category(category_id)) + # invalidate_key_cache(redis, RedisKeysList.sessions(category_id)) + # if session_id is not None: + # invalidate_key_cache(redis, RedisKeysList.session(session_id)) + + +async def get_event_remaining_quota_with_cache( + redis: Redis | None, + db: AsyncSession, + event_id: UUID, +) -> int | None: + """Get the remaining quota for an event.""" + return await use_or_set_cache_with_crud_int( + redis=redis, + key=RedisKeysList.event_remaining_quota(event_id), + crud_func=cruds_ticketing.get_event_remaining_quota, + expire=6 * 3_600, + db=db, + event_id=event_id, + ) + + +async def get_session_remaining_quota_with_cache( + redis: Redis | None, + db: AsyncSession, + session_id: UUID, +) -> int | None: + """Get the remaining quota for a session.""" + return await use_or_set_cache_with_crud_int( + redis=redis, + key=RedisKeysList.session_remaining_quota(session_id), + crud_func=cruds_ticketing.get_session_remaining_quota, + expire=6 * 3_600, + db=db, + session_id=session_id, + ) + + +async def get_category_remaining_quota_with_cache( + redis: Redis | None, + db: AsyncSession, + category_id: UUID, +) -> int | None: + """Get the remaining quota for a category.""" + return await use_or_set_cache_with_crud_int( + redis=redis, + key=RedisKeysList.category_remaining_quota(category_id), + crud_func=cruds_ticketing.get_category_remaining_quota, + expire=6 * 3_600, + db=db, + category_id=category_id, + ) diff --git a/app/modules/ticketing/cruds_ticketing.py b/app/modules/ticketing/cruds_ticketing.py new file mode 100644 index 0000000000..dbf6588fe8 --- /dev/null +++ b/app/modules/ticketing/cruds_ticketing.py @@ -0,0 +1,1032 @@ +from uuid import UUID + +from sqlalchemy import delete, select, update +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.core.users import schemas_users +from app.modules.ticketing import models_ticketing, schemas_ticketing + + +async def get_organisers( + db: AsyncSession, +) -> list[schemas_ticketing.OrganiserComplete]: + """Get all organisers.""" + + organisers = await db.execute(select(models_ticketing.Organiser)) + return [ + schemas_ticketing.OrganiserComplete( + id=organiser.id, + name=organiser.name, + store_id=organiser.store_id, + ) + for organiser in organisers.scalars().all() + ] + + +async def get_organiser_by_id( + db: AsyncSession, + organiser_id: UUID, +) -> schemas_ticketing.OrganiserComplete | None: + """Get an organiser by its ID.""" + + organiser = ( + ( + await db.execute( + select(models_ticketing.Organiser).where( + models_ticketing.Organiser.id == organiser_id, + ), + ) + ) + .scalars() + .first() + ) + + return ( + schemas_ticketing.OrganiserComplete( + id=organiser.id, + name=organiser.name, + store_id=organiser.store_id, + ) + if organiser + else None + ) + + +async def create_organiser( + db: AsyncSession, + organiser: schemas_ticketing.OrganiserBase, +) -> None: + """Create a new organiser.""" + + db.add( + models_ticketing.Organiser(**organiser.model_dump()), + ) + await db.flush() + + +async def update_organiser( + db: AsyncSession, + organiser_id: UUID, + organiser_update: schemas_ticketing.OrganiserEdit, +) -> None: + """Update an existing organiser.""" + + await db.execute( + update(models_ticketing.Organiser) + .where(models_ticketing.Organiser.id == organiser_id) + .values(**organiser_update.model_dump(exclude_unset=True)), + ) + await db.flush() + + +async def get_events( + db: AsyncSession, +) -> list[schemas_ticketing.EventSimple]: + """Get all events.""" + + events = await db.execute(select(models_ticketing.TicketingEvent)) + return [ + schemas_ticketing.EventSimple( + id=event.id, + organiser_id=event.organiser_id, + creator_id=event.creator_id, + name=event.name, + open_date=event.open_date, + close_date=event.close_date, + quota=event.quota, + user_quota=event.user_quota, + disabled=event.disabled, + ) + for event in events.scalars().all() + ] + + +async def get_event_by_id( + db: AsyncSession, + event_id: UUID, +) -> schemas_ticketing.EventComplete | None: + """Get an event by its ID.""" + + event = ( + ( + await db.execute( + select(models_ticketing.TicketingEvent) + .where( + models_ticketing.TicketingEvent.id == event_id, + ) + .options( + selectinload(models_ticketing.TicketingEvent.sessions), + selectinload(models_ticketing.TicketingEvent.categories), + ), + ) + ) + .scalars() + .first() + ) + + return ( + schemas_ticketing.EventComplete( + id=event.id, + organiser_id=event.organiser_id, + creator_id=event.creator_id, + name=event.name, + open_date=event.open_date, + close_date=event.close_date, + quota=event.quota, + user_quota=event.user_quota, + disabled=event.disabled, + organiser=schemas_ticketing.OrganiserComplete( + id=event.organiser.id, + name=event.organiser.name, + store_id=event.organiser.store_id, + ), + sessions=[ + schemas_ticketing.SessionSimple( + id=session.id, + event_id=session.event_id, + date=session.date, + name=session.name, + quota=session.quota, + user_quota=session.user_quota, + disabled=session.disabled, + ) + for session in event.sessions + ], + categories=[ + schemas_ticketing.CategorySimple( + id=category.id, + event_id=category.event_id, + name=category.name, + required_mebership=category.required_mebership, + quota=category.quota, + user_quota=category.user_quota, + price=category.price, + disabled=category.disabled, + ) + for category in event.categories + ], + ) + if event + else None + ) + + +async def get_event_used_quota( + db: AsyncSession, + event_id: UUID, +) -> int | None: + """Get the used quota for an event.""" + + return ( + ( + await db.execute( + select(models_ticketing.TicketingEvent.used_quota).where( + models_ticketing.TicketingEvent.id == event_id, + ), + ) + ) + .scalars() + .first() + ) + + +async def get_event_remaining_quota( + db: AsyncSession, + event_id: UUID, +) -> int | None: + """Get the remaining quota for an event.""" + + return ( + ( + await db.execute( + select( + models_ticketing.TicketingEvent.quota + - models_ticketing.TicketingEvent.used_quota, + ).where( + models_ticketing.TicketingEvent.id == event_id, + ), + ) + ) + .scalars() + .first() + ) + + +async def get_event_by_name( + db: AsyncSession, + name: str, +) -> schemas_ticketing.EventComplete | None: + """Get an event by its name.""" + event = ( + ( + await db.execute( + select(models_ticketing.TicketingEvent) + .where( + models_ticketing.TicketingEvent.name == name, + ) + .options( + selectinload(models_ticketing.TicketingEvent.sessions), + selectinload(models_ticketing.TicketingEvent.categories), + ), + ) + ) + .scalars() + .first() + ) + + return ( + schemas_ticketing.EventComplete( + id=event.id, + organiser_id=event.organiser_id, + creator_id=event.creator_id, + name=event.name, + open_date=event.open_date, + close_date=event.close_date, + quota=event.quota, + user_quota=event.user_quota, + disabled=event.disabled, + organiser=schemas_ticketing.OrganiserComplete( + id=event.organiser.id, + name=event.organiser.name, + store_id=event.organiser.store_id, + ), + sessions=[ + schemas_ticketing.SessionSimple( + id=session.id, + event_id=session.event_id, + date=session.date, + name=session.name, + quota=session.quota, + user_quota=session.user_quota, + disabled=session.disabled, + ) + for session in event.sessions + ], + categories=[ + schemas_ticketing.CategorySimple( + id=category.id, + event_id=category.event_id, + name=category.name, + required_mebership=category.required_mebership, + quota=category.quota, + user_quota=category.user_quota, + price=category.price, + disabled=category.disabled, + ) + for category in event.categories + ], + ) + if event + else None + ) + + +async def create_event( + db: AsyncSession, + event: schemas_ticketing.EventSimple, +) -> None: + """Create a new event.""" + + db.add( + models_ticketing.TicketingEvent(**event.model_dump(), used_quota=0), + ) + await db.flush() + + +async def update_event( + db: AsyncSession, + event_id: UUID, + event_update: schemas_ticketing.EventUpdate, +) -> None: + """Update an existing event.""" + + await db.execute( + update(models_ticketing.TicketingEvent) + .where(models_ticketing.TicketingEvent.id == event_id) + .values(**event_update.model_dump(exclude_unset=True)), + ) + await db.flush() + + +async def increment_used_quota_event( + db: AsyncSession, + event_id: UUID, +) -> None: + """Increment the used quota of an event, its sessions and its category if applicable.""" + await db.execute( + update(models_ticketing.TicketingEvent) + # Only increment if the event has a quota and the quota is not already full + # This prevents overbooking in case of concurrent ticket purchases across multiple workers + .where( + models_ticketing.TicketingEvent.id == event_id + and models_ticketing.TicketingEvent.used_quota + < models_ticketing.TicketingEvent.quota, + ) + .values(used_quota=models_ticketing.TicketingEvent.used_quota + 1), + ) + + await db.flush() + + +async def delete_event( + db: AsyncSession, + event_id: UUID, +) -> None: + """Delete an existing event.""" + + await db.execute( + delete(models_ticketing.TicketingEvent).where( + models_ticketing.TicketingEvent.id == event_id, + ), + ) + await db.flush() + + +async def get_sessions_by_event_id( + db: AsyncSession, + event_id: UUID, +) -> list[schemas_ticketing.SessionComplete]: + """Get all sessions for a specific event.""" + + return [ + schemas_ticketing.SessionComplete.model_validate(session) + for session in ( + await db.execute( + select(models_ticketing.TicketingSession).where( + models_ticketing.TicketingSession.event_id == event_id, + ), + ) + ) + .scalars() + .all() + ] + + +async def get_session_by_id( + session_id: UUID, + db: AsyncSession, +) -> schemas_ticketing.SessionComplete | None: + """Get a session by its ID.""" + + session = ( + ( + await db.execute( + select(models_ticketing.TicketingSession).where( + models_ticketing.TicketingSession.id == session_id, + ), + ) + ) + .scalars() + .first() + ) + + return ( + schemas_ticketing.SessionComplete( + id=session.id, + name=session.name, + date=session.date, + quota=session.quota, + user_quota=session.user_quota, + disabled=session.disabled, + event_id=session.event_id, + event=session.event, + ) + if session + else None + ) + + +async def get_session_used_quota( + db: AsyncSession, + session_id: UUID, +) -> int | None: + """Get the used quota for a session.""" + + return ( + ( + await db.execute( + select(models_ticketing.TicketingSession.used_quota).where( + models_ticketing.TicketingSession.id == session_id, + ), + ) + ) + .scalars() + .first() + ) + + +async def get_session_remaining_quota( + db: AsyncSession, + session_id: UUID, +) -> int | None: + """Get the remaining quota for a session.""" + + return ( + ( + await db.execute( + select( + models_ticketing.TicketingSession.quota + - models_ticketing.TicketingSession.used_quota, + ).where( + models_ticketing.TicketingSession.id == session_id, + ), + ) + ) + .scalars() + .first() + ) + + +async def get_sessions_by_ids( + session_ids: list[UUID], + db: AsyncSession, +) -> list[schemas_ticketing.SessionSimple]: + """Get sessions by their IDs.""" + + return [ + schemas_ticketing.SessionSimple( + id=session.id, + name=session.name, + date=session.date, + quota=session.quota, + user_quota=session.user_quota, + disabled=session.disabled, + event_id=session.event_id, + ) + for session in ( + await db.execute( + select(models_ticketing.TicketingSession).where( + models_ticketing.TicketingSession.id.in_(session_ids), + ), + ) + ) + .scalars() + .all() + ] + + +async def create_session( + db: AsyncSession, + session: schemas_ticketing.SessionSimple, +) -> None: + """Create a new session.""" + + db.add( + models_ticketing.TicketingSession(**session.model_dump(), used_quota=0), + ) + await db.flush() + + +async def update_session( + db: AsyncSession, + session_id: UUID, + session_update: schemas_ticketing.SessionUpdate, +) -> None: + """Update an existing session.""" + + await db.execute( + update(models_ticketing.TicketingSession) + .where(models_ticketing.TicketingSession.id == session_id) + .values(**session_update.model_dump(exclude_unset=True)), + ) + await db.flush() + + +async def delete_session( + db: AsyncSession, + session_id: UUID, +) -> None: + """Delete an existing session.""" + + await db.execute( + delete(models_ticketing.TicketingSession).where( + models_ticketing.TicketingSession.id == session_id, + ), + ) + await db.flush() + + +async def increment_used_quota_session( + db: AsyncSession, + session_id: UUID, +) -> None: + """Increment the used quota of a session.""" + await db.execute( + update(models_ticketing.TicketingSession) + # Only increment if the session has a quota and the quota is not already full + # This prevents overbooking in case of concurrent ticket purchases across multiple workers + .where( + models_ticketing.TicketingSession.id == session_id + and models_ticketing.TicketingSession.used_quota + < models_ticketing.TicketingSession.quota, + ) + .values(used_quota=models_ticketing.TicketingSession.used_quota + 1), + ) + + await db.flush() + + +async def get_category_by_id( + category_id: UUID, + db: AsyncSession, +) -> schemas_ticketing.CategoryComplete | None: + """Get a category by its ID.""" + + category = ( + ( + await db.execute( + select(models_ticketing.TicketingCategory).where( + models_ticketing.TicketingCategory.id == category_id, + ), + ) + ) + .scalars() + .first() + ) + + return ( + schemas_ticketing.CategoryComplete( + id=category.id, + event_id=category.event_id, + name=category.name, + event=schemas_ticketing.EventSimple( + id=category.event.id, + organiser_id=category.event.organiser_id, + creator_id=category.event.creator_id, + name=category.event.name, + open_date=category.event.open_date, + close_date=category.event.close_date, + quota=category.event.quota, + user_quota=category.event.user_quota, + disabled=category.event.disabled, + ), + sessions=[session.id for session in category.sessions], + required_mebership=category.required_mebership, + quota=category.quota, + user_quota=category.user_quota, + price=category.price, + disabled=category.disabled, + ) + if category + else None + ) + + +async def get_category_used_quota( + db: AsyncSession, + category_id: UUID, +) -> int | None: + """Get the used quota for a category.""" + + return ( + ( + await db.execute( + select(models_ticketing.TicketingCategory.used_quota).where( + models_ticketing.TicketingCategory.id == category_id, + ), + ) + ) + .scalars() + .first() + ) + + +async def get_category_remaining_quota( + db: AsyncSession, + category_id: UUID, +) -> int | None: + """Get the remaining quota for a category.""" + + return ( + ( + await db.execute( + select( + models_ticketing.TicketingCategory.quota + - models_ticketing.TicketingCategory.used_quota, + ).where( + models_ticketing.TicketingCategory.id == category_id, + ), + ) + ) + .scalars() + .first() + ) + + +async def get_categories_by_session_id( + session_id: UUID, + db: AsyncSession, +) -> list[schemas_ticketing.CategorySimple]: + """Get all categories for a specific session.""" + + return [ + schemas_ticketing.CategorySimple( + id=category.id, + event_id=category.event_id, + name=category.name, + required_mebership=category.required_mebership, + quota=category.quota, + user_quota=category.user_quota, + price=category.price, + disabled=category.disabled, + ) + for category in ( + await db.execute( + select(models_ticketing.TicketingCategory) + .join(models_ticketing.CategorySessionAssociation) + .where( + models_ticketing.CategorySessionAssociation.session_id + == session_id, + ), + ) + ) + .scalars() + .all() + ] + + +async def get_categories_by_event_id( + event_id: UUID, + db: AsyncSession, +) -> list[schemas_ticketing.CategorySimple]: + """Get all categories for a specific event.""" + + return [ + schemas_ticketing.CategorySimple( + id=category.id, + event_id=category.event_id, + name=category.name, + required_mebership=category.required_mebership, + quota=category.quota, + user_quota=category.user_quota, + price=category.price, + disabled=category.disabled, + ) + for category in ( + await db.execute( + select(models_ticketing.TicketingCategory).where( + models_ticketing.TicketingCategory.event_id == event_id, + ), + ) + ) + .scalars() + .all() + ] + + +async def create_category( + db: AsyncSession, + category: schemas_ticketing.CategorySimple, +) -> None: + """Create a new category.""" + db.add( + models_ticketing.TicketingCategory( + **category.model_dump(exclude={"sessions"}), + used_quota=0, + ), + ) + await db.flush() + if category.sessions: + for session_id in category.sessions: + db.add( + models_ticketing.CategorySessionAssociation( + category_id=category.id, + session_id=session_id, + ), + ) + await db.flush() + + +async def update_category( + db: AsyncSession, + category_id: UUID, + category_update: schemas_ticketing.CategoryUpdate, +) -> None: + """Update an existing category.""" + + await db.execute( + update(models_ticketing.TicketingCategory) + .where(models_ticketing.TicketingCategory.id == category_id) + .values(**category_update.model_dump(exclude_unset=True)), + ) + await db.flush() + + +async def delete_category( + db: AsyncSession, + category_id: UUID, +) -> None: + """Delete an existing category.""" + + await db.execute( + delete(models_ticketing.TicketingCategory).where( + models_ticketing.TicketingCategory.id == category_id, + ), + ) + await db.flush() + + +async def increment_used_quota_category( + db: AsyncSession, + category_id: UUID, +) -> None: + """Increment the used quota of a category.""" + await db.execute( + update(models_ticketing.TicketingCategory) + # Only increment if the category has a quota and the quota is not already full + # This prevents overbooking in case of concurrent ticket purchases across multiple workers + .where( + models_ticketing.TicketingCategory.id == category_id + and models_ticketing.TicketingCategory.used_quota + < models_ticketing.TicketingCategory.quota, + ) + .values(used_quota=models_ticketing.TicketingCategory.used_quota + 1), + ) + + await db.flush() + + +async def get_tickets( + db: AsyncSession, +) -> list[schemas_ticketing.TicketSimple]: + """Get all tickets.""" + + tickets = await db.execute(select(models_ticketing.TicketingTicket)) + return [ + schemas_ticketing.TicketSimple( + id=ticket.id, + user_id=ticket.user_id, + event_id=ticket.event_id, + category_id=ticket.category_id, + session_id=ticket.session_id, + total=ticket.total, + created_at=ticket.created_at, + status=ticket.status, + nb_scan=ticket.nb_scan, + user=schemas_users.CoreUserSimple( + id=ticket.user.id, + name=ticket.user.name, + firstname=ticket.user.firstname, + nickname=ticket.user.nickname, + account_type=ticket.user.account_type, + school_id=ticket.user.school_id, + ), + ) + for ticket in tickets.scalars().all() + ] + + +async def get_tickets_by_event_id( + event_id: UUID, + db: AsyncSession, +) -> list[schemas_ticketing.TicketSimple]: + """Get all tickets for a specific event.""" + + tickets = await db.execute( + select(models_ticketing.TicketingTicket).where( + models_ticketing.TicketingTicket.event_id == event_id, + ), + ) + return [ + schemas_ticketing.TicketSimple( + id=ticket.id, + user_id=ticket.user_id, + event_id=ticket.event_id, + category_id=ticket.category_id, + session_id=ticket.session_id, + total=ticket.total, + created_at=ticket.created_at, + status=ticket.status, + nb_scan=ticket.nb_scan, + user=schemas_users.CoreUserSimple( + id=ticket.user.id, + name=ticket.user.name, + firstname=ticket.user.firstname, + nickname=ticket.user.nickname, + account_type=ticket.user.account_type, + school_id=ticket.user.school_id, + ), + ) + for ticket in tickets.scalars().all() + ] + + +async def get_tickets_by_category_id( + category_id: UUID, + db: AsyncSession, +) -> list[schemas_ticketing.TicketSimple]: + """Get all tickets for a specific category.""" + tickets = await db.execute( + select(models_ticketing.TicketingTicket).where( + models_ticketing.TicketingTicket.category_id == category_id, + ), + ) + return [ + schemas_ticketing.TicketSimple( + id=ticket.id, + user_id=ticket.user_id, + event_id=ticket.event_id, + category_id=ticket.category_id, + session_id=ticket.session_id, + total=ticket.total, + created_at=ticket.created_at, + status=ticket.status, + nb_scan=ticket.nb_scan, + user=schemas_users.CoreUserSimple( + id=ticket.user.id, + name=ticket.user.name, + firstname=ticket.user.firstname, + nickname=ticket.user.nickname, + account_type=ticket.user.account_type, + school_id=ticket.user.school_id, + ), + ) + for ticket in tickets.scalars().all() + ] + + +async def get_tickets_by_session_id( + session_id: UUID, + db: AsyncSession, +) -> list[schemas_ticketing.TicketSimple]: + """Get all tickets for a specific session.""" + + tickets = await db.execute( + select(models_ticketing.TicketingTicket).where( + models_ticketing.TicketingTicket.session_id == session_id, + ), + ) + return [ + schemas_ticketing.TicketSimple( + id=ticket.id, + user_id=ticket.user_id, + event_id=ticket.event_id, + category_id=ticket.category_id, + session_id=ticket.session_id, + total=ticket.total, + created_at=ticket.created_at, + status=ticket.status, + nb_scan=ticket.nb_scan, + user=schemas_users.CoreUserSimple( + id=ticket.user.id, + name=ticket.user.name, + firstname=ticket.user.firstname, + nickname=ticket.user.nickname, + account_type=ticket.user.account_type, + school_id=ticket.user.school_id, + ), + ) + for ticket in tickets.scalars().all() + ] + + +async def get_tickets_by_user_id( + user_id: str, + db: AsyncSession, +) -> list[schemas_ticketing.TicketSimple]: + """Get all tickets for a specific user.""" + + tickets = await db.execute( + select(models_ticketing.TicketingTicket).where( + models_ticketing.TicketingTicket.user_id == user_id, + ), + ) + return [ + schemas_ticketing.TicketSimple( + id=ticket.id, + user_id=ticket.user_id, + event_id=ticket.event_id, + category_id=ticket.category_id, + session_id=ticket.session_id, + total=ticket.total, + created_at=ticket.created_at, + status=ticket.status, + nb_scan=ticket.nb_scan, + user=schemas_users.CoreUserSimple( + id=ticket.user.id, + name=ticket.user.name, + firstname=ticket.user.firstname, + nickname=ticket.user.nickname, + account_type=ticket.user.account_type, + school_id=ticket.user.school_id, + ), + ) + for ticket in tickets.scalars().all() + ] + + +async def get_ticket_by_id( + ticket_id: UUID, + db: AsyncSession, +) -> schemas_ticketing.TicketComplete | None: + """Get a ticket by its ID.""" + + ticket = ( + ( + await db.execute( + select(models_ticketing.TicketingTicket).where( + models_ticketing.TicketingTicket.id == ticket_id, + ), + ) + ) + .scalars() + .first() + ) + + return ( + schemas_ticketing.TicketComplete( + id=ticket.id, + user_id=ticket.user_id, + event_id=ticket.event_id, + category_id=ticket.category_id, + session_id=ticket.session_id, + total=ticket.total, + created_at=ticket.created_at, + user=schemas_users.CoreUserSimple( + id=ticket.user.id, + name=ticket.user.name, + firstname=ticket.user.firstname, + nickname=ticket.user.nickname, + account_type=ticket.user.account_type, + school_id=ticket.user.school_id, + ), + event=schemas_ticketing.EventSimple( + id=ticket.event.id, + organiser_id=ticket.event.organiser_id, + creator_id=ticket.event.creator_id, + name=ticket.event.name, + open_date=ticket.event.open_date, + close_date=ticket.event.close_date, + quota=ticket.event.quota, + user_quota=ticket.event.user_quota, + disabled=ticket.event.disabled, + ), + session=schemas_ticketing.SessionSimple( + event_id=ticket.session.event_id, + id=ticket.session.id, + date=ticket.session.date, + name=ticket.session.name, + quota=ticket.session.quota, + user_quota=ticket.session.user_quota, + disabled=ticket.session.disabled, + ) + if ticket.session + else None, + category=schemas_ticketing.CategorySimple( + id=ticket.category.id, + event_id=ticket.category.event_id, + name=ticket.category.name, + required_mebership=ticket.category.required_mebership, + quota=ticket.category.quota, + user_quota=ticket.category.user_quota, + price=ticket.category.price, + disabled=ticket.category.disabled, + ), + status=ticket.status, + nb_scan=ticket.nb_scan, + ) + if ticket + else None + ) + + +async def create_ticket( + db: AsyncSession, + ticket: schemas_ticketing.TicketSimple, +) -> None: + """Create a new ticket.""" + + db.add( + models_ticketing.TicketingTicket(**ticket.model_dump(exclude={"user"})), + ) + await db.flush() + + +async def update_ticket( + db: AsyncSession, + ticket_id: UUID, + ticket_update: schemas_ticketing.TicketBase, +) -> None: + """Update an existing ticket.""" + + await db.execute( + update(models_ticketing.TicketingTicket) + .where(models_ticketing.TicketingTicket.id == ticket_id) + .values(**ticket_update.model_dump(exclude_unset=True)), + ) + await db.flush() + + +async def delete_ticket( + db: AsyncSession, + ticket_id: UUID, +) -> None: + """Delete an existing ticket.""" + + await db.execute( + delete(models_ticketing.TicketingTicket).where( + models_ticketing.TicketingTicket.id == ticket_id, + ), + ) + await db.flush() diff --git a/app/modules/ticketing/endpoints_ticketing.py b/app/modules/ticketing/endpoints_ticketing.py new file mode 100644 index 0000000000..d464473172 --- /dev/null +++ b/app/modules/ticketing/endpoints_ticketing.py @@ -0,0 +1,932 @@ +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +from fastapi import APIRouter, Depends, HTTPException +from redis import Redis +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.groups.groups_type import GroupType +from app.core.permissions.type_permissions import ModulePermissions +from app.core.users import models_users +from app.dependencies import get_db, get_redis_client, is_user, is_user_allowed_to +from app.modules.ticketing import cache_ticketing, cruds_ticketing, schemas_ticketing +from app.modules.ticketing.factory_ticketing import TicketingFactory +from app.types.module import Module + + +class TicketingPermissions(ModulePermissions): + access_ticketing = "access_ticketing" + manage_events = "manage_events" + + +router = APIRouter(tags=["Ticketing"]) + +module = Module( + root="ticketing", + tag="Ticketing", + router=router, + factory=TicketingFactory(), +) + + +@module.router.get( + "/ticketing/organisers/", + response_model=list[schemas_ticketing.OrganiserComplete], + status_code=200, +) +async def get_organisers( + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.access_ticketing]), + ), +): + """ + Get all organisers. + """ + return await cruds_ticketing.get_organisers(db=db) + + +@module.router.get( + "/ticketing/organisers/{organiser_id}", + response_model=schemas_ticketing.OrganiserComplete, + status_code=200, +) +async def get_organiser( + organiser_id: UUID, + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.access_ticketing]), + ), +) -> schemas_ticketing.OrganiserComplete: + """ + Get an Organiser by its id. + """ + organiser = await cruds_ticketing.get_organiser_by_id( + db=db, + organiser_id=organiser_id, + ) + if organiser is None: + raise HTTPException(status_code=404, detail="Organiser not found") + return organiser + + +# @module.router.post( +# "/ticketing/organisers/", +# response_model=schemas_ticketing.OrganiserComplete, +# status_code=201, +# ) +# async def create_organiser( +# organiser: schemas_ticketing.OrganiserBase, +# db: AsyncSession = Depends(get_db), +# user: models_users.CoreUser = Depends( +# is_user_allowed_to([TicketingPermissions.access_ticketing])), + +# ) -> None: +# """Create an organiser""" +# await cruds_ticketing.create_organiser( +# organiser=schemas_ticketing.OrganiserComplete( +# id=uuid4(), +# group_id= +# store_id= +# name=organiser.name, +# )) + + +@module.router.get( + "/ticketing/events/", + summary="Get all events", + response_model=list[schemas_ticketing.EventSimple], + status_code=200, +) +async def get_events( + db: AsyncSession = Depends(get_db), +) -> list[schemas_ticketing.EventSimple]: + """Get all events.""" + return await cruds_ticketing.get_events(db=db) + + +@module.router.get( + "/ticketing/events/{event_id}/quota/", + summary="Get the remaining quota for an event", + response_model=int, + status_code=200, +) +async def get_event_remaining_quota( + event_id: UUID, + db: AsyncSession = Depends(get_db), + redis: Redis | None = Depends(get_redis_client), +) -> int: + """Get the remaining quota for an event.""" + quota = await cache_ticketing.get_event_remaining_quota_with_cache( + redis=redis, + db=db, + event_id=event_id, + ) + if quota is None: + raise HTTPException(status_code=404, detail="Event not found") + return quota + + +@module.router.get( + "/ticketing/events/{event_id}", + summary="Get an event by its ID", + response_model=schemas_ticketing.EventComplete, + status_code=200, +) +async def get_event_by_id( + event_id: UUID, + db: AsyncSession = Depends(get_db), +) -> schemas_ticketing.EventComplete: + """Get an event by its ID.""" + event = await cruds_ticketing.get_event_by_id(event_id=event_id, db=db) + if event is None: + raise HTTPException(status_code=404, detail="Event not found") + return event + + +@module.router.post( + "/ticketing/events/", + summary="Create a new event", + response_model=schemas_ticketing.EventComplete, + status_code=201, +) +async def create_event( + event: schemas_ticketing.EventBase, + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.manage_events]), + ), +) -> schemas_ticketing.EventComplete: + """Create a new event.""" + stored = await cruds_ticketing.get_event_by_name(name=event.name, db=db) + if stored is not None: + raise HTTPException(status_code=400, detail="Event already exists") + event = schemas_ticketing.EventSimple( + **event.model_dump(), + id=uuid4(), + creator_id=user.id, + disabled=False, + ) + await cruds_ticketing.create_event(event=event, db=db) + + event_complete = await cruds_ticketing.get_event_by_id(event_id=event.id, db=db) + if event_complete is None: + await db.rollback() + raise HTTPException(status_code=500, detail="Event creation failed") + return event_complete + + +@module.router.patch( + "/ticketing/events/{event_id}", + summary="Update an existing event", + response_model=None, + status_code=204, +) +async def update_event( + event_id: UUID, + event_update: schemas_ticketing.EventUpdate, + db: AsyncSession = Depends(get_db), + redis: Redis | None = Depends(get_redis_client), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.manage_events]), + ), +) -> None: + """Update an existing event.""" + used_quota = await cruds_ticketing.get_event_used_quota(db=db, event_id=event_id) + if used_quota is None: + raise HTTPException(status_code=404, detail="Event not found") + if event_update.quota is not None and used_quota > event_update.quota: + raise HTTPException( + status_code=400, + detail="Cannot set quota less than used quota", + ) + await cruds_ticketing.update_event( + event_id=event_id, + event_update=event_update, + db=db, + ) + + +@module.router.delete( + "/ticketing/events/{event_id}", + summary="Delete an existing event", + response_model=None, + status_code=204, +) +async def delete_event( + event_id: UUID, + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.manage_events]), + ), +) -> None: + """Delete an existing event.""" + used_quota = await cruds_ticketing.get_event_used_quota(db=db, event_id=event_id) + if used_quota is None: + raise HTTPException(status_code=404, detail="Event not found") + if used_quota > 0: + raise HTTPException( + status_code=400, + detail="Cannot delete an event with used quota", + ) + await cruds_ticketing.delete_event(event_id=event_id, db=db) + + +@module.router.get( + "/ticketing/sessions/{session_id}", + summary="Get a session by its ID", + response_model=schemas_ticketing.SessionComplete, + status_code=200, +) +async def get_session_by_id( + session_id: UUID, + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.access_ticketing]), + ), +) -> schemas_ticketing.SessionComplete | None: + """Get a session by its ID.""" + session = await cruds_ticketing.get_session_by_id( + session_id=session_id, + db=db, + ) + if session is None: + raise HTTPException(status_code=404, detail="Session not found") + return session + + +@module.router.get( + "/ticketing/sessions/{session_id}/quota/", + summary="Get the remaining quota for a session", + response_model=int, + status_code=200, +) +async def get_session_remaining_quota( + session_id: UUID, + db: AsyncSession = Depends(get_db), + redis: Redis | None = Depends(get_redis_client), +) -> int: + """Get the remaining quota for a session.""" + quota = await cache_ticketing.get_session_remaining_quota_with_cache( + redis=redis, + db=db, + session_id=session_id, + ) + if quota is None: + raise HTTPException(status_code=404, detail="Session not found") + return quota + + +@module.router.get( + "/ticketing/events/{event_id}/sessions/", + summary="Get all sessions for a specific event", + response_model=list[schemas_ticketing.SessionComplete], + status_code=200, +) +async def get_sessions_by_event_id( + event_id: UUID, + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.access_ticketing]), + ), +) -> list[schemas_ticketing.SessionComplete]: + """Get all sessions for a specific event.""" + return await cruds_ticketing.get_sessions_by_event_id(event_id=event_id, db=db) + + +@module.router.post( + "/ticketing/sessions/", + summary="Create a new session", + response_model=schemas_ticketing.SessionComplete, + status_code=201, +) +async def create_session( + session: schemas_ticketing.SessionBase, + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.manage_events]), + ), +) -> schemas_ticketing.SessionComplete: + """Create a new session.""" + session_simple = schemas_ticketing.SessionSimple( + **session.model_dump(), + id=uuid4(), + disabled=False, + ) + # Verify that the event exists before + event = await cruds_ticketing.get_event_by_id( + event_id=session_simple.event_id, + db=db, + ) + if event is None: + raise HTTPException(status_code=404, detail="Event not found") + if event.open_date is not None and session.date < event.open_date: + raise HTTPException( + status_code=400, + detail="Session date cannot be before event open date", + ) + if event.close_date is not None and session.date > event.close_date: + raise HTTPException( + status_code=400, + detail="Session date cannot be after event close date", + ) + await cruds_ticketing.create_session(session=session_simple, db=db) + session_complete = await cruds_ticketing.get_session_by_id( + session_id=session_simple.id, + db=db, + ) + if session_complete is None: + await db.rollback() + raise HTTPException(status_code=500, detail="Session creation failed") + return session_complete + + +@module.router.patch( + "/ticketing/sessions/{session_id}", + summary="Update an existing session", + response_model=None, + status_code=204, +) +async def update_session( + session_id: UUID, + session_update: schemas_ticketing.SessionUpdate, + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.manage_events]), + ), +) -> None: + """Update an existing session.""" + used_quota = await cruds_ticketing.get_session_used_quota( + db=db, + session_id=session_id, + ) + if used_quota is None: + raise HTTPException(status_code=404, detail="Session not found") + if session_update.quota is not None and used_quota > session_update.quota: + raise HTTPException( + status_code=400, + detail="Cannot set quota less than used quota", + ) + await cruds_ticketing.update_session( + session_id=session_id, + session_update=session_update, + db=db, + ) + + +@module.router.delete( + "/ticketing/sessions/{session_id}", + summary="Delete an existing session", + response_model=None, + status_code=204, +) +async def delete_session( + session_id: UUID, + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.manage_events]), + ), +) -> None: + """Delete an existing session.""" + used_quota = await cruds_ticketing.get_session_used_quota( + db=db, + session_id=session_id, + ) + if used_quota is None: + raise HTTPException(status_code=404, detail="Session not found") + if used_quota > 0: + raise HTTPException( + status_code=400, + detail="Cannot delete a session with used quota", + ) + categories = await cruds_ticketing.get_categories_by_session_id( + session_id=session_id, + db=db, + ) + if len(categories) > 0: + raise HTTPException( + status_code=400, + detail="Cannot delete a session with associated categories", + ) + tickets = await cruds_ticketing.get_tickets_by_session_id( + session_id=session_id, + db=db, + ) + if len(tickets) > 0: + raise HTTPException( + status_code=400, + detail="Cannot delete a session with associated tickets", + ) + await cruds_ticketing.delete_session(session_id=session_id, db=db) + + +@module.router.get( + "/ticketing/categories/{category_id}", + summary="Get a category by its ID", + response_model=schemas_ticketing.CategoryComplete, + status_code=200, +) +async def get_category_by_id( + category_id: UUID, + db: AsyncSession = Depends(get_db), +) -> schemas_ticketing.CategoryComplete | None: + """Get a category by its ID.""" + category = await cruds_ticketing.get_category_by_id(category_id=category_id, db=db) + if category is None: + raise HTTPException(status_code=404, detail="Category not found") + return category + + +@module.router.get( + "/ticketing/categories/{category_id}/quota/", + summary="Get the remaining quota for a category", + response_model=int, + status_code=200, +) +async def get_category_remaining_quota( + category_id: UUID, + db: AsyncSession = Depends(get_db), + redis: Redis | None = Depends(get_redis_client), +) -> int: + """Get the remaining quota for a category.""" + quota = await cache_ticketing.get_category_remaining_quota_with_cache( + redis=redis, + db=db, + category_id=category_id, + ) + if quota is None: + raise HTTPException(status_code=404, detail="Category not found") + return quota + + +@module.router.get( + "/ticketing/events/{event_id}/categories/", + summary="Get all categories for an event", + response_model=list[schemas_ticketing.CategorySimple], + status_code=200, +) +async def get_categories_by_event( + event_id: UUID, + db: AsyncSession = Depends(get_db), +) -> list[schemas_ticketing.CategorySimple]: + """Get all categories for an event.""" + return await cruds_ticketing.get_categories_by_event_id( + event_id=event_id, + db=db, + ) + + +@module.router.get( + "/ticketing/sessions/{session_id}/categories/", + summary="Get all categories for a session", + response_model=list[schemas_ticketing.CategorySimple], + status_code=200, +) +async def get_categories_by_session( + session_id: UUID, + db: AsyncSession = Depends(get_db), +) -> list[schemas_ticketing.CategorySimple]: + """Get all categories for a session.""" + return await cruds_ticketing.get_categories_by_session_id( + session_id=session_id, + db=db, + ) + + +@module.router.post( + "/ticketing/categories/", + summary="Create a new category", + response_model=schemas_ticketing.CategorySimple, + status_code=201, +) +async def create_category( + category: schemas_ticketing.CategoryCreate, + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.manage_events]), + ), +) -> schemas_ticketing.CategorySimple: + """Create a new category.""" + # Verify that the event exists before creating the category. + event = await cruds_ticketing.get_event_by_id( + event_id=category.event_id, + db=db, + ) + if event is None: + raise HTTPException(status_code=404, detail="Event not found") + # Verify that the sessions exist before creating the category. + if category.sessions is not None: + sessions = await cruds_ticketing.get_sessions_by_event_id( + db=db, + event_id=category.event_id, + ) + sessions_ids = [session.id for session in sessions] + if sessions is None or any( + session_id not in sessions_ids for session_id in category.sessions + ): + raise HTTPException( + status_code=404, + detail="One or more sessions not found", + ) + category_simple = schemas_ticketing.CategorySimple( + **category.model_dump(), + id=uuid4(), + disabled=False, + ) + await cruds_ticketing.create_category(category=category_simple, db=db) + return category_simple + + +@module.router.patch( + "/ticketing/categories/{category_id}", + summary="Update an existing category", + response_model=None, + status_code=204, +) +async def update_category( + category_id: UUID, + category_update: schemas_ticketing.CategoryUpdate, + db: AsyncSession = Depends(get_db), + redis: Redis | None = Depends(get_redis_client), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.manage_events]), + ), +) -> None: + """Update an existing category.""" + used_quota = await cruds_ticketing.get_category_used_quota( + db=db, + category_id=category_id, + ) + if used_quota is None: + raise HTTPException(status_code=404, detail="Category not found") + if category_update.quota is not None and used_quota > category_update.quota: + raise HTTPException( + status_code=400, + detail="Cannot set quota less than used quota", + ) + await cruds_ticketing.update_category( + category_id=category_id, + category_update=category_update, + db=db, + ) + + +@module.router.delete( + "/ticketing/categories/{category_id}", + summary="Delete an existing category", + response_model=None, + status_code=204, +) +async def delete_category( + category_id: UUID, + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.manage_events]), + ), +) -> None: + """Delete an existing category.""" + used_quota = await cruds_ticketing.get_category_used_quota( + db=db, + category_id=category_id, + ) + if used_quota is None: + raise HTTPException(status_code=404, detail="Category not found") + if used_quota > 0: + raise HTTPException( + status_code=400, + detail="Cannot delete a category with used quota", + ) + await cruds_ticketing.delete_category(category_id=category_id, db=db) + + +@module.router.get( + "/ticketing/tickets/{ticket_id}", + summary="Get a ticket by its ID", + response_model=schemas_ticketing.TicketComplete, + status_code=200, +) +async def get_ticket_by_id( + ticket_id: UUID, + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.access_ticketing]), + ), +) -> schemas_ticketing.TicketComplete | None: + """Get a ticket by its ID.""" + ticket = await cruds_ticketing.get_ticket_by_id(ticket_id=ticket_id, db=db) + if ticket is None: + raise HTTPException(status_code=404, detail="Ticket not found") + # Allow access if it's the user's own ticket or if they're an admin + if ticket.user_id != user.id and GroupType.admin not in [ + group.id for group in user.groups + ]: + raise HTTPException( + status_code=404, + detail="Ticket not found", + ) + return ticket + + +@module.router.get( + "/ticketing/tickets/", + summary="Get all tickets", + response_model=list[schemas_ticketing.TicketSimple], + status_code=200, +) +async def get_all_tickets( + db: AsyncSession = Depends(get_db), +) -> list[schemas_ticketing.TicketSimple]: + """Get all tickets.""" + return await cruds_ticketing.get_tickets(db=db) + + +@module.router.get( + "/ticketing/events/{event_id}/tickets/", + summary="Get all tickets for an event", + response_model=list[schemas_ticketing.TicketSimple], + status_code=200, +) +async def get_tickets_by_event( + event_id: UUID, + db: AsyncSession = Depends(get_db), +) -> list[schemas_ticketing.TicketSimple]: + """Get all tickets for an event.""" + return await cruds_ticketing.get_tickets_by_event_id(event_id=event_id, db=db) + + +@module.router.get( + "/ticketing/sessions/{session_id}/tickets/", + summary="Get all tickets for a session", + response_model=list[schemas_ticketing.TicketSimple], + status_code=200, +) +async def get_tickets_by_session( + session_id: UUID, + db: AsyncSession = Depends(get_db), +) -> list[schemas_ticketing.TicketSimple]: + """Get all tickets for a session.""" + return await cruds_ticketing.get_tickets_by_session_id( + session_id=session_id, + db=db, + ) + + +@module.router.get( + "/ticketing/categories/{category_id}/tickets/", + summary="Get all tickets for a category", + response_model=list[schemas_ticketing.TicketSimple], + status_code=200, +) +async def get_tickets_by_category( + category_id: UUID, + db: AsyncSession = Depends(get_db), +) -> list[schemas_ticketing.TicketSimple]: + """Get all tickets for a category.""" + return await cruds_ticketing.get_tickets_by_category_id( + category_id=category_id, + db=db, + ) + + +@module.router.get( + "/ticketing/users/{user_id}/tickets/", + summary="Get all tickets for a user", + response_model=list[schemas_ticketing.TicketSimple], + status_code=200, +) +async def get_tickets_by_user( + user_id: str, + db: AsyncSession = Depends(get_db), +) -> list[schemas_ticketing.TicketSimple]: + """Get all tickets for a user.""" + return await cruds_ticketing.get_tickets_by_user_id(user_id=user_id, db=db) + + +@module.router.get( + "/ticketing/users/me/tickets/", + summary="Get all tickets for a user", + response_model=list[schemas_ticketing.TicketSimple], + status_code=200, +) +async def get_my_tickets( + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends(is_user()), +) -> list[schemas_ticketing.TicketSimple]: + """Get all tickets for a user.""" + return await cruds_ticketing.get_tickets_by_user_id(user_id=user.id, db=db) + + +@module.router.post( + "/ticketing/tickets/", + summary="Create a new ticket", + response_model=schemas_ticketing.TicketSimple, + status_code=201, +) +async def create_ticket( + ticket: schemas_ticketing.TicketBase, + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends(is_user()), + redis_client: Redis | None = Depends(get_redis_client), +) -> schemas_ticketing.TicketSimple: + """Create a new ticket.""" + + if user.id != ticket.user_id and not await is_user_allowed_to( + [TicketingPermissions.manage_events], + )(user): + raise HTTPException( + status_code=403, + detail="Users can only create tickets for themselves", + ) + + ticket_simple = schemas_ticketing.TicketSimple( + **ticket.model_dump(), + id=uuid4(), + status="pending", + nb_scan=0, + created_at=datetime.now(UTC), + ) + + # Verify quota from cache given event_id, category_id and session_id to prevent overbooking in case of concurrent ticket purchases across multiple workers + event_quota = await cache_ticketing.get_event_remaining_quota_with_cache( + redis=redis_client, + db=db, + event_id=ticket_simple.event_id, + ) + category_quota = await cache_ticketing.get_category_remaining_quota_with_cache( + redis=redis_client, + db=db, + category_id=ticket_simple.category_id, + ) + session_quota = await cache_ticketing.get_session_remaining_quota_with_cache( + redis=redis_client, + db=db, + session_id=ticket_simple.session_id, + ) + if event_quota is not None and event_quota <= 0: + raise HTTPException(status_code=400, detail="Event quota exceeded") + if category_quota is not None and category_quota <= 0: + raise HTTPException(status_code=400, detail="Category quota exceeded") + if session_quota is not None and session_quota <= 0: + raise HTTPException(status_code=400, detail="Session quota exceeded") + + # Verify that the event, category and session exist before creating the ticket to prevent creating tickets for non existing entities + event = await cruds_ticketing.get_event_by_id( + event_id=ticket_simple.event_id, + db=db, + ) + if event is None: + raise HTTPException(status_code=404, detail="Event not found") + category = await cruds_ticketing.get_category_by_id( + category_id=ticket_simple.category_id, + db=db, + ) + if category is None: + raise HTTPException(status_code=404, detail="Category not found") + session = await cruds_ticketing.get_session_by_id( + session_id=ticket_simple.session_id, + db=db, + ) + if session is None: + raise HTTPException(status_code=404, detail="Session not found") + + if category.event_id != event.id: + raise HTTPException( + status_code=400, + detail="Category does not belong to event", + ) + if session.event_id != event.id: + raise HTTPException( + status_code=400, + detail="Session does not belong to event", + ) + if category.sessions and session.id not in category.sessions: + raise HTTPException( + status_code=400, + detail="Session is not available for category", + ) + + # Check if the user has already reached the user quota for the event, category and session + user_tickets = await cruds_ticketing.get_tickets_by_user_id( + user_id=ticket_simple.user_id, + db=db, + ) + user_event_tickets = [ + ticket for ticket in user_tickets if ticket.event_id == ticket_simple.event_id + ] + user_category_tickets = [ + ticket + for ticket in user_tickets + if ticket.category_id == ticket_simple.category_id + ] + user_session_tickets = [ + ticket + for ticket in user_tickets + if ticket.session_id == ticket_simple.session_id + ] + if event.user_quota is not None and len(user_event_tickets) >= event.user_quota: + raise HTTPException( + status_code=400, + detail="User event quota exceeded", + ) + if ( + category.user_quota is not None + and len(user_category_tickets) >= category.user_quota + ): + raise HTTPException( + status_code=400, + detail="User category quota exceeded", + ) + if ( + session.user_quota is not None + and len(user_session_tickets) >= session.user_quota + ): + raise HTTPException( + status_code=400, + detail="User session quota exceeded", + ) + + await cruds_ticketing.create_ticket(ticket=ticket_simple, db=db) + + # TODO: Add redis cache update for event quota + cache_ticketing.update_cache_for_new_ticket( + redis=redis_client, + event_id=ticket_simple.event_id, + category_id=ticket_simple.category_id, + session_id=ticket_simple.session_id, + ) + + await cruds_ticketing.increment_used_quota_event( + event_id=ticket_simple.event_id, + db=db, + ) + await cruds_ticketing.increment_used_quota_category( + category_id=ticket_simple.category_id, + db=db, + ) + await cruds_ticketing.increment_used_quota_session( + session_id=ticket_simple.session_id, + db=db, + ) + ticket_complete = await cruds_ticketing.get_ticket_by_id( + ticket_id=ticket_simple.id, + db=db, + ) + + if ticket_complete is None: + await db.rollback() + await cache_ticketing.update_cache_for_new_ticket( + redis=redis_client, + event_id=ticket_simple.event_id, + category_id=ticket_simple.category_id, + session_id=ticket_simple.session_id, + amount=-1, + ) + raise HTTPException(status_code=500, detail="Ticket creation failed") + + # TODO: Init MyECLPay Transfer + + return ticket_complete + + +@module.router.patch( + "/ticketing/tickets/{ticket_id}", + summary="Update an existing ticket", + response_model=None, + status_code=204, +) +async def update_ticket( + ticket_id: UUID, + ticket_update: schemas_ticketing.TicketBase, + db: AsyncSession = Depends(get_db), +) -> None: + """Update an existing ticket.""" + stored = await cruds_ticketing.get_ticket_by_id(ticket_id=ticket_id, db=db) + if stored is None: + raise HTTPException(status_code=404, detail="Ticket not found") + await cruds_ticketing.update_ticket( + ticket_id=ticket_id, + ticket_update=ticket_update, + db=db, + ) + + +# Could be deleted if the user is the one who has created the ticket +# or if the user has the right permissions to manage events +@module.router.delete( + "/ticketing/tickets/{ticket_id}", + summary="Delete an existing ticket", + response_model=None, + status_code=204, +) +async def delete_ticket( + ticket_id: UUID, + db: AsyncSession = Depends(get_db), +) -> None: + """Delete an existing ticket.""" + stored = await cruds_ticketing.get_ticket_by_id(ticket_id=ticket_id, db=db) + if stored is None: + raise HTTPException(status_code=404, detail="Ticket not found") + # TODO: Add permission check to allow only the user who has created the ticket or users with manage_events permission to delete the ticket + # Should it be a pending ticket? + # Should we keep the ticket but mark it as cancelled to keep track of the quota and for historical data? + await cruds_ticketing.delete_ticket(ticket_id=ticket_id, db=db) diff --git a/app/modules/ticketing/factory_ticketing.py b/app/modules/ticketing/factory_ticketing.py new file mode 100644 index 0000000000..dde5f24cf0 --- /dev/null +++ b/app/modules/ticketing/factory_ticketing.py @@ -0,0 +1,175 @@ +import random +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.memberships.factory_memberships import CoreMembershipsFactory +from app.core.mypayment.factory_mypayment import MyPaymentFactory +from app.core.users import schemas_users +from app.core.users.factory_users import CoreUsersFactory +from app.core.utils.config import Settings +from app.modules.ticketing import cruds_ticketing, schemas_ticketing +from app.types.factory import Factory + + +class TicketingFactory(Factory): + depends_on = [ + CoreUsersFactory, + MyPaymentFactory, + CoreMembershipsFactory, + ] + + organiser_id = uuid4() + event_id = uuid4() + session1_id = uuid4() + session2_id = uuid4() + category1_id = uuid4() + category2_id = uuid4() + category3_id = uuid4() + + @classmethod + async def run(cls, db: AsyncSession, settings: Settings) -> None: + await cls.add_organiser(db) + await cls.add_event(db) + await cls.add_sessions(db) + await cls.add_categories(db) + await cls.add_tickets(db) + + @classmethod + async def add_organiser(cls, db: AsyncSession) -> None: + """Create an organiser""" + await cruds_ticketing.create_organiser( + db, + schemas_ticketing.OrganiserComplete( + id=cls.organiser_id, + store_id=MyPaymentFactory.other_stores_id[0][0], + name="ECLAIR", + ), + ) + + @classmethod + async def add_event(cls, db: AsyncSession) -> None: + """Create a sample event.""" + await cruds_ticketing.create_event( + db, + schemas_ticketing.EventSimple( + id=cls.event_id, + organiser_id=cls.organiser_id, + creator_id=CoreUsersFactory.other_users_id[0], + name="Commuz 2025", + open_date=datetime.now(UTC), + close_date=datetime.now(UTC) + timedelta(days=30), + quota=500, + user_quota=4, + disabled=False, + ), + ) + + @classmethod + async def add_sessions(cls, db: AsyncSession) -> None: + """Create sample sessions.""" + await cruds_ticketing.create_session( + db, + schemas_ticketing.SessionSimple( + id=cls.session1_id, + event_id=cls.event_id, + date=datetime.now(UTC) + timedelta(days=10), + name="TicketingSession du Samedi Soir", + quota=300, + user_quota=2, + disabled=False, + ), + ) + await cruds_ticketing.create_session( + db, + schemas_ticketing.SessionSimple( + id=cls.session2_id, + event_id=cls.event_id, + date=datetime.now(UTC) + timedelta(days=11), + name="TicketingSession du Dimanche Après-midi", + quota=200, + user_quota=2, + disabled=False, + ), + ) + + @classmethod + async def add_categories(cls, db: AsyncSession) -> None: + """Create sample categories.""" + await cruds_ticketing.create_category( + db, + schemas_ticketing.CategorySimple( + id=cls.category1_id, + event_id=cls.event_id, + name="Étudiant Centrale", + sessions=[cls.session1_id, cls.session2_id], + required_mebership=CoreMembershipsFactory.memberships_ids[0], + quota=150, + user_quota=2, + price=1500, + disabled=False, + ), + ) + await cruds_ticketing.create_category( + db, + schemas_ticketing.CategorySimple( + id=cls.category2_id, + event_id=cls.event_id, + name="Étudiant Lyon", + sessions=[cls.session1_id, cls.session2_id], + required_mebership=CoreMembershipsFactory.memberships_ids[1], + quota=200, + user_quota=2, + price=2000, + disabled=False, + ), + ) + await cruds_ticketing.create_category( + db, + schemas_ticketing.CategorySimple( + id=cls.category3_id, + event_id=cls.event_id, + name="Externe", + sessions=[cls.session1_id], + required_mebership=None, + quota=100, + user_quota=1, + price=3000, + disabled=False, + ), + ) + + @classmethod + async def add_tickets(cls, db: AsyncSession) -> None: + """Create sample tickets for users.""" + categories = [cls.category1_id, cls.category2_id] + + for _i, user_id in enumerate(CoreUsersFactory.other_users_id[:10]): + category_id = random.choice(categories) # noqa: S311 + + await cruds_ticketing.create_ticket( + db, + schemas_ticketing.TicketSimple( + id=uuid4(), + user_id=user_id, + event_id=cls.event_id, + category_id=category_id, + session_id=cls.session1_id, + total=1500 if category_id == cls.category1_id else 2000, + created_at=datetime.now(UTC), + nb_scan=0, + status="valid", + user=schemas_users.CoreUserSimple( + id=user_id, + name=f"Raizor-{_i}", + firstname=f"Raiz-{_i}", + account_type=schemas_users.AccountType.student, + school_id=CoreMembershipsFactory.memberships_ids[0], + ), + ), + ) + + @classmethod + async def should_run(cls, db: AsyncSession): + return await cruds_ticketing.get_events(db) == [] diff --git a/app/modules/ticketing/models_ticketing.py b/app/modules/ticketing/models_ticketing.py new file mode 100644 index 0000000000..fe34e9fac9 --- /dev/null +++ b/app/modules/ticketing/models_ticketing.py @@ -0,0 +1,154 @@ +from datetime import datetime +from typing import TYPE_CHECKING +from uuid import UUID + +from sqlalchemy import ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.users import models_users +from app.types.sqlalchemy import Base, PrimaryKey + +if TYPE_CHECKING: + from app.core.mypayment.models_mypayment import Store + + +class Organiser(Base): + __tablename__ = "ticketing_organiser" + + id: Mapped[PrimaryKey] + name: Mapped[str] + store_id: Mapped[UUID] = mapped_column(ForeignKey("mypayment_store.id")) + store: Mapped["Store"] = relationship( + init=False, + lazy="selectin", + ) + + +class CategorySessionAssociation(Base): + __tablename__ = "ticketing_category_session" + + category_id: Mapped[UUID] = mapped_column( + ForeignKey("ticketing_category.id"), + primary_key=True, + ) + session_id: Mapped[UUID] = mapped_column( + ForeignKey("ticketing_session.id"), + primary_key=True, + ) + + +class TicketingEvent(Base): + __tablename__ = "ticketing_event" + + id: Mapped[PrimaryKey] + organiser_id: Mapped[UUID] = mapped_column(ForeignKey("ticketing_organiser.id")) + organiser: Mapped["Organiser"] = relationship( + init=False, + lazy="selectin", + ) + + creator_id: Mapped[str] = mapped_column(ForeignKey("core_user.id")) + name: Mapped[str] + open_date: Mapped[datetime] + close_date: Mapped[datetime | None] + quota: Mapped[int | None] + used_quota: Mapped[int] + user_quota: Mapped[int | None] + disabled: Mapped[bool] + + sessions: Mapped[list["TicketingSession"]] = relationship( + back_populates="event", + init=False, + lazy="selectin", + ) + categories: Mapped[list["TicketingCategory"]] = relationship( + back_populates="event", + init=False, + lazy="selectin", + ) + + +class TicketingSession(Base): + __tablename__ = "ticketing_session" + + id: Mapped[PrimaryKey] + event_id: Mapped[UUID] = mapped_column(ForeignKey("ticketing_event.id")) + event: Mapped[TicketingEvent] = relationship( + back_populates="sessions", + init=False, + lazy="selectin", + ) + name: Mapped[str] + date: Mapped[datetime] + quota: Mapped[int | None] + used_quota: Mapped[int] + user_quota: Mapped[int | None] + disabled: Mapped[bool] + + categories: Mapped[list["TicketingCategory"]] = relationship( + secondary=CategorySessionAssociation.__table__, + back_populates="sessions", + init=False, + lazy="selectin", + default_factory=list, + ) + + +class TicketingCategory(Base): + __tablename__ = "ticketing_category" + + id: Mapped[PrimaryKey] + event_id: Mapped[UUID] = mapped_column(ForeignKey("ticketing_event.id")) + event: Mapped[TicketingEvent] = relationship( + back_populates="categories", + init=False, + lazy="selectin", + ) + name: Mapped[str] + sessions: Mapped[list["TicketingSession"]] = relationship( + secondary=CategorySessionAssociation.__table__, + back_populates="categories", + init=False, + lazy="selectin", + default_factory=list, + ) + required_mebership: Mapped[UUID | None] = mapped_column( + ForeignKey("core_association_membership.id"), + ) + quota: Mapped[int | None] + used_quota: Mapped[int] + user_quota: Mapped[int | None] + price: Mapped[int] + disabled: Mapped[bool] + + +class TicketingTicket(Base): + __tablename__ = "ticketing_ticket" + + id: Mapped[PrimaryKey] + user_id: Mapped[str] = mapped_column(ForeignKey("core_user.id")) + event_id: Mapped[UUID] = mapped_column(ForeignKey("ticketing_event.id")) + event: Mapped[TicketingEvent] = relationship( + init=False, + lazy="selectin", + ) + category_id: Mapped[UUID] = mapped_column(ForeignKey("ticketing_category.id")) + category: Mapped[TicketingCategory] = relationship( + init=False, + lazy="selectin", + ) + session_id: Mapped[UUID | None] = mapped_column(ForeignKey("ticketing_session.id")) + session: Mapped[TicketingSession | None] = relationship( + init=False, + lazy="selectin", + ) + total: Mapped[int] + created_at: Mapped[datetime] + status: Mapped[str] # TODO: Enum + nb_scan: Mapped[int] + + user: Mapped[models_users.CoreUser] = relationship( + "CoreUser", + lazy="joined", + init=False, + ) diff --git a/app/modules/ticketing/schemas_ticketing.py b/app/modules/ticketing/schemas_ticketing.py new file mode 100644 index 0000000000..ac32802c42 --- /dev/null +++ b/app/modules/ticketing/schemas_ticketing.py @@ -0,0 +1,149 @@ +from datetime import datetime +from typing import Annotated +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.core.users import schemas_users + + +class OrganiserBase(BaseModel): + name: str + + +class OrganiserComplete(OrganiserBase): + id: UUID + store_id: UUID + + model_config = ConfigDict(from_attributes=True) + + +class OrganiserEdit(BaseModel): + name: str | None = None + store_id: UUID | None = None + + +class EventBase(BaseModel): + organiser_id: UUID + name: str + open_date: datetime + close_date: datetime | None = None + quota: int | None = None + user_quota: int | None = None + + +class EventSimple(EventBase): + creator_id: str + id: UUID + disabled: bool + + model_config = ConfigDict(from_attributes=True) + + +class EventComplete(EventSimple): + organiser: "OrganiserComplete" + sessions: list["SessionSimple"] + categories: list["CategorySimple"] + + model_config = ConfigDict(from_attributes=True) + + +class EventUpdate(BaseModel): + name: str | None = None + open_date: datetime | None = None + close_date: datetime | None = None + quota: int | None = None + user_quota: int | None = None + disabled: bool | None = None + + +class SessionBase(BaseModel): + event_id: UUID + date: datetime + name: str + quota: Annotated[int, Field(gt=0)] | None = None + user_quota: Annotated[int, Field(gt=0)] | None = None + + +class SessionSimple(SessionBase): + id: UUID + disabled: bool + + model_config = ConfigDict(from_attributes=True) + + +class SessionComplete(SessionSimple): + event: EventSimple + + model_config = ConfigDict(from_attributes=True) + + +class SessionUpdate(BaseModel): + name: str | None = None + quota: Annotated[int, Field(gt=0)] | None = None + user_quota: Annotated[int, Field(gt=0)] | None = None + disabled: bool | None = None + + +class CategoryBase(BaseModel): + event_id: UUID + name: str + required_mebership: UUID | None = None + quota: Annotated[int, Field(gt=0)] | None = None + user_quota: Annotated[int, Field(gt=0)] | None = None + price: Annotated[int, Field(gt=0)] + + +class CategoryCreate(CategoryBase): + sessions: list[UUID] | None = None + + +class CategorySimple(CategoryBase): + id: UUID + disabled: bool + sessions: list[UUID] | None = None + + model_config = ConfigDict(from_attributes=True) + + +class CategoryComplete(CategorySimple): + event: EventSimple + + model_config = ConfigDict(from_attributes=True) + + +class CategoryUpdate(BaseModel): + name: str | None = None + sessions: list[UUID] | None = None + required_mebership: UUID | None = None + quota: Annotated[int, Field(gt=0)] | None = None + user_quota: Annotated[int, Field(gt=0)] | None = None + price: Annotated[int, Field(gt=0)] | None = None + disabled: bool | None = None + + +class TicketBase(BaseModel): + user_id: str + event_id: UUID + category_id: UUID + session_id: UUID + total: int + + +class TicketSimple(TicketBase): + id: UUID + created_at: datetime + status: str + nb_scan: int + + user: schemas_users.CoreUserSimple + + model_config = ConfigDict(from_attributes=True) + + +class TicketComplete(TicketSimple): + event: EventSimple + category: CategorySimple + session: SessionSimple | None + + model_config = ConfigDict(from_attributes=True) diff --git a/migrations/versions/60-ticketing.py b/migrations/versions/60-ticketing.py new file mode 100644 index 0000000000..08300b23c7 --- /dev/null +++ b/migrations/versions/60-ticketing.py @@ -0,0 +1,131 @@ +"""59-ticketing + +Create Date: 2026-03-19 17:38:31.400400 +""" + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pytest_alembic import MigrationContext + +import sqlalchemy as sa +from alembic import op + +from app.types.sqlalchemy import TZDateTime + +# revision identifiers, used by Alembic. +revision: str = "8ccdd1f377c9" +down_revision: str | None = "7dbe3290e145" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "ticketing_organiser", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("store_id", sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint(["store_id"], ["mypayment_store.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "ticketing_event", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("organiser_id", sa.Uuid(), nullable=False), + sa.Column("creator_id", sa.String(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("open_date", TZDateTime(), nullable=False), + sa.Column("close_date", TZDateTime(), nullable=True), + sa.Column("quota", sa.Integer(), nullable=True), + sa.Column("used_quota", sa.Integer(), nullable=False), + sa.Column("user_quota", sa.Integer(), nullable=True), + sa.Column("disabled", sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(["creator_id"], ["core_user.id"]), + sa.ForeignKeyConstraint(["organiser_id"], ["ticketing_organiser.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "ticketing_category", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("event_id", sa.Uuid(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("required_mebership", sa.Uuid(), nullable=True), + sa.Column("quota", sa.Integer(), nullable=True), + sa.Column("used_quota", sa.Integer(), nullable=False), + sa.Column("user_quota", sa.Integer(), nullable=True), + sa.Column("price", sa.Integer(), nullable=False), + sa.Column("disabled", sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(["event_id"], ["ticketing_event.id"]), + sa.ForeignKeyConstraint( + ["required_mebership"], + ["core_association_membership.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "ticketing_session", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("event_id", sa.Uuid(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("date", TZDateTime(), nullable=False), + sa.Column("quota", sa.Integer(), nullable=True), + sa.Column("used_quota", sa.Integer(), nullable=False), + sa.Column("user_quota", sa.Integer(), nullable=True), + sa.Column("disabled", sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(["event_id"], ["ticketing_event.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "ticketing_category_session", + sa.Column("category_id", sa.Uuid(), nullable=False), + sa.Column("session_id", sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint(["category_id"], ["ticketing_category.id"]), + sa.ForeignKeyConstraint(["session_id"], ["ticketing_session.id"]), + sa.PrimaryKeyConstraint("category_id", "session_id"), + ) + op.create_table( + "ticketing_ticket", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("user_id", sa.String(), nullable=False), + sa.Column("event_id", sa.Uuid(), nullable=False), + sa.Column("category_id", sa.Uuid(), nullable=False), + sa.Column("session_id", sa.Uuid(), nullable=True), + sa.Column("total", sa.Integer(), nullable=False), + sa.Column("created_at", TZDateTime(), nullable=False), + sa.Column("status", sa.String(), nullable=False), + sa.Column("nb_scan", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["category_id"], ["ticketing_category.id"]), + sa.ForeignKeyConstraint(["event_id"], ["ticketing_event.id"]), + sa.ForeignKeyConstraint(["session_id"], ["ticketing_session.id"]), + sa.ForeignKeyConstraint(["user_id"], ["core_user.id"]), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("ticketing_ticket") + op.drop_table("ticketing_category_session") + op.drop_table("ticketing_session") + op.drop_table("ticketing_category") + op.drop_table("ticketing_event") + op.drop_table("ticketing_organiser") + # ### end Alembic commands ### + + +def pre_test_upgrade( + alembic_runner: "MigrationContext", + alembic_connection: sa.Connection, +) -> None: + pass + + +def test_upgrade( + alembic_runner: "MigrationContext", + alembic_connection: sa.Connection, +) -> None: + pass diff --git a/tests/modules/test_ticketing.py b/tests/modules/test_ticketing.py new file mode 100644 index 0000000000..fb2dd275b0 --- /dev/null +++ b/tests/modules/test_ticketing.py @@ -0,0 +1,1633 @@ +import asyncio +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest_asyncio +from fastapi.testclient import TestClient + +from app.core.groups import models_groups +from app.core.groups.groups_type import AccountType, GroupType +from app.core.memberships import models_memberships +from app.core.mypayment import models_mypayment +from app.core.mypayment.types_mypayment import WalletType +from app.core.users import models_users +from app.modules.ticketing import models_ticketing, schemas_ticketing + +# We need to import event_loop for pytest-asyncio routine defined bellow +from app.modules.ticketing.endpoints_ticketing import TicketingPermissions +from tests.commons import ( + add_object_to_db, + create_api_access_token, + create_groups_with_permissions, + create_user_with_groups, +) + +admin_user: models_users.CoreUser +admin_user_token: str +structure_manager_user: models_users.CoreUser +structure_manager_user_token: str + +bde_group: models_groups.CoreGroup + +association_membership: models_memberships.CoreAssociationMembership +association_membership_user: models_memberships.CoreAssociationUserMembership +structure: models_mypayment.Structure + +store_wallet: models_mypayment.Wallet +store: models_mypayment.Store + +organiser: models_ticketing.Organiser + +student_user: models_users.CoreUser + +event1: models_ticketing.TicketingEvent +event2: models_ticketing.TicketingEvent + +session1: models_ticketing.TicketingSession +session2: models_ticketing.TicketingSession +session3: models_ticketing.TicketingSession + +category1: models_ticketing.TicketingCategory + +ticket1: models_ticketing.TicketingTicket +ticket2: models_ticketing.TicketingTicket + + +student_token: str +admin_token: str + + +@pytest_asyncio.fixture(scope="module", autouse=True) +async def init_objects(): + global admin_user, admin_user_token + admin_user = await create_user_with_groups(groups=[GroupType.admin]) + admin_user_token = create_api_access_token(admin_user) + + global bde_group + bde_group = await create_groups_with_permissions( + [TicketingPermissions.manage_events], + "BDE Group", + ) + + global association_membership + association_membership = models_memberships.CoreAssociationMembership( + id=uuid4(), + name="Test Association Membership", + manager_group_id=bde_group.id, + ) + await add_object_to_db(association_membership) + + global structure_manager_user, structure_manager_user_token, structure + + structure_manager_user = await create_user_with_groups(groups=[]) + structure_manager_user_token = create_api_access_token(structure_manager_user) + + structure = models_mypayment.Structure( + id=uuid4(), + name="Test Structure", + creation=datetime.now(UTC), + association_membership_id=association_membership.id, + manager_user_id=structure_manager_user.id, + short_id="ABC", + siege_address_street="123 Test Street", + siege_address_city="Test City", + siege_address_zipcode="12345", + siege_address_country="Test Country", + siret="12345678901234", + iban="FR76 1234 5678 9012 3456 7890 123", + bic="AZERTYUIOP", + ) + await add_object_to_db(structure) + + # Create store + global store_wallet, store + store_wallet = models_mypayment.Wallet( + id=uuid4(), + type=WalletType.STORE, + balance=0, + ) + await add_object_to_db(store_wallet) + + store = models_mypayment.Store( + id=uuid4(), + name="Test Store", + structure_id=structure.id, + wallet_id=store_wallet.id, + creation=datetime.now(UTC), + ) + await add_object_to_db(store) + + global organiser + organiser = models_ticketing.Organiser( + id=uuid4(), + name="Test Organiser", + store_id=store.id, + ) + await add_object_to_db(organiser) + + # Create events + global event1, event2 + event1 = models_ticketing.TicketingEvent( + id=uuid4(), + name="Event 1", + open_date=datetime(2024, 1, 1, tzinfo=UTC), + # Tests will not pass in 2200, will MyECLPay be still around ? :D + close_date=datetime(2200, 12, 31, tzinfo=UTC), + quota=100, + user_quota=20, + used_quota=1, + disabled=False, + creator_id=str(admin_user.id), + organiser_id=organiser.id, + ) + await add_object_to_db(event1) + # Event will be used to test disabled state etc. + event2 = models_ticketing.TicketingEvent( + id=uuid4(), + name="Event 2", + open_date=datetime(2024, 1, 1, tzinfo=UTC), + close_date=datetime(2200, 12, 31, tzinfo=UTC), + quota=3, + user_quota=2, + used_quota=1, + disabled=False, + creator_id=str(admin_user.id), + organiser_id=organiser.id, + ) + await add_object_to_db(event2) + + global session1, session2, session3 + # Create sessions and categories for event1 + session1 = models_ticketing.TicketingSession( + id=uuid4(), + event_id=event1.id, + name="Session 1", + quota=100, + user_quota=20, + used_quota=1, + disabled=False, + date=datetime(2024, 1, 1, tzinfo=UTC), + ) + await add_object_to_db(session1) + session2 = models_ticketing.TicketingSession( + id=uuid4(), + event_id=event1.id, + name="Session 2", + quota=5, + user_quota=1, + used_quota=3, + disabled=False, + date=datetime(2024, 1, 2, tzinfo=UTC), + ) + + await add_object_to_db(session2) + session3 = models_ticketing.TicketingSession( + id=uuid4(), + event_id=event1.id, + name="Session 3", + quota=2, + user_quota=1, + used_quota=0, + disabled=True, + date=datetime(2024, 2, 3, tzinfo=UTC), + ) + await add_object_to_db(session3) + + global category1 + category1 = models_ticketing.TicketingCategory( + id=uuid4(), + event_id=event1.id, + name="Category 1", + quota=5, + user_quota=20, + used_quota=2, + disabled=False, + required_mebership=None, + price=100, + ) + category1.sessions = [session1, session2] + await add_object_to_db(category1) + + student_group = await create_groups_with_permissions( + [TicketingPermissions.access_ticketing], + "group_student", + ) + # await add_object_to_db(student_group) + + # manage_group = await create_groups_with_permissions( + # [TicketingPermissions.manage_events], + # "Group 2", + # ) + + global student_user, student_token + student_user = await create_user_with_groups( + groups=[student_group.id], + account_type=AccountType.student, + ) + student_token = create_api_access_token(student_user) + + global ticket1, ticket2 + ticket1 = models_ticketing.TicketingTicket( + id=uuid4(), + event_id=event1.id, + session_id=session1.id, + category_id=category1.id, + user_id=student_user.id, + status="active", + nb_scan=0, + total=1, + created_at=datetime.now(UTC), + ) + await add_object_to_db(ticket1) + + ticket2 = models_ticketing.TicketingTicket( + id=uuid4(), + event_id=event1.id, + session_id=session2.id, + category_id=category1.id, + user_id=student_user.id, + status="active", + nb_scan=0, + total=1, + created_at=datetime.now(UTC), + ) + await add_object_to_db(ticket2) + + +# Units tests for basic CRUD operations on events, sessions and categories. + +# -------------------------- Test organiser endpoints -------------------------- # + + +async def test_get_organisers_list(client: TestClient): + response = client.get( + "/ticketing/organisers", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 200 + organisers = response.json() + assert isinstance(organisers, list) + assert len(organisers) >= 1 + assert any( + organiser_item["id"] == str(organiser.id) for organiser_item in organisers + ) + + +async def test_get_organiser(client: TestClient): + response = client.get( + f"/ticketing/organisers/{organiser.id}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 200 + organiser_response = response.json() + assert organiser_response["id"] == str(organiser.id) + + response = client.get( + f"/ticketing/organisers/{uuid4()}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 404 + + +# -------------------------- Test event basic cruds -------------------------- # + + +# Get all events +async def test_get_events_list(client: TestClient): + response = client.get( + "/ticketing/events", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 200 + events = response.json() + assert isinstance(events, list) + assert len(events) >= 2 # We created 2 events in the fixture + + +# get event by id +async def test_get_event(client: TestClient): + # Test with event1 (should succeed) + response = client.get( + f"/ticketing/events/{event1.id}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 200 + + # Test with event2 (should succeed) + response = client.get( + f"/ticketing/events/{event2.id}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 200 + + # Test with event_fake (not in DB, should return 404) + response = client.get( + f"/ticketing/events/{uuid4()}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 404 + + +# create event +async def test_create_event(client: TestClient): + new_event_data = { + "name": "New Event", + "open_date": "2024-01-01T00:00:00Z", + "close_date": "2200-12-31T23:59:59Z", + "quota": 10, + "user_quota": 2, + "organiser_id": str(organiser.id), + } + response = client.post( + "/ticketing/events", + json=new_event_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 201 + created_event = response.json() + assert created_event["name"] == new_event_data["name"] + assert created_event["open_date"] == new_event_data["open_date"] + + +# create event without perms +async def test_create_event_without_perms(client: TestClient): + new_event_data = { + "name": "New Event", + "open_date": "2024-01-01T00:00:00Z", + "close_date": "2200-12-31T23:59:59Z", + "quota": 10, + "user_quota": 2, + "organiser_id": str(organiser.id), + } + response = client.post( + "/ticketing/events", + json=new_event_data, + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 403 + + +async def test_create_event_with_invalid_organiser(client: TestClient): + new_event_data = { + "name": "New Event", + "open_date": "2024-01-01T00:00:00Z", + "close_date": "2200-12-31T23:59:59Z", + "quota": 10, + "user_quota": 2, + "organiser_id": str(uuid4()), # Invalid organiser ID + } + response = client.post( + "/ticketing/events", + json=new_event_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 400 + + +# update event +async def test_update_event_as_admin(client: TestClient): + update_data = { + "name": "Updated Event Name", + "quota": 20, + } + response = client.patch( + f"/ticketing/events/{event1.id}", + json=update_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 204 + + +async def test_update_event_as_lambda(client: TestClient): + update_data = { + "name": "Updated Event Name", + "quota": 20, + } + response = client.patch( + f"/ticketing/events/{event1.id}", + json=update_data, + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 403 + + +async def test_update_event_with_invalid_id(client: TestClient): + update_data = { + "name": "Updated Event Name", + "quota": 20, + } + response = client.patch( + f"/ticketing/events/{uuid4()}", + json=update_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 404 + + +# fail to update event with quota less than used_quota +async def test_update_event_with_quota_less_than_used_quota(client: TestClient): + update_data = { + "quota": 0, # event1 has used_quota=1, so this should fail + } + response = client.patch( + f"/ticketing/events/{event1.id}", + json=update_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 400 + + +# test delete event as lambda, should fail +async def test_delete_event_as_lambda(client: TestClient): + response = client.delete( + f"/ticketing/events/{event1.id}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 403 + + +# test delete event as admin, should succeed +async def test_delete_event_as_admin(client: TestClient): + # First create a new event to delete + to_delete_event = models_ticketing.TicketingEvent( + id=uuid4(), + name="To Delete Event", + open_date=datetime(2024, 1, 1, tzinfo=UTC), + close_date=datetime(2200, 12, 31, tzinfo=UTC), + quota=5, + user_quota=2, + used_quota=0, + disabled=False, + creator_id=str(admin_user.id), + organiser_id=organiser.id, + ) + await add_object_to_db(to_delete_event) + response = client.delete( + f"/ticketing/events/{to_delete_event.id}", + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 204 + # Verify that the event is actually deleted + response = client.get( + f"/ticketing/events/{to_delete_event.id}", + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 404 + + +# test delete event as admin with tickets, should fail +async def test_deleted_as_admin_with_tickets(client: TestClient): + # Create a ticket for the event + ticket = models_ticketing.TicketingTicket( + id=uuid4(), + event_id=event1.id, + session_id=session1.id, + category_id=category1.id, + user_id=student_user.id, + status="active", + nb_scan=0, + total=1, + created_at=datetime.now(UTC), + ) + await add_object_to_db(ticket) + # Try to delete the event with existing tickets + response = client.delete( + f"/ticketing/events/{event1.id}", + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 400 + + +# -------------------------- Test session basic cruds -------------------------- # + + +async def test_get_sessions_list(client: TestClient): + response = client.get( + f"/ticketing/events/{event1.id}/sessions", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 200 + sessions = response.json() + assert isinstance(sessions, list) + assert len(sessions) >= 3 # We created 3 sessions for event1 + + +async def test_get_session(client: TestClient): + # Test with session1 (should succeed) + response = client.get( + f"/ticketing/sessions/{session1.id}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 200 + + # Test with session2 (should succeed) + response = client.get( + f"/ticketing/sessions/{session2.id}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 200 + + # Test with session_fake (not in DB, should return 404) + response = client.get( + f"/ticketing/sessions/{uuid4()}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 404 + + +async def test_create_session(client: TestClient): + new_session_data = { + "name": "New Session", + "date": "2024-01-03T00:00:00Z", + "quota": 2, + "user_quota": 1, + "event_id": str(event1.id), + } + response = client.post( + "/ticketing/sessions", + json=new_session_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 201 + created_session = response.json() + assert created_session["name"] == new_session_data["name"] + assert created_session["date"] == new_session_data["date"] + + +# create session without perms +async def test_create_session_without_perms(client: TestClient): + new_session_data = { + "name": "New Session", + "date": "2024-01-03T00:00:00Z", + "quota": 2, + "user_quota": 1, + "event_id": str(event1.id), + } + response = client.post( + "/ticketing/sessions", + json=new_session_data, + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 403 + + +# create session with date before event open date, should fail +async def test_create_session_with_date_before_event_open_date(client: TestClient): + new_session_data = { + "name": "New Session", + "date": "2023-12-31T00:00:00Z", # Before event1 open date + "quota": 2, + "user_quota": 1, + "event_id": str(event1.id), + } + response = client.post( + "/ticketing/sessions", + json=new_session_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 400 + + +# create session with date after event close date, should fail +async def test_create_session_with_date_after_event_close_date(client: TestClient): + new_session_data = { + "name": "New Session", + "date": "2201-01-01T00:00:00Z", # After event1 close date + "quota": 2, + "user_quota": 1, + "event_id": str(event1.id), + } + response = client.post( + "/ticketing/sessions", + json=new_session_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 400 + + +# test create session with negative quota, should fail +async def test_create_session_with_negative_quota(client: TestClient): + new_session_data = { + "name": "New Session", + "date": "2024-01-03T00:00:00Z", + "quota": -1, # Negative quota + "user_quota": 1, + "event_id": str(event1.id), + } + response = client.post( + "/ticketing/sessions", + json=new_session_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 422 + + +# update session as admin +async def test_update_session_as_admin(client: TestClient): + update_data = { + "name": "Updated Session Name", + "quota": 10, + } + response = client.patch( + f"/ticketing/sessions/{session1.id}", + json=update_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 204 + + +# update session as lambda, should fail +async def test_update_session_as_lambda(client: TestClient): + update_data = { + "name": "Updated Session Name", + "quota": 10, + } + response = client.patch( + f"/ticketing/sessions/{session1.id}", + json=update_data, + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 403 + + +# update session with invalid id, should fail +async def test_update_session_with_invalid_id(client: TestClient): + update_data = { + "name": "Updated Session Name", + "quota": 10, + } + response = client.patch( + f"/ticketing/sessions/{uuid4()}", + json=update_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 404 + + +# update session with quota less than used_quota, should fail +async def test_update_session_with_quota_less_than_used_quota(client: TestClient): + update_data = { + "quota": 1, # session2 has used_quota=3 + } + response = client.patch( + f"/ticketing/sessions/{session2.id}", + json=update_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 400 + + +# test delete session as lambda, should fail +async def test_delete_session_as_lambda(client: TestClient): + response = client.delete( + f"/ticketing/sessions/{session2.id}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 403 + + +# test delete session as admin, should succeed +async def test_delete_session_as_admin(client: TestClient): + # First create a new session to delete + to_delete_session = models_ticketing.TicketingSession( + id=uuid4(), + event_id=event1.id, + name="To Delete Session", + quota=2, + user_quota=1, + used_quota=0, + disabled=False, + date=datetime(2024, 1, 3, tzinfo=UTC), + ) + await add_object_to_db(to_delete_session) + response = client.delete( + f"/ticketing/sessions/{to_delete_session.id}", + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 204 + + +# test delete session as admin with tickets, should fail +async def test_delete_session_as_admin_with_tickets(client: TestClient): + # Create a ticket for the session + ticket = models_ticketing.TicketingTicket( + id=uuid4(), + event_id=event1.id, + session_id=session2.id, + category_id=category1.id, + user_id=student_user.id, + status="active", + nb_scan=0, + total=1, + created_at=datetime.now(UTC), + ) + await add_object_to_db(ticket) + # Try to delete the session with existing tickets + response = client.delete( + f"/ticketing/sessions/{session2.id}", + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 400 + + +# test delete session with invalid id, should fail +async def test_delete_session_with_invalid_id(client: TestClient): + response = client.delete( + f"/ticketing/sessions/{uuid4()}", + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 404 + + +# test delete session with categories, should fail +async def test_delete_session_with_categories(client: TestClient): + # session1 is linked to category1, so deleting it should fail + response = client.delete( + f"/ticketing/sessions/{session1.id}", + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 400 + + +# -------------------------- Test category basic cruds -------------------------- # + + +# Get categories by session id +async def test_get_categories_list_by_session(client: TestClient): + response = client.get( + f"/ticketing/sessions/{session1.id}/categories", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 200 + categories = response.json() + assert isinstance(categories, list) + assert len(categories) >= 1 # We created 1 category for event1 + + +# Get categories by event id +async def test_get_categories_list_by_event(client: TestClient): + response = client.get( + f"/ticketing/events/{event1.id}/categories", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 200 + categories = response.json() + assert isinstance(categories, list) + assert len(categories) >= 1 # We created 1 category for event1 + + +async def test_get_category(client: TestClient): + # Test with category1 (should succeed) + response = client.get( + f"/ticketing/categories/{category1.id}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 200 + + # Test with category_fake (not in DB, should return 404) + response = client.get( + f"/ticketing/categories/{uuid4()}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 404 + + +async def test_create_category(client: TestClient): + new_category_data = { + "name": "New Category", + "quota": 2, + "user_quota": 1, + "event_id": str(event1.id), + "price": 50, + "sessions": [str(session1.id), str(session2.id)], + } + response = client.post( + "/ticketing/categories", + json=new_category_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 201 + created_category = response.json() + assert created_category["name"] == new_category_data["name"] + assert created_category["price"] == new_category_data["price"] + + +# create category without perms +async def test_create_category_without_perms(client: TestClient): + new_category_data = { + "name": "New Category", + "quota": 2, + "user_quota": 1, + "event_id": str(event1.id), + "price": 50, + } + response = client.post( + "/ticketing/categories", + json=new_category_data, + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 403 + + +# test create category with incorrect event id, should fail +async def test_create_category_with_incorrect_event_id(client: TestClient): + new_category_data = { + "name": "New Category", + "quota": 2, + "user_quota": 1, + "event_id": str(uuid4()), # Incorrect event ID + "price": 50, + } + response = client.post( + "/ticketing/categories", + json=new_category_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 404 + + +# test create category with incorrect sessions ids, should fail +async def test_create_category_with_incorrect_sessions_ids(client: TestClient): + new_category_data = { + "name": "New Category", + "quota": 2, + "user_quota": 1, + "event_id": str(event1.id), + "price": 50, + "sessions": [str(uuid4()), str(uuid4())], # Incorrect session IDs + } + response = client.post( + "/ticketing/categories", + json=new_category_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 404 + + +async def test_create_category_with_sessions_from_different_event(client: TestClient): + # Create a session for event2 + session_event2 = models_ticketing.TicketingSession( + id=uuid4(), + event_id=event2.id, + name="Session Event 2", + quota=2, + user_quota=1, + used_quota=0, + disabled=False, + date=datetime(2024, 1, 4, tzinfo=UTC), + ) + await add_object_to_db(session_event2) + + new_category_data = { + "name": "New Category", + "quota": 2, + "user_quota": 1, + "event_id": str(event1.id), + "price": 50, + "sessions": [ + str(session1.id), + str(session_event2.id), + ], # session_event2 belongs to event2 + } + response = client.post( + "/ticketing/categories", + json=new_category_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 404 + + +# test create category with negative price, should fail +async def test_create_category_with_negative_price(client: TestClient): + new_category_data = { + "name": "New Category", + "quota": 2, + "user_quota": 1, + "event_id": str(event1.id), + "price": -10, # Negative price + } + response = client.post( + "/ticketing/categories", + json=new_category_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 422 + + +# test update category as admin +async def test_update_category_as_admin(client: TestClient): + update_data = { + "name": "Updated Category Name", + "price": 200, + } + response = client.patch( + f"/ticketing/categories/{category1.id}", + json=update_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 204 + + +# test update category as lambda, should fail +async def test_update_category_as_lambda(client: TestClient): + update_data = { + "name": "Updated Category Name", + "price": 200, + } + response = client.patch( + f"/ticketing/categories/{category1.id}", + json=update_data, + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 403 + + +# test update category with invalid id, should fail +async def test_update_category_with_invalid_id(client: TestClient): + update_data = { + "name": "Updated Category Name", + "price": 200, + } + response = client.patch( + f"/ticketing/categories/{uuid4()}", + json=update_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 404 + + +# test update category with negative price, should fail +async def test_update_category_with_negative_price(client: TestClient): + update_data = { + "price": -50, # Negative price + } + response = client.patch( + f"/ticketing/categories/{category1.id}", + json=update_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 422 + + +# test update category with quota less than used_quota, should fail +async def test_update_category_with_quota_less_than_used_quota(client: TestClient): + update_data = { + "quota": 1, # category1 has used_quota=1 + } + response = client.patch( + f"/ticketing/categories/{category1.id}", + json=update_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 400 + + +# test delete category as lambda, should fail +async def test_delete_category_as_lambda(client: TestClient): + response = client.delete( + f"/ticketing/categories/{category1.id}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 403 + + +# test delete category as admin, should succeed +async def test_delete_category_as_admin(client: TestClient): + # First create a new category to delete + to_delete_category = models_ticketing.TicketingCategory( + id=uuid4(), + event_id=event1.id, + name="To Delete Category", + quota=2, + user_quota=1, + used_quota=0, + disabled=False, + required_mebership=None, + price=50, + ) + await add_object_to_db(to_delete_category) + response = client.delete( + f"/ticketing/categories/{to_delete_category.id}", + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 204 + + +# test delete category as admin with tickets, should fail +async def test_delete_category_as_admin_with_tickets(client: TestClient): + # Create a ticket for the category + ticket = models_ticketing.TicketingTicket( + id=uuid4(), + event_id=event1.id, + session_id=session1.id, + category_id=category1.id, + user_id=student_user.id, + status="active", + nb_scan=0, + total=1, + created_at=datetime.now(UTC), + ) + await add_object_to_db(ticket) + # Try to delete the category with existing tickets + response = client.delete( + f"/ticketing/categories/{category1.id}", + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 400 + + +# test delete category with invalid id, should fail +async def test_delete_category_with_invalid_id(client: TestClient): + response = client.delete( + f"/ticketing/categories/{uuid4()}", + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 404 + + +# -------------------------- Test ticket basic cruds -------------------------- # + + +async def test_get_all_tickets(client: TestClient): + response = client.get( + "/ticketing/tickets", + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 200 + tickets = response.json() + assert isinstance(tickets, list) + assert len(tickets) >= 2 # We created 2 tickets for the student + + +async def test_get_tickets_by_event(client: TestClient): + response = client.get( + f"/ticketing/events/{event1.id}/tickets", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 200 + tickets = response.json() + assert isinstance(tickets, list) + assert len(tickets) >= 2 # We created 2 tickets for event1 + + response_fake_event = client.get( + f"/ticketing/events/{uuid4()}/tickets", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response_fake_event.status_code == 200 + assert ( + response_fake_event.json() == [] + ) # No tickets for fake event, should return empty list + + +async def test_get_tickets_by_session(client: TestClient): + response = client.get( + f"/ticketing/sessions/{session1.id}/tickets", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 200 + tickets = response.json() + assert isinstance(tickets, list) + assert len(tickets) >= 2 # We created 2 tickets for session1 + + response_fake_session = client.get( + f"/ticketing/sessions/{uuid4()}/tickets", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response_fake_session.status_code == 200 + assert ( + response_fake_session.json() == [] + ) # No tickets for fake session, should return empty list + + +async def test_get_tickets_by_category(client: TestClient): + response = client.get( + f"/ticketing/categories/{category1.id}/tickets", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 200 + tickets = response.json() + assert isinstance(tickets, list) + assert len(tickets) >= 2 # We created 2 tickets for category1 + + response_fake_category = client.get( + f"/ticketing/categories/{uuid4()}/tickets", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response_fake_category.status_code == 200 + assert ( + response_fake_category.json() == [] + ) # No tickets for fake category, should return empty list + + +async def test_get_tickets_by_user(client: TestClient): + response = client.get( + f"/ticketing/users/{student_user.id}/tickets", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 200 + tickets = response.json() + assert isinstance(tickets, list) + assert len(tickets) >= 2 # We created 2 tickets for the student + + response_fake_user = client.get( + f"/ticketing/users/{uuid4()}/tickets", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response_fake_user.status_code == 200 + assert ( + response_fake_user.json() == [] + ) # No tickets for fake user, should return empty list + + +async def test_get_my_tickets(client: TestClient): + response = client.get( + "/ticketing/users/me/tickets", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 200 + tickets = response.json() + assert isinstance(tickets, list) + assert all(ticket["user_id"] == str(student_user.id) for ticket in tickets) + + +async def test_get_ticket(client: TestClient): + # Test with ticket1 (should succeed) + response = client.get( + f"/ticketing/tickets/{ticket1.id}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 200 + + # Test with ticket_fake (not in DB, should return 404) + response = client.get( + f"/ticketing/tickets/{uuid4()}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 404 + + +async def test_get_ticket_without_perms(client: TestClient): + # Create a ticket for another user + other_user = await create_user_with_groups( + groups=[], + account_type=AccountType.student, + ) + other_ticket = models_ticketing.TicketingTicket( + id=uuid4(), + event_id=event1.id, + session_id=session1.id, + category_id=category1.id, + user_id=other_user.id, + status="active", + nb_scan=0, + total=1, + created_at=datetime.now(UTC), + ) + await add_object_to_db(other_ticket) + + # Try to get the other user's ticket, should return 404 since the student doesn't have permission to see it + response = client.get( + f"/ticketing/tickets/{other_ticket.id}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 404 + + +async def test_get_ticket_as_admin(client: TestClient): + # Create a ticket for another user + other_user = await create_user_with_groups( + groups=[], + account_type=AccountType.student, + ) + other_ticket = models_ticketing.TicketingTicket( + id=uuid4(), + event_id=event1.id, + session_id=session1.id, + category_id=category1.id, + user_id=other_user.id, + status="active", + nb_scan=0, + total=1, + created_at=datetime.now(UTC), + ) + await add_object_to_db(other_ticket) + + # Try to get the other user's ticket as admin, should succeed + response = client.get( + f"/ticketing/tickets/{other_ticket.id}", + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 200 + + +async def test_create_ticket_as_student(client: TestClient): + new_ticket_data = { + "user_id": str(student_user.id), + "event_id": str(event1.id), + "session_id": str(session1.id), + "category_id": str(category1.id), + "total": 1, + } + response = client.post( + "/ticketing/tickets", + json=new_ticket_data, + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 201 + created_ticket = response.json() + assert created_ticket["event_id"] == new_ticket_data["event_id"] + assert created_ticket["session_id"] == new_ticket_data["session_id"] + assert created_ticket["category_id"] == new_ticket_data["category_id"] + assert created_ticket["total"] == new_ticket_data["total"] + + +async def test_create_ticket_as_student_with_invalid_data(client: TestClient): + # Try to create a ticket with invalid session_id (not linked to the category), should fail + new_ticket_data = { + "user_id": str(student_user.id), + "event_id": str(event1.id), + "session_id": str(session3.id), # session3 is not linked to category1 + "category_id": str(category1.id), + "total": 1, + } + response = client.post( + "/ticketing/tickets", + json=new_ticket_data, + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 400 + + +async def test_create_ticket_as_student_with_quota_exceeded(client: TestClient): + # Try to create a ticket with total exceeding the category quota, should fail + category_with_low_quota = models_ticketing.TicketingCategory( + id=uuid4(), + event_id=event1.id, + name="Low Quota Category", + quota=1, # Only 1 ticket allowed for this category + user_quota=10, + used_quota=0, + disabled=False, + required_mebership=None, + price=50, + ) + category_with_low_quota.sessions = [session1] # Link it to session1 + await add_object_to_db(category_with_low_quota) + + new_ticket_data = { + "user_id": str(student_user.id), + "event_id": str(event1.id), + "session_id": str(session1.id), + "category_id": str(category_with_low_quota.id), + "total": 10, # price of the ticket + } + response = client.post( + "/ticketing/tickets", + json=new_ticket_data, + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 201 + + response = client.post( + "/ticketing/tickets", + json=new_ticket_data, + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 400 + + +async def test_create_ticket_as_student_with_user_quota_exceeded(client: TestClient): + # Try to create a ticket with total exceeding the user quota, should fail + new_event = models_ticketing.TicketingEvent( + id=uuid4(), + name="User Quota Event", + open_date=datetime(2024, 1, 1, tzinfo=UTC), + close_date=datetime(2200, 12, 31, tzinfo=UTC), + quota=10, + user_quota=1, # user_quota=1, so the student can only have 1 ticket for this event + used_quota=0, + disabled=False, + creator_id=str(admin_user.id), + organiser_id=organiser.id, + ) + await add_object_to_db(new_event) + + new_category = models_ticketing.TicketingCategory( + id=uuid4(), + event_id=new_event.id, + name="User Quota Category", + quota=10, + user_quota=1, + used_quota=0, + disabled=False, + required_mebership=None, + price=50, + ) + await add_object_to_db(new_category) + + new_session = models_ticketing.TicketingSession( + id=uuid4(), + event_id=new_event.id, + name="User Quota Session", + quota=10, + user_quota=1, + used_quota=0, + disabled=False, + date=datetime(2024, 1, 2, tzinfo=UTC), + ) + await add_object_to_db(new_session) + + # Create the first ticket, should succeed + first_ticket_data = { + "user_id": str(student_user.id), + "event_id": str(new_event.id), + "session_id": str(new_session.id), + "category_id": str(new_category.id), + "total": 1, + } + + response = client.post( + "/ticketing/tickets", + json=first_ticket_data, + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 201 + + # Create the second ticket, should fail due to user quota exceeded + second_ticket_data = { + "user_id": str(student_user.id), + "event_id": str(new_event.id), + "session_id": str(new_session.id), + "category_id": str(new_category.id), + "total": 1, + } + response = client.post( + "/ticketing/tickets", + json=second_ticket_data, + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 400 + + +async def test_create_ticket_with_category_from_different_event_or_session( + client: TestClient, +): + other_category = models_ticketing.TicketingCategory( + id=uuid4(), + event_id=event2.id, + name="Other Event Category", + quota=10, + user_quota=1, + used_quota=0, + disabled=False, + required_mebership=None, + price=30, + ) + await add_object_to_db(other_category) + + new_ticket_data = { + "user_id": str(student_user.id), + "event_id": str(event1.id), + "session_id": str(session1.id), + "category_id": str(other_category.id), + "total": 1, + } + response = client.post( + "/ticketing/tickets", + json=new_ticket_data, + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 400 + + +async def test_create_ticket_with_session_from_different_event(client: TestClient): + other_session = models_ticketing.TicketingSession( + id=uuid4(), + event_id=event2.id, + name="Other Event Session", + quota=2, + user_quota=1, + used_quota=0, + disabled=False, + date=datetime(2024, 1, 4, tzinfo=UTC), + ) + await add_object_to_db(other_session) + + new_ticket_data = { + "user_id": str(student_user.id), + "event_id": str(event1.id), + "session_id": str(other_session.id), + "category_id": str(category1.id), + "total": 1, + } + response = client.post( + "/ticketing/tickets", + json=new_ticket_data, + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 400 + + +async def test_update_ticket(client: TestClient): + to_update_ticket = models_ticketing.TicketingTicket( + id=uuid4(), + event_id=event1.id, + session_id=session1.id, + category_id=category1.id, + user_id=student_user.id, + status="active", + nb_scan=0, + total=1, + created_at=datetime.now(UTC), + ) + await add_object_to_db(to_update_ticket) + + update_data = { + "user_id": str(student_user.id), + "event_id": str(event1.id), + "session_id": str(session1.id), + "category_id": str(category1.id), + "total": 2, + } + response = client.patch( + f"/ticketing/tickets/{to_update_ticket.id}", + json=update_data, + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 204 + + response = client.get( + f"/ticketing/tickets/{to_update_ticket.id}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 200 + assert response.json()["total"] == update_data["total"] + + +async def test_update_ticket_with_invalid_id(client: TestClient): + update_data = { + "user_id": str(student_user.id), + "event_id": str(event1.id), + "session_id": str(session1.id), + "category_id": str(category1.id), + "total": 2, + } + response = client.patch( + f"/ticketing/tickets/{uuid4()}", + json=update_data, + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 404 + + +async def test_delete_ticket(client: TestClient): + to_delete_ticket = models_ticketing.TicketingTicket( + id=uuid4(), + event_id=event1.id, + session_id=session1.id, + category_id=category1.id, + user_id=student_user.id, + status="active", + nb_scan=0, + total=1, + created_at=datetime.now(UTC), + ) + await add_object_to_db(to_delete_ticket) + response = client.delete( + f"/ticketing/tickets/{to_delete_ticket.id}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 204 + + response = client.get( + f"/ticketing/tickets/{to_delete_ticket.id}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 404 + + +async def test_delete_ticket_with_invalid_id(client: TestClient): + response = client.delete( + f"/ticketing/tickets/{uuid4()}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 404 + + +# -------------------------- Test number of places -------------------------- # + + +# -------------------------- Test Redis cache -------------------------- # + + +async def test_get_event_cache(client: TestClient): + # First get the event to populate the cache + response = client.get( + f"/ticketing/events/{event1.id}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 200 + + # Now get the event again, this time it should be served from cache + response = client.get( + f"/ticketing/events/{event1.id}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 200 + + +async def test_use_or_set_cache_with_crud_function(client: TestClient): + # This test is to directly test the get_or_set_cache function used in the endpoints + from app.modules.ticketing.cache_ticketing import use_or_set_cache_with_crud + + cache_key = f"test_cache_event_key_{uuid4()}" + cache_value = schemas_ticketing.EventSimple( + id=uuid4(), + name="Cached Event", + open_date=datetime(2024, 1, 1, tzinfo=UTC), + close_date=datetime(2200, 12, 31, tzinfo=UTC), + quota=10, + user_quota=2, + used_quota=0, + disabled=False, + creator_id=str(admin_user.id), + organiser_id=organiser.id, + ) + + async def test_crud_function(event_name: str = "Cached - Event"): + # This function simulates a CRUD operation that returns the cache value + cache_value.name = event_name + return cache_value + + # First call should set the cache + result = await use_or_set_cache_with_crud( + None, + cache_key, + test_crud_function, + schemas_ticketing.EventSimple, + expire=60, + event_name="Cached - Event", + ) + assert result == cache_value + + # Second call should get the value from cache, so we change the event name + result = await use_or_set_cache_with_crud( + None, + cache_key, + test_crud_function, + schemas_ticketing.EventSimple, + event_name="Changed Cached - Event", + expire=60, # Invert arg in order to be sure. + ) + assert result == cache_value # Should still return the old value from cache + + +async def test_cache_expiration(client: TestClient): + # This test will check if the cache is invalidated after updating an event + from app.modules.ticketing.cache_ticketing import use_or_set_cache_with_crud + + cache_key = f"test_cache_event_key_{uuid4()}" + cache_value = schemas_ticketing.EventSimple( + id=uuid4(), + name="Cached Event", + open_date=datetime(2024, 1, 1, tzinfo=UTC), + close_date=datetime(2200, 12, 31, tzinfo=UTC), + quota=10, + user_quota=2, + used_quota=0, + disabled=False, + creator_id=str(admin_user.id), + organiser_id=organiser.id, + ) + + async def test_crud_function(event_name: str = "Cached - Event"): + # This function simulates a CRUD operation that returns the cache value + cache_value.name = event_name + return cache_value + + # Set the cache + result = await use_or_set_cache_with_crud( + None, + cache_key, + test_crud_function, + schemas_ticketing.EventSimple, + expire=2, # expire in 2 seconds + event_name="Cached - Event", + ) # expire in 2 seconds + assert result == cache_value + + # Wait for 3 seconds to let the cache expire + await asyncio.sleep(3) + + # Now the cache should be expired, so calling the function should set it again with the new name + result = await use_or_set_cache_with_crud( + None, + cache_key, + test_crud_function, + schemas_ticketing.EventSimple, + expire=60, + event_name="New Cached - Event", + ) + assert ( + result.name == "New Cached - Event" + ) # Should return the new value after cache expiration