Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion app/core/tickets/cruds_tickets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
)
140 changes: 139 additions & 1 deletion app/core/tickets/endpoints_tickets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"])

Expand Down Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions app/core/tickets/models_tickets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
15 changes: 11 additions & 4 deletions app/core/tickets/schemas_tickets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
49 changes: 49 additions & 0 deletions migrations/versions/72-tickets_change_over.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading