From 93de3dae6415206be12fa48c839bbc853a3b1ac9 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Wed, 8 Apr 2026 19:16:53 +0000 Subject: [PATCH 1/5] Ticket change over --- app/core/tickets/cruds_tickets.py | 81 ++++++++++++++- app/core/tickets/endpoints_tickets.py | 140 +++++++++++++++++++++++++- app/core/tickets/models_tickets.py | 13 +++ app/core/tickets/schemas_tickets.py | 15 ++- 4 files changed, 243 insertions(+), 6 deletions(-) diff --git a/app/core/tickets/cruds_tickets.py b/app/core/tickets/cruds_tickets.py index b0c51202af..cfdd7a1dbb 100644 --- a/app/core/tickets/cruds_tickets.py +++ b/app/core/tickets/cruds_tickets.py @@ -6,7 +6,7 @@ from sqlalchemy import func, not_, or_, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import joinedload, selectinload -from sqlalchemy.sql import select +from sqlalchemy.sql import delete, select from app.core.tickets import models_tickets, schemas_tickets from app.core.users import schemas_users @@ -581,6 +581,28 @@ async def get_paid_tickets_by_event_id( ] +async def get_ticket_simple_by_id( + ticket_id: UUID, + db: AsyncSession, +) -> schemas_tickets.TicketSimple | None: + result = await db.execute( + select(models_tickets.Checkout).where(models_tickets.Checkout.id == ticket_id), + ) + ticket = result.scalars().first() + if ticket is None: + return None + + return schemas_tickets.TicketSimple( + id=ticket.id, + category_id=ticket.category_id, + session_id=ticket.session_id, + event_id=ticket.event_id, + scanned=ticket.scanned, + user_id=ticket.user_id, + price=ticket.price, + ) + + async def get_ticket_by_id( ticket_id: UUID, db: AsyncSession, @@ -654,6 +676,18 @@ async def mark_ticket_as_scanned( ) +async def change_ticket_owner( + ticket_id: UUID, + new_user_id: str, + db: AsyncSession, +): + await db.execute( + update(models_tickets.Checkout) + .where(models_tickets.Checkout.id == ticket_id) + .values(user_id=new_user_id), + ) + + async def count_tickets_by_event_id( event_id: UUID, db: AsyncSession, @@ -869,3 +903,48 @@ async def update_question( .where(models_tickets.Question.id == question_id) .values(**question_update.model_dump(exclude_unset=True)), ) + + +async def delete_ticket_change_over_invitation( + ticket_id: UUID, + db: AsyncSession, +): + await db.execute( + delete(models_tickets.TicketChangeOverInvitation).where( + models_tickets.TicketChangeOverInvitation.ticket_id == ticket_id, + ), + ) + + +async def create_ticket_change_over_invitation( + ticket_id: UUID, + new_user_id: str, + token: str, + db: AsyncSession, +): + db_invitation = models_tickets.TicketChangeOverInvitation( + ticket_id=ticket_id, + new_user_id=new_user_id, + token=token, + ) + db.add(db_invitation) + + +async def get_ticket_change_over_invitation_by_token( + token: str, + db: AsyncSession, +) -> schemas_tickets.TicketChangeOverContent | None: + result = await db.execute( + select(models_tickets.TicketChangeOverInvitation).where( + models_tickets.TicketChangeOverInvitation.token == token, + ), + ) + invitation = result.scalars().first() + if invitation is None: + return None + + return schemas_tickets.TicketChangeOverContent( + ticket_id=invitation.ticket_id, + new_user_id=invitation.new_user_id, + token=invitation.token, + ) diff --git a/app/core/tickets/endpoints_tickets.py b/app/core/tickets/endpoints_tickets.py index 88b2ea11f4..317e2c9c9d 100644 --- a/app/core/tickets/endpoints_tickets.py +++ b/app/core/tickets/endpoints_tickets.py @@ -5,13 +5,15 @@ from io import StringIO from uuid import UUID +import calypsso from fastapi import ( APIRouter, + BackgroundTasks, Depends, HTTPException, Response, ) -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, RedirectResponse from sqlalchemy.ext.asyncio import AsyncSession from app.core.feed import schemas_feed, utils_feed @@ -22,17 +24,23 @@ from app.core.tickets import cruds_tickets, schemas_tickets, utils_tickets from app.core.tickets.factory_tickets import TicketsFactory from app.core.users import schemas_users +from app.core.users.cruds_users import get_user_by_email from app.core.users.models_users import CoreUser +from app.core.utils import security +from app.core.utils.config import Settings from app.dependencies import ( get_db, + get_mail_templates, get_mypayment_tool, get_notification_tool, + get_settings, is_user, is_user_allowed_to, ) from app.types.exceptions import ObjectExpectedInDbNotFoundError from app.types.module import CoreModule from app.utils.communication.notifications import NotificationTool +from app.utils.mail.mailworker import send_email router = APIRouter(tags=["Tickets"]) @@ -346,6 +354,136 @@ async def get_user_tickets( ) +@router.post( + "/tickets/user/me/tickets/change-over/request", + status_code=204, +) +async def ticket_request_change_over( + ticket_transfer: schemas_tickets.TicketChangeOverInvitation, + background_tasks: BackgroundTasks, + user: CoreUser = Depends( + is_user_allowed_to( + [TicketsPermissions.buy_tickets], + ), + ), + db: AsyncSession = Depends(get_db), + settings: Settings = Depends(get_settings), + mail_templates: calypsso.MailTemplates = Depends(get_mail_templates), +): + """ + Give its ticket to another user. The other user will receive an email with a link to accept the transfer. + + Using this endpoint will invalidate existing transfer invitations. + """ + ticket = await cruds_tickets.get_ticket_by_id( + ticket_id=ticket_transfer.ticket_id, + db=db, + ) + + if ticket is None: + raise HTTPException(404, "Ticket not found") + + if ticket.user_id != user.id: + raise HTTPException(403, "User is not the owner of the ticket") + + event = await cruds_tickets.get_event_simple_by_id( + event_id=ticket.event_id, + db=db, + ) + + if event is None: + raise ObjectExpectedInDbNotFoundError( + object_name="Event", + object_id=ticket.event_id, + ) + + await cruds_tickets.delete_ticket_change_over_invitation( + ticket_id=ticket.id, + db=db, + ) + + receiver_user = await get_user_by_email( + email=ticket_transfer.email, + db=db, + ) + + token = security.generate_token() + + if receiver_user is None: + mail = mail_templates.get_mail_ticket_change_over_account_does_not_exist( + event_name=event.name, + giver_name=user.full_name, + ) + + else: + await cruds_tickets.create_ticket_change_over_invitation( + ticket_id=ticket.id, + new_user_id=receiver_user.id, + token=token, + db=db, + ) + + confirmation_url = f"{settings.CLIENT_URL}tickets/user/me/tickets/change-over/accept?token={token}" + + mail = mail_templates.get_mail_ticket_change_over( + event_name=event.name, + giver_name=user.full_name, + confirmation_url=confirmation_url, + ) + + background_tasks.add_task( + send_email, + recipient=ticket_transfer.email, + subject=f"{settings.school.application_name} - Ticket transfer for {event.name}", + content=mail, + settings=settings, + ) + + +@router.get( + "/tickets/user/me/tickets/change-over/accept", + status_code=200, +) +async def ticket_accept_change_over( + token: str, + db: AsyncSession = Depends(get_db), + settings: Settings = Depends(get_settings), +): + """ + Accept a ticket transfer invitation. The user will become the new owner of the ticket. + """ + invitation = await cruds_tickets.get_ticket_change_over_invitation_by_token( + token=token, + db=db, + ) + + if invitation is None: + return RedirectResponse( + url=settings.CLIENT_URL + + calypsso.get_message_relative_url( + message_type=calypsso.TypeMessage.ticket_change_over_invalid, + ), + ) + + await cruds_tickets.delete_ticket_change_over_invitation( + ticket_id=invitation.ticket_id, + db=db, + ) + + await cruds_tickets.change_ticket_owner( + ticket_id=invitation.ticket_id, + new_user_id=invitation.new_user_id, + db=db, + ) + + return RedirectResponse( + url=settings.CLIENT_URL + + calypsso.get_message_relative_url( + message_type=calypsso.TypeMessage.ticket_change_over_success, + ), + ) + + @router.get( "/tickets/admin/events/{event_id}", response_model=schemas_tickets.EventAdmin, diff --git a/app/core/tickets/models_tickets.py b/app/core/tickets/models_tickets.py index 960d1b5a00..4d45c5e52b 100644 --- a/app/core/tickets/models_tickets.py +++ b/app/core/tickets/models_tickets.py @@ -141,3 +141,16 @@ class Checkout(Base): category: Mapped["Category"] = relationship(init=False) session: Mapped["EventSession"] = relationship(init=False) event: Mapped["TicketEvent"] = relationship(init=False) + + +class TicketChangeOverInvitation(Base): + __tablename__ = "tickets_change_over_invitation" + + ticket_id: Mapped[UUID] = mapped_column( + ForeignKey("tickets_checkout.id"), + primary_key=True, + ) + new_user_id: Mapped[str] = mapped_column( + ForeignKey("core_user.id"), + ) + token: Mapped[str] diff --git a/app/core/tickets/schemas_tickets.py b/app/core/tickets/schemas_tickets.py index ffdc646c5b..a0f1826597 100644 --- a/app/core/tickets/schemas_tickets.py +++ b/app/core/tickets/schemas_tickets.py @@ -245,15 +245,16 @@ def from_answer_value( class Ticket(BaseModel): id: UUID - price: int user_id: str + price: int + + scanned: bool + event_id: UUID category_id: UUID session_id: UUID - scanned: bool - category: Category session: Session user: schemas_users.CoreUserSimple @@ -278,6 +279,12 @@ class CheckoutResponse(BaseModel): payment_url: str | None -class TicketTransfer(BaseModel): +class TicketChangeOverInvitation(BaseModel): ticket_id: UUID email: str + + +class TicketChangeOverContent(BaseModel): + ticket_id: UUID + new_user_id: str + token: str From 1097fc46595bc2ced165cf5aec223aa3a1afa96b Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Fri, 1 May 2026 15:10:53 +0200 Subject: [PATCH 2/5] Remove unused and fix --- app/core/tickets/cruds_tickets.py | 22 ---------------------- app/core/tickets/endpoints_tickets.py | 2 +- 2 files changed, 1 insertion(+), 23 deletions(-) diff --git a/app/core/tickets/cruds_tickets.py b/app/core/tickets/cruds_tickets.py index cfdd7a1dbb..7bf090e8f9 100644 --- a/app/core/tickets/cruds_tickets.py +++ b/app/core/tickets/cruds_tickets.py @@ -581,28 +581,6 @@ async def get_paid_tickets_by_event_id( ] -async def get_ticket_simple_by_id( - ticket_id: UUID, - db: AsyncSession, -) -> schemas_tickets.TicketSimple | None: - result = await db.execute( - select(models_tickets.Checkout).where(models_tickets.Checkout.id == ticket_id), - ) - ticket = result.scalars().first() - if ticket is None: - return None - - return schemas_tickets.TicketSimple( - id=ticket.id, - category_id=ticket.category_id, - session_id=ticket.session_id, - event_id=ticket.event_id, - scanned=ticket.scanned, - user_id=ticket.user_id, - price=ticket.price, - ) - - async def get_ticket_by_id( ticket_id: UUID, db: AsyncSession, diff --git a/app/core/tickets/endpoints_tickets.py b/app/core/tickets/endpoints_tickets.py index 317e2c9c9d..fbc512a27c 100644 --- a/app/core/tickets/endpoints_tickets.py +++ b/app/core/tickets/endpoints_tickets.py @@ -363,7 +363,7 @@ async def ticket_request_change_over( background_tasks: BackgroundTasks, user: CoreUser = Depends( is_user_allowed_to( - [TicketsPermissions.buy_tickets], + [TicketsPermissions.access_tickets], ), ), db: AsyncSession = Depends(get_db), From 443ad8a717f04c8a56775eb05065320eb080e17d Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sun, 7 Jun 2026 16:00:32 +0200 Subject: [PATCH 3/5] Calypsso 2.8.0 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9769112ea8..f487459191 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ authlib==1.7.2 bcrypt==5.0.0 # password hashing boto3==1.43.24 # S3 storage broadcaster==0.3.1 # Working with websockets with multiple workers. -calypsso==2.7.0 +calypsso-proximapp==2.8.0 email-validator==2.3.0 Faker==40.21.0 fastapi[standard]==0.136.3 From 44f6112504e8774632c0d674791e9631eec3b14c Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sun, 7 Jun 2026 17:44:06 +0200 Subject: [PATCH 4/5] Migration --- migrations/versions/72-tickets_change_over.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 migrations/versions/72-tickets_change_over.py diff --git a/migrations/versions/72-tickets_change_over.py b/migrations/versions/72-tickets_change_over.py new file mode 100644 index 0000000000..4a032f4fbf --- /dev/null +++ b/migrations/versions/72-tickets_change_over.py @@ -0,0 +1,40 @@ +"""empty message + +Create Date: 2026-06-07 17:43:34.457871 +""" + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pytest_alembic import MigrationContext + +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "af6920fed071" +down_revision: str | None = "3108c3bc5425" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass + + +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 97db1db65147c85da0b8d2088d3f62507e3d3f22 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 8 Jun 2026 09:12:28 +0200 Subject: [PATCH 5/5] Migration --- migrations/versions/72-tickets_change_over.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/migrations/versions/72-tickets_change_over.py b/migrations/versions/72-tickets_change_over.py index 4a032f4fbf..178b6a853d 100644 --- a/migrations/versions/72-tickets_change_over.py +++ b/migrations/versions/72-tickets_change_over.py @@ -10,6 +10,7 @@ from pytest_alembic import MigrationContext import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. revision: str = "af6920fed071" @@ -19,11 +20,19 @@ def upgrade() -> None: - pass + op.create_table( + "tickets_change_over_invitation", + sa.Column("ticket_id", sa.Uuid(), nullable=False), + sa.Column("new_user_id", sa.String(), nullable=False), + sa.Column("token", sa.String(), nullable=False), + sa.ForeignKeyConstraint(["new_user_id"], ["core_user.id"]), + sa.ForeignKeyConstraint(["ticket_id"], ["tickets_checkout.id"]), + sa.PrimaryKeyConstraint("ticket_id"), + ) def downgrade() -> None: - pass + op.drop_table("tickets_change_over_invitation") def pre_test_upgrade(