diff --git a/app/core/tickets/cruds_tickets.py b/app/core/tickets/cruds_tickets.py index b0c51202af..7bf090e8f9 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 @@ -654,6 +654,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 +881,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..fbc512a27c 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.access_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 diff --git a/migrations/versions/72-tickets_change_over.py b/migrations/versions/72-tickets_change_over.py new file mode 100644 index 0000000000..178b6a853d --- /dev/null +++ b/migrations/versions/72-tickets_change_over.py @@ -0,0 +1,49 @@ +"""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 +from alembic import op + +# 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: + 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: + op.drop_table("tickets_change_over_invitation") + + +def pre_test_upgrade( + alembic_runner: "MigrationContext", + alembic_connection: sa.Connection, +) -> None: + pass + + +def test_upgrade( + alembic_runner: "MigrationContext", + alembic_connection: sa.Connection, +) -> None: + pass diff --git a/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