From e569ef5254c5d3c078b6a25454000a7f5e5b409a Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Sun, 30 Nov 2025 01:20:56 +0100 Subject: [PATCH 01/50] WIP Basic cruds --- app/modules/ticketing/__init__.py | 0 app/modules/ticketing/cruds_ticketing.py | 439 +++++++++++++++++++ app/modules/ticketing/endpoints_ticketing.py | 403 +++++++++++++++++ app/modules/ticketing/models_ticketing.py | 97 ++++ app/modules/ticketing/schemas_ticketing.py | 101 +++++ 5 files changed, 1040 insertions(+) create mode 100644 app/modules/ticketing/__init__.py create mode 100644 app/modules/ticketing/cruds_ticketing.py create mode 100644 app/modules/ticketing/endpoints_ticketing.py create mode 100644 app/modules/ticketing/models_ticketing.py create mode 100644 app/modules/ticketing/schemas_ticketing.py 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/cruds_ticketing.py b/app/modules/ticketing/cruds_ticketing.py new file mode 100644 index 0000000000..bcaf8069bb --- /dev/null +++ b/app/modules/ticketing/cruds_ticketing.py @@ -0,0 +1,439 @@ +from uuid import UUID + +from sqlalchemy import delete, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.myeclpay import schemas_myeclpay +from app.modules.ticketing import models_ticketing, schemas_ticketing + + +async def get_events( + db: AsyncSession, +) -> list[schemas_ticketing.EventComplete]: + """Get all events.""" + + events = await db.execute(select(models_ticketing.Event)) + return [ + schemas_ticketing.EventComplete( + id=event.id, + store_id=event.store_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, + used_quota=event.used_quota, + disabled=event.disabled, + store=schemas_myeclpay.StoreSimple( + id=event.store.id, + structure_id=event.store.structure_id, + wallet_id=event.store.wallet_id, + name=event.store.name, + creation=event.store.creation, + ), + sessions=event.sessions, + categories=event.categories, + ) + 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.Event).where( + models_ticketing.Event.id == event_id, + ), + ) + ) + .scalars() + .first() + ) + + return ( + schemas_ticketing.EventComplete( + id=event.id, + store_id=event.store_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, + used_quota=event.used_quota, + disabled=event.disabled, + store=schemas_myeclpay.StoreSimple( + id=event.store.id, + structure_id=event.store.structure_id, + wallet_id=event.store.wallet_id, + name=event.store.name, + creation=event.store.creation, + ), + sessions=event.sessions, + categories=event.categories, + ) + if event + else None + ) + + +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.Event).where( + models_ticketing.Event.name == name, + ), + ) + ) + .scalars() + .first() + ) + + return ( + schemas_ticketing.EventComplete( + id=event.id, + store_id=event.store_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, + used_quota=event.used_quota, + disabled=event.disabled, + store=schemas_myeclpay.StoreSimple( + id=event.store.id, + structure_id=event.store.structure_id, + wallet_id=event.store.wallet_id, + name=event.store.name, + creation=event.store.creation, + ), + sessions=event.sessions, + categories=event.categories, + ) + if event + else None + ) + + +async def create_event( + db: AsyncSession, + event: schemas_ticketing.EventComplete, +) -> None: + """Create a new event.""" + + db.add( + models_ticketing.Event(**event.model_dump()), + ) + 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.Event) + .where(models_ticketing.Event.id == event_id) + .values(**event_update.model_dump(exclude_unset=True)), + ) + await db.flush() + + +async def delete_event( + db: AsyncSession, + event_id: UUID, +) -> None: + """Delete an existing event.""" + + await db.execute( + delete(models_ticketing.Event).where(models_ticketing.Event.id == event_id), + ) + await db.flush() + + +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.Session).where( + models_ticketing.Session.id == session_id, + ), + ) + ) + .scalars() + .first() + ) + + return ( + schemas_ticketing.SessionComplete( + id=session.id, + name=session.name, + quota=session.quota, + user_quota=session.user_quota, + used_quota=session.used_quota, + disabled=session.disabled, + ) + if session + else None + ) + + +async def create_session( + db: AsyncSession, + session: schemas_ticketing.SessionBase, +) -> None: + """Create a new session.""" + + db.add( + models_ticketing.Session(**session.model_dump()), + ) + 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.Session) + .where(models_ticketing.Session.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.Session).where( + models_ticketing.Session.id == session_id, + ), + ) + 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.Category).where( + models_ticketing.Category.id == category_id, + ), + ) + ) + .scalars() + .first() + ) + + return ( + schemas_ticketing.CategoryComplete( + id=category.id, + event_id=category.event_id, + name=category.name, + linked_sessions=category.linked_sessions, + required_mebership=category.required_mebership, + quota=category.quota, + user_quota=category.user_quota, + used_quota=category.used_quota, + price=category.price, + disabled=category.disabled, + ) + if category + else None + ) + + +async def create_category( + db: AsyncSession, + category: schemas_ticketing.CategoryBase, +) -> None: + """Create a new category.""" + + db.add( + models_ticketing.Category(**category.model_dump()), + ) + 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.Category) + .where(models_ticketing.Category.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.Category).where( + models_ticketing.Category.id == category_id, + ), + ) + await db.flush() + + +async def get_tickets( + db: AsyncSession, +) -> list[schemas_ticketing.TicketComplete]: + """Get all tickets.""" + + tickets = await db.execute(select(models_ticketing.Ticket)) + return [ + schemas_ticketing.TicketComplete( + id=ticket.id, + user_id=ticket.user_id, + event_id=ticket.event_id, + category_id=ticket.category_id, + total=ticket.total, + created_at=ticket.created_at, + event=ticket.event, + category=ticket.category, + ) + for ticket in tickets.scalars().all() + ] + + +async def get_tickets_by_user_id( + user_id: str, + db: AsyncSession, +) -> list[schemas_ticketing.TicketComplete]: + """Get all tickets for a specific user.""" + + tickets = await db.execute( + select(models_ticketing.Ticket).where( + models_ticketing.Ticket.user_id == user_id, + ), + ) + return [ + schemas_ticketing.TicketComplete( + id=ticket.id, + user_id=ticket.user_id, + event_id=ticket.event_id, + category_id=ticket.category_id, + total=ticket.total, + created_at=ticket.created_at, + event=ticket.event, + category=ticket.category, + ) + for ticket in tickets.scalars().all() + ] + + +async def get_ticket_by_id( + ticket_id: UUID, + db: AsyncSession, +) -> schemas_ticketing.TicketComplete | None: + """Get a ticket by its ID.""" + + ticket = ( + ( + await db.execute( + select(models_ticketing.Ticket).where( + models_ticketing.Ticket.id == ticket_id, + ), + ) + ) + .scalars() + .first() + ) + + return ( + schemas_ticketing.TicketComplete( + id=ticket.id, + user_id=ticket.user_id, + event_id=ticket.event_id, + category_id=ticket.category_id, + total=ticket.total, + created_at=ticket.created_at, + event=ticket.event, + category=ticket.category, + ) + if ticket + else None + ) + + +async def create_ticket( + db: AsyncSession, + ticket: schemas_ticketing.TicketSimple, +) -> None: + """Create a new ticket.""" + + db.add( + models_ticketing.Ticket(**ticket.model_dump()), + ) + 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.Ticket) + .where(models_ticketing.Ticket.id == ticket_id) + .values(**ticket_update.model_dump(exclude_unset=True)), + ) + await db.flush() + + +async def delete_ticket( + db: AsyncSession, + ticket_id: UUID, +) -> None: + """Delete an existing ticket.""" + + await db.execute( + delete(models_ticketing.Ticket).where( + models_ticketing.Ticket.id == ticket_id, + ), + ) + await db.flush() diff --git a/app/modules/ticketing/endpoints_ticketing.py b/app/modules/ticketing/endpoints_ticketing.py new file mode 100644 index 0000000000..3fc1a75213 --- /dev/null +++ b/app/modules/ticketing/endpoints_ticketing.py @@ -0,0 +1,403 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.users import models_users +from app.dependencies import get_db, is_user +from app.modules.ticketing import cruds_ticketing, schemas_ticketing +from app.types.module import CoreModule + +router = APIRouter(tags=["Ticketing"]) + +core_module = CoreModule( + root="ticketing", + tag="Ticketing", + router=router, + factory=None, +) + + +@router.get( + "/ticketing/events/", + summary="Get all events", + response_model=list[schemas_ticketing.EventComplete], + status_code=200, +) +async def get_events( + db: AsyncSession = Depends(get_db), +) -> list[schemas_ticketing.EventComplete]: + """Get all events.""" + return await cruds_ticketing.get_events(db=db) + + +@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 | None: + """Get an event by its ID.""" + return await cruds_ticketing.get_event_by_id(event_id=event_id, db=db) + + +@router.post( + "/ticketing/events/", + summary="Create a new event", + response_model=schemas_ticketing.EventComplete, + status_code=201, +) +async def create_event( + event: schemas_ticketing.EventBase, + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends(is_user()), +) -> schemas_ticketing.EventComplete: + """Create a new event.""" + stored = await cruds_ticketing.get_event_by_name(name=event.name, db=db) + if stored is not None: + raise HTTPException(status_code=400, detail="Event already exists") + event = schemas_ticketing.EventComplete( + **event.model_dump(), + id=UUID(), + creator_id=user.id, + ) + await cruds_ticketing.create_event(event=event, db=db) + return event + + +@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), +) -> None: + """Update an existing event.""" + stored = await cruds_ticketing.get_event_by_id(event_id=event_id, db=db) + if stored is None: + raise HTTPException(status_code=404, detail="Event not found") + await cruds_ticketing.update_event( + event_id=event_id, + event_update=event_update, + db=db, + ) + + +@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), +) -> None: + """Delete an existing event.""" + stored = await cruds_ticketing.get_event_by_id(event_id=event_id, db=db) + if stored is None: + raise HTTPException(status_code=404, detail="Event not found") + if stored.used_quota > 0: + raise HTTPException( + status_code=400, + detail="Cannot delete an event with used quota", + ) + await cruds_ticketing.delete_event(event_id=event_id, db=db) + + +@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), +) -> 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 + + +@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), +) -> schemas_ticketing.SessionComplete: + """Create a new session.""" + session_complete = schemas_ticketing.SessionComplete( + **session.model_dump(), + id=UUID(), + used_quota=0, + disabled=False, + ) + await cruds_ticketing.create_session(session=session_complete, db=db) + return session_complete + + +@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), +) -> None: + """Update an existing session.""" + stored = await cruds_ticketing.get_session_by_id(session_id=session_id, db=db) + if stored is None: + raise HTTPException(status_code=404, detail="Session not found") + await cruds_ticketing.update_session( + session_id=session_id, + session_update=session_update, + db=db, + ) + + +@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), +) -> None: + """Delete an existing session.""" + stored = await cruds_ticketing.get_session_by_id(session_id=session_id, db=db) + if stored is None: + raise HTTPException(status_code=404, detail="Session not found") + if stored.used_quota > 0: + raise HTTPException( + status_code=400, + detail="Cannot delete a session with used quota", + ) + await cruds_ticketing.delete_session(session_id=session_id, db=db) + + +@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 + + +@router.post( + "/ticketing/categories/", + summary="Create a new category", + response_model=schemas_ticketing.CategoryComplete, + status_code=201, +) +async def create_category( + category: schemas_ticketing.CategoryBase, + db: AsyncSession = Depends(get_db), +) -> schemas_ticketing.CategoryComplete: + """Create a new category.""" + category_complete = schemas_ticketing.CategoryComplete( + **category.model_dump(), + id=UUID(), + used_quota=0, + disabled=False, + ) + await cruds_ticketing.create_category(category=category_complete, db=db) + return category_complete + + +@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), +) -> None: + """Update an existing category.""" + stored = await cruds_ticketing.get_category_by_id(category_id=category_id, db=db) + if stored is None: + raise HTTPException(status_code=404, detail="Category not found") + await cruds_ticketing.update_category( + category_id=category_id, + category_update=category_update, + db=db, + ) + + +@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), +) -> None: + """Delete an existing category.""" + stored = await cruds_ticketing.get_category_by_id(category_id=category_id, db=db) + if stored is None: + raise HTTPException(status_code=404, detail="Category not found") + if stored.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) + + +@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), +) -> schemas_ticketing.TicketComplete | None: + """Get a ticket by its ID.""" + ticket = await cruds_ticketing.get_ticket_by_id(ticket_id=ticket_id, db=db) + if ticket is None: + raise HTTPException(status_code=404, detail="Ticket not found") + return ticket + + +@router.get( + "/ticketing/tickets/", + summary="Get all tickets", + response_model=list[schemas_ticketing.TicketComplete], + status_code=200, +) +async def get_all_tickets( + db: AsyncSession = Depends(get_db), +) -> list[schemas_ticketing.TicketComplete]: + """Get all tickets.""" + return await cruds_ticketing.get_tickets(db=db) + + +@router.get( + "/ticketing/users/{user_id}/tickets/", + summary="Get all tickets for a user", + response_model=list[schemas_ticketing.TicketComplete], + status_code=200, +) +async def get_tickets_by_user( + user_id: str, + db: AsyncSession = Depends(get_db), +) -> list[schemas_ticketing.TicketComplete]: + """Get all tickets for a user.""" + return await cruds_ticketing.get_tickets_by_user_id(user_id=user_id, db=db) + + +@router.get( + "/ticketing/users/me/tickets/", + summary="Get all tickets for a user", + response_model=list[schemas_ticketing.TicketComplete], + status_code=200, +) +async def get_my_tickets( + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends(is_user()), +) -> list[schemas_ticketing.TicketComplete]: + """Get all tickets for a user.""" + return await cruds_ticketing.get_tickets_by_user_id(user_id=user.id, db=db) + + +@router.post( + "/ticketing/tickets/", + summary="Create a new ticket", + response_model=schemas_ticketing.TicketComplete, + status_code=201, +) +async def create_ticket( + ticket: schemas_ticketing.TicketBase, + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends(is_user()), +) -> schemas_ticketing.TicketComplete: + """Create a new ticket.""" + ticket_simple = schemas_ticketing.TicketSimple( + **ticket.model_dump(), + id=UUID(), + user_id=user.id, + ) + await cruds_ticketing.create_ticket(ticket=ticket_simple, 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() + raise HTTPException(status_code=500, detail="Ticket creation failed") + return ticket_complete + + +@router.patch( + "/ticketing/tickets/{ticket_id}", + summary="Update an existing ticket", + response_model=None, + status_code=204, +) +async def update_ticket( + ticket_id: UUID, + ticket_update: schemas_ticketing.TicketBase, + db: AsyncSession = Depends(get_db), +) -> None: + """Update an existing ticket.""" + stored = await cruds_ticketing.get_ticket_by_id(ticket_id=ticket_id, db=db) + if stored is None: + raise HTTPException(status_code=404, detail="Ticket not found") + await cruds_ticketing.update_ticket( + ticket_id=ticket_id, + ticket_update=ticket_update, + db=db, + ) + + +@router.delete( + "/ticketing/tickets/{ticket_id}", + summary="Delete an existing ticket", + response_model=None, + status_code=204, +) +async def delete_ticket( + ticket_id: UUID, + db: AsyncSession = Depends(get_db), +) -> None: + """Delete an existing ticket.""" + stored = await cruds_ticketing.get_ticket_by_id(ticket_id=ticket_id, db=db) + if stored is None: + raise HTTPException(status_code=404, detail="Ticket not found") + await cruds_ticketing.delete_ticket(ticket_id=ticket_id, db=db) diff --git a/app/modules/ticketing/models_ticketing.py b/app/modules/ticketing/models_ticketing.py new file mode 100644 index 0000000000..43e83366c6 --- /dev/null +++ b/app/modules/ticketing/models_ticketing.py @@ -0,0 +1,97 @@ +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.types.sqlalchemy import Base, PrimaryKey + +if TYPE_CHECKING: + from app.core.myeclpay.models_myeclpay import Store + + +class Event(Base): + __tablename__ = "ticketing_event" + + id: Mapped[PrimaryKey] + store_id: Mapped[UUID] = mapped_column(ForeignKey("myeclpay_store.id")) + store: Mapped["Store"] = 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["Session"]] = relationship( + init=False, + lazy="selectin", + ) + categories: Mapped[list["Category"]] = relationship( + init=False, + lazy="selectin", + ) + + +class Session(Base): + __tablename__ = "ticketing_session" + + id: Mapped[PrimaryKey] + event_id: Mapped[UUID] = mapped_column(ForeignKey("ticketing_event.id")) + event: Mapped[Event] = relationship( + init=False, + lazy="selectin", + ) + name: Mapped[str] + quota: Mapped[int | None] + used_quota: Mapped[int] + user_quota: Mapped[int | None] + disabled: Mapped[bool] + + +class Category(Base): + __tablename__ = "ticketing_category" + + id: Mapped[PrimaryKey] + event_id: Mapped[UUID] = mapped_column(ForeignKey("ticketing_event.id")) + event: Mapped[Event] = relationship( + init=False, + lazy="selectin", + ) + name: Mapped[str] + linked_sessions: Mapped[list[UUID] | None] + 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 Ticket(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[Event] = relationship( + init=False, + lazy="selectin", + ) + category_id: Mapped[UUID] = mapped_column(ForeignKey("ticketing_category.id")) + category: Mapped[Category] = relationship( + init=False, + lazy="selectin", + ) + total: Mapped[int] + created_at: Mapped[datetime] + status: Mapped[str] + nb_scan: Mapped[int] diff --git a/app/modules/ticketing/schemas_ticketing.py b/app/modules/ticketing/schemas_ticketing.py new file mode 100644 index 0000000000..918dc3969f --- /dev/null +++ b/app/modules/ticketing/schemas_ticketing.py @@ -0,0 +1,101 @@ +from datetime import datetime +from typing import TYPE_CHECKING +from uuid import UUID + +from pydantic import BaseModel + +if TYPE_CHECKING: + from app.core.myeclpay.schemas_myeclpay import StoreSimple + + +class EventBase(BaseModel): + store_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 + used_quota: int + disabled: bool + + +class EventComplete(EventSimple): + store: "StoreSimple" + sessions: list["SessionComplete"] + categories: list["CategoryComplete"] + + +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): + name: str + quota: int | None = None + user_quota: int | None = None + + +class SessionComplete(SessionBase): + id: UUID + used_quota: int + disabled: bool + + +class SessionUpdate(BaseModel): + name: str | None = None + quota: int | None = None + user_quota: int | None = None + disabled: bool | None = None + + +class CategoryBase(BaseModel): + event_id: UUID + name: str + linked_sessions: list[UUID] | None = None + required_mebership: UUID | None = None + quota: int | None = None + user_quota: int | None = None + price: int + + +class CategoryComplete(CategoryBase): + id: UUID + used_quota: int + disabled: bool + + +class CategoryUpdate(BaseModel): + name: str | None = None + linked_sessions: list[UUID] | None = None + required_mebership: UUID | None = None + quota: int | None = None + user_quota: int | None = None + price: int | None = None + disabled: bool | None = None + + +class TicketBase(BaseModel): + user_id: str + event_id: UUID + category_id: UUID + total: int + + +class TicketSimple(TicketBase): + id: UUID + created_at: datetime + + +class TicketComplete(TicketSimple): + event: EventComplete + category: CategoryComplete From c461a1778f15ede178dec789dbc755c214e29b0f Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Sun, 30 Nov 2025 01:38:19 +0100 Subject: [PATCH 02/50] Small fixes --- app/modules/ticketing/cruds_ticketing.py | 2 +- app/modules/ticketing/endpoints_ticketing.py | 20 +++++++++++++++----- app/modules/ticketing/schemas_ticketing.py | 6 +++++- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/app/modules/ticketing/cruds_ticketing.py b/app/modules/ticketing/cruds_ticketing.py index bcaf8069bb..d8f55698e8 100644 --- a/app/modules/ticketing/cruds_ticketing.py +++ b/app/modules/ticketing/cruds_ticketing.py @@ -130,7 +130,7 @@ async def get_event_by_name( async def create_event( db: AsyncSession, - event: schemas_ticketing.EventComplete, + event: schemas_ticketing.EventSimple, ) -> None: """Create a new event.""" diff --git a/app/modules/ticketing/endpoints_ticketing.py b/app/modules/ticketing/endpoints_ticketing.py index 3fc1a75213..a933921823 100644 --- a/app/modules/ticketing/endpoints_ticketing.py +++ b/app/modules/ticketing/endpoints_ticketing.py @@ -60,14 +60,17 @@ async def create_event( stored = await cruds_ticketing.get_event_by_name(name=event.name, db=db) if stored is not None: raise HTTPException(status_code=400, detail="Event already exists") - event = schemas_ticketing.EventComplete( + event = schemas_ticketing.EventSimple( **event.model_dump(), id=UUID(), creator_id=user.id, ) await cruds_ticketing.create_event(event=event, db=db) - return event - + 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 @router.patch( "/ticketing/events/{event_id}", @@ -141,13 +144,20 @@ async def create_session( db: AsyncSession = Depends(get_db), ) -> schemas_ticketing.SessionComplete: """Create a new session.""" - session_complete = schemas_ticketing.SessionComplete( + session_simple = schemas_ticketing.SessionSimple( **session.model_dump(), id=UUID(), used_quota=0, disabled=False, ) - await cruds_ticketing.create_session(session=session_complete, db=db) + 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 diff --git a/app/modules/ticketing/schemas_ticketing.py b/app/modules/ticketing/schemas_ticketing.py index 918dc3969f..f8300ea7b2 100644 --- a/app/modules/ticketing/schemas_ticketing.py +++ b/app/modules/ticketing/schemas_ticketing.py @@ -45,12 +45,16 @@ class SessionBase(BaseModel): user_quota: int | None = None -class SessionComplete(SessionBase): +class SessionSimple(SessionBase): id: UUID used_quota: int disabled: bool +class SessionComplete(SessionSimple): + event: EventSimple + + class SessionUpdate(BaseModel): name: str | None = None quota: int | None = None From 5e37262aa89a0265678e5bd8d8a2c39ac49439d4 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Sun, 30 Nov 2025 01:38:25 +0100 Subject: [PATCH 03/50] Create factory_ticketing.py --- app/modules/ticketing/factory_ticketing.py | 152 +++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 app/modules/ticketing/factory_ticketing.py diff --git a/app/modules/ticketing/factory_ticketing.py b/app/modules/ticketing/factory_ticketing.py new file mode 100644 index 0000000000..9e26c212b4 --- /dev/null +++ b/app/modules/ticketing/factory_ticketing.py @@ -0,0 +1,152 @@ +import random +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.myeclpay.factory_myeclpay import MyECLPayFactory +from app.core.users.factory_users import CoreUsersFactory +from app.core.utils.config import Settings +from app.modules.ticketing import cruds_ticketing, schemas_ticketing +from app.types.factory import Factory + + +class TicketingFactory(Factory): + depends_on = [ + CoreUsersFactory, + MyECLPayFactory, + ] + + 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_event(db) + await cls.add_sessions(db) + await cls.add_categories(db) + await cls.add_tickets(db) + + @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, + store_id=MyECLPayFactory.other_stores_id[0], + 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, + used_quota=0, + disabled=False, + ), + ) + + @classmethod + async def add_sessions(cls, db: AsyncSession) -> None: + """Create sample sessions.""" + await cruds_ticketing.create_session( + db, + schemas_ticketing.SessionBase( + id=cls.session1_id, + event_id=cls.event_id, + name="Session du Samedi Soir", + quota=300, + user_quota=2, + used_quota=0, + disabled=False, + ), + ) + await cruds_ticketing.create_session( + db, + schemas_ticketing.SessionBase( + id=cls.session2_id, + event_id=cls.event_id, + name="Session du Dimanche Après-midi", + quota=200, + user_quota=2, + used_quota=0, + disabled=False, + ), + ) + + @classmethod + async def add_categories(cls, db: AsyncSession) -> None: + """Create sample categories.""" + await cruds_ticketing.create_category( + db, + schemas_ticketing.CategoryBase( + id=cls.category1_id, + event_id=cls.event_id, + name="Étudiant Centrale", + linked_sessions=[cls.session1_id, cls.session2_id], + required_mebership=["centrale_student"], + quota=150, + user_quota=2, + used_quota=0, + price=1500, + disabled=False, + ), + ) + await cruds_ticketing.create_category( + db, + schemas_ticketing.CategoryBase( + id=cls.category2_id, + event_id=cls.event_id, + name="Étudiant Lyon", + linked_sessions=[cls.session1_id, cls.session2_id], + required_mebership=["student"], + quota=200, + user_quota=2, + used_quota=0, + price=2000, + disabled=False, + ), + ) + await cruds_ticketing.create_category( + db, + schemas_ticketing.CategoryBase( + id=cls.category3_id, + event_id=cls.event_id, + name="Externe", + linked_sessions=[cls.session1_id], + required_mebership=None, + quota=100, + user_quota=1, + used_quota=0, + 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, + total=1500 if category_id == cls.category1_id else 2000, + created_at=datetime.now(UTC), + ), + ) + + @classmethod + async def should_run(cls, db: AsyncSession): + return await cruds_ticketing.get_events(db) == [] From e1eefcd6d72263ce770e70357c09424dcf8e0b3c Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Sun, 30 Nov 2025 02:14:52 +0100 Subject: [PATCH 04/50] Fix typing --- app/modules/ticketing/endpoints_ticketing.py | 4 +++- app/modules/ticketing/models_ticketing.py | 5 +++-- app/modules/ticketing/schemas_ticketing.py | 6 ++---- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/app/modules/ticketing/endpoints_ticketing.py b/app/modules/ticketing/endpoints_ticketing.py index a933921823..e7678e4e31 100644 --- a/app/modules/ticketing/endpoints_ticketing.py +++ b/app/modules/ticketing/endpoints_ticketing.py @@ -6,6 +6,7 @@ from app.core.users import models_users from app.dependencies import get_db, is_user from app.modules.ticketing import cruds_ticketing, schemas_ticketing +from app.modules.ticketing.factory_ticketing import TicketingFactory from app.types.module import CoreModule router = APIRouter(tags=["Ticketing"]) @@ -14,7 +15,7 @@ root="ticketing", tag="Ticketing", router=router, - factory=None, + factory=TicketingFactory(), ) @@ -72,6 +73,7 @@ async def create_event( raise HTTPException(status_code=500, detail="Event creation failed") return event_complete + @router.patch( "/ticketing/events/{event_id}", summary="Update an existing event", diff --git a/app/modules/ticketing/models_ticketing.py b/app/modules/ticketing/models_ticketing.py index 43e83366c6..8fe6a633b2 100644 --- a/app/modules/ticketing/models_ticketing.py +++ b/app/modules/ticketing/models_ticketing.py @@ -9,6 +9,7 @@ if TYPE_CHECKING: from app.core.myeclpay.models_myeclpay import Store + from app.modules import ticketing class Event(Base): @@ -29,7 +30,7 @@ class Event(Base): user_quota: Mapped[int | None] disabled: Mapped[bool] - sessions: Mapped[list["Session"]] = relationship( + sessions: Mapped[list["ticketing.models_ticketing.Session"]] = relationship( init=False, lazy="selectin", ) @@ -65,7 +66,7 @@ class Category(Base): lazy="selectin", ) name: Mapped[str] - linked_sessions: Mapped[list[UUID] | None] + linked_sessions: Mapped[str | None] required_mebership: Mapped[UUID | None] = mapped_column( ForeignKey("core_association_membership.id"), ) diff --git a/app/modules/ticketing/schemas_ticketing.py b/app/modules/ticketing/schemas_ticketing.py index f8300ea7b2..e9cc128b39 100644 --- a/app/modules/ticketing/schemas_ticketing.py +++ b/app/modules/ticketing/schemas_ticketing.py @@ -1,11 +1,9 @@ from datetime import datetime -from typing import TYPE_CHECKING from uuid import UUID from pydantic import BaseModel -if TYPE_CHECKING: - from app.core.myeclpay.schemas_myeclpay import StoreSimple +from app.core.myeclpay.schemas_myeclpay import StoreSimple class EventBase(BaseModel): @@ -25,7 +23,7 @@ class EventSimple(EventBase): class EventComplete(EventSimple): - store: "StoreSimple" + store: StoreSimple sessions: list["SessionComplete"] categories: list["CategoryComplete"] From 593e498dc6886ed91cc3e55ccdada40a98053a74 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Sun, 30 Nov 2025 02:19:40 +0100 Subject: [PATCH 05/50] Added migrations --- migrations/versions/45-ticketing.py | 110 ++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 migrations/versions/45-ticketing.py diff --git a/migrations/versions/45-ticketing.py b/migrations/versions/45-ticketing.py new file mode 100644 index 0000000000..4a92f88ac4 --- /dev/null +++ b/migrations/versions/45-ticketing.py @@ -0,0 +1,110 @@ +"""ticketing + +Create Date: 2025-11-30 02:18:35.600988 +""" + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pytest_alembic import MigrationContext + +import sqlalchemy as sa +from alembic import op + +from app.types.sqlalchemy import TZDateTime + +# revision identifiers, used by Alembic. +revision: str = "b2e04c519621" +down_revision: str | None = "91fadc90f892" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "ticketing_event", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("store_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(["store_id"], ["myeclpay_store.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("linked_sessions", sa.String(), nullable=True), + 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("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_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("total", sa.Integer(), nullable=False), + sa.Column("created_at", TZDateTime(), nullable=False), + sa.Column("status", sa.String(), nullable=False), + sa.Column("nb_scan", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["category_id"], ["ticketing_category.id"]), + sa.ForeignKeyConstraint(["event_id"], ["ticketing_event.id"]), + sa.ForeignKeyConstraint(["user_id"], ["core_user.id"]), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("ticketing_ticket") + op.drop_table("ticketing_session") + op.drop_table("ticketing_category") + op.drop_table("ticketing_event") + # ### 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 From 344c86a23790ff6a5869df54e8f95a49feca0dc2 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Sun, 30 Nov 2025 03:26:46 +0100 Subject: [PATCH 06/50] Various fixes --- app/modules/ticketing/cruds_ticketing.py | 23 ++++++++-- app/modules/ticketing/endpoints_ticketing.py | 44 ++++++++++---------- app/modules/ticketing/factory_ticketing.py | 20 +++++---- app/modules/ticketing/schemas_ticketing.py | 13 ++++-- 4 files changed, 63 insertions(+), 37 deletions(-) diff --git a/app/modules/ticketing/cruds_ticketing.py b/app/modules/ticketing/cruds_ticketing.py index d8f55698e8..88a43b5d61 100644 --- a/app/modules/ticketing/cruds_ticketing.py +++ b/app/modules/ticketing/cruds_ticketing.py @@ -193,6 +193,8 @@ async def get_session_by_id( user_quota=session.user_quota, used_quota=session.used_quota, disabled=session.disabled, + event_id=session.event_id, + event=session.event, ) if session else None @@ -201,7 +203,7 @@ async def get_session_by_id( async def create_session( db: AsyncSession, - session: schemas_ticketing.SessionBase, + session: schemas_ticketing.SessionSimple, ) -> None: """Create a new session.""" @@ -262,6 +264,7 @@ async def get_category_by_id( schemas_ticketing.CategoryComplete( id=category.id, event_id=category.event_id, + event=category.event, name=category.name, linked_sessions=category.linked_sessions, required_mebership=category.required_mebership, @@ -278,12 +281,18 @@ async def get_category_by_id( async def create_category( db: AsyncSession, - category: schemas_ticketing.CategoryBase, + category: schemas_ticketing.CategorySimple, ) -> None: """Create a new category.""" - + joined_sessions = ( + ",".join(str(session_id) for session_id in category.linked_sessions) + if category.linked_sessions + else None + ) + category_model = category.model_dump() + category_model["linked_sessions"] = joined_sessions db.add( - models_ticketing.Category(**category.model_dump()), + models_ticketing.Category(**category_model), ) await db.flush() @@ -333,6 +342,8 @@ async def get_tickets( created_at=ticket.created_at, event=ticket.event, category=ticket.category, + status=ticket.status, + nb_scan=ticket.nb_scan, ) for ticket in tickets.scalars().all() ] @@ -359,6 +370,8 @@ async def get_tickets_by_user_id( created_at=ticket.created_at, event=ticket.event, category=ticket.category, + status=ticket.status, + nb_scan=ticket.nb_scan, ) for ticket in tickets.scalars().all() ] @@ -392,6 +405,8 @@ async def get_ticket_by_id( created_at=ticket.created_at, event=ticket.event, category=ticket.category, + status=ticket.status, + nb_scan=ticket.nb_scan, ) if ticket else None diff --git a/app/modules/ticketing/endpoints_ticketing.py b/app/modules/ticketing/endpoints_ticketing.py index e7678e4e31..edd38167ec 100644 --- a/app/modules/ticketing/endpoints_ticketing.py +++ b/app/modules/ticketing/endpoints_ticketing.py @@ -7,11 +7,11 @@ from app.dependencies import get_db, is_user from app.modules.ticketing import cruds_ticketing, schemas_ticketing from app.modules.ticketing.factory_ticketing import TicketingFactory -from app.types.module import CoreModule +from app.types.module import Module router = APIRouter(tags=["Ticketing"]) -core_module = CoreModule( +module = Module( root="ticketing", tag="Ticketing", router=router, @@ -19,7 +19,7 @@ ) -@router.get( +@module.router.get( "/ticketing/events/", summary="Get all events", response_model=list[schemas_ticketing.EventComplete], @@ -32,7 +32,7 @@ async def get_events( return await cruds_ticketing.get_events(db=db) -@router.get( +@module.router.get( "/ticketing/events/{event_id}", summary="Get an event by its ID", response_model=schemas_ticketing.EventComplete, @@ -46,7 +46,7 @@ async def get_event_by_id( return await cruds_ticketing.get_event_by_id(event_id=event_id, db=db) -@router.post( +@module.router.post( "/ticketing/events/", summary="Create a new event", response_model=schemas_ticketing.EventComplete, @@ -74,7 +74,7 @@ async def create_event( return event_complete -@router.patch( +@module.router.patch( "/ticketing/events/{event_id}", summary="Update an existing event", response_model=None, @@ -96,7 +96,7 @@ async def update_event( ) -@router.delete( +@module.router.delete( "/ticketing/events/{event_id}", summary="Delete an existing event", response_model=None, @@ -118,7 +118,7 @@ async def delete_event( await cruds_ticketing.delete_event(event_id=event_id, db=db) -@router.get( +@module.router.get( "/ticketing/sessions/{session_id}", summary="Get a session by its ID", response_model=schemas_ticketing.SessionComplete, @@ -135,7 +135,7 @@ async def get_session_by_id( return session -@router.post( +@module.router.post( "/ticketing/sessions/", summary="Create a new session", response_model=schemas_ticketing.SessionComplete, @@ -163,7 +163,7 @@ async def create_session( return session_complete -@router.patch( +@module.router.patch( "/ticketing/sessions/{session_id}", summary="Update an existing session", response_model=None, @@ -185,7 +185,7 @@ async def update_session( ) -@router.delete( +@module.router.delete( "/ticketing/sessions/{session_id}", summary="Delete an existing session", response_model=None, @@ -207,7 +207,7 @@ async def delete_session( await cruds_ticketing.delete_session(session_id=session_id, db=db) -@router.get( +@module.router.get( "/ticketing/categories/{category_id}", summary="Get a category by its ID", response_model=schemas_ticketing.CategoryComplete, @@ -224,7 +224,7 @@ async def get_category_by_id( return category -@router.post( +@module.router.post( "/ticketing/categories/", summary="Create a new category", response_model=schemas_ticketing.CategoryComplete, @@ -245,7 +245,7 @@ async def create_category( return category_complete -@router.patch( +@module.router.patch( "/ticketing/categories/{category_id}", summary="Update an existing category", response_model=None, @@ -267,7 +267,7 @@ async def update_category( ) -@router.delete( +@module.router.delete( "/ticketing/categories/{category_id}", summary="Delete an existing category", response_model=None, @@ -289,7 +289,7 @@ async def delete_category( await cruds_ticketing.delete_category(category_id=category_id, db=db) -@router.get( +@module.router.get( "/ticketing/tickets/{ticket_id}", summary="Get a ticket by its ID", response_model=schemas_ticketing.TicketComplete, @@ -306,7 +306,7 @@ async def get_ticket_by_id( return ticket -@router.get( +@module.router.get( "/ticketing/tickets/", summary="Get all tickets", response_model=list[schemas_ticketing.TicketComplete], @@ -319,7 +319,7 @@ async def get_all_tickets( return await cruds_ticketing.get_tickets(db=db) -@router.get( +@module.router.get( "/ticketing/users/{user_id}/tickets/", summary="Get all tickets for a user", response_model=list[schemas_ticketing.TicketComplete], @@ -333,7 +333,7 @@ async def get_tickets_by_user( return await cruds_ticketing.get_tickets_by_user_id(user_id=user_id, db=db) -@router.get( +@module.router.get( "/ticketing/users/me/tickets/", summary="Get all tickets for a user", response_model=list[schemas_ticketing.TicketComplete], @@ -347,7 +347,7 @@ async def get_my_tickets( return await cruds_ticketing.get_tickets_by_user_id(user_id=user.id, db=db) -@router.post( +@module.router.post( "/ticketing/tickets/", summary="Create a new ticket", response_model=schemas_ticketing.TicketComplete, @@ -376,7 +376,7 @@ async def create_ticket( return ticket_complete -@router.patch( +@module.router.patch( "/ticketing/tickets/{ticket_id}", summary="Update an existing ticket", response_model=None, @@ -398,7 +398,7 @@ async def update_ticket( ) -@router.delete( +@module.router.delete( "/ticketing/tickets/{ticket_id}", summary="Delete an existing ticket", response_model=None, diff --git a/app/modules/ticketing/factory_ticketing.py b/app/modules/ticketing/factory_ticketing.py index 9e26c212b4..6d3b9368b2 100644 --- a/app/modules/ticketing/factory_ticketing.py +++ b/app/modules/ticketing/factory_ticketing.py @@ -4,6 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession +from app.core.memberships.factory_memberships import CoreMembershipsFactory from app.core.myeclpay.factory_myeclpay import MyECLPayFactory from app.core.users.factory_users import CoreUsersFactory from app.core.utils.config import Settings @@ -15,6 +16,7 @@ class TicketingFactory(Factory): depends_on = [ CoreUsersFactory, MyECLPayFactory, + CoreMembershipsFactory, ] event_id = uuid4() @@ -38,7 +40,7 @@ async def add_event(cls, db: AsyncSession) -> None: db, schemas_ticketing.EventSimple( id=cls.event_id, - store_id=MyECLPayFactory.other_stores_id[0], + store_id=MyECLPayFactory.other_stores_id[0][0], creator_id=CoreUsersFactory.other_users_id[0], name="Commuz 2025", open_date=datetime.now(UTC), @@ -55,7 +57,7 @@ async def add_sessions(cls, db: AsyncSession) -> None: """Create sample sessions.""" await cruds_ticketing.create_session( db, - schemas_ticketing.SessionBase( + schemas_ticketing.SessionSimple( id=cls.session1_id, event_id=cls.event_id, name="Session du Samedi Soir", @@ -67,7 +69,7 @@ async def add_sessions(cls, db: AsyncSession) -> None: ) await cruds_ticketing.create_session( db, - schemas_ticketing.SessionBase( + schemas_ticketing.SessionSimple( id=cls.session2_id, event_id=cls.event_id, name="Session du Dimanche Après-midi", @@ -83,12 +85,12 @@ async def add_categories(cls, db: AsyncSession) -> None: """Create sample categories.""" await cruds_ticketing.create_category( db, - schemas_ticketing.CategoryBase( + schemas_ticketing.CategorySimple( id=cls.category1_id, event_id=cls.event_id, name="Étudiant Centrale", linked_sessions=[cls.session1_id, cls.session2_id], - required_mebership=["centrale_student"], + required_mebership=CoreMembershipsFactory.memberships_ids[0], quota=150, user_quota=2, used_quota=0, @@ -98,12 +100,12 @@ async def add_categories(cls, db: AsyncSession) -> None: ) await cruds_ticketing.create_category( db, - schemas_ticketing.CategoryBase( + schemas_ticketing.CategorySimple( id=cls.category2_id, event_id=cls.event_id, name="Étudiant Lyon", linked_sessions=[cls.session1_id, cls.session2_id], - required_mebership=["student"], + required_mebership=CoreMembershipsFactory.memberships_ids[1], quota=200, user_quota=2, used_quota=0, @@ -113,7 +115,7 @@ async def add_categories(cls, db: AsyncSession) -> None: ) await cruds_ticketing.create_category( db, - schemas_ticketing.CategoryBase( + schemas_ticketing.CategorySimple( id=cls.category3_id, event_id=cls.event_id, name="Externe", @@ -144,6 +146,8 @@ async def add_tickets(cls, db: AsyncSession) -> None: category_id=category_id, total=1500 if category_id == cls.category1_id else 2000, created_at=datetime.now(UTC), + nb_scan=0, + status="valid", ), ) diff --git a/app/modules/ticketing/schemas_ticketing.py b/app/modules/ticketing/schemas_ticketing.py index e9cc128b39..baee016786 100644 --- a/app/modules/ticketing/schemas_ticketing.py +++ b/app/modules/ticketing/schemas_ticketing.py @@ -24,8 +24,8 @@ class EventSimple(EventBase): class EventComplete(EventSimple): store: StoreSimple - sessions: list["SessionComplete"] - categories: list["CategoryComplete"] + sessions: list["SessionSimple"] + categories: list["CategorySimple"] class EventUpdate(BaseModel): @@ -38,6 +38,7 @@ class EventUpdate(BaseModel): class SessionBase(BaseModel): + event_id: UUID name: str quota: int | None = None user_quota: int | None = None @@ -70,12 +71,16 @@ class CategoryBase(BaseModel): price: int -class CategoryComplete(CategoryBase): +class CategorySimple(CategoryBase): id: UUID used_quota: int disabled: bool +class CategoryComplete(CategorySimple): + event: EventSimple + + class CategoryUpdate(BaseModel): name: str | None = None linked_sessions: list[UUID] | None = None @@ -96,6 +101,8 @@ class TicketBase(BaseModel): class TicketSimple(TicketBase): id: UUID created_at: datetime + status: str + nb_scan: int class TicketComplete(TicketSimple): From 35a480bd5552512dd83dbd79818c86b79d699d39 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Mon, 9 Feb 2026 16:12:49 +0100 Subject: [PATCH 07/50] WIP --- app/modules/ticketing/cache_ticketing.py | 48 ++++++++++++++ app/modules/ticketing/cruds_ticketing.py | 60 +++++++++++++++-- app/modules/ticketing/endpoints_ticketing.py | 70 ++++++++++++++++++-- app/modules/ticketing/models_ticketing.py | 7 +- app/modules/ticketing/schemas_ticketing.py | 2 + tests/modules/test_ticketing.py | 0 6 files changed, 178 insertions(+), 9 deletions(-) create mode 100644 app/modules/ticketing/cache_ticketing.py create mode 100644 tests/modules/test_ticketing.py diff --git a/app/modules/ticketing/cache_ticketing.py b/app/modules/ticketing/cache_ticketing.py new file mode 100644 index 0000000000..52801f7737 --- /dev/null +++ b/app/modules/ticketing/cache_ticketing.py @@ -0,0 +1,48 @@ +# Redis Cache for Ticketing Module + +from uuid import UUID + +from redis import Redis + +# TODO: Detect redis instance or skip caching if not available + + +def use_cache_or_else_db( + redis: Redis, + key: str, + db_func, + *args, + **kwargs, +): + """Use cache if available, otherwise call the database function.""" + cached_value = redis.get(key) + if cached_value is not None: + return int(cached_value) + value = db_func(*args, **kwargs) + redis.set(key, value) + return value + + +def increment_quota_event( + redis: Redis, + event_id: UUID, + amount: int = 1, +) -> None: + """Increment the quota for an event.""" + redis.incrby(f"ticketing:event:{event_id}:quota", amount) + +def increment_quota_category( + redis: Redis, + category_id: UUID, + amount: int = 1, +) -> None: + """Increment the quota for a category.""" + redis.incrby(f"ticketing:category:{category_id}:quota", amount) + +def increment_quota_session( + redis: Redis, + session_id: UUID, + amount: int = 1, +) -> None: + """Increment the quota for a session.""" + redis.incrby(f"ticketing:session:{session_id}:quota", amount) diff --git a/app/modules/ticketing/cruds_ticketing.py b/app/modules/ticketing/cruds_ticketing.py index 88a43b5d61..bbccdae995 100644 --- a/app/modules/ticketing/cruds_ticketing.py +++ b/app/modules/ticketing/cruds_ticketing.py @@ -9,12 +9,12 @@ async def get_events( db: AsyncSession, -) -> list[schemas_ticketing.EventComplete]: +) -> list[schemas_ticketing.EventSimple]: """Get all events.""" events = await db.execute(select(models_ticketing.Event)) return [ - schemas_ticketing.EventComplete( + schemas_ticketing.EventSimple( id=event.id, store_id=event.store_id, creator_id=event.creator_id, @@ -32,8 +32,6 @@ async def get_events( name=event.store.name, creation=event.store.creation, ), - sessions=event.sessions, - categories=event.categories, ) for event in events.scalars().all() ] @@ -155,6 +153,22 @@ async def update_event( 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.Event) + # 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.Event.id == event_id and models_ticketing.Event.used_quota < models_ticketing.Event.quota) + .values(used_quota=models_ticketing.Event.used_quota + 1), + ) + + await db.flush() + + async def delete_event( db: AsyncSession, event_id: UUID, @@ -242,6 +256,22 @@ async def delete_session( 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.Session) + # 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.Session.id == session_id and models_ticketing.Session.used_quota < models_ticketing.Session.quota) + .values(used_quota=models_ticketing.Session.used_quota + 1), + ) + + await db.flush() + + async def get_category_by_id( category_id: UUID, db: AsyncSession, @@ -326,6 +356,22 @@ async def delete_category( 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.Category) + # 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.Category.id == category_id and models_ticketing.Category.used_quota < models_ticketing.Category.quota) + .values(used_quota=models_ticketing.Category.used_quota + 1), + ) + + await db.flush() + + async def get_tickets( db: AsyncSession, ) -> list[schemas_ticketing.TicketComplete]: @@ -338,10 +384,12 @@ async def get_tickets( 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, event=ticket.event, category=ticket.category, + session=ticket.session, status=ticket.status, nb_scan=ticket.nb_scan, ) @@ -366,10 +414,12 @@ async def get_tickets_by_user_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, event=ticket.event, category=ticket.category, + session=ticket.session, status=ticket.status, nb_scan=ticket.nb_scan, ) @@ -401,10 +451,12 @@ async def get_ticket_by_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, event=ticket.event, category=ticket.category, + session=ticket.session, status=ticket.status, nb_scan=ticket.nb_scan, ) diff --git a/app/modules/ticketing/endpoints_ticketing.py b/app/modules/ticketing/endpoints_ticketing.py index edd38167ec..584d78701b 100644 --- a/app/modules/ticketing/endpoints_ticketing.py +++ b/app/modules/ticketing/endpoints_ticketing.py @@ -1,11 +1,12 @@ from uuid import UUID from fastapi import APIRouter, Depends, HTTPException +from redis import Redis from sqlalchemy.ext.asyncio import AsyncSession from app.core.users import models_users -from app.dependencies import get_db, is_user -from app.modules.ticketing import cruds_ticketing, schemas_ticketing +from app.dependencies import get_db, get_redis_client, is_user +from app.modules.ticketing import cache_ticketing, cruds_ticketing, schemas_ticketing from app.modules.ticketing.factory_ticketing import TicketingFactory from app.types.module import Module @@ -22,12 +23,12 @@ @module.router.get( "/ticketing/events/", summary="Get all events", - response_model=list[schemas_ticketing.EventComplete], + response_model=list[schemas_ticketing.EventSimple], status_code=200, ) async def get_events( db: AsyncSession = Depends(get_db), -) -> list[schemas_ticketing.EventComplete]: +) -> list[schemas_ticketing.EventSimple]: """Get all events.""" return await cruds_ticketing.get_events(db=db) @@ -357,14 +358,72 @@ 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.TicketComplete: """Create a new ticket.""" ticket_simple = schemas_ticketing.TicketSimple( **ticket.model_dump(), id=UUID(), user_id=user.id, + status="pending", + nb_scan=0, ) + + # 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") + + # TODO: Verify that the quota is not already full before creating the ticket to prevent overbooking in case of concurrent ticket purchases across multiple workers + # First with redis cache and then with database queries as fallback if redis is not available + if isinstance(redis_client, Redis): + pass + else: + if event.quota is not None and event.used_quota >= event.quota: + raise HTTPException(status_code=400, detail="Event quota exceeded") + if category.quota is not None and category.used_quota >= category.quota: + raise HTTPException(status_code=400, detail="Category quota exceeded") + if session.quota is not None and session.used_quota >= session.quota: + raise HTTPException(status_code=400, detail="Session quota exceeded") + await cruds_ticketing.create_ticket(ticket=ticket_simple, db=db) + + # TODO: Add redis cache update for event quota + if isinstance(redis_client, Redis): + cache_ticketing.increment_quota_event( + redis=redis_client, + event_id=ticket_simple.event_id, + amount=1, + ) + cache_ticketing.increment_quota_category( + redis=redis_client, + category_id=ticket_simple.category_id, + amount=1, + ) + cache_ticketing.increment_quota_session( + redis=redis_client, + session_id=ticket_simple.session_id, + amount=1, + ) + + 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, @@ -373,6 +432,9 @@ async def create_ticket( if ticket_complete is None: await db.rollback() raise HTTPException(status_code=500, detail="Ticket creation failed") + + # TODO: Init MyECLPay Transfer + return ticket_complete diff --git a/app/modules/ticketing/models_ticketing.py b/app/modules/ticketing/models_ticketing.py index 8fe6a633b2..bf1516bae9 100644 --- a/app/modules/ticketing/models_ticketing.py +++ b/app/modules/ticketing/models_ticketing.py @@ -92,7 +92,12 @@ class Ticket(Base): init=False, lazy="selectin", ) + session_id: Mapped[UUID | None] = mapped_column(ForeignKey("ticketing_session.id")) + session: Mapped[Session | None] = relationship( + init=False, + lazy="selectin", + ) total: Mapped[int] created_at: Mapped[datetime] - status: Mapped[str] + status: Mapped[str] # TODO: Enum nb_scan: Mapped[int] diff --git a/app/modules/ticketing/schemas_ticketing.py b/app/modules/ticketing/schemas_ticketing.py index baee016786..4268094059 100644 --- a/app/modules/ticketing/schemas_ticketing.py +++ b/app/modules/ticketing/schemas_ticketing.py @@ -95,6 +95,7 @@ class TicketBase(BaseModel): user_id: str event_id: UUID category_id: UUID + session_id: UUID total: int @@ -108,3 +109,4 @@ class TicketSimple(TicketBase): class TicketComplete(TicketSimple): event: EventComplete category: CategoryComplete + session: SessionComplete diff --git a/tests/modules/test_ticketing.py b/tests/modules/test_ticketing.py new file mode 100644 index 0000000000..e69de29bb2 From e3bfeb4eaf22a3e5458cee3440b045f413f655da Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:35:34 +0100 Subject: [PATCH 08/50] WIP Test init --- tests/modules/test_ticketing.py | 167 ++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) diff --git a/tests/modules/test_ticketing.py b/tests/modules/test_ticketing.py index e69de29bb2..c2184b60e2 100644 --- a/tests/modules/test_ticketing.py +++ b/tests/modules/test_ticketing.py @@ -0,0 +1,167 @@ +import uuid +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest +import pytest_asyncio +from fastapi.testclient import TestClient + +from app.core.groups.groups_type import AccountType, GroupType +from app.core.memberships import models_memberships +from app.core.myeclpay import models_myeclpay +from app.core.myeclpay.types_myeclpay import WalletType +from app.core.users import models_users +from app.modules.ticketing import models_ticketing + +# We need to import event_loop for pytest-asyncio routine defined bellow +from tests.commons import ( + add_object_to_db, + create_api_access_token, + create_user_with_groups, +) + +admin_user: models_users.CoreUser +admin_user_token: str +structure_manager_user: models_users.CoreUser +structure_manager_user_token: str + +association_membership: models_memberships.CoreAssociationMembership +association_membership_user: models_memberships.CoreAssociationUserMembership +structure: models_myeclpay.Structure + +store_wallet: models_myeclpay.Wallet +store: models_myeclpay.Store + + +student_user: models_users.CoreUser + +event1: models_ticketing.Event +event2: models_ticketing.Event +event3: models_ticketing.Event +event_fake_id = UUID("5e9ec7bf-0ab4-421a-bbe7-7ec064fcec8d") + +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 association_membership + association_membership = models_memberships.CoreAssociationMembership( + id=uuid4(), + name="Test Association Membership", + manager_group_id=GroupType.BDE, + ) + 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_myeclpay.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_myeclpay.Wallet( + id=uuid4(), + type=WalletType.STORE, + balance=0, + ) + await add_object_to_db(store_wallet) + + store = models_myeclpay.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) + + # Create events + global event1, event2, event3 + event1 = models_ticketing.Event( + 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=5, + used_quota=0, + disabled=False, + creator_id=str(admin_user.id), + store_id=store.id, + ) + await add_object_to_db(event1) + # Event will be used to test quota, so we set a low quota + event2 = models_ticketing.Event( + 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), + store_id=store.id, + ) + await add_object_to_db(event2) + # Event 3 will be used to test disabled state + event3 = models_ticketing.Event( + id=uuid4(), + name="Event 3", + open_date=datetime(2024, 1, 1, tzinfo=UTC), + close_date=datetime(2200, 12, 31, tzinfo=UTC), + quota=100, + user_quota=5, + used_quota=0, + disabled=True, + creator_id=str(admin_user.id), + store_id=store.id, + ) + await add_object_to_db(event3) + + global student_user, student_token + student_user = await create_user_with_groups( + groups=[], account_type=AccountType.student + ) + student_token = create_api_access_token(student_user) + + +@pytest.mark.parametrize( + ("event_id", "expected_code"), + [ + (event1.id, 200), + (event2.id, 200), + (event3.id, 200), + (event_fake_id, 404), + ], +) +def test_get_offer(event_id: uuid.UUID, expected_code: int, client: TestClient): + response = client.get( + f"/pmf/offers/{event_id}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == expected_code From 2589bcd256b070810358d6eee755576bcc026793 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Tue, 17 Feb 2026 21:55:12 +0100 Subject: [PATCH 09/50] WIP tests init --- tests/modules/test_ticketing.py | 66 ++++++++++++++++++++++++--------- 1 file changed, 48 insertions(+), 18 deletions(-) diff --git a/tests/modules/test_ticketing.py b/tests/modules/test_ticketing.py index c2184b60e2..4a7ec9489d 100644 --- a/tests/modules/test_ticketing.py +++ b/tests/modules/test_ticketing.py @@ -37,7 +37,6 @@ event1: models_ticketing.Event event2: models_ticketing.Event -event3: models_ticketing.Event event_fake_id = UUID("5e9ec7bf-0ab4-421a-bbe7-7ec064fcec8d") student_token: str @@ -99,22 +98,22 @@ async def init_objects(): await add_object_to_db(store) # Create events - global event1, event2, event3 + global event1, event2 event1 = models_ticketing.Event( 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=5, - used_quota=0, + quota=4, + user_quota=2, + used_quota=1, disabled=False, creator_id=str(admin_user.id), store_id=store.id, ) await add_object_to_db(event1) - # Event will be used to test quota, so we set a low quota + # Event will be used to test disabled state etc. event2 = models_ticketing.Event( id=uuid4(), name="Event 2", @@ -128,24 +127,56 @@ async def init_objects(): store_id=store.id, ) await add_object_to_db(event2) - # Event 3 will be used to test disabled state - event3 = models_ticketing.Event( + + # Create sessions and categories for event1 + session1 = models_ticketing.Session( id=uuid4(), - name="Event 3", - open_date=datetime(2024, 1, 1, tzinfo=UTC), - close_date=datetime(2200, 12, 31, tzinfo=UTC), - quota=100, - user_quota=5, + event_id=event1.id, + name="Session 1", + quota=2, + user_quota=1, + used_quota=1, + disabled=False, + ) + await add_object_to_db(session1) + session2 = models_ticketing.Session( + id=uuid4(), + event_id=event1.id, + name="Session 2", + quota=2, + user_quota=1, + used_quota=0, + disabled=False, + ) + await add_object_to_db(session2) + session3 = models_ticketing.Session( + id=uuid4(), + event_id=event1.id, + name="Session 3", + quota=2, + user_quota=1, used_quota=0, disabled=True, - creator_id=str(admin_user.id), - store_id=store.id, ) - await add_object_to_db(event3) + await add_object_to_db(session3) + + category1 = models_ticketing.Category( + id=uuid4(), + event_id=event1.id, + name="Category 1", + quota=2, + user_quota=1, + used_quota=1, + disabled=False, + required_mebership=None, + linked_sessions=[session1, session2], + price=100, + ) + await add_object_to_db(category1) global student_user, student_token student_user = await create_user_with_groups( - groups=[], account_type=AccountType.student + groups=[], account_type=AccountType.student, ) student_token = create_api_access_token(student_user) @@ -155,7 +186,6 @@ async def init_objects(): [ (event1.id, 200), (event2.id, 200), - (event3.id, 200), (event_fake_id, 404), ], ) From b3a7bc685fa6b6f4db6d41d39bd5bc902fea840e Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Tue, 17 Feb 2026 22:05:35 +0100 Subject: [PATCH 10/50] Finish rebase Myeclpay was renamed --- app/modules/ticketing/cruds_ticketing.py | 8 ++++---- app/modules/ticketing/factory_ticketing.py | 6 +++--- app/modules/ticketing/models_ticketing.py | 4 ++-- app/modules/ticketing/schemas_ticketing.py | 2 +- tests/modules/test_ticketing.py | 19 ++++++++++--------- 5 files changed, 20 insertions(+), 19 deletions(-) diff --git a/app/modules/ticketing/cruds_ticketing.py b/app/modules/ticketing/cruds_ticketing.py index bbccdae995..efc8f41d73 100644 --- a/app/modules/ticketing/cruds_ticketing.py +++ b/app/modules/ticketing/cruds_ticketing.py @@ -3,7 +3,7 @@ from sqlalchemy import delete, select, update from sqlalchemy.ext.asyncio import AsyncSession -from app.core.myeclpay import schemas_myeclpay +from app.core.mypayment import schemas_mypayment from app.modules.ticketing import models_ticketing, schemas_ticketing @@ -25,7 +25,7 @@ async def get_events( user_quota=event.user_quota, used_quota=event.used_quota, disabled=event.disabled, - store=schemas_myeclpay.StoreSimple( + store=schemas_mypayment.StoreSimple( id=event.store.id, structure_id=event.store.structure_id, wallet_id=event.store.wallet_id, @@ -67,7 +67,7 @@ async def get_event_by_id( user_quota=event.user_quota, used_quota=event.used_quota, disabled=event.disabled, - store=schemas_myeclpay.StoreSimple( + store=schemas_mypayment.StoreSimple( id=event.store.id, structure_id=event.store.structure_id, wallet_id=event.store.wallet_id, @@ -111,7 +111,7 @@ async def get_event_by_name( user_quota=event.user_quota, used_quota=event.used_quota, disabled=event.disabled, - store=schemas_myeclpay.StoreSimple( + store=schemas_mypayment.StoreSimple( id=event.store.id, structure_id=event.store.structure_id, wallet_id=event.store.wallet_id, diff --git a/app/modules/ticketing/factory_ticketing.py b/app/modules/ticketing/factory_ticketing.py index 6d3b9368b2..b99ea1c955 100644 --- a/app/modules/ticketing/factory_ticketing.py +++ b/app/modules/ticketing/factory_ticketing.py @@ -5,7 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.memberships.factory_memberships import CoreMembershipsFactory -from app.core.myeclpay.factory_myeclpay import MyECLPayFactory +from app.core.mypayment.factory_mypayment import MyPaymentFactory from app.core.users.factory_users import CoreUsersFactory from app.core.utils.config import Settings from app.modules.ticketing import cruds_ticketing, schemas_ticketing @@ -15,7 +15,7 @@ class TicketingFactory(Factory): depends_on = [ CoreUsersFactory, - MyECLPayFactory, + MyPaymentFactory, CoreMembershipsFactory, ] @@ -40,7 +40,7 @@ async def add_event(cls, db: AsyncSession) -> None: db, schemas_ticketing.EventSimple( id=cls.event_id, - store_id=MyECLPayFactory.other_stores_id[0][0], + store_id=MyPaymentFactory.other_stores_id[0][0], creator_id=CoreUsersFactory.other_users_id[0], name="Commuz 2025", open_date=datetime.now(UTC), diff --git a/app/modules/ticketing/models_ticketing.py b/app/modules/ticketing/models_ticketing.py index bf1516bae9..b16232a541 100644 --- a/app/modules/ticketing/models_ticketing.py +++ b/app/modules/ticketing/models_ticketing.py @@ -8,7 +8,7 @@ from app.types.sqlalchemy import Base, PrimaryKey if TYPE_CHECKING: - from app.core.myeclpay.models_myeclpay import Store + from app.core.mypayment.models_mypayment import Store from app.modules import ticketing @@ -16,7 +16,7 @@ class Event(Base): __tablename__ = "ticketing_event" id: Mapped[PrimaryKey] - store_id: Mapped[UUID] = mapped_column(ForeignKey("myeclpay_store.id")) + store_id: Mapped[UUID] = mapped_column(ForeignKey("mypayment_store.id")) store: Mapped["Store"] = relationship( init=False, lazy="selectin", diff --git a/app/modules/ticketing/schemas_ticketing.py b/app/modules/ticketing/schemas_ticketing.py index 4268094059..ff581c35cb 100644 --- a/app/modules/ticketing/schemas_ticketing.py +++ b/app/modules/ticketing/schemas_ticketing.py @@ -3,7 +3,7 @@ from pydantic import BaseModel -from app.core.myeclpay.schemas_myeclpay import StoreSimple +from app.core.mypayment.schemas_mypayment import StoreSimple class EventBase(BaseModel): diff --git a/tests/modules/test_ticketing.py b/tests/modules/test_ticketing.py index 4a7ec9489d..cb473f5949 100644 --- a/tests/modules/test_ticketing.py +++ b/tests/modules/test_ticketing.py @@ -8,8 +8,8 @@ from app.core.groups.groups_type import AccountType, GroupType from app.core.memberships import models_memberships -from app.core.myeclpay import models_myeclpay -from app.core.myeclpay.types_myeclpay import WalletType +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 @@ -27,10 +27,10 @@ association_membership: models_memberships.CoreAssociationMembership association_membership_user: models_memberships.CoreAssociationUserMembership -structure: models_myeclpay.Structure +structure: models_mypayment.Structure -store_wallet: models_myeclpay.Wallet -store: models_myeclpay.Store +store_wallet: models_mypayment.Wallet +store: models_mypayment.Store student_user: models_users.CoreUser @@ -62,7 +62,7 @@ async def init_objects(): structure_manager_user = await create_user_with_groups(groups=[]) structure_manager_user_token = create_api_access_token(structure_manager_user) - structure = models_myeclpay.Structure( + structure = models_mypayment.Structure( id=uuid4(), name="Test Structure", creation=datetime.now(UTC), @@ -81,14 +81,14 @@ async def init_objects(): # Create store global store_wallet, store - store_wallet = models_myeclpay.Wallet( + store_wallet = models_mypayment.Wallet( id=uuid4(), type=WalletType.STORE, balance=0, ) await add_object_to_db(store_wallet) - store = models_myeclpay.Store( + store = models_mypayment.Store( id=uuid4(), name="Test Store", structure_id=structure.id, @@ -176,7 +176,8 @@ async def init_objects(): global student_user, student_token student_user = await create_user_with_groups( - groups=[], account_type=AccountType.student, + groups=[], + account_type=AccountType.student, ) student_token = create_api_access_token(student_user) From f21a8adaa2495615a826c900e2ec1de1520db272 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Tue, 17 Feb 2026 22:51:44 +0100 Subject: [PATCH 11/50] Handle new permissions --- app/modules/ticketing/endpoints_ticketing.py | 58 ++++++++++++++++++-- app/modules/ticketing/factory_ticketing.py | 1 + app/modules/ticketing/models_ticketing.py | 25 ++++++++- tests/modules/test_ticketing.py | 15 ++++- 4 files changed, 89 insertions(+), 10 deletions(-) diff --git a/app/modules/ticketing/endpoints_ticketing.py b/app/modules/ticketing/endpoints_ticketing.py index 584d78701b..d3357835b7 100644 --- a/app/modules/ticketing/endpoints_ticketing.py +++ b/app/modules/ticketing/endpoints_ticketing.py @@ -4,12 +4,19 @@ from redis import Redis from sqlalchemy.ext.asyncio import AsyncSession +from app.core.permissions.type_permissions import ModulePermissions from app.core.users import models_users -from app.dependencies import get_db, get_redis_client, is_user +from app.dependencies import get_db, get_redis_client, is_user, is_user_allowed_to from app.modules.ticketing import cache_ticketing, cruds_ticketing, schemas_ticketing from app.modules.ticketing.factory_ticketing import TicketingFactory from app.types.module import Module + +class TicketingPermissions(ModulePermissions): + acess_ticketing = "acess_ticketing" + manage_events = "manage_events" + + router = APIRouter(tags=["Ticketing"]) module = Module( @@ -56,7 +63,9 @@ async def get_event_by_id( async def create_event( event: schemas_ticketing.EventBase, db: AsyncSession = Depends(get_db), - user: models_users.CoreUser = Depends(is_user()), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.manage_events]), + ), ) -> schemas_ticketing.EventComplete: """Create a new event.""" stored = await cruds_ticketing.get_event_by_name(name=event.name, db=db) @@ -85,6 +94,9 @@ async def update_event( event_id: UUID, event_update: schemas_ticketing.EventUpdate, db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.manage_events]), + ), ) -> None: """Update an existing event.""" stored = await cruds_ticketing.get_event_by_id(event_id=event_id, db=db) @@ -106,6 +118,9 @@ async def update_event( async def delete_event( event_id: UUID, db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.manage_events]), + ), ) -> None: """Delete an existing event.""" stored = await cruds_ticketing.get_event_by_id(event_id=event_id, db=db) @@ -145,6 +160,9 @@ async def get_session_by_id( async def create_session( session: schemas_ticketing.SessionBase, db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.manage_events]), + ), ) -> schemas_ticketing.SessionComplete: """Create a new session.""" session_simple = schemas_ticketing.SessionSimple( @@ -174,6 +192,9 @@ async def update_session( session_id: UUID, session_update: schemas_ticketing.SessionUpdate, db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.manage_events]), + ), ) -> None: """Update an existing session.""" stored = await cruds_ticketing.get_session_by_id(session_id=session_id, db=db) @@ -195,6 +216,9 @@ async def update_session( async def delete_session( session_id: UUID, db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.manage_events]), + ), ) -> None: """Delete an existing session.""" stored = await cruds_ticketing.get_session_by_id(session_id=session_id, db=db) @@ -234,6 +258,9 @@ async def get_category_by_id( async def create_category( category: schemas_ticketing.CategoryBase, db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.manage_events]), + ), ) -> schemas_ticketing.CategoryComplete: """Create a new category.""" category_complete = schemas_ticketing.CategoryComplete( @@ -256,6 +283,9 @@ async def update_category( category_id: UUID, category_update: schemas_ticketing.CategoryUpdate, db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.manage_events]), + ), ) -> None: """Update an existing category.""" stored = await cruds_ticketing.get_category_by_id(category_id=category_id, db=db) @@ -277,6 +307,9 @@ async def update_category( async def delete_category( category_id: UUID, db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.manage_events]), + ), ) -> None: """Delete an existing category.""" stored = await cruds_ticketing.get_category_by_id(category_id=category_id, db=db) @@ -370,13 +403,22 @@ async def create_ticket( ) # 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) + 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) + 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) + 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") @@ -459,7 +501,8 @@ async def update_ticket( 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", @@ -474,4 +517,7 @@ async def delete_ticket( stored = await cruds_ticketing.get_ticket_by_id(ticket_id=ticket_id, db=db) if stored is None: raise HTTPException(status_code=404, detail="Ticket not found") + # TODO: Add permission check to allow only the user who has created the ticket or users with manage_events permission to delete the ticket + # Should it be a pending ticket? + # Should we keep the ticket but mark it as cancelled to keep track of the quota and for historical data? await cruds_ticketing.delete_ticket(ticket_id=ticket_id, db=db) diff --git a/app/modules/ticketing/factory_ticketing.py b/app/modules/ticketing/factory_ticketing.py index b99ea1c955..d2f7c12548 100644 --- a/app/modules/ticketing/factory_ticketing.py +++ b/app/modules/ticketing/factory_ticketing.py @@ -144,6 +144,7 @@ async def add_tickets(cls, db: AsyncSession) -> None: 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, diff --git a/app/modules/ticketing/models_ticketing.py b/app/modules/ticketing/models_ticketing.py index b16232a541..2e66db09b6 100644 --- a/app/modules/ticketing/models_ticketing.py +++ b/app/modules/ticketing/models_ticketing.py @@ -12,6 +12,13 @@ from app.modules import ticketing +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 Event(Base): __tablename__ = "ticketing_event" @@ -55,6 +62,14 @@ class Session(Base): user_quota: Mapped[int | None] disabled: Mapped[bool] + categories: Mapped[list["Category"]] = relationship( + secondary=CategorySessionAssociation.__table__, + back_populates="sessions", + init=False, + lazy="selectin", + default_factory=list, + ) + class Category(Base): __tablename__ = "ticketing_category" @@ -66,7 +81,13 @@ class Category(Base): lazy="selectin", ) name: Mapped[str] - linked_sessions: Mapped[str | None] + sessions: Mapped[list["Session"]] = 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"), ) @@ -99,5 +120,5 @@ class Ticket(Base): ) total: Mapped[int] created_at: Mapped[datetime] - status: Mapped[str] # TODO: Enum + status: Mapped[str] # TODO: Enum nb_scan: Mapped[int] diff --git a/tests/modules/test_ticketing.py b/tests/modules/test_ticketing.py index cb473f5949..11bde655d1 100644 --- a/tests/modules/test_ticketing.py +++ b/tests/modules/test_ticketing.py @@ -6,6 +6,7 @@ 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 @@ -14,9 +15,11 @@ from app.modules.ticketing import models_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, ) @@ -25,6 +28,8 @@ structure_manager_user: models_users.CoreUser structure_manager_user_token: str +bde_group: models_groups.Groups + association_membership: models_memberships.CoreAssociationMembership association_membership_user: models_memberships.CoreAssociationUserMembership structure: models_mypayment.Structure @@ -49,11 +54,17 @@ async def init_objects(): admin_user = await create_user_with_groups(groups=[GroupType.admin]) admin_user_token = create_api_access_token(admin_user) + global bde_group + bde_group = await create_groups_with_permissions( + [TicketingPermissions.manage_events], + "BDE Group", + ) + global association_membership association_membership = models_memberships.CoreAssociationMembership( id=uuid4(), name="Test Association Membership", - manager_group_id=GroupType.BDE, + manager_group_id=bde_group.id, ) await add_object_to_db(association_membership) @@ -169,9 +180,9 @@ async def init_objects(): used_quota=1, disabled=False, required_mebership=None, - linked_sessions=[session1, session2], price=100, ) + category1.sessions = [session1, session2] await add_object_to_db(category1) global student_user, student_token From ac00327a75640f1bb490caebe5150fed2283a3a3 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Tue, 17 Feb 2026 23:03:20 +0100 Subject: [PATCH 12/50] Fix parametrized test --- tests/modules/test_ticketing.py | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/tests/modules/test_ticketing.py b/tests/modules/test_ticketing.py index 11bde655d1..b5ccb75a97 100644 --- a/tests/modules/test_ticketing.py +++ b/tests/modules/test_ticketing.py @@ -42,7 +42,7 @@ event1: models_ticketing.Event event2: models_ticketing.Event -event_fake_id = UUID("5e9ec7bf-0ab4-421a-bbe7-7ec064fcec8d") +event_fake: models_ticketing.Event student_token: str admin_token: str @@ -109,7 +109,7 @@ async def init_objects(): await add_object_to_db(store) # Create events - global event1, event2 + global event1, event2, event_fake event1 = models_ticketing.Event( id=uuid4(), name="Event 1", @@ -139,6 +139,20 @@ async def init_objects(): ) await add_object_to_db(event2) + event_fake = models_ticketing.Event( + id=uuid4(), + name="Event Fake", + 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), + store_id=store.id, + ) + # Do not add event_fake to the database + # Create sessions and categories for event1 session1 = models_ticketing.Session( id=uuid4(), @@ -196,14 +210,14 @@ async def init_objects(): @pytest.mark.parametrize( ("event_id", "expected_code"), [ - (event1.id, 200), - (event2.id, 200), - (event_fake_id, 404), + (event1, 200), + (event2, 200), + (event_fake, 404), ], ) -def test_get_offer(event_id: uuid.UUID, expected_code: int, client: TestClient): +def test_get_offer(event: models_ticketing.Event, expected_code: int, client: TestClient): response = client.get( - f"/pmf/offers/{event_id}", + f"/pmf/offers/{event.id}", headers={"Authorization": f"Bearer {student_token}"}, ) assert response.status_code == expected_code From a1dc661bf7cf18b20b76cbf4e51eb24ca1940324 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Tue, 17 Feb 2026 23:16:21 +0100 Subject: [PATCH 13/50] Cannot parametrize global variables sick --- tests/modules/test_ticketing.py | 35 +++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/tests/modules/test_ticketing.py b/tests/modules/test_ticketing.py index b5ccb75a97..a81cf49ed8 100644 --- a/tests/modules/test_ticketing.py +++ b/tests/modules/test_ticketing.py @@ -1,8 +1,6 @@ -import uuid from datetime import UTC, datetime -from uuid import UUID, uuid4 +from uuid import uuid4 -import pytest import pytest_asyncio from fastapi.testclient import TestClient @@ -28,7 +26,7 @@ structure_manager_user: models_users.CoreUser structure_manager_user_token: str -bde_group: models_groups.Groups +bde_group: models_groups.CoreGroup association_membership: models_memberships.CoreAssociationMembership association_membership_user: models_memberships.CoreAssociationUserMembership @@ -207,17 +205,24 @@ async def init_objects(): student_token = create_api_access_token(student_user) -@pytest.mark.parametrize( - ("event_id", "expected_code"), - [ - (event1, 200), - (event2, 200), - (event_fake, 404), - ], -) -def test_get_offer(event: models_ticketing.Event, expected_code: int, client: TestClient): +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"/pmf/offers/{event.id}", + f"/ticketing/events/{event_fake.id}", headers={"Authorization": f"Bearer {student_token}"}, ) - assert response.status_code == expected_code + assert response.status_code == 404 From 7c3683e44a1b01025d22f5fe1e5d2afcea0b867b Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Tue, 17 Feb 2026 23:19:42 +0100 Subject: [PATCH 14/50] Migrations rebase --- migrations/versions/{45-ticketing.py => 56-ticketing.py} | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) rename migrations/versions/{45-ticketing.py => 56-ticketing.py} (96%) diff --git a/migrations/versions/45-ticketing.py b/migrations/versions/56-ticketing.py similarity index 96% rename from migrations/versions/45-ticketing.py rename to migrations/versions/56-ticketing.py index 4a92f88ac4..75cd80c46b 100644 --- a/migrations/versions/45-ticketing.py +++ b/migrations/versions/56-ticketing.py @@ -16,7 +16,7 @@ # revision identifiers, used by Alembic. revision: str = "b2e04c519621" -down_revision: str | None = "91fadc90f892" +down_revision: str | None = "562adbd796ae" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None @@ -53,7 +53,8 @@ def upgrade() -> None: sa.Column("disabled", sa.Boolean(), nullable=False), sa.ForeignKeyConstraint(["event_id"], ["ticketing_event.id"]), sa.ForeignKeyConstraint( - ["required_mebership"], ["core_association_membership.id"], + ["required_mebership"], + ["core_association_membership.id"], ), sa.PrimaryKeyConstraint("id"), ) From 4bc953635b489b382e7308d77b4f05ff73872ea5 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Wed, 18 Feb 2026 00:41:31 +0100 Subject: [PATCH 15/50] WIP --- app/modules/ticketing/cruds_ticketing.py | 26 ++--- app/modules/ticketing/factory_ticketing.py | 6 +- app/modules/ticketing/models_ticketing.py | 4 +- app/modules/ticketing/schemas_ticketing.py | 4 +- migrations/versions/56-ticketing.py | 111 --------------------- 5 files changed, 21 insertions(+), 130 deletions(-) delete mode 100644 migrations/versions/56-ticketing.py diff --git a/app/modules/ticketing/cruds_ticketing.py b/app/modules/ticketing/cruds_ticketing.py index efc8f41d73..7743729f5b 100644 --- a/app/modules/ticketing/cruds_ticketing.py +++ b/app/modules/ticketing/cruds_ticketing.py @@ -162,7 +162,10 @@ async def increment_used_quota_event( update(models_ticketing.Event) # 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.Event.id == event_id and models_ticketing.Event.used_quota < models_ticketing.Event.quota) + .where( + models_ticketing.Event.id == event_id + and models_ticketing.Event.used_quota < models_ticketing.Event.quota, + ) .values(used_quota=models_ticketing.Event.used_quota + 1), ) @@ -265,7 +268,10 @@ async def increment_used_quota_session( update(models_ticketing.Session) # 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.Session.id == session_id and models_ticketing.Session.used_quota < models_ticketing.Session.quota) + .where( + models_ticketing.Session.id == session_id + and models_ticketing.Session.used_quota < models_ticketing.Session.quota, + ) .values(used_quota=models_ticketing.Session.used_quota + 1), ) @@ -296,7 +302,7 @@ async def get_category_by_id( event_id=category.event_id, event=category.event, name=category.name, - linked_sessions=category.linked_sessions, + sessions=category.sessions, required_mebership=category.required_mebership, quota=category.quota, user_quota=category.user_quota, @@ -314,15 +320,8 @@ async def create_category( category: schemas_ticketing.CategorySimple, ) -> None: """Create a new category.""" - joined_sessions = ( - ",".join(str(session_id) for session_id in category.linked_sessions) - if category.linked_sessions - else None - ) - category_model = category.model_dump() - category_model["linked_sessions"] = joined_sessions db.add( - models_ticketing.Category(**category_model), + models_ticketing.Category(**category.model_dump()), ) await db.flush() @@ -365,7 +364,10 @@ async def increment_used_quota_category( update(models_ticketing.Category) # 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.Category.id == category_id and models_ticketing.Category.used_quota < models_ticketing.Category.quota) + .where( + models_ticketing.Category.id == category_id + and models_ticketing.Category.used_quota < models_ticketing.Category.quota, + ) .values(used_quota=models_ticketing.Category.used_quota + 1), ) diff --git a/app/modules/ticketing/factory_ticketing.py b/app/modules/ticketing/factory_ticketing.py index d2f7c12548..8a6ba3a818 100644 --- a/app/modules/ticketing/factory_ticketing.py +++ b/app/modules/ticketing/factory_ticketing.py @@ -89,7 +89,7 @@ async def add_categories(cls, db: AsyncSession) -> None: id=cls.category1_id, event_id=cls.event_id, name="Étudiant Centrale", - linked_sessions=[cls.session1_id, cls.session2_id], + sessions=[cls.session1_id, cls.session2_id], required_mebership=CoreMembershipsFactory.memberships_ids[0], quota=150, user_quota=2, @@ -104,7 +104,7 @@ async def add_categories(cls, db: AsyncSession) -> None: id=cls.category2_id, event_id=cls.event_id, name="Étudiant Lyon", - linked_sessions=[cls.session1_id, cls.session2_id], + sessions=[cls.session1_id, cls.session2_id], required_mebership=CoreMembershipsFactory.memberships_ids[1], quota=200, user_quota=2, @@ -119,7 +119,7 @@ async def add_categories(cls, db: AsyncSession) -> None: id=cls.category3_id, event_id=cls.event_id, name="Externe", - linked_sessions=[cls.session1_id], + sessions=[cls.session1_id], required_mebership=None, quota=100, user_quota=1, diff --git a/app/modules/ticketing/models_ticketing.py b/app/modules/ticketing/models_ticketing.py index 2e66db09b6..5e0aa9baf9 100644 --- a/app/modules/ticketing/models_ticketing.py +++ b/app/modules/ticketing/models_ticketing.py @@ -62,7 +62,7 @@ class Session(Base): user_quota: Mapped[int | None] disabled: Mapped[bool] - categories: Mapped[list["Category"]] = relationship( + categories: Mapped[list["ticketing.models_ticketing.Category"]] = relationship( secondary=CategorySessionAssociation.__table__, back_populates="sessions", init=False, @@ -81,7 +81,7 @@ class Category(Base): lazy="selectin", ) name: Mapped[str] - sessions: Mapped[list["Session"]] = relationship( + sessions: Mapped[list["ticketing.models_ticketing.Session"]] = relationship( secondary=CategorySessionAssociation.__table__, back_populates="categories", init=False, diff --git a/app/modules/ticketing/schemas_ticketing.py b/app/modules/ticketing/schemas_ticketing.py index ff581c35cb..60f7c4a3d1 100644 --- a/app/modules/ticketing/schemas_ticketing.py +++ b/app/modules/ticketing/schemas_ticketing.py @@ -64,7 +64,6 @@ class SessionUpdate(BaseModel): class CategoryBase(BaseModel): event_id: UUID name: str - linked_sessions: list[UUID] | None = None required_mebership: UUID | None = None quota: int | None = None user_quota: int | None = None @@ -79,11 +78,12 @@ class CategorySimple(CategoryBase): class CategoryComplete(CategorySimple): event: EventSimple + sessions: list[UUID] | None = None class CategoryUpdate(BaseModel): name: str | None = None - linked_sessions: list[UUID] | None = None + sessions: list[UUID] | None = None required_mebership: UUID | None = None quota: int | None = None user_quota: int | None = None diff --git a/migrations/versions/56-ticketing.py b/migrations/versions/56-ticketing.py deleted file mode 100644 index 75cd80c46b..0000000000 --- a/migrations/versions/56-ticketing.py +++ /dev/null @@ -1,111 +0,0 @@ -"""ticketing - -Create Date: 2025-11-30 02:18:35.600988 -""" - -from collections.abc import Sequence -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from pytest_alembic import MigrationContext - -import sqlalchemy as sa -from alembic import op - -from app.types.sqlalchemy import TZDateTime - -# revision identifiers, used by Alembic. -revision: str = "b2e04c519621" -down_revision: str | None = "562adbd796ae" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.create_table( - "ticketing_event", - sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("store_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(["store_id"], ["myeclpay_store.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("linked_sessions", sa.String(), nullable=True), - 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("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_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("total", sa.Integer(), nullable=False), - sa.Column("created_at", TZDateTime(), nullable=False), - sa.Column("status", sa.String(), nullable=False), - sa.Column("nb_scan", sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(["category_id"], ["ticketing_category.id"]), - sa.ForeignKeyConstraint(["event_id"], ["ticketing_event.id"]), - sa.ForeignKeyConstraint(["user_id"], ["core_user.id"]), - sa.PrimaryKeyConstraint("id"), - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table("ticketing_ticket") - op.drop_table("ticketing_session") - op.drop_table("ticketing_category") - op.drop_table("ticketing_event") - # ### 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 From cbf0ffc244f2ad10e7fbe7c4ff1e560d1fb58470 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Wed, 18 Feb 2026 00:47:27 +0100 Subject: [PATCH 16/50] repaired migrations --- migrations/versions/56-ticketing.py | 120 ++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 migrations/versions/56-ticketing.py diff --git a/migrations/versions/56-ticketing.py b/migrations/versions/56-ticketing.py new file mode 100644 index 0000000000..ff8c05fb3b --- /dev/null +++ b/migrations/versions/56-ticketing.py @@ -0,0 +1,120 @@ +"""56-ticketing + +Create Date: 2026-02-18 00:43:05.909471 +""" + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pytest_alembic import MigrationContext + +import sqlalchemy as sa +from alembic import op + +from app.types.sqlalchemy import TZDateTime + +# revision identifiers, used by Alembic. +revision: str = "321017e1de7f" +down_revision: str | None = "562adbd796ae" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "ticketing_event", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("store_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(["store_id"], ["mypayment_store.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("quota", sa.Integer(), nullable=True), + sa.Column("used_quota", sa.Integer(), nullable=False), + sa.Column("user_quota", sa.Integer(), nullable=True), + sa.Column("disabled", sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(["event_id"], ["ticketing_event.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "ticketing_category_session", + sa.Column("category_id", sa.Uuid(), nullable=False), + sa.Column("session_id", sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint(["category_id"], ["ticketing_category.id"]), + sa.ForeignKeyConstraint(["session_id"], ["ticketing_session.id"]), + sa.PrimaryKeyConstraint("category_id", "session_id"), + ) + op.create_table( + "ticketing_ticket", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("user_id", sa.String(), nullable=False), + sa.Column("event_id", sa.Uuid(), nullable=False), + sa.Column("category_id", sa.Uuid(), nullable=False), + sa.Column("session_id", sa.Uuid(), nullable=True), + sa.Column("total", sa.Integer(), nullable=False), + sa.Column("created_at", TZDateTime(), nullable=False), + sa.Column("status", sa.String(), nullable=False), + sa.Column("nb_scan", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["category_id"], ["ticketing_category.id"]), + sa.ForeignKeyConstraint(["event_id"], ["ticketing_event.id"]), + sa.ForeignKeyConstraint(["session_id"], ["ticketing_session.id"]), + sa.ForeignKeyConstraint(["user_id"], ["core_user.id"]), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("ticketing_ticket") + op.drop_table("ticketing_category_session") + op.drop_table("ticketing_session") + op.drop_table("ticketing_category") + op.drop_table("ticketing_event") + # ### 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 From 3609b3548f5bf7ad0d77201263a76495a896346a Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:37:57 +0100 Subject: [PATCH 17/50] First fixes --- app/modules/ticketing/cruds_ticketing.py | 15 ++- app/modules/ticketing/models_ticketing.py | 21 ++-- migrations/versions/56-ticketing.py | 120 ---------------------- 3 files changed, 26 insertions(+), 130 deletions(-) delete mode 100644 migrations/versions/56-ticketing.py diff --git a/app/modules/ticketing/cruds_ticketing.py b/app/modules/ticketing/cruds_ticketing.py index 7743729f5b..317c3c6f1d 100644 --- a/app/modules/ticketing/cruds_ticketing.py +++ b/app/modules/ticketing/cruds_ticketing.py @@ -2,6 +2,7 @@ from sqlalchemy import delete, select, update from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from app.core.mypayment import schemas_mypayment from app.modules.ticketing import models_ticketing, schemas_ticketing @@ -48,6 +49,9 @@ async def get_event_by_id( await db.execute( select(models_ticketing.Event).where( models_ticketing.Event.id == event_id, + ).options( + selectinload(models_ticketing.Event.sessions), + selectinload(models_ticketing.Event.categories), ), ) ) @@ -74,8 +78,8 @@ async def get_event_by_id( name=event.store.name, creation=event.store.creation, ), - sessions=event.sessions, - categories=event.categories, + sessions=[schemas_ticketing.SessionSimple.model_validate(session) for session in event.sessions], + categories=[schemas_ticketing.CategorySimple.model_validate(category) for category in event.categories], ) if event else None @@ -92,6 +96,9 @@ async def get_event_by_name( await db.execute( select(models_ticketing.Event).where( models_ticketing.Event.name == name, + ).options( + selectinload(models_ticketing.Event.sessions), + selectinload(models_ticketing.Event.categories), ), ) ) @@ -118,8 +125,8 @@ async def get_event_by_name( name=event.store.name, creation=event.store.creation, ), - sessions=event.sessions, - categories=event.categories, + sessions=[schemas_ticketing.SessionSimple.model_validate(session) for session in event.sessions], + categories=[schemas_ticketing.CategorySimple.model_validate(category) for category in event.categories], ) if event else None diff --git a/app/modules/ticketing/models_ticketing.py b/app/modules/ticketing/models_ticketing.py index 5e0aa9baf9..886b049bd0 100644 --- a/app/modules/ticketing/models_ticketing.py +++ b/app/modules/ticketing/models_ticketing.py @@ -9,14 +9,19 @@ if TYPE_CHECKING: from app.core.mypayment.models_mypayment import Store - from app.modules import ticketing 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) + 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 Event(Base): @@ -37,11 +42,13 @@ class Event(Base): user_quota: Mapped[int | None] disabled: Mapped[bool] - sessions: Mapped[list["ticketing.models_ticketing.Session"]] = relationship( + sessions: Mapped[list["Session"]] = relationship( + back_populates="event", init=False, lazy="selectin", ) categories: Mapped[list["Category"]] = relationship( + back_populates="event", init=False, lazy="selectin", ) @@ -53,6 +60,7 @@ class Session(Base): id: Mapped[PrimaryKey] event_id: Mapped[UUID] = mapped_column(ForeignKey("ticketing_event.id")) event: Mapped[Event] = relationship( + back_populates="sessions", init=False, lazy="selectin", ) @@ -62,7 +70,7 @@ class Session(Base): user_quota: Mapped[int | None] disabled: Mapped[bool] - categories: Mapped[list["ticketing.models_ticketing.Category"]] = relationship( + categories: Mapped[list["Category"]] = relationship( secondary=CategorySessionAssociation.__table__, back_populates="sessions", init=False, @@ -77,11 +85,12 @@ class Category(Base): id: Mapped[PrimaryKey] event_id: Mapped[UUID] = mapped_column(ForeignKey("ticketing_event.id")) event: Mapped[Event] = relationship( + back_populates="categories", init=False, lazy="selectin", ) name: Mapped[str] - sessions: Mapped[list["ticketing.models_ticketing.Session"]] = relationship( + sessions: Mapped[list["Session"]] = relationship( secondary=CategorySessionAssociation.__table__, back_populates="categories", init=False, diff --git a/migrations/versions/56-ticketing.py b/migrations/versions/56-ticketing.py deleted file mode 100644 index ff8c05fb3b..0000000000 --- a/migrations/versions/56-ticketing.py +++ /dev/null @@ -1,120 +0,0 @@ -"""56-ticketing - -Create Date: 2026-02-18 00:43:05.909471 -""" - -from collections.abc import Sequence -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from pytest_alembic import MigrationContext - -import sqlalchemy as sa -from alembic import op - -from app.types.sqlalchemy import TZDateTime - -# revision identifiers, used by Alembic. -revision: str = "321017e1de7f" -down_revision: str | None = "562adbd796ae" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.create_table( - "ticketing_event", - sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("store_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(["store_id"], ["mypayment_store.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("quota", sa.Integer(), nullable=True), - sa.Column("used_quota", sa.Integer(), nullable=False), - sa.Column("user_quota", sa.Integer(), nullable=True), - sa.Column("disabled", sa.Boolean(), nullable=False), - sa.ForeignKeyConstraint(["event_id"], ["ticketing_event.id"]), - sa.PrimaryKeyConstraint("id"), - ) - op.create_table( - "ticketing_category_session", - sa.Column("category_id", sa.Uuid(), nullable=False), - sa.Column("session_id", sa.Uuid(), nullable=False), - sa.ForeignKeyConstraint(["category_id"], ["ticketing_category.id"]), - sa.ForeignKeyConstraint(["session_id"], ["ticketing_session.id"]), - sa.PrimaryKeyConstraint("category_id", "session_id"), - ) - op.create_table( - "ticketing_ticket", - sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("user_id", sa.String(), nullable=False), - sa.Column("event_id", sa.Uuid(), nullable=False), - sa.Column("category_id", sa.Uuid(), nullable=False), - sa.Column("session_id", sa.Uuid(), nullable=True), - sa.Column("total", sa.Integer(), nullable=False), - sa.Column("created_at", TZDateTime(), nullable=False), - sa.Column("status", sa.String(), nullable=False), - sa.Column("nb_scan", sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(["category_id"], ["ticketing_category.id"]), - sa.ForeignKeyConstraint(["event_id"], ["ticketing_event.id"]), - sa.ForeignKeyConstraint(["session_id"], ["ticketing_session.id"]), - sa.ForeignKeyConstraint(["user_id"], ["core_user.id"]), - sa.PrimaryKeyConstraint("id"), - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table("ticketing_ticket") - op.drop_table("ticketing_category_session") - op.drop_table("ticketing_session") - op.drop_table("ticketing_category") - op.drop_table("ticketing_event") - # ### 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 From fe4bfece6d2a8809349fd00b00f66b9ed421cd71 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Wed, 18 Feb 2026 18:05:05 +0100 Subject: [PATCH 18/50] Renaming due to SQLAlchemy class conflict --- app/modules/ticketing/cruds_ticketing.py | 131 ++++++++++++--------- app/modules/ticketing/factory_ticketing.py | 4 +- app/modules/ticketing/models_ticketing.py | 34 ++++-- tests/modules/test_ticketing.py | 16 +-- 4 files changed, 107 insertions(+), 78 deletions(-) diff --git a/app/modules/ticketing/cruds_ticketing.py b/app/modules/ticketing/cruds_ticketing.py index 317c3c6f1d..a91002ab97 100644 --- a/app/modules/ticketing/cruds_ticketing.py +++ b/app/modules/ticketing/cruds_ticketing.py @@ -13,7 +13,7 @@ async def get_events( ) -> list[schemas_ticketing.EventSimple]: """Get all events.""" - events = await db.execute(select(models_ticketing.Event)) + events = await db.execute(select(models_ticketing.TicketingEvent)) return [ schemas_ticketing.EventSimple( id=event.id, @@ -47,11 +47,13 @@ async def get_event_by_id( event = ( ( await db.execute( - select(models_ticketing.Event).where( - models_ticketing.Event.id == event_id, - ).options( - selectinload(models_ticketing.Event.sessions), - selectinload(models_ticketing.Event.categories), + select(models_ticketing.TicketingEvent) + .where( + models_ticketing.TicketingEvent.id == event_id, + ) + .options( + selectinload(models_ticketing.TicketingEvent.sessions), + selectinload(models_ticketing.TicketingEvent.categories), ), ) ) @@ -78,8 +80,14 @@ async def get_event_by_id( name=event.store.name, creation=event.store.creation, ), - sessions=[schemas_ticketing.SessionSimple.model_validate(session) for session in event.sessions], - categories=[schemas_ticketing.CategorySimple.model_validate(category) for category in event.categories], + sessions=[ + schemas_ticketing.SessionSimple.model_validate(session) + for session in event.sessions + ], + categories=[ + schemas_ticketing.CategorySimple.model_validate(category) + for category in event.categories + ], ) if event else None @@ -94,11 +102,13 @@ async def get_event_by_name( event = ( ( await db.execute( - select(models_ticketing.Event).where( - models_ticketing.Event.name == name, - ).options( - selectinload(models_ticketing.Event.sessions), - selectinload(models_ticketing.Event.categories), + select(models_ticketing.TicketingEvent) + .where( + models_ticketing.TicketingEvent.name == name, + ) + .options( + selectinload(models_ticketing.TicketingEvent.sessions), + selectinload(models_ticketing.TicketingEvent.categories), ), ) ) @@ -125,8 +135,14 @@ async def get_event_by_name( name=event.store.name, creation=event.store.creation, ), - sessions=[schemas_ticketing.SessionSimple.model_validate(session) for session in event.sessions], - categories=[schemas_ticketing.CategorySimple.model_validate(category) for category in event.categories], + sessions=[ + schemas_ticketing.SessionSimple.model_validate(session) + for session in event.sessions + ], + categories=[ + schemas_ticketing.CategorySimple.model_validate(category) + for category in event.categories + ], ) if event else None @@ -140,7 +156,7 @@ async def create_event( """Create a new event.""" db.add( - models_ticketing.Event(**event.model_dump()), + models_ticketing.TicketingEvent(**event.model_dump()), ) await db.flush() @@ -153,8 +169,8 @@ async def update_event( """Update an existing event.""" await db.execute( - update(models_ticketing.Event) - .where(models_ticketing.Event.id == event_id) + update(models_ticketing.TicketingEvent) + .where(models_ticketing.TicketingEvent.id == event_id) .values(**event_update.model_dump(exclude_unset=True)), ) await db.flush() @@ -166,14 +182,15 @@ async def increment_used_quota_event( ) -> None: """Increment the used quota of an event, its sessions and its category if applicable.""" await db.execute( - update(models_ticketing.Event) + 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.Event.id == event_id - and models_ticketing.Event.used_quota < models_ticketing.Event.quota, + models_ticketing.TicketingEvent.id == event_id + and models_ticketing.TicketingEvent.used_quota + < models_ticketing.TicketingEvent.quota, ) - .values(used_quota=models_ticketing.Event.used_quota + 1), + .values(used_quota=models_ticketing.TicketingEvent.used_quota + 1), ) await db.flush() @@ -186,7 +203,9 @@ async def delete_event( """Delete an existing event.""" await db.execute( - delete(models_ticketing.Event).where(models_ticketing.Event.id == event_id), + delete(models_ticketing.TicketingEvent).where( + models_ticketing.TicketingEvent.id == event_id, + ), ) await db.flush() @@ -200,8 +219,8 @@ async def get_session_by_id( session = ( ( await db.execute( - select(models_ticketing.Session).where( - models_ticketing.Session.id == session_id, + select(models_ticketing.TicketingSession).where( + models_ticketing.TicketingSession.id == session_id, ), ) ) @@ -232,7 +251,7 @@ async def create_session( """Create a new session.""" db.add( - models_ticketing.Session(**session.model_dump()), + models_ticketing.TicketingSession(**session.model_dump()), ) await db.flush() @@ -245,8 +264,8 @@ async def update_session( """Update an existing session.""" await db.execute( - update(models_ticketing.Session) - .where(models_ticketing.Session.id == session_id) + update(models_ticketing.TicketingSession) + .where(models_ticketing.TicketingSession.id == session_id) .values(**session_update.model_dump(exclude_unset=True)), ) await db.flush() @@ -259,8 +278,8 @@ async def delete_session( """Delete an existing session.""" await db.execute( - delete(models_ticketing.Session).where( - models_ticketing.Session.id == session_id, + delete(models_ticketing.TicketingSession).where( + models_ticketing.TicketingSession.id == session_id, ), ) await db.flush() @@ -272,14 +291,15 @@ async def increment_used_quota_session( ) -> None: """Increment the used quota of a session.""" await db.execute( - update(models_ticketing.Session) + 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.Session.id == session_id - and models_ticketing.Session.used_quota < models_ticketing.Session.quota, + models_ticketing.TicketingSession.id == session_id + and models_ticketing.TicketingSession.used_quota + < models_ticketing.TicketingSession.quota, ) - .values(used_quota=models_ticketing.Session.used_quota + 1), + .values(used_quota=models_ticketing.TicketingSession.used_quota + 1), ) await db.flush() @@ -294,8 +314,8 @@ async def get_category_by_id( category = ( ( await db.execute( - select(models_ticketing.Category).where( - models_ticketing.Category.id == category_id, + select(models_ticketing.TicketingCategory).where( + models_ticketing.TicketingCategory.id == category_id, ), ) ) @@ -328,7 +348,7 @@ async def create_category( ) -> None: """Create a new category.""" db.add( - models_ticketing.Category(**category.model_dump()), + models_ticketing.TicketingCategory(**category.model_dump()), ) await db.flush() @@ -341,8 +361,8 @@ async def update_category( """Update an existing category.""" await db.execute( - update(models_ticketing.Category) - .where(models_ticketing.Category.id == category_id) + update(models_ticketing.TicketingCategory) + .where(models_ticketing.TicketingCategory.id == category_id) .values(**category_update.model_dump(exclude_unset=True)), ) await db.flush() @@ -355,8 +375,8 @@ async def delete_category( """Delete an existing category.""" await db.execute( - delete(models_ticketing.Category).where( - models_ticketing.Category.id == category_id, + delete(models_ticketing.TicketingCategory).where( + models_ticketing.TicketingCategory.id == category_id, ), ) await db.flush() @@ -368,14 +388,15 @@ async def increment_used_quota_category( ) -> None: """Increment the used quota of a category.""" await db.execute( - update(models_ticketing.Category) + 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.Category.id == category_id - and models_ticketing.Category.used_quota < models_ticketing.Category.quota, + models_ticketing.TicketingCategory.id == category_id + and models_ticketing.TicketingCategory.used_quota + < models_ticketing.TicketingCategory.quota, ) - .values(used_quota=models_ticketing.Category.used_quota + 1), + .values(used_quota=models_ticketing.TicketingCategory.used_quota + 1), ) await db.flush() @@ -386,7 +407,7 @@ async def get_tickets( ) -> list[schemas_ticketing.TicketComplete]: """Get all tickets.""" - tickets = await db.execute(select(models_ticketing.Ticket)) + tickets = await db.execute(select(models_ticketing.TicketingTicket)) return [ schemas_ticketing.TicketComplete( id=ticket.id, @@ -413,8 +434,8 @@ async def get_tickets_by_user_id( """Get all tickets for a specific user.""" tickets = await db.execute( - select(models_ticketing.Ticket).where( - models_ticketing.Ticket.user_id == user_id, + select(models_ticketing.TicketingTicket).where( + models_ticketing.TicketingTicket.user_id == user_id, ), ) return [ @@ -445,8 +466,8 @@ async def get_ticket_by_id( ticket = ( ( await db.execute( - select(models_ticketing.Ticket).where( - models_ticketing.Ticket.id == ticket_id, + select(models_ticketing.TicketingTicket).where( + models_ticketing.TicketingTicket.id == ticket_id, ), ) ) @@ -481,7 +502,7 @@ async def create_ticket( """Create a new ticket.""" db.add( - models_ticketing.Ticket(**ticket.model_dump()), + models_ticketing.TicketingTicket(**ticket.model_dump()), ) await db.flush() @@ -494,8 +515,8 @@ async def update_ticket( """Update an existing ticket.""" await db.execute( - update(models_ticketing.Ticket) - .where(models_ticketing.Ticket.id == ticket_id) + update(models_ticketing.TicketingTicket) + .where(models_ticketing.TicketingTicket.id == ticket_id) .values(**ticket_update.model_dump(exclude_unset=True)), ) await db.flush() @@ -508,8 +529,8 @@ async def delete_ticket( """Delete an existing ticket.""" await db.execute( - delete(models_ticketing.Ticket).where( - models_ticketing.Ticket.id == ticket_id, + delete(models_ticketing.TicketingTicket).where( + models_ticketing.TicketingTicket.id == ticket_id, ), ) await db.flush() diff --git a/app/modules/ticketing/factory_ticketing.py b/app/modules/ticketing/factory_ticketing.py index 8a6ba3a818..0a88ef5aa1 100644 --- a/app/modules/ticketing/factory_ticketing.py +++ b/app/modules/ticketing/factory_ticketing.py @@ -60,7 +60,7 @@ async def add_sessions(cls, db: AsyncSession) -> None: schemas_ticketing.SessionSimple( id=cls.session1_id, event_id=cls.event_id, - name="Session du Samedi Soir", + name="TicketingSession du Samedi Soir", quota=300, user_quota=2, used_quota=0, @@ -72,7 +72,7 @@ async def add_sessions(cls, db: AsyncSession) -> None: schemas_ticketing.SessionSimple( id=cls.session2_id, event_id=cls.event_id, - name="Session du Dimanche Après-midi", + name="TicketingSession du Dimanche Après-midi", quota=200, user_quota=2, used_quota=0, diff --git a/app/modules/ticketing/models_ticketing.py b/app/modules/ticketing/models_ticketing.py index 886b049bd0..65471a883a 100644 --- a/app/modules/ticketing/models_ticketing.py +++ b/app/modules/ticketing/models_ticketing.py @@ -24,7 +24,7 @@ class CategorySessionAssociation(Base): ) -class Event(Base): +class TicketingEvent(Base): __tablename__ = "ticketing_event" id: Mapped[PrimaryKey] @@ -42,24 +42,28 @@ class Event(Base): user_quota: Mapped[int | None] disabled: Mapped[bool] - sessions: Mapped[list["Session"]] = relationship( + sessions: Mapped[ + list["TicketingSession"] + ] = relationship( back_populates="event", init=False, lazy="selectin", ) - categories: Mapped[list["Category"]] = relationship( + categories: Mapped[ + list["TicketingCategory"] + ] = relationship( back_populates="event", init=False, lazy="selectin", ) -class Session(Base): +class TicketingSession(Base): __tablename__ = "ticketing_session" id: Mapped[PrimaryKey] event_id: Mapped[UUID] = mapped_column(ForeignKey("ticketing_event.id")) - event: Mapped[Event] = relationship( + event: Mapped[TicketingEvent] = relationship( back_populates="sessions", init=False, lazy="selectin", @@ -70,7 +74,9 @@ class Session(Base): user_quota: Mapped[int | None] disabled: Mapped[bool] - categories: Mapped[list["Category"]] = relationship( + categories: Mapped[ + list["TicketingCategory"] + ] = relationship( secondary=CategorySessionAssociation.__table__, back_populates="sessions", init=False, @@ -79,18 +85,20 @@ class Session(Base): ) -class Category(Base): +class TicketingCategory(Base): __tablename__ = "ticketing_category" id: Mapped[PrimaryKey] event_id: Mapped[UUID] = mapped_column(ForeignKey("ticketing_event.id")) - event: Mapped[Event] = relationship( + event: Mapped[TicketingEvent] = relationship( back_populates="categories", init=False, lazy="selectin", ) name: Mapped[str] - sessions: Mapped[list["Session"]] = relationship( + sessions: Mapped[ + list["TicketingSession"] + ] = relationship( secondary=CategorySessionAssociation.__table__, back_populates="categories", init=False, @@ -107,23 +115,23 @@ class Category(Base): disabled: Mapped[bool] -class Ticket(Base): +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[Event] = relationship( + event: Mapped[TicketingEvent] = relationship( init=False, lazy="selectin", ) category_id: Mapped[UUID] = mapped_column(ForeignKey("ticketing_category.id")) - category: Mapped[Category] = relationship( + category: Mapped[TicketingCategory] = relationship( init=False, lazy="selectin", ) session_id: Mapped[UUID | None] = mapped_column(ForeignKey("ticketing_session.id")) - session: Mapped[Session | None] = relationship( + session: Mapped[TicketingSession | None] = relationship( init=False, lazy="selectin", ) diff --git a/tests/modules/test_ticketing.py b/tests/modules/test_ticketing.py index a81cf49ed8..5e14cd3366 100644 --- a/tests/modules/test_ticketing.py +++ b/tests/modules/test_ticketing.py @@ -38,9 +38,9 @@ student_user: models_users.CoreUser -event1: models_ticketing.Event -event2: models_ticketing.Event -event_fake: models_ticketing.Event +event1: models_ticketing.TicketingEvent +event2: models_ticketing.TicketingEvent +event_fake: models_ticketing.TicketingEvent student_token: str admin_token: str @@ -123,7 +123,7 @@ async def init_objects(): ) await add_object_to_db(event1) # Event will be used to test disabled state etc. - event2 = models_ticketing.Event( + event2 = models_ticketing.TicketingEvent( id=uuid4(), name="Event 2", open_date=datetime(2024, 1, 1, tzinfo=UTC), @@ -137,7 +137,7 @@ async def init_objects(): ) await add_object_to_db(event2) - event_fake = models_ticketing.Event( + event_fake = models_ticketing.TicketingEvent( id=uuid4(), name="Event Fake", open_date=datetime(2024, 1, 1, tzinfo=UTC), @@ -152,7 +152,7 @@ async def init_objects(): # Do not add event_fake to the database # Create sessions and categories for event1 - session1 = models_ticketing.Session( + session1 = models_ticketing.TicketingSession( id=uuid4(), event_id=event1.id, name="Session 1", @@ -172,7 +172,7 @@ async def init_objects(): disabled=False, ) await add_object_to_db(session2) - session3 = models_ticketing.Session( + session3 = models_ticketing.TicketingSession( id=uuid4(), event_id=event1.id, name="Session 3", @@ -183,7 +183,7 @@ async def init_objects(): ) await add_object_to_db(session3) - category1 = models_ticketing.Category( + category1 = models_ticketing.TicketingCategory( id=uuid4(), event_id=event1.id, name="Category 1", From cd4428018a9a92050249e240aac05a699fd75da0 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Wed, 18 Feb 2026 18:10:33 +0100 Subject: [PATCH 19/50] Redone migrations --- migrations/versions/56-ticketing.py | 120 ++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 migrations/versions/56-ticketing.py diff --git a/migrations/versions/56-ticketing.py b/migrations/versions/56-ticketing.py new file mode 100644 index 0000000000..f84c0ea5e5 --- /dev/null +++ b/migrations/versions/56-ticketing.py @@ -0,0 +1,120 @@ +"""56-ticketing + +Create Date: 2026-02-18 18:06:38.299343 +""" + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pytest_alembic import MigrationContext + +import sqlalchemy as sa +from alembic import op + +from app.types.sqlalchemy import TZDateTime + +# revision identifiers, used by Alembic. +revision: str = "36b295e97d88" +down_revision: str | None = "562adbd796ae" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "ticketing_event", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("store_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(["store_id"], ["mypayment_store.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("quota", sa.Integer(), nullable=True), + sa.Column("used_quota", sa.Integer(), nullable=False), + sa.Column("user_quota", sa.Integer(), nullable=True), + sa.Column("disabled", sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(["event_id"], ["ticketing_event.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "ticketing_category_session", + sa.Column("category_id", sa.Uuid(), nullable=False), + sa.Column("session_id", sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint(["category_id"], ["ticketing_category.id"]), + sa.ForeignKeyConstraint(["session_id"], ["ticketing_session.id"]), + sa.PrimaryKeyConstraint("category_id", "session_id"), + ) + op.create_table( + "ticketing_ticket", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("user_id", sa.String(), nullable=False), + sa.Column("event_id", sa.Uuid(), nullable=False), + sa.Column("category_id", sa.Uuid(), nullable=False), + sa.Column("session_id", sa.Uuid(), nullable=True), + sa.Column("total", sa.Integer(), nullable=False), + sa.Column("created_at", TZDateTime(), nullable=False), + sa.Column("status", sa.String(), nullable=False), + sa.Column("nb_scan", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["category_id"], ["ticketing_category.id"]), + sa.ForeignKeyConstraint(["event_id"], ["ticketing_event.id"]), + sa.ForeignKeyConstraint(["session_id"], ["ticketing_session.id"]), + sa.ForeignKeyConstraint(["user_id"], ["core_user.id"]), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("ticketing_ticket") + op.drop_table("ticketing_category_session") + op.drop_table("ticketing_session") + op.drop_table("ticketing_category") + op.drop_table("ticketing_event") + # ### 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 From 9139cfc0de71751dad827cb4e08d44702c22f4bf Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Wed, 18 Feb 2026 18:42:46 +0100 Subject: [PATCH 20/50] Fix factory test --- app/modules/ticketing/cruds_ticketing.py | 22 ++++++++++++++++++++-- tests/modules/test_ticketing.py | 4 ++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/app/modules/ticketing/cruds_ticketing.py b/app/modules/ticketing/cruds_ticketing.py index a91002ab97..a11c810dd0 100644 --- a/app/modules/ticketing/cruds_ticketing.py +++ b/app/modules/ticketing/cruds_ticketing.py @@ -81,11 +81,29 @@ async def get_event_by_id( creation=event.store.creation, ), sessions=[ - schemas_ticketing.SessionSimple.model_validate(session) + schemas_ticketing.SessionSimple( + id=session.id, + event_id=session.event_id, + name=session.name, + quota=session.quota, + user_quota=session.user_quota, + used_quota=session.used_quota, + disabled=session.disabled, + ) for session in event.sessions ], categories=[ - schemas_ticketing.CategorySimple.model_validate(category) + 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, + used_quota=category.used_quota, + price=category.price, + disabled=category.disabled, + ) for category in event.categories ], ) diff --git a/tests/modules/test_ticketing.py b/tests/modules/test_ticketing.py index 5e14cd3366..55af446cd4 100644 --- a/tests/modules/test_ticketing.py +++ b/tests/modules/test_ticketing.py @@ -108,7 +108,7 @@ async def init_objects(): # Create events global event1, event2, event_fake - event1 = models_ticketing.Event( + event1 = models_ticketing.TicketingEvent( id=uuid4(), name="Event 1", open_date=datetime(2024, 1, 1, tzinfo=UTC), @@ -162,7 +162,7 @@ async def init_objects(): disabled=False, ) await add_object_to_db(session1) - session2 = models_ticketing.Session( + session2 = models_ticketing.TicketingSession( id=uuid4(), event_id=event1.id, name="Session 2", From 16f0d854c78e7553cf4367c4928593df9fa8bb08 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Wed, 18 Feb 2026 19:00:00 +0100 Subject: [PATCH 21/50] Forgot to return a 404 --- app/modules/ticketing/endpoints_ticketing.py | 7 +++++-- tests/modules/test_ticketing.py | 19 ++----------------- 2 files changed, 7 insertions(+), 19 deletions(-) diff --git a/app/modules/ticketing/endpoints_ticketing.py b/app/modules/ticketing/endpoints_ticketing.py index d3357835b7..b84cc0bdaa 100644 --- a/app/modules/ticketing/endpoints_ticketing.py +++ b/app/modules/ticketing/endpoints_ticketing.py @@ -49,9 +49,12 @@ async def get_events( async def get_event_by_id( event_id: UUID, db: AsyncSession = Depends(get_db), -) -> schemas_ticketing.EventComplete | None: +) -> schemas_ticketing.EventComplete: """Get an event by its ID.""" - return await cruds_ticketing.get_event_by_id(event_id=event_id, db=db) + 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( diff --git a/tests/modules/test_ticketing.py b/tests/modules/test_ticketing.py index 55af446cd4..088a07f85a 100644 --- a/tests/modules/test_ticketing.py +++ b/tests/modules/test_ticketing.py @@ -40,7 +40,6 @@ event1: models_ticketing.TicketingEvent event2: models_ticketing.TicketingEvent -event_fake: models_ticketing.TicketingEvent student_token: str admin_token: str @@ -107,7 +106,7 @@ async def init_objects(): await add_object_to_db(store) # Create events - global event1, event2, event_fake + global event1, event2 event1 = models_ticketing.TicketingEvent( id=uuid4(), name="Event 1", @@ -137,20 +136,6 @@ async def init_objects(): ) await add_object_to_db(event2) - event_fake = models_ticketing.TicketingEvent( - id=uuid4(), - name="Event Fake", - 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), - store_id=store.id, - ) - # Do not add event_fake to the database - # Create sessions and categories for event1 session1 = models_ticketing.TicketingSession( id=uuid4(), @@ -222,7 +207,7 @@ def test_get_event(client: TestClient): # Test with event_fake (not in DB, should return 404) response = client.get( - f"/ticketing/events/{event_fake.id}", + f"/ticketing/events/{uuid4()}", headers={"Authorization": f"Bearer {student_token}"}, ) assert response.status_code == 404 From d8def2cccc57c5c1802eb44290c31f146e24b530 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Sat, 28 Feb 2026 11:04:27 +0100 Subject: [PATCH 22/50] Tests for events --- app/modules/ticketing/endpoints_ticketing.py | 5 + tests/modules/test_ticketing.py | 192 ++++++++++++++++++- 2 files changed, 195 insertions(+), 2 deletions(-) diff --git a/app/modules/ticketing/endpoints_ticketing.py b/app/modules/ticketing/endpoints_ticketing.py index b84cc0bdaa..d828664412 100644 --- a/app/modules/ticketing/endpoints_ticketing.py +++ b/app/modules/ticketing/endpoints_ticketing.py @@ -105,6 +105,11 @@ async def update_event( stored = await cruds_ticketing.get_event_by_id(event_id=event_id, db=db) if stored is None: raise HTTPException(status_code=404, detail="Event not found") + if event_update.quota is not None and stored.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, diff --git a/tests/modules/test_ticketing.py b/tests/modules/test_ticketing.py index 088a07f85a..512da84c17 100644 --- a/tests/modules/test_ticketing.py +++ b/tests/modules/test_ticketing.py @@ -10,7 +10,7 @@ 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 +from app.modules.ticketing import models_ticketing, schemas_ticketing # We need to import event_loop for pytest-asyncio routine defined bellow from app.modules.ticketing.endpoints_ticketing import TicketingPermissions @@ -189,14 +189,34 @@ async def init_objects(): ) student_token = create_api_access_token(student_user) +# Units tests for basic CRUD operations on events, sessions and categories. -def test_get_event(client: TestClient): +# -------------------------- 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 + # Verify that an event is an instance of EventSimple + for event in events: + assert isinstance(event, schemas_ticketing.EventSimple) + + +# 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 + assert isinstance(response.json(), schemas_ticketing.EventComplete) # Test with event2 (should succeed) response = client.get( @@ -211,3 +231,171 @@ def test_get_event(client: TestClient): 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, + "store_id": str(store.id), + } + response = client.post( + "/ticketing/events", + json=new_event_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 201 + created_event = response.json() + assert created_event == new_event_data + + +# create event without perms +async def test_create_event_without_perms(client: TestClient): + new_event_data = { + "name": "New Event", + "open_date": "2024-01-01T00:00:00Z", + "close_date": "2200-12-31T23:59:59Z", + "quota": 10, + "user_quota": 2, + "store_id": str(store.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_store(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, + "store_id": str(uuid4()), # Invalid store ID + } + response = client.post( + "/ticketing/events", + json=new_event_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 400 + + +# update event +async def test_update_event_as_admin(client: TestClient): + update_data = { + "name": "Updated Event Name", + "quota": 20, + } + response = client.patch( + f"/ticketing/events/{event1.id}", + json=update_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 200 + updated_event = response.json() + assert updated_event["name"] == update_data["name"] + assert updated_event["quota"] == update_data["quota"] + + +async def test_update_event_as_lambda(client: TestClient): + update_data = { + "name": "Updated Event Name", + "quota": 20, + } + response = client.patch( + f"/ticketing/events/{event1.id}", + json=update_data, + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 403 + + +async def test_update_event_with_invalid_id(client: TestClient): + update_data = { + "name": "Updated Event Name", + "quota": 20, + } + response = client.patch( + f"/ticketing/events/{uuid4()}", + json=update_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 404 + + +# fail to update event with quota less than used_quota +async def test_update_event_with_quota_less_than_used_quota(client: TestClient): + update_data = { + "quota": 0, # event1 has used_quota=1, so this should fail + } + response = client.patch( + f"/ticketing/events/{event1.id}", + json=update_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 400 + + +# test delete event as lambda, should fail +async def test_delete_event_as_lambda(client: TestClient): + response = client.delete( + f"/ticketing/events/{event1.id}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 403 + +# test delete event as admin, should succeed +async def test_delete_event_as_admin(client: TestClient): + # First create a new event to delete + to_delete_event = models_ticketing.TicketingEvent( + id=uuid4(), + name="To Delete Event", + open_date=datetime(2024, 1, 1, tzinfo=UTC), + close_date=datetime(2200, 12, 31, tzinfo=UTC), + quota=5, + user_quota=2, + used_quota=0, + disabled=False, + creator_id=str(admin_user.id), + store_id=store.id, + ) + await add_object_to_db(to_delete_event) + response = client.delete( + f"/ticketing/events/{to_delete_event.id}", + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 204 + # Verify that the event is actually deleted + response = client.get( + f"/ticketing/events/{to_delete_event.id}", + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 404 + +# test delete event as admin with tickets, should fail +async def test_deleted_as_admin_with_tickets(client: TestClient): + # Create a ticket for the event + ticket = models_ticketing.Ticket( + id=uuid4(), + event_id=event1.id, + user_id=student_user.id, + status="active", + ) + await add_object_to_db(ticket) + # Try to delete the event with existing tickets + response = client.delete( + f"/ticketing/events/{event1.id}", + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 400 + + +# -------------------------- Test session basic cruds -------------------------- # From 4de2f76d0b65ca3f5374f9cd7e0e699ebcf8eb21 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Sat, 28 Feb 2026 11:04:41 +0100 Subject: [PATCH 23/50] Lint --- tests/modules/test_ticketing.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/modules/test_ticketing.py b/tests/modules/test_ticketing.py index 512da84c17..0d25f35c14 100644 --- a/tests/modules/test_ticketing.py +++ b/tests/modules/test_ticketing.py @@ -189,10 +189,12 @@ async def init_objects(): ) student_token = create_api_access_token(student_user) + # Units tests for basic CRUD operations on events, sessions and categories. # -------------------------- Test event basic cruds -------------------------- # + # Get all events async def test_get_events_list(client: TestClient): response = client.get( @@ -352,6 +354,7 @@ async def test_delete_event_as_lambda(client: TestClient): ) 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 @@ -380,6 +383,7 @@ async def test_delete_event_as_admin(client: TestClient): ) 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 From 265cd01a3e6189211b8cf6a594cabd6a8e306503 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Sat, 28 Feb 2026 11:55:16 +0100 Subject: [PATCH 24/50] Fixed tests (locally) --- app/modules/ticketing/endpoints_ticketing.py | 12 ++++---- tests/modules/test_ticketing.py | 29 ++++++++++++-------- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/app/modules/ticketing/endpoints_ticketing.py b/app/modules/ticketing/endpoints_ticketing.py index d828664412..2b68954c29 100644 --- a/app/modules/ticketing/endpoints_ticketing.py +++ b/app/modules/ticketing/endpoints_ticketing.py @@ -1,4 +1,4 @@ -from uuid import UUID +from uuid import UUID, uuid4 from fastapi import APIRouter, Depends, HTTPException from redis import Redis @@ -76,8 +76,10 @@ async def create_event( raise HTTPException(status_code=400, detail="Event already exists") event = schemas_ticketing.EventSimple( **event.model_dump(), - id=UUID(), + id=uuid4(), creator_id=user.id, + used_quota=0, + 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) @@ -175,7 +177,7 @@ async def create_session( """Create a new session.""" session_simple = schemas_ticketing.SessionSimple( **session.model_dump(), - id=UUID(), + id=uuid4(), used_quota=0, disabled=False, ) @@ -273,7 +275,7 @@ async def create_category( """Create a new category.""" category_complete = schemas_ticketing.CategoryComplete( **category.model_dump(), - id=UUID(), + id=uuid4(), used_quota=0, disabled=False, ) @@ -404,7 +406,7 @@ async def create_ticket( """Create a new ticket.""" ticket_simple = schemas_ticketing.TicketSimple( **ticket.model_dump(), - id=UUID(), + id=uuid4(), user_id=user.id, status="pending", nb_scan=0, diff --git a/tests/modules/test_ticketing.py b/tests/modules/test_ticketing.py index 0d25f35c14..83f9807c1b 100644 --- a/tests/modules/test_ticketing.py +++ b/tests/modules/test_ticketing.py @@ -10,7 +10,7 @@ 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 +from app.modules.ticketing import models_ticketing # We need to import event_loop for pytest-asyncio routine defined bellow from app.modules.ticketing.endpoints_ticketing import TicketingPermissions @@ -41,6 +41,12 @@ event1: models_ticketing.TicketingEvent event2: models_ticketing.TicketingEvent +session1: models_ticketing.TicketingSession +session2: models_ticketing.TicketingSession + +category1: models_ticketing.TicketingCategory + + student_token: str admin_token: str @@ -136,6 +142,7 @@ async def init_objects(): ) await add_object_to_db(event2) + global session1, session2, session3 # Create sessions and categories for event1 session1 = models_ticketing.TicketingSession( id=uuid4(), @@ -168,6 +175,7 @@ async def init_objects(): ) await add_object_to_db(session3) + global category1 category1 = models_ticketing.TicketingCategory( id=uuid4(), event_id=event1.id, @@ -205,9 +213,6 @@ async def test_get_events_list(client: TestClient): events = response.json() assert isinstance(events, list) assert len(events) >= 2 # We created 2 events in the fixture - # Verify that an event is an instance of EventSimple - for event in events: - assert isinstance(event, schemas_ticketing.EventSimple) # get event by id @@ -218,7 +223,6 @@ async def test_get_event(client: TestClient): headers={"Authorization": f"Bearer {student_token}"}, ) assert response.status_code == 200 - assert isinstance(response.json(), schemas_ticketing.EventComplete) # Test with event2 (should succeed) response = client.get( @@ -252,7 +256,8 @@ async def test_create_event(client: TestClient): ) assert response.status_code == 201 created_event = response.json() - assert created_event == new_event_data + assert created_event["name"] == new_event_data["name"] + assert created_event["open_date"] == new_event_data["open_date"] # create event without perms @@ -301,10 +306,7 @@ async def test_update_event_as_admin(client: TestClient): json=update_data, headers={"Authorization": f"Bearer {admin_user_token}"}, ) - assert response.status_code == 200 - updated_event = response.json() - assert updated_event["name"] == update_data["name"] - assert updated_event["quota"] == update_data["quota"] + assert response.status_code == 204 async def test_update_event_as_lambda(client: TestClient): @@ -387,11 +389,16 @@ async def test_delete_event_as_admin(client: TestClient): # 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.Ticket( + ticket = models_ticketing.TicketingTicket( id=uuid4(), event_id=event1.id, + session_id=session1.id, + category_id=category1.id, user_id=student_user.id, status="active", + nb_scan=0, + total=1, + created_at=datetime.now(UTC), ) await add_object_to_db(ticket) # Try to delete the event with existing tickets From 093a2e8dfff5a26167ccae1e6eeb50405f5ad73c Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Sat, 28 Feb 2026 12:14:21 +0100 Subject: [PATCH 25/50] Rebase migration --- migrations/versions/{56-ticketing.py => 57-ticketing.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename migrations/versions/{56-ticketing.py => 57-ticketing.py} (99%) diff --git a/migrations/versions/56-ticketing.py b/migrations/versions/57-ticketing.py similarity index 99% rename from migrations/versions/56-ticketing.py rename to migrations/versions/57-ticketing.py index f84c0ea5e5..f475bbb0d2 100644 --- a/migrations/versions/56-ticketing.py +++ b/migrations/versions/57-ticketing.py @@ -16,7 +16,7 @@ # revision identifiers, used by Alembic. revision: str = "36b295e97d88" -down_revision: str | None = "562adbd796ae" +down_revision: str | None = "a1b2c3d4e5f6" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None From 8a77ad9a42cca9c1e83ee6978082382c00561b71 Mon Sep 17 00:00:00 2001 From: Rayan Date: Tue, 3 Mar 2026 21:07:43 +0100 Subject: [PATCH 26/50] add organisers --- app/modules/ticketing/cruds_ticketing.py | 106 +++++++++++++++---- app/modules/ticketing/endpoints_ticketing.py | 60 ++++++++++- app/modules/ticketing/factory_ticketing.py | 20 +++- app/modules/ticketing/models_ticketing.py | 33 +++--- app/modules/ticketing/schemas_ticketing.py | 18 +++- 5 files changed, 194 insertions(+), 43 deletions(-) diff --git a/app/modules/ticketing/cruds_ticketing.py b/app/modules/ticketing/cruds_ticketing.py index a11c810dd0..d3f77f640f 100644 --- a/app/modules/ticketing/cruds_ticketing.py +++ b/app/modules/ticketing/cruds_ticketing.py @@ -4,10 +4,81 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.core.mypayment import schemas_mypayment from app.modules.ticketing import models_ticketing, schemas_ticketing +async def get_organisers( + db: AsyncSession, +) -> list[schemas_ticketing.OrganiserComplete]: + """Get all organisers.""" + + organisers = await db.execute(select(models_ticketing.Organiser)) + return [ + schemas_ticketing.OrganiserComplete( + id=organiser.id, + name=organiser.name, + store_id=organiser.store_id, + ) + for organiser in organisers.scalars().all() + ] + + +async def get_organiser_by_id( + db: AsyncSession, + organiser_id: UUID, +) -> schemas_ticketing.OrganiserComplete | None: + """Get an organiser by its ID.""" + + organiser = ( + ( + await db.execute( + select(models_ticketing.Organiser).where( + models_ticketing.Organiser.id == organiser_id, + ), + ) + ) + .scalars() + .first() + ) + + return ( + schemas_ticketing.OrganiserComplete( + id=organiser.id, + name=organiser.name, + store_id=organiser.store_id, + ) + if organiser + else None + ) + + +async def create_organiser( + db: AsyncSession, + organiser: schemas_ticketing.OrganiserBase, +) -> None: + """Create a new organiser.""" + + db.add( + models_ticketing.Organiser(**organiser.model_dump()), + ) + await db.flush() + + +async def update_organiser( + db: AsyncSession, + organiser_id: UUID, + organiser_update: schemas_ticketing.OrganiserEdit, +) -> None: + """Update an existing organiser.""" + + await db.execute( + update(models_ticketing.Organiser) + .where(models_ticketing.Organiser.id == organiser_id) + .values(**organiser_update.model_dump(exclude_unset=True)), + ) + await db.flush() + + async def get_events( db: AsyncSession, ) -> list[schemas_ticketing.EventSimple]: @@ -17,7 +88,7 @@ async def get_events( return [ schemas_ticketing.EventSimple( id=event.id, - store_id=event.store_id, + organiser_id=event.organiser_id, creator_id=event.creator_id, name=event.name, open_date=event.open_date, @@ -26,13 +97,6 @@ async def get_events( user_quota=event.user_quota, used_quota=event.used_quota, disabled=event.disabled, - store=schemas_mypayment.StoreSimple( - id=event.store.id, - structure_id=event.store.structure_id, - wallet_id=event.store.wallet_id, - name=event.store.name, - creation=event.store.creation, - ), ) for event in events.scalars().all() ] @@ -64,7 +128,7 @@ async def get_event_by_id( return ( schemas_ticketing.EventComplete( id=event.id, - store_id=event.store_id, + organiser_id=event.organiser_id, creator_id=event.creator_id, name=event.name, open_date=event.open_date, @@ -73,12 +137,10 @@ async def get_event_by_id( user_quota=event.user_quota, used_quota=event.used_quota, disabled=event.disabled, - store=schemas_mypayment.StoreSimple( - id=event.store.id, - structure_id=event.store.structure_id, - wallet_id=event.store.wallet_id, - name=event.store.name, - creation=event.store.creation, + organiser=schemas_ticketing.OrganiserComplete( + id=event.organiser.id, + name=event.organiser.name, + store_id=event.organiser.store_id, ), sessions=[ schemas_ticketing.SessionSimple( @@ -137,7 +199,7 @@ async def get_event_by_name( return ( schemas_ticketing.EventComplete( id=event.id, - store_id=event.store_id, + organiser_id=event.organiser_id, creator_id=event.creator_id, name=event.name, open_date=event.open_date, @@ -146,12 +208,10 @@ async def get_event_by_name( user_quota=event.user_quota, used_quota=event.used_quota, disabled=event.disabled, - store=schemas_mypayment.StoreSimple( - id=event.store.id, - structure_id=event.store.structure_id, - wallet_id=event.store.wallet_id, - name=event.store.name, - creation=event.store.creation, + organiser=schemas_ticketing.OrganiserComplete( + id=event.organiser.id, + name=event.organiser.name, + store_id=event.organiser.store_id, ), sessions=[ schemas_ticketing.SessionSimple.model_validate(session) diff --git a/app/modules/ticketing/endpoints_ticketing.py b/app/modules/ticketing/endpoints_ticketing.py index 2b68954c29..1d252a35f6 100644 --- a/app/modules/ticketing/endpoints_ticketing.py +++ b/app/modules/ticketing/endpoints_ticketing.py @@ -13,7 +13,7 @@ class TicketingPermissions(ModulePermissions): - acess_ticketing = "acess_ticketing" + access_ticketing = "access_ticketing" manage_events = "manage_events" @@ -27,6 +27,63 @@ class TicketingPermissions(ModulePermissions): ) +@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: str, + 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. + """ + return await cruds_ticketing.get_organiser_by_id(db=db, organiser_id=organiser_id) + + +# @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", @@ -511,6 +568,7 @@ async def update_ticket( 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( diff --git a/app/modules/ticketing/factory_ticketing.py b/app/modules/ticketing/factory_ticketing.py index 0a88ef5aa1..88493c3573 100644 --- a/app/modules/ticketing/factory_ticketing.py +++ b/app/modules/ticketing/factory_ticketing.py @@ -1,9 +1,10 @@ import random from datetime import UTC, datetime, timedelta -from uuid import uuid4 +from uuid import UUID, uuid4 from sqlalchemy.ext.asyncio import AsyncSession +from app.core.groups.factory_groups import CoreGroupsFactory from app.core.memberships.factory_memberships import CoreMembershipsFactory from app.core.mypayment.factory_mypayment import MyPaymentFactory from app.core.users.factory_users import CoreUsersFactory @@ -17,8 +18,10 @@ class TicketingFactory(Factory): CoreUsersFactory, MyPaymentFactory, CoreMembershipsFactory, + CoreGroupsFactory, ] + organiser_id = uuid4() event_id = uuid4() session1_id = uuid4() session2_id = uuid4() @@ -28,11 +31,24 @@ class TicketingFactory(Factory): @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.""" @@ -40,7 +56,7 @@ async def add_event(cls, db: AsyncSession) -> None: db, schemas_ticketing.EventSimple( id=cls.event_id, - store_id=MyPaymentFactory.other_stores_id[0][0], + organiser_id=cls.organiser_id, creator_id=CoreUsersFactory.other_users_id[0], name="Commuz 2025", open_date=datetime.now(UTC), diff --git a/app/modules/ticketing/models_ticketing.py b/app/modules/ticketing/models_ticketing.py index 65471a883a..9956638162 100644 --- a/app/modules/ticketing/models_ticketing.py +++ b/app/modules/ticketing/models_ticketing.py @@ -11,6 +11,18 @@ 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" @@ -28,11 +40,12 @@ class TicketingEvent(Base): __tablename__ = "ticketing_event" id: Mapped[PrimaryKey] - store_id: Mapped[UUID] = mapped_column(ForeignKey("mypayment_store.id")) - store: Mapped["Store"] = relationship( + 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] @@ -42,16 +55,12 @@ class TicketingEvent(Base): user_quota: Mapped[int | None] disabled: Mapped[bool] - sessions: Mapped[ - list["TicketingSession"] - ] = relationship( + sessions: Mapped[list["TicketingSession"]] = relationship( back_populates="event", init=False, lazy="selectin", ) - categories: Mapped[ - list["TicketingCategory"] - ] = relationship( + categories: Mapped[list["TicketingCategory"]] = relationship( back_populates="event", init=False, lazy="selectin", @@ -74,9 +83,7 @@ class TicketingSession(Base): user_quota: Mapped[int | None] disabled: Mapped[bool] - categories: Mapped[ - list["TicketingCategory"] - ] = relationship( + categories: Mapped[list["TicketingCategory"]] = relationship( secondary=CategorySessionAssociation.__table__, back_populates="sessions", init=False, @@ -96,9 +103,7 @@ class TicketingCategory(Base): lazy="selectin", ) name: Mapped[str] - sessions: Mapped[ - list["TicketingSession"] - ] = relationship( + sessions: Mapped[list["TicketingSession"]] = relationship( secondary=CategorySessionAssociation.__table__, back_populates="categories", init=False, diff --git a/app/modules/ticketing/schemas_ticketing.py b/app/modules/ticketing/schemas_ticketing.py index 60f7c4a3d1..ea8d601615 100644 --- a/app/modules/ticketing/schemas_ticketing.py +++ b/app/modules/ticketing/schemas_ticketing.py @@ -3,11 +3,23 @@ from pydantic import BaseModel -from app.core.mypayment.schemas_mypayment import StoreSimple +class OrganiserBase(BaseModel): + name: str -class EventBase(BaseModel): + +class OrganiserComplete(OrganiserBase): + id: UUID store_id: UUID + + +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 @@ -23,7 +35,7 @@ class EventSimple(EventBase): class EventComplete(EventSimple): - store: StoreSimple + organiser: "OrganiserComplete" sessions: list["SessionSimple"] categories: list["CategorySimple"] From ef5ec3f62bf30784f354ce502791ed55a2dcd3dd Mon Sep 17 00:00:00 2001 From: Rayan Date: Tue, 10 Mar 2026 11:20:35 +0100 Subject: [PATCH 27/50] add: date to sessions --- app/modules/ticketing/cruds_ticketing.py | 2 ++ app/modules/ticketing/endpoints_ticketing.py | 1 + app/modules/ticketing/factory_ticketing.py | 4 ++-- app/modules/ticketing/models_ticketing.py | 1 + app/modules/ticketing/schemas_ticketing.py | 1 + 5 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/modules/ticketing/cruds_ticketing.py b/app/modules/ticketing/cruds_ticketing.py index d3f77f640f..b25e6e9c16 100644 --- a/app/modules/ticketing/cruds_ticketing.py +++ b/app/modules/ticketing/cruds_ticketing.py @@ -146,6 +146,7 @@ async def get_event_by_id( 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, @@ -310,6 +311,7 @@ async def get_session_by_id( schemas_ticketing.SessionComplete( id=session.id, name=session.name, + date=session.date, quota=session.quota, user_quota=session.user_quota, used_quota=session.used_quota, diff --git a/app/modules/ticketing/endpoints_ticketing.py b/app/modules/ticketing/endpoints_ticketing.py index 1d252a35f6..47cdd278ad 100644 --- a/app/modules/ticketing/endpoints_ticketing.py +++ b/app/modules/ticketing/endpoints_ticketing.py @@ -1,3 +1,4 @@ +import datetime from uuid import UUID, uuid4 from fastapi import APIRouter, Depends, HTTPException diff --git a/app/modules/ticketing/factory_ticketing.py b/app/modules/ticketing/factory_ticketing.py index 88493c3573..44f5977404 100644 --- a/app/modules/ticketing/factory_ticketing.py +++ b/app/modules/ticketing/factory_ticketing.py @@ -4,7 +4,6 @@ from sqlalchemy.ext.asyncio import AsyncSession -from app.core.groups.factory_groups import CoreGroupsFactory from app.core.memberships.factory_memberships import CoreMembershipsFactory from app.core.mypayment.factory_mypayment import MyPaymentFactory from app.core.users.factory_users import CoreUsersFactory @@ -18,7 +17,6 @@ class TicketingFactory(Factory): CoreUsersFactory, MyPaymentFactory, CoreMembershipsFactory, - CoreGroupsFactory, ] organiser_id = uuid4() @@ -76,6 +74,7 @@ async def add_sessions(cls, db: AsyncSession) -> None: 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, @@ -88,6 +87,7 @@ async def add_sessions(cls, db: AsyncSession) -> None: 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, diff --git a/app/modules/ticketing/models_ticketing.py b/app/modules/ticketing/models_ticketing.py index 9956638162..dba6d56ac3 100644 --- a/app/modules/ticketing/models_ticketing.py +++ b/app/modules/ticketing/models_ticketing.py @@ -78,6 +78,7 @@ class TicketingSession(Base): lazy="selectin", ) name: Mapped[str] + date: Mapped[datetime] quota: Mapped[int | None] used_quota: Mapped[int] user_quota: Mapped[int | None] diff --git a/app/modules/ticketing/schemas_ticketing.py b/app/modules/ticketing/schemas_ticketing.py index ea8d601615..ef54104f12 100644 --- a/app/modules/ticketing/schemas_ticketing.py +++ b/app/modules/ticketing/schemas_ticketing.py @@ -51,6 +51,7 @@ class EventUpdate(BaseModel): class SessionBase(BaseModel): event_id: UUID + date: datetime name: str quota: int | None = None user_quota: int | None = None From 4710cfed8ff8ed32646ee4fffd454e6f3b0109d1 Mon Sep 17 00:00:00 2001 From: Rayan Date: Fri, 13 Mar 2026 10:17:34 +0100 Subject: [PATCH 28/50] wip --- app/modules/ticketing/endpoints_ticketing.py | 9 ++++++++- app/modules/ticketing/schemas_ticketing.py | 20 +++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/app/modules/ticketing/endpoints_ticketing.py b/app/modules/ticketing/endpoints_ticketing.py index 47cdd278ad..e63957df4d 100644 --- a/app/modules/ticketing/endpoints_ticketing.py +++ b/app/modules/ticketing/endpoints_ticketing.py @@ -140,6 +140,7 @@ async def create_event( 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() @@ -211,9 +212,15 @@ async def delete_event( 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) + 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 diff --git a/app/modules/ticketing/schemas_ticketing.py b/app/modules/ticketing/schemas_ticketing.py index ef54104f12..8cbaa9b620 100644 --- a/app/modules/ticketing/schemas_ticketing.py +++ b/app/modules/ticketing/schemas_ticketing.py @@ -1,7 +1,7 @@ from datetime import datetime from uuid import UUID -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict class OrganiserBase(BaseModel): @@ -12,6 +12,8 @@ class OrganiserComplete(OrganiserBase): id: UUID store_id: UUID + model_config = ConfigDict(from_attributes=True) + class OrganiserEdit(BaseModel): name: str | None = None @@ -33,12 +35,16 @@ class EventSimple(EventBase): used_quota: int 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 @@ -62,10 +68,14 @@ class SessionSimple(SessionBase): used_quota: int 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 @@ -88,11 +98,15 @@ class CategorySimple(CategoryBase): used_quota: int disabled: bool + model_config = ConfigDict(from_attributes=True) + class CategoryComplete(CategorySimple): event: EventSimple sessions: list[UUID] | None = None + model_config = ConfigDict(from_attributes=True) + class CategoryUpdate(BaseModel): name: str | None = None @@ -118,8 +132,12 @@ class TicketSimple(TicketBase): status: str nb_scan: int + model_config = ConfigDict(from_attributes=True) + class TicketComplete(TicketSimple): event: EventComplete category: CategoryComplete session: SessionComplete + + model_config = ConfigDict(from_attributes=True) From 3bfe0b1f29a12c6231ab25c7f3dfa1cf9250e91d Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Thu, 19 Mar 2026 18:07:15 +0100 Subject: [PATCH 29/50] rebase migrations & fix tests --- .../{57-ticketing.py => 59-ticketing.py} | 26 ++++++++++++------ tests/modules/test_ticketing.py | 27 ++++++++++++++----- 2 files changed, 38 insertions(+), 15 deletions(-) rename migrations/versions/{57-ticketing.py => 59-ticketing.py} (88%) diff --git a/migrations/versions/57-ticketing.py b/migrations/versions/59-ticketing.py similarity index 88% rename from migrations/versions/57-ticketing.py rename to migrations/versions/59-ticketing.py index f475bbb0d2..cf759642e5 100644 --- a/migrations/versions/57-ticketing.py +++ b/migrations/versions/59-ticketing.py @@ -1,10 +1,10 @@ -"""56-ticketing +"""59-ticketing -Create Date: 2026-02-18 18:06:38.299343 +Create Date: 2026-03-19 17:38:31.400400 """ from collections.abc import Sequence -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Union if TYPE_CHECKING: from pytest_alembic import MigrationContext @@ -15,8 +15,8 @@ from app.types.sqlalchemy import TZDateTime # revision identifiers, used by Alembic. -revision: str = "36b295e97d88" -down_revision: str | None = "a1b2c3d4e5f6" +revision: str = "8ccdd1f377c9" +down_revision: str | None = "e58ffcd6b9eb" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None @@ -24,9 +24,17 @@ def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### op.create_table( - "ticketing_event", + "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), @@ -36,7 +44,7 @@ def upgrade() -> None: sa.Column("user_quota", sa.Integer(), nullable=True), sa.Column("disabled", sa.Boolean(), nullable=False), sa.ForeignKeyConstraint(["creator_id"], ["core_user.id"]), - sa.ForeignKeyConstraint(["store_id"], ["mypayment_store.id"]), + sa.ForeignKeyConstraint(["organiser_id"], ["ticketing_organiser.id"]), sa.PrimaryKeyConstraint("id"), ) op.create_table( @@ -52,7 +60,7 @@ def upgrade() -> None: sa.Column("disabled", sa.Boolean(), nullable=False), sa.ForeignKeyConstraint(["event_id"], ["ticketing_event.id"]), sa.ForeignKeyConstraint( - ["required_mebership"], ["core_association_membership.id"], + ["required_mebership"], ["core_association_membership.id"] ), sa.PrimaryKeyConstraint("id"), ) @@ -61,6 +69,7 @@ def upgrade() -> None: 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), @@ -103,6 +112,7 @@ def downgrade() -> None: op.drop_table("ticketing_session") op.drop_table("ticketing_category") op.drop_table("ticketing_event") + op.drop_table("ticketing_organiser") # ### end Alembic commands ### diff --git a/tests/modules/test_ticketing.py b/tests/modules/test_ticketing.py index 83f9807c1b..00df7c12b3 100644 --- a/tests/modules/test_ticketing.py +++ b/tests/modules/test_ticketing.py @@ -35,6 +35,7 @@ store_wallet: models_mypayment.Wallet store: models_mypayment.Store +organiser: models_ticketing.Organiser student_user: models_users.CoreUser @@ -111,6 +112,14 @@ async def init_objects(): ) await add_object_to_db(store) + global organiser + organiser = models_ticketing.Organiser( + id=uuid4(), + name="Test Organiser", + store_id=store.id, + ) + await add_object_to_db(organiser) + # Create events global event1, event2 event1 = models_ticketing.TicketingEvent( @@ -124,7 +133,7 @@ async def init_objects(): used_quota=1, disabled=False, creator_id=str(admin_user.id), - store_id=store.id, + organiser_id=organiser.id, ) await add_object_to_db(event1) # Event will be used to test disabled state etc. @@ -138,7 +147,7 @@ async def init_objects(): used_quota=1, disabled=False, creator_id=str(admin_user.id), - store_id=store.id, + organiser_id=organiser.id, ) await add_object_to_db(event2) @@ -152,6 +161,7 @@ async def init_objects(): user_quota=1, used_quota=1, disabled=False, + date=datetime(2024, 1, 1, tzinfo=UTC), ) await add_object_to_db(session1) session2 = models_ticketing.TicketingSession( @@ -162,7 +172,9 @@ async def init_objects(): user_quota=1, used_quota=0, disabled=False, + date=datetime(2024, 1, 2, tzinfo=UTC), ) + await add_object_to_db(session2) session3 = models_ticketing.TicketingSession( id=uuid4(), @@ -172,6 +184,7 @@ async def init_objects(): user_quota=1, used_quota=0, disabled=True, + date=datetime(2024, 2, 3, tzinfo=UTC), ) await add_object_to_db(session3) @@ -247,7 +260,7 @@ async def test_create_event(client: TestClient): "close_date": "2200-12-31T23:59:59Z", "quota": 10, "user_quota": 2, - "store_id": str(store.id), + "organiser_id": str(organiser.id), } response = client.post( "/ticketing/events", @@ -268,7 +281,7 @@ async def test_create_event_without_perms(client: TestClient): "close_date": "2200-12-31T23:59:59Z", "quota": 10, "user_quota": 2, - "store_id": str(store.id), + "organiser_id": str(organiser.id), } response = client.post( "/ticketing/events", @@ -278,14 +291,14 @@ async def test_create_event_without_perms(client: TestClient): assert response.status_code == 403 -async def test_create_event_with_invalid_store(client: TestClient): +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, - "store_id": str(uuid4()), # Invalid store ID + "organiser_id": str(uuid4()), # Invalid organiser ID } response = client.post( "/ticketing/events", @@ -370,7 +383,7 @@ async def test_delete_event_as_admin(client: TestClient): used_quota=0, disabled=False, creator_id=str(admin_user.id), - store_id=store.id, + organiser_id=organiser.id, ) await add_object_to_db(to_delete_event) response = client.delete( From 9b2704dfac224c77b15157158088d567f3f79ddc Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Thu, 19 Mar 2026 19:19:16 +0100 Subject: [PATCH 30/50] Finished tests for sessions --- app/modules/ticketing/cruds_ticketing.py | 78 ++++++ app/modules/ticketing/endpoints_ticketing.py | 62 ++++- app/modules/ticketing/schemas_ticketing.py | 21 +- tests/modules/test_ticketing.py | 268 ++++++++++++++++++- 4 files changed, 413 insertions(+), 16 deletions(-) diff --git a/app/modules/ticketing/cruds_ticketing.py b/app/modules/ticketing/cruds_ticketing.py index b25e6e9c16..884eea26c8 100644 --- a/app/modules/ticketing/cruds_ticketing.py +++ b/app/modules/ticketing/cruds_ticketing.py @@ -289,6 +289,24 @@ async def delete_event( 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, @@ -421,6 +439,36 @@ async def get_category_by_id( else None ) +async def get_categories_by_session_id( + session_id: UUID, + db: AsyncSession, +) -> list[schemas_ticketing.CategoryComplete]: + """Get all categories for a specific session.""" + + return [ + schemas_ticketing.CategoryComplete( + id=category.id, + event_id=category.event_id, + event=category.event, + name=category.name, + sessions=category.sessions, + required_mebership=category.required_mebership, + quota=category.quota, + user_quota=category.user_quota, + used_quota=category.used_quota, + price=category.price, + disabled=category.disabled, + ) + for category in ( + await db.execute( + select(models_ticketing.TicketingCategory) + .join(models_ticketing.CategorySessionAssociation) + .where( + models_ticketing.CategorySessionAssociation.session_id == session_id, + ), + ) + ).scalars().all() + ] async def create_category( db: AsyncSession, @@ -507,6 +555,36 @@ async def get_tickets( ] +async def get_tickets_by_session_id( + session_id: UUID, + db: AsyncSession, +) -> list[schemas_ticketing.TicketComplete]: + """Get all tickets for a specific session.""" + + tickets = await db.execute( + select(models_ticketing.TicketingTicket).where( + models_ticketing.TicketingTicket.session_id == session_id, + ), + ) + return [ + schemas_ticketing.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, + event=ticket.event, + category=ticket.category, + session=ticket.session, + status=ticket.status, + nb_scan=ticket.nb_scan, + ) + for ticket in tickets.scalars().all() + ] + + async def get_tickets_by_user_id( user_id: str, db: AsyncSession, diff --git a/app/modules/ticketing/endpoints_ticketing.py b/app/modules/ticketing/endpoints_ticketing.py index e63957df4d..400e2e278d 100644 --- a/app/modules/ticketing/endpoints_ticketing.py +++ b/app/modules/ticketing/endpoints_ticketing.py @@ -1,4 +1,3 @@ -import datetime from uuid import UUID, uuid4 from fastapi import APIRouter, Depends, HTTPException @@ -36,7 +35,7 @@ class TicketingPermissions(ModulePermissions): async def get_organisers( db: AsyncSession = Depends(get_db), user: models_users.CoreUser = Depends( - is_user_allowed_to([TicketingPermissions.access_ticketing]) + is_user_allowed_to([TicketingPermissions.access_ticketing]), ), ): """ @@ -54,7 +53,7 @@ async def get_organiser( organiser_id: str, db: AsyncSession = Depends(get_db), user: models_users.CoreUser = Depends( - is_user_allowed_to([TicketingPermissions.access_ticketing]) + is_user_allowed_to([TicketingPermissions.access_ticketing]), ), ) -> schemas_ticketing.OrganiserComplete: """ @@ -226,6 +225,23 @@ async def get_session_by_id( return session +@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", @@ -246,6 +262,23 @@ async def create_session( used_quota=0, 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, @@ -275,6 +308,11 @@ async def update_session( stored = await cruds_ticketing.get_session_by_id(session_id=session_id, db=db) if stored is None: raise HTTPException(status_code=404, detail="Session not found") + if session_update.quota is not None and stored.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, @@ -304,6 +342,24 @@ async def delete_session( 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) diff --git a/app/modules/ticketing/schemas_ticketing.py b/app/modules/ticketing/schemas_ticketing.py index 8cbaa9b620..d06711c859 100644 --- a/app/modules/ticketing/schemas_ticketing.py +++ b/app/modules/ticketing/schemas_ticketing.py @@ -1,7 +1,8 @@ from datetime import datetime +from typing import Annotated from uuid import UUID -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field class OrganiserBase(BaseModel): @@ -59,8 +60,8 @@ class SessionBase(BaseModel): event_id: UUID date: datetime name: str - quota: int | None = None - user_quota: int | None = None + quota: Annotated[int, Field(gt=0)] | None = None + user_quota: Annotated[int, Field(gt=0)] | None = None class SessionSimple(SessionBase): @@ -79,8 +80,8 @@ class SessionComplete(SessionSimple): class SessionUpdate(BaseModel): name: str | None = None - quota: int | None = None - user_quota: int | None = None + quota: Annotated[int, Field(gt=0)] | None = None + user_quota: Annotated[int, Field(gt=0)] | None = None disabled: bool | None = None @@ -88,8 +89,8 @@ class CategoryBase(BaseModel): event_id: UUID name: str required_mebership: UUID | None = None - quota: int | None = None - user_quota: int | None = None + quota: Annotated[int, Field(gt=0)] | None = None + user_quota: Annotated[int, Field(gt=0)] | None = None price: int @@ -112,9 +113,9 @@ class CategoryUpdate(BaseModel): name: str | None = None sessions: list[UUID] | None = None required_mebership: UUID | None = None - quota: int | None = None - user_quota: int | None = None - price: int | 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 diff --git a/tests/modules/test_ticketing.py b/tests/modules/test_ticketing.py index 00df7c12b3..daea1fe533 100644 --- a/tests/modules/test_ticketing.py +++ b/tests/modules/test_ticketing.py @@ -168,9 +168,9 @@ async def init_objects(): id=uuid4(), event_id=event1.id, name="Session 2", - quota=2, + quota=5, user_quota=1, - used_quota=0, + used_quota=3, disabled=False, date=datetime(2024, 1, 2, tzinfo=UTC), ) @@ -203,9 +203,20 @@ async def init_objects(): category1.sessions = [session1, session2] await add_object_to_db(category1) + student_group = await create_groups_with_permissions( + [TicketingPermissions.access_ticketing], + "group_student", + ) + #await add_object_to_db(student_group) + + # manage_group = await create_groups_with_permissions( + # [TicketingPermissions.manage_events], + # "Group 2", + # ) + global student_user, student_token student_user = await create_user_with_groups( - groups=[], + groups=[student_group.id], account_type=AccountType.student, ) student_token = create_api_access_token(student_user) @@ -423,3 +434,254 @@ async def test_deleted_as_admin_with_tickets(client: TestClient): # -------------------------- Test session basic cruds -------------------------- # + + +async def test_get_sessions_list(client: TestClient): + response = client.get( + f"/ticketing/events/{event1.id}/sessions", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 200 + sessions = response.json() + assert isinstance(sessions, list) + assert len(sessions) >= 3 # We created 3 sessions for event1 + + +async def test_get_session(client: TestClient): + # Test with session1 (should succeed) + response = client.get( + f"/ticketing/sessions/{session1.id}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 200 + + # Test with session2 (should succeed) + response = client.get( + f"/ticketing/sessions/{session2.id}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 200 + + # Test with session_fake (not in DB, should return 404) + response = client.get( + f"/ticketing/sessions/{uuid4()}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 404 + + +async def test_create_session(client: TestClient): + new_session_data = { + "name": "New Session", + "date": "2024-01-03T00:00:00Z", + "quota": 2, + "user_quota": 1, + "event_id": str(event1.id), + } + response = client.post( + "/ticketing/sessions", + json=new_session_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 201 + created_session = response.json() + assert created_session["name"] == new_session_data["name"] + assert created_session["date"] == new_session_data["date"] + + +# create session without perms +async def test_create_session_without_perms(client: TestClient): + new_session_data = { + "name": "New Session", + "date": "2024-01-03T00:00:00Z", + "quota": 2, + "user_quota": 1, + "event_id": str(event1.id), + } + response = client.post( + "/ticketing/sessions", + json=new_session_data, + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 403 + + +# create session with date before event open date, should fail +async def test_create_session_with_date_before_event_open_date(client: TestClient): + new_session_data = { + "name": "New Session", + "date": "2023-12-31T00:00:00Z", # Before event1 open date + "quota": 2, + "user_quota": 1, + "event_id": str(event1.id), + } + response = client.post( + "/ticketing/sessions", + json=new_session_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 400 + + +# create session with date after event close date, should fail +async def test_create_session_with_date_after_event_close_date(client: TestClient): + new_session_data = { + "name": "New Session", + "date": "2201-01-01T00:00:00Z", # After event1 close date + "quota": 2, + "user_quota": 1, + "event_id": str(event1.id), + } + response = client.post( + "/ticketing/sessions", + json=new_session_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 400 + + +# test create session with negative quota, should fail +async def test_create_session_with_negative_quota(client: TestClient): + new_session_data = { + "name": "New Session", + "date": "2024-01-03T00:00:00Z", + "quota": -1, # Negative quota + "user_quota": 1, + "event_id": str(event1.id), + } + response = client.post( + "/ticketing/sessions", + json=new_session_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 422 + + +# update session as admin +async def test_update_session_as_admin(client: TestClient): + update_data = { + "name": "Updated Session Name", + "quota": 10, + } + response = client.patch( + f"/ticketing/sessions/{session1.id}", + json=update_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 204 + + +# update session as lambda, should fail +async def test_update_session_as_lambda(client: TestClient): + update_data = { + "name": "Updated Session Name", + "quota": 10, + } + response = client.patch( + f"/ticketing/sessions/{session1.id}", + json=update_data, + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 403 + + +# update session with invalid id, should fail +async def test_update_session_with_invalid_id(client: TestClient): + update_data = { + "name": "Updated Session Name", + "quota": 10, + } + response = client.patch( + f"/ticketing/sessions/{uuid4()}", + json=update_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 404 + + +# update session with quota less than used_quota, should fail +async def test_update_session_with_quota_less_than_used_quota(client: TestClient): + update_data = { + "quota": 1, # session2 has used_quota=3 + } + response = client.patch( + f"/ticketing/sessions/{session2.id}", + json=update_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 400 + + +# test delete session as lambda, should fail +async def test_delete_session_as_lambda(client: TestClient): + response = client.delete( + f"/ticketing/sessions/{session2.id}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 403 + + +# test delete session as admin, should succeed +async def test_delete_session_as_admin(client: TestClient): + # First create a new session to delete + to_delete_session = models_ticketing.TicketingSession( + id=uuid4(), + event_id=event1.id, + name="To Delete Session", + quota=2, + user_quota=1, + used_quota=0, + disabled=False, + date=datetime(2024, 1, 3, tzinfo=UTC), + ) + await add_object_to_db(to_delete_session) + response = client.delete( + f"/ticketing/sessions/{to_delete_session.id}", + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 204 + + +# test delete session as admin with tickets, should fail +async def test_delete_session_as_admin_with_tickets(client: TestClient): + # Create a ticket for the session + ticket = models_ticketing.TicketingTicket( + id=uuid4(), + event_id=event1.id, + session_id=session2.id, + category_id=category1.id, + user_id=student_user.id, + status="active", + nb_scan=0, + total=1, + created_at=datetime.now(UTC), + ) + await add_object_to_db(ticket) + # Try to delete the session with existing tickets + response = client.delete( + f"/ticketing/sessions/{session2.id}", + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 400 + + +# test delete session with invalid id, should fail +async def test_delete_session_with_invalid_id(client: TestClient): + response = client.delete( + f"/ticketing/sessions/{uuid4()}", + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 404 + + +# test delete session with categories, should fail +async def test_delete_session_with_categories(client: TestClient): + # session1 is linked to category1, so deleting it should fail + response = client.delete( + f"/ticketing/sessions/{session1.id}", + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 400 + + +# -------------------------- Test category basic cruds -------------------------- # From 225d1570a308dd06c1c410d292bcaff14173102d Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Thu, 19 Mar 2026 19:19:44 +0100 Subject: [PATCH 31/50] ruff format --- app/modules/ticketing/cache_ticketing.py | 2 ++ app/modules/ticketing/cruds_ticketing.py | 13 ++++++++++--- tests/modules/test_ticketing.py | 2 +- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/app/modules/ticketing/cache_ticketing.py b/app/modules/ticketing/cache_ticketing.py index 52801f7737..9e1db4dab9 100644 --- a/app/modules/ticketing/cache_ticketing.py +++ b/app/modules/ticketing/cache_ticketing.py @@ -31,6 +31,7 @@ def increment_quota_event( """Increment the quota for an event.""" redis.incrby(f"ticketing:event:{event_id}:quota", amount) + def increment_quota_category( redis: Redis, category_id: UUID, @@ -39,6 +40,7 @@ def increment_quota_category( """Increment the quota for a category.""" redis.incrby(f"ticketing:category:{category_id}:quota", amount) + def increment_quota_session( redis: Redis, session_id: UUID, diff --git a/app/modules/ticketing/cruds_ticketing.py b/app/modules/ticketing/cruds_ticketing.py index 884eea26c8..1a89be7c66 100644 --- a/app/modules/ticketing/cruds_ticketing.py +++ b/app/modules/ticketing/cruds_ticketing.py @@ -303,7 +303,9 @@ async def get_sessions_by_event_id( models_ticketing.TicketingSession.event_id == event_id, ), ) - ).scalars().all() + ) + .scalars() + .all() ] @@ -439,6 +441,7 @@ async def get_category_by_id( else None ) + async def get_categories_by_session_id( session_id: UUID, db: AsyncSession, @@ -464,12 +467,16 @@ async def get_categories_by_session_id( select(models_ticketing.TicketingCategory) .join(models_ticketing.CategorySessionAssociation) .where( - models_ticketing.CategorySessionAssociation.session_id == session_id, + models_ticketing.CategorySessionAssociation.session_id + == session_id, ), ) - ).scalars().all() + ) + .scalars() + .all() ] + async def create_category( db: AsyncSession, category: schemas_ticketing.CategorySimple, diff --git a/tests/modules/test_ticketing.py b/tests/modules/test_ticketing.py index daea1fe533..f6b2371410 100644 --- a/tests/modules/test_ticketing.py +++ b/tests/modules/test_ticketing.py @@ -207,7 +207,7 @@ async def init_objects(): [TicketingPermissions.access_ticketing], "group_student", ) - #await add_object_to_db(student_group) + # await add_object_to_db(student_group) # manage_group = await create_groups_with_permissions( # [TicketingPermissions.manage_events], From f1dffa5c4e36549c03fcfc8d23e15dc7198caf13 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Thu, 19 Mar 2026 19:24:46 +0100 Subject: [PATCH 32/50] relint --- app/modules/ticketing/factory_ticketing.py | 2 +- migrations/versions/59-ticketing.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/modules/ticketing/factory_ticketing.py b/app/modules/ticketing/factory_ticketing.py index 44f5977404..8a9f3f967a 100644 --- a/app/modules/ticketing/factory_ticketing.py +++ b/app/modules/ticketing/factory_ticketing.py @@ -1,6 +1,6 @@ import random from datetime import UTC, datetime, timedelta -from uuid import UUID, uuid4 +from uuid import uuid4 from sqlalchemy.ext.asyncio import AsyncSession diff --git a/migrations/versions/59-ticketing.py b/migrations/versions/59-ticketing.py index cf759642e5..362698c08f 100644 --- a/migrations/versions/59-ticketing.py +++ b/migrations/versions/59-ticketing.py @@ -4,7 +4,7 @@ """ from collections.abc import Sequence -from typing import TYPE_CHECKING, Union +from typing import TYPE_CHECKING if TYPE_CHECKING: from pytest_alembic import MigrationContext @@ -60,7 +60,7 @@ def upgrade() -> None: sa.Column("disabled", sa.Boolean(), nullable=False), sa.ForeignKeyConstraint(["event_id"], ["ticketing_event.id"]), sa.ForeignKeyConstraint( - ["required_mebership"], ["core_association_membership.id"] + ["required_mebership"], ["core_association_membership.id"], ), sa.PrimaryKeyConstraint("id"), ) From f26d7b86b63f823af382668a551604745b5f2509 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Thu, 2 Apr 2026 03:46:07 +0200 Subject: [PATCH 33/50] WIP Tests categories --- app/modules/ticketing/cruds_ticketing.py | 32 ++++ app/modules/ticketing/endpoints_ticketing.py | 32 ++++ tests/modules/test_ticketing.py | 175 +++++++++++++++++++ 3 files changed, 239 insertions(+) diff --git a/app/modules/ticketing/cruds_ticketing.py b/app/modules/ticketing/cruds_ticketing.py index 1a89be7c66..311ab6ae53 100644 --- a/app/modules/ticketing/cruds_ticketing.py +++ b/app/modules/ticketing/cruds_ticketing.py @@ -477,6 +477,38 @@ async def get_categories_by_session_id( ] +async def get_categories_by_event_id( + event_id: UUID, + db: AsyncSession, +) -> list[schemas_ticketing.CategoryComplete]: + """Get all categories for a specific event.""" + + return [ + schemas_ticketing.CategoryComplete( + id=category.id, + event_id=category.event_id, + event=category.event, + name=category.name, + sessions=category.sessions, + required_mebership=category.required_mebership, + quota=category.quota, + user_quota=category.user_quota, + used_quota=category.used_quota, + price=category.price, + disabled=category.disabled, + ) + for category in ( + await db.execute( + select(models_ticketing.TicketingCategory).where( + models_ticketing.TicketingCategory.event_id == event_id, + ), + ) + ) + .scalars() + .all() + ] + + async def create_category( db: AsyncSession, category: schemas_ticketing.CategorySimple, diff --git a/app/modules/ticketing/endpoints_ticketing.py b/app/modules/ticketing/endpoints_ticketing.py index 400e2e278d..1963ed5dcc 100644 --- a/app/modules/ticketing/endpoints_ticketing.py +++ b/app/modules/ticketing/endpoints_ticketing.py @@ -380,6 +380,38 @@ async def get_category_by_id( return category +@module.router.get( + "/ticketing/events/{event_id}/categories/", + summary="Get all categories for an event", + response_model=list[schemas_ticketing.CategoryComplete], + status_code=200, +) +async def get_categories_by_event( + event_id: UUID, + db: AsyncSession = Depends(get_db), +) -> list[schemas_ticketing.CategoryComplete]: + """Get all categories for an event.""" + return await cruds_ticketing.get_categories_by_event_id( + event_id=event_id, db=db, + ) + + +@module.router.get( + "/ticketing/sessions/{session_id}/categories/", + summary="Get all categories for a session", + response_model=list[schemas_ticketing.CategoryComplete], + status_code=200, +) +async def get_categories_by_session( + session_id: UUID, + db: AsyncSession = Depends(get_db), +) -> list[schemas_ticketing.CategoryComplete]: + """Get all categories for a session.""" + return await cruds_ticketing.get_categories_by_session_id( + session_id=session_id, db=db, + ) + + @module.router.post( "/ticketing/categories/", summary="Create a new category", diff --git a/tests/modules/test_ticketing.py b/tests/modules/test_ticketing.py index f6b2371410..6327ba43d6 100644 --- a/tests/modules/test_ticketing.py +++ b/tests/modules/test_ticketing.py @@ -685,3 +685,178 @@ async def test_delete_session_with_categories(client: TestClient): # -------------------------- Test category basic cruds -------------------------- # + +# Get categories by session id +async def test_get_categories_list_by_session(client: TestClient): + response = client.get( + f"/ticketing/sessions/{session1.id}/categories", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 200 + categories = response.json() + assert isinstance(categories, list) + assert len(categories) >= 1 # We created 1 category for event1 + + +# Get categories by event id +async def test_get_categories_list_by_event(client: TestClient): + response = client.get( + f"/ticketing/events/{event1.id}/categories", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 200 + categories = response.json() + assert isinstance(categories, list) + assert len(categories) >= 1 # We created 1 category for event1 + + +async def test_get_category(client: TestClient): + # Test with category1 (should succeed) + response = client.get( + f"/ticketing/categories/{category1.id}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 200 + + # Test with category_fake (not in DB, should return 404) + response = client.get( + f"/ticketing/categories/{uuid4()}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 404 + + +async def test_create_category(client: TestClient): + new_category_data = { + "name": "New Category", + "quota": 2, + "user_quota": 1, + "event_id": str(event1.id), + "price": 50, + "sessions": [str(session1.id), str(session2.id)], + } + response = client.post( + "/ticketing/categories", + json=new_category_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 201 + created_category = response.json() + assert created_category["name"] == new_category_data["name"] + assert created_category["price"] == new_category_data["price"] + + +# create category without perms +async def test_create_category_without_perms(client: TestClient): + new_category_data = { + "name": "New Category", + "quota": 2, + "user_quota": 1, + "event_id": str(event1.id), + "price": 50, + } + response = client.post( + "/ticketing/categories", + json=new_category_data, + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 403 + + +# test create category with incorrect event id, should fail +async def test_create_category_with_incorrect_event_id(client: TestClient): + new_category_data = { + "name": "New Category", + "quota": 2, + "user_quota": 1, + "event_id": str(uuid4()), # Incorrect event ID + "price": 50, + } + response = client.post( + "/ticketing/categories", + json=new_category_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 400 + + +# test create category with incorrect sessions ids, should fail +async def test_create_category_with_incorrect_sessions_ids(client: TestClient): + new_category_data = { + "name": "New Category", + "quota": 2, + "user_quota": 1, + "event_id": str(event1.id), + "price": 50, + "sessions": [str(uuid4()), str(uuid4())], # Incorrect session IDs + } + response = client.post( + "/ticketing/categories", + json=new_category_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 400 + + +async def test_create_category_with_sessions_from_different_event(client: TestClient): + # Create a session for event2 + session_event2 = models_ticketing.TicketingSession( + id=uuid4(), + event_id=event2.id, + name="Session Event 2", + quota=2, + user_quota=1, + used_quota=0, + disabled=False, + date=datetime(2024, 1, 4, tzinfo=UTC), + ) + await add_object_to_db(session_event2) + + new_category_data = { + "name": "New Category", + "quota": 2, + "user_quota": 1, + "event_id": str(event1.id), + "price": 50, + "sessions": [str(session1.id), str(session_event2.id)], # session_event2 belongs to event2 + } + response = client.post( + "/ticketing/categories", + json=new_category_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 400 + + +# test create category with quota less than used_quota, should fail +async def test_create_category_with_quota_less_than_used_quota(client: TestClient): + new_category_data = { + "name": "New Category", + "quota": 0, # category1 has used_quota=1 + "user_quota": 1, + "event_id": str(event1.id), + "price": 50, + } + response = client.post( + "/ticketing/categories", + json=new_category_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 400 + + +# test create category with negative price, should fail +async def test_create_category_with_negative_price(client: TestClient): + new_category_data = { + "name": "New Category", + "quota": 2, + "user_quota": 1, + "event_id": str(event1.id), + "price": -10, # Negative price + } + response = client.post( + "/ticketing/categories", + json=new_category_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 422 From af457d1b3ba6b0e57c644d28b565d8014ea06328 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:43:14 +0200 Subject: [PATCH 34/50] Fixed Categories --- app/modules/ticketing/cruds_ticketing.py | 113 ++++++++++--- app/modules/ticketing/endpoints_ticketing.py | 50 ++++-- app/modules/ticketing/schemas_ticketing.py | 12 +- tests/modules/test_ticketing.py | 159 ++++++++++++++++--- 4 files changed, 272 insertions(+), 62 deletions(-) diff --git a/app/modules/ticketing/cruds_ticketing.py b/app/modules/ticketing/cruds_ticketing.py index 311ab6ae53..0102774f8a 100644 --- a/app/modules/ticketing/cruds_ticketing.py +++ b/app/modules/ticketing/cruds_ticketing.py @@ -344,6 +344,35 @@ async def get_session_by_id( ) +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, + used_quota=session.used_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, @@ -427,9 +456,20 @@ async def get_category_by_id( schemas_ticketing.CategoryComplete( id=category.id, event_id=category.event_id, - event=category.event, name=category.name, - sessions=category.sessions, + 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, + used_quota=category.event.used_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, @@ -445,16 +485,14 @@ async def get_category_by_id( async def get_categories_by_session_id( session_id: UUID, db: AsyncSession, -) -> list[schemas_ticketing.CategoryComplete]: +) -> list[schemas_ticketing.CategorySimple]: """Get all categories for a specific session.""" return [ - schemas_ticketing.CategoryComplete( + schemas_ticketing.CategorySimple( id=category.id, event_id=category.event_id, - event=category.event, name=category.name, - sessions=category.sessions, required_mebership=category.required_mebership, quota=category.quota, user_quota=category.user_quota, @@ -480,16 +518,14 @@ async def get_categories_by_session_id( async def get_categories_by_event_id( event_id: UUID, db: AsyncSession, -) -> list[schemas_ticketing.CategoryComplete]: +) -> list[schemas_ticketing.CategorySimple]: """Get all categories for a specific event.""" return [ - schemas_ticketing.CategoryComplete( + schemas_ticketing.CategorySimple( id=category.id, event_id=category.event_id, - event=category.event, name=category.name, - sessions=category.sessions, required_mebership=category.required_mebership, quota=category.quota, user_quota=category.user_quota, @@ -586,9 +622,11 @@ async def get_tickets( created_at=ticket.created_at, event=ticket.event, category=ticket.category, - session=ticket.session, - status=ticket.status, - nb_scan=ticket.nb_scan, + session=schemas_ticketing.SessionComplete( + id=ticket.session.id, + status=ticket.session.status, + nb_scan=ticket.session.nb_scan, + ), ) for ticket in tickets.scalars().all() ] @@ -597,7 +635,7 @@ async def get_tickets( async def get_tickets_by_session_id( session_id: UUID, db: AsyncSession, -) -> list[schemas_ticketing.TicketComplete]: +) -> list[schemas_ticketing.TicketSimple]: """Get all tickets for a specific session.""" tickets = await db.execute( @@ -606,7 +644,7 @@ async def get_tickets_by_session_id( ), ) return [ - schemas_ticketing.TicketComplete( + schemas_ticketing.TicketSimple( id=ticket.id, user_id=ticket.user_id, event_id=ticket.event_id, @@ -615,8 +653,6 @@ async def get_tickets_by_session_id( total=ticket.total, created_at=ticket.created_at, event=ticket.event, - category=ticket.category, - session=ticket.session, status=ticket.status, nb_scan=ticket.nb_scan, ) @@ -627,7 +663,7 @@ async def get_tickets_by_session_id( async def get_tickets_by_user_id( user_id: str, db: AsyncSession, -) -> list[schemas_ticketing.TicketComplete]: +) -> list[schemas_ticketing.TicketSimple]: """Get all tickets for a specific user.""" tickets = await db.execute( @@ -636,7 +672,7 @@ async def get_tickets_by_user_id( ), ) return [ - schemas_ticketing.TicketComplete( + schemas_ticketing.TicketSimple( id=ticket.id, user_id=ticket.user_id, event_id=ticket.event_id, @@ -644,9 +680,6 @@ async def get_tickets_by_user_id( session_id=ticket.session_id, total=ticket.total, created_at=ticket.created_at, - event=ticket.event, - category=ticket.category, - session=ticket.session, status=ticket.status, nb_scan=ticket.nb_scan, ) @@ -681,9 +714,39 @@ async def get_ticket_by_id( session_id=ticket.session_id, total=ticket.total, created_at=ticket.created_at, - event=ticket.event, - category=ticket.category, - session=ticket.session, + 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, + used_quota=ticket.event.used_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, + used_quota=ticket.session.used_quota, + disabled=ticket.session.disabled, + ), + 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, + used_quota=ticket.category.used_quota, + price=ticket.category.price, + disabled=ticket.category.disabled, + ), status=ticket.status, nb_scan=ticket.nb_scan, ) diff --git a/app/modules/ticketing/endpoints_ticketing.py b/app/modules/ticketing/endpoints_ticketing.py index 1963ed5dcc..8a4b69f9cd 100644 --- a/app/modules/ticketing/endpoints_ticketing.py +++ b/app/modules/ticketing/endpoints_ticketing.py @@ -383,57 +383,78 @@ async def get_category_by_id( @module.router.get( "/ticketing/events/{event_id}/categories/", summary="Get all categories for an event", - response_model=list[schemas_ticketing.CategoryComplete], + response_model=list[schemas_ticketing.CategorySimple], status_code=200, ) async def get_categories_by_event( event_id: UUID, db: AsyncSession = Depends(get_db), -) -> list[schemas_ticketing.CategoryComplete]: +) -> list[schemas_ticketing.CategorySimple]: """Get all categories for an event.""" return await cruds_ticketing.get_categories_by_event_id( - event_id=event_id, db=db, + event_id=event_id, + db=db, ) @module.router.get( "/ticketing/sessions/{session_id}/categories/", summary="Get all categories for a session", - response_model=list[schemas_ticketing.CategoryComplete], + response_model=list[schemas_ticketing.CategorySimple], status_code=200, ) async def get_categories_by_session( session_id: UUID, db: AsyncSession = Depends(get_db), -) -> list[schemas_ticketing.CategoryComplete]: +) -> list[schemas_ticketing.CategorySimple]: """Get all categories for a session.""" return await cruds_ticketing.get_categories_by_session_id( - session_id=session_id, db=db, + session_id=session_id, + db=db, ) @module.router.post( "/ticketing/categories/", summary="Create a new category", - response_model=schemas_ticketing.CategoryComplete, + response_model=schemas_ticketing.CategorySimple, status_code=201, ) async def create_category( - category: schemas_ticketing.CategoryBase, + category: schemas_ticketing.CategoryCreate, db: AsyncSession = Depends(get_db), user: models_users.CoreUser = Depends( is_user_allowed_to([TicketingPermissions.manage_events]), ), -) -> schemas_ticketing.CategoryComplete: +) -> schemas_ticketing.CategorySimple: """Create a new category.""" - category_complete = schemas_ticketing.CategoryComplete( + # Verify that the event exists before creating the category. + event = await cruds_ticketing.get_event_by_id( + event_id=category.event_id, + db=db, + ) + if event is None: + raise HTTPException(status_code=404, detail="Event not found") + # Verify that the sessions exist before creating the category. + if category.sessions is not None: + sessions = await cruds_ticketing.get_sessions_by_event_id( + db=db, + event_id=category.event_id, + ) + sessions_ids = [session.id for session in sessions] + if sessions is None or any(session_id not in sessions_ids for session_id in category.sessions): + raise HTTPException( + status_code=404, + detail="One or more sessions not found", + ) + category_simple = schemas_ticketing.CategorySimple( **category.model_dump(), id=uuid4(), used_quota=0, disabled=False, ) - await cruds_ticketing.create_category(category=category_complete, db=db) - return category_complete + await cruds_ticketing.create_category(category=category_simple, db=db) + return category_simple @module.router.patch( @@ -454,6 +475,11 @@ async def update_category( stored = await cruds_ticketing.get_category_by_id(category_id=category_id, db=db) if stored is None: raise HTTPException(status_code=404, detail="Category not found") + if category_update.quota is not None and stored.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, diff --git a/app/modules/ticketing/schemas_ticketing.py b/app/modules/ticketing/schemas_ticketing.py index d06711c859..1a7da4d913 100644 --- a/app/modules/ticketing/schemas_ticketing.py +++ b/app/modules/ticketing/schemas_ticketing.py @@ -91,7 +91,11 @@ class CategoryBase(BaseModel): required_mebership: UUID | None = None quota: Annotated[int, Field(gt=0)] | None = None user_quota: Annotated[int, Field(gt=0)] | None = None - price: int + price: Annotated[int, Field(gt=0)] + + +class CategoryCreate(CategoryBase): + sessions: list[UUID] | None = None class CategorySimple(CategoryBase): @@ -137,8 +141,8 @@ class TicketSimple(TicketBase): class TicketComplete(TicketSimple): - event: EventComplete - category: CategoryComplete - session: SessionComplete + event: EventSimple + category: CategorySimple + session: SessionSimple model_config = ConfigDict(from_attributes=True) diff --git a/tests/modules/test_ticketing.py b/tests/modules/test_ticketing.py index 6327ba43d6..b1ae7dc1e6 100644 --- a/tests/modules/test_ticketing.py +++ b/tests/modules/test_ticketing.py @@ -193,9 +193,9 @@ async def init_objects(): id=uuid4(), event_id=event1.id, name="Category 1", - quota=2, + quota=5, user_quota=1, - used_quota=1, + used_quota=2, disabled=False, required_mebership=None, price=100, @@ -740,6 +740,7 @@ async def test_create_category(client: TestClient): json=new_category_data, headers={"Authorization": f"Bearer {admin_user_token}"}, ) + print(response.json()) assert response.status_code == 201 created_category = response.json() assert created_category["name"] == new_category_data["name"] @@ -777,7 +778,7 @@ async def test_create_category_with_incorrect_event_id(client: TestClient): json=new_category_data, headers={"Authorization": f"Bearer {admin_user_token}"}, ) - assert response.status_code == 400 + assert response.status_code == 404 # test create category with incorrect sessions ids, should fail @@ -795,7 +796,7 @@ async def test_create_category_with_incorrect_sessions_ids(client: TestClient): json=new_category_data, headers={"Authorization": f"Bearer {admin_user_token}"}, ) - assert response.status_code == 400 + assert response.status_code == 404 async def test_create_category_with_sessions_from_different_event(client: TestClient): @@ -825,38 +826,154 @@ async def test_create_category_with_sessions_from_different_event(client: TestCl json=new_category_data, headers={"Authorization": f"Bearer {admin_user_token}"}, ) - assert response.status_code == 400 + assert response.status_code == 404 -# test create category with quota less than used_quota, should fail -async def test_create_category_with_quota_less_than_used_quota(client: TestClient): +# 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": 0, # category1 has used_quota=1 + "quota": 2, "user_quota": 1, "event_id": str(event1.id), - "price": 50, + "price": -10, # Negative price } response = client.post( "/ticketing/categories", json=new_category_data, headers={"Authorization": f"Bearer {admin_user_token}"}, ) - assert response.status_code == 400 + assert response.status_code == 422 -# 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 +# test update category as admin +async def test_update_category_as_admin(client: TestClient): + update_data = { + "name": "Updated Category Name", + "price": 200, } - response = client.post( - "/ticketing/categories", - json=new_category_data, + response = client.patch( + f"/ticketing/categories/{category1.id}", + json=update_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 204 + + +# test update category as lambda, should fail +async def test_update_category_as_lambda(client: TestClient): + update_data = { + "name": "Updated Category Name", + "price": 200, + } + response = client.patch( + f"/ticketing/categories/{category1.id}", + json=update_data, + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 403 + + +# test update category with invalid id, should fail +async def test_update_category_with_invalid_id(client: TestClient): + update_data = { + "name": "Updated Category Name", + "price": 200, + } + response = client.patch( + f"/ticketing/categories/{uuid4()}", + json=update_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 404 + + +# test update category with negative price, should fail +async def test_update_category_with_negative_price(client: TestClient): + update_data = { + "price": -50, # Negative price + } + response = client.patch( + f"/ticketing/categories/{category1.id}", + json=update_data, headers={"Authorization": f"Bearer {admin_user_token}"}, ) assert response.status_code == 422 + + +# test update category with quota less than used_quota, should fail +async def test_update_category_with_quota_less_than_used_quota(client: TestClient): + update_data = { + "quota": 1, # category1 has used_quota=1 + } + response = client.patch( + f"/ticketing/categories/{category1.id}", + json=update_data, + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 400 + + +# test delete category as lambda, should fail +async def test_delete_category_as_lambda(client: TestClient): + response = client.delete( + f"/ticketing/categories/{category1.id}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 403 + + +# test delete category as admin, should succeed +async def test_delete_category_as_admin(client: TestClient): + # First create a new category to delete + to_delete_category = models_ticketing.TicketingCategory( + id=uuid4(), + event_id=event1.id, + name="To Delete Category", + quota=2, + user_quota=1, + used_quota=0, + disabled=False, + required_mebership=None, + price=50, + ) + await add_object_to_db(to_delete_category) + response = client.delete( + f"/ticketing/categories/{to_delete_category.id}", + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 204 + + +# test delete category as admin with tickets, should fail +async def test_delete_category_as_admin_with_tickets(client: TestClient): + # Create a ticket for the category + ticket = models_ticketing.TicketingTicket( + id=uuid4(), + event_id=event1.id, + session_id=session1.id, + category_id=category1.id, + user_id=student_user.id, + status="active", + nb_scan=0, + total=1, + created_at=datetime.now(UTC), + ) + await add_object_to_db(ticket) + # Try to delete the category with existing tickets + response = client.delete( + f"/ticketing/categories/{category1.id}", + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 400 + + +# test delete category with invalid id, should fail +async def test_delete_category_with_invalid_id(client: TestClient): + response = client.delete( + f"/ticketing/categories/{uuid4()}", + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 404 + + From cb8956e3c42829866be3cc5adacd3f1ca0389f45 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:43:40 +0200 Subject: [PATCH 35/50] Format --- app/modules/ticketing/endpoints_ticketing.py | 4 +++- migrations/versions/59-ticketing.py | 3 ++- tests/modules/test_ticketing.py | 8 +++++--- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/app/modules/ticketing/endpoints_ticketing.py b/app/modules/ticketing/endpoints_ticketing.py index 8a4b69f9cd..41b8a55d08 100644 --- a/app/modules/ticketing/endpoints_ticketing.py +++ b/app/modules/ticketing/endpoints_ticketing.py @@ -442,7 +442,9 @@ async def create_category( 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): + 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", diff --git a/migrations/versions/59-ticketing.py b/migrations/versions/59-ticketing.py index 362698c08f..83eaf24c82 100644 --- a/migrations/versions/59-ticketing.py +++ b/migrations/versions/59-ticketing.py @@ -60,7 +60,8 @@ def upgrade() -> None: sa.Column("disabled", sa.Boolean(), nullable=False), sa.ForeignKeyConstraint(["event_id"], ["ticketing_event.id"]), sa.ForeignKeyConstraint( - ["required_mebership"], ["core_association_membership.id"], + ["required_mebership"], + ["core_association_membership.id"], ), sa.PrimaryKeyConstraint("id"), ) diff --git a/tests/modules/test_ticketing.py b/tests/modules/test_ticketing.py index b1ae7dc1e6..acd1e8e428 100644 --- a/tests/modules/test_ticketing.py +++ b/tests/modules/test_ticketing.py @@ -686,6 +686,7 @@ async def test_delete_session_with_categories(client: TestClient): # -------------------------- Test category basic cruds -------------------------- # + # Get categories by session id async def test_get_categories_list_by_session(client: TestClient): response = client.get( @@ -819,7 +820,10 @@ async def test_create_category_with_sessions_from_different_event(client: TestCl "user_quota": 1, "event_id": str(event1.id), "price": 50, - "sessions": [str(session1.id), str(session_event2.id)], # session_event2 belongs to event2 + "sessions": [ + str(session1.id), + str(session_event2.id), + ], # session_event2 belongs to event2 } response = client.post( "/ticketing/categories", @@ -975,5 +979,3 @@ async def test_delete_category_with_invalid_id(client: TestClient): headers={"Authorization": f"Bearer {admin_user_token}"}, ) assert response.status_code == 404 - - From ed4644deb13eea9ed7ed14251b9b40d05d766cea Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:46:41 +0200 Subject: [PATCH 36/50] Remove print() --- tests/modules/test_ticketing.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/modules/test_ticketing.py b/tests/modules/test_ticketing.py index acd1e8e428..f43c1d6dde 100644 --- a/tests/modules/test_ticketing.py +++ b/tests/modules/test_ticketing.py @@ -741,7 +741,6 @@ async def test_create_category(client: TestClient): json=new_category_data, headers={"Authorization": f"Bearer {admin_user_token}"}, ) - print(response.json()) assert response.status_code == 201 created_category = response.json() assert created_category["name"] == new_category_data["name"] From d8b5cc5a215ff1ace0306e946adc91922de64414 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:07:10 +0200 Subject: [PATCH 37/50] Redis helpers --- app/modules/ticketing/cache_ticketing.py | 77 ++++++++++++++---------- 1 file changed, 45 insertions(+), 32 deletions(-) diff --git a/app/modules/ticketing/cache_ticketing.py b/app/modules/ticketing/cache_ticketing.py index 9e1db4dab9..6a6547916b 100644 --- a/app/modules/ticketing/cache_ticketing.py +++ b/app/modules/ticketing/cache_ticketing.py @@ -1,50 +1,63 @@ # Redis Cache for Ticketing Module +import logging +from typing import TypeVar from uuid import UUID +from pydantic import BaseModel from redis import Redis -# TODO: Detect redis instance or skip caching if not available +hyperion_error_logger = logging.getLogger("hyperion.error") -def use_cache_or_else_db( +SchemaT = TypeVar("SchemaT", bound=BaseModel) + + +class RedisKeysList: + """List of Redis keys used in the ticketing module.""" + + @staticmethod + def event_quota(event_id: UUID) -> str: + return f"ticketing:event:{event_id}:quota" + + @staticmethod + def category_quota(category_id: UUID) -> str: + return f"ticketing:category:{category_id}:quota" + + @staticmethod + def session_quota(session_id: UUID) -> str: + return f"ticketing:session:{session_id}:quota" + + +def use_or_set_cache_with_crud( redis: Redis, key: str, - db_func, + crud_func, + schema_class: type[SchemaT], *args, **kwargs, -): +) -> SchemaT: """Use cache if available, otherwise call the database function.""" + # If redis is not available, call the crud directly + if redis is None and not isinstance(redis, Redis): + return crud_func(*args, **kwargs) cached_value = redis.get(key) if cached_value is not None: - return int(cached_value) - value = db_func(*args, **kwargs) - redis.set(key, value) + 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( + f"Error parsing cache for key {key}, deleting it. Value: {cached_value}" + ) + redis.delete(key) + + value = crud_func(*args, **kwargs) + redis.set(key, value.model_dump_json()) return value -def increment_quota_event( - redis: Redis, - event_id: UUID, - amount: int = 1, -) -> None: - """Increment the quota for an event.""" - redis.incrby(f"ticketing:event:{event_id}:quota", amount) - - -def increment_quota_category( - redis: Redis, - category_id: UUID, - amount: int = 1, -) -> None: - """Increment the quota for a category.""" - redis.incrby(f"ticketing:category:{category_id}:quota", amount) - - -def increment_quota_session( - redis: Redis, - session_id: UUID, - amount: int = 1, -) -> None: - """Increment the quota for a session.""" - redis.incrby(f"ticketing:session:{session_id}:quota", amount) +def increment_key(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) From a259547856f290ff1a4a46ba23941bbfabc7fec5 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:29:53 +0200 Subject: [PATCH 38/50] Fix mypy issues --- app/modules/ticketing/cache_ticketing.py | 48 ++++++++++++++++---- app/modules/ticketing/cruds_ticketing.py | 16 ++----- app/modules/ticketing/endpoints_ticketing.py | 45 ++++++++---------- app/modules/ticketing/schemas_ticketing.py | 2 +- 4 files changed, 64 insertions(+), 47 deletions(-) diff --git a/app/modules/ticketing/cache_ticketing.py b/app/modules/ticketing/cache_ticketing.py index 6a6547916b..d8b7970af4 100644 --- a/app/modules/ticketing/cache_ticketing.py +++ b/app/modules/ticketing/cache_ticketing.py @@ -1,7 +1,7 @@ # Redis Cache for Ticketing Module import logging -from typing import TypeVar +from typing import Callable, ParamSpec, TypeVar from uuid import UUID from pydantic import BaseModel @@ -11,6 +11,7 @@ hyperion_error_logger = logging.getLogger("hyperion.error") SchemaT = TypeVar("SchemaT", bound=BaseModel) +CrudFuncT = ParamSpec("CrudFuncT") class RedisKeysList: @@ -30,25 +31,27 @@ def session_quota(session_id: UUID) -> str: def use_or_set_cache_with_crud( - redis: Redis, + redis: Redis | None, key: str, - crud_func, + crud_func: Callable[CrudFuncT, SchemaT], schema_class: type[SchemaT], - *args, - **kwargs, + *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 and not isinstance(redis, Redis): + if redis is None or not isinstance(redis, Redis): return crud_func(*args, **kwargs) - cached_value = redis.get(key) + 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( - f"Error parsing cache for key {key}, deleting it. Value: {cached_value}" + "Error parsing cache for key %s, deleting it. Value: %r", + key, + cached_value, ) redis.delete(key) @@ -57,7 +60,34 @@ def use_or_set_cache_with_crud( return value -def increment_key(redis: Redis, key: str, amount: int = 1): +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, +): + """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_quota(event_id)) + increment_key_cache(redis, RedisKeysList.category_quota(category_id)) + if session_id is not None: + increment_key_cache(redis, RedisKeysList.session_quota(session_id)) + # Invalidate the cache for the event, category, and session to ensure consistency + invalidate_key_cache(redis, RedisKeysList.event_quota(event_id)) + invalidate_key_cache(redis, RedisKeysList.category_quota(category_id)) + if session_id is not None: + invalidate_key_cache(redis, RedisKeysList.session_quota(session_id)) + \ No newline at end of file diff --git a/app/modules/ticketing/cruds_ticketing.py b/app/modules/ticketing/cruds_ticketing.py index 0102774f8a..2c6f6957b9 100644 --- a/app/modules/ticketing/cruds_ticketing.py +++ b/app/modules/ticketing/cruds_ticketing.py @@ -607,12 +607,12 @@ async def increment_used_quota_category( async def get_tickets( db: AsyncSession, -) -> list[schemas_ticketing.TicketComplete]: +) -> list[schemas_ticketing.TicketSimple]: """Get all tickets.""" tickets = await db.execute(select(models_ticketing.TicketingTicket)) return [ - schemas_ticketing.TicketComplete( + schemas_ticketing.TicketSimple( id=ticket.id, user_id=ticket.user_id, event_id=ticket.event_id, @@ -620,13 +620,8 @@ async def get_tickets( session_id=ticket.session_id, total=ticket.total, created_at=ticket.created_at, - event=ticket.event, - category=ticket.category, - session=schemas_ticketing.SessionComplete( - id=ticket.session.id, - status=ticket.session.status, - nb_scan=ticket.session.nb_scan, - ), + status=ticket.status, + nb_scan=ticket.nb_scan, ) for ticket in tickets.scalars().all() ] @@ -652,7 +647,6 @@ async def get_tickets_by_session_id( session_id=ticket.session_id, total=ticket.total, created_at=ticket.created_at, - event=ticket.event, status=ticket.status, nb_scan=ticket.nb_scan, ) @@ -735,7 +729,7 @@ async def get_ticket_by_id( user_quota=ticket.session.user_quota, used_quota=ticket.session.used_quota, disabled=ticket.session.disabled, - ), + ) if ticket.session else None, category=schemas_ticketing.CategorySimple( id=ticket.category.id, event_id=ticket.category.event_id, diff --git a/app/modules/ticketing/endpoints_ticketing.py b/app/modules/ticketing/endpoints_ticketing.py index 41b8a55d08..07d46d4315 100644 --- a/app/modules/ticketing/endpoints_ticketing.py +++ b/app/modules/ticketing/endpoints_ticketing.py @@ -50,7 +50,7 @@ async def get_organisers( status_code=200, ) async def get_organiser( - organiser_id: str, + organiser_id: UUID, db: AsyncSession = Depends(get_db), user: models_users.CoreUser = Depends( is_user_allowed_to([TicketingPermissions.access_ticketing]), @@ -59,7 +59,10 @@ async def get_organiser( """ Get an Organiser by its id. """ - return await cruds_ticketing.get_organiser_by_id(db=db, organiser_id=organiser_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( @@ -534,12 +537,12 @@ async def get_ticket_by_id( @module.router.get( "/ticketing/tickets/", summary="Get all tickets", - response_model=list[schemas_ticketing.TicketComplete], + response_model=list[schemas_ticketing.TicketSimple], status_code=200, ) async def get_all_tickets( db: AsyncSession = Depends(get_db), -) -> list[schemas_ticketing.TicketComplete]: +) -> list[schemas_ticketing.TicketSimple]: """Get all tickets.""" return await cruds_ticketing.get_tickets(db=db) @@ -547,13 +550,13 @@ async def get_all_tickets( @module.router.get( "/ticketing/users/{user_id}/tickets/", summary="Get all tickets for a user", - response_model=list[schemas_ticketing.TicketComplete], + response_model=list[schemas_ticketing.TicketSimple], status_code=200, ) async def get_tickets_by_user( user_id: str, db: AsyncSession = Depends(get_db), -) -> list[schemas_ticketing.TicketComplete]: +) -> list[schemas_ticketing.TicketSimple]: """Get all tickets for a user.""" return await cruds_ticketing.get_tickets_by_user_id(user_id=user_id, db=db) @@ -561,13 +564,13 @@ async def get_tickets_by_user( @module.router.get( "/ticketing/users/me/tickets/", summary="Get all tickets for a user", - response_model=list[schemas_ticketing.TicketComplete], + response_model=list[schemas_ticketing.TicketSimple], status_code=200, ) async def get_my_tickets( db: AsyncSession = Depends(get_db), user: models_users.CoreUser = Depends(is_user()), -) -> list[schemas_ticketing.TicketComplete]: +) -> list[schemas_ticketing.TicketSimple]: """Get all tickets for a user.""" return await cruds_ticketing.get_tickets_by_user_id(user_id=user.id, db=db) @@ -575,7 +578,7 @@ async def get_my_tickets( @module.router.post( "/ticketing/tickets/", summary="Create a new ticket", - response_model=schemas_ticketing.TicketComplete, + response_model=schemas_ticketing.TicketSimple, status_code=201, ) async def create_ticket( @@ -583,7 +586,7 @@ async def create_ticket( db: AsyncSession = Depends(get_db), user: models_users.CoreUser = Depends(is_user()), redis_client: Redis | None = Depends(get_redis_client), -) -> schemas_ticketing.TicketComplete: +) -> schemas_ticketing.TicketSimple: """Create a new ticket.""" ticket_simple = schemas_ticketing.TicketSimple( **ticket.model_dump(), @@ -628,22 +631,12 @@ async def create_ticket( await cruds_ticketing.create_ticket(ticket=ticket_simple, db=db) # TODO: Add redis cache update for event quota - if isinstance(redis_client, Redis): - cache_ticketing.increment_quota_event( - redis=redis_client, - event_id=ticket_simple.event_id, - amount=1, - ) - cache_ticketing.increment_quota_category( - redis=redis_client, - category_id=ticket_simple.category_id, - amount=1, - ) - cache_ticketing.increment_quota_session( - redis=redis_client, - session_id=ticket_simple.session_id, - amount=1, - ) + 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, diff --git a/app/modules/ticketing/schemas_ticketing.py b/app/modules/ticketing/schemas_ticketing.py index 1a7da4d913..6587d52229 100644 --- a/app/modules/ticketing/schemas_ticketing.py +++ b/app/modules/ticketing/schemas_ticketing.py @@ -143,6 +143,6 @@ class TicketSimple(TicketBase): class TicketComplete(TicketSimple): event: EventSimple category: CategorySimple - session: SessionSimple + session: SessionSimple | None model_config = ConfigDict(from_attributes=True) From 7b936cf3f3402aadefac3f0187c3c1f6c6c8b602 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:55:54 +0200 Subject: [PATCH 39/50] Fix every mypy, ruff errors --- app/modules/ticketing/cache_ticketing.py | 15 +-- app/modules/ticketing/cruds_ticketing.py | 4 +- app/modules/ticketing/endpoints_ticketing.py | 4 +- tests/modules/test_ticketing.py | 123 ++++++++++++++++++- 4 files changed, 136 insertions(+), 10 deletions(-) diff --git a/app/modules/ticketing/cache_ticketing.py b/app/modules/ticketing/cache_ticketing.py index d8b7970af4..e46495fd51 100644 --- a/app/modules/ticketing/cache_ticketing.py +++ b/app/modules/ticketing/cache_ticketing.py @@ -1,7 +1,8 @@ # Redis Cache for Ticketing Module import logging -from typing import Callable, ParamSpec, TypeVar +from collections.abc import Awaitable, Callable +from typing import ParamSpec, TypeVar from uuid import UUID from pydantic import BaseModel @@ -30,18 +31,19 @@ def session_quota(session_id: UUID) -> str: return f"ticketing:session:{session_id}:quota" -def use_or_set_cache_with_crud( +async def use_or_set_cache_with_crud( redis: Redis | None, key: str, - crud_func: Callable[CrudFuncT, SchemaT], + 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 crud_func(*args, **kwargs) + return await crud_func(*args, **kwargs) cached_value: str | bytes | None = redis.get(key) if cached_value is not None: try: @@ -55,8 +57,8 @@ def use_or_set_cache_with_crud( ) redis.delete(key) - value = crud_func(*args, **kwargs) - redis.set(key, value.model_dump_json()) + value = await crud_func(*args, **kwargs) + redis.set(key, value.model_dump_json(), ex=expire) return value @@ -90,4 +92,3 @@ def update_cache_for_new_ticket( invalidate_key_cache(redis, RedisKeysList.category_quota(category_id)) if session_id is not None: invalidate_key_cache(redis, RedisKeysList.session_quota(session_id)) - \ No newline at end of file diff --git a/app/modules/ticketing/cruds_ticketing.py b/app/modules/ticketing/cruds_ticketing.py index 2c6f6957b9..9ec9db6985 100644 --- a/app/modules/ticketing/cruds_ticketing.py +++ b/app/modules/ticketing/cruds_ticketing.py @@ -729,7 +729,9 @@ async def get_ticket_by_id( user_quota=ticket.session.user_quota, used_quota=ticket.session.used_quota, disabled=ticket.session.disabled, - ) if ticket.session else None, + ) + if ticket.session + else None, category=schemas_ticketing.CategorySimple( id=ticket.category.id, event_id=ticket.category.event_id, diff --git a/app/modules/ticketing/endpoints_ticketing.py b/app/modules/ticketing/endpoints_ticketing.py index 07d46d4315..ddb19cc490 100644 --- a/app/modules/ticketing/endpoints_ticketing.py +++ b/app/modules/ticketing/endpoints_ticketing.py @@ -59,7 +59,9 @@ async def get_organiser( """ Get an Organiser by its id. """ - organiser = await cruds_ticketing.get_organiser_by_id(db=db, organiser_id=organiser_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 diff --git a/tests/modules/test_ticketing.py b/tests/modules/test_ticketing.py index f43c1d6dde..c7ffcbbddb 100644 --- a/tests/modules/test_ticketing.py +++ b/tests/modules/test_ticketing.py @@ -1,3 +1,4 @@ +import asyncio from datetime import UTC, datetime from uuid import uuid4 @@ -10,7 +11,7 @@ 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 +from app.modules.ticketing import models_ticketing, schemas_ticketing # We need to import event_loop for pytest-asyncio routine defined bellow from app.modules.ticketing.endpoints_ticketing import TicketingPermissions @@ -44,6 +45,7 @@ session1: models_ticketing.TicketingSession session2: models_ticketing.TicketingSession +session3: models_ticketing.TicketingSession category1: models_ticketing.TicketingCategory @@ -978,3 +980,122 @@ async def test_delete_category_with_invalid_id(client: TestClient): headers={"Authorization": f"Bearer {admin_user_token}"}, ) assert response.status_code == 404 + + +# -------------------------- Test ticket basic cruds -------------------------- # + + +# -------------------------- Test Redis cache -------------------------- # + + +async def test_get_event_cache(client: TestClient): + # First get the event to populate the cache + response = client.get( + f"/ticketing/events/{event1.id}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 200 + + # Now get the event again, this time it should be served from cache + response = client.get( + f"/ticketing/events/{event1.id}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 200 + + +async def test_use_or_set_cache_with_crud_function(client: TestClient): + # This test is to directly test the get_or_set_cache function used in the endpoints + from app.modules.ticketing.cache_ticketing import use_or_set_cache_with_crud + + cache_key = f"test_cache_event_key_{uuid4()}" + cache_value = schemas_ticketing.EventSimple( + id=uuid4(), + name="Cached Event", + open_date=datetime(2024, 1, 1, tzinfo=UTC), + close_date=datetime(2200, 12, 31, tzinfo=UTC), + quota=10, + user_quota=2, + used_quota=0, + disabled=False, + creator_id=str(admin_user.id), + organiser_id=organiser.id, + ) + + async def test_crud_function(event_name: str = "Cached - Event"): + # This function simulates a CRUD operation that returns the cache value + cache_value.name = event_name + return cache_value + + # First call should set the cache + result = await use_or_set_cache_with_crud( + None, + cache_key, + test_crud_function, + schemas_ticketing.EventSimple, + expire=60, + event_name="Cached - Event", + ) + assert result == cache_value + + # Second call should get the value from cache, so we change the event name + result = await use_or_set_cache_with_crud( + None, + cache_key, + test_crud_function, + schemas_ticketing.EventSimple, + event_name="Changed Cached - Event", + expire=60, # Invert arg in order to be sure. + ) + assert result == cache_value # Should still return the old value from cache + + +async def test_cache_expiration(client: TestClient): + # This test will check if the cache is invalidated after updating an event + from app.modules.ticketing.cache_ticketing import use_or_set_cache_with_crud + + cache_key = f"test_cache_event_key_{uuid4()}" + cache_value = schemas_ticketing.EventSimple( + id=uuid4(), + name="Cached Event", + open_date=datetime(2024, 1, 1, tzinfo=UTC), + close_date=datetime(2200, 12, 31, tzinfo=UTC), + quota=10, + user_quota=2, + used_quota=0, + disabled=False, + creator_id=str(admin_user.id), + organiser_id=organiser.id, + ) + + async def test_crud_function(event_name: str = "Cached - Event"): + # This function simulates a CRUD operation that returns the cache value + cache_value.name = event_name + return cache_value + + # Set the cache + result = await use_or_set_cache_with_crud( + None, + cache_key, + test_crud_function, + schemas_ticketing.EventSimple, + expire=2, # expire in 2 seconds + event_name="Cached - Event", + ) # expire in 2 seconds + assert result == cache_value + + # Wait for 3 seconds to let the cache expire + await asyncio.sleep(3) + + # Now the cache should be expired, so calling the function should set it again with the new name + result = await use_or_set_cache_with_crud( + None, + cache_key, + test_crud_function, + schemas_ticketing.EventSimple, + expire=60, + event_name="New Cached - Event", + ) + assert ( + result.name == "New Cached - Event" + ) # Should return the new value after cache expiration From b22b22ef41dcadb2483dadf89e2f2169375c2fd1 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Mon, 27 Apr 2026 18:01:58 +0200 Subject: [PATCH 40/50] Wtf ruff format != check --- app/modules/ticketing/cache_ticketing.py | 1 - app/modules/ticketing/endpoints_ticketing.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/modules/ticketing/cache_ticketing.py b/app/modules/ticketing/cache_ticketing.py index e46495fd51..90f55424a0 100644 --- a/app/modules/ticketing/cache_ticketing.py +++ b/app/modules/ticketing/cache_ticketing.py @@ -8,7 +8,6 @@ from pydantic import BaseModel from redis import Redis - hyperion_error_logger = logging.getLogger("hyperion.error") SchemaT = TypeVar("SchemaT", bound=BaseModel) diff --git a/app/modules/ticketing/endpoints_ticketing.py b/app/modules/ticketing/endpoints_ticketing.py index ddb19cc490..c58d09cc72 100644 --- a/app/modules/ticketing/endpoints_ticketing.py +++ b/app/modules/ticketing/endpoints_ticketing.py @@ -60,7 +60,7 @@ async def get_organiser( Get an Organiser by its id. """ organiser = await cruds_ticketing.get_organiser_by_id( - db=db, organiser_id=organiser_id + db=db, organiser_id=organiser_id, ) if organiser is None: raise HTTPException(status_code=404, detail="Organiser not found") From dff8365becdfe3398227568ea61b125fa04fc437 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Mon, 27 Apr 2026 18:04:10 +0200 Subject: [PATCH 41/50] Ruff is broken Grr --- app/modules/ticketing/endpoints_ticketing.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/modules/ticketing/endpoints_ticketing.py b/app/modules/ticketing/endpoints_ticketing.py index c58d09cc72..88beb033e1 100644 --- a/app/modules/ticketing/endpoints_ticketing.py +++ b/app/modules/ticketing/endpoints_ticketing.py @@ -60,7 +60,8 @@ async def get_organiser( Get an Organiser by its id. """ organiser = await cruds_ticketing.get_organiser_by_id( - db=db, organiser_id=organiser_id, + db=db, + organiser_id=organiser_id, ) if organiser is None: raise HTTPException(status_code=404, detail="Organiser not found") From 8f26070ce8cf13b3eebe9ebe5645d3f72c1a422f Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Mon, 1 Jun 2026 18:22:02 +0200 Subject: [PATCH 42/50] WIP tickets --- app/modules/ticketing/cruds_ticketing.py | 53 ++ app/modules/ticketing/endpoints_ticketing.py | 88 +++- tests/modules/test_ticketing.py | 511 +++++++++++++++++++ 3 files changed, 651 insertions(+), 1 deletion(-) diff --git a/app/modules/ticketing/cruds_ticketing.py b/app/modules/ticketing/cruds_ticketing.py index 9ec9db6985..af42cb7e3c 100644 --- a/app/modules/ticketing/cruds_ticketing.py +++ b/app/modules/ticketing/cruds_ticketing.py @@ -627,6 +627,59 @@ async def get_tickets( ] +async def get_tickets_by_event_id( + event_id: UUID, + db: AsyncSession, +) -> list[schemas_ticketing.TicketSimple]: + """Get all tickets for a specific event.""" + + tickets = await db.execute( + select(models_ticketing.TicketingTicket).where( + models_ticketing.TicketingTicket.event_id == event_id, + ), + ) + return [ + schemas_ticketing.TicketSimple( + id=ticket.id, + user_id=ticket.user_id, + event_id=ticket.event_id, + category_id=ticket.category_id, + session_id=ticket.session_id, + total=ticket.total, + created_at=ticket.created_at, + status=ticket.status, + nb_scan=ticket.nb_scan, + ) + for ticket in tickets.scalars().all() + ] + + +async def get_tickets_by_category_id( + category_id: UUID, + db: AsyncSession, +) -> list[schemas_ticketing.TicketSimple]: + """Get all tickets for a specific category.""" + tickets = await db.execute( + select(models_ticketing.TicketingTicket).where( + models_ticketing.TicketingTicket.category_id == category_id, + ), + ) + return [ + schemas_ticketing.TicketSimple( + id=ticket.id, + user_id=ticket.user_id, + event_id=ticket.event_id, + category_id=ticket.category_id, + session_id=ticket.session_id, + total=ticket.total, + created_at=ticket.created_at, + status=ticket.status, + nb_scan=ticket.nb_scan, + ) + for ticket in tickets.scalars().all() + ] + + async def get_tickets_by_session_id( session_id: UUID, db: AsyncSession, diff --git a/app/modules/ticketing/endpoints_ticketing.py b/app/modules/ticketing/endpoints_ticketing.py index 88beb033e1..3438670f24 100644 --- a/app/modules/ticketing/endpoints_ticketing.py +++ b/app/modules/ticketing/endpoints_ticketing.py @@ -1,9 +1,11 @@ +from datetime import UTC, datetime from uuid import UUID, uuid4 from fastapi import APIRouter, Depends, HTTPException from redis import Redis from sqlalchemy.ext.asyncio import AsyncSession +from app.core.groups.groups_type import GroupType from app.core.permissions.type_permissions import ModulePermissions from app.core.users import models_users from app.dependencies import get_db, get_redis_client, is_user, is_user_allowed_to @@ -529,11 +531,22 @@ async def delete_category( async def get_ticket_by_id( ticket_id: UUID, db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends( + is_user_allowed_to([TicketingPermissions.access_ticketing]), + ), ) -> schemas_ticketing.TicketComplete | None: """Get a ticket by its ID.""" ticket = await cruds_ticketing.get_ticket_by_id(ticket_id=ticket_id, db=db) if ticket is None: raise HTTPException(status_code=404, detail="Ticket not found") + # Allow access if it's the user's own ticket or if they're an admin + if ticket.user_id != user.id and GroupType.admin not in [ + group.id for group in user.groups + ]: + raise HTTPException( + status_code=404, + detail="Ticket not found", + ) return ticket @@ -550,6 +563,54 @@ async def get_all_tickets( return await cruds_ticketing.get_tickets(db=db) +@module.router.get( + "/ticketing/events/{event_id}/tickets/", + summary="Get all tickets for an event", + response_model=list[schemas_ticketing.TicketSimple], + status_code=200, +) +async def get_tickets_by_event( + event_id: UUID, + db: AsyncSession = Depends(get_db), +) -> list[schemas_ticketing.TicketSimple]: + """Get all tickets for an event.""" + return await cruds_ticketing.get_tickets_by_event_id(event_id=event_id, db=db) + + +@module.router.get( + "/ticketing/sessions/{session_id}/tickets/", + summary="Get all tickets for a session", + response_model=list[schemas_ticketing.TicketSimple], + status_code=200, +) +async def get_tickets_by_session( + session_id: UUID, + db: AsyncSession = Depends(get_db), +) -> list[schemas_ticketing.TicketSimple]: + """Get all tickets for a session.""" + return await cruds_ticketing.get_tickets_by_session_id( + session_id=session_id, + db=db, + ) + + +@module.router.get( + "/ticketing/categories/{category_id}/tickets/", + summary="Get all tickets for a category", + response_model=list[schemas_ticketing.TicketSimple], + status_code=200, +) +async def get_tickets_by_category( + category_id: UUID, + db: AsyncSession = Depends(get_db), +) -> list[schemas_ticketing.TicketSimple]: + """Get all tickets for a category.""" + return await cruds_ticketing.get_tickets_by_category_id( + category_id=category_id, + db=db, + ) + + @module.router.get( "/ticketing/users/{user_id}/tickets/", summary="Get all tickets for a user", @@ -591,12 +652,21 @@ async def create_ticket( redis_client: Redis | None = Depends(get_redis_client), ) -> schemas_ticketing.TicketSimple: """Create a new ticket.""" + + if user.id != ticket.user_id and not await is_user_allowed_to( + [TicketingPermissions.manage_events], + )(user): + raise HTTPException( + status_code=403, + detail="Users can only create tickets for themselves", + ) + ticket_simple = schemas_ticketing.TicketSimple( **ticket.model_dump(), id=uuid4(), - user_id=user.id, status="pending", nb_scan=0, + created_at=datetime.now(UTC), ) # Verify that the event, category and session exist before creating the ticket to prevent creating tickets for non existing entities @@ -619,6 +689,22 @@ async def create_ticket( 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", + ) + # TODO: Verify that the quota is not already full before creating the ticket to prevent overbooking in case of concurrent ticket purchases across multiple workers # First with redis cache and then with database queries as fallback if redis is not available if isinstance(redis_client, Redis): diff --git a/tests/modules/test_ticketing.py b/tests/modules/test_ticketing.py index c7ffcbbddb..e1bef1abcd 100644 --- a/tests/modules/test_ticketing.py +++ b/tests/modules/test_ticketing.py @@ -49,6 +49,9 @@ category1: models_ticketing.TicketingCategory +ticket1: models_ticketing.TicketingTicket +ticket2: models_ticketing.TicketingTicket + student_token: str admin_token: str @@ -223,9 +226,69 @@ async def init_objects(): ) student_token = create_api_access_token(student_user) + global ticket1, ticket2 + ticket1 = models_ticketing.TicketingTicket( + id=uuid4(), + event_id=event1.id, + session_id=session1.id, + category_id=category1.id, + user_id=student_user.id, + status="active", + nb_scan=0, + total=1, + created_at=datetime.now(UTC), + ) + await add_object_to_db(ticket1) + + ticket2 = models_ticketing.TicketingTicket( + id=uuid4(), + event_id=event1.id, + session_id=session2.id, + category_id=category1.id, + user_id=student_user.id, + status="active", + nb_scan=0, + total=1, + created_at=datetime.now(UTC), + ) + await add_object_to_db(ticket2) + # Units tests for basic CRUD operations on events, sessions and categories. +# -------------------------- Test organiser endpoints -------------------------- # + + +async def test_get_organisers_list(client: TestClient): + response = client.get( + "/ticketing/organisers", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 200 + organisers = response.json() + assert isinstance(organisers, list) + assert len(organisers) >= 1 + assert any( + organiser_item["id"] == str(organiser.id) for organiser_item in organisers + ) + + +async def test_get_organiser(client: TestClient): + response = client.get( + f"/ticketing/organisers/{organiser.id}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 200 + organiser_response = response.json() + assert organiser_response["id"] == str(organiser.id) + + response = client.get( + f"/ticketing/organisers/{uuid4()}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 404 + + # -------------------------- Test event basic cruds -------------------------- # @@ -985,6 +1048,454 @@ async def test_delete_category_with_invalid_id(client: TestClient): # -------------------------- Test ticket basic cruds -------------------------- # +async def test_get_all_tickets(client: TestClient): + response = client.get( + "/ticketing/tickets", + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 200 + tickets = response.json() + assert isinstance(tickets, list) + assert len(tickets) >= 2 # We created 2 tickets for the student + + +async def test_get_tickets_by_event(client: TestClient): + response = client.get( + f"/ticketing/events/{event1.id}/tickets", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 200 + tickets = response.json() + assert isinstance(tickets, list) + assert len(tickets) >= 2 # We created 2 tickets for event1 + + response_fake_event = client.get( + f"/ticketing/events/{uuid4()}/tickets", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response_fake_event.status_code == 200 + assert ( + response_fake_event.json() == [] + ) # No tickets for fake event, should return empty list + + +async def test_get_tickets_by_session(client: TestClient): + response = client.get( + f"/ticketing/sessions/{session1.id}/tickets", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 200 + tickets = response.json() + assert isinstance(tickets, list) + assert len(tickets) >= 2 # We created 2 tickets for session1 + + response_fake_session = client.get( + f"/ticketing/sessions/{uuid4()}/tickets", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response_fake_session.status_code == 200 + assert ( + response_fake_session.json() == [] + ) # No tickets for fake session, should return empty list + + +async def test_get_tickets_by_category(client: TestClient): + response = client.get( + f"/ticketing/categories/{category1.id}/tickets", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 200 + tickets = response.json() + assert isinstance(tickets, list) + assert len(tickets) >= 2 # We created 2 tickets for category1 + + response_fake_category = client.get( + f"/ticketing/categories/{uuid4()}/tickets", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response_fake_category.status_code == 200 + assert ( + response_fake_category.json() == [] + ) # No tickets for fake category, should return empty list + + +async def test_get_tickets_by_user(client: TestClient): + response = client.get( + f"/ticketing/users/{student_user.id}/tickets", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 200 + tickets = response.json() + assert isinstance(tickets, list) + assert len(tickets) >= 2 # We created 2 tickets for the student + + response_fake_user = client.get( + f"/ticketing/users/{uuid4()}/tickets", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response_fake_user.status_code == 200 + assert ( + response_fake_user.json() == [] + ) # No tickets for fake user, should return empty list + + +async def test_get_my_tickets(client: TestClient): + response = client.get( + "/ticketing/users/me/tickets", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 200 + tickets = response.json() + assert isinstance(tickets, list) + assert all(ticket["user_id"] == str(student_user.id) for ticket in tickets) + + +async def test_get_ticket(client: TestClient): + # Test with ticket1 (should succeed) + response = client.get( + f"/ticketing/tickets/{ticket1.id}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 200 + + # Test with ticket_fake (not in DB, should return 404) + response = client.get( + f"/ticketing/tickets/{uuid4()}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 404 + + +async def test_get_ticket_without_perms(client: TestClient): + # Create a ticket for another user + other_user = await create_user_with_groups( + groups=[], + account_type=AccountType.student, + ) + other_ticket = models_ticketing.TicketingTicket( + id=uuid4(), + event_id=event1.id, + session_id=session1.id, + category_id=category1.id, + user_id=other_user.id, + status="active", + nb_scan=0, + total=1, + created_at=datetime.now(UTC), + ) + await add_object_to_db(other_ticket) + + # Try to get the other user's ticket, should return 404 since the student doesn't have permission to see it + response = client.get( + f"/ticketing/tickets/{other_ticket.id}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 404 + + +async def test_get_ticket_as_admin(client: TestClient): + # Create a ticket for another user + other_user = await create_user_with_groups( + groups=[], + account_type=AccountType.student, + ) + other_ticket = models_ticketing.TicketingTicket( + id=uuid4(), + event_id=event1.id, + session_id=session1.id, + category_id=category1.id, + user_id=other_user.id, + status="active", + nb_scan=0, + total=1, + created_at=datetime.now(UTC), + ) + await add_object_to_db(other_ticket) + + # Try to get the other user's ticket as admin, should succeed + response = client.get( + f"/ticketing/tickets/{other_ticket.id}", + headers={"Authorization": f"Bearer {admin_user_token}"}, + ) + assert response.status_code == 200 + + +async def test_create_ticket_as_student(client: TestClient): + new_ticket_data = { + "user_id": str(student_user.id), + "event_id": str(event1.id), + "session_id": str(session1.id), + "category_id": str(category1.id), + "total": 1, + } + response = client.post( + "/ticketing/tickets", + json=new_ticket_data, + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 201 + created_ticket = response.json() + assert created_ticket["event_id"] == new_ticket_data["event_id"] + assert created_ticket["session_id"] == new_ticket_data["session_id"] + assert created_ticket["category_id"] == new_ticket_data["category_id"] + assert created_ticket["total"] == new_ticket_data["total"] + + +async def test_create_ticket_as_student_with_invalid_data(client: TestClient): + # Try to create a ticket with invalid session_id (not linked to the category), should fail + new_ticket_data = { + "user_id": str(student_user.id), + "event_id": str(event1.id), + "session_id": str(session3.id), # session3 is not linked to category1 + "category_id": str(category1.id), + "total": 1, + } + response = client.post( + "/ticketing/tickets", + json=new_ticket_data, + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 400 + + +async def test_create_ticket_as_student_with_quota_exceeded(client: TestClient): + # Try to create a ticket with total exceeding the category quota, should fail + 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": 10, # category1 has quota=5 and used_quota=2, so only 3 tickets left + } + response = client.post( + "/ticketing/tickets", + json=new_ticket_data, + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 400 + + +async def test_create_ticket_as_student_with_user_quota_exceeded(client: TestClient): + # Try to create a ticket with total exceeding the user quota, should fail + new_event = models_ticketing.TicketingEvent( + id=uuid4(), + name="User Quota Event", + open_date=datetime(2024, 1, 1, tzinfo=UTC), + close_date=datetime(2200, 12, 31, tzinfo=UTC), + quota=10, + user_quota=1, # user_quota=1, so the student can only have 1 ticket for this event + used_quota=0, + disabled=False, + creator_id=str(admin_user.id), + organiser_id=organiser.id, + ) + await add_object_to_db(new_event) + + new_category = models_ticketing.TicketingCategory( + id=uuid4(), + event_id=new_event.id, + name="User Quota Category", + quota=10, + user_quota=1, + used_quota=0, + disabled=False, + required_mebership=None, + price=50, + ) + await add_object_to_db(new_category) + + new_session = models_ticketing.TicketingSession( + id=uuid4(), + event_id=new_event.id, + name="User Quota Session", + quota=10, + user_quota=1, + used_quota=0, + disabled=False, + date=datetime(2024, 1, 2, tzinfo=UTC), + ) + await add_object_to_db(new_session) + + # Create the first ticket, should succeed + first_ticket_data = { + "user_id": str(student_user.id), + "event_id": str(new_event.id), + "session_id": str(new_session.id), + "category_id": str(new_category.id), + "total": 1, + } + + response = client.post( + "/ticketing/tickets", + json=first_ticket_data, + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 201 + + # Create the second ticket, should fail due to user quota exceeded + second_ticket_data = { + "user_id": str(student_user.id), + "event_id": str(new_event.id), + "session_id": str(new_session.id), + "category_id": str(new_category.id), + "total": 1, + } + response = client.post( + "/ticketing/tickets", + json=second_ticket_data, + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 400 + + +async def test_create_ticket_with_category_from_different_event_or_session( + client: TestClient, +): + other_category = models_ticketing.TicketingCategory( + id=uuid4(), + event_id=event2.id, + name="Other Event Category", + quota=10, + user_quota=1, + used_quota=0, + disabled=False, + required_mebership=None, + price=30, + ) + await add_object_to_db(other_category) + + new_ticket_data = { + "user_id": str(student_user.id), + "event_id": str(event1.id), + "session_id": str(session1.id), + "category_id": str(other_category.id), + "total": 1, + } + response = client.post( + "/ticketing/tickets", + json=new_ticket_data, + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 400 + + +async def test_create_ticket_with_session_from_different_event(client: TestClient): + other_session = models_ticketing.TicketingSession( + id=uuid4(), + event_id=event2.id, + name="Other Event Session", + quota=2, + user_quota=1, + used_quota=0, + disabled=False, + date=datetime(2024, 1, 4, tzinfo=UTC), + ) + await add_object_to_db(other_session) + + new_ticket_data = { + "user_id": str(student_user.id), + "event_id": str(event1.id), + "session_id": str(other_session.id), + "category_id": str(category1.id), + "total": 1, + } + response = client.post( + "/ticketing/tickets", + json=new_ticket_data, + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 400 + + +async def test_update_ticket(client: TestClient): + to_update_ticket = models_ticketing.TicketingTicket( + id=uuid4(), + event_id=event1.id, + session_id=session1.id, + category_id=category1.id, + user_id=student_user.id, + status="active", + nb_scan=0, + total=1, + created_at=datetime.now(UTC), + ) + await add_object_to_db(to_update_ticket) + + update_data = { + "user_id": str(student_user.id), + "event_id": str(event1.id), + "session_id": str(session1.id), + "category_id": str(category1.id), + "total": 2, + } + response = client.patch( + f"/ticketing/tickets/{to_update_ticket.id}", + json=update_data, + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 204 + + response = client.get( + f"/ticketing/tickets/{to_update_ticket.id}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 200 + assert response.json()["total"] == update_data["total"] + + +async def test_update_ticket_with_invalid_id(client: TestClient): + update_data = { + "user_id": str(student_user.id), + "event_id": str(event1.id), + "session_id": str(session1.id), + "category_id": str(category1.id), + "total": 2, + } + response = client.patch( + f"/ticketing/tickets/{uuid4()}", + json=update_data, + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 404 + + +async def test_delete_ticket(client: TestClient): + to_delete_ticket = models_ticketing.TicketingTicket( + id=uuid4(), + event_id=event1.id, + session_id=session1.id, + category_id=category1.id, + user_id=student_user.id, + status="active", + nb_scan=0, + total=1, + created_at=datetime.now(UTC), + ) + await add_object_to_db(to_delete_ticket) + response = client.delete( + f"/ticketing/tickets/{to_delete_ticket.id}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 204 + + response = client.get( + f"/ticketing/tickets/{to_delete_ticket.id}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 404 + + +async def test_delete_ticket_with_invalid_id(client: TestClient): + response = client.delete( + f"/ticketing/tickets/{uuid4()}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 404 + + +# -------------------------- Test number of places -------------------------- # + + # -------------------------- Test Redis cache -------------------------- # From bee5bbb6b2e244eefdb7215bceade6c1a9fb68d7 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Sat, 6 Jun 2026 02:25:33 +0200 Subject: [PATCH 43/50] Adding the remaining quota (with cache) --- app/modules/ticketing/cache_ticketing.py | 129 +++++++++++++++-- app/modules/ticketing/cruds_ticketing.py | 63 +++++++++ app/modules/ticketing/endpoints_ticketing.py | 141 +++++++++++++++++-- tests/modules/test_ticketing.py | 35 ++++- 4 files changed, 340 insertions(+), 28 deletions(-) diff --git a/app/modules/ticketing/cache_ticketing.py b/app/modules/ticketing/cache_ticketing.py index 90f55424a0..7bdbf2fc9f 100644 --- a/app/modules/ticketing/cache_ticketing.py +++ b/app/modules/ticketing/cache_ticketing.py @@ -7,6 +7,9 @@ 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") @@ -18,16 +21,40 @@ class RedisKeysList: """List of Redis keys used in the ticketing module.""" @staticmethod - def event_quota(event_id: UUID) -> str: + def event_remaining_quota(event_id: UUID) -> str: return f"ticketing:event:{event_id}:quota" @staticmethod - def category_quota(category_id: UUID) -> str: + def category_remaining_quota(category_id: UUID) -> str: return f"ticketing:category:{category_id}:quota" @staticmethod - def session_quota(session_id: UUID) -> str: + def session_remaining_quota(session_id: UUID) -> str: return f"ticketing:session:{session_id}:quota" + + @staticmethod + def events() -> str: + return "ticketing:events" + + @staticmethod + def event(event_id: UUID) -> str: + return f"ticketing:event:{event_id}" + + # @staticmethod + # def categories(event_id: UUID) -> str: + # return f"ticketing:event:{event_id}:categories" + + # @staticmethod + # def category(category_id: UUID) -> str: + # return f"ticketing:category:{category_id}" + + # @staticmethod + # def sessions(category_id: UUID) -> str: + # return f"ticketing:category:{category_id}:sessions" + + # @staticmethod + # def session(session_id: UUID) -> str: + # return f"ticketing:session:{session_id}" async def use_or_set_cache_with_crud( @@ -61,6 +88,36 @@ async def use_or_set_cache_with_crud( return value +async def use_or_set_cache_with_crud_int( + redis: Redis | None, + key: str, + crud_func: Callable[CrudFuncT, Awaitable[int]], + expire: int | None = 300, + *args: CrudFuncT.args, + **kwargs: CrudFuncT.kwargs, +) -> int: + """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, 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): @@ -78,16 +135,68 @@ def update_cache_for_new_ticket( 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_quota(event_id)) - increment_key_cache(redis, RedisKeysList.category_quota(category_id)) + 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_quota(session_id)) + 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.event_quota(event_id)) - invalidate_key_cache(redis, RedisKeysList.category_quota(category_id)) - if session_id is not None: - invalidate_key_cache(redis, RedisKeysList.session_quota(session_id)) + #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, + ) \ No newline at end of file diff --git a/app/modules/ticketing/cruds_ticketing.py b/app/modules/ticketing/cruds_ticketing.py index af42cb7e3c..91255581b6 100644 --- a/app/modules/ticketing/cruds_ticketing.py +++ b/app/modules/ticketing/cruds_ticketing.py @@ -175,6 +175,27 @@ async def get_event_by_id( ) +async def get_event_remaining_quota( + db: AsyncSession, + event_id: UUID, +) -> int | None: + """Get the remaining quota for an event.""" + + event = ( + ( + await db.execute( + select(models_ticketing.TicketingEvent.quota - models_ticketing.TicketingEvent.used_quota).where( + models_ticketing.TicketingEvent.id == event_id, + ), + ) + ) + .scalars() + .first() + ) + + return event + + async def get_event_by_name( db: AsyncSession, name: str, @@ -344,6 +365,27 @@ async def get_session_by_id( ) +async def get_session_remaining_quota( + db: AsyncSession, + session_id: UUID, +) -> int | None: + """Get the remaining quota for a session.""" + + session = ( + ( + await db.execute( + select(models_ticketing.TicketingSession.quota - models_ticketing.TicketingSession.used_quota).where( + models_ticketing.TicketingSession.id == session_id, + ), + ) + ) + .scalars() + .first() + ) + + return session + + async def get_sessions_by_ids( session_ids: list[UUID], db: AsyncSession, @@ -482,6 +524,27 @@ async def get_category_by_id( ) +async def get_category_remaining_quota( + db: AsyncSession, + category_id: UUID, +) -> int | None: + """Get the remaining quota for a category.""" + + category = ( + ( + await db.execute( + select(models_ticketing.TicketingCategory.quota - models_ticketing.TicketingCategory.used_quota).where( + models_ticketing.TicketingCategory.id == category_id, + ), + ) + ) + .scalars() + .first() + ) + + return category + + async def get_categories_by_session_id( session_id: UUID, db: AsyncSession, diff --git a/app/modules/ticketing/endpoints_ticketing.py b/app/modules/ticketing/endpoints_ticketing.py index 3438670f24..0db23b14dc 100644 --- a/app/modules/ticketing/endpoints_ticketing.py +++ b/app/modules/ticketing/endpoints_ticketing.py @@ -105,6 +105,28 @@ async def get_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", @@ -233,6 +255,28 @@ async def get_session_by_id( 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", @@ -388,6 +432,28 @@ async def get_category_by_id( 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", @@ -669,6 +735,29 @@ async def create_ticket( created_at=datetime.now(UTC), ) + # Verify quota from cache given event_id, category_id and session_id to prevent overbooking in case of concurrent ticket purchases across multiple workers + event_quota = await cache_ticketing.get_event_remaining_quota_with_cache( + redis=redis_client, + db=db, + event_id=ticket_simple.event_id, + ) + category_quota = await cache_ticketing.get_category_remaining_quota_with_cache( + redis=redis_client, + db=db, + category_id=ticket_simple.category_id, + ) + session_quota = await cache_ticketing.get_session_remaining_quota_with_cache( + redis=redis_client, + db=db, + session_id=ticket_simple.session_id, + ) + if event_quota is not None and event_quota <= 0: + raise HTTPException(status_code=400, detail="Event quota exceeded") + if category_quota is not None and category_quota <= 0: + raise HTTPException(status_code=400, detail="Category quota exceeded") + if session_quota is not None and session_quota <= 0: + raise HTTPException(status_code=400, detail="Session quota exceeded") + # Verify that the event, category and session exist before creating the ticket to prevent creating tickets for non existing entities event = await cruds_ticketing.get_event_by_id( event_id=ticket_simple.event_id, @@ -704,18 +793,41 @@ async def create_ticket( status_code=400, detail="Session is not available for category", ) + + - # TODO: Verify that the quota is not already full before creating the ticket to prevent overbooking in case of concurrent ticket purchases across multiple workers - # First with redis cache and then with database queries as fallback if redis is not available - if isinstance(redis_client, Redis): - pass - else: - if event.quota is not None and event.used_quota >= event.quota: - raise HTTPException(status_code=400, detail="Event quota exceeded") - if category.quota is not None and category.used_quota >= category.quota: - raise HTTPException(status_code=400, detail="Category quota exceeded") - if session.quota is not None and session.used_quota >= session.quota: - raise HTTPException(status_code=400, detail="Session quota exceeded") + # 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 + ] + print("user_event_tickets", len(user_event_tickets)) + user_category_tickets = [ + ticket for ticket in user_tickets if ticket.category_id == ticket_simple.category_id + ] + print("user_category_tickets", len(user_category_tickets)) + user_session_tickets = [ + ticket for ticket in user_tickets if ticket.session_id == ticket_simple.session_id + ] + print("user_session_tickets", len(user_session_tickets)) + if event.user_quota is not None and len(user_event_tickets) >= event.user_quota: + raise HTTPException( + status_code=400, + detail="User event quota exceeded", + ) + if category.user_quota is not None and len(user_category_tickets) >= category.user_quota: + raise HTTPException( + status_code=400, + detail="User category quota exceeded", + ) + if session.user_quota is not None and len(user_session_tickets) >= session.user_quota: + raise HTTPException( + status_code=400, + detail="User session quota exceeded", + ) await cruds_ticketing.create_ticket(ticket=ticket_simple, db=db) @@ -746,6 +858,13 @@ async def create_ticket( if ticket_complete is None: await db.rollback() + await cache_ticketing.update_cache_for_deleted_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 diff --git a/tests/modules/test_ticketing.py b/tests/modules/test_ticketing.py index e1bef1abcd..f7420b968e 100644 --- a/tests/modules/test_ticketing.py +++ b/tests/modules/test_ticketing.py @@ -133,8 +133,8 @@ async def init_objects(): 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=4, - user_quota=2, + quota=100, + user_quota=20, used_quota=1, disabled=False, creator_id=str(admin_user.id), @@ -162,8 +162,8 @@ async def init_objects(): id=uuid4(), event_id=event1.id, name="Session 1", - quota=2, - user_quota=1, + quota=100, + user_quota=20, used_quota=1, disabled=False, date=datetime(2024, 1, 1, tzinfo=UTC), @@ -199,7 +199,7 @@ async def init_objects(): event_id=event1.id, name="Category 1", quota=5, - user_quota=1, + user_quota=20, used_quota=2, disabled=False, required_mebership=None, @@ -1260,13 +1260,34 @@ async def test_create_ticket_as_student_with_invalid_data(client: TestClient): 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(category1.id), - "total": 10, # category1 has quota=5 and used_quota=2, so only 3 tickets left + "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, From b41b0d9987f6f46db40ddd29a021ff4636637f74 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Sat, 6 Jun 2026 02:36:03 +0200 Subject: [PATCH 44/50] Lint and format --- app/modules/ticketing/cache_ticketing.py | 98 +++++++++++--------- app/modules/ticketing/cruds_ticketing.py | 27 +++--- app/modules/ticketing/endpoints_ticketing.py | 25 +++-- tests/modules/test_ticketing.py | 2 +- 4 files changed, 86 insertions(+), 66 deletions(-) diff --git a/app/modules/ticketing/cache_ticketing.py b/app/modules/ticketing/cache_ticketing.py index 7bdbf2fc9f..f19bf642f5 100644 --- a/app/modules/ticketing/cache_ticketing.py +++ b/app/modules/ticketing/cache_ticketing.py @@ -1,8 +1,8 @@ # Redis Cache for Ticketing Module import logging -from collections.abc import Awaitable, Callable -from typing import ParamSpec, TypeVar +from collections.abc import Awaitable, Callable, Coroutine +from typing import Any, ParamSpec, TypeVar from uuid import UUID from pydantic import BaseModel @@ -31,15 +31,15 @@ def category_remaining_quota(category_id: UUID) -> str: @staticmethod def session_remaining_quota(session_id: UUID) -> str: return f"ticketing:session:{session_id}:quota" - + @staticmethod def events() -> str: return "ticketing:events" - + @staticmethod def event(event_id: UUID) -> str: return f"ticketing:event:{event_id}" - + # @staticmethod # def categories(event_id: UUID) -> str: # return f"ticketing:event:{event_id}:categories" @@ -47,7 +47,7 @@ def event(event_id: UUID) -> str: # @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" @@ -91,11 +91,11 @@ async def use_or_set_cache_with_crud( async def use_or_set_cache_with_crud_int( redis: Redis | None, key: str, - crud_func: Callable[CrudFuncT, Awaitable[int]], + crud_func: Callable[CrudFuncT, Coroutine[Any, Any, int | None]], expire: int | None = 300, *args: CrudFuncT.args, **kwargs: CrudFuncT.kwargs, -) -> int: +) -> 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): @@ -114,7 +114,7 @@ async def use_or_set_cache_with_crud_int( redis.delete(key) value = await crud_func(*args, **kwargs) - redis.set(key, value, ex=expire) + redis.set(key, str(value), ex=expire) return value @@ -135,68 +135,80 @@ def update_cache_for_new_ticket( 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) + 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) + 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) + 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.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 + 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, - ) + 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 + 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, - ) + 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 + 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, - ) \ No newline at end of file + redis=redis, + key=RedisKeysList.category_remaining_quota(category_id), + crud_func=cruds_ticketing.get_category_remaining_quota, + expire=6 * 3_600, + db=db, + category_id=category_id, + ) diff --git a/app/modules/ticketing/cruds_ticketing.py b/app/modules/ticketing/cruds_ticketing.py index 91255581b6..348cbbf12d 100644 --- a/app/modules/ticketing/cruds_ticketing.py +++ b/app/modules/ticketing/cruds_ticketing.py @@ -181,10 +181,13 @@ async def get_event_remaining_quota( ) -> int | None: """Get the remaining quota for an event.""" - event = ( + return ( ( await db.execute( - select(models_ticketing.TicketingEvent.quota - models_ticketing.TicketingEvent.used_quota).where( + select( + models_ticketing.TicketingEvent.quota + - models_ticketing.TicketingEvent.used_quota, + ).where( models_ticketing.TicketingEvent.id == event_id, ), ) @@ -193,8 +196,6 @@ async def get_event_remaining_quota( .first() ) - return event - async def get_event_by_name( db: AsyncSession, @@ -371,10 +372,13 @@ async def get_session_remaining_quota( ) -> int | None: """Get the remaining quota for a session.""" - session = ( + return ( ( await db.execute( - select(models_ticketing.TicketingSession.quota - models_ticketing.TicketingSession.used_quota).where( + select( + models_ticketing.TicketingSession.quota + - models_ticketing.TicketingSession.used_quota, + ).where( models_ticketing.TicketingSession.id == session_id, ), ) @@ -383,8 +387,6 @@ async def get_session_remaining_quota( .first() ) - return session - async def get_sessions_by_ids( session_ids: list[UUID], @@ -530,10 +532,13 @@ async def get_category_remaining_quota( ) -> int | None: """Get the remaining quota for a category.""" - category = ( + return ( ( await db.execute( - select(models_ticketing.TicketingCategory.quota - models_ticketing.TicketingCategory.used_quota).where( + select( + models_ticketing.TicketingCategory.quota + - models_ticketing.TicketingCategory.used_quota, + ).where( models_ticketing.TicketingCategory.id == category_id, ), ) @@ -542,8 +547,6 @@ async def get_category_remaining_quota( .first() ) - return category - async def get_categories_by_session_id( session_id: UUID, diff --git a/app/modules/ticketing/endpoints_ticketing.py b/app/modules/ticketing/endpoints_ticketing.py index 0db23b14dc..2548e60461 100644 --- a/app/modules/ticketing/endpoints_ticketing.py +++ b/app/modules/ticketing/endpoints_ticketing.py @@ -793,8 +793,6 @@ async def create_ticket( 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( @@ -804,26 +802,33 @@ async def create_ticket( user_event_tickets = [ ticket for ticket in user_tickets if ticket.event_id == ticket_simple.event_id ] - print("user_event_tickets", len(user_event_tickets)) user_category_tickets = [ - ticket for ticket in user_tickets if ticket.category_id == ticket_simple.category_id + ticket + for ticket in user_tickets + if ticket.category_id == ticket_simple.category_id ] - print("user_category_tickets", len(user_category_tickets)) user_session_tickets = [ - ticket for ticket in user_tickets if ticket.session_id == ticket_simple.session_id + ticket + for ticket in user_tickets + if ticket.session_id == ticket_simple.session_id ] - print("user_session_tickets", len(user_session_tickets)) 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: + 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: + 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", @@ -858,7 +863,7 @@ async def create_ticket( if ticket_complete is None: await db.rollback() - await cache_ticketing.update_cache_for_deleted_ticket( + await cache_ticketing.update_cache_for_new_ticket( redis=redis_client, event_id=ticket_simple.event_id, category_id=ticket_simple.category_id, diff --git a/tests/modules/test_ticketing.py b/tests/modules/test_ticketing.py index f7420b968e..fb2dd275b0 100644 --- a/tests/modules/test_ticketing.py +++ b/tests/modules/test_ticketing.py @@ -1279,7 +1279,7 @@ async def test_create_ticket_as_student_with_quota_exceeded(client: TestClient): "event_id": str(event1.id), "session_id": str(session1.id), "category_id": str(category_with_low_quota.id), - "total": 10, # price of the ticket + "total": 10, # price of the ticket } response = client.post( "/ticketing/tickets", From f9e20234bc3925e84df3e2df118d0a986fd737a8 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Sat, 6 Jun 2026 19:58:18 +0200 Subject: [PATCH 45/50] removed used quota property --- app/modules/ticketing/cruds_ticketing.py | 96 ++++++++++++++++---- app/modules/ticketing/endpoints_ticketing.py | 49 +++++----- app/modules/ticketing/schemas_ticketing.py | 3 - 3 files changed, 106 insertions(+), 42 deletions(-) diff --git a/app/modules/ticketing/cruds_ticketing.py b/app/modules/ticketing/cruds_ticketing.py index 348cbbf12d..db6084f47a 100644 --- a/app/modules/ticketing/cruds_ticketing.py +++ b/app/modules/ticketing/cruds_ticketing.py @@ -95,7 +95,6 @@ async def get_events( close_date=event.close_date, quota=event.quota, user_quota=event.user_quota, - used_quota=event.used_quota, disabled=event.disabled, ) for event in events.scalars().all() @@ -135,7 +134,6 @@ async def get_event_by_id( close_date=event.close_date, quota=event.quota, user_quota=event.user_quota, - used_quota=event.used_quota, disabled=event.disabled, organiser=schemas_ticketing.OrganiserComplete( id=event.organiser.id, @@ -150,7 +148,6 @@ async def get_event_by_id( name=session.name, quota=session.quota, user_quota=session.user_quota, - used_quota=session.used_quota, disabled=session.disabled, ) for session in event.sessions @@ -163,7 +160,6 @@ async def get_event_by_id( required_mebership=category.required_mebership, quota=category.quota, user_quota=category.user_quota, - used_quota=category.used_quota, price=category.price, disabled=category.disabled, ) @@ -175,6 +171,25 @@ async def get_event_by_id( ) +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, @@ -229,7 +244,6 @@ async def get_event_by_name( close_date=event.close_date, quota=event.quota, user_quota=event.user_quota, - used_quota=event.used_quota, disabled=event.disabled, organiser=schemas_ticketing.OrganiserComplete( id=event.organiser.id, @@ -237,11 +251,28 @@ async def get_event_by_name( store_id=event.organiser.store_id, ), sessions=[ - schemas_ticketing.SessionSimple.model_validate(session) + 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.model_validate(category) + 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 ], ) @@ -257,7 +288,7 @@ async def create_event( """Create a new event.""" db.add( - models_ticketing.TicketingEvent(**event.model_dump()), + models_ticketing.TicketingEvent(**event.model_dump(), used_quota=0), ) await db.flush() @@ -356,7 +387,6 @@ async def get_session_by_id( date=session.date, quota=session.quota, user_quota=session.user_quota, - used_quota=session.used_quota, disabled=session.disabled, event_id=session.event_id, event=session.event, @@ -366,6 +396,25 @@ async def get_session_by_id( ) +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, @@ -401,7 +450,6 @@ async def get_sessions_by_ids( date=session.date, quota=session.quota, user_quota=session.user_quota, - used_quota=session.used_quota, disabled=session.disabled, event_id=session.event_id, ) @@ -424,7 +472,7 @@ async def create_session( """Create a new session.""" db.add( - models_ticketing.TicketingSession(**session.model_dump()), + models_ticketing.TicketingSession(**session.model_dump(), used_quota=0), ) await db.flush() @@ -510,14 +558,12 @@ async def get_category_by_id( close_date=category.event.close_date, quota=category.event.quota, user_quota=category.event.user_quota, - used_quota=category.event.used_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, - used_quota=category.used_quota, price=category.price, disabled=category.disabled, ) @@ -526,6 +572,25 @@ async def get_category_by_id( ) +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, @@ -562,7 +627,6 @@ async def get_categories_by_session_id( required_mebership=category.required_mebership, quota=category.quota, user_quota=category.user_quota, - used_quota=category.used_quota, price=category.price, disabled=category.disabled, ) @@ -595,7 +659,6 @@ async def get_categories_by_event_id( required_mebership=category.required_mebership, quota=category.quota, user_quota=category.user_quota, - used_quota=category.used_quota, price=category.price, disabled=category.disabled, ) @@ -836,7 +899,6 @@ async def get_ticket_by_id( close_date=ticket.event.close_date, quota=ticket.event.quota, user_quota=ticket.event.user_quota, - used_quota=ticket.event.used_quota, disabled=ticket.event.disabled, ), session=schemas_ticketing.SessionSimple( @@ -846,7 +908,6 @@ async def get_ticket_by_id( name=ticket.session.name, quota=ticket.session.quota, user_quota=ticket.session.user_quota, - used_quota=ticket.session.used_quota, disabled=ticket.session.disabled, ) if ticket.session @@ -858,7 +919,6 @@ async def get_ticket_by_id( required_mebership=ticket.category.required_mebership, quota=ticket.category.quota, user_quota=ticket.category.user_quota, - used_quota=ticket.category.used_quota, price=ticket.category.price, disabled=ticket.category.disabled, ), diff --git a/app/modules/ticketing/endpoints_ticketing.py b/app/modules/ticketing/endpoints_ticketing.py index 2548e60461..eb5d037f80 100644 --- a/app/modules/ticketing/endpoints_ticketing.py +++ b/app/modules/ticketing/endpoints_ticketing.py @@ -165,7 +165,6 @@ async def create_event( **event.model_dump(), id=uuid4(), creator_id=user.id, - used_quota=0, disabled=False, ) await cruds_ticketing.create_event(event=event, db=db) @@ -187,15 +186,16 @@ async def update_event( event_id: UUID, event_update: schemas_ticketing.EventUpdate, db: AsyncSession = Depends(get_db), + redis: Redis | None = Depends(get_redis_client), user: models_users.CoreUser = Depends( is_user_allowed_to([TicketingPermissions.manage_events]), ), ) -> None: """Update an existing event.""" - stored = await cruds_ticketing.get_event_by_id(event_id=event_id, db=db) - if stored is None: + 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 stored.used_quota > event_update.quota: + 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", @@ -221,10 +221,10 @@ async def delete_event( ), ) -> None: """Delete an existing event.""" - stored = await cruds_ticketing.get_event_by_id(event_id=event_id, db=db) - if stored is None: + 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 stored.used_quota > 0: + if used_quota > 0: raise HTTPException( status_code=400, detail="Cannot delete an event with used quota", @@ -311,7 +311,6 @@ async def create_session( session_simple = schemas_ticketing.SessionSimple( **session.model_dump(), id=uuid4(), - used_quota=0, disabled=False, ) # Verify that the event exists before @@ -357,10 +356,12 @@ async def update_session( ), ) -> None: """Update an existing session.""" - stored = await cruds_ticketing.get_session_by_id(session_id=session_id, db=db) - if stored is None: + 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 stored.used_quota > session_update.quota: + 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", @@ -386,10 +387,12 @@ async def delete_session( ), ) -> None: """Delete an existing session.""" - stored = await cruds_ticketing.get_session_by_id(session_id=session_id, db=db) - if stored is None: + 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 stored.used_quota > 0: + if used_quota > 0: raise HTTPException( status_code=400, detail="Cannot delete a session with used quota", @@ -526,7 +529,6 @@ async def create_category( category_simple = schemas_ticketing.CategorySimple( **category.model_dump(), id=uuid4(), - used_quota=0, disabled=False, ) await cruds_ticketing.create_category(category=category_simple, db=db) @@ -543,15 +545,18 @@ async def update_category( category_id: UUID, category_update: schemas_ticketing.CategoryUpdate, db: AsyncSession = Depends(get_db), + redis: Redis | None = Depends(get_redis_client), user: models_users.CoreUser = Depends( is_user_allowed_to([TicketingPermissions.manage_events]), ), ) -> None: """Update an existing category.""" - stored = await cruds_ticketing.get_category_by_id(category_id=category_id, db=db) - if stored is None: + 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 stored.used_quota > category_update.quota: + 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", @@ -577,10 +582,12 @@ async def delete_category( ), ) -> None: """Delete an existing category.""" - stored = await cruds_ticketing.get_category_by_id(category_id=category_id, db=db) - if stored is None: + 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 stored.used_quota > 0: + if used_quota > 0: raise HTTPException( status_code=400, detail="Cannot delete a category with used quota", diff --git a/app/modules/ticketing/schemas_ticketing.py b/app/modules/ticketing/schemas_ticketing.py index 6587d52229..56ff9dd80e 100644 --- a/app/modules/ticketing/schemas_ticketing.py +++ b/app/modules/ticketing/schemas_ticketing.py @@ -33,7 +33,6 @@ class EventBase(BaseModel): class EventSimple(EventBase): creator_id: str id: UUID - used_quota: int disabled: bool model_config = ConfigDict(from_attributes=True) @@ -66,7 +65,6 @@ class SessionBase(BaseModel): class SessionSimple(SessionBase): id: UUID - used_quota: int disabled: bool model_config = ConfigDict(from_attributes=True) @@ -100,7 +98,6 @@ class CategoryCreate(CategoryBase): class CategorySimple(CategoryBase): id: UUID - used_quota: int disabled: bool model_config = ConfigDict(from_attributes=True) From 9bdb3194dcb9e7b6f16313c8f1a87cc249520d8e Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Sat, 6 Jun 2026 20:08:29 +0200 Subject: [PATCH 46/50] Trailing comma missing --- app/modules/ticketing/endpoints_ticketing.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/modules/ticketing/endpoints_ticketing.py b/app/modules/ticketing/endpoints_ticketing.py index eb5d037f80..8d65cfcce1 100644 --- a/app/modules/ticketing/endpoints_ticketing.py +++ b/app/modules/ticketing/endpoints_ticketing.py @@ -357,7 +357,7 @@ async def update_session( ) -> None: """Update an existing session.""" used_quota = await cruds_ticketing.get_session_used_quota( - db=db, session_id=session_id + db=db, session_id=session_id, ) if used_quota is None: raise HTTPException(status_code=404, detail="Session not found") @@ -388,7 +388,7 @@ async def delete_session( ) -> None: """Delete an existing session.""" used_quota = await cruds_ticketing.get_session_used_quota( - db=db, session_id=session_id + db=db, session_id=session_id, ) if used_quota is None: raise HTTPException(status_code=404, detail="Session not found") @@ -552,7 +552,7 @@ async def update_category( ) -> None: """Update an existing category.""" used_quota = await cruds_ticketing.get_category_used_quota( - db=db, category_id=category_id + db=db, category_id=category_id, ) if used_quota is None: raise HTTPException(status_code=404, detail="Category not found") @@ -583,7 +583,7 @@ async def delete_category( ) -> None: """Delete an existing category.""" used_quota = await cruds_ticketing.get_category_used_quota( - db=db, category_id=category_id + db=db, category_id=category_id, ) if used_quota is None: raise HTTPException(status_code=404, detail="Category not found") From b43bd2a621174bdbb65e01689f69c0383fd94c8e Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Sat, 6 Jun 2026 20:10:49 +0200 Subject: [PATCH 47/50] Format again --- app/modules/ticketing/endpoints_ticketing.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/app/modules/ticketing/endpoints_ticketing.py b/app/modules/ticketing/endpoints_ticketing.py index 8d65cfcce1..d464473172 100644 --- a/app/modules/ticketing/endpoints_ticketing.py +++ b/app/modules/ticketing/endpoints_ticketing.py @@ -357,7 +357,8 @@ async def update_session( ) -> None: """Update an existing session.""" used_quota = await cruds_ticketing.get_session_used_quota( - db=db, session_id=session_id, + db=db, + session_id=session_id, ) if used_quota is None: raise HTTPException(status_code=404, detail="Session not found") @@ -388,7 +389,8 @@ async def delete_session( ) -> None: """Delete an existing session.""" used_quota = await cruds_ticketing.get_session_used_quota( - db=db, session_id=session_id, + db=db, + session_id=session_id, ) if used_quota is None: raise HTTPException(status_code=404, detail="Session not found") @@ -552,7 +554,8 @@ async def update_category( ) -> None: """Update an existing category.""" used_quota = await cruds_ticketing.get_category_used_quota( - db=db, category_id=category_id, + db=db, + category_id=category_id, ) if used_quota is None: raise HTTPException(status_code=404, detail="Category not found") @@ -583,7 +586,8 @@ async def delete_category( ) -> None: """Delete an existing category.""" used_quota = await cruds_ticketing.get_category_used_quota( - db=db, category_id=category_id, + db=db, + category_id=category_id, ) if used_quota is None: raise HTTPException(status_code=404, detail="Category not found") From 4d10e0e3fd3a02bcd6a630fd6ed3c53a72f0d962 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Sat, 6 Jun 2026 20:17:03 +0200 Subject: [PATCH 48/50] Rebase migration --- migrations/versions/{59-ticketing.py => 60-ticketing.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename migrations/versions/{59-ticketing.py => 60-ticketing.py} (99%) diff --git a/migrations/versions/59-ticketing.py b/migrations/versions/60-ticketing.py similarity index 99% rename from migrations/versions/59-ticketing.py rename to migrations/versions/60-ticketing.py index 83eaf24c82..08300b23c7 100644 --- a/migrations/versions/59-ticketing.py +++ b/migrations/versions/60-ticketing.py @@ -16,7 +16,7 @@ # revision identifiers, used by Alembic. revision: str = "8ccdd1f377c9" -down_revision: str | None = "e58ffcd6b9eb" +down_revision: str | None = "7dbe3290e145" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None From 0a242f0031bb2760a57d905d9f8fac54a7c82657 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Sat, 6 Jun 2026 20:56:59 +0200 Subject: [PATCH 49/50] Missing used_quota init --- app/modules/ticketing/cruds_ticketing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/modules/ticketing/cruds_ticketing.py b/app/modules/ticketing/cruds_ticketing.py index db6084f47a..5f972b0f69 100644 --- a/app/modules/ticketing/cruds_ticketing.py +++ b/app/modules/ticketing/cruds_ticketing.py @@ -680,7 +680,7 @@ async def create_category( ) -> None: """Create a new category.""" db.add( - models_ticketing.TicketingCategory(**category.model_dump()), + models_ticketing.TicketingCategory(**category.model_dump(), used_quota=0), ) await db.flush() From c25b843ccf37fe611cebdfe616305679b30f7f3b Mon Sep 17 00:00:00 2001 From: Nako Date: Tue, 9 Jun 2026 10:29:55 +0200 Subject: [PATCH 50/50] add user relationship for tickets --- app/modules/ticketing/cruds_ticketing.py | 65 +++++++++++++++++++++- app/modules/ticketing/factory_ticketing.py | 14 +++-- app/modules/ticketing/models_ticketing.py | 7 +++ app/modules/ticketing/schemas_ticketing.py | 6 +- 4 files changed, 83 insertions(+), 9 deletions(-) diff --git a/app/modules/ticketing/cruds_ticketing.py b/app/modules/ticketing/cruds_ticketing.py index 5f972b0f69..dbf6588fe8 100644 --- a/app/modules/ticketing/cruds_ticketing.py +++ b/app/modules/ticketing/cruds_ticketing.py @@ -4,6 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload +from app.core.users import schemas_users from app.modules.ticketing import models_ticketing, schemas_ticketing @@ -680,9 +681,21 @@ async def create_category( ) -> None: """Create a new category.""" db.add( - models_ticketing.TicketingCategory(**category.model_dump(), used_quota=0), + 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( @@ -751,6 +764,14 @@ async def get_tickets( created_at=ticket.created_at, status=ticket.status, nb_scan=ticket.nb_scan, + user=schemas_users.CoreUserSimple( + id=ticket.user.id, + name=ticket.user.name, + firstname=ticket.user.firstname, + nickname=ticket.user.nickname, + account_type=ticket.user.account_type, + school_id=ticket.user.school_id, + ), ) for ticket in tickets.scalars().all() ] @@ -778,6 +799,14 @@ async def get_tickets_by_event_id( created_at=ticket.created_at, status=ticket.status, nb_scan=ticket.nb_scan, + user=schemas_users.CoreUserSimple( + id=ticket.user.id, + name=ticket.user.name, + firstname=ticket.user.firstname, + nickname=ticket.user.nickname, + account_type=ticket.user.account_type, + school_id=ticket.user.school_id, + ), ) for ticket in tickets.scalars().all() ] @@ -804,6 +833,14 @@ async def get_tickets_by_category_id( created_at=ticket.created_at, status=ticket.status, nb_scan=ticket.nb_scan, + user=schemas_users.CoreUserSimple( + id=ticket.user.id, + name=ticket.user.name, + firstname=ticket.user.firstname, + nickname=ticket.user.nickname, + account_type=ticket.user.account_type, + school_id=ticket.user.school_id, + ), ) for ticket in tickets.scalars().all() ] @@ -831,6 +868,14 @@ async def get_tickets_by_session_id( created_at=ticket.created_at, status=ticket.status, nb_scan=ticket.nb_scan, + user=schemas_users.CoreUserSimple( + id=ticket.user.id, + name=ticket.user.name, + firstname=ticket.user.firstname, + nickname=ticket.user.nickname, + account_type=ticket.user.account_type, + school_id=ticket.user.school_id, + ), ) for ticket in tickets.scalars().all() ] @@ -858,6 +903,14 @@ async def get_tickets_by_user_id( created_at=ticket.created_at, status=ticket.status, nb_scan=ticket.nb_scan, + user=schemas_users.CoreUserSimple( + id=ticket.user.id, + name=ticket.user.name, + firstname=ticket.user.firstname, + nickname=ticket.user.nickname, + account_type=ticket.user.account_type, + school_id=ticket.user.school_id, + ), ) for ticket in tickets.scalars().all() ] @@ -890,6 +943,14 @@ async def get_ticket_by_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, @@ -937,7 +998,7 @@ async def create_ticket( """Create a new ticket.""" db.add( - models_ticketing.TicketingTicket(**ticket.model_dump()), + models_ticketing.TicketingTicket(**ticket.model_dump(exclude={"user"})), ) await db.flush() diff --git a/app/modules/ticketing/factory_ticketing.py b/app/modules/ticketing/factory_ticketing.py index 8a9f3f967a..dde5f24cf0 100644 --- a/app/modules/ticketing/factory_ticketing.py +++ b/app/modules/ticketing/factory_ticketing.py @@ -6,6 +6,7 @@ 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 @@ -61,7 +62,6 @@ async def add_event(cls, db: AsyncSession) -> None: close_date=datetime.now(UTC) + timedelta(days=30), quota=500, user_quota=4, - used_quota=0, disabled=False, ), ) @@ -78,7 +78,6 @@ async def add_sessions(cls, db: AsyncSession) -> None: name="TicketingSession du Samedi Soir", quota=300, user_quota=2, - used_quota=0, disabled=False, ), ) @@ -91,7 +90,6 @@ async def add_sessions(cls, db: AsyncSession) -> None: name="TicketingSession du Dimanche Après-midi", quota=200, user_quota=2, - used_quota=0, disabled=False, ), ) @@ -109,7 +107,6 @@ async def add_categories(cls, db: AsyncSession) -> None: required_mebership=CoreMembershipsFactory.memberships_ids[0], quota=150, user_quota=2, - used_quota=0, price=1500, disabled=False, ), @@ -124,7 +121,6 @@ async def add_categories(cls, db: AsyncSession) -> None: required_mebership=CoreMembershipsFactory.memberships_ids[1], quota=200, user_quota=2, - used_quota=0, price=2000, disabled=False, ), @@ -139,7 +135,6 @@ async def add_categories(cls, db: AsyncSession) -> None: required_mebership=None, quota=100, user_quota=1, - used_quota=0, price=3000, disabled=False, ), @@ -165,6 +160,13 @@ async def add_tickets(cls, db: AsyncSession) -> None: created_at=datetime.now(UTC), nb_scan=0, status="valid", + user=schemas_users.CoreUserSimple( + id=user_id, + name=f"Raizor-{_i}", + firstname=f"Raiz-{_i}", + account_type=schemas_users.AccountType.student, + school_id=CoreMembershipsFactory.memberships_ids[0], + ), ), ) diff --git a/app/modules/ticketing/models_ticketing.py b/app/modules/ticketing/models_ticketing.py index dba6d56ac3..fe34e9fac9 100644 --- a/app/modules/ticketing/models_ticketing.py +++ b/app/modules/ticketing/models_ticketing.py @@ -5,6 +5,7 @@ from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.core.users import models_users from app.types.sqlalchemy import Base, PrimaryKey if TYPE_CHECKING: @@ -145,3 +146,9 @@ class TicketingTicket(Base): created_at: Mapped[datetime] status: Mapped[str] # TODO: Enum nb_scan: Mapped[int] + + user: Mapped[models_users.CoreUser] = relationship( + "CoreUser", + lazy="joined", + init=False, + ) diff --git a/app/modules/ticketing/schemas_ticketing.py b/app/modules/ticketing/schemas_ticketing.py index 56ff9dd80e..ac32802c42 100644 --- a/app/modules/ticketing/schemas_ticketing.py +++ b/app/modules/ticketing/schemas_ticketing.py @@ -4,6 +4,8 @@ from pydantic import BaseModel, ConfigDict, Field +from app.core.users import schemas_users + class OrganiserBase(BaseModel): name: str @@ -99,13 +101,13 @@ class CategoryCreate(CategoryBase): class CategorySimple(CategoryBase): id: UUID disabled: bool + sessions: list[UUID] | None = None model_config = ConfigDict(from_attributes=True) class CategoryComplete(CategorySimple): event: EventSimple - sessions: list[UUID] | None = None model_config = ConfigDict(from_attributes=True) @@ -134,6 +136,8 @@ class TicketSimple(TicketBase): status: str nb_scan: int + user: schemas_users.CoreUserSimple + model_config = ConfigDict(from_attributes=True)