diff --git a/app/core/mypayment/cruds_mypayment.py b/app/core/mypayment/cruds_mypayment.py index 1fc5056ee4..2b6e01ec78 100644 --- a/app/core/mypayment/cruds_mypayment.py +++ b/app/core/mypayment/cruds_mypayment.py @@ -270,6 +270,7 @@ async def create_seller( can_see_history: bool, can_cancel: bool, can_manage_sellers: bool, + can_manage_ticketing: bool, db: AsyncSession, ) -> None: wallet = models_mypayment.Seller( @@ -279,6 +280,7 @@ async def create_seller( can_see_history=can_see_history, can_cancel=can_cancel, can_manage_sellers=can_manage_sellers, + can_manage_ticketing=can_manage_ticketing, ) db.add(wallet) @@ -308,6 +310,7 @@ async def get_seller( can_see_history=result.can_see_history, can_cancel=result.can_cancel, can_manage_sellers=result.can_manage_sellers, + can_manage_ticketing=result.can_manage_ticketing, user=schemas_users.CoreUserSimple( id=result.user.id, firstname=result.user.firstname, @@ -339,6 +342,7 @@ async def get_sellers_by_store_id( can_see_history=seller.can_see_history, can_cancel=seller.can_cancel, can_manage_sellers=seller.can_manage_sellers, + can_manage_ticketing=seller.can_manage_ticketing, user=schemas_users.CoreUserSimple( id=seller.user.id, firstname=seller.user.firstname, diff --git a/app/core/mypayment/endpoints_mypayment.py b/app/core/mypayment/endpoints_mypayment.py index a824743e69..498c4405fb 100644 --- a/app/core/mypayment/endpoints_mypayment.py +++ b/app/core/mypayment/endpoints_mypayment.py @@ -397,6 +397,7 @@ async def create_structure_administrator( can_see_history=True, can_cancel=True, can_manage_sellers=True, + can_manage_ticketing=True, db=db, ) else: @@ -408,6 +409,7 @@ async def create_structure_administrator( can_see_history=True, can_cancel=True, can_manage_sellers=True, + can_manage_ticketing=True, ), db=db, ) @@ -475,6 +477,7 @@ async def delete_structure_administrator( seller_update=schemas_mypayment.SellerUpdate( can_cancel=False, can_manage_sellers=False, + can_manage_ticketing=False, ), db=db, ) @@ -617,6 +620,7 @@ async def confirm_structure_manager_transfer( can_see_history=True, can_cancel=True, can_manage_sellers=True, + can_manage_ticketing=True, db=db, ) else: @@ -628,6 +632,7 @@ async def confirm_structure_manager_transfer( can_see_history=True, can_cancel=True, can_manage_sellers=True, + can_manage_ticketing=True, ), db=db, ) @@ -715,6 +720,7 @@ async def create_store( can_see_history=True, can_cancel=True, can_manage_sellers=True, + can_manage_ticketing=True, db=db, ) for admin in structure.administrators: @@ -725,6 +731,7 @@ async def create_store( can_see_history=True, can_cancel=True, can_manage_sellers=True, + can_manage_ticketing=True, db=db, ) @@ -1014,6 +1021,7 @@ async def get_user_stores( can_see_history=seller.can_see_history, can_cancel=seller.can_cancel, can_manage_sellers=seller.can_manage_sellers, + can_manage_ticketing=seller.can_manage_ticketing, ), ) @@ -1164,6 +1172,7 @@ async def create_store_seller( - can_see_history - can_cancel - can_manage_sellers + - can_manage_ticketing **The user must have the `can_manage_sellers` permission for this store** """ @@ -1206,6 +1215,7 @@ async def create_store_seller( can_see_history=seller.can_see_history, can_cancel=seller.can_cancel, can_manage_sellers=seller.can_manage_sellers, + can_manage_ticketing=seller.can_manage_ticketing, db=db, ) diff --git a/app/core/mypayment/models_mypayment.py b/app/core/mypayment/models_mypayment.py index c33e89b282..de97d9a2f2 100644 --- a/app/core/mypayment/models_mypayment.py +++ b/app/core/mypayment/models_mypayment.py @@ -237,6 +237,7 @@ class Seller(Base): can_see_history: Mapped[bool] can_cancel: Mapped[bool] can_manage_sellers: Mapped[bool] + can_manage_ticketing: Mapped[bool] user: Mapped[models_users.CoreUser] = relationship(init=False, lazy="joined") diff --git a/app/core/mypayment/schemas_mypayment.py b/app/core/mypayment/schemas_mypayment.py index 8cd13dbacf..bc5ed22e9d 100644 --- a/app/core/mypayment/schemas_mypayment.py +++ b/app/core/mypayment/schemas_mypayment.py @@ -90,6 +90,7 @@ class UserStore(Store): can_see_history: bool can_cancel: bool can_manage_sellers: bool + can_manage_ticketing: bool class StoreUpdate(BaseModel): @@ -102,6 +103,7 @@ class SellerCreation(BaseModel): can_see_history: bool can_cancel: bool can_manage_sellers: bool + can_manage_ticketing: bool class SellerUpdate(BaseModel): @@ -109,6 +111,7 @@ class SellerUpdate(BaseModel): can_see_history: bool | None = None can_cancel: bool | None = None can_manage_sellers: bool | None = None + can_manage_ticketing: bool | None = None class Seller(BaseModel): @@ -118,6 +121,7 @@ class Seller(BaseModel): can_see_history: bool can_cancel: bool can_manage_sellers: bool + can_manage_ticketing: bool user: schemas_users.CoreUserSimple diff --git a/app/modules/cdr/exception_cdr.py b/app/modules/cdr/exception_cdr.py index 857ca38d2f..0b7a0b17ea 100644 --- a/app/modules/cdr/exception_cdr.py +++ b/app/modules/cdr/exception_cdr.py @@ -23,7 +23,6 @@ class SignatureNotFoundError(Exception): """Raised when a signature is not found for a document.""" def __init__(self, document_id: UUID): - super().__init__( f"Signature with numeric_signature_id {document_id} not found.", ) diff --git a/app/modules/cdr/utils_cdr.py b/app/modules/cdr/utils_cdr.py index ce60a54d75..d28fa77f2c 100644 --- a/app/modules/cdr/utils_cdr.py +++ b/app/modules/cdr/utils_cdr.py @@ -235,7 +235,6 @@ async def start_signature_flow( db: AsyncSession, settings: Settings, ): - for cdr_document in product.document_constraints: existing_signature = await cruds_cdr.get_signature_by_user_id_and_document_id( db=db, @@ -796,7 +795,6 @@ def construct_dataframe_from_users_purchases( users_curriculum: dict[str, str], export_io: BytesIO, ): - product_structure, col_idx = build_product_structure( products, variants, diff --git a/app/modules/raid/utils/validation_checker.py b/app/modules/raid/utils/validation_checker.py index 8741f79460..dfd2c4abf5 100644 --- a/app/modules/raid/utils/validation_checker.py +++ b/app/modules/raid/utils/validation_checker.py @@ -12,7 +12,6 @@ from fastapi import HTTPException -from app.modules.raid import cruds_raid from app.modules.raid.raid_type import ( DocumentValidation, RaidRegistrationStatus, @@ -132,6 +131,8 @@ async def _check_team_complete( participant: schemas_raid.RaidParticipant, db: AsyncSession, ) -> None: + from app.modules.raid import cruds_raid # noqa: PLC0415 + team = await cruds_raid.get_team_by_participant_id( participant.user_id, participant.edition_id, 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..8e672dffca --- /dev/null +++ b/app/modules/ticketing/cache_ticketing.py @@ -0,0 +1,308 @@ +# 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 managed_event_user_permission(event_id: UUID, user_id: str) -> str: + return f"ticketing:event:{event_id}:user:{user_id}:can_manage" + + @staticmethod + def scan_permission_for_seller(event_id: UUID, user_id: str) -> str: + return f"ticketing:event:{event_id}:user:{user_id}:can_scan" + + # @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[**CrudFuncT, SchemaT: BaseModel]( + 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[**CrudFuncT]( + 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, + ) + + +async def get_boolean_from_cache_or_none( + redis: Redis | None, + key: str, +) -> bool | None: + """Get a boolean value from the cache, or None if not found/error.""" + if redis is None or not isinstance(redis, Redis): + return None + + result = redis.get(key) + if result is None: + return None + + try: + return bool(int(result)) + except (ValueError, TypeError): + return None + + +async def get_manage_event_permission_with_cache( + redis: Redis | None, + user_id: str, + event_id: UUID, +) -> bool | None: + """ + Check if the user has permission to manage the event. + Returns: + - True/False if the user has/does not have permission, + - None if key not found or error occurs. + """ + # Use a cache layer to avoid multiple queries for the same event_id and user_id + return await get_boolean_from_cache_or_none( + redis=redis, + key=RedisKeysList.managed_event_user_permission(event_id, user_id), + ) + + +async def get_scan_permission_for_seller_with_cache( + redis: Redis | None, + user_id: str, + event_id: UUID, +) -> bool | None: + """ + Check if the user has permission to scan tickets for the event. + Returns: + - True/False if the user has/does not have permission, + - None if key not found or error occurs. + """ + # Use a cache layer to avoid multiple queries for the same event_id and user_id + return await get_boolean_from_cache_or_none( + redis=redis, + key=RedisKeysList.scan_permission_for_seller(event_id, user_id), + ) + + +async def set_manage_event_permission_cache( + redis: Redis | None, + user_id: str, + event_id: UUID, + has_permission: bool, + expire: int | None = 300, +) -> None: + """Set the cache for manage event permission.""" + if redis is not None and isinstance(redis, Redis): + redis.set( + RedisKeysList.managed_event_user_permission(event_id, user_id), + int(has_permission), + ex=expire, + ) + + +async def set_scan_permission_for_seller_cache( + redis: Redis | None, + user_id: str, + event_id: UUID, + has_permission: bool, + expire: int | None = 300, +) -> None: + """Set the cache for scan permission for seller.""" + if redis is not None and isinstance(redis, Redis): + redis.set( + RedisKeysList.scan_permission_for_seller(event_id, user_id), + int(has_permission), + ex=expire, + ) diff --git a/app/modules/ticketing/cruds_ticketing.py b/app/modules/ticketing/cruds_ticketing.py new file mode 100644 index 0000000000..f3e62085a0 --- /dev/null +++ b/app/modules/ticketing/cruds_ticketing.py @@ -0,0 +1,910 @@ +from uuid import UUID, uuid4 + +from sqlalchemy import delete, select, update +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.modules.ticketing import models_ticketing, schemas_ticketing +from app.modules.ticketing.types_ticketing import TicketStatus +from app.modules.ticketing.utils import schemas_converter_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_converter_ticketing.model_to_event_complete_schema(event) + 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_converter_ticketing.model_to_event_complete_schema(event) + 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_converter_ticketing.model_to_category_complete_schema(category) + 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, + user_groups: list[str] | None = None, +) -> list[schemas_ticketing.CategorySimple]: + """Get all categories for a specific session.""" + + query = ( + select(models_ticketing.TicketingCategory) + .join(models_ticketing.CategorySessionAssociation) + .where( + models_ticketing.CategorySessionAssociation.session_id == session_id, + ) + ) + if user_groups is not None: + query = query.where( + models_ticketing.TicketingCategory.required_mebership.is_(None) + | models_ticketing.TicketingCategory.required_mebership.in_( + user_groups, + ), + ) + + 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( + query, + ) + ) + .scalars() + .all() + ] + + +async def get_categories_by_event_id( + event_id: UUID, + db: AsyncSession, + user_groups: list[str] | None = None, +) -> list[schemas_ticketing.CategorySimple]: + """Get all categories for a specific event.""" + + query = select(models_ticketing.TicketingCategory).where( + models_ticketing.TicketingCategory.event_id == event_id, + ) + if user_groups is not None: + query = query.where( + models_ticketing.TicketingCategory.required_mebership.is_(None) + | models_ticketing.TicketingCategory.required_mebership.in_( + user_groups, + ), + ) + + 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( + query, + ) + ) + .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, + status: list[TicketStatus] | None = None, +) -> list[schemas_ticketing.TicketSimple]: + """Get all tickets.""" + + query = select(models_ticketing.TicketingTicket) + if status is not None: + query = query.where(models_ticketing.TicketingTicket.status.in_(status)) + + tickets = await db.execute(query) + return [ + schemas_converter_ticketing.model_to_ticket_complete_schema(ticket) + for ticket in tickets.scalars().all() + ] + + +async def get_tickets_by_event_id( + event_id: UUID, + db: AsyncSession, + status: list[TicketStatus] | None = None, +) -> list[schemas_ticketing.TicketSimple]: + """Get all tickets for a specific event.""" + + query = select(models_ticketing.TicketingTicket).where( + models_ticketing.TicketingTicket.event_id == event_id, + ) + if status is not None: + query = query.where(models_ticketing.TicketingTicket.status.in_(status)) + + tickets = await db.execute(query) + return [ + schemas_converter_ticketing.model_to_ticket_complete_schema(ticket) + for ticket in tickets.scalars().all() + ] + + +async def get_tickets_by_category_id( + category_id: UUID, + db: AsyncSession, + status: list[TicketStatus] | None = None, +) -> list[schemas_ticketing.TicketSimple]: + """Get all tickets for a specific category.""" + query = select(models_ticketing.TicketingTicket).where( + models_ticketing.TicketingTicket.category_id == category_id, + ) + if status is not None: + query = query.where(models_ticketing.TicketingTicket.status.in_(status)) + + tickets = await db.execute(query) + return [ + schemas_converter_ticketing.model_to_ticket_complete_schema(ticket) + for ticket in tickets.scalars().all() + ] + + +async def get_tickets_by_session_id( + session_id: UUID, + db: AsyncSession, + status: list[TicketStatus] | None = None, +) -> list[schemas_ticketing.TicketSimple]: + """Get all tickets for a specific session.""" + + query = select(models_ticketing.TicketingTicket).where( + models_ticketing.TicketingTicket.session_id == session_id, + ) + if status is not None: + query = query.where(models_ticketing.TicketingTicket.status.in_(status)) + + tickets = await db.execute(query) + return [ + schemas_converter_ticketing.model_to_ticket_complete_schema(ticket) + for ticket in tickets.scalars().all() + ] + + +async def get_tickets_by_user_id( + user_id: str, + db: AsyncSession, + status: list[TicketStatus] | None = None, +) -> list[schemas_ticketing.TicketSimple]: + """Get all tickets for a specific user.""" + + query = select(models_ticketing.TicketingTicket).where( + models_ticketing.TicketingTicket.user_id == user_id, + ) + if status is not None: + query = query.where(models_ticketing.TicketingTicket.status.in_(status)) + + tickets = await db.execute(query) + return [ + schemas_converter_ticketing.model_to_ticket_simple_schema(ticket) + for ticket in tickets.scalars().all() + ] + + +async def get_ticket_by_id( + ticket_id: UUID, + db: AsyncSession, + status: list[TicketStatus] | None = None, +) -> schemas_ticketing.TicketComplete | None: + """Get a ticket by its ID.""" + + query = select(models_ticketing.TicketingTicket).where( + models_ticketing.TicketingTicket.id == ticket_id, + ) + if status is not None: + query = query.where(models_ticketing.TicketingTicket.status.in_(status)) + + ticket = (await db.execute(query)).scalars().first() + + return ( + schemas_converter_ticketing.model_to_ticket_complete_schema(ticket) + if ticket + else None + ) + + +async def get_ticket_by_secret( + secret: UUID, + db: AsyncSession, +) -> schemas_ticketing.TicketComplete | None: + """Get a ticket by its secret. Sould be a valid ticket (Confirmed Status)""" + + query = select(models_ticketing.TicketingTicket).where( + models_ticketing.TicketingTicket.secret == secret, + ) + query = query.where( + models_ticketing.TicketingTicket.status == TicketStatus.CONFIRMED, + ) + + ticket = (await db.execute(query)).scalars().first() + + return ( + schemas_converter_ticketing.model_to_ticket_complete_schema(ticket) + if ticket + else None + ) + + +async def create_ticket( + db: AsyncSession, + ticket: schemas_ticketing.TicketSimple, +) -> None: + """Create a new ticket.""" + + ticket_dict = ticket.model_dump(exclude={"user"}) + ticket_dict["secret"] = uuid4() # Generate a new secret for the ticket + + db.add( + models_ticketing.TicketingTicket(**ticket_dict), + ) + 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 increment_ticket_scan_count( + db: AsyncSession, + ticket_id: UUID, +) -> None: + """Increment the scan count of a ticket.""" + + await db.execute( + update(models_ticketing.TicketingTicket) + .where(models_ticketing.TicketingTicket.id == ticket_id) + .values(nb_scan=models_ticketing.TicketingTicket.nb_scan + 1), + ) + await db.flush() + + +async def change_ticket_status( + db: AsyncSession, + ticket_id: UUID, + new_status: TicketStatus, +) -> None: + """Change the status of an existing ticket.""" + + await db.execute( + update(models_ticketing.TicketingTicket) + .where(models_ticketing.TicketingTicket.id == ticket_id) + .values(status=new_status), + ) + 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() + + +async def get_ticket_secret( + db: AsyncSession, + ticket_id: UUID, +) -> schemas_ticketing.TicketSecret | None: + """Get the secret of 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.TicketSecret( + secret=ticket.secret, + user_id=ticket.user_id, + ticket_id=ticket.id, + ) + if ticket + else None + ) diff --git a/app/modules/ticketing/endpoints_ticketing.py b/app/modules/ticketing/endpoints_ticketing.py new file mode 100644 index 0000000000..e9c9940cc9 --- /dev/null +++ b/app/modules/ticketing/endpoints_ticketing.py @@ -0,0 +1,1235 @@ +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +from fastapi import APIRouter, Depends, HTTPException, Query +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 cruds_users, models_users +from app.core.users.schemas_users import CoreUserSimple +from app.dependencies import ( + get_db, + get_redis_client, + is_user, + is_user_allowed_to, + is_user_in, +) +from app.modules.ticketing import cache_ticketing, cruds_ticketing, schemas_ticketing +from app.modules.ticketing.factory_ticketing import TicketingFactory +from app.modules.ticketing.types_ticketing import TicketStatus +from app.modules.ticketing.utils.permissions_check_ticketing import ( + check_manage_event_for_organiser_by_user, + check_manage_event_permission_for_user, + check_scan_permission_for_seller, +) +from app.types.module import Module + + +class TicketingPermissions(ModulePermissions): + access_ticketing = "access_ticketing" + + +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), + redis: Redis | None = Depends(get_redis_client), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.access_ticketing]), + ), +) -> 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") + # check if the user has permission to create an event for the organiser + await check_manage_event_for_organiser_by_user( + db=db, + redis=redis, + user=user, + organiser_id=event.organiser_id, + ) + 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.access_ticketing]), + ), +) -> None: + """Update an existing event.""" + await check_manage_event_permission_for_user( + user=user, + db=db, + redis=redis, + event_id=event_id, + ) + 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), + redis: Redis | None = Depends(get_redis_client), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.access_ticketing]), + ), +) -> None: + """Delete an existing event.""" + await check_manage_event_permission_for_user( + user=user, + db=db, + redis=redis, + event_id=event_id, + ) + 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", + ) + 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") + if len(event.categories) > 0: + raise HTTPException( + status_code=400, + detail="Cannot delete an event with associated categories", + ) + if len(event.sessions) > 0: + raise HTTPException( + status_code=400, + detail="Cannot delete an event with associated sessions", + ) + 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), + redis: Redis | None = Depends(get_redis_client), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.access_ticketing]), + ), +) -> schemas_ticketing.SessionComplete: + """Create a new session.""" + await check_manage_event_permission_for_user( + user=user, + db=db, + redis=redis, + event_id=session.event_id, + ) + 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), + redis: Redis | None = Depends(get_redis_client), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.access_ticketing]), + ), +) -> None: + """Update an existing session.""" + 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") + await check_manage_event_permission_for_user( + user=user, + db=db, + redis=redis, + event_id=session.event_id, + ) + 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), + redis: Redis | None = Depends(get_redis_client), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.access_ticketing]), + ), +) -> None: + """Delete an existing session.""" + 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") + await check_manage_event_permission_for_user( + user=user, + db=db, + redis=redis, + event_id=session.event_id, + ) + 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), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.access_ticketing]), + ), + display_all: bool = Query( + default=False, + description="Display all categories, even if the member has not the correct memberships", + ), +) -> list[schemas_ticketing.CategorySimple]: + """Get all categories for an event.""" + user_groups = ( + [group.id for group in user.groups] + if user is not None and not display_all + else None + ) + return await cruds_ticketing.get_categories_by_event_id( + event_id=event_id, + db=db, + user_groups=user_groups, + ) + + +@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), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.access_ticketing]), + ), + display_all: bool = Query( + default=False, + description="Display all categories, even if the member has not the correct memberships", + ), +) -> list[schemas_ticketing.CategorySimple]: + """Get all categories for a session.""" + user_groups = ( + [group.id for group in user.groups] + if user is not None and not display_all + else None + ) + return await cruds_ticketing.get_categories_by_session_id( + session_id=session_id, + db=db, + user_groups=user_groups, + ) + + +@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), + redis: Redis | None = Depends(get_redis_client), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.access_ticketing]), + ), +) -> 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") + await check_manage_event_permission_for_user( + user=user, + db=db, + event_id=category.event_id, + redis=redis, + ) + # 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.access_ticketing]), + ), +) -> None: + """Update an existing category.""" + 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") + await check_manage_event_permission_for_user( + user=user, + db=db, + event_id=category.event_id, + redis=redis, + ) + 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), + redis: Redis | None = Depends(get_redis_client), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.access_ticketing]), + ), +) -> None: + """Delete an existing category.""" + 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") + await check_manage_event_permission_for_user( + user=user, + db=db, + event_id=category.event_id, + redis=redis, + ) + 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), + redis: Redis | None = Depends(get_redis_client), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.access_ticketing]), + ), +) -> schemas_ticketing.TicketComplete | None: + """Get a ticket by its ID. Only for the user who owns the ticket or for scanners of the event.""" + 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: + return ticket + + await check_scan_permission_for_seller( + user=user, + db=db, + redis=redis, + ticket=ticket, + ) + 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), + user: models_users.CoreUser = Depends( + is_user_in(GroupType.admin), + ), +) -> list[schemas_ticketing.TicketSimple]: + """Get all tickets. Admins only.""" + 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), + redis: Redis | None = Depends(get_redis_client), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.access_ticketing]), + ), +) -> list[schemas_ticketing.TicketSimple]: + """Get all tickets for an event. Only accessible by event organisers.""" + await check_manage_event_permission_for_user( + user=user, + db=db, + event_id=event_id, + redis=redis, + ) + 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), + redis: Redis | None = Depends(get_redis_client), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.access_ticketing]), + ), +) -> list[schemas_ticketing.TicketSimple]: + """Get all tickets for a session. Only accessible by event organisers.""" + 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") + await check_manage_event_permission_for_user( + user=user, + db=db, + event_id=session.event_id, + redis=redis, + ) + 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), + redis: Redis | None = Depends(get_redis_client), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.access_ticketing]), + ), +) -> list[schemas_ticketing.TicketSimple]: + """Get all tickets for a category. Only accessible by event organisers""" + 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") + await check_manage_event_permission_for_user( + user=user, + db=db, + event_id=category.event_id, + redis=redis, + ) + return await cruds_ticketing.get_tickets_by_category_id( + category_id=category_id, + db=db, + ) + + +# users/me/tickets should be declared before /users/{user_id}/tickets to avoid path conflicts with FastAPI +@module.router.get( + "/ticketing/users/me/tickets/", + summary="Get all tickets for the current 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_allowed_to([TicketingPermissions.access_ticketing]), + ), +) -> list[schemas_ticketing.TicketSimple]: + """Get all tickets for the current user.""" + return await cruds_ticketing.get_tickets_by_user_id(user_id=user.id, db=db) + + +@module.router.get( + "/ticketing/users/me/tickets/{ticket_id}/secret/", + summary="Get the ticket secret for the current user", + response_model=schemas_ticketing.TicketSecret, + status_code=200, +) +async def get_my_ticket_secret( + ticket_id: UUID, + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.access_ticketing]), + ), +) -> schemas_ticketing.TicketSecret: + """Get the ticket secret for the current user.""" + ticket_secret = await cruds_ticketing.get_ticket_secret(ticket_id=ticket_id, db=db) + if ticket_secret is None: + raise HTTPException(status_code=404, detail="Ticket not found") + if ticket_secret.user_id != user.id: + raise HTTPException( + status_code=403, + detail="Users can only access their own tickets secret", + ) + return ticket_secret + + +@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), + user: models_users.CoreUser = Depends(is_user_in(GroupType.admin)), +) -> list[schemas_ticketing.TicketSimple]: + """Get all tickets for a user. Only accessible by admins.""" + target_user = await cruds_users.get_user_by_id_schema(user_id=user_id, db=db) + if target_user is None: + raise HTTPException(status_code=404, detail="User not found") + 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_in( + GroupType.admin, + )(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), + user=CoreUserSimple( + id=user.id, + firstname=user.firstname, + name=user.name, + nickname=user.nickname, + account_type=user.account_type, + school_id=user.school.id if user.school else None, + ), + ) + + # 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", + ) + + # Check membership requirements for the ticket category, if any + if category.required_mebership is not None: + if category.required_mebership not in [group.id for group in user.groups]: + raise HTTPException( + status_code=403, + detail="User does not have the required membership for this category", + ) + + 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), + redis_client: Redis | None = Depends(get_redis_client), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.access_ticketing]), + ), +) -> 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") + if stored.status == TicketStatus.CONFIRMED: + raise HTTPException( + status_code=400, + detail="Cannot update a confirmed ticket", + ) + await check_manage_event_permission_for_user( + db=db, + user=user, + event_id=stored.event_id, + redis=redis_client, + ) + 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), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.access_ticketing]), + ), + redis_client: Redis | None = Depends(get_redis_client), +) -> 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") + if stored.status == TicketStatus.CONFIRMED: + raise HTTPException( + status_code=400, + detail="Cannot delete a confirmed ticket", + ) + if stored.status == TicketStatus.CANCELLED: + raise HTTPException( + status_code=400, + detail="Ticket is already cancelled", + ) + if stored.user_id != user.id and GroupType.admin not in [ + group.id for group in user.groups + ]: # TODO: not the right permission, should be linked to Seller perm + raise HTTPException( + status_code=403, + detail="Users can only delete their own tickets or should have the right permissions to manage events", + ) + # Now we will mark the ticket as cancelled instead of deleting it to keep track of the quota and for historical data + await cruds_ticketing.change_ticket_status( + db=db, + ticket_id=ticket_id, + new_status=TicketStatus.CANCELLED, + ) + # Then we give back the quota to the event, category and session + cache_ticketing.update_cache_for_new_ticket( + redis=redis_client, + event_id=stored.event_id, + category_id=stored.category_id, + session_id=stored.session_id, + amount=-1, # We add -1 to the quota to give back the quota to the event, category and session + ) + + # 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) + + +@module.router.get( + "/ticketing/tickets/{ticket_id}/scan/{secret}", + summary="Scan a ticket", + response_model=None, + status_code=200, +) +async def scan_ticket( + ticket_id: UUID, + secret: UUID, + db: AsyncSession = Depends(get_db), + redis_client: Redis | None = Depends(get_redis_client), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.access_ticketing]), + ), +) -> None: + """Scan a ticket.""" + stored_ticket = await cruds_ticketing.get_ticket_by_secret(secret=secret, db=db) + if stored_ticket is None: + raise HTTPException(status_code=404, detail="Ticket not found") + if stored_ticket.id != ticket_id: + raise HTTPException( + status_code=400, + detail="Ticket ID does not match the secret", + ) + # Check seller permissions to scan the ticket + await check_scan_permission_for_seller( + db=db, + redis=redis_client, + user=user, + ticket=stored_ticket, + ) + if stored_ticket.status != TicketStatus.CONFIRMED: + raise HTTPException( + status_code=400, + detail="Ticket has invalid status for scanning, probably not paid yet.", + ) + + await cruds_ticketing.increment_ticket_scan_count(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..8872dd2482 --- /dev/null +++ b/app/modules/ticketing/factory_ticketing.py @@ -0,0 +1,176 @@ +import random +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.groups import groups_type +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, types_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=types_ticketing.TicketStatus.CONFIRMED, + user=schemas_users.CoreUserSimple( + id=user_id, + name=f"Raizor-{_i}", + firstname=f"Raiz-{_i}", + account_type=groups_type.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..1170b9a12c --- /dev/null +++ b/app/modules/ticketing/models_ticketing.py @@ -0,0 +1,156 @@ +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.modules.ticketing.types_ticketing import TicketStatus +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[TicketStatus] + nb_scan: Mapped[int] + secret: Mapped[UUID] + + 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..a518691056 --- /dev/null +++ b/app/modules/ticketing/schemas_ticketing.py @@ -0,0 +1,156 @@ +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 + date: datetime | 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) + + +class TicketSecret(BaseModel): + secret: UUID + user_id: str + ticket_id: UUID diff --git a/app/modules/ticketing/types_ticketing.py b/app/modules/ticketing/types_ticketing.py new file mode 100644 index 0000000000..192156490d --- /dev/null +++ b/app/modules/ticketing/types_ticketing.py @@ -0,0 +1,9 @@ +from enum import StrEnum + + +class TicketStatus(StrEnum): + PENDING = ( + "pending" # The ticket has been created but not yet confirmed or paid for. + ) + CONFIRMED = "confirmed" # The ticket has been confirmed and is valid for entry. + CANCELLED = "cancelled" # The ticket has been cancelled. diff --git a/app/modules/ticketing/utils/__init__.py b/app/modules/ticketing/utils/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/modules/ticketing/utils/permissions_check_ticketing.py b/app/modules/ticketing/utils/permissions_check_ticketing.py new file mode 100644 index 0000000000..b772f3c0b9 --- /dev/null +++ b/app/modules/ticketing/utils/permissions_check_ticketing.py @@ -0,0 +1,156 @@ +from uuid import UUID + +from fastapi import HTTPException +from redis import Redis +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.mypayment import cruds_mypayment +from app.core.users import models_users +from app.modules.ticketing import cache_ticketing, cruds_ticketing, schemas_ticketing + + +async def check_scan_permission_for_seller( + db: AsyncSession, + redis: Redis | None, + user: models_users.CoreUser, + ticket: schemas_ticketing.TicketComplete, +): + """Check if the user has permission to scan the ticket.""" + cache_result = await cache_ticketing.get_scan_permission_for_seller_with_cache( + redis=redis, + user_id=user.id, + event_id=ticket.event.id, + ) + if cache_result is not None: + if not cache_result: + raise HTTPException( + status_code=403, + detail="User does not have permission to scan tickets for this organiser", + ) + return # User has permission, no need to check the database + + organiser = await cruds_ticketing.get_organiser_by_id( + organiser_id=ticket.event.organiser_id, + db=db, + ) + if organiser is None: + raise HTTPException(status_code=404, detail="Organiser not found") + store = await cruds_mypayment.get_store( + store_id=organiser.store_id, + db=db, + ) + if store is None: + raise HTTPException(status_code=404, detail="Store not found") + + seller = await cruds_mypayment.get_seller( + store_id=organiser.store_id, + user_id=user.id, + db=db, + ) + + if seller is None or not seller.can_bank: + await cache_ticketing.set_scan_permission_for_seller_cache( + redis=redis, + user_id=user.id, + event_id=ticket.event.id, + has_permission=False, + ) + raise HTTPException( + status_code=403, + detail="User does not have permission to scan tickets for this organiser", + ) + + await cache_ticketing.set_scan_permission_for_seller_cache( + redis=redis, + user_id=user.id, + event_id=ticket.event.id, + has_permission=True, + ) + + +async def check_manage_event_permission_for_user( + db: AsyncSession, + redis: Redis | None, + user: models_users.CoreUser, + event_id: UUID, +): + """Check if the user has permission to manage the event.""" + # Use a cache layer to avoid multiple queries for the same event_id and user_id + cache_result = await cache_ticketing.get_manage_event_permission_with_cache( + redis=redis, + user_id=user.id, + event_id=event_id, + ) + if cache_result is not None: + if not cache_result: + raise HTTPException( + status_code=403, + detail="User does not have permission to manage this event", + ) + return # User has permission, no need to check the database + 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") + + try: + await check_manage_event_for_organiser_by_user( + db=db, + redis=redis, + user=user, + organiser_id=event.organiser_id, + ) + except HTTPException: + await cache_ticketing.set_manage_event_permission_cache( + redis=redis, + user_id=user.id, + event_id=event.id, + has_permission=False, + ) + raise + except Exception: + raise + + await cache_ticketing.set_manage_event_permission_cache( + redis=redis, + user_id=user.id, + event_id=event.id, + has_permission=True, + ) + + +async def check_manage_event_for_organiser_by_user( + db: AsyncSession, + redis: Redis | None, + user: models_users.CoreUser, + organiser_id: UUID, +): + """Check if the user has permission to manage the organiser.""" + organiser = await cruds_ticketing.get_organiser_by_id( + organiser_id=organiser_id, + db=db, + ) + if organiser is None: + raise HTTPException(status_code=404, detail="Organiser not found") + store = await cruds_mypayment.get_store( + store_id=organiser.store_id, + db=db, + ) + if store is None: + raise HTTPException(status_code=404, detail="Store not found") + + seller = await cruds_mypayment.get_seller( + store_id=organiser.store_id, + user_id=user.id, + db=db, + ) + + if ( + seller is None or not seller.can_manage_ticketing + ): # TODO: check if : or not seller.can_manage_events + raise HTTPException( + status_code=403, + detail="User does not have permission to manage this organiser", + ) diff --git a/app/modules/ticketing/utils/schemas_converter_ticketing.py b/app/modules/ticketing/utils/schemas_converter_ticketing.py new file mode 100644 index 0000000000..a4fb8bc1a7 --- /dev/null +++ b/app/modules/ticketing/utils/schemas_converter_ticketing.py @@ -0,0 +1,164 @@ +from app.core.users import schemas_users +from app.modules.ticketing import models_ticketing, schemas_ticketing + + +def model_to_ticket_complete_schema( + ticket: models_ticketing.TicketingTicket, +) -> schemas_ticketing.TicketComplete: + """ + Convert a TicketingTicket model to a TicketComplete schema. + """ + 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, + ) + + +def model_to_ticket_simple_schema( + ticket: models_ticketing.TicketingTicket, +) -> schemas_ticketing.TicketSimple: + """ + Convert a TicketingTicket model to a TicketSimple schema. + """ + 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, + ), + ) + + +def model_to_category_complete_schema( + category: models_ticketing.TicketingCategory, +) -> schemas_ticketing.CategoryComplete: + """ + Convert a TicketingCategory model to a CategoryComplete schema. + """ + 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, + ) + + +def model_to_event_complete_schema( + event: models_ticketing.TicketingEvent, +) -> schemas_ticketing.EventComplete: + 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 + ], + ) diff --git a/migrations/versions/67_ticketing.py b/migrations/versions/67_ticketing.py new file mode 100644 index 0000000000..0f0109645a --- /dev/null +++ b/migrations/versions/67_ticketing.py @@ -0,0 +1,149 @@ +"""ticketing + +Create Date: 2026-09-06 14:21:54.901620 +""" + +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 sqlalchemy.dialects import postgresql + +from app.modules.ticketing.types_ticketing import TicketStatus +from app.types.sqlalchemy import TZDateTime + +# revision identifiers, used by Alembic. +revision: str = "b2be3cc43de1" +down_revision: str | None = "320892a84fd8" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +ticket_status_enum = postgresql.ENUM(TicketStatus, name="ticketstatus") + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + ticket_status_enum.create(op.get_bind()) + 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", + postgresql.ENUM(TicketStatus, name="ticketstatus", create_type=False), + nullable=False, + ), + sa.Column("nb_scan", sa.Integer(), nullable=False), + sa.Column("secret", sa.Uuid(), 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"), + ) + # add can_manage_ticketing column to mypayment_seller table + op.add_column( + "mypayment_seller", + sa.Column("can_manage_ticketing", sa.Boolean(), nullable=False, server_default=sa.false()), + ) + # ### 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") + ticket_status_enum.drop(op.get_bind()) + op.drop_column("mypayment_seller", "can_manage_ticketing") + # ### 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/core/test_mypayment.py b/tests/core/test_mypayment.py index 09e5ca0ca9..5aca0025c9 100644 --- a/tests/core/test_mypayment.py +++ b/tests/core/test_mypayment.py @@ -97,6 +97,7 @@ store_seller_can_bank_user_access_token: str store_seller_can_cancel_user_access_token: str store_seller_can_manage_sellers_user_access_token: str +store_seller_can_manage_ticketing_user_access_token: str store_seller_can_see_history_user_access_token: str @@ -351,6 +352,7 @@ async def init_objects() -> None: can_see_history=True, can_cancel=True, can_manage_sellers=True, + can_manage_ticketing=True, ) await add_object_to_db(manager_as_admin) @@ -479,6 +481,7 @@ async def init_objects() -> None: can_see_history=False, can_cancel=False, can_manage_sellers=False, + can_manage_ticketing=False, ) await add_object_to_db(store_seller_no_permission) @@ -496,6 +499,7 @@ async def init_objects() -> None: can_see_history=False, can_cancel=False, can_manage_sellers=False, + can_manage_ticketing=False, ) await add_object_to_db(store_seller_can_bank) @@ -513,6 +517,7 @@ async def init_objects() -> None: can_see_history=False, can_cancel=True, can_manage_sellers=False, + can_manage_ticketing=False, ) await add_object_to_db(store_seller_can_cancel) @@ -530,6 +535,7 @@ async def init_objects() -> None: can_see_history=False, can_cancel=False, can_manage_sellers=True, + can_manage_ticketing=False, ) await add_object_to_db(store_seller_can_manage_sellers) @@ -544,12 +550,15 @@ async def init_objects() -> None: can_see_history=True, can_cancel=False, can_manage_sellers=False, + can_manage_ticketing=False, ) await add_object_to_db(store_seller_can_see_history_seller) store_seller_can_see_history_user_access_token = create_api_access_token( store_seller_can_see_history_user, ) + # TODO: Do I need to do tests for can_manage_ticketing here or in ticketing module tests? + global unregistered_ecl_user_access_token unregistered_ecl_user = await create_user_with_groups( groups=[], @@ -766,6 +775,7 @@ async def test_add_structure_administrator_as_manager(client: TestClient): assert sellers.json()[0]["can_cancel"] is True assert sellers.json()[0]["can_manage_sellers"] is True assert sellers.json()[0]["can_see_history"] is True + assert sellers.json()[0]["can_manage_ticketing"] is True async def test_delete_structure_administrator_as_manager(client: TestClient): @@ -804,6 +814,7 @@ async def test_delete_structure_administrator_as_manager(client: TestClient): assert sellers.json()[0]["can_cancel"] is False assert sellers.json()[0]["can_manage_sellers"] is False assert sellers.json()[0]["can_see_history"] is True + assert sellers.json()[0]["can_manage_ticketing"] is False async def test_transfer_non_existing_structure_manager(client: TestClient): @@ -922,6 +933,7 @@ async def test_transfer_structure_manager_as_manager( can_see_history=False, can_cancel=False, can_manage_sellers=False, + can_manage_ticketing=False, ) await add_object_to_db(seller) @@ -1351,6 +1363,7 @@ async def test_delete_store(client: TestClient): can_see_history=True, can_cancel=True, can_manage_sellers=True, + can_manage_ticketing=True, ) await add_object_to_db(sellet) @@ -1409,6 +1422,7 @@ async def test_add_seller_for_non_existing_store(client: TestClient): "can_see_history": True, "can_cancel": True, "can_manage_sellers": True, + "can_manage_ticketing": True, }, ) assert response.status_code == 404 @@ -1425,6 +1439,7 @@ async def test_add_seller_as_lambda(client: TestClient): "can_see_history": True, "can_cancel": True, "can_manage_sellers": True, + "can_manage_ticketing": True, }, ) assert response.status_code == 403 @@ -1449,6 +1464,7 @@ async def test_add_seller_as_seller_with_permission(client: TestClient): "can_see_history": True, "can_cancel": True, "can_manage_sellers": True, + "can_manage_ticketing": True, }, ) assert response.status_code == 201 @@ -1469,6 +1485,7 @@ async def test_add_seller_as_seller_without_permission(client: TestClient): "can_see_history": True, "can_cancel": True, "can_manage_sellers": True, + "can_manage_ticketing": True, }, ) assert response.status_code == 403 @@ -1489,6 +1506,7 @@ async def test_add_already_existing_seller(client: TestClient): can_see_history=True, can_cancel=True, can_manage_sellers=False, + can_manage_ticketing=False, ) await add_object_to_db(seller) @@ -1503,6 +1521,7 @@ async def test_add_already_existing_seller(client: TestClient): "can_see_history": True, "can_cancel": True, "can_manage_sellers": True, + "can_manage_ticketing": True, }, ) assert response.status_code == 400 @@ -1564,6 +1583,7 @@ async def test_update_seller_of_non_existing_store(client: TestClient): "can_see_history": True, "can_cancel": False, "can_manage_sellers": False, + "can_manage_ticketing": False, }, ) assert response.status_code == 404 @@ -1579,6 +1599,7 @@ async def test_update_seller_as_lambda(client: TestClient): "can_see_history": True, "can_cancel": False, "can_manage_sellers": False, + "can_manage_ticketing": False, }, ) assert response.status_code == 403 @@ -1599,6 +1620,7 @@ async def test_update_seller_as_seller_without_permission(client: TestClient): "can_see_history": False, "can_cancel": False, "can_manage_sellers": False, + "can_manage_ticketing": False, }, ) assert response.status_code == 403 @@ -1619,6 +1641,7 @@ async def test_update_non_existing_seller(client: TestClient): can_see_history=False, can_cancel=False, can_manage_sellers=False, + can_manage_ticketing=False, ) await add_object_to_db(seller) response = client.patch( @@ -1646,6 +1669,7 @@ async def test_update_seller_as_seller_with_permission(client: TestClient): can_see_history=False, can_cancel=False, can_manage_sellers=False, + can_manage_ticketing=False, ) await add_object_to_db(seller) response = client.patch( @@ -1675,6 +1699,7 @@ async def test_update_seller_as_seller_with_permission(client: TestClient): assert seller_json["can_see_history"] is True assert seller_json["can_cancel"] is False assert seller_json["can_manage_sellers"] is False + assert seller_json["can_manage_ticketing"] is False async def test_update_manager_seller(client: TestClient): @@ -1688,6 +1713,7 @@ async def test_update_manager_seller(client: TestClient): "can_see_history": False, "can_cancel": False, "can_manage_sellers": False, + "can_manage_ticketing": False, }, ) assert response.status_code == 400 @@ -1754,6 +1780,7 @@ async def test_delete_seller_as_seller_with_permission(client: TestClient): can_see_history=False, can_cancel=False, can_manage_sellers=False, + can_manage_ticketing=False, ) await add_object_to_db(seller) response = client.delete( diff --git a/tests/modules/cdr/test_cdr.py b/tests/modules/cdr/test_cdr.py index 8a73517068..84f59c3be2 100644 --- a/tests/modules/cdr/test_cdr.py +++ b/tests/modules/cdr/test_cdr.py @@ -1622,7 +1622,6 @@ def test_create_purchase_user(client: TestClient): def test_create_purchase_seller(client: TestClient, mocker: MockerFixture): - mocked_use_template_for_user = mocker.patch( "app.core.documents.utils_documents.use_template_for_user", return_value=schemas_documents.Document( @@ -1749,7 +1748,6 @@ def test_create_signature_user(client: TestClient): def test_create_signature_seller_existing_signature(client: TestClient): - response = client.post( f"/cdr/users/{user_admin.id}/signatures/{document.id}/", json={ @@ -1834,7 +1832,6 @@ def test_validate_purchase_admin_with_non_validated_signature(client: TestClient def test_validate_purchase_admin(client: TestClient): - response = client.patch( f"/cdr/users/{user.id}/purchases/{variant.id}/validated/?validated=True", headers={"Authorization": f"Bearer {token_admin}"}, diff --git a/tests/modules/test_ticketing.py b/tests/modules/test_ticketing.py new file mode 100644 index 0000000000..97693674d9 --- /dev/null +++ b/tests/modules/test_ticketing.py @@ -0,0 +1,1709 @@ +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, types_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 + +seller_manager_user: models_users.CoreUser +seller_manager_user_token: str + +seller_user: models_users.CoreUser +seller_token: str + +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.access_ticketing], + "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) + + global seller_manager_user, seller_manager_user_token + seller_manager_user = await create_user_with_groups(groups=[bde_group.id]) + seller_manager_user_token = create_api_access_token(seller_manager_user) + seller_manager = models_mypayment.Seller( + user_id=seller_manager_user.id, + store_id=store.id, + can_bank=True, + can_see_history=True, + can_cancel=True, + can_manage_sellers=True, + can_manage_ticketing=True, + ) + await add_object_to_db(seller_manager) + + global seller_user, seller_token + seller_user = await create_user_with_groups(groups=[bde_group.id]) + seller_token = create_api_access_token(seller_user) + seller = models_mypayment.Seller( + user_id=seller_user.id, + store_id=store.id, + can_bank=True, # Can bank gives the ability to scan tickets only + can_see_history=False, + can_cancel=False, + can_manage_sellers=False, + can_manage_ticketing=False, + ) + await add_object_to_db(seller) + + # 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) + + 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=types_ticketing.TicketStatus.CONFIRMED, + nb_scan=0, + secret=uuid4(), + 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=types_ticketing.TicketStatus.CONFIRMED, + nb_scan=0, + secret=uuid4(), + 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 {seller_manager_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 zdzadzada", + "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 {seller_manager_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 {seller_manager_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 {seller_manager_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 {seller_manager_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 {seller_manager_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 {seller_manager_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=types_ticketing.TicketStatus.CONFIRMED, + nb_scan=0, + secret=uuid4(), + 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 {seller_manager_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 {seller_manager_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 {seller_manager_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 {seller_manager_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 {seller_manager_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 {seller_manager_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 {seller_manager_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 {seller_manager_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 {seller_manager_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=types_ticketing.TicketStatus.CONFIRMED, + nb_scan=0, + secret=uuid4(), + 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 {seller_manager_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 {seller_manager_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 {seller_manager_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 {seller_manager_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 {seller_manager_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 {seller_manager_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 {seller_manager_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 {seller_manager_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 {seller_manager_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 {seller_manager_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 {seller_manager_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 {seller_manager_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=types_ticketing.TicketStatus.CONFIRMED, + nb_scan=0, + secret=uuid4(), + 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 {seller_manager_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 {seller_manager_user_token}"}, + ) + assert response.status_code == 404 + + +# -------------------------- Test ticket basic cruds -------------------------- # + + +async def test_get_all_tickets_as_admin(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_all_tickets_as_seller_manager(client: TestClient): + response = client.get( + "/ticketing/tickets", + headers={"Authorization": f"Bearer {seller_manager_user_token}"}, + ) + assert response.status_code == 403 + + +async def test_get_tickets_by_event(client: TestClient): + response = client.get( + f"/ticketing/events/{event1.id}/tickets", + headers={"Authorization": f"Bearer {seller_manager_user_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 {seller_manager_user_token}"}, + ) + assert response_fake_event.status_code == 404 + + +async def test_get_tickets_by_session(client: TestClient): + response = client.get( + f"/ticketing/sessions/{session1.id}/tickets", + headers={"Authorization": f"Bearer {seller_manager_user_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 {seller_manager_user_token}"}, + ) + assert response_fake_session.status_code == 404 + + +async def test_get_tickets_by_category(client: TestClient): + response = client.get( + f"/ticketing/categories/{category1.id}/tickets", + headers={"Authorization": f"Bearer {seller_manager_user_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 {seller_manager_user_token}"}, + ) + assert response_fake_category.status_code == 404 + + +async def test_get_tickets_by_user_as_admin(client: TestClient): + response = client.get( + f"/ticketing/users/{student_user.id}/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 + + response_fake_user = client.get( + f"/ticketing/users/{uuid4()}/tickets/", + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response_fake_user.status_code == 404 + + +async def test_get_tickets_by_user_as_seller_manager(client: TestClient): + response = client.get( + f"/ticketing/users/{student_user.id}/tickets/", + headers={"Authorization": f"Bearer {seller_manager_user_token}"}, + ) + assert response.status_code == 403 + + +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=types_ticketing.TicketStatus.CONFIRMED, + nb_scan=0, + secret=uuid4(), + total=1, + created_at=datetime.now(UTC), + ) + await add_object_to_db(other_ticket) + + # Try to get the other user's ticket, should return 403 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 == 403 + + +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=types_ticketing.TicketStatus.CONFIRMED, + nb_scan=0, + secret=uuid4(), + 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 {seller_manager_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(seller_manager_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_confirmed_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=types_ticketing.TicketStatus.CONFIRMED, + nb_scan=0, + secret=uuid4(), + 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 {seller_manager_user_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=types_ticketing.TicketStatus.PENDING, + nb_scan=0, + secret=uuid4(), + 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 {seller_manager_user_token}"}, + ) + assert response.status_code == 204 + + response = client.get( + f"/ticketing/tickets/{to_update_ticket.id}", + headers={"Authorization": f"Bearer {seller_manager_user_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=types_ticketing.TicketStatus.PENDING, + nb_scan=0, + secret=uuid4(), + 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 ( # noqa: PLC0415 + 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 ( # noqa: PLC0415 + 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