From 691ab0c95e8e28d5a8d3ffc2ab4514537435dc1c Mon Sep 17 00:00:00 2001 From: Thonyk Date: Mon, 6 Apr 2026 18:50:39 +0200 Subject: [PATCH 01/72] feat: support requests --- app/core/groups/groups_type.py | 6 +- app/core/mypayment/cruds_mypayment.py | 148 +++++++- app/core/mypayment/endpoints_mypayment.py | 364 +++++++++++++++---- app/core/mypayment/exceptions_mypayment.py | 23 ++ app/core/mypayment/models_mypayment.py | 7 +- app/core/mypayment/schemas_mypayment.py | 33 ++ app/core/mypayment/types_mypayment.py | 44 +-- app/core/mypayment/utils_mypayment.py | 248 ++++++++++++- app/modules/mypayment/__init__.py | 1 - app/modules/mypayment/endpoints_mypayment.py | 16 - app/types/module.py | 18 + 11 files changed, 784 insertions(+), 124 deletions(-) delete mode 100644 app/modules/mypayment/__init__.py delete mode 100644 app/modules/mypayment/endpoints_mypayment.py diff --git a/app/core/groups/groups_type.py b/app/core/groups/groups_type.py index bb6adb4486..1ac6514d11 100644 --- a/app/core/groups/groups_type.py +++ b/app/core/groups/groups_type.py @@ -1,7 +1,7 @@ -from enum import Enum +from enum import StrEnum -class GroupType(str, Enum): +class GroupType(StrEnum): """ In Hyperion, each user may have multiple groups. Belonging to a group gives access to a set of specific permissions. @@ -14,7 +14,7 @@ class GroupType(str, Enum): admin = "0a25cb76-4b63-4fd3-b939-da6d9feabf28" -class AccountType(str, Enum): +class AccountType(StrEnum): """ Various account types that can be created in Hyperion. Each account type is associated with a set of permissions. diff --git a/app/core/mypayment/cruds_mypayment.py b/app/core/mypayment/cruds_mypayment.py index a4321adcc5..4b53637afc 100644 --- a/app/core/mypayment/cruds_mypayment.py +++ b/app/core/mypayment/cruds_mypayment.py @@ -1,5 +1,5 @@ from collections.abc import Sequence -from datetime import datetime +from datetime import UTC, datetime, timedelta from uuid import UUID from sqlalchemy import and_, delete, or_, select, update @@ -9,11 +9,13 @@ from app.core.mypayment import models_mypayment, schemas_mypayment from app.core.mypayment.exceptions_mypayment import WalletNotFoundOnUpdateError from app.core.mypayment.types_mypayment import ( + RequestStatus, TransactionStatus, WalletDeviceStatus, WalletType, ) from app.core.mypayment.utils_mypayment import ( + REQUEST_EXPIRATION, invoice_model_to_schema, refund_model_to_schema, structure_model_to_schema, @@ -949,6 +951,150 @@ async def get_refunds_and_sellers_by_wallet_id( return refunds_with_sellers +async def get_requests_by_wallet_id( + wallet_id: UUID, + db: AsyncSession, + include_used: bool = False, +) -> list[schemas_mypayment.Request]: + result = await db.execute( + select(models_mypayment.Request) + .where( + models_mypayment.Request.wallet_id == wallet_id, + ) + .where( + models_mypayment.Request.status == RequestStatus.PROPOSED + if not include_used + else and_(True), + ), + ) + return [ + schemas_mypayment.Request( + id=request.id, + wallet_id=request.wallet_id, + status=request.status, + creation=request.creation, + total=request.total, + store_note=request.store_note, + store_id=request.store_id, + name=request.name, + module=request.module, + object_id=request.object_id, + ) + for request in result.scalars().all() + ] + + +async def get_request_by_id( + request_id: UUID, + db: AsyncSession, +) -> schemas_mypayment.Request | None: + result = await db.execute( + select(models_mypayment.Request).where( + models_mypayment.Request.id == request_id, + ), + ) + request = result.scalars().first() + return ( + schemas_mypayment.Request( + id=request.id, + wallet_id=request.wallet_id, + status=request.status, + creation=request.creation, + total=request.total, + store_note=request.store_note, + store_id=request.store_id, + name=request.name, + module=request.module, + object_id=request.object_id, + ) + if request + else None + ) + + +async def get_request_by_store_id( + store_id: UUID, + db: AsyncSession, +) -> list[schemas_mypayment.Request]: + result = await db.execute( + select(models_mypayment.Request).where( + models_mypayment.Request.store_id == store_id, + ), + ) + return [ + schemas_mypayment.Request( + id=request.id, + wallet_id=request.wallet_id, + status=request.status, + creation=request.creation, + total=request.total, + store_note=request.store_note, + store_id=request.store_id, + name=request.name, + module=request.module, + object_id=request.object_id, + ) + for request in result.scalars().all() + ] + + +async def create_request( + request: schemas_mypayment.Request, + db: AsyncSession, +) -> None: + request_db = models_mypayment.Request( + id=request.id, + wallet_id=request.wallet_id, + status=request.status, + creation=request.creation, + total=request.total, + store_note=request.store_note, + store_id=request.store_id, + name=request.name, + module=request.module, + transaction_id=request.transaction_id, + object_id=request.object_id, + ) + db.add(request_db) + + +async def mark_expired_requests_as_expired( + db: AsyncSession, +) -> None: + await db.execute( + update(models_mypayment.Request) + .where( + models_mypayment.Request.status == RequestStatus.PROPOSED, + models_mypayment.Request.creation + <= datetime.now(tz=UTC) - timedelta(minutes=REQUEST_EXPIRATION), + ) + .values(status=RequestStatus.EXPIRED), + ) + + +async def update_request( + request_id: UUID, + request_update: schemas_mypayment.RequestEdit, + db: AsyncSession, +) -> None: + await db.execute( + update(models_mypayment.Request) + .where(models_mypayment.Request.id == request_id) + .values(**request_update.model_dump(exclude_none=True)), + ) + + +async def delete_request( + request_id: UUID, + db: AsyncSession, +) -> None: + await db.execute( + delete(models_mypayment.Request).where( + models_mypayment.Request.id == request_id, + ), + ) + + async def get_store( store_id: UUID, db: AsyncSession, diff --git a/app/core/mypayment/endpoints_mypayment.py b/app/core/mypayment/endpoints_mypayment.py index 405c64ef13..854a61c99c 100644 --- a/app/core/mypayment/endpoints_mypayment.py +++ b/app/core/mypayment/endpoints_mypayment.py @@ -37,34 +37,46 @@ from app.core.mypayment.exceptions_mypayment import ( InvoiceNotFoundAfterCreationError, ReferencedStructureNotFoundError, + UnexpectedError, ) from app.core.mypayment.factory_mypayment import MyPaymentFactory from app.core.mypayment.integrity_mypayment import ( format_cancel_log, format_refund_log, - format_transaction_log, format_withdrawal_log, ) from app.core.mypayment.models_mypayment import Store, WalletDevice from app.core.mypayment.types_mypayment import ( HistoryType, + MyPaymentCallType, + RequestStatus, TransactionStatus, TransactionType, TransferType, - UnexpectedError, WalletDeviceStatus, WalletType, ) from app.core.mypayment.utils.data_exporter import generate_store_history_csv from app.core.mypayment.utils_mypayment import ( LATEST_TOS, + MYPAYMENT_DEVICES_S3_SUBFOLDER, + MYPAYMENT_LOGS_S3_SUBFOLDER, + MYPAYMENT_ROOT, + MYPAYMENT_STORES_S3_SUBFOLDER, + MYPAYMENT_STRUCTURE_S3_SUBFOLDER, + MYPAYMENT_USERS_S3_SUBFOLDER, QRCODE_EXPIRATION, + REQUEST_EXPIRATION, + RETENTION_DURATION, + apply_transaction, + call_mypayment_callback, is_user_latest_tos_signed, structure_model_to_schema, validate_transfer_callback, verify_signature, ) from app.core.notification.schemas_notification import Message +from app.core.permissions.type_permissions import ModulePermissions from app.core.users import cruds_users, schemas_users from app.core.users.models_users import CoreUser from app.core.utils import security @@ -78,7 +90,7 @@ get_settings, get_token_data, is_user, - is_user_a_school_member, + is_user_allowed_to, is_user_in, ) from app.types import standard_responses @@ -97,12 +109,18 @@ router = APIRouter(tags=["MyPayment"]) + +class MyPaymentPermissions(ModulePermissions): + access_payment = "access_payment" + + core_module = CoreModule( - root="mypayment", + root=MYPAYMENT_ROOT, tag="MyPayment", router=router, payment_callback=validate_transfer_callback, factory=MyPaymentFactory(), + permissions=MyPaymentPermissions, ) @@ -110,13 +128,6 @@ hyperion_security_logger = logging.getLogger("hyperion.security") hyperion_mypayment_logger = logging.getLogger("hyperion.mypayment") -MYPAYMENT_STRUCTURE_S3_SUBFOLDER = "structures" -MYPAYMENT_STORES_S3_SUBFOLDER = "stores" -MYPAYMENT_USERS_S3_SUBFOLDER = "users" -MYPAYMENT_DEVICES_S3_SUBFOLDER = "devices" -MYPAYMENT_LOGS_S3_SUBFOLDER = "logs" -RETENTION_DURATION = 10 * 365 # 10 years in days - @router.get( "/mypayment/bank-account-holder", @@ -814,7 +825,7 @@ async def export_store_history( ) async def get_user_stores( db: AsyncSession = Depends(get_db), - user: CoreUser = Depends(is_user()), + user: CoreUser = Depends(is_user_allowed_to([MyPaymentPermissions.access_payment])), ): """ Get all stores for the current user. @@ -1224,7 +1235,7 @@ async def delete_store_seller( ) async def register_user( db: AsyncSession = Depends(get_db), - user: CoreUser = Depends(is_user()), + user: CoreUser = Depends(is_user_allowed_to([MyPaymentPermissions.access_payment])), ): """ Sign MyPayment TOS for the given user. @@ -1281,7 +1292,7 @@ async def register_user( ) async def get_user_tos( db: AsyncSession = Depends(get_db), - user: CoreUser = Depends(is_user()), + user: CoreUser = Depends(is_user_allowed_to([MyPaymentPermissions.access_payment])), settings: Settings = Depends(get_settings), ): """ @@ -1319,7 +1330,7 @@ async def sign_tos( signature: schemas_mypayment.TOSSignature, background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db), - user: CoreUser = Depends(is_user()), + user: CoreUser = Depends(is_user_allowed_to([MyPaymentPermissions.access_payment])), mail_templates: calypsso.MailTemplates = Depends(get_mail_templates), settings: Settings = Depends(get_settings), ): @@ -1381,7 +1392,7 @@ async def sign_tos( ) async def get_user_devices( db: AsyncSession = Depends(get_db), - user: CoreUser = Depends(is_user()), + user: CoreUser = Depends(is_user_allowed_to([MyPaymentPermissions.access_payment])), ): """ Get user devices. @@ -1413,7 +1424,7 @@ async def get_user_devices( async def get_user_device( wallet_device_id: UUID, db: AsyncSession = Depends(get_db), - user: CoreUser = Depends(is_user()), + user: CoreUser = Depends(is_user_allowed_to([MyPaymentPermissions.access_payment])), ): """ Get user devices. @@ -1458,7 +1469,7 @@ async def get_user_device( ) async def get_user_wallet( db: AsyncSession = Depends(get_db), - user: CoreUser = Depends(is_user()), + user: CoreUser = Depends(is_user_allowed_to([MyPaymentPermissions.access_payment])), ): """ Get user wallet. @@ -1499,7 +1510,7 @@ async def create_user_devices( wallet_device_creation: schemas_mypayment.WalletDeviceCreation, background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db), - user: CoreUser = Depends(is_user()), + user: CoreUser = Depends(is_user_allowed_to([MyPaymentPermissions.access_payment])), mail_templates: calypsso.MailTemplates = Depends(get_mail_templates), settings: Settings = Depends(get_settings), ): @@ -1712,7 +1723,7 @@ async def revoke_user_devices( ) async def get_user_wallet_history( db: AsyncSession = Depends(get_db), - user: CoreUser = Depends(is_user()), + user: CoreUser = Depends(is_user_allowed_to([MyPaymentPermissions.access_payment])), start_date: datetime | None = None, end_date: datetime | None = None, ): @@ -1842,7 +1853,7 @@ async def get_user_wallet_history( async def init_ha_transfer( transfer_info: schemas_mypayment.TransferInfo, db: AsyncSession = Depends(get_db), - user: CoreUser = Depends(is_user()), + user: CoreUser = Depends(is_user_allowed_to([MyPaymentPermissions.access_payment])), settings: Settings = Depends(get_settings), payment_tool: PaymentTool = Depends( get_payment_tool(HelloAssoConfigName.MYPAYMENT), @@ -1997,7 +2008,7 @@ async def validate_can_scan_qrcode( store_id: UUID, scan_info: schemas_mypayment.ScanInfo, db: AsyncSession = Depends(get_db), - user: CoreUser = Depends(is_user_a_school_member), + user: CoreUser = Depends(is_user()), ): """ Validate if a given QR Code can be scanned by the seller. @@ -2084,7 +2095,7 @@ async def store_scan_qrcode( store_id: UUID, scan_info: schemas_mypayment.ScanInfo, db: AsyncSession = Depends(get_db), - user: CoreUser = Depends(is_user_a_school_member), + user: CoreUser = Depends(is_user()), request_id: str = Depends(get_request_id), notification_tool: NotificationTool = Depends(get_notification_tool), settings: Settings = Depends(get_settings), @@ -2263,56 +2274,27 @@ async def store_scan_qrcode( detail="User is not a member of the association", ) - # We increment the receiving wallet balance - await cruds_mypayment.increment_wallet_balance( - wallet_id=store.wallet_id, - amount=scan_info.tot, - db=db, - ) - - # We decrement the debited wallet balance - await cruds_mypayment.increment_wallet_balance( - wallet_id=debited_wallet.id, - amount=-scan_info.tot, - db=db, - ) - transaction_id = uuid.uuid4() - creation_date = datetime.now(UTC) transaction = schemas_mypayment.TransactionBase( - id=transaction_id, + id=uuid.uuid4(), debited_wallet_id=debited_wallet_device.wallet_id, credited_wallet_id=store.wallet_id, transaction_type=TransactionType.DIRECT, seller_user_id=user.id, total=scan_info.tot, - creation=creation_date, + creation=datetime.now(UTC), status=TransactionStatus.CONFIRMED, qr_code_id=scan_info.id, ) - # We create a transaction - await cruds_mypayment.create_transaction( + await apply_transaction( + user_id=debited_wallet.user.id, + debited_wallet_device=debited_wallet_device, + store=store, transaction=transaction, - debited_wallet_device_id=debited_wallet_device.id, - store_note=None, db=db, + notification_tool=notification_tool, + settings=settings, ) - hyperion_mypayment_logger.info( - format_transaction_log(transaction), - extra={ - "s3_subfolder": MYPAYMENT_LOGS_S3_SUBFOLDER, - "s3_retention": RETENTION_DURATION, - }, - ) - message = Message( - title=f"💳 Paiement - {store.name}", - content=f"Une transaction de {scan_info.tot / 100} € a été effectuée", - action_module=settings.school.payment_name, - ) - await notification_tool.send_notification_to_user( - user_id=debited_wallet.user.id, - message=message, - ) return transaction @@ -2324,7 +2306,7 @@ async def refund_transaction( transaction_id: UUID, refund_info: schemas_mypayment.RefundInfo, db: AsyncSession = Depends(get_db), - user: CoreUser = Depends(is_user_a_school_member), + user: CoreUser = Depends(is_user()), notification_tool: NotificationTool = Depends(get_notification_tool), settings: Settings = Depends(get_settings), ): @@ -2516,7 +2498,7 @@ async def refund_transaction( async def cancel_transaction( transaction_id: UUID, db: AsyncSession = Depends(get_db), - user: CoreUser = Depends(is_user_a_school_member), + user: CoreUser = Depends(is_user()), request_id: str = Depends(get_request_id), notification_tool: NotificationTool = Depends(get_notification_tool), settings: Settings = Depends(get_settings), @@ -2645,6 +2627,264 @@ async def cancel_transaction( ) +@router.get( + "/mypayment/requests", + response_model=list[schemas_mypayment.Request], +) +async def get_user_requests( + used: bool | None = None, + db: AsyncSession = Depends(get_db), + user: CoreUser = Depends(is_user_allowed_to([MyPaymentPermissions.access_payment])), +): + """ + Get all requests made by the user. + + **The user must be authenticated to use this endpoint** + """ + user_payment = await cruds_mypayment.get_user_payment( + user_id=user.id, + db=db, + ) + if user_payment is None: + raise HTTPException( + status_code=404, + detail="User is not registered for MyPayment", + ) + return await cruds_mypayment.get_requests_by_wallet_id( + wallet_id=user_payment.wallet_id, + db=db, + include_used=used or False, + ) + + +@router.post( + "/mypayment/requests/{request_id}/accept", + status_code=204, +) +async def accept_request( + request_id: UUID, + request_validation: schemas_mypayment.RequestValidation, + db: AsyncSession = Depends(get_db), + user: CoreUser = Depends(is_user_allowed_to([MyPaymentPermissions.access_payment])), + http_request_id: str = Depends(get_request_id), + notification_tool: NotificationTool = Depends(get_notification_tool), + settings: Settings = Depends(get_settings), +): + """ + Confirm a request. + + **The user must be authenticated to use this endpoint** + """ + await cruds_mypayment.mark_expired_requests_as_expired( + db=db, + ) + await db.flush() + if request_id != request_validation.request_id: + raise HTTPException( + status_code=400, + detail="Request ID in the path and in the body do not match", + ) + request = await cruds_mypayment.get_request_by_id( + request_id=request_id, + db=db, + ) + if request is None: + raise HTTPException( + status_code=404, + detail="Request does not exist", + ) + + user_payment = await cruds_mypayment.get_user_payment( + user_id=user.id, + db=db, + ) + if user_payment is None: + raise HTTPException( + status_code=404, + detail="User is not registered for MyPayment", + ) + + if request.wallet_id != user_payment.wallet_id: + raise HTTPException( + status_code=403, + detail="User is not allowed to confirm this request", + ) + + debited_wallet_device = await cruds_mypayment.get_wallet_device( + wallet_device_id=request_validation.key, + db=db, + ) + if debited_wallet_device is None: + raise HTTPException( + status_code=404, + detail="Wallet device does not exist", + ) + if debited_wallet_device.wallet_id != user_payment.wallet_id: + raise HTTPException( + status_code=400, + detail="Wallet device is not associated with the user wallet", + ) + + if request.status != RequestStatus.PROPOSED: + raise HTTPException( + status_code=400, + detail="Only pending requests can be confirmed", + ) + if request.creation < datetime.now(UTC) - timedelta(minutes=REQUEST_EXPIRATION): + raise HTTPException( + status_code=400, + detail="Request is expired", + ) + + if not verify_signature( + public_key_bytes=debited_wallet_device.ed25519_public_key, + signature=request_validation.signature, + data=request_validation, + wallet_device_id=request_validation.key, + request_id=http_request_id, + ): + raise HTTPException( + status_code=400, + detail="Invalid signature", + ) + + # We verify that the debited walled contains enough money + debited_wallet = await cruds_mypayment.get_wallet( + wallet_id=debited_wallet_device.wallet_id, + db=db, + ) + if debited_wallet is None: + hyperion_error_logger.error( + f"MyPayment: Could not find wallet associated with the debited wallet device {debited_wallet_device.id}, this should never happen", + ) + raise HTTPException( + status_code=400, + detail="Could not find wallet associated with the debited wallet device", + ) + if debited_wallet.user is None or debited_wallet.store is not None: + raise HTTPException( + status_code=400, + detail="Stores are not allowed to make transaction by QR code", + ) + + debited_user_payment = await cruds_mypayment.get_user_payment( + debited_wallet.user.id, + db=db, + ) + if debited_user_payment is None or not is_user_latest_tos_signed( + debited_user_payment, + ): + raise HTTPException( + status_code=400, + detail="Debited user has not signed the latest TOS", + ) + + if debited_wallet.balance < request_validation.tot: + raise HTTPException( + status_code=400, + detail="Insufficient balance in the debited wallet", + ) + + store = await cruds_mypayment.get_store( + store_id=request.store_id, + db=db, + ) + if store is None: + raise HTTPException( + status_code=500, + detail="Store linked to the request does not exist", + ) + transaction = schemas_mypayment.TransactionBase( + id=uuid.uuid4(), + debited_wallet_id=debited_wallet_device.wallet_id, + credited_wallet_id=store.wallet_id, + transaction_type=TransactionType.DIRECT, + seller_user_id=user.id, + total=request_validation.tot, + creation=datetime.now(UTC), + status=TransactionStatus.CONFIRMED, + qr_code_id=None, + ) + await apply_transaction( + transaction=transaction, + debited_wallet_device=debited_wallet_device, + user_id=user.id, + db=db, + settings=settings, + notification_tool=notification_tool, + store=store, + ) + + await cruds_mypayment.update_request( + request_id=request_id, + request_update=schemas_mypayment.RequestEdit( + status=RequestStatus.ACCEPTED, + transaction_id=transaction.id, + ), + db=db, + ) + await call_mypayment_callback( + call_type=MyPaymentCallType.REQUEST, + module_root=request.module, + object_id=request.object_id, + call_id=request.id, + db=db, + ) + + +@router.post( + "/mypayment/requests/{request_id}/refuse", + status_code=204, +) +async def refuse_request( + request_id: UUID, + db: AsyncSession = Depends(get_db), + user: CoreUser = Depends(is_user_allowed_to([MyPaymentPermissions.access_payment])), +): + """ + Refuse a request. + + **The user must be authenticated to use this endpoint** + """ + request = await cruds_mypayment.get_request_by_id( + request_id=request_id, + db=db, + ) + if request is None: + raise HTTPException( + status_code=404, + detail="Request does not exist", + ) + + user_payment = await cruds_mypayment.get_user_payment( + user_id=user.id, + db=db, + ) + if user_payment is None: + raise HTTPException( + status_code=404, + detail="User is not registered for MyPayment", + ) + + if request.wallet_id != user_payment.wallet_id: + raise HTTPException( + status_code=403, + detail="User is not allowed to refuse this request", + ) + + if request.status != RequestStatus.PROPOSED: + raise HTTPException( + status_code=400, + detail="Only pending requests can be refused", + ) + + await cruds_mypayment.update_request( + request_id=request_id, + request_update=schemas_mypayment.RequestEdit(status=RequestStatus.REFUSED), + db=db, + ) + + @router.get( "/mypayment/invoices", response_model=list[schemas_mypayment.Invoice], diff --git a/app/core/mypayment/exceptions_mypayment.py b/app/core/mypayment/exceptions_mypayment.py index 1d639cc02d..7d57437a7d 100644 --- a/app/core/mypayment/exceptions_mypayment.py +++ b/app/core/mypayment/exceptions_mypayment.py @@ -29,3 +29,26 @@ class ReferencedStructureNotFoundError(Exception): def __init__(self, structure_id: UUID): super().__init__(f"Referenced structure {structure_id} not found") + + +class UnexpectedError(Exception): + pass + + +class TransferNotFoundByCallbackError(Exception): + def __init__(self, checkout_id: UUID): + super().__init__(f"User transfer {checkout_id} not found.") + + +class TransferTotalDontMatchInCallbackError(Exception): + def __init__(self, transfer_id: UUID): + super().__init__( + f"User transfer {transfer_id} amount does not match the paid amount", + ) + + +class TransferAlreadyConfirmedInCallbackError(Exception): + def __init__(self, transfer_id: UUID): + super().__init__( + f"User transfer {transfer_id} has already been confirmed", + ) diff --git a/app/core/mypayment/models_mypayment.py b/app/core/mypayment/models_mypayment.py index b6af28ce2f..c808d4759f 100644 --- a/app/core/mypayment/models_mypayment.py +++ b/app/core/mypayment/models_mypayment.py @@ -177,13 +177,14 @@ class Request(Base): __tablename__ = "mypayment_request" id: Mapped[PrimaryKey] - wallet_id: Mapped[str] = mapped_column(ForeignKey("mypayment_wallet.id")) + wallet_id: Mapped[UUID] = mapped_column(ForeignKey("mypayment_wallet.id")) creation: Mapped[datetime] total: Mapped[int] # Stored in cents - store_id: Mapped[str] = mapped_column(ForeignKey("mypayment_store.id")) + store_id: Mapped[UUID] = mapped_column(ForeignKey("mypayment_store.id")) name: Mapped[str] store_note: Mapped[str | None] - callback: Mapped[str] + module: Mapped[str] + object_id: Mapped[UUID] status: Mapped[RequestStatus] transaction_id: Mapped[UUID | None] = mapped_column( ForeignKey("mypayment_transaction.id"), diff --git a/app/core/mypayment/schemas_mypayment.py b/app/core/mypayment/schemas_mypayment.py index 8825a457de..50c63edf3b 100644 --- a/app/core/mypayment/schemas_mypayment.py +++ b/app/core/mypayment/schemas_mypayment.py @@ -10,6 +10,7 @@ from app.core.memberships import schemas_memberships from app.core.mypayment.types_mypayment import ( HistoryType, + RequestStatus, TransactionStatus, TransactionType, TransferType, @@ -342,3 +343,35 @@ class Withdrawal(BaseModel): wallet_id: UUID total: int # Stored in cents creation: datetime + + +class Request(BaseModel): + id: UUID + wallet_id: UUID + creation: datetime + total: int # Stored in cents + store_id: UUID + name: str + store_note: str | None = None + module: str + object_id: UUID + status: RequestStatus + transaction_id: UUID | None = None + + +class RequestEdit(BaseModel): + name: str | None = None + store_note: str | None = None + status: RequestStatus | None = None + transaction_id: UUID | None = None + + +class RequestValidationData(BaseModel): + request_id: UUID + key: UUID + iat: datetime + tot: int + + +class RequestValidation(RequestValidationData): + signature: str diff --git a/app/core/mypayment/types_mypayment.py b/app/core/mypayment/types_mypayment.py index 2fce885bc4..21eaa5da4c 100644 --- a/app/core/mypayment/types_mypayment.py +++ b/app/core/mypayment/types_mypayment.py @@ -1,25 +1,24 @@ -from enum import Enum -from uuid import UUID +from enum import StrEnum -class WalletType(str, Enum): +class WalletType(StrEnum): USER = "user" STORE = "store" -class WalletDeviceStatus(str, Enum): +class WalletDeviceStatus(StrEnum): INACTIVE = "inactive" ACTIVE = "active" REVOKED = "revoked" -class TransactionType(str, Enum): +class TransactionType(StrEnum): DIRECT = "direct" REQUEST = "request" REFUND = "refund" -class HistoryType(str, Enum): +class HistoryType(StrEnum): TRANSFER = "transfer" RECEIVED = "received" GIVEN = "given" @@ -27,7 +26,7 @@ class HistoryType(str, Enum): REFUND_DEBITED = "refund_debited" -class TransactionStatus(str, Enum): +class TransactionStatus(StrEnum): """ CONFIRMED: The transaction has been confirmed and is complete. CANCELED: The transaction has been canceled. It is used for transfer requests, for which the user has 15 minutes to complete the HelloAsso checkout @@ -41,17 +40,18 @@ class TransactionStatus(str, Enum): PENDING = "pending" -class RequestStatus(str, Enum): +class RequestStatus(StrEnum): PROPOSED = "proposed" ACCEPTED = "accepted" REFUSED = "refused" + EXPIRED = "expired" -class TransferType(str, Enum): +class TransferType(StrEnum): HELLO_ASSO = "hello_asso" -class ActionType(str, Enum): +class ActionType(StrEnum): TRANSFER = "transfer" REFUND = "refund" CANCEL = "cancel" @@ -59,24 +59,6 @@ class ActionType(str, Enum): WITHDRAWAL = "withdrawal" -class UnexpectedError(Exception): - pass - - -class TransferNotFoundByCallbackError(Exception): - def __init__(self, checkout_id: UUID): - super().__init__(f"User transfer {checkout_id} not found.") - - -class TransferTotalDontMatchInCallbackError(Exception): - def __init__(self, transfer_id: UUID): - super().__init__( - f"User transfer {transfer_id} amount does not match the paid amount", - ) - - -class TransferAlreadyConfirmedInCallbackError(Exception): - def __init__(self, transfer_id: UUID): - super().__init__( - f"User transfer {transfer_id} has already been confirmed", - ) +class MyPaymentCallType(StrEnum): + TRANSFER = "transfer" + REQUEST = "request" diff --git a/app/core/mypayment/utils_mypayment.py b/app/core/mypayment/utils_mypayment.py index 01b613f86c..544c7332e9 100644 --- a/app/core/mypayment/utils_mypayment.py +++ b/app/core/mypayment/utils_mypayment.py @@ -1,25 +1,42 @@ import base64 import logging -from uuid import UUID +from datetime import UTC, datetime +from uuid import UUID, uuid4 from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives.asymmetric import ed25519 +from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession from app.core.checkout import schemas_checkout +from app.core.checkout.payment_tool import PaymentTool from app.core.memberships import schemas_memberships from app.core.mypayment import cruds_mypayment, models_mypayment, schemas_mypayment -from app.core.mypayment.integrity_mypayment import format_transfer_log +from app.core.mypayment.exceptions_mypayment import ( + TransferAlreadyConfirmedInCallbackError, + TransferNotFoundByCallbackError, + TransferTotalDontMatchInCallbackError, + WalletNotFoundOnUpdateError, +) +from app.core.mypayment.integrity_mypayment import ( + format_transaction_log, + format_transfer_log, +) from app.core.mypayment.models_mypayment import UserPayment from app.core.mypayment.schemas_mypayment import ( QRCodeContentData, + RequestValidationData, ) from app.core.mypayment.types_mypayment import ( - TransferAlreadyConfirmedInCallbackError, - TransferNotFoundByCallbackError, - TransferTotalDontMatchInCallbackError, + MyPaymentCallType, + RequestStatus, + TransferType, ) +from app.core.notification.schemas_notification import Message from app.core.users import schemas_users +from app.core.utils.config import Settings +from app.module import all_modules +from app.utils.communication.notifications import NotificationTool hyperion_security_logger = logging.getLogger("hyperion.security") hyperion_mypayment_logger = logging.getLogger("hyperion.mypayment") @@ -27,14 +44,21 @@ LATEST_TOS = 2 QRCODE_EXPIRATION = 5 # minutes -MYPAYMENT_LOGS_S3_SUBFOLDER = "logs" +REQUEST_EXPIRATION = 15 # minutes RETENTION_DURATION = 10 * 365 # 10 years in days +MYPAYMENT_ROOT = "mypayment" + +MYPAYMENT_STRUCTURE_S3_SUBFOLDER = "structures" +MYPAYMENT_STORES_S3_SUBFOLDER = "stores" +MYPAYMENT_USERS_S3_SUBFOLDER = "users" +MYPAYMENT_DEVICES_S3_SUBFOLDER = "devices" +MYPAYMENT_LOGS_S3_SUBFOLDER = "logs" def verify_signature( public_key_bytes: bytes, signature: str, - data: QRCodeContentData, + data: QRCodeContentData | RequestValidationData, wallet_device_id: UUID, request_id: str, ) -> bool: @@ -89,6 +113,14 @@ async def validate_transfer_callback( ) raise TransferNotFoundByCallbackError(checkout_id) + wallet = await cruds_mypayment.get_wallet(transfer.wallet_id, db) + + if not wallet: + hyperion_error_logger.error( + f"MyPayment payment callback: wallet with id {transfer.wallet_id} not found for transfer {transfer.id}.", + ) + raise WalletNotFoundOnUpdateError(transfer.wallet_id) + if transfer.total != paid_amount: hyperion_error_logger.error( f"MyPayment payment callback: user transfer {transfer.id} amount does not match the paid amount.", @@ -118,6 +150,208 @@ async def validate_transfer_callback( "s3_retention": RETENTION_DURATION, }, ) + if wallet.store: # This transfer is a direct transfer to a store, it was requested by a module, so we want to call the module callback if it exists + if transfer.module and transfer.object_id: + await call_mypayment_callback( + call_type=MyPaymentCallType.TRANSFER, + module_root=transfer.module, + object_id=transfer.object_id, + call_id=transfer.id, + db=db, + ) + + +async def request_transaction( + user_id: str, + store_id: UUID, + total: int, + name: str, + note: str | None, + module: str, + object_id: UUID, + db: AsyncSession, + notification_tool: NotificationTool, + settings: Settings, +) -> UUID: + """ + Create a transaction request for a user from a store. + """ + payment_user = await cruds_mypayment.get_user_payment(user_id, db) + if not payment_user: + raise HTTPException( + status_code=400, + detail=f"User {user_id} does not have a payment account", + ) + request_id = uuid4() + await cruds_mypayment.create_request( + db=db, + request=schemas_mypayment.Request( + id=request_id, + wallet_id=payment_user.wallet_id, + creation=datetime.now(UTC), + total=total, + store_id=store_id, + name=name, + store_note=note, + module=module, + object_id=object_id, + status=RequestStatus.PROPOSED, + ), + ) + message = Message( + title=f"💸 Nouvelle demande de paiement - {name}", + content=f"Une nouvelle demande de paiement de {total / 100} € attend votre validation", + action_module=settings.school.payment_name, + ) + await notification_tool.send_notification_to_user( + user_id=user_id, + message=message, + ) + return request_id + + +async def request_store_transfer( + user: schemas_users.CoreUser, + transfer_info: schemas_mypayment.StoreTransferInfo, + db: AsyncSession, + payment_tool: PaymentTool, + settings: Settings, +) -> schemas_checkout.PaymentUrl: + """ + Create a direct transfer to a store + """ + if transfer_info.redirect_url not in settings.TRUSTED_PAYMENT_REDIRECT_URLS: + hyperion_error_logger.warning( + f"User {user.id} tried to redirect to an untrusted URL: {transfer_info.redirect_url}", + ) + raise HTTPException( + status_code=400, + detail="Redirect URL is not trusted by hyperion", + ) + + if transfer_info.amount < 100: + raise HTTPException( + status_code=400, + detail="Please give an amount in cents, greater than 1€.", + ) + + store = await cruds_mypayment.get_store(transfer_info.store_id, db) + if not store: + raise HTTPException( + status_code=404, + detail=f"Store with id {transfer_info.store_id} not found", + ) + + checkout = await payment_tool.init_checkout( + module=MYPAYMENT_ROOT, + checkout_amount=transfer_info.amount, + checkout_name=f"Recharge {settings.school.payment_name}", + redirection_uri=f"{settings.CLIENT_URL}mypayment/transfer/redirect?url={transfer_info.redirect_url}", + payer_user=user, + db=db, + ) + + await cruds_mypayment.create_transfer( + db=db, + transfer=schemas_mypayment.Transfer( + id=uuid4(), + type=TransferType.HELLO_ASSO, + approver_user_id=None, + total=transfer_info.amount, + transfer_identifier=str(checkout.id), + wallet_id=store.wallet_id, + creation=datetime.now(UTC), + confirmed=False, + module=transfer_info.module, + object_id=transfer_info.object_id, + ), + ) + + return schemas_checkout.PaymentUrl( + url=checkout.payment_url, + ) + + +async def apply_transaction( + user_id: str, + transaction: schemas_mypayment.TransactionBase, + debited_wallet_device: models_mypayment.WalletDevice, + store: models_mypayment.Store, + settings: Settings, + notification_tool: NotificationTool, + db: AsyncSession, +): + # We increment the receiving wallet balance + await cruds_mypayment.increment_wallet_balance( + wallet_id=transaction.credited_wallet_id, + amount=transaction.total, + db=db, + ) + + # We decrement the debited wallet balance + await cruds_mypayment.increment_wallet_balance( + wallet_id=transaction.debited_wallet_id, + amount=-transaction.total, + db=db, + ) + # We create a transaction + await cruds_mypayment.create_transaction( + transaction=transaction, + debited_wallet_device_id=debited_wallet_device.id, + store_note=None, + db=db, + ) + + hyperion_mypayment_logger.info( + format_transaction_log(transaction), + extra={ + "s3_subfolder": MYPAYMENT_LOGS_S3_SUBFOLDER, + "s3_retention": RETENTION_DURATION, + }, + ) + message = Message( + title=f"💳 Paiement - {store.name}", + content=f"Une transaction de {transaction.total / 100} € a été effectuée", + action_module=settings.school.payment_name, + ) + await notification_tool.send_notification_to_user( + user_id=user_id, + message=message, + ) + + +async def call_mypayment_callback( + call_type: MyPaymentCallType, + module_root: str, + object_id: UUID, + call_id: UUID, + db: AsyncSession, +): + id_name = "transfer_id" if call_type == MyPaymentCallType.TRANSFER else "request_id" + try: + for module in all_modules: + if module.root == module_root: + if module.mypayment_callback is None: + hyperion_error_logger.info( + f"MyPayment: module {module_root} does not define a request callback ({id_name}: {call_id})", + ) + return + hyperion_error_logger.info( + f"MyPayment: calling module {module_root} request callback", + ) + await module.mypayment_callback(object_id, db) + hyperion_error_logger.info( + f"MyPayment: call to module {module_root} request callback ({id_name}: {call_id}) succeeded", + ) + return + + hyperion_error_logger.info( + f"MyPayment: request callback for module {module_root} not found ({id_name}: {call_id})", + ) + except Exception: + hyperion_error_logger.exception( + f"MyPayment: call to module {module_root} request callback ({id_name}: {call_id}) failed", + ) def structure_model_to_schema( diff --git a/app/modules/mypayment/__init__.py b/app/modules/mypayment/__init__.py deleted file mode 100644 index 8b13789179..0000000000 --- a/app/modules/mypayment/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/app/modules/mypayment/endpoints_mypayment.py b/app/modules/mypayment/endpoints_mypayment.py deleted file mode 100644 index a101a85116..0000000000 --- a/app/modules/mypayment/endpoints_mypayment.py +++ /dev/null @@ -1,16 +0,0 @@ -from app.core.groups.groups_type import AccountType -from app.core.permissions.type_permissions import ModulePermissions -from app.types.module import Module - - -class MyPaymentPermissions(ModulePermissions): - access_payment = "access_payment" - - -module = Module( - root="mypayment", - tag="MyPayment", - default_allowed_account_types=[AccountType.student, AccountType.staff], - factory=None, - permissions=MyPaymentPermissions, -) diff --git a/app/types/module.py b/app/types/module.py index 3b87068e6d..9a323e0fc6 100644 --- a/app/types/module.py +++ b/app/types/module.py @@ -1,10 +1,12 @@ from collections.abc import Awaitable, Callable +from uuid import UUID from fastapi import APIRouter from sqlalchemy.ext.asyncio import AsyncSession from app.core.checkout import schemas_checkout from app.core.groups.groups_type import AccountType, GroupType +from app.core.mypayment import schemas_mypayment from app.core.notification.schemas_notification import Topic from app.core.permissions.type_permissions import ModulePermissions from app.types.factory import Factory @@ -22,6 +24,11 @@ def __init__( Awaitable[None], ] | None = None, + mypayment_callback: Callable[ + [UUID, AsyncSession], + Awaitable[None], + ] + | None = None, registred_topics: list[Topic] | None = None, permissions: type[ModulePermissions] | None = None, ): @@ -42,6 +49,9 @@ def __init__( Callable[[schemas_checkout.CheckoutPayment, AsyncSession], Awaitable[None]] | None ) = payment_callback + self.mypayment_callback: ( + Callable[[UUID, AsyncSession], Awaitable[None]] | None + ) = mypayment_callback self.registred_topics = registred_topics self.factory = factory self.permissions = permissions @@ -61,6 +71,11 @@ def __init__( Awaitable[None], ] | None = None, + mypayment_callback: Callable[ + [UUID, AsyncSession], + Awaitable[None], + ] + | None = None, registred_topics: list[Topic] | None = None, permissions: type[ModulePermissions] | None = None, ): @@ -85,6 +100,9 @@ def __init__( Callable[[schemas_checkout.CheckoutPayment, AsyncSession], Awaitable[None]] | None ) = payment_callback + self.mypayment_callback: ( + Callable[[UUID, AsyncSession], Awaitable[None]] | None + ) = mypayment_callback self.registred_topics = registred_topics self.factory = factory self.permissions = permissions From b9c02956e1e494d43da987697b1fec4e8ab833b5 Mon Sep 17 00:00:00 2001 From: Thonyk Date: Mon, 6 Apr 2026 18:50:39 +0200 Subject: [PATCH 02/72] feat: support direct store transfer --- app/core/mypayment/cruds_mypayment.py | 57 +++++++++++++++++++---- app/core/mypayment/endpoints_mypayment.py | 19 ++++---- app/core/mypayment/models_mypayment.py | 6 +++ app/core/mypayment/schemas_mypayment.py | 8 ++++ app/core/mypayment/utils/data_exporter.py | 22 ++++++++- 5 files changed, 91 insertions(+), 21 deletions(-) diff --git a/app/core/mypayment/cruds_mypayment.py b/app/core/mypayment/cruds_mypayment.py index 4b53637afc..02db8955c5 100644 --- a/app/core/mypayment/cruds_mypayment.py +++ b/app/core/mypayment/cruds_mypayment.py @@ -706,6 +706,8 @@ async def get_transfers( total=transfer.total, creation=transfer.creation, confirmed=transfer.confirmed, + module=transfer.module, + object_id=transfer.object_id, ) for transfer in result.scalars().all() ] @@ -724,6 +726,8 @@ async def create_transfer( total=transfer.total, creation=transfer.creation, confirmed=transfer.confirmed, + module=transfer.module, + object_id=transfer.object_id, ) db.add(transfer_db) @@ -744,7 +748,7 @@ async def get_transfers_by_wallet_id( db: AsyncSession, start_datetime: datetime | None = None, end_datetime: datetime | None = None, -) -> Sequence[models_mypayment.Transfer]: +) -> list[schemas_mypayment.Transfer]: result = await db.execute( select(models_mypayment.Transfer) .where( @@ -759,7 +763,21 @@ async def get_transfers_by_wallet_id( else and_(True), ), ) - return result.scalars().all() + return [ + schemas_mypayment.Transfer( + id=transfer.id, + type=transfer.type, + transfer_identifier=transfer.transfer_identifier, + approver_user_id=transfer.approver_user_id, + wallet_id=transfer.wallet_id, + total=transfer.total, + creation=transfer.creation, + confirmed=transfer.confirmed, + module=transfer.module, + object_id=transfer.object_id, + ) + for transfer in result.scalars().all() + ] async def get_transfers_and_sellers_by_wallet_id( @@ -801,13 +819,36 @@ async def get_transfers_and_sellers_by_wallet_id( async def get_transfer_by_transfer_identifier( db: AsyncSession, transfer_identifier: str, -) -> models_mypayment.Transfer | None: - result = await db.execute( - select(models_mypayment.Transfer).where( - models_mypayment.Transfer.transfer_identifier == transfer_identifier, - ), +) -> schemas_mypayment.Transfer | None: + result = ( + ( + await db.execute( + select(models_mypayment.Transfer).where( + models_mypayment.Transfer.transfer_identifier + == transfer_identifier, + ), + ) + ) + .scalars() + .first() + ) + + return ( + schemas_mypayment.Transfer( + id=result.id, + type=result.type, + transfer_identifier=result.transfer_identifier, + approver_user_id=result.approver_user_id, + wallet_id=result.wallet_id, + total=result.total, + creation=result.creation, + confirmed=result.confirmed, + module=result.module, + object_id=result.object_id, + ) + if result + else None ) - return result.scalars().first() async def get_refunds( diff --git a/app/core/mypayment/endpoints_mypayment.py b/app/core/mypayment/endpoints_mypayment.py index 854a61c99c..c77e2d6a39 100644 --- a/app/core/mypayment/endpoints_mypayment.py +++ b/app/core/mypayment/endpoints_mypayment.py @@ -756,18 +756,12 @@ async def export_store_history( ) ) - transfers_with_sellers = ( - await cruds_mypayment.get_transfers_and_sellers_by_wallet_id( - wallet_id=store.wallet_id, - db=db, - start_datetime=start_date, - end_datetime=end_date, - ) + direct_transfers = await cruds_mypayment.get_transfers_by_wallet_id( + wallet_id=store.wallet_id, + db=db, + start_datetime=start_date, + end_datetime=end_date, ) - if len(transfers_with_sellers) > 0: - hyperion_error_logger.error( - f"Store {store.id} should never have transfers", - ) # We add refunds refunds_with_sellers = await cruds_mypayment.get_refunds_and_sellers_by_wallet_id( @@ -787,6 +781,7 @@ async def export_store_history( csv_content = generate_store_history_csv( transactions_with_sellers=list(transactions_with_sellers), refunds_map=refunds_map, + direct_transfers=direct_transfers, store_wallet_id=store.wallet_id, ) @@ -1947,6 +1942,8 @@ async def init_ha_transfer( wallet_id=user_payment.wallet_id, creation=datetime.now(UTC), confirmed=False, + module=None, + object_id=None, ), ) diff --git a/app/core/mypayment/models_mypayment.py b/app/core/mypayment/models_mypayment.py index c808d4759f..0908f7eb8c 100644 --- a/app/core/mypayment/models_mypayment.py +++ b/app/core/mypayment/models_mypayment.py @@ -207,6 +207,12 @@ class Transfer(Base): creation: Mapped[datetime] confirmed: Mapped[bool] + # Store transfer can occur when a user ask for a direct payment instead of a payment request. + # In this case, we want to keep the information of module and object that generated the transfer, + # to be able to call the right callback when the transfer is confirmed + module: Mapped[str | None] + object_id: Mapped[UUID | None] + class Seller(Base): __tablename__ = "mypayment_seller" diff --git a/app/core/mypayment/schemas_mypayment.py b/app/core/mypayment/schemas_mypayment.py index 50c63edf3b..8bf21a6d91 100644 --- a/app/core/mypayment/schemas_mypayment.py +++ b/app/core/mypayment/schemas_mypayment.py @@ -144,6 +144,12 @@ class TransferInfo(BaseModel): redirect_url: str +class StoreTransferInfo(TransferInfo): + store_id: UUID + module: str + object_id: UUID + + class RefundInfo(BaseModel): complete_refund: bool amount: int | None = None @@ -254,6 +260,8 @@ class Transfer(BaseModel): total: int # Stored in cents creation: datetime confirmed: bool + module: str | None + object_id: UUID | None class RefundBase(BaseModel): diff --git a/app/core/mypayment/utils/data_exporter.py b/app/core/mypayment/utils/data_exporter.py index 42e356b5f3..af0ef8ff70 100644 --- a/app/core/mypayment/utils/data_exporter.py +++ b/app/core/mypayment/utils/data_exporter.py @@ -1,12 +1,14 @@ import csv from io import StringIO +from uuid import UUID -from app.core.mypayment import models_mypayment +from app.core.mypayment import models_mypayment, schemas_mypayment def generate_store_history_csv( transactions_with_sellers: list[tuple[models_mypayment.Transaction, str | None]], - refunds_map: dict, + refunds_map: dict[UUID, tuple[models_mypayment.Refund, str | None]], + direct_transfers: list[schemas_mypayment.Transfer], store_wallet_id, ) -> str: """ @@ -82,6 +84,22 @@ def generate_store_history_csv( ], ) + # Write direct transfers data + for transfer in direct_transfers: + writer.writerow( + [ + transfer.creation.strftime("%d/%m/%Y %H:%M:%S"), + "REÇU", + "HelloAsso", + str(transfer.total / 100), + "CONFIRMÉ" if transfer.confirmed else "EN ATTENTE", + "N/A", + "", + "", + f"Transfert direct pour le module {transfer.module}", + ], + ) + csv_content = csv_io.getvalue() csv_io.close() From 7c45db876a0d47ac7e334cf0850a0dc7a7bb36a6 Mon Sep 17 00:00:00 2001 From: Thonyk Date: Mon, 6 Apr 2026 18:50:39 +0200 Subject: [PATCH 03/72] feat: migration and tests --- migrations/versions/66-mypayment-extended.py | 62 ++++++ tests/core/test_mypayment.py | 222 ++++++++++++++++++- 2 files changed, 280 insertions(+), 4 deletions(-) create mode 100644 migrations/versions/66-mypayment-extended.py diff --git a/migrations/versions/66-mypayment-extended.py b/migrations/versions/66-mypayment-extended.py new file mode 100644 index 0000000000..9cd1ef135f --- /dev/null +++ b/migrations/versions/66-mypayment-extended.py @@ -0,0 +1,62 @@ +"""empty message + +Create Date: 2026-03-19 15:49:33.554684 +""" + +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 = "46fbbcee7237" +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.add_column("mypayment_request", sa.Column("module", sa.String(), nullable=False)) + op.add_column( + "mypayment_request", + sa.Column("object_id", sa.Uuid(), nullable=False), + ) + op.drop_column("mypayment_request", "callback") + op.add_column("mypayment_transfer", sa.Column("module", sa.String(), nullable=True)) + op.add_column( + "mypayment_transfer", + sa.Column("object_id", sa.Uuid(), nullable=True), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("mypayment_transfer", "object_id") + op.drop_column("mypayment_transfer", "module") + op.add_column( + "mypayment_request", + sa.Column("callback", sa.VARCHAR(), autoincrement=False, nullable=False), + ) + op.drop_column("mypayment_request", "module") + op.drop_column("mypayment_request", "object_id") + # ### end Alembic commands ### + + +def pre_test_upgrade( + alembic_runner: "MigrationContext", + alembic_connection: sa.Connection, +) -> None: + pass + + +def test_upgrade( + alembic_runner: "MigrationContext", + alembic_connection: sa.Connection, +) -> None: + pass diff --git a/tests/core/test_mypayment.py b/tests/core/test_mypayment.py index 9820d8a611..abc443f36b 100644 --- a/tests/core/test_mypayment.py +++ b/tests/core/test_mypayment.py @@ -10,24 +10,35 @@ ) from fastapi.testclient import TestClient from pytest_mock import MockerFixture +from sqlalchemy.ext.asyncio import AsyncSession +from app.core.checkout import schemas_checkout from app.core.groups import models_groups -from app.core.groups.groups_type import GroupType +from app.core.groups.groups_type import AccountType, GroupType from app.core.memberships import models_memberships -from app.core.mypayment import cruds_mypayment, models_mypayment +from app.core.mypayment import cruds_mypayment, models_mypayment, schemas_mypayment from app.core.mypayment.coredata_mypayment import ( MyPaymentBankAccountHolder, ) -from app.core.mypayment.schemas_mypayment import QRCodeContentData +from app.core.mypayment.endpoints_mypayment import MyPaymentPermissions +from app.core.mypayment.schemas_mypayment import ( + QRCodeContentData, + RequestValidation, + RequestValidationData, +) from app.core.mypayment.types_mypayment import ( + MyPaymentCallType, + RequestStatus, TransactionStatus, TransactionType, TransferType, WalletDeviceStatus, WalletType, ) -from app.core.mypayment.utils_mypayment import LATEST_TOS +from app.core.mypayment.utils_mypayment import LATEST_TOS, validate_transfer_callback +from app.core.permissions import models_permissions from app.core.users import models_users +from app.types.module import Module from tests.commons import ( add_coredata_to_db, add_object_to_db, @@ -37,6 +48,8 @@ get_TestingSessionLocal, ) +TEST_MODULE_ROOT = "tests" + bde_group: models_groups.CoreGroup admin_user: models_users.CoreUser @@ -72,6 +85,7 @@ store3: models_mypayment.Store store_wallet_device_private_key: Ed25519PrivateKey store_wallet_device: models_mypayment.WalletDevice +store_direct_transfer: models_mypayment.Transfer transaction_from_ecl_user_to_store: models_mypayment.Transaction @@ -88,6 +102,10 @@ invoice2_detail: models_mypayment.InvoiceDetail invoice3_detail: models_mypayment.InvoiceDetail +proposed_request: models_mypayment.Request +expired_request: models_mypayment.Request +refused_request: models_mypayment.Request + store_seller_can_bank_user: models_users.CoreUser store_seller_no_permission_user_access_token: str store_seller_can_bank_user_access_token: str @@ -103,6 +121,14 @@ @pytest_asyncio.fixture(scope="module", autouse=True) async def init_objects() -> None: + for account_type in AccountType: + await add_object_to_db( + models_permissions.CorePermissionAccountType( + permission_name=MyPaymentPermissions.access_payment.value, + account_type=account_type, + ), + ) + global bde_group bde_group = await create_groups_with_permissions( [], @@ -352,6 +378,21 @@ async def init_objects() -> None: ) await add_object_to_db(store_wallet_device) + global store_direct_transfer + store_direct_transfer = models_mypayment.Transfer( + id=uuid4(), + type=TransferType.HELLO_ASSO, + transfer_identifier=str(uuid4()), + approver_user_id=None, + wallet_id=store_wallet.id, + total=1500, # 15€ + creation=datetime.now(UTC), + confirmed=False, + module=TEST_MODULE_ROOT, + object_id=uuid4(), + ) + await add_object_to_db(store_direct_transfer) + # Create test transactions global transaction_from_ecl_user_to_store transaction_from_ecl_user_to_store = models_mypayment.Transaction( @@ -430,6 +471,8 @@ async def init_objects() -> None: total=1000, # 10€ creation=datetime.now(UTC), confirmed=True, + module=None, + object_id=None, ) await add_object_to_db(ecl_user_transfer) @@ -599,6 +642,50 @@ async def init_objects() -> None: ) await add_object_to_db(invoice3_detail) + global proposed_request, expired_request, refused_request + proposed_request = models_mypayment.Request( + id=uuid4(), + wallet_id=ecl_user_wallet.id, + store_id=store.id, + total=1000, + name="Proposed Request", + store_note="Proposed Request Note", + status=RequestStatus.PROPOSED, + module=TEST_MODULE_ROOT, + object_id=uuid4(), + transaction_id=None, + creation=datetime.now(UTC), + ) + await add_object_to_db(proposed_request) + expired_request = models_mypayment.Request( + id=uuid4(), + wallet_id=ecl_user_wallet.id, + store_id=store.id, + total=1000, + name="Expired Request", + store_note="Expired Request Note", + status=RequestStatus.EXPIRED, + module=TEST_MODULE_ROOT, + object_id=uuid4(), + transaction_id=None, + creation=datetime.now(UTC) - timedelta(days=30), + ) + await add_object_to_db(expired_request) + refused_request = models_mypayment.Request( + id=uuid4(), + wallet_id=ecl_user_wallet.id, + store_id=store.id, + total=1000, + name="Refused Request", + store_note="Refused Request Note", + status=RequestStatus.REFUSED, + module=TEST_MODULE_ROOT, + object_id=uuid4(), + transaction_id=None, + creation=datetime.now(UTC) - timedelta(days=30), + ) + await add_object_to_db(refused_request) + async def test_get_structures(client: TestClient): response = client.get( @@ -3211,3 +3298,130 @@ async def test_delete_invoice( ) assert response.status_code == 200 assert not any(invoice["id"] == invoice3.id for invoice in response.json()) + + +async def mypayment_callback( + object_id: UUID, + db: AsyncSession, +) -> None: + pass + + +async def test_get_request( + client: TestClient, +): + response = client.get( + "/mypayment/requests", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + ) + assert response.status_code == 200 + assert len(response.json()) == 1 + assert response.json()[0]["id"] == str(proposed_request.id) + + +async def test_get_request_with_used_filter( + client: TestClient, +): + response = client.get( + "/mypayment/requests?used=true", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + ) + assert response.status_code == 200 + assert len(response.json()) == 3 + + +async def test_accept_request( + mocker: MockerFixture, + client: TestClient, +): + # We patch the callback to be able to check if it was called + mocked_callback = mocker.patch( + "tests.core.test_mypayment.mypayment_callback", + ) + + # We patch the module_list to inject our custom test module + test_module = Module( + root=TEST_MODULE_ROOT, + tag="Tests", + default_allowed_groups_ids=[], + mypayment_callback=mypayment_callback, + factory=None, + permissions=None, + ) + mocker.patch( + "app.core.mypayment.utils_mypayment.all_modules", + [test_module], + ) + + validation_data = RequestValidationData( + request_id=proposed_request.id, + key=ecl_user_wallet_device.id, + iat=datetime.now(UTC), + tot=proposed_request.total, + ) + validation_data_signature = ecl_user_wallet_device_private_key.sign( + validation_data.model_dump_json().encode("utf-8"), + ) + validation = RequestValidation( + **validation_data.model_dump(), + signature=base64.b64encode(validation_data_signature).decode("utf-8"), + ) + response = client.post( + f"/mypayment/requests/{proposed_request.id}/accept", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + json=validation.model_dump(mode="json"), + ) + assert response.status_code == 204 + + responser = client.get( + "/mypayment/requests?used=true", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + ) + assert responser.status_code == 200 + accepted = next( + ( + request + for request in responser.json() + if request["id"] == str(proposed_request.id) + ), + None, + ) + assert accepted is not None + assert accepted["status"] == RequestStatus.ACCEPTED + mocked_callback.assert_called_once() + + +async def test_direct_transfer_callback( + mocker: MockerFixture, + client: TestClient, +): + # We patch the callback to be able to check if it was called + mocked_callback = mocker.patch( + "tests.core.test_mypayment.mypayment_callback", + ) + + # We patch the module_list to inject our custom test module + test_module = Module( + root=TEST_MODULE_ROOT, + tag="Tests", + default_allowed_groups_ids=[], + mypayment_callback=mypayment_callback, + factory=None, + permissions=None, + ) + mocker.patch( + "app.core.mypayment.utils_mypayment.all_modules", + [test_module], + ) + + async with get_TestingSessionLocal()() as db: + await validate_transfer_callback( + checkout_payment=schemas_checkout.CheckoutPayment( + id=uuid4(), + paid_amount=1500, + checkout_id=UUID(store_direct_transfer.transfer_identifier), + ), + db=db, + ) + + mocked_callback.assert_called_once() From 3b3b268f0533754ca3d855ea4ca158d1f51cc7b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Robert?= Date: Mon, 6 Apr 2026 18:50:39 +0200 Subject: [PATCH 04/72] feat: apply suggestions --- app/core/checkout/endpoints_checkout.py | 12 +++++++----- app/core/mypayment/cruds_mypayment.py | 5 +---- app/core/mypayment/endpoints_mypayment.py | 6 +++--- app/modules/cdr/endpoints_cdr.py | 2 +- app/modules/raid/endpoints_raid.py | 2 +- .../sport_competition/endpoints_sport_competition.py | 2 +- app/types/module.py | 12 ++++++------ tests/core/test_checkout.py | 4 ++-- 8 files changed, 22 insertions(+), 23 deletions(-) diff --git a/app/core/checkout/endpoints_checkout.py b/app/core/checkout/endpoints_checkout.py index dca3a57b1c..84af7002b2 100644 --- a/app/core/checkout/endpoints_checkout.py +++ b/app/core/checkout/endpoints_checkout.py @@ -73,9 +73,11 @@ async def webhook( ): # We may receive the webhook multiple times, we only want to save a CheckoutPayment # in the database the first time - existing_checkout_payment_model = await cruds_checkout.get_checkout_payment_by_hello_asso_payment_id( - hello_asso_payment_id=content.data.id, # ty:ignore[unresolved-attribute] - db=db, + existing_checkout_payment_model = ( + await cruds_checkout.get_checkout_payment_by_hello_asso_payment_id( + hello_asso_payment_id=content.data.id, # ty:ignore[unresolved-attribute] + db=db, + ) ) if existing_checkout_payment_model is not None: hyperion_error_logger.debug( @@ -134,7 +136,7 @@ async def webhook( try: for module in all_modules: if module.root == checkout.module: - if module.payment_callback is None: + if module.checkout_callback is None: hyperion_error_logger.info( f"Payment: module {checkout.module} does not define a payment callback for checkout (hyperion_checkout_id: {checkout_metadata.hyperion_checkout_id}, HelloAsso checkout_id: {checkout.id})", ) @@ -147,7 +149,7 @@ async def webhook( paid_amount=checkout_payment_model.paid_amount, checkout_id=checkout_payment_model.checkout_id, ) - await module.payment_callback(checkout_payment_schema, db) + await module.checkout_callback(checkout_payment_schema, db) hyperion_error_logger.info( f"Payment: call to module {checkout.module} payment callback for checkout (hyperion_checkout_id: {checkout_metadata.hyperion_checkout_id}, HelloAsso checkout_id: {checkout.id}) succeeded", ) diff --git a/app/core/mypayment/cruds_mypayment.py b/app/core/mypayment/cruds_mypayment.py index 02db8955c5..dd7eb56fbf 100644 --- a/app/core/mypayment/cruds_mypayment.py +++ b/app/core/mypayment/cruds_mypayment.py @@ -998,11 +998,8 @@ async def get_requests_by_wallet_id( include_used: bool = False, ) -> list[schemas_mypayment.Request]: result = await db.execute( - select(models_mypayment.Request) - .where( + select(models_mypayment.Request).where( models_mypayment.Request.wallet_id == wallet_id, - ) - .where( models_mypayment.Request.status == RequestStatus.PROPOSED if not include_used else and_(True), diff --git a/app/core/mypayment/endpoints_mypayment.py b/app/core/mypayment/endpoints_mypayment.py index c77e2d6a39..ae4ad6e218 100644 --- a/app/core/mypayment/endpoints_mypayment.py +++ b/app/core/mypayment/endpoints_mypayment.py @@ -118,7 +118,7 @@ class MyPaymentPermissions(ModulePermissions): root=MYPAYMENT_ROOT, tag="MyPayment", router=router, - payment_callback=validate_transfer_callback, + checkout_callback=validate_transfer_callback, factory=MyPaymentFactory(), permissions=MyPaymentPermissions, ) @@ -2761,7 +2761,7 @@ async def accept_request( if debited_wallet.user is None or debited_wallet.store is not None: raise HTTPException( status_code=400, - detail="Stores are not allowed to make transaction by QR code", + detail="Wrong type of wallet's owner", ) debited_user_payment = await cruds_mypayment.get_user_payment( @@ -2795,7 +2795,7 @@ async def accept_request( id=uuid.uuid4(), debited_wallet_id=debited_wallet_device.wallet_id, credited_wallet_id=store.wallet_id, - transaction_type=TransactionType.DIRECT, + transaction_type=TransactionType.REQUEST, seller_user_id=user.id, total=request_validation.tot, creation=datetime.now(UTC), diff --git a/app/modules/cdr/endpoints_cdr.py b/app/modules/cdr/endpoints_cdr.py index 0a965d1df3..6778fdc9d7 100644 --- a/app/modules/cdr/endpoints_cdr.py +++ b/app/modules/cdr/endpoints_cdr.py @@ -71,7 +71,7 @@ class CdrPermissions(ModulePermissions): module = Module( root="cdr", tag="Cdr", - payment_callback=validate_payment, + checkout_callback=validate_payment, default_allowed_account_types=list(AccountType), factory=None, permissions=CdrPermissions, diff --git a/app/modules/raid/endpoints_raid.py b/app/modules/raid/endpoints_raid.py index b069ccd9f5..ba31f3e0ea 100644 --- a/app/modules/raid/endpoints_raid.py +++ b/app/modules/raid/endpoints_raid.py @@ -50,7 +50,7 @@ class RaidPermissions(ModulePermissions): module = Module( root="raid", tag="Raid", - payment_callback=validate_payment, + checkout_callback=validate_payment, default_allowed_account_types=list(AccountType), factory=None, permissions=RaidPermissions, diff --git a/app/modules/sport_competition/endpoints_sport_competition.py b/app/modules/sport_competition/endpoints_sport_competition.py index 61e7adfd58..a558840eb8 100644 --- a/app/modules/sport_competition/endpoints_sport_competition.py +++ b/app/modules/sport_competition/endpoints_sport_competition.py @@ -80,7 +80,7 @@ root="sport_competition", tag="Sport Competition", default_allowed_account_types=get_account_types_except_externals(), - payment_callback=validate_payment, + checkout_callback=validate_payment, factory=None, permissions=SportCompetitionPermissions, ) diff --git a/app/types/module.py b/app/types/module.py index 9a323e0fc6..e961b37dbf 100644 --- a/app/types/module.py +++ b/app/types/module.py @@ -19,7 +19,7 @@ def __init__( tag: str, factory: Factory | None, router: APIRouter | None = None, - payment_callback: Callable[ + checkout_callback: Callable[ [schemas_checkout.CheckoutPayment, AsyncSession], Awaitable[None], ] @@ -45,10 +45,10 @@ def __init__( """ self.root = root self.router = router or APIRouter(tags=[tag]) - self.payment_callback: ( + self.checkout_callback: ( Callable[[schemas_checkout.CheckoutPayment, AsyncSession], Awaitable[None]] | None - ) = payment_callback + ) = checkout_callback self.mypayment_callback: ( Callable[[UUID, AsyncSession], Awaitable[None]] | None ) = mypayment_callback @@ -66,7 +66,7 @@ def __init__( default_allowed_groups_ids: list[GroupType] | None = None, default_allowed_account_types: list[AccountType] | None = None, router: APIRouter | None = None, - payment_callback: Callable[ + checkout_callback: Callable[ [schemas_checkout.CheckoutPayment, AsyncSession], Awaitable[None], ] @@ -96,10 +96,10 @@ def __init__( self.default_allowed_groups_ids = default_allowed_groups_ids self.default_allowed_account_types = default_allowed_account_types self.router = router or APIRouter(tags=[tag]) - self.payment_callback: ( + self.checkout_callback: ( Callable[[schemas_checkout.CheckoutPayment, AsyncSession], Awaitable[None]] | None - ) = payment_callback + ) = checkout_callback self.mypayment_callback: ( Callable[[UUID, AsyncSession], Awaitable[None]] | None ) = mypayment_callback diff --git a/tests/core/test_checkout.py b/tests/core/test_checkout.py index 8fffefffa8..58fe2f20fc 100644 --- a/tests/core/test_checkout.py +++ b/tests/core/test_checkout.py @@ -304,7 +304,7 @@ async def test_webhook_payment_callback( root=TEST_MODULE_ROOT, tag="Tests", default_allowed_groups_ids=[], - payment_callback=callback, + checkout_callback=callback, factory=None, permissions=None, ) @@ -347,7 +347,7 @@ async def test_webhook_payment_callback_fail( root=TEST_MODULE_ROOT, tag="Tests", default_allowed_groups_ids=[], - payment_callback=callback, + checkout_callback=callback, factory=None, permissions=None, ) From 4ce58f4b69288a37710464c1fbd9530910dc0b70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Robert?= Date: Mon, 6 Apr 2026 18:50:39 +0200 Subject: [PATCH 05/72] refacto: use schema --- app/core/mypayment/schemas_mypayment.py | 10 +++++++++ app/core/mypayment/utils_mypayment.py | 30 ++++++++++--------------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/app/core/mypayment/schemas_mypayment.py b/app/core/mypayment/schemas_mypayment.py index 8bf21a6d91..9c3e880164 100644 --- a/app/core/mypayment/schemas_mypayment.py +++ b/app/core/mypayment/schemas_mypayment.py @@ -383,3 +383,13 @@ class RequestValidationData(BaseModel): class RequestValidation(RequestValidationData): signature: str + + +class RequestInfo(BaseModel): + user_id: str + store_id: UUID + total: int + name: str + note: str | None + module: str + object_id: UUID diff --git a/app/core/mypayment/utils_mypayment.py b/app/core/mypayment/utils_mypayment.py index 544c7332e9..1954546b98 100644 --- a/app/core/mypayment/utils_mypayment.py +++ b/app/core/mypayment/utils_mypayment.py @@ -162,13 +162,7 @@ async def validate_transfer_callback( async def request_transaction( - user_id: str, - store_id: UUID, - total: int, - name: str, - note: str | None, - module: str, - object_id: UUID, + request_info: schemas_mypayment.RequestInfo, db: AsyncSession, notification_tool: NotificationTool, settings: Settings, @@ -176,11 +170,11 @@ async def request_transaction( """ Create a transaction request for a user from a store. """ - payment_user = await cruds_mypayment.get_user_payment(user_id, db) + payment_user = await cruds_mypayment.get_user_payment(request_info.user_id, db) if not payment_user: raise HTTPException( status_code=400, - detail=f"User {user_id} does not have a payment account", + detail=f"User {request_info.user_id} does not have a payment account", ) request_id = uuid4() await cruds_mypayment.create_request( @@ -189,22 +183,22 @@ async def request_transaction( id=request_id, wallet_id=payment_user.wallet_id, creation=datetime.now(UTC), - total=total, - store_id=store_id, - name=name, - store_note=note, - module=module, - object_id=object_id, + total=request_info.total, + store_id=request_info.store_id, + name=request_info.name, + store_note=request_info.note, + module=request_info.module, + object_id=request_info.object_id, status=RequestStatus.PROPOSED, ), ) message = Message( - title=f"💸 Nouvelle demande de paiement - {name}", - content=f"Une nouvelle demande de paiement de {total / 100} € attend votre validation", + title=f"💸 Nouvelle demande de paiement - {request_info.name}", + content=f"Une nouvelle demande de paiement de {request_info.total / 100} € attend votre validation", action_module=settings.school.payment_name, ) await notification_tool.send_notification_to_user( - user_id=user_id, + user_id=request_info.user_id, message=message, ) return request_id From 38d920c97cd9556a2d3d3d4462c58038c3f75426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Robert?= Date: Mon, 6 Apr 2026 18:50:39 +0200 Subject: [PATCH 06/72] refacto : factorize schemas --- app/core/mypayment/endpoints_mypayment.py | 4 +- app/core/mypayment/schemas_mypayment.py | 64 ++++---- app/core/mypayment/utils_mypayment.py | 13 +- app/types/module.py | 1 - tests/core/test_mypayment.py | 180 ++++++++++++++++++++-- 5 files changed, 199 insertions(+), 63 deletions(-) diff --git a/app/core/mypayment/endpoints_mypayment.py b/app/core/mypayment/endpoints_mypayment.py index ae4ad6e218..d4e8d125b4 100644 --- a/app/core/mypayment/endpoints_mypayment.py +++ b/app/core/mypayment/endpoints_mypayment.py @@ -2660,7 +2660,7 @@ async def get_user_requests( ) async def accept_request( request_id: UUID, - request_validation: schemas_mypayment.RequestValidation, + request_validation: schemas_mypayment.SignedContent, db: AsyncSession = Depends(get_db), user: CoreUser = Depends(is_user_allowed_to([MyPaymentPermissions.access_payment])), http_request_id: str = Depends(get_request_id), @@ -2676,7 +2676,7 @@ async def accept_request( db=db, ) await db.flush() - if request_id != request_validation.request_id: + if request_id != request_validation.id: raise HTTPException( status_code=400, detail="Request ID in the path and in the body do not match", diff --git a/app/core/mypayment/schemas_mypayment.py b/app/core/mypayment/schemas_mypayment.py index 9c3e880164..2dbe5499b6 100644 --- a/app/core/mypayment/schemas_mypayment.py +++ b/app/core/mypayment/schemas_mypayment.py @@ -170,31 +170,6 @@ class History(BaseModel): refund: HistoryRefund | None = None -class QRCodeContentData(BaseModel): - """ - Format of the data stored in the QR code. - - This data will be signed using ed25519 and the private key of the WalletDevice that generated the QR Code. - - id: Unique identifier of the QR Code - tot: Total amount of the transaction, in cents - iat: Generation datetime of the QR Code - key: Id of the WalletDevice that generated the QR Code, will be used to verify the signature - store: If the QR Code is intended to be scanned for a Store Wallet, or for an other user Wallet - """ - - id: UUID - tot: int - iat: datetime - key: UUID - store: bool - - -class ScanInfo(QRCodeContentData): - signature: str - bypass_membership: bool = False - - class WalletBase(BaseModel): id: UUID type: WalletType @@ -374,17 +349,6 @@ class RequestEdit(BaseModel): transaction_id: UUID | None = None -class RequestValidationData(BaseModel): - request_id: UUID - key: UUID - iat: datetime - tot: int - - -class RequestValidation(RequestValidationData): - signature: str - - class RequestInfo(BaseModel): user_id: str store_id: UUID @@ -393,3 +357,31 @@ class RequestInfo(BaseModel): note: str | None module: str object_id: UUID + + +class SecuredContentData(BaseModel): + """ + Format of the data stored in the payment order. + + This data will be signed using ed25519 and the private key of the WalletDevice that generated the payment order + + id: Unique identifier of the payment + tot: Total amount of the transaction, in cents + iat: Generation datetime of the payment order + key: Id of the WalletDevice that generated the payment order, will be used to get the public key to verify the signature + store: If the payment is destined to a store + """ + + id: UUID + tot: int + iat: datetime + key: UUID + store: bool + + +class SignedContent(SecuredContentData): + signature: str + + +class ScanInfo(SignedContent): + bypass_membership: bool = False diff --git a/app/core/mypayment/utils_mypayment.py b/app/core/mypayment/utils_mypayment.py index 1954546b98..98b5c1392e 100644 --- a/app/core/mypayment/utils_mypayment.py +++ b/app/core/mypayment/utils_mypayment.py @@ -23,10 +23,7 @@ format_transfer_log, ) from app.core.mypayment.models_mypayment import UserPayment -from app.core.mypayment.schemas_mypayment import ( - QRCodeContentData, - RequestValidationData, -) +from app.core.mypayment.schemas_mypayment import SecuredContentData from app.core.mypayment.types_mypayment import ( MyPaymentCallType, RequestStatus, @@ -58,7 +55,7 @@ def verify_signature( public_key_bytes: bytes, signature: str, - data: QRCodeContentData | RequestValidationData, + data: SecuredContentData, wallet_device_id: UUID, request_id: str, ) -> bool: @@ -69,9 +66,9 @@ def verify_signature( loaded_public_key = ed25519.Ed25519PublicKey.from_public_bytes(public_key_bytes) loaded_public_key.verify( base64.decodebytes(signature.encode("utf-8")), - data.model_dump_json(exclude={"signature", "bypass_membership"}).encode( - "utf-8", - ), + data.model_dump_json( + include=set(SecuredContentData.model_fields.keys()), + ).encode("utf-8"), ) except InvalidSignature: hyperion_security_logger.info( diff --git a/app/types/module.py b/app/types/module.py index e961b37dbf..68152a6a8a 100644 --- a/app/types/module.py +++ b/app/types/module.py @@ -6,7 +6,6 @@ from app.core.checkout import schemas_checkout from app.core.groups.groups_type import AccountType, GroupType -from app.core.mypayment import schemas_mypayment from app.core.notification.schemas_notification import Topic from app.core.permissions.type_permissions import ModulePermissions from app.types.factory import Factory diff --git a/tests/core/test_mypayment.py b/tests/core/test_mypayment.py index abc443f36b..2ef44062cd 100644 --- a/tests/core/test_mypayment.py +++ b/tests/core/test_mypayment.py @@ -16,18 +16,16 @@ 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 cruds_mypayment, models_mypayment, schemas_mypayment +from app.core.mypayment import cruds_mypayment, models_mypayment from app.core.mypayment.coredata_mypayment import ( MyPaymentBankAccountHolder, ) from app.core.mypayment.endpoints_mypayment import MyPaymentPermissions from app.core.mypayment.schemas_mypayment import ( - QRCodeContentData, - RequestValidation, - RequestValidationData, + SecuredContentData, + SignedContent, ) from app.core.mypayment.types_mypayment import ( - MyPaymentCallType, RequestStatus, TransactionStatus, TransactionType, @@ -35,7 +33,11 @@ WalletDeviceStatus, WalletType, ) -from app.core.mypayment.utils_mypayment import LATEST_TOS, validate_transfer_callback +from app.core.mypayment.utils_mypayment import ( + LATEST_TOS, + REQUEST_EXPIRATION, + validate_transfer_callback, +) from app.core.permissions import models_permissions from app.core.users import models_users from app.types.module import Module @@ -2476,7 +2478,7 @@ def test_store_scan_store_invalid_signature(client: TestClient): def test_store_scan_store_with_non_store_qr_code(client: TestClient): qr_code_id = uuid4() - qr_code_content = QRCodeContentData( + qr_code_content = SecuredContentData( id=qr_code_id, tot=-1, iat=datetime.now(UTC), @@ -2511,7 +2513,7 @@ def test_store_scan_store_with_non_store_qr_code(client: TestClient): def test_store_scan_store_negative_total(client: TestClient): qr_code_id = uuid4() - qr_code_content = QRCodeContentData( + qr_code_content = SecuredContentData( id=qr_code_id, tot=-1, iat=datetime.now(UTC), @@ -2553,7 +2555,7 @@ def test_store_scan_store_missing_wallet( qr_code_id = uuid4() - qr_code_content = QRCodeContentData( + qr_code_content = SecuredContentData( id=qr_code_id, tot=100, iat=datetime.now(UTC), @@ -2589,7 +2591,7 @@ def test_store_scan_store_missing_wallet( def test_store_scan_store_from_store_wallet(client: TestClient): qr_code_id = uuid4() - qr_code_content = QRCodeContentData( + qr_code_content = SecuredContentData( id=qr_code_id, tot=1100, iat=datetime.now(UTC), @@ -2659,7 +2661,7 @@ async def test_store_scan_store_from_wallet_with_old_tos_version(client: TestCli qr_code_id = uuid4() - qr_code_content = QRCodeContentData( + qr_code_content = SecuredContentData( id=qr_code_id, tot=1100, iat=datetime.now(UTC), @@ -2692,7 +2694,7 @@ async def test_store_scan_store_from_wallet_with_old_tos_version(client: TestCli def test_store_scan_store_insufficient_ballance(client: TestClient): qr_code_id = uuid4() - qr_code_content = QRCodeContentData( + qr_code_content = SecuredContentData( id=qr_code_id, tot=3000, iat=datetime.now(UTC), @@ -2725,7 +2727,7 @@ def test_store_scan_store_insufficient_ballance(client: TestClient): async def test_store_scan_store_successful_scan(client: TestClient): qr_code_id = uuid4() - qr_code_content = QRCodeContentData( + qr_code_content = SecuredContentData( id=qr_code_id, tot=500, iat=datetime.now(UTC), @@ -3330,6 +3332,88 @@ async def test_get_request_with_used_filter( assert len(response.json()) == 3 +async def test_accept_request_with_invalid_signature( + client: TestClient, +): + response = client.post( + f"/mypayment/requests/{proposed_request.id}/accept", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + json=SignedContent( + id=proposed_request.id, + key=ecl_user_wallet_device.id, + iat=datetime.now(UTC), + tot=proposed_request.total, + store=True, + signature="invalid signature", + ).model_dump(mode="json"), + ) + assert response.status_code == 400 + assert response.json()["detail"] == "Invalid signature" + + +async def test_accept_expired_request( + client: TestClient, + mocker: MockerFixture, +): + # We patch the callback to be able to check if it was called + mocked_callback = mocker.patch( + "tests.core.test_mypayment.mypayment_callback", + ) + + # We patch the module_list to inject our custom test module + test_module = Module( + root=TEST_MODULE_ROOT, + tag="Tests", + default_allowed_groups_ids=[], + mypayment_callback=mypayment_callback, + factory=None, + permissions=None, + ) + mocker.patch( + "app.core.mypayment.utils_mypayment.all_modules", + [test_module], + ) + + expired_request = models_mypayment.Request( + id=uuid4(), + wallet_id=ecl_user_wallet.id, + store_id=store.id, + name="Test request", + store_note="", + module=TEST_MODULE_ROOT, + object_id=uuid4(), + transaction_id=None, + total=1000, + status=RequestStatus.PROPOSED, + creation=datetime.now(UTC) - timedelta(minutes=REQUEST_EXPIRATION + 1), + ) + await add_object_to_db(expired_request) + + validation_data = SecuredContentData( + id=expired_request.id, + key=ecl_user_wallet_device.id, + iat=datetime.now(UTC), + tot=expired_request.total, + store=True, + ) + validation_data_signature = ecl_user_wallet_device_private_key.sign( + validation_data.model_dump_json().encode("utf-8"), + ) + validation = SignedContent( + **validation_data.model_dump(), + signature=base64.b64encode(validation_data_signature).decode("utf-8"), + ) + response = client.post( + f"/mypayment/requests/{expired_request.id}/accept", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + json=validation.model_dump(mode="json"), + ) + assert response.status_code == 400 + assert response.json()["detail"] == "Only pending requests can be confirmed" + + mocked_callback.assert_not_called() + + async def test_accept_request( mocker: MockerFixture, client: TestClient, @@ -3353,16 +3437,17 @@ async def test_accept_request( [test_module], ) - validation_data = RequestValidationData( - request_id=proposed_request.id, + validation_data = SecuredContentData( + id=proposed_request.id, key=ecl_user_wallet_device.id, iat=datetime.now(UTC), tot=proposed_request.total, + store=True, ) validation_data_signature = ecl_user_wallet_device_private_key.sign( validation_data.model_dump_json().encode("utf-8"), ) - validation = RequestValidation( + validation = SignedContent( **validation_data.model_dump(), signature=base64.b64encode(validation_data_signature).decode("utf-8"), ) @@ -3391,6 +3476,69 @@ async def test_accept_request( mocked_callback.assert_called_once() +async def test_refuse_request( + client: TestClient, + mocker: MockerFixture, +): + # We patch the callback to be able to check if it was called + mocked_callback = mocker.patch( + "tests.core.test_mypayment.mypayment_callback", + ) + + # We patch the module_list to inject our custom test module + test_module = Module( + root=TEST_MODULE_ROOT, + tag="Tests", + default_allowed_groups_ids=[], + mypayment_callback=mypayment_callback, + factory=None, + permissions=None, + ) + mocker.patch( + "app.core.mypayment.utils_mypayment.all_modules", + [test_module], + ) + + new_request = models_mypayment.Request( + id=uuid4(), + wallet_id=ecl_user_wallet.id, + store_id=store.id, + name="Test request", + store_note="", + module=TEST_MODULE_ROOT, + object_id=uuid4(), + transaction_id=None, + total=1000, + status=RequestStatus.PROPOSED, + creation=datetime.now(UTC), + ) + await add_object_to_db(new_request) + + response = client.post( + f"/mypayment/requests/{new_request.id}/refuse", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + ) + assert response.status_code == 204 + + mocked_callback.assert_not_called() + + responser = client.get( + "/mypayment/requests?used=true", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + ) + assert responser.status_code == 200 + refused = next( + ( + request + for request in responser.json() + if request["id"] == str(new_request.id) + ), + None, + ) + assert refused is not None + assert refused["status"] == RequestStatus.REFUSED + + async def test_direct_transfer_callback( mocker: MockerFixture, client: TestClient, From 7fe4cd1c8cc68ba14277736753180c3039744be5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Robert?= Date: Mon, 6 Apr 2026 18:50:39 +0200 Subject: [PATCH 07/72] format --- app/core/checkout/endpoints_checkout.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/app/core/checkout/endpoints_checkout.py b/app/core/checkout/endpoints_checkout.py index 84af7002b2..b14b0e1daa 100644 --- a/app/core/checkout/endpoints_checkout.py +++ b/app/core/checkout/endpoints_checkout.py @@ -73,11 +73,9 @@ async def webhook( ): # We may receive the webhook multiple times, we only want to save a CheckoutPayment # in the database the first time - existing_checkout_payment_model = ( - await cruds_checkout.get_checkout_payment_by_hello_asso_payment_id( - hello_asso_payment_id=content.data.id, # ty:ignore[unresolved-attribute] - db=db, - ) + existing_checkout_payment_model = await cruds_checkout.get_checkout_payment_by_hello_asso_payment_id( + hello_asso_payment_id=content.data.id, # ty:ignore[unresolved-attribute] + db=db, ) if existing_checkout_payment_model is not None: hyperion_error_logger.debug( From 9f6a4eba5a20b44d9fd9e48af86fb7ec5ccabbdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Robert?= Date: Mon, 6 Apr 2026 23:34:33 +0200 Subject: [PATCH 08/72] refacto: add unique function --- app/core/mypayment/cruds_mypayment.py | 6 +- app/core/mypayment/endpoints_mypayment.py | 11 +- app/core/mypayment/exceptions_mypayment.py | 7 + app/core/mypayment/schemas_mypayment.py | 13 +- app/core/mypayment/utils/models_converter.py | 121 ++++++++ app/core/mypayment/utils_mypayment.py | 180 +++-------- tests/config.test.yaml | 2 +- tests/core/test_mypayment.py | 310 +++++++++++++++++-- 8 files changed, 488 insertions(+), 162 deletions(-) create mode 100644 app/core/mypayment/utils/models_converter.py diff --git a/app/core/mypayment/cruds_mypayment.py b/app/core/mypayment/cruds_mypayment.py index dd7eb56fbf..6a1dde3f98 100644 --- a/app/core/mypayment/cruds_mypayment.py +++ b/app/core/mypayment/cruds_mypayment.py @@ -14,12 +14,14 @@ WalletDeviceStatus, WalletType, ) -from app.core.mypayment.utils_mypayment import ( - REQUEST_EXPIRATION, +from app.core.mypayment.utils.models_converter import ( invoice_model_to_schema, refund_model_to_schema, structure_model_to_schema, ) +from app.core.mypayment.utils_mypayment import ( + REQUEST_EXPIRATION, +) from app.core.users import models_users, schemas_users diff --git a/app/core/mypayment/endpoints_mypayment.py b/app/core/mypayment/endpoints_mypayment.py index d4e8d125b4..e708d0f799 100644 --- a/app/core/mypayment/endpoints_mypayment.py +++ b/app/core/mypayment/endpoints_mypayment.py @@ -57,6 +57,7 @@ WalletType, ) from app.core.mypayment.utils.data_exporter import generate_store_history_csv +from app.core.mypayment.utils.models_converter import structure_model_to_schema from app.core.mypayment.utils_mypayment import ( LATEST_TOS, MYPAYMENT_DEVICES_S3_SUBFOLDER, @@ -71,7 +72,6 @@ apply_transaction, call_mypayment_callback, is_user_latest_tos_signed, - structure_model_to_schema, validate_transfer_callback, verify_signature, ) @@ -2690,6 +2690,11 @@ async def accept_request( status_code=404, detail="Request does not exist", ) + if request.total != request_validation.tot: + raise HTTPException( + status_code=400, + detail="Request total in the body do not match the request total in the database", + ) user_payment = await cruds_mypayment.get_user_payment( user_id=user.id, @@ -3329,7 +3334,7 @@ async def delete_structure_invoice( response_model=schemas_mypayment.IntegrityCheckData, ) async def get_data_for_integrity_check( - headers: schemas_mypayment.IntegrityCheckHeaders = Header(), + headers: schemas_mypayment.IntegrityCheckHeaders = Header(...), query_params: schemas_mypayment.IntegrityCheckQuery = Query(), db: AsyncSession = Depends(get_db), settings: Settings = Depends(get_settings), @@ -3346,7 +3351,7 @@ async def get_data_for_integrity_check( """ if settings.MYPAYMENT_DATA_VERIFIER_ACCESS_TOKEN is None: raise HTTPException( - status_code=301, + status_code=401, detail="MYPAYMENT_DATA_VERIFIER_ACCESS_TOKEN is not set in the settings", ) diff --git a/app/core/mypayment/exceptions_mypayment.py b/app/core/mypayment/exceptions_mypayment.py index 7d57437a7d..ff9d75d510 100644 --- a/app/core/mypayment/exceptions_mypayment.py +++ b/app/core/mypayment/exceptions_mypayment.py @@ -52,3 +52,10 @@ def __init__(self, transfer_id: UUID): super().__init__( f"User transfer {transfer_id} has already been confirmed", ) + + +class PaymentUserNotFoundError(Exception): + def __init__(self, user_id: str): + super().__init__( + f"User {user_id} does not have a payment account", + ) diff --git a/app/core/mypayment/schemas_mypayment.py b/app/core/mypayment/schemas_mypayment.py index 2dbe5499b6..6b4c524382 100644 --- a/app/core/mypayment/schemas_mypayment.py +++ b/app/core/mypayment/schemas_mypayment.py @@ -1,4 +1,5 @@ from datetime import datetime +from typing import Literal from uuid import UUID from pydantic import ( @@ -10,6 +11,7 @@ from app.core.memberships import schemas_memberships from app.core.mypayment.types_mypayment import ( HistoryType, + MyPaymentCallType, RequestStatus, TransactionStatus, TransactionType, @@ -350,7 +352,6 @@ class RequestEdit(BaseModel): class RequestInfo(BaseModel): - user_id: str store_id: UUID total: int name: str @@ -359,6 +360,16 @@ class RequestInfo(BaseModel): object_id: UUID +class PaymentInfo(BaseModel): + store_id: UUID + total: int + name: str + note: str | None + module: str + object_id: UUID + redirect_url: str + + class SecuredContentData(BaseModel): """ Format of the data stored in the payment order. diff --git a/app/core/mypayment/utils/models_converter.py b/app/core/mypayment/utils/models_converter.py new file mode 100644 index 0000000000..b1f33bb91b --- /dev/null +++ b/app/core/mypayment/utils/models_converter.py @@ -0,0 +1,121 @@ +from app.core.memberships import schemas_memberships +from app.core.mypayment import models_mypayment, schemas_mypayment +from app.core.users import schemas_users + + +def structure_model_to_schema( + structure: models_mypayment.Structure, +) -> schemas_mypayment.Structure: + """ + Convert a structure model to a schema. + """ + return schemas_mypayment.Structure( + id=structure.id, + short_id=structure.short_id, + name=structure.name, + association_membership_id=structure.association_membership_id, + association_membership=schemas_memberships.MembershipSimple( + id=structure.association_membership.id, + name=structure.association_membership.name, + manager_group_id=structure.association_membership.manager_group_id, + ) + if structure.association_membership + else None, + manager_user_id=structure.manager_user_id, + manager_user=schemas_users.CoreUserSimple( + id=structure.manager_user.id, + firstname=structure.manager_user.firstname, + name=structure.manager_user.name, + nickname=structure.manager_user.nickname, + account_type=structure.manager_user.account_type, + school_id=structure.manager_user.school_id, + ), + siret=structure.siret, + siege_address_street=structure.siege_address_street, + siege_address_city=structure.siege_address_city, + siege_address_zipcode=structure.siege_address_zipcode, + siege_address_country=structure.siege_address_country, + iban=structure.iban, + bic=structure.bic, + creation=structure.creation, + ) + + +def refund_model_to_schema( + refund: models_mypayment.Refund, +) -> schemas_mypayment.Refund: + """ + Convert a refund model to a schema. + """ + return schemas_mypayment.Refund( + id=refund.id, + transaction_id=refund.transaction_id, + credited_wallet_id=refund.credited_wallet_id, + debited_wallet_id=refund.debited_wallet_id, + total=refund.total, + creation=refund.creation, + seller_user_id=refund.seller_user_id, + transaction=schemas_mypayment.Transaction( + id=refund.transaction.id, + debited_wallet_id=refund.transaction.debited_wallet_id, + credited_wallet_id=refund.transaction.credited_wallet_id, + transaction_type=refund.transaction.transaction_type, + seller_user_id=refund.transaction.seller_user_id, + total=refund.transaction.total, + creation=refund.transaction.creation, + status=refund.transaction.status, + ), + debited_wallet=schemas_mypayment.WalletInfo( + id=refund.debited_wallet.id, + type=refund.debited_wallet.type, + owner_name=refund.debited_wallet.store.name + if refund.debited_wallet.store + else refund.debited_wallet.user.full_name + if refund.debited_wallet.user + else None, + ), + credited_wallet=schemas_mypayment.WalletInfo( + id=refund.credited_wallet.id, + type=refund.credited_wallet.type, + owner_name=refund.credited_wallet.store.name + if refund.credited_wallet.store + else refund.credited_wallet.user.full_name + if refund.credited_wallet.user + else None, + ), + ) + + +def invoice_model_to_schema( + invoice: models_mypayment.Invoice, +) -> schemas_mypayment.Invoice: + """ + Convert an invoice model to a schema. + """ + return schemas_mypayment.Invoice( + id=invoice.id, + reference=invoice.reference, + structure_id=invoice.structure_id, + creation=invoice.creation, + start_date=invoice.start_date, + end_date=invoice.end_date, + total=invoice.total, + paid=invoice.paid, + received=invoice.received, + structure=structure_model_to_schema(invoice.structure), + details=[ + schemas_mypayment.InvoiceDetail( + invoice_id=invoice.id, + store_id=detail.store_id, + total=detail.total, + store=schemas_mypayment.StoreSimple( + id=detail.store.id, + name=detail.store.name, + structure_id=detail.store.structure_id, + wallet_id=detail.store.wallet_id, + creation=detail.store.creation, + ), + ) + for detail in invoice.details + ], + ) diff --git a/app/core/mypayment/utils_mypayment.py b/app/core/mypayment/utils_mypayment.py index 98b5c1392e..6136484692 100644 --- a/app/core/mypayment/utils_mypayment.py +++ b/app/core/mypayment/utils_mypayment.py @@ -10,9 +10,9 @@ from app.core.checkout import schemas_checkout from app.core.checkout.payment_tool import PaymentTool -from app.core.memberships import schemas_memberships from app.core.mypayment import cruds_mypayment, models_mypayment, schemas_mypayment from app.core.mypayment.exceptions_mypayment import ( + PaymentUserNotFoundError, TransferAlreadyConfirmedInCallbackError, TransferNotFoundByCallbackError, TransferTotalDontMatchInCallbackError, @@ -159,25 +159,22 @@ async def validate_transfer_callback( async def request_transaction( + user: schemas_users.CoreUser, request_info: schemas_mypayment.RequestInfo, db: AsyncSession, notification_tool: NotificationTool, settings: Settings, -) -> UUID: +) -> None: """ Create a transaction request for a user from a store. """ - payment_user = await cruds_mypayment.get_user_payment(request_info.user_id, db) + payment_user = await cruds_mypayment.get_user_payment(user.id, db) if not payment_user: - raise HTTPException( - status_code=400, - detail=f"User {request_info.user_id} does not have a payment account", - ) - request_id = uuid4() + raise PaymentUserNotFoundError(user.id) await cruds_mypayment.create_request( db=db, request=schemas_mypayment.Request( - id=request_id, + id=uuid4(), wallet_id=payment_user.wallet_id, creation=datetime.now(UTC), total=request_info.total, @@ -195,10 +192,9 @@ async def request_transaction( action_module=settings.school.payment_name, ) await notification_tool.send_notification_to_user( - user_id=request_info.user_id, + user_id=user.id, message=message, ) - return request_id async def request_store_transfer( @@ -263,6 +259,50 @@ async def request_store_transfer( ) +async def request_payment( + payment_type: MyPaymentCallType, + payment_info: schemas_mypayment.PaymentInfo, + user: schemas_users.CoreUser, + db: AsyncSession, + payment_tool: PaymentTool, + notification_tool: NotificationTool, + settings: Settings, +) -> None | schemas_checkout.PaymentUrl: + if payment_type == MyPaymentCallType.REQUEST: + return await request_transaction( + user=user, + request_info=schemas_mypayment.RequestInfo( + total=payment_info.total, + store_id=payment_info.store_id, + name=payment_info.name, + note=payment_info.note, + module=payment_info.module, + object_id=payment_info.object_id, + ), + db=db, + notification_tool=notification_tool, + settings=settings, + ) + if payment_type == MyPaymentCallType.TRANSFER: + return await request_store_transfer( + user=user, + transfer_info=schemas_mypayment.StoreTransferInfo( + amount=payment_info.total, + store_id=payment_info.store_id, + module=payment_info.module, + object_id=payment_info.object_id, + redirect_url=payment_info.redirect_url, + ), + db=db, + payment_tool=payment_tool, + settings=settings, + ) + raise HTTPException( + status_code=400, + detail="Invalid payment type", + ) + + async def apply_transaction( user_id: str, transaction: schemas_mypayment.TransactionBase, @@ -343,121 +383,3 @@ async def call_mypayment_callback( hyperion_error_logger.exception( f"MyPayment: call to module {module_root} request callback ({id_name}: {call_id}) failed", ) - - -def structure_model_to_schema( - structure: models_mypayment.Structure, -) -> schemas_mypayment.Structure: - """ - Convert a structure model to a schema. - """ - return schemas_mypayment.Structure( - id=structure.id, - short_id=structure.short_id, - name=structure.name, - association_membership_id=structure.association_membership_id, - association_membership=schemas_memberships.MembershipSimple( - id=structure.association_membership.id, - name=structure.association_membership.name, - manager_group_id=structure.association_membership.manager_group_id, - ) - if structure.association_membership - else None, - manager_user_id=structure.manager_user_id, - manager_user=schemas_users.CoreUserSimple( - id=structure.manager_user.id, - firstname=structure.manager_user.firstname, - name=structure.manager_user.name, - nickname=structure.manager_user.nickname, - account_type=structure.manager_user.account_type, - school_id=structure.manager_user.school_id, - ), - siret=structure.siret, - siege_address_street=structure.siege_address_street, - siege_address_city=structure.siege_address_city, - siege_address_zipcode=structure.siege_address_zipcode, - siege_address_country=structure.siege_address_country, - iban=structure.iban, - bic=structure.bic, - creation=structure.creation, - ) - - -def refund_model_to_schema( - refund: models_mypayment.Refund, -) -> schemas_mypayment.Refund: - """ - Convert a refund model to a schema. - """ - return schemas_mypayment.Refund( - id=refund.id, - transaction_id=refund.transaction_id, - credited_wallet_id=refund.credited_wallet_id, - debited_wallet_id=refund.debited_wallet_id, - total=refund.total, - creation=refund.creation, - seller_user_id=refund.seller_user_id, - transaction=schemas_mypayment.Transaction( - id=refund.transaction.id, - debited_wallet_id=refund.transaction.debited_wallet_id, - credited_wallet_id=refund.transaction.credited_wallet_id, - transaction_type=refund.transaction.transaction_type, - seller_user_id=refund.transaction.seller_user_id, - total=refund.transaction.total, - creation=refund.transaction.creation, - status=refund.transaction.status, - ), - debited_wallet=schemas_mypayment.WalletInfo( - id=refund.debited_wallet.id, - type=refund.debited_wallet.type, - owner_name=refund.debited_wallet.store.name - if refund.debited_wallet.store - else refund.debited_wallet.user.full_name - if refund.debited_wallet.user - else None, - ), - credited_wallet=schemas_mypayment.WalletInfo( - id=refund.credited_wallet.id, - type=refund.credited_wallet.type, - owner_name=refund.credited_wallet.store.name - if refund.credited_wallet.store - else refund.credited_wallet.user.full_name - if refund.credited_wallet.user - else None, - ), - ) - - -def invoice_model_to_schema( - invoice: models_mypayment.Invoice, -) -> schemas_mypayment.Invoice: - """ - Convert an invoice model to a schema. - """ - return schemas_mypayment.Invoice( - id=invoice.id, - reference=invoice.reference, - structure_id=invoice.structure_id, - creation=invoice.creation, - start_date=invoice.start_date, - end_date=invoice.end_date, - total=invoice.total, - paid=invoice.paid, - received=invoice.received, - structure=structure_model_to_schema(invoice.structure), - details=[ - schemas_mypayment.InvoiceDetail( - invoice_id=invoice.id, - store_id=detail.store_id, - total=detail.total, - store=schemas_mypayment.StoreSimple( - id=detail.store.id, - name=detail.store.name, - structure_id=detail.store.structure_id, - wallet_id=detail.store.wallet_id, - creation=detail.store.creation, - ), - ) - for detail in invoice.details - ], - ) diff --git a/tests/config.test.yaml b/tests/config.test.yaml index ac1281abe5..2ed894cdd2 100644 --- a/tests/config.test.yaml +++ b/tests/config.test.yaml @@ -200,4 +200,4 @@ TRUSTED_PAYMENT_REDIRECT_URLS: # MyECLPay requires an external service to recurrently check for transactions and state integrity, this service needs an access to all the data related to the transactions and the users involved # This service will use a special token to access the data # If this token is not set, the service will not be able to access the data and no integrity check will be performed -#MYECLPAY_DATA_VERIFIER_ACCESS_TOKEN: "" +MYPAYMENT_DATA_VERIFIER_ACCESS_TOKEN: "test_data_verifier_access_token" diff --git a/tests/core/test_mypayment.py b/tests/core/test_mypayment.py index 2ef44062cd..5f2b96aa97 100644 --- a/tests/core/test_mypayment.py +++ b/tests/core/test_mypayment.py @@ -520,7 +520,7 @@ async def init_objects() -> None: store_id=store.id, can_bank=True, can_see_history=False, - can_cancel=False, + can_cancel=True, can_manage_sellers=False, ) await add_object_to_db(store_seller_can_bank) @@ -3035,6 +3035,37 @@ async def test_transaction_refund_partial(client: TestClient): ) +async def test_cancel_transaction( + client: TestClient, +): + recent_transaction = models_mypayment.Transaction( + id=uuid4(), + debited_wallet_id=ecl_user_wallet.id, + credited_wallet_id=store_wallet.id, + total=100, + status=TransactionStatus.CONFIRMED, + creation=datetime.now(UTC), + transaction_type=TransactionType.DIRECT, + seller_user_id=store_seller_can_bank_user.id, + debited_wallet_device_id=ecl_user_wallet_device.id, + store_note="", + qr_code_id=None, + ) + await add_object_to_db(recent_transaction) + response = client.post( + f"/mypayment/transactions/{recent_transaction.id}/cancel", + headers={"Authorization": f"Bearer {store_seller_can_bank_user_access_token}"}, + ) + assert response.status_code == 204, response.text + async with get_TestingSessionLocal()() as db: + transaction_after_cancel = await cruds_mypayment.get_transaction( + db=db, + transaction_id=recent_transaction.id, + ) + assert transaction_after_cancel is not None + assert transaction_after_cancel.status == TransactionStatus.CANCELED + + async def test_get_invoices_as_random_user(client: TestClient): response = client.get( "/mypayment/invoices", @@ -3351,30 +3382,186 @@ async def test_accept_request_with_invalid_signature( assert response.json()["detail"] == "Invalid signature" -async def test_accept_expired_request( +async def test_accept_request_with_wrong_wallet_device( client: TestClient, - mocker: MockerFixture, ): - # We patch the callback to be able to check if it was called - mocked_callback = mocker.patch( - "tests.core.test_mypayment.mypayment_callback", + wrong_wallet_device_private_key = Ed25519PrivateKey.generate() + wrong_wallet_device = models_mypayment.WalletDevice( + id=uuid4(), + name="Wrong device", + wallet_id=ecl_user2_wallet.id, + ed25519_public_key=wrong_wallet_device_private_key.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ), + creation=datetime.now(UTC), + status=WalletDeviceStatus.ACTIVE, + activation_token=str(uuid4()), ) + await add_object_to_db(wrong_wallet_device) - # We patch the module_list to inject our custom test module - test_module = Module( - root=TEST_MODULE_ROOT, - tag="Tests", - default_allowed_groups_ids=[], - mypayment_callback=mypayment_callback, - factory=None, - permissions=None, + validation_data = SecuredContentData( + id=proposed_request.id, + key=wrong_wallet_device.id, + iat=datetime.now(UTC), + tot=proposed_request.total, + store=True, ) - mocker.patch( - "app.core.mypayment.utils_mypayment.all_modules", - [test_module], + validation_data_signature = wrong_wallet_device_private_key.sign( + validation_data.model_dump_json().encode("utf-8"), + ) + validation = SignedContent( + **validation_data.model_dump(), + signature=base64.b64encode(validation_data_signature).decode("utf-8"), + ) + response = client.post( + f"/mypayment/requests/{proposed_request.id}/accept", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + json=validation.model_dump(mode="json"), + ) + assert response.status_code == 400 + assert ( + response.json()["detail"] + == "Wallet device is not associated with the user wallet" ) - expired_request = models_mypayment.Request( + +async def test_accept_request_with_wrong_user( + client: TestClient, +): + validation_data = SecuredContentData( + id=proposed_request.id, + key=ecl_user_wallet_device.id, + iat=datetime.now(UTC), + tot=proposed_request.total, + store=True, + ) + validation_data_signature = ecl_user_wallet_device_private_key.sign( + validation_data.model_dump_json().encode("utf-8"), + ) + validation = SignedContent( + **validation_data.model_dump(), + signature=base64.b64encode(validation_data_signature).decode("utf-8"), + ) + response = client.post( + f"/mypayment/requests/{proposed_request.id}/accept", + headers={"Authorization": f"Bearer {ecl_user2_access_token}"}, + json=validation.model_dump(mode="json"), + ) + assert response.status_code == 403 + assert response.json()["detail"] == "User is not allowed to confirm this request" + + +async def test_accept_request_with_different_total( + client: TestClient, +): + validation_data = SecuredContentData( + id=proposed_request.id, + key=ecl_user_wallet_device.id, + iat=datetime.now(UTC), + tot=proposed_request.total + 100, + store=True, + ) + validation_data_signature = ecl_user_wallet_device_private_key.sign( + validation_data.model_dump_json().encode("utf-8"), + ) + validation = SignedContent( + **validation_data.model_dump(), + signature=base64.b64encode(validation_data_signature).decode("utf-8"), + ) + response = client.post( + f"/mypayment/requests/{proposed_request.id}/accept", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + json=validation.model_dump(mode="json"), + ) + assert response.status_code == 400 + assert ( + response.json()["detail"] + == "Request total in the body do not match the request total in the database" + ) + + +async def test_accept_request_with_inexistant_wallet_device( + client: TestClient, +): + validation_data = SecuredContentData( + id=proposed_request.id, + key=uuid4(), + iat=datetime.now(UTC), + tot=proposed_request.total, + store=True, + ) + validation_data_signature = ecl_user_wallet_device_private_key.sign( + validation_data.model_dump_json().encode("utf-8"), + ) + validation = SignedContent( + **validation_data.model_dump(), + signature=base64.b64encode(validation_data_signature).decode("utf-8"), + ) + response = client.post( + f"/mypayment/requests/{proposed_request.id}/accept", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + json=validation.model_dump(mode="json"), + ) + assert response.status_code == 404 + assert response.json()["detail"] == "Wallet device does not exist" + + +async def test_accept_request_with_wallet_device_linked_to_another_wallet( + client: TestClient, +): + other_wallet = models_mypayment.Wallet( + id=uuid4(), + type=WalletType.USER, + balance=1000, + ) + await add_object_to_db(other_wallet) + + other_wallet_device_private_key = Ed25519PrivateKey.generate() + other_wallet_device = models_mypayment.WalletDevice( + id=uuid4(), + name="Other device", + wallet_id=other_wallet.id, + ed25519_public_key=other_wallet_device_private_key.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ), + creation=datetime.now(UTC), + status=WalletDeviceStatus.ACTIVE, + activation_token=str(uuid4()), + ) + await add_object_to_db(other_wallet_device) + + validation_data = SecuredContentData( + id=proposed_request.id, + key=other_wallet_device.id, + iat=datetime.now(UTC), + tot=proposed_request.total, + store=True, + ) + validation_data_signature = other_wallet_device_private_key.sign( + validation_data.model_dump_json().encode("utf-8"), + ) + validation = SignedContent( + **validation_data.model_dump(), + signature=base64.b64encode(validation_data_signature).decode("utf-8"), + ) + response = client.post( + f"/mypayment/requests/{proposed_request.id}/accept", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + json=validation.model_dump(mode="json"), + ) + assert response.status_code == 400 + assert ( + response.json()["detail"] + == "Wallet device is not associated with the user wallet" + ) + + +async def test_accept_request_with_non_proposed_request( + client: TestClient, +): + non_proposed_request = models_mypayment.Request( id=uuid4(), wallet_id=ecl_user_wallet.id, store_id=store.id, @@ -3384,16 +3571,16 @@ async def test_accept_expired_request( object_id=uuid4(), transaction_id=None, total=1000, - status=RequestStatus.PROPOSED, - creation=datetime.now(UTC) - timedelta(minutes=REQUEST_EXPIRATION + 1), + status=RequestStatus.ACCEPTED, + creation=datetime.now(UTC), ) - await add_object_to_db(expired_request) + await add_object_to_db(non_proposed_request) validation_data = SecuredContentData( - id=expired_request.id, + id=non_proposed_request.id, key=ecl_user_wallet_device.id, iat=datetime.now(UTC), - tot=expired_request.total, + tot=non_proposed_request.total, store=True, ) validation_data_signature = ecl_user_wallet_device_private_key.sign( @@ -3404,15 +3591,13 @@ async def test_accept_expired_request( signature=base64.b64encode(validation_data_signature).decode("utf-8"), ) response = client.post( - f"/mypayment/requests/{expired_request.id}/accept", + f"/mypayment/requests/{non_proposed_request.id}/accept", headers={"Authorization": f"Bearer {ecl_user_access_token}"}, json=validation.model_dump(mode="json"), ) assert response.status_code == 400 assert response.json()["detail"] == "Only pending requests can be confirmed" - mocked_callback.assert_not_called() - async def test_accept_request( mocker: MockerFixture, @@ -3476,6 +3661,69 @@ async def test_accept_request( mocked_callback.assert_called_once() +async def test_accept_expired_request( + client: TestClient, + mocker: MockerFixture, +): + # We patch the callback to be able to check if it was called + mocked_callback = mocker.patch( + "tests.core.test_mypayment.mypayment_callback", + ) + + # We patch the module_list to inject our custom test module + test_module = Module( + root=TEST_MODULE_ROOT, + tag="Tests", + default_allowed_groups_ids=[], + mypayment_callback=mypayment_callback, + factory=None, + permissions=None, + ) + mocker.patch( + "app.core.mypayment.utils_mypayment.all_modules", + [test_module], + ) + + expired_request = models_mypayment.Request( + id=uuid4(), + wallet_id=ecl_user_wallet.id, + store_id=store.id, + name="Test request", + store_note="", + module=TEST_MODULE_ROOT, + object_id=uuid4(), + transaction_id=None, + total=1000, + status=RequestStatus.PROPOSED, + creation=datetime.now(UTC) - timedelta(minutes=REQUEST_EXPIRATION + 1), + ) + await add_object_to_db(expired_request) + + validation_data = SecuredContentData( + id=expired_request.id, + key=ecl_user_wallet_device.id, + iat=datetime.now(UTC), + tot=expired_request.total, + store=True, + ) + validation_data_signature = ecl_user_wallet_device_private_key.sign( + validation_data.model_dump_json().encode("utf-8"), + ) + validation = SignedContent( + **validation_data.model_dump(), + signature=base64.b64encode(validation_data_signature).decode("utf-8"), + ) + response = client.post( + f"/mypayment/requests/{expired_request.id}/accept", + headers={"Authorization": f"Bearer {ecl_user_access_token}"}, + json=validation.model_dump(mode="json"), + ) + assert response.status_code == 400 + assert response.json()["detail"] == "Only pending requests can be confirmed" + + mocked_callback.assert_not_called() + + async def test_refuse_request( client: TestClient, mocker: MockerFixture, @@ -3573,3 +3821,13 @@ async def test_direct_transfer_callback( ) mocked_callback.assert_called_once() + + +async def test_integrity_check( + client: TestClient, +): + response = client.get( + "/mypayment/integrity-check", + headers={"x-data-verifier-token": "test_data_verifier_access_token"}, + ) + assert response.status_code == 200, response.text From b263f89c934ce0bc9224ef6373e093167b482ff3 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sun, 1 Mar 2026 11:28:23 +0100 Subject: [PATCH 09/72] Add CoreAssociation relationship to stores # Conflicts: # app/core/mypayment/utils_mypayment.py --- app/core/mypayment/endpoints_mypayment.py | 24 +++++++++++++++++++++++ app/core/mypayment/factory_mypayment.py | 4 +++- app/core/mypayment/models_mypayment.py | 4 ++++ app/core/mypayment/schemas_mypayment.py | 1 + tests/core/test_mypayment.py | 7 +++++++ 5 files changed, 39 insertions(+), 1 deletion(-) diff --git a/app/core/mypayment/endpoints_mypayment.py b/app/core/mypayment/endpoints_mypayment.py index e708d0f799..7d4ffe4a78 100644 --- a/app/core/mypayment/endpoints_mypayment.py +++ b/app/core/mypayment/endpoints_mypayment.py @@ -20,6 +20,7 @@ from fastapi.responses import FileResponse, RedirectResponse from sqlalchemy.ext.asyncio import AsyncSession +from app.core.associations import cruds_associations from app.core.auth import schemas_auth from app.core.checkout import schemas_checkout from app.core.checkout.payment_tool import PaymentTool @@ -103,6 +104,7 @@ generate_pdf_from_template, get_core_data, get_file_from_data, + is_user_member_of_an_association, patch_identity_in_text, set_core_data, ) @@ -511,6 +513,7 @@ async def create_store( Stores name should be unique, as an user need to be able to identify a store by its name. **The user must be the manager for this structure** + **The user must be a member of the associated CoreAssociation** """ structure = await cruds_mypayment.get_structure_by_id( structure_id=structure_id, @@ -537,6 +540,24 @@ async def create_store( detail="Store with this name already exists in this structure", ) + association = await cruds_associations.get_association_by_id( + db=db, + association_id=store.association_id, + ) + if not association: + raise HTTPException( + status_code=404, + detail="Association not found", + ) + if not is_user_member_of_an_association( + user=user, + association=association, + ): + raise HTTPException( + status_code=403, + detail="You are not allowed to create stores for this association", + ) + # Create new wallet for store wallet_id = uuid.uuid4() await cruds_mypayment.create_wallet( @@ -552,6 +573,7 @@ async def create_store( structure_id=structure_id, wallet_id=wallet_id, creation=datetime.now(tz=UTC), + association_id=store.association_id, ) await cruds_mypayment.create_store( store=store_db, @@ -585,6 +607,7 @@ async def create_store( wallet_id=store_db.wallet_id, creation=store_db.creation, structure=structure, + association_id=store_db.association_id, ) @@ -851,6 +874,7 @@ async def get_user_stores( can_see_history=seller.can_see_history, can_cancel=seller.can_cancel, can_manage_sellers=seller.can_manage_sellers, + association_id=store.association_id, ), ) diff --git a/app/core/mypayment/factory_mypayment.py b/app/core/mypayment/factory_mypayment.py index dec2297102..f7aad4bdf4 100644 --- a/app/core/mypayment/factory_mypayment.py +++ b/app/core/mypayment/factory_mypayment.py @@ -5,6 +5,7 @@ from faker import Faker from sqlalchemy.ext.asyncio import AsyncSession +from app.core.associations.factory_associations import AssociationsFactory from app.core.mypayment import cruds_mypayment, models_mypayment, schemas_mypayment from app.core.mypayment.types_mypayment import WalletType from app.core.users.factory_users import CoreUsersFactory @@ -17,7 +18,7 @@ class MyPaymentFactory(Factory): - depends_on = [CoreUsersFactory] + depends_on = [CoreUsersFactory, AssociationsFactory] demo_structures_id: list[uuid.UUID] other_structures_id: list[uuid.UUID] @@ -88,6 +89,7 @@ async def create_other_structures_stores(cls, db: AsyncSession): name=faker.company(), creation=datetime.now(UTC), wallet_id=wallet_id, + association_id=AssociationsFactory.association_ids[0], ), db, ) diff --git a/app/core/mypayment/models_mypayment.py b/app/core/mypayment/models_mypayment.py index 0908f7eb8c..14c7d695e6 100644 --- a/app/core/mypayment/models_mypayment.py +++ b/app/core/mypayment/models_mypayment.py @@ -170,6 +170,10 @@ class Store(Base): ) creation: Mapped[datetime] + association_id: Mapped[UUID] = mapped_column( + ForeignKey("core_association.id"), + ) + structure: Mapped[Structure] = relationship(init=False, lazy="joined") diff --git a/app/core/mypayment/schemas_mypayment.py b/app/core/mypayment/schemas_mypayment.py index 6b4c524382..6364a96cb6 100644 --- a/app/core/mypayment/schemas_mypayment.py +++ b/app/core/mypayment/schemas_mypayment.py @@ -69,6 +69,7 @@ class StructureTranfert(BaseModel): class StoreBase(BaseModel): name: str + association_id: UUID class StoreSimple(StoreBase): diff --git a/tests/core/test_mypayment.py b/tests/core/test_mypayment.py index 5f2b96aa97..f187921883 100644 --- a/tests/core/test_mypayment.py +++ b/tests/core/test_mypayment.py @@ -334,6 +334,7 @@ async def init_objects() -> None: name="Test Store", structure_id=structure.id, creation=datetime.now(UTC), + association_id=association_membership.id, ) await add_object_to_db(store) store2 = models_mypayment.Store( @@ -342,6 +343,7 @@ async def init_objects() -> None: name="Test Store 2", structure_id=structure2.id, creation=datetime.now(UTC), + association_id=association_membership.id, ) await add_object_to_db(store2) store3 = models_mypayment.Store( @@ -350,6 +352,7 @@ async def init_objects() -> None: name="Test Store 3", structure_id=structure2.id, creation=datetime.now(UTC), + association_id=association_membership.id, ) await add_object_to_db(store3) @@ -887,6 +890,7 @@ async def test_transfer_structure_manager_as_manager( wallet_id=new_wallet.id, name="Test Store Structure 2", structure_id=new_structure.id, + association_id=association_membership.id, ) await add_object_to_db(new_store) new_wallet2 = models_mypayment.Wallet( @@ -901,6 +905,7 @@ async def test_transfer_structure_manager_as_manager( wallet_id=new_wallet2.id, name="Test Store Structure 2 Where New Manager Already Seller", structure_id=new_structure.id, + association_id=association_membership.id, ) await add_object_to_db(new_store2_where_new_manager_already_seller) seller = models_mypayment.Seller( @@ -1330,6 +1335,7 @@ async def test_delete_store(client: TestClient): wallet_id=new_wallet.id, name="Test Store to Delete", structure_id=structure.id, + association_id=association_membership.id, ) await add_object_to_db(new_store) sellet = models_mypayment.Seller( @@ -1364,6 +1370,7 @@ async def test_update_store(client: TestClient): wallet_id=new_wallet.id, name="Test Store Update", structure_id=structure.id, + association_id=association_membership.id, ) await add_object_to_db(new_store) response = client.patch( From 9a9ae17e7da95c07b6795095556627919c14bc82 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sun, 1 Mar 2026 11:50:47 +0100 Subject: [PATCH 10/72] Migration --- app/core/mypayment/models_mypayment.py | 2 +- .../versions/50-stores_coreassociations.py | 78 +++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 migrations/versions/50-stores_coreassociations.py diff --git a/app/core/mypayment/models_mypayment.py b/app/core/mypayment/models_mypayment.py index 14c7d695e6..6c91fc054d 100644 --- a/app/core/mypayment/models_mypayment.py +++ b/app/core/mypayment/models_mypayment.py @@ -171,7 +171,7 @@ class Store(Base): creation: Mapped[datetime] association_id: Mapped[UUID] = mapped_column( - ForeignKey("core_association.id"), + ForeignKey("associations_associations.id"), ) structure: Mapped[Structure] = relationship(init=False, lazy="joined") diff --git a/migrations/versions/50-stores_coreassociations.py b/migrations/versions/50-stores_coreassociations.py new file mode 100644 index 0000000000..2b730d8e06 --- /dev/null +++ b/migrations/versions/50-stores_coreassociations.py @@ -0,0 +1,78 @@ +"""empty message + +Create Date: 2026-03-01 11:41:22.994301 +""" + +import uuid +from collections.abc import Sequence +from typing import TYPE_CHECKING, Union + +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 = "146db8dcb23e" +down_revision: str | None = "1ec573d854a1" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + store_table = sa.table( + "mypayment_store", + sa.column("id", sa.String), + ) + + conn = op.get_bind() + stores = conn.execute( + sa.select(store_table.c.id), + ).fetchall() + + if len(stores) > 0: + raise Exception( + "There are already stores in database, we cannot safely migrate to add association_id to store", + ) + + op.add_column( + "mypayment_store", + sa.Column( + "association_id", + sa.Uuid(), + nullable=False, + ), + ) + op.create_foreign_key( + None, + "mypayment_store", + "associations_associations", + ["association_id"], + ["id"], + ) + + +def downgrade() -> None: + op.drop_constraint( + "mypayment_store_association_id_fkey", + "mypayment_store", + type_="foreignkey", + ) + op.drop_column("mypayment_store", "association_id") + + +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 7bcaa951ad015662d024d369f66a16816fd7a750 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sun, 1 Mar 2026 11:51:09 +0100 Subject: [PATCH 11/72] Don't print all image in migration 49 Compress images --- migrations/versions/49-compress_images.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/migrations/versions/49-compress_images.py b/migrations/versions/49-compress_images.py index dc7ac39860..013d454f25 100644 --- a/migrations/versions/49-compress_images.py +++ b/migrations/versions/49-compress_images.py @@ -70,15 +70,14 @@ def upgrade() -> None: for data_folder, params in data_sources.items(): - print("__________________________________________") # noqa: T201 - print(f"Processing folder: {data_folder}") # noqa: T201 + print(f"\nCompressing images from folder: {data_folder}", end="") # noqa: T201 height = params.get("height") width = params.get("width") quality = params.get("quality", 85) fit = bool(params.get("fit", 0)) if Path("data/" + data_folder).exists(): for file_path in Path("data/" + data_folder).iterdir(): - print(" - ", file_path) # noqa: T201 + print(".", end="") # noqa: T201 if file_path.suffix in (".png", ".jpg", ".webp"): file_bytes = file_path.read_bytes() @@ -105,8 +104,11 @@ def upgrade() -> None: Path(f"data/{data_folder}/{file_path.stem}.webp").write_bytes(res) - # Delete the original file - Path(f"data/{data_folder}/{file_path.name}").unlink() + with Path(f"data/{data_folder}/{file_path.stem}.webp").open( + "wb", + ) as out_file: + out_file.write(res) + print() # noqa: T201 def downgrade() -> None: From fc4812fed75aca82c933f24976d542955a364ac0 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sun, 1 Mar 2026 11:52:42 +0100 Subject: [PATCH 12/72] Lint --- migrations/versions/50-stores_coreassociations.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/migrations/versions/50-stores_coreassociations.py b/migrations/versions/50-stores_coreassociations.py index 2b730d8e06..4dc95642f9 100644 --- a/migrations/versions/50-stores_coreassociations.py +++ b/migrations/versions/50-stores_coreassociations.py @@ -3,9 +3,8 @@ Create Date: 2026-03-01 11:41:22.994301 """ -import uuid from collections.abc import Sequence -from typing import TYPE_CHECKING, Union +from typing import TYPE_CHECKING if TYPE_CHECKING: from pytest_alembic import MigrationContext @@ -13,8 +12,6 @@ import sqlalchemy as sa from alembic import op -from app.types.sqlalchemy import TZDateTime - # revision identifiers, used by Alembic. revision: str = "146db8dcb23e" down_revision: str | None = "1ec573d854a1" @@ -34,7 +31,7 @@ def upgrade() -> None: ).fetchall() if len(stores) > 0: - raise Exception( + raise Exception( # noqa: TRY002, TRY003 "There are already stores in database, we cannot safely migrate to add association_id to store", ) From d7ca8cf61bd4a6f1cb00ed96287a8050eb4ecc9b Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sun, 1 Mar 2026 13:07:33 +0100 Subject: [PATCH 13/72] Fix tests # Conflicts: # tests/core/test_mypayment.py --- tests/core/test_mypayment.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/tests/core/test_mypayment.py b/tests/core/test_mypayment.py index f187921883..a72f79eb43 100644 --- a/tests/core/test_mypayment.py +++ b/tests/core/test_mypayment.py @@ -12,6 +12,7 @@ from pytest_mock import MockerFixture from sqlalchemy.ext.asyncio import AsyncSession +from app.core.associations import models_associations from app.core.checkout import schemas_checkout from app.core.groups import models_groups from app.core.groups.groups_type import AccountType, GroupType @@ -77,6 +78,8 @@ ecl_user2_wallet_device: models_mypayment.WalletDevice ecl_user2_payment: models_mypayment.UserPayment +core_association: models_associations.CoreAssociation + association_membership: models_memberships.CoreAssociationMembership association_membership_user: models_memberships.CoreAssociationUserMembership structure: models_mypayment.Structure @@ -141,6 +144,13 @@ async def init_objects() -> None: admin_user = await create_user_with_groups(groups=[GroupType.admin]) admin_user_token = create_api_access_token(admin_user) + global core_association + core_association = models_associations.CoreAssociation( + id=uuid4(), + name="Association", + group_id=GroupType.admin, + ) + global association_membership association_membership = models_memberships.CoreAssociationMembership( id=uuid4(), @@ -334,7 +344,7 @@ async def init_objects() -> None: name="Test Store", structure_id=structure.id, creation=datetime.now(UTC), - association_id=association_membership.id, + association_id=core_association.id, ) await add_object_to_db(store) store2 = models_mypayment.Store( @@ -343,7 +353,7 @@ async def init_objects() -> None: name="Test Store 2", structure_id=structure2.id, creation=datetime.now(UTC), - association_id=association_membership.id, + association_id=core_association.id, ) await add_object_to_db(store2) store3 = models_mypayment.Store( @@ -352,7 +362,7 @@ async def init_objects() -> None: name="Test Store 3", structure_id=structure2.id, creation=datetime.now(UTC), - association_id=association_membership.id, + association_id=core_association.id, ) await add_object_to_db(store3) @@ -890,7 +900,7 @@ async def test_transfer_structure_manager_as_manager( wallet_id=new_wallet.id, name="Test Store Structure 2", structure_id=new_structure.id, - association_id=association_membership.id, + association_id=core_association.id, ) await add_object_to_db(new_store) new_wallet2 = models_mypayment.Wallet( @@ -905,7 +915,7 @@ async def test_transfer_structure_manager_as_manager( wallet_id=new_wallet2.id, name="Test Store Structure 2 Where New Manager Already Seller", structure_id=new_structure.id, - association_id=association_membership.id, + association_id=core_association.id, ) await add_object_to_db(new_store2_where_new_manager_already_seller) seller = models_mypayment.Seller( @@ -1335,7 +1345,7 @@ async def test_delete_store(client: TestClient): wallet_id=new_wallet.id, name="Test Store to Delete", structure_id=structure.id, - association_id=association_membership.id, + association_id=core_association.id, ) await add_object_to_db(new_store) sellet = models_mypayment.Seller( @@ -1370,7 +1380,7 @@ async def test_update_store(client: TestClient): wallet_id=new_wallet.id, name="Test Store Update", structure_id=structure.id, - association_id=association_membership.id, + association_id=core_association.id, ) await add_object_to_db(new_store) response = client.patch( From 3113cf22c2fd6845e3a43c34e98111914d5fcd06 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sat, 21 Mar 2026 17:20:05 +0100 Subject: [PATCH 14/72] fixup --- migrations/versions/49-compress_images.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/migrations/versions/49-compress_images.py b/migrations/versions/49-compress_images.py index 013d454f25..da7b725d1f 100644 --- a/migrations/versions/49-compress_images.py +++ b/migrations/versions/49-compress_images.py @@ -104,10 +104,10 @@ def upgrade() -> None: Path(f"data/{data_folder}/{file_path.stem}.webp").write_bytes(res) - with Path(f"data/{data_folder}/{file_path.stem}.webp").open( - "wb", - ) as out_file: - out_file.write(res) + with Path(f"data/{data_folder}/{file_path.stem}.webp").open( + "wb", + ) as out_file: + out_file.write(res) print() # noqa: T201 From 860b499b16b2d2861d2ea817a9980e4196b13b91 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sat, 21 Mar 2026 17:21:24 +0100 Subject: [PATCH 15/72] fixup --- migrations/versions/49-compress_images.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/migrations/versions/49-compress_images.py b/migrations/versions/49-compress_images.py index da7b725d1f..45dd3a5b2b 100644 --- a/migrations/versions/49-compress_images.py +++ b/migrations/versions/49-compress_images.py @@ -104,10 +104,6 @@ def upgrade() -> None: Path(f"data/{data_folder}/{file_path.stem}.webp").write_bytes(res) - with Path(f"data/{data_folder}/{file_path.stem}.webp").open( - "wb", - ) as out_file: - out_file.write(res) print() # noqa: T201 From 47b3be0b2481820de6fde911f73a2d05cff9d1a4 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sat, 21 Mar 2026 17:25:01 +0100 Subject: [PATCH 16/72] Migration rebase --- ...stores_coreassociations.py => 66-stores_coreassociations.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename migrations/versions/{50-stores_coreassociations.py => 66-stores_coreassociations.py} (97%) diff --git a/migrations/versions/50-stores_coreassociations.py b/migrations/versions/66-stores_coreassociations.py similarity index 97% rename from migrations/versions/50-stores_coreassociations.py rename to migrations/versions/66-stores_coreassociations.py index 4dc95642f9..f440541df6 100644 --- a/migrations/versions/50-stores_coreassociations.py +++ b/migrations/versions/66-stores_coreassociations.py @@ -14,7 +14,7 @@ # revision identifiers, used by Alembic. revision: str = "146db8dcb23e" -down_revision: str | None = "1ec573d854a1" +down_revision: str | None = "562adbd796ae" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None From 9b5d2b9b63db7eb7a02de96ad275832f2bf61f4d Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Fri, 27 Mar 2026 23:29:16 +0100 Subject: [PATCH 17/72] get_store_by_association_id --- app/core/mypayment/cruds_mypayment.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/core/mypayment/cruds_mypayment.py b/app/core/mypayment/cruds_mypayment.py index 6a1dde3f98..a25ce2a975 100644 --- a/app/core/mypayment/cruds_mypayment.py +++ b/app/core/mypayment/cruds_mypayment.py @@ -198,6 +198,18 @@ async def get_store_by_name( return result.scalars().first() +async def get_store_by_association_id( + association_id: UUID, + db: AsyncSession, +) -> models_mypayment.Store | None: + result = await db.execute( + select(models_mypayment.Store).where( + models_mypayment.Store.association_id == association_id, + ), + ) + return result.scalars().first() + + async def get_stores_by_structure_id( db: AsyncSession, structure_id: UUID, From c6f0e5552a7d8d7d72258a8b3cdd18e0335ddb71 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sat, 28 Mar 2026 12:21:39 +0100 Subject: [PATCH 18/72] fix tests --- tests/core/test_mypayment.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/core/test_mypayment.py b/tests/core/test_mypayment.py index a72f79eb43..6b7518c4ff 100644 --- a/tests/core/test_mypayment.py +++ b/tests/core/test_mypayment.py @@ -150,6 +150,7 @@ async def init_objects() -> None: name="Association", group_id=GroupType.admin, ) + await add_object_to_db(core_association) global association_membership association_membership = models_memberships.CoreAssociationMembership( @@ -974,6 +975,7 @@ async def test_create_store_for_non_existing_structure(client: TestClient): headers={"Authorization": f"Bearer {structure_manager_user_token}"}, json={ "name": "test_create_store Test Store", + "association_id": str(core_association.id), }, ) assert response.status_code == 404 @@ -986,6 +988,7 @@ async def test_create_store(client: TestClient): headers={"Authorization": f"Bearer {structure_manager_user_token}"}, json={ "name": "test_create_store Test Store", + "association_id": str(core_association.id), }, ) assert response.status_code == 201 @@ -1005,6 +1008,7 @@ async def test_create_store_when_user_not_manager_of_structure(client: TestClien headers={"Authorization": f"Bearer {ecl_user_access_token}"}, json={ "name": "test_create_store Test Store", + "association_id": str(core_association.id), }, ) assert response.status_code == 403 @@ -1017,6 +1021,7 @@ async def test_create_store_with_name_already_exist(client: TestClient): headers={"Authorization": f"Bearer {structure_manager_user_token}"}, json={ "name": "Test Store", + "association_id": str(core_association.id), }, ) assert response.status_code == 400 From 433bfbc17e18ed371c6a71ab431945b6f5574f6d Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sat, 28 Mar 2026 12:43:20 +0100 Subject: [PATCH 19/72] Fix tests --- tests/core/test_mypayment.py | 73 +++++++++++++++++++++++++++++++++--- 1 file changed, 68 insertions(+), 5 deletions(-) diff --git a/tests/core/test_mypayment.py b/tests/core/test_mypayment.py index 6b7518c4ff..0c4f53e3c9 100644 --- a/tests/core/test_mypayment.py +++ b/tests/core/test_mypayment.py @@ -78,6 +78,7 @@ ecl_user2_wallet_device: models_mypayment.WalletDevice ecl_user2_payment: models_mypayment.UserPayment +core_association_group: models_groups.CoreGroup core_association: models_associations.CoreAssociation association_membership: models_memberships.CoreAssociationMembership @@ -144,11 +145,15 @@ async def init_objects() -> None: admin_user = await create_user_with_groups(groups=[GroupType.admin]) admin_user_token = create_api_access_token(admin_user) - global core_association + global core_association_group, core_association + core_association_group = await create_groups_with_permissions( + group_name="Core Association Group", + permissions=[], + ) core_association = models_associations.CoreAssociation( id=uuid4(), name="Association", - group_id=GroupType.admin, + group_id=core_association_group.id, ) await add_object_to_db(core_association) @@ -982,10 +987,66 @@ async def test_create_store_for_non_existing_structure(client: TestClient): assert response.json()["detail"] == "Structure does not exist" -async def test_create_store(client: TestClient): +async def test_create_store_for_non_existing_association(client: TestClient): response = client.post( f"/mypayment/structures/{structure.id}/stores", headers={"Authorization": f"Bearer {structure_manager_user_token}"}, + json={ + "name": "test_create_store Test Store", + "association_id": str(uuid4()), + }, + ) + assert response.status_code == 404 + assert response.json()["detail"] == "Association not found" + + +async def test_create_store_as_non_association_manager_member(client: TestClient): + response = client.post( + f"/mypayment/structures/{structure.id}/stores", + headers={ + "Authorization": f"Bearer {structure_manager_user_token}", + }, + json={ + "name": "test_create_store Test Store", + "association_id": str(core_association.id), + }, + ) + assert response.status_code == 403 + assert ( + response.json()["detail"] + == "You are not allowed to create stores for this association" + ) + + +async def test_create_store(client: TestClient): + structure_manager_and_association_member_user = await create_user_with_groups( + groups=[core_association_group.id], + ) + structure_manager_and_association_member_user_token = create_api_access_token( + structure_manager_and_association_member_user, + ) + structure = models_mypayment.Structure( + id=uuid4(), + name="Test Structure", + creation=datetime.now(UTC), + association_membership_id=association_membership.id, + manager_user_id=structure_manager_and_association_member_user.id, + short_id="DEF", + 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) + + response = client.post( + f"/mypayment/structures/{structure.id}/stores", + headers={ + "Authorization": f"Bearer {structure_manager_and_association_member_user_token}" + }, json={ "name": "test_create_store Test Store", "association_id": str(core_association.id), @@ -996,7 +1057,9 @@ async def test_create_store(client: TestClient): stores = client.get( "/mypayment/users/me/stores", - headers={"Authorization": f"Bearer {structure_manager_user_token}"}, + headers={ + "Authorization": f"Bearer {structure_manager_and_association_member_user_token}" + }, ) stores_ids = [store["id"] for store in stores.json()] assert response.json()["id"] in stores_ids @@ -1269,7 +1332,7 @@ async def test_get_stores_as_manager(client: TestClient): headers={"Authorization": f"Bearer {structure_manager_user_token}"}, ) assert response.status_code == 200 - assert len(response.json()) > 1 + assert len(response.json()) == 1 async def test_update_store_non_existing(client: TestClient): From 85c8b7ed57dafca575a222e788bc3591d9d22c94 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sat, 28 Mar 2026 12:43:55 +0100 Subject: [PATCH 20/72] Lint --- tests/core/test_mypayment.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/core/test_mypayment.py b/tests/core/test_mypayment.py index 0c4f53e3c9..2ca6dcbb72 100644 --- a/tests/core/test_mypayment.py +++ b/tests/core/test_mypayment.py @@ -1045,7 +1045,7 @@ async def test_create_store(client: TestClient): response = client.post( f"/mypayment/structures/{structure.id}/stores", headers={ - "Authorization": f"Bearer {structure_manager_and_association_member_user_token}" + "Authorization": f"Bearer {structure_manager_and_association_member_user_token}", }, json={ "name": "test_create_store Test Store", @@ -1058,7 +1058,7 @@ async def test_create_store(client: TestClient): stores = client.get( "/mypayment/users/me/stores", headers={ - "Authorization": f"Bearer {structure_manager_and_association_member_user_token}" + "Authorization": f"Bearer {structure_manager_and_association_member_user_token}", }, ) stores_ids = [store["id"] for store in stores.json()] From e13fe9ae6cf99cf7ee3a0a53abdde8280270c764 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sat, 28 Mar 2026 12:50:10 +0100 Subject: [PATCH 21/72] Unique constraint for CoreAssociation and Store relationship --- app/core/mypayment/models_mypayment.py | 1 + migrations/versions/66-stores_coreassociations.py | 1 + 2 files changed, 2 insertions(+) diff --git a/app/core/mypayment/models_mypayment.py b/app/core/mypayment/models_mypayment.py index 6c91fc054d..a19b005907 100644 --- a/app/core/mypayment/models_mypayment.py +++ b/app/core/mypayment/models_mypayment.py @@ -172,6 +172,7 @@ class Store(Base): association_id: Mapped[UUID] = mapped_column( ForeignKey("associations_associations.id"), + unique=True, ) structure: Mapped[Structure] = relationship(init=False, lazy="joined") diff --git a/migrations/versions/66-stores_coreassociations.py b/migrations/versions/66-stores_coreassociations.py index f440541df6..51aa3b1e6a 100644 --- a/migrations/versions/66-stores_coreassociations.py +++ b/migrations/versions/66-stores_coreassociations.py @@ -50,6 +50,7 @@ def upgrade() -> None: ["association_id"], ["id"], ) + op.create_unique_constraint(None, "mypayment_store", ["association_id"]) def downgrade() -> None: From f7bb6d95ddfd5b7cfafd78c0e81d271a254587e7 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sat, 28 Mar 2026 13:05:22 +0100 Subject: [PATCH 22/72] Unique constraint --- app/core/mypayment/endpoints_mypayment.py | 9 ++++ tests/core/test_mypayment.py | 58 ++++++++++++++++++++--- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/app/core/mypayment/endpoints_mypayment.py b/app/core/mypayment/endpoints_mypayment.py index 7d4ffe4a78..6ac55d14ad 100644 --- a/app/core/mypayment/endpoints_mypayment.py +++ b/app/core/mypayment/endpoints_mypayment.py @@ -557,6 +557,15 @@ async def create_store( status_code=403, detail="You are not allowed to create stores for this association", ) + existing_store_for_association = await cruds_mypayment.get_store_by_association_id( + association_id=store.association_id, + db=db, + ) + if existing_store_for_association is not None: + raise HTTPException( + status_code=400, + detail="Store for this association already exists", + ) # Create new wallet for store wallet_id = uuid.uuid4() diff --git a/tests/core/test_mypayment.py b/tests/core/test_mypayment.py index 2ca6dcbb72..092134e339 100644 --- a/tests/core/test_mypayment.py +++ b/tests/core/test_mypayment.py @@ -21,6 +21,8 @@ from app.core.mypayment.coredata_mypayment import ( MyPaymentBankAccountHolder, ) +from app.core.mypayment.cruds_mypayment import delete_store +from app.core.mypayment.schemas_mypayment import QRCodeContentData from app.core.mypayment.endpoints_mypayment import MyPaymentPermissions from app.core.mypayment.schemas_mypayment import ( SecuredContentData, @@ -353,22 +355,34 @@ async def init_objects() -> None: association_id=core_association.id, ) await add_object_to_db(store) + core_association2 = models_associations.CoreAssociation( + id=uuid4(), + name="core_association2", + group_id=core_association_group.id, + ) + await add_object_to_db(core_association2) store2 = models_mypayment.Store( id=uuid4(), wallet_id=store2_wallet.id, name="Test Store 2", structure_id=structure2.id, creation=datetime.now(UTC), - association_id=core_association.id, + association_id=core_association2.id, ) await add_object_to_db(store2) + core_association3 = models_associations.CoreAssociation( + id=uuid4(), + name="core_association3", + group_id=core_association_group.id, + ) + await add_object_to_db(core_association3) store3 = models_mypayment.Store( id=uuid4(), wallet_id=store3_wallet.id, name="Test Store 3", structure_id=structure2.id, creation=datetime.now(UTC), - association_id=core_association.id, + association_id=core_association3.id, ) await add_object_to_db(store3) @@ -900,13 +914,19 @@ async def test_transfer_structure_manager_as_manager( balance=5000, ) await add_object_to_db(new_wallet) + new_core_association = models_associations.CoreAssociation( + id=uuid4(), + name="new_core_association", + group_id=core_association_group.id, + ) + await add_object_to_db(new_core_association) new_store = models_mypayment.Store( id=uuid4(), creation=datetime.now(UTC), wallet_id=new_wallet.id, name="Test Store Structure 2", structure_id=new_structure.id, - association_id=core_association.id, + association_id=new_core_association.id, ) await add_object_to_db(new_store) new_wallet2 = models_mypayment.Wallet( @@ -915,13 +935,19 @@ async def test_transfer_structure_manager_as_manager( balance=5000, ) await add_object_to_db(new_wallet2) + new2_core_association = models_associations.CoreAssociation( + id=uuid4(), + name="new2_core_association", + group_id=core_association_group.id, + ) + await add_object_to_db(new2_core_association) new_store2_where_new_manager_already_seller = models_mypayment.Store( id=uuid4(), creation=datetime.now(UTC), wallet_id=new_wallet2.id, name="Test Store Structure 2 Where New Manager Already Seller", structure_id=new_structure.id, - association_id=core_association.id, + association_id=new2_core_association.id, ) await add_object_to_db(new_store2_where_new_manager_already_seller) seller = models_mypayment.Seller( @@ -1041,6 +1067,12 @@ async def test_create_store(client: TestClient): bic="AZERTYUIOP", ) await add_object_to_db(structure) + create_store_core_association = models_associations.CoreAssociation( + id=uuid4(), + name="create_store_core_association", + group_id=core_association_group.id, + ) + await add_object_to_db(create_store_core_association) response = client.post( f"/mypayment/structures/{structure.id}/stores", @@ -1049,7 +1081,7 @@ async def test_create_store(client: TestClient): }, json={ "name": "test_create_store Test Store", - "association_id": str(core_association.id), + "association_id": str(create_store_core_association.id), }, ) assert response.status_code == 201 @@ -1407,13 +1439,19 @@ async def test_delete_store(client: TestClient): balance=5000, ) await add_object_to_db(new_wallet) + delete_store_core_association = models_associations.CoreAssociation( + id=uuid4(), + name="delete_store_core_association", + group_id=core_association_group.id, + ) + await add_object_to_db(delete_store_core_association) new_store = models_mypayment.Store( id=store_id, creation=datetime.now(UTC), wallet_id=new_wallet.id, name="Test Store to Delete", structure_id=structure.id, - association_id=core_association.id, + association_id=delete_store_core_association.id, ) await add_object_to_db(new_store) sellet = models_mypayment.Seller( @@ -1442,13 +1480,19 @@ async def test_update_store(client: TestClient): balance=5000, ) await add_object_to_db(new_wallet) + update_store_core_association = models_associations.CoreAssociation( + id=uuid4(), + name="update_store_core_association", + group_id=core_association_group.id, + ) + await add_object_to_db(update_store_core_association) new_store = models_mypayment.Store( id=uuid4(), creation=datetime.now(UTC), wallet_id=new_wallet.id, name="Test Store Update", structure_id=structure.id, - association_id=core_association.id, + association_id=update_store_core_association.id, ) await add_object_to_db(new_store) response = client.patch( From 54ede67ec427cb023a799fd36b7a070e3094d719 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sat, 28 Mar 2026 13:06:01 +0100 Subject: [PATCH 23/72] lint --- tests/core/test_mypayment.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/core/test_mypayment.py b/tests/core/test_mypayment.py index 092134e339..b8e9c33738 100644 --- a/tests/core/test_mypayment.py +++ b/tests/core/test_mypayment.py @@ -21,7 +21,6 @@ from app.core.mypayment.coredata_mypayment import ( MyPaymentBankAccountHolder, ) -from app.core.mypayment.cruds_mypayment import delete_store from app.core.mypayment.schemas_mypayment import QRCodeContentData from app.core.mypayment.endpoints_mypayment import MyPaymentPermissions from app.core.mypayment.schemas_mypayment import ( From 495b6664f24c72aea22ce6a0818a9c59192b3467 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sat, 28 Mar 2026 13:29:03 +0100 Subject: [PATCH 24/72] Fix factories --- app/core/associations/factory_associations.py | 12 ++------ app/core/groups/factory_groups.py | 28 +++++++++++++------ app/core/mypayment/factory_mypayment.py | 6 +++- 3 files changed, 27 insertions(+), 19 deletions(-) diff --git a/app/core/associations/factory_associations.py b/app/core/associations/factory_associations.py index faa34214de..8332fc767a 100644 --- a/app/core/associations/factory_associations.py +++ b/app/core/associations/factory_associations.py @@ -11,22 +11,14 @@ class AssociationsFactory(Factory): - association_ids = [ - uuid.uuid4(), - uuid.uuid4(), - uuid.uuid4(), - uuid.uuid4(), - ] + association_ids = [uuid.uuid4() for _ in range(len(CoreGroupsFactory.groups_ids))] depends_on = [CoreGroupsFactory] @classmethod async def create_associations(cls, db: AsyncSession): descriptions = [ - "Association 1", - "Association 2", - "Association 3", - "Association 4", + f"Association {i + 1}" for i in range(len(CoreGroupsFactory.groups_ids)) ] for i in range(len(CoreGroupsFactory.groups_ids)): await cruds_associations.create_association( diff --git a/app/core/groups/factory_groups.py b/app/core/groups/factory_groups.py index e7db9846f0..407c7758de 100644 --- a/app/core/groups/factory_groups.py +++ b/app/core/groups/factory_groups.py @@ -12,23 +12,35 @@ class CoreGroupsFactory(Factory): - groups_ids = [ - str(uuid.uuid4()), - str(uuid.uuid4()), - str(uuid.uuid4()), - str(uuid.uuid4()), - ] + groups_ids = [str(uuid.uuid4()) for _ in range(10)] depends_on = [CoreUsersFactory] @classmethod async def create_core_groups(cls, db: AsyncSession): - groups = ["BDE", "BDS", "Oui", "Pixels"] + groups = [ + "BDE", + "BDS", + "Pixels", + "Commuz", + "Musique", + "Fablab", + "BDA", + "Fouquette", + "Soli", + "SDeC", + ] descriptions = [ "Bureau des élèves", "Bureau des sports", - "Association d'entraide", "Association de photographie", + "", + "", + "", + "", + "", + "", + "", ] for i in range(len(groups)): await cruds_groups.create_group( diff --git a/app/core/mypayment/factory_mypayment.py b/app/core/mypayment/factory_mypayment.py index f7aad4bdf4..a8c4c57b3c 100644 --- a/app/core/mypayment/factory_mypayment.py +++ b/app/core/mypayment/factory_mypayment.py @@ -68,6 +68,7 @@ async def create_structures(cls, db: AsyncSession): @classmethod async def create_other_structures_stores(cls, db: AsyncSession): + association_id_index = 0 for structure_id in cls.other_structures_id: structure_store_ids = [] structure_wallet_ids = [] @@ -89,10 +90,13 @@ async def create_other_structures_stores(cls, db: AsyncSession): name=faker.company(), creation=datetime.now(UTC), wallet_id=wallet_id, - association_id=AssociationsFactory.association_ids[0], + association_id=AssociationsFactory.association_ids[ + association_id_index + ], ), db, ) + association_id_index += 1 cls.other_stores_id.append(structure_store_ids) cls.other_stores_wallet_id.append(structure_wallet_ids) From caa82d39d1bcc598c561e0b60ab86edd4294f272 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:49:16 +0200 Subject: [PATCH 25/72] Optional association_id --- app/core/mypayment/models_mypayment.py | 2 +- app/core/mypayment/schemas_mypayment.py | 2 +- .../versions/66-stores_coreassociations.py | 17 +---------------- tests/core/test_mypayment.py | 17 +++-------------- 4 files changed, 6 insertions(+), 32 deletions(-) diff --git a/app/core/mypayment/models_mypayment.py b/app/core/mypayment/models_mypayment.py index a19b005907..9968ee5c17 100644 --- a/app/core/mypayment/models_mypayment.py +++ b/app/core/mypayment/models_mypayment.py @@ -170,7 +170,7 @@ class Store(Base): ) creation: Mapped[datetime] - association_id: Mapped[UUID] = mapped_column( + association_id: Mapped[UUID | None] = mapped_column( ForeignKey("associations_associations.id"), unique=True, ) diff --git a/app/core/mypayment/schemas_mypayment.py b/app/core/mypayment/schemas_mypayment.py index 6364a96cb6..703f273a97 100644 --- a/app/core/mypayment/schemas_mypayment.py +++ b/app/core/mypayment/schemas_mypayment.py @@ -69,7 +69,7 @@ class StructureTranfert(BaseModel): class StoreBase(BaseModel): name: str - association_id: UUID + association_id: UUID | None = None class StoreSimple(StoreBase): diff --git a/migrations/versions/66-stores_coreassociations.py b/migrations/versions/66-stores_coreassociations.py index 51aa3b1e6a..e30966c34e 100644 --- a/migrations/versions/66-stores_coreassociations.py +++ b/migrations/versions/66-stores_coreassociations.py @@ -20,27 +20,12 @@ def upgrade() -> None: - store_table = sa.table( - "mypayment_store", - sa.column("id", sa.String), - ) - - conn = op.get_bind() - stores = conn.execute( - sa.select(store_table.c.id), - ).fetchall() - - if len(stores) > 0: - raise Exception( # noqa: TRY002, TRY003 - "There are already stores in database, we cannot safely migrate to add association_id to store", - ) - op.add_column( "mypayment_store", sa.Column( "association_id", sa.Uuid(), - nullable=False, + nullable=True, ), ) op.create_foreign_key( diff --git a/tests/core/test_mypayment.py b/tests/core/test_mypayment.py index b8e9c33738..d849297b6a 100644 --- a/tests/core/test_mypayment.py +++ b/tests/core/test_mypayment.py @@ -354,34 +354,23 @@ async def init_objects() -> None: association_id=core_association.id, ) await add_object_to_db(store) - core_association2 = models_associations.CoreAssociation( - id=uuid4(), - name="core_association2", - group_id=core_association_group.id, - ) - await add_object_to_db(core_association2) store2 = models_mypayment.Store( id=uuid4(), wallet_id=store2_wallet.id, name="Test Store 2", structure_id=structure2.id, creation=datetime.now(UTC), - association_id=core_association2.id, + association_id=None, ) await add_object_to_db(store2) - core_association3 = models_associations.CoreAssociation( - id=uuid4(), - name="core_association3", - group_id=core_association_group.id, - ) - await add_object_to_db(core_association3) + store3 = models_mypayment.Store( id=uuid4(), wallet_id=store3_wallet.id, name="Test Store 3", structure_id=structure2.id, creation=datetime.now(UTC), - association_id=core_association3.id, + association_id=None, ) await add_object_to_db(store3) From 306fc066ea94771dbce9a7f95d79aab6ff937608 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:52:10 +0200 Subject: [PATCH 26/72] fix --- app/core/mypayment/endpoints_mypayment.py | 51 ++++++++++++----------- 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/app/core/mypayment/endpoints_mypayment.py b/app/core/mypayment/endpoints_mypayment.py index 6ac55d14ad..d5aaa3ea78 100644 --- a/app/core/mypayment/endpoints_mypayment.py +++ b/app/core/mypayment/endpoints_mypayment.py @@ -540,32 +540,35 @@ async def create_store( detail="Store with this name already exists in this structure", ) - association = await cruds_associations.get_association_by_id( - db=db, - association_id=store.association_id, - ) - if not association: - raise HTTPException( - status_code=404, - detail="Association not found", - ) - if not is_user_member_of_an_association( - user=user, - association=association, - ): - raise HTTPException( - status_code=403, - detail="You are not allowed to create stores for this association", + if store.association_id is not None: + association = await cruds_associations.get_association_by_id( + db=db, + association_id=store.association_id, ) - existing_store_for_association = await cruds_mypayment.get_store_by_association_id( - association_id=store.association_id, - db=db, - ) - if existing_store_for_association is not None: - raise HTTPException( - status_code=400, - detail="Store for this association already exists", + if not association: + raise HTTPException( + status_code=404, + detail="Association not found", + ) + if not is_user_member_of_an_association( + user=user, + association=association, + ): + raise HTTPException( + status_code=403, + detail="You are not allowed to create stores for this association", + ) + existing_store_for_association = ( + await cruds_mypayment.get_store_by_association_id( + association_id=store.association_id, + db=db, + ) ) + if existing_store_for_association is not None: + raise HTTPException( + status_code=400, + detail="Store for this association already exists", + ) # Create new wallet for store wallet_id = uuid.uuid4() From 28c68c63b9ac052aff99326489c704119d010cdd Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Wed, 25 Mar 2026 23:18:59 +0100 Subject: [PATCH 27/72] Tickets --- app/core/tickets/__init__.py | 0 app/core/tickets/cruds_tickets.py | 297 +++++++++++++++++++++ app/core/tickets/endpoints_tickets.py | 371 ++++++++++++++++++++++++++ app/core/tickets/factory_tickets.py | 14 + app/core/tickets/models_tickets.py | 107 ++++++++ app/core/tickets/schemas_tickets.py | 100 +++++++ app/core/tickets/todo.md | 13 + 7 files changed, 902 insertions(+) create mode 100644 app/core/tickets/__init__.py create mode 100644 app/core/tickets/cruds_tickets.py create mode 100644 app/core/tickets/endpoints_tickets.py create mode 100644 app/core/tickets/factory_tickets.py create mode 100644 app/core/tickets/models_tickets.py create mode 100644 app/core/tickets/schemas_tickets.py create mode 100644 app/core/tickets/todo.md diff --git a/app/core/tickets/__init__.py b/app/core/tickets/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/core/tickets/cruds_tickets.py b/app/core/tickets/cruds_tickets.py new file mode 100644 index 0000000000..b93a34c717 --- /dev/null +++ b/app/core/tickets/cruds_tickets.py @@ -0,0 +1,297 @@ +import uuid +from collections.abc import Sequence +from datetime import UTC, datetime +from uuid import UUID + +from sqlalchemy import or_ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload +from sqlalchemy.sql import select + +from app.core.tickets import models_tickets, schemas_tickets + + +async def get_tickets_by_user_id( + user_id: str, + db: AsyncSession, +) -> Sequence[schemas_tickets.Ticket]: + result = await db.execute( + select(models_tickets.Ticket) + .where(models_tickets.Ticket.user_id == user_id) + .options( + selectinload(models_tickets.Ticket.category), + selectinload(models_tickets.Ticket.session), + ), + ) + return [ + schemas_tickets.Ticket( + id=ticket.id, + category_id=ticket.category_id, + session_id=ticket.session_id, + scanned=ticket.scanned, + category=schemas_tickets.Category( + id=ticket.category.id, + name=ticket.category.name, + price=ticket.category.price, + required_membership=ticket.category.required_membership, + event_id=ticket.category.event_id, + ), + session=schemas_tickets.Session( + name=ticket.session.name, + start_time=ticket.session.start_time, + end_time=ticket.session.end_time, + event_id=ticket.session.event_id, + ), + user_id=ticket.user_id, + price=ticket.price, + ) + for ticket in result.scalars().all() + ] + + +async def get_tickets_by_event_id( + event_id: UUID, + db: AsyncSession, +) -> Sequence[schemas_tickets.Ticket]: + result = await db.execute( + select(models_tickets.Ticket) + .where(models_tickets.Ticket.category.event_id == event_id) + .options( + selectinload(models_tickets.Ticket.category), + selectinload(models_tickets.Ticket.session), + ), + ) + return [ + schemas_tickets.Ticket( + id=ticket.id, + category_id=ticket.category_id, + session_id=ticket.session_id, + scanned=ticket.scanned, + category=schemas_tickets.Category( + id=ticket.category.id, + name=ticket.category.name, + price=ticket.category.price, + required_membership=ticket.category.required_membership, + event_id=ticket.category.event_id, + ), + session=schemas_tickets.Session( + name=ticket.session.name, + start_time=ticket.session.start_time, + end_time=ticket.session.end_time, + event_id=ticket.session.event_id, + ), + user_id=ticket.user_id, + price=ticket.price, + ) + for ticket in result.scalars().all() + ] + + +async def get_open_events( + db: AsyncSession, +) -> Sequence[schemas_tickets.EventSimple]: + """Return all open events from database""" + + time = datetime.now(UTC) + + result = await db.execute( + select(models_tickets.TicketEvent).where( + models_tickets.TicketEvent.open_datetime <= time, + or_( + models_tickets.TicketEvent.close_datetime.is_(None), + models_tickets.TicketEvent.close_datetime > time, + ), + ), + ) + return [ + schemas_tickets.EventSimple( + id=association.id, + name=association.name, + store_id=association.store_id, + ) + for association in result.scalars().all() + ] + + +async def get_events_by_store_id( + store_id: UUID, + db: AsyncSession, +) -> Sequence[schemas_tickets.EventSimple]: + """Return all open events from database""" + + result = await db.execute( + select(models_tickets.TicketEvent).where( + models_tickets.TicketEvent.store_id == store_id, + ), + ) + return [ + schemas_tickets.EventSimple( + id=association.id, + name=association.name, + store_id=association.store_id, + ) + for association in result.scalars().all() + ] + + +async def get_event_by_id( + event_id: UUID, + db: AsyncSession, +) -> schemas_tickets.EventAdmin | None: + """Return one open event with public details from database""" + + result = await db.execute( + select(models_tickets.TicketEvent) + .where( + models_tickets.TicketEvent.id == event_id, + ) + .options( + selectinload(models_tickets.TicketEvent.sessions), + selectinload(models_tickets.TicketEvent.categories), + ), + ) + + event = result.scalars().first() + if event is None: + return None + + return schemas_tickets.EventAdmin( + id=event.id, + name=event.name, + open_datetime=event.open_datetime, + close_datetime=event.close_datetime, + quota=event.quota, + quota_per_checkout=event.quota_per_checkout, + quota_per_user=event.quota_per_user, + store_id=event.store_id, + sessions=[ + schemas_tickets.SessionComplete( + id=session.id, + name=session.name, + start_time=session.start_time, + end_time=session.end_time, + event_id=session.event_id, + ) + for session in sorted(event.sessions, key=lambda item: item.start_time) + ], + categories=[ + schemas_tickets.CategoryComplete( + id=category.id, + name=category.name, + price=category.price, + required_membership=category.required_membership, + event_id=category.event_id, + ) + for category in sorted(event.categories, key=lambda item: item.name) + ], + ) + + +async def create_event( + event_id: UUID, + event: schemas_tickets.EventCreate, + db: AsyncSession, +): + db_event = models_tickets.TicketEvent( + id=event_id, + store_id=event.store_id, + name=event.name, + quota_per_user=event.quota_per_user, + quota_per_checkout=event.quota_per_checkout, + quota=event.quota, + open_datetime=event.open_datetime, + close_datetime=event.close_datetime, + sessions=[ + models_tickets.Session( + id=uuid.uuid4(), + event_id=event_id, + name=session.name, + start_time=session.start_time, + end_time=session.end_time, + quota=session.quota, + ) + for session in event.sessions + ], + categories=[ + models_tickets.Category( + id=uuid.uuid4(), + event_id=event_id, + name=category.name, + quota=category.quota, + price=category.price, + required_membership=category.required_membership, + ) + for category in event.categories + ], + ) + db.add(db_event) + + +async def get_category_by_id( + category_id: UUID, + db: AsyncSession, +) -> schemas_tickets.CategoryComplete | None: + """Return one category from database""" + + result = await db.execute( + select(models_tickets.Category).where( + models_tickets.Category.id == category_id, + ), + ) + + category = result.scalars().first() + if category is None: + return None + + return schemas_tickets.CategoryComplete( + id=category.id, + name=category.name, + price=category.price, + required_membership=category.required_membership, + event_id=category.event_id, + ) + + +async def get_session_by_id( + session_id: UUID, + db: AsyncSession, +) -> schemas_tickets.SessionComplete | None: + """Return one session from database""" + + result = await db.execute( + select(models_tickets.Session).where( + models_tickets.Session.id == session_id, + ), + ) + + session = result.scalars().first() + if session is None: + return None + + return schemas_tickets.SessionComplete( + id=session.id, + name=session.name, + start_time=session.start_time, + end_time=session.end_time, + event_id=session.event_id, + ) + + +async def create_checkout( + checkout_id: UUID, + user_id: str, + category_id: UUID, + session_id: UUID, + price: int, + expiration: datetime, + db: AsyncSession, +): + db_checkout = models_tickets.Checkout( + id=checkout_id, + user_id=user_id, + category_id=category_id, + session_id=session_id, + price=price, + expiration=expiration, + ) + db.add(db_checkout) diff --git a/app/core/tickets/endpoints_tickets.py b/app/core/tickets/endpoints_tickets.py new file mode 100644 index 0000000000..a9b9be3ccc --- /dev/null +++ b/app/core/tickets/endpoints_tickets.py @@ -0,0 +1,371 @@ +import csv +import logging +import uuid +from datetime import UTC, datetime, timedelta +from io import StringIO +from uuid import UUID + +from fastapi import ( + APIRouter, + Depends, + HTTPException, + Response, +) +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.mypayment import cruds_mypayment +from app.core.permissions.type_permissions import ModulePermissions +from app.core.tickets import cruds_tickets, schemas_tickets +from app.core.tickets.factory_tickets import TicketsFactory +from app.core.users.models_users import CoreUser +from app.dependencies import ( + get_db, + is_user, + is_user_allowed_to, +) +from app.types.module import CoreModule + +router = APIRouter(tags=["Tickets"]) + +core_module = CoreModule( + root="ticket", + tag="Tickets", + router=router, + factory=TicketsFactory(), +) + +CHECKOUT_EXPIRATION_MINUTES = 15 + +hyperion_error_logger = logging.getLogger("hyperion.error") +hyperion_security_logger = logging.getLogger("hyperion.security") +hyperion_mypayment_logger = logging.getLogger("hyperion.mypayment") + + +class TicketsPermissions(ModulePermissions): + buy_tickets = "buy_tickets" + + +@router.get( + "/tickets/events", + response_model=list[schemas_tickets.EventSimple], + status_code=200, +) +async def get_open_events( + user: CoreUser = Depends( + is_user_allowed_to( + [TicketsPermissions.buy_tickets], + ), + ), + db: AsyncSession = Depends(get_db), +): + """ + Return all open events + """ + return await cruds_tickets.get_open_events(db=db) + + +@router.get( + "/tickets/events/{event_id}", + response_model=schemas_tickets.EventPublic, + status_code=200, +) +async def get_event( + event_id: UUID, + user: CoreUser = Depends( + is_user_allowed_to( + [TicketsPermissions.buy_tickets], + ), + ), + db: AsyncSession = Depends(get_db), +): + """ + Get an event public details + """ + event = await cruds_tickets.get_event_by_id(event_id=event_id, db=db) + # TODO: indicate if the event is sold out + if event is None: + raise HTTPException(404, "Event not found") + return event + + +@router.post( + "/tickets/events/{event_id}/checkout", + response_model=schemas_tickets.CheckoutResponse, + status_code=200, +) +async def create_checkout( + event_id: UUID, + checkout: schemas_tickets.Checkout, + user: CoreUser = Depends( + is_user_allowed_to( + [TicketsPermissions.buy_tickets], + ), + ), + db: AsyncSession = Depends(get_db), +): + """ + Create a checkout for an open event + """ + category = await cruds_tickets.get_category_by_id( + category_id=checkout.category_id, + db=db, + ) + if category is None: + raise HTTPException(404, "Category not found") + session = await cruds_tickets.get_session_by_id( + session_id=checkout.session_id, + db=db, + ) + if session is None: + raise HTTPException(404, "Session not found") + + if category.event_id != event_id: + raise HTTPException(400, "Category does not belong to the event") + if session.event_id != event_id: + raise HTTPException(400, "Session does not belong to the event") + + price = category.price + expiration = datetime.now(UTC) + timedelta(minutes=CHECKOUT_EXPIRATION_MINUTES) + + # TODO: indicate if the event is sold out + + await cruds_tickets.create_checkout( + checkout_id=uuid.uuid4(), + user_id=user.id, + category_id=checkout.category_id, + session_id=checkout.session_id, + expiration=expiration, + price=price, + db=db, + ) + + # TODO: return the payment id + return schemas_tickets.CheckoutResponse( + price=price, + expiration=expiration, + ) + + +@router.get( + "/tickets/user/me/tickets", + response_model=list[schemas_tickets.Ticket], + status_code=200, +) +async def get_user_tickets( + event_id: UUID, + user: CoreUser = Depends( + is_user_allowed_to( + [TicketsPermissions.buy_tickets], + ), + ), + db: AsyncSession = Depends(get_db), +): + """ + Get all tickets of the current user + """ + return await cruds_tickets.get_tickets_by_user_id( + user_id=user.id, + db=db, + ) + + +@router.get( + "/tickets/admin/events/{event_id}", + response_model=schemas_tickets.EventAdmin, + status_code=200, +) +async def get_event_admin( + event_id: UUID, + user: CoreUser = Depends( + is_user(), + ), + db: AsyncSession = Depends(get_db), +): + """ + Get one event admin details + + **The user should have the right to manage the event seller** + """ + event = await cruds_tickets.get_event_by_id(event_id=event_id, db=db) + if event is None: + raise HTTPException(404, "Event not found") + + # TODO: check if user has the right to manage the seller + return event + + +@router.post( + "/tickets/admin/events/", + response_model=schemas_tickets.EventAdmin, + status_code=201, +) +async def create_event( + event_create: schemas_tickets.EventCreate, + user: CoreUser = Depends( + is_user(), + ), + db: AsyncSession = Depends(get_db), +): + """ + Create an event + + **The user should have the right to manage the event seller** + """ + # TODO: check if user has the right to manage the seller + event_id = uuid.uuid4() + + await cruds_tickets.create_event( + event_id=event_id, + event=event_create, + db=db, + ) + + return await cruds_tickets.get_event_by_id( + event_id=event_id, + db=db, + ) + + +# router.patch( +# "/tickets/admin/events/{event_id}", +# response_model=schemas_tickets.EventComplete, +# status_code=204, +# ) +# async def edit_event( +# event_id: UUID, +# event_edit: schemas_tickets.EventCreate, +# user: CoreUser = Depends( +# is_user(), +# ), +# db: AsyncSession = Depends(get_db), +# ): +# """ +# Edit one event for admin +# """ +# # TODO: an open event should not be editable +# pass + + +@router.get( + "/tickets/admin/events/{event_id}/tickets", + response_model=list[schemas_tickets.Ticket], + status_code=200, +) +async def get_event_tickets( + event_id: UUID, + user: CoreUser = Depends( + is_user(), + ), + db: AsyncSession = Depends(get_db), +): + """ + Get all tickets of an event + + **The user should have the right to manage the event seller** + """ + # TODO: check if user has the right to manage the seller + + return await cruds_tickets.get_tickets_by_event_id(event_id=event_id, db=db) + + +@router.get( + "/tickets/admin/events/{event_id}/tickets/csv", + response_model=list[schemas_tickets.Ticket], + status_code=200, +) +async def get_event_tickets_csv( + event_id: UUID, + user: CoreUser = Depends( + is_user(), + ), + db: AsyncSession = Depends(get_db), +): + """ + Get all tickets of an event as csv + + **The user should have the right to manage the event seller** + """ + # TODO: check if user has the right to manage the seller + event = await cruds_tickets.get_event_by_id( + event_id=event_id, + db=db, + ) + if event is None: + raise HTTPException(404, "Event not found") + + csv_io = StringIO() + + writer = csv.writer(csv_io, delimiter=";", quoting=csv.QUOTE_MINIMAL) + + # Write headers + writer.writerow( + [ + "Ticket ID", + "Session ID", + "Session Name", + "Category ID", + "Category Name", + "Price (€)", + "Scanned", + ], + ) + + tickets = await cruds_tickets.get_tickets_by_event_id(event_id=event_id, db=db) + for ticket in tickets: + writer.writerow( + [ + ticket.id, + ticket.session_id, + ticket.session.name, + ticket.category_id, + ticket.category.name, + f"{ticket.price / 100:.2f}€", + ticket.scanned, + ], + ) + + csv_content = csv_io.getvalue() + csv_io.close() + + filename = f"event_{event_id}_{datetime.now(UTC)}.csv" + + headers = { + "Content-Disposition": f'attachment; filename="{filename}"', + } + return Response( + csv_content, + headers=headers, + media_type="text/csv; charset=utf-8", + ) + + +@router.get( + "/tickets/admin/association/{association_id}/events", + response_model=list[schemas_tickets.EventSimple], + status_code=200, +) +async def get_events_by_association( + association_id: UUID, + user: CoreUser = Depends( + is_user(), + ), + db: AsyncSession = Depends(get_db), +): + """ + Get all events of an association + + **The user should have the right to manage the event seller** + """ + # TODO: check if user has the right to manage the association + store = await cruds_mypayment.get_store_by_association_id( + association_id=association_id, + db=db, + ) + # TODO: maybe return an empty list + if store is None: + raise HTTPException(400, "No seller associated with this association") + + return await cruds_tickets.get_events_by_store_id( + store_id=store.id, + db=db, + ) diff --git a/app/core/tickets/factory_tickets.py b/app/core/tickets/factory_tickets.py new file mode 100644 index 0000000000..cd1a5332a1 --- /dev/null +++ b/app/core/tickets/factory_tickets.py @@ -0,0 +1,14 @@ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.utils.config import Settings +from app.types.factory import Factory + + +class TicketsFactory(Factory): + @classmethod + async def run(cls, db: AsyncSession, settings: Settings) -> None: + pass + + @classmethod + async def should_run(cls, db: AsyncSession): + pass diff --git a/app/core/tickets/models_tickets.py b/app/core/tickets/models_tickets.py new file mode 100644 index 0000000000..2050c0b589 --- /dev/null +++ b/app/core/tickets/models_tickets.py @@ -0,0 +1,107 @@ +from datetime import datetime +from uuid import UUID + +from sqlalchemy import ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.mypayment import models_mypayment +from app.core.users import models_users +from app.types.sqlalchemy import Base, PrimaryKey + +# TODO: do we want to be able to disable sessions or prices? + + +class TicketEvent(Base): + __tablename__ = "tickets_event" + + id: Mapped[PrimaryKey] + store_id: Mapped[UUID] = mapped_column(ForeignKey("mypayment_store.id")) + + name: Mapped[str] + + open_datetime: Mapped[datetime] + close_datetime: Mapped[datetime | None] + + # Number of tickets a user can buy for this event + quota_per_user: Mapped[int | None] + # Number of tickets that can be bought in a single checkout for this event + quota_per_checkout: Mapped[int | None] + # Total number of tickets available + quota: Mapped[int | None] + # None means unlimited + + store: Mapped[models_mypayment.Store] = relationship(init=False) + + sessions: Mapped[list["EventSession"]] = relationship(back_populates="event") + categories: Mapped[list["Category"]] = relationship(back_populates="event") + + +class EventSession(Base): + __tablename__ = "tickets_session" + + id: Mapped[PrimaryKey] + event_id: Mapped[UUID] = mapped_column(ForeignKey("tickets_event.id")) + + name: Mapped[str] + + start_time: Mapped[datetime] + end_time: Mapped[datetime] + + quota: Mapped[int | None] = mapped_column(default=None) + + event: Mapped["TicketEvent"] = relationship(back_populates="sessions", init=False) + + +class Category(Base): + __tablename__ = "tickets_category" + + id: Mapped[PrimaryKey] + event_id: Mapped[UUID] = mapped_column(ForeignKey("tickets_event.id")) + + name: Mapped[str] + + quota: Mapped[int | None] + + price: Mapped[int] # in cents + required_membership: Mapped[UUID | None] = mapped_column( + ForeignKey("core_association_membership.id"), + default=None, + ) + + event: Mapped["TicketEvent"] = relationship(back_populates="categories", init=False) + + +class Ticket(Base): + __tablename__ = "tickets_ticket" + + id: Mapped[PrimaryKey] + + category_id: Mapped[UUID] = mapped_column(ForeignKey("tickets_category.id")) + session_id: Mapped[UUID] = mapped_column(ForeignKey("tickets_session.id")) + + user_id: Mapped[UUID] = mapped_column(ForeignKey("core_user.id")) + + price: Mapped[int] # in cents + + scanned: Mapped[bool] + + category: Mapped["Category"] = relationship() + session: Mapped["EventSession"] = relationship() + user: Mapped[models_users.CoreUser] = relationship() + + +class Checkout(Base): + __tablename__ = "tickets_checkout" + + id: Mapped[PrimaryKey] + + category_id: Mapped[UUID] = mapped_column(ForeignKey("tickets_category.id")) + session_id: Mapped[UUID] = mapped_column(ForeignKey("tickets_session.id")) + + price: Mapped[int] # in cents + expiration: Mapped[datetime] + + user_id: Mapped[str] = mapped_column(ForeignKey("core_user.id")) + + # Do we need this? + user: Mapped[models_users.CoreUser] = relationship(init=False) diff --git a/app/core/tickets/schemas_tickets.py b/app/core/tickets/schemas_tickets.py new file mode 100644 index 0000000000..e743203ee5 --- /dev/null +++ b/app/core/tickets/schemas_tickets.py @@ -0,0 +1,100 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import ( + BaseModel, +) + + +class Session(BaseModel): + name: str + start_time: datetime + end_time: datetime + event_id: UUID + + +class SessionCreate(BaseModel): + name: str + start_time: datetime + end_time: datetime + quota: int | None + + +class SessionComplete(Session): + id: UUID + + +class Category(BaseModel): + id: UUID + name: str + price: int + required_membership: UUID | None + event_id: UUID + + +class CategoryCreate(BaseModel): + name: str + price: int + quota: int | None + required_membership: UUID | None + + +class CategoryComplete(Category): + id: UUID + + +class EventSimple(BaseModel): + id: UUID + name: str + + store_id: UUID + + +class EventPublic(EventSimple): + sessions: list[Session] + categories: list[Category] + + open_datetime: datetime + close_datetime: datetime | None + + +class EventAdmin(EventPublic): + quota_per_user: int | None + quota_per_checkout: int | None + quota: int | None + + +class EventCreate(BaseModel): + store_id: UUID + name: str + quota_per_user: int | None + quota_per_checkout: int | None + quota: int | None + open_datetime: datetime + close_datetime: datetime | None + sessions: list[SessionCreate] + categories: list[CategoryCreate] + + +class Ticket(BaseModel): + id: UUID + price: int + user_id: UUID + + category_id: UUID + session_id: UUID + + scanned: bool + + category: Category + session: Session + + +class Checkout(BaseModel): + category_id: UUID + session_id: UUID + + +class CheckoutResponse(BaseModel): + price: int + expiration: datetime diff --git a/app/core/tickets/todo.md b/app/core/tickets/todo.md new file mode 100644 index 0000000000..fa71e16f01 --- /dev/null +++ b/app/core/tickets/todo.md @@ -0,0 +1,13 @@ +SG pour lesquel j'ai un ticket OK +Questions obligatoires ou non + +POST PATCH GET SG +GET participants +GET participants csv + +GET sg of association for feeds + +GET un sg ouvert en particulier +-> bien indiquer s'il en reste + +POST reserver \ No newline at end of file From 15db31de1a6bf835d910c29736663b4cd1532045 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Fri, 27 Mar 2026 23:28:51 +0100 Subject: [PATCH 28/72] Migration --- migrations/versions/67-tickets.py | 140 ++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 migrations/versions/67-tickets.py diff --git a/migrations/versions/67-tickets.py b/migrations/versions/67-tickets.py new file mode 100644 index 0000000000..0847b34be2 --- /dev/null +++ b/migrations/versions/67-tickets.py @@ -0,0 +1,140 @@ +"""empty message + +Create Date: 2026-03-27 23:23:07.797594 +""" + +from collections.abc import Sequence +from typing import TYPE_CHECKING, Union + +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 = "c052cfbe6d75" +down_revision: str | None = "146db8dcb23e" +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( + "tickets_event", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("store_id", sa.Uuid(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("open_datetime", TZDateTime(), nullable=False), + sa.Column("close_datetime", TZDateTime(), nullable=True), + sa.Column("quota_per_user", sa.Integer(), nullable=True), + sa.Column("quota_per_checkout", sa.Integer(), nullable=True), + sa.Column("quota", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ["store_id"], + ["mypayment_store.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "tickets_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("quota", sa.Integer(), nullable=True), + sa.Column("price", sa.Integer(), nullable=False), + sa.Column("required_membership", sa.Uuid(), nullable=True), + sa.ForeignKeyConstraint( + ["event_id"], + ["tickets_event.id"], + ), + sa.ForeignKeyConstraint( + ["required_membership"], + ["core_association_membership.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "tickets_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("start_time", TZDateTime(), nullable=False), + sa.Column("end_time", TZDateTime(), nullable=False), + sa.Column("quota", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ["event_id"], + ["tickets_event.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "tickets_checkout", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("category_id", sa.Uuid(), nullable=False), + sa.Column("session_id", sa.Uuid(), nullable=False), + sa.Column("price", sa.Integer(), nullable=False), + sa.Column("expiration", TZDateTime(), nullable=False), + sa.Column("user_id", sa.String(), nullable=False), + sa.ForeignKeyConstraint( + ["category_id"], + ["tickets_category.id"], + ), + sa.ForeignKeyConstraint( + ["session_id"], + ["tickets_session.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["core_user.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "tickets_ticket", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("category_id", sa.Uuid(), nullable=False), + sa.Column("session_id", sa.Uuid(), nullable=False), + sa.Column("user_id", sa.String(), nullable=False), + sa.Column("price", sa.Integer(), nullable=False), + sa.Column("scanned", sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint( + ["category_id"], + ["tickets_category.id"], + ), + sa.ForeignKeyConstraint( + ["session_id"], + ["tickets_session.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["core_user.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + op.drop_table("tickets_checkout") + op.drop_table("tickets_ticket") + op.drop_table("tickets_session") + op.drop_table("tickets_category") + op.drop_table("tickets_event") + + +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 433803194a7f07877f64295accf21422d780428f Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sat, 28 Mar 2026 00:01:25 +0100 Subject: [PATCH 29/72] Test tickets --- tests/core/test_tickets.py | 157 +++++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 tests/core/test_tickets.py diff --git a/tests/core/test_tickets.py b/tests/core/test_tickets.py new file mode 100644 index 0000000000..041aa77046 --- /dev/null +++ b/tests/core/test_tickets.py @@ -0,0 +1,157 @@ +import uuid +from datetime import UTC, datetime, timedelta + +import pytest_asyncio +from fastapi.testclient import TestClient + +from app.core.associations.models_associations import CoreAssociation +from app.core.groups.groups_type import GroupType +from app.core.memberships import models_memberships +from app.core.mypayment.models_mypayment import Store, Structure, Wallet +from app.core.mypayment.types_mypayment import WalletType +from app.core.tickets import models_tickets +from app.core.tickets.endpoints_tickets import TicketsPermissions +from app.core.users import models_users +from tests.commons import ( + add_object_to_db, + create_api_access_token, + create_groups_with_permissions, + create_user_with_groups, +) + +user: models_users.CoreUser +user_token: str + +membership: models_memberships.CoreAssociationMembership +structure_manager_user: models_users.CoreUser +structure: Structure +wallet: Wallet +core_association: CoreAssociation +store: Store + + +ticket_event: models_tickets.TicketEvent +ticket_session: models_tickets.EventSession +ticket_category: models_tickets.Category + +ticket: models_tickets.Ticket + + +@pytest_asyncio.fixture(scope="module", autouse=True) +async def init_objects() -> None: + global user, user_token + ticket_permission_group = await create_groups_with_permissions( + [TicketsPermissions.buy_tickets], + "ticket_permission_group", + ) + user = await create_user_with_groups(groups=[ticket_permission_group.id]) + user_token = create_api_access_token(user) + + global \ + membership, \ + structure_manager_user, \ + structure, \ + wallet, \ + core_association, \ + store + membership = models_memberships.CoreAssociationMembership( + id=uuid.uuid4(), + name="Test Membership", + manager_group_id=GroupType.admin, + ) + await add_object_to_db(membership) + structure_manager_user = await create_user_with_groups(groups=[]) + structure = Structure( + id=uuid.uuid4(), + short_id="test", + name="Test Structure", + siege_address_street="123 Test Street", + siege_address_city="Test City", + siege_address_zipcode="12345", + siege_address_country="Test Country", + siret=None, + iban="FR", + bic="", + manager_user_id=structure_manager_user.id, + creation=datetime.now(tz=UTC), + association_membership_id=membership.id, + ) + await add_object_to_db(structure) + wallet = Wallet( + id=uuid.uuid4(), + type=WalletType.STORE, + balance=0, + ) + await add_object_to_db(wallet) + core_association = CoreAssociation( + id=uuid.uuid4(), + name="Test Association", + group_id=GroupType.admin, + ) + await add_object_to_db(core_association) + store = Store( + id=uuid.uuid4(), + name="Test Store", + structure_id=structure.id, + wallet_id=wallet.id, + creation=datetime.now(tz=UTC), + association_id=core_association.id, + ) + await add_object_to_db(store) + + global ticket_event, ticket_session, ticket_category + ticket_event_id = uuid.uuid4() + ticket_session = models_tickets.EventSession( + id=uuid.uuid4(), + event_id=ticket_event_id, + name="Test Session", + start_time=datetime.now(tz=UTC) - timedelta(days=1), + end_time=datetime.now(tz=UTC) + timedelta(days=1), + quota=None, + ) + ticket_category = models_tickets.Category( + id=uuid.uuid4(), + event_id=ticket_event_id, + name="Test Category", + quota=None, + price=1000, + required_membership=None, + ) + ticket_event = models_tickets.TicketEvent( + id=uuid.uuid4(), + store_id=store.id, + name="Test Event", + open_datetime=datetime.now(tz=UTC) - timedelta(days=1), + close_datetime=datetime.now(tz=UTC) + timedelta(days=1), + quota_per_user=2, + quota_per_checkout=2, + quota=10, + sessions=[ticket_session], + categories=[ticket_category], + ) + await add_object_to_db(ticket_event) + + +def test_get_open_events(client: TestClient): + response = client.get( + "/tickets/events", + headers={"Authorization": f"Bearer {user_token}"}, + ) + assert response.status_code == 200 + assert len(response.json()) == 1 + + +def test_get_event_with_non_existing_id(client: TestClient): + response = client.get( + f"/tickets/events/{uuid.uuid4()}", + headers={"Authorization": f"Bearer {user_token}"}, + ) + assert response.status_code == 404 + + +def test_get_event(client: TestClient): + response = client.get( + f"/tickets/events/{ticket_event.id}", + headers={"Authorization": f"Bearer {user_token}"}, + ) + assert response.status_code == 200 From 979b2862b39840c8872e72303ac6410738ca241e Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sun, 29 Mar 2026 11:52:04 +0200 Subject: [PATCH 30/72] Schema cleaning --- app/core/tickets/schemas_tickets.py | 74 +++++++++++++++++++++++------ 1 file changed, 59 insertions(+), 15 deletions(-) diff --git a/app/core/tickets/schemas_tickets.py b/app/core/tickets/schemas_tickets.py index e743203ee5..508897a9e8 100644 --- a/app/core/tickets/schemas_tickets.py +++ b/app/core/tickets/schemas_tickets.py @@ -5,12 +5,32 @@ BaseModel, ) +from app.core.users import schemas_users + class Session(BaseModel): + id: UUID + event_id: UUID name: str start_time: datetime end_time: datetime - event_id: UUID + + +class SessionComplete(Session): + """ + Correspond to a Session in the database + """ + + quota: int | None + + +class SessionPublic(Session): + sold_out: bool + + +class SessionAdmin(SessionComplete): + sold: int + waiting: int class SessionCreate(BaseModel): @@ -20,16 +40,29 @@ class SessionCreate(BaseModel): quota: int | None -class SessionComplete(Session): - id: UUID - - class Category(BaseModel): id: UUID + event_id: UUID name: str price: int required_membership: UUID | None - event_id: UUID + + +class CategoryComplete(Category): + """ + Correspond to a Category in the database + """ + + quota: int | None + + +class CategoryPublic(Category): + sold_out: bool + + +class CategoryAdmin(CategoryComplete): + sold: int + waiting: int class CategoryCreate(BaseModel): @@ -39,26 +72,36 @@ class CategoryCreate(BaseModel): required_membership: UUID | None -class CategoryComplete(Category): - id: UUID - - class EventSimple(BaseModel): id: UUID name: str store_id: UUID + open_datetime: datetime + close_datetime: datetime | None + + +class EventComplete(EventSimple): + sessions: list[SessionComplete] + categories: list[CategoryComplete] + + quota_per_user: int | None + quota_per_checkout: int | None + quota: int | None + class EventPublic(EventSimple): - sessions: list[Session] - categories: list[Category] + sessions: list[SessionPublic] + categories: list[CategoryPublic] + + sold_out: bool - open_datetime: datetime - close_datetime: datetime | None +class EventAdmin(EventComplete): + sessions: list[SessionAdmin] + categories: list[CategoryAdmin] -class EventAdmin(EventPublic): quota_per_user: int | None quota_per_checkout: int | None quota: int | None @@ -88,6 +131,7 @@ class Ticket(BaseModel): category: Category session: Session + user: schemas_users.CoreUserSimple class Checkout(BaseModel): From fce4d1e23aa5ac5be3d4f4ad060c16f081627a53 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sun, 29 Mar 2026 11:52:11 +0200 Subject: [PATCH 31/72] WIP --- app/core/tickets/cruds_tickets.py | 100 +++++++++++++++++++++++-- app/core/tickets/endpoints_tickets.py | 104 +++++++++++++++++++++++++- app/core/tickets/models_tickets.py | 11 +-- 3 files changed, 200 insertions(+), 15 deletions(-) diff --git a/app/core/tickets/cruds_tickets.py b/app/core/tickets/cruds_tickets.py index b93a34c717..1aca16d206 100644 --- a/app/core/tickets/cruds_tickets.py +++ b/app/core/tickets/cruds_tickets.py @@ -3,12 +3,13 @@ from datetime import UTC, datetime from uuid import UUID -from sqlalchemy import or_ +from sqlalchemy import or_, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from sqlalchemy.sql import select from app.core.tickets import models_tickets, schemas_tickets +from app.core.users import schemas_users async def get_tickets_by_user_id( @@ -37,12 +38,20 @@ async def get_tickets_by_user_id( event_id=ticket.category.event_id, ), session=schemas_tickets.Session( + id=ticket.session.id, name=ticket.session.name, start_time=ticket.session.start_time, end_time=ticket.session.end_time, event_id=ticket.session.event_id, ), user_id=ticket.user_id, + user=schemas_users.CoreUserSimple( + id=ticket.user.id, + name=ticket.user.name, + firstname=ticket.user.firstname, + account_type=ticket.user.account_type, + school_id=ticket.user.school_id, + ), price=ticket.price, ) for ticket in result.scalars().all() @@ -59,6 +68,7 @@ async def get_tickets_by_event_id( .options( selectinload(models_tickets.Ticket.category), selectinload(models_tickets.Ticket.session), + selectinload(models_tickets.Ticket.user), ), ) return [ @@ -75,18 +85,84 @@ async def get_tickets_by_event_id( event_id=ticket.category.event_id, ), session=schemas_tickets.Session( + id=ticket.session.id, name=ticket.session.name, start_time=ticket.session.start_time, end_time=ticket.session.end_time, event_id=ticket.session.event_id, ), user_id=ticket.user_id, + user=schemas_users.CoreUserSimple( + id=ticket.user.id, + name=ticket.user.name, + firstname=ticket.user.firstname, + account_type=ticket.user.account_type, + school_id=ticket.user.school_id, + ), price=ticket.price, ) for ticket in result.scalars().all() ] +async def get_ticket_by_id( + ticket_id: UUID, + db: AsyncSession, +) -> schemas_tickets.Ticket | None: + result = await db.execute( + select(models_tickets.Ticket) + .where(models_tickets.Ticket.id == ticket_id) + .options( + selectinload(models_tickets.Ticket.category), + selectinload(models_tickets.Ticket.session), + ), + ) + ticket = result.scalars().first() + if ticket is None: + return None + + return schemas_tickets.Ticket( + id=ticket.id, + category_id=ticket.category_id, + session_id=ticket.session_id, + scanned=ticket.scanned, + category=schemas_tickets.Category( + id=ticket.category.id, + name=ticket.category.name, + price=ticket.category.price, + required_membership=ticket.category.required_membership, + event_id=ticket.category.event_id, + ), + session=schemas_tickets.Session( + id=ticket.session.id, + name=ticket.session.name, + start_time=ticket.session.start_time, + end_time=ticket.session.end_time, + event_id=ticket.session.event_id, + ), + user_id=ticket.user_id, + user=schemas_users.CoreUserSimple( + id=ticket.user.id, + name=ticket.user.name, + firstname=ticket.user.firstname, + account_type=ticket.user.account_type, + school_id=ticket.user.school_id, + ), + price=ticket.price, + ) + + +async def mark_ticket_as_scanned( + ticket_id: UUID, + db: AsyncSession, +): + await db.execute( + update(models_tickets.Ticket) + .where(models_tickets.Ticket.id == ticket_id) + .values(scanned=True), + ) + + async def get_open_events( db: AsyncSession, ) -> Sequence[schemas_tickets.EventSimple]: @@ -108,6 +184,8 @@ async def get_open_events( id=association.id, name=association.name, store_id=association.store_id, + open_datetime=association.open_datetime, + close_datetime=association.close_datetime, ) for association in result.scalars().all() ] @@ -129,6 +207,8 @@ async def get_events_by_store_id( id=association.id, name=association.name, store_id=association.store_id, + open_datetime=association.open_datetime, + close_datetime=association.close_datetime, ) for association in result.scalars().all() ] @@ -137,9 +217,7 @@ async def get_events_by_store_id( async def get_event_by_id( event_id: UUID, db: AsyncSession, -) -> schemas_tickets.EventAdmin | None: - """Return one open event with public details from database""" - +) -> schemas_tickets.EventComplete | None: result = await db.execute( select(models_tickets.TicketEvent) .where( @@ -155,7 +233,7 @@ async def get_event_by_id( if event is None: return None - return schemas_tickets.EventAdmin( + return schemas_tickets.EventComplete( id=event.id, name=event.name, open_datetime=event.open_datetime, @@ -171,6 +249,7 @@ async def get_event_by_id( start_time=session.start_time, end_time=session.end_time, event_id=session.event_id, + quota=session.quota, ) for session in sorted(event.sessions, key=lambda item: item.start_time) ], @@ -181,6 +260,7 @@ async def get_event_by_id( price=category.price, required_membership=category.required_membership, event_id=category.event_id, + quota=category.quota, ) for category in sorted(event.categories, key=lambda item: item.name) ], @@ -202,7 +282,7 @@ async def create_event( open_datetime=event.open_datetime, close_datetime=event.close_datetime, sessions=[ - models_tickets.Session( + models_tickets.EventSession( id=uuid.uuid4(), event_id=event_id, name=session.name, @@ -249,6 +329,7 @@ async def get_category_by_id( price=category.price, required_membership=category.required_membership, event_id=category.event_id, + quota=category.quota, ) @@ -259,8 +340,8 @@ async def get_session_by_id( """Return one session from database""" result = await db.execute( - select(models_tickets.Session).where( - models_tickets.Session.id == session_id, + select(models_tickets.EventSession).where( + models_tickets.EventSession.id == session_id, ), ) @@ -274,12 +355,14 @@ async def get_session_by_id( start_time=session.start_time, end_time=session.end_time, event_id=session.event_id, + quota=session.quota, ) async def create_checkout( checkout_id: UUID, user_id: str, + event_id: UUID, category_id: UUID, session_id: UUID, price: int, @@ -288,6 +371,7 @@ async def create_checkout( ): db_checkout = models_tickets.Checkout( id=checkout_id, + event_id=event_id, user_id=user_id, category_id=category_id, session_id=session_id, diff --git a/app/core/tickets/endpoints_tickets.py b/app/core/tickets/endpoints_tickets.py index a9b9be3ccc..b5ae30315b 100644 --- a/app/core/tickets/endpoints_tickets.py +++ b/app/core/tickets/endpoints_tickets.py @@ -11,6 +11,7 @@ HTTPException, Response, ) +from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession from app.core.mypayment import cruds_mypayment @@ -82,10 +83,41 @@ async def get_event( Get an event public details """ event = await cruds_tickets.get_event_by_id(event_id=event_id, db=db) + # TODO: indicate if the event is sold out if event is None: raise HTTPException(404, "Event not found") - return event + + return schemas_tickets.EventPublic( + id=event.id, + name=event.name, + store_id=event.store_id, + sessions=[ + schemas_tickets.SessionPublic( + event_id=session.event_id, + id=session.id, + name=session.name, + start_time=session.start_time, + end_time=session.end_time, + sold_out=await cruds_tickets.is_session_sold_out(session.id, db), + ) + for session in event.sessions + ], + categories=[ + schemas_tickets.CategoryPublic( + event_id=category.event_id, + id=category.id, + name=category.name, + price=category.price, + required_membership=category.required_membership, + sold_out=await cruds_tickets.is_category_sold_out(category.id, db), + ) + for category in event.categories + ], + sold_out=await cruds_tickets.is_event_sold_out(event.id, db), + open_datetime=event.open_datetime, + close_datetime=event.close_datetime, + ) @router.post( @@ -106,6 +138,7 @@ async def create_checkout( """ Create a checkout for an open event """ + # TODO: can we ask for multiple tickets? category = await cruds_tickets.get_category_by_id( category_id=checkout.category_id, db=db, @@ -128,9 +161,11 @@ async def create_checkout( expiration = datetime.now(UTC) + timedelta(minutes=CHECKOUT_EXPIRATION_MINUTES) # TODO: indicate if the event is sold out + # TODO: check required membership await cruds_tickets.create_checkout( checkout_id=uuid.uuid4(), + event_id=event_id, user_id=user.id, category_id=checkout.category_id, session_id=checkout.session_id, @@ -270,7 +305,7 @@ async def get_event_tickets( @router.get( "/tickets/admin/events/{event_id}/tickets/csv", - response_model=list[schemas_tickets.Ticket], + response_class=FileResponse, status_code=200, ) async def get_event_tickets_csv( @@ -307,6 +342,11 @@ async def get_event_tickets_csv( "Category Name", "Price (€)", "Scanned", + "User ID", + "User Name", + "User Firstname", + "User Account Type", + "User School ID", ], ) @@ -321,6 +361,11 @@ async def get_event_tickets_csv( ticket.category.name, f"{ticket.price / 100:.2f}€", ticket.scanned, + ticket.user_id, + ticket.user.name, + ticket.user.firstname, + ticket.user.account_type, + ticket.user.school_id, ], ) @@ -339,6 +384,61 @@ async def get_event_tickets_csv( ) +@router.post( + "/tickets/admin/tickets/{ticket_id}/check", + response_model=schemas_tickets.Ticket, + status_code=200, +) +async def check_ticket( + ticket_id: UUID, + user: CoreUser = Depends( + is_user(), + ), + db: AsyncSession = Depends(get_db), +): + """ + Check a ticket + + **The user should have the right to manage the event seller** + """ + + ticket = await cruds_tickets.get_ticket_by_id(ticket_id=ticket_id, db=db) + if ticket is None: + raise HTTPException(404, "Ticket not found") + + # TODO: check if user has the right to manage the seller + + return ticket + + +@router.post( + "/tickets/admin/tickets/{ticket_id}/scan", + status_code=204, +) +async def scan_ticket( + ticket_id: UUID, + user: CoreUser = Depends( + is_user(), + ), + db: AsyncSession = Depends(get_db), +): + """ + Mark a ticket as scanned + + **The user should have the right to manage the event seller** + """ + + ticket = await cruds_tickets.get_ticket_by_id(ticket_id=ticket_id, db=db) + if ticket is None: + raise HTTPException(404, "Ticket not found") + + # TODO: check if user has the right to manage the seller + + await cruds_tickets.mark_ticket_as_scanned(ticket_id=ticket_id, db=db) + + return ticket + + @router.get( "/tickets/admin/association/{association_id}/events", response_model=list[schemas_tickets.EventSimple], diff --git a/app/core/tickets/models_tickets.py b/app/core/tickets/models_tickets.py index 2050c0b589..bc7641a270 100644 --- a/app/core/tickets/models_tickets.py +++ b/app/core/tickets/models_tickets.py @@ -15,10 +15,10 @@ class TicketEvent(Base): __tablename__ = "tickets_event" id: Mapped[PrimaryKey] - store_id: Mapped[UUID] = mapped_column(ForeignKey("mypayment_store.id")) - name: Mapped[str] + store_id: Mapped[UUID] = mapped_column(ForeignKey("mypayment_store.id")) + open_datetime: Mapped[datetime] close_datetime: Mapped[datetime | None] @@ -49,7 +49,7 @@ class EventSession(Base): quota: Mapped[int | None] = mapped_column(default=None) - event: Mapped["TicketEvent"] = relationship(back_populates="sessions", init=False) + # event: Mapped["TicketEvent"] = relationship(back_populates="sessions", init=False) class Category(Base): @@ -65,10 +65,9 @@ class Category(Base): price: Mapped[int] # in cents required_membership: Mapped[UUID | None] = mapped_column( ForeignKey("core_association_membership.id"), - default=None, ) - event: Mapped["TicketEvent"] = relationship(back_populates="categories", init=False) + # event: Mapped["TicketEvent"] = relationship(back_populates="categories", init=False) class Ticket(Base): @@ -98,6 +97,8 @@ class Checkout(Base): category_id: Mapped[UUID] = mapped_column(ForeignKey("tickets_category.id")) session_id: Mapped[UUID] = mapped_column(ForeignKey("tickets_session.id")) + event_id: Mapped[UUID] = mapped_column(ForeignKey("tickets_event.id")) + price: Mapped[int] # in cents expiration: Mapped[datetime] From 5c36b716501bb685852b1c1a36587128d237ec1e Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sun, 29 Mar 2026 14:25:55 +0200 Subject: [PATCH 32/72] Quota --- app/core/tickets/cruds_tickets.py | 385 ++++++++++++++++---------- app/core/tickets/endpoints_tickets.py | 20 +- app/core/tickets/models_tickets.py | 1 + app/core/tickets/utils_tickets.py | 72 +++++ 4 files changed, 322 insertions(+), 156 deletions(-) create mode 100644 app/core/tickets/utils_tickets.py diff --git a/app/core/tickets/cruds_tickets.py b/app/core/tickets/cruds_tickets.py index 1aca16d206..2e71d945a2 100644 --- a/app/core/tickets/cruds_tickets.py +++ b/app/core/tickets/cruds_tickets.py @@ -3,7 +3,7 @@ from datetime import UTC, datetime from uuid import UUID -from sqlalchemy import or_, update +from sqlalchemy import func, or_, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from sqlalchemy.sql import select @@ -12,157 +12,6 @@ from app.core.users import schemas_users -async def get_tickets_by_user_id( - user_id: str, - db: AsyncSession, -) -> Sequence[schemas_tickets.Ticket]: - result = await db.execute( - select(models_tickets.Ticket) - .where(models_tickets.Ticket.user_id == user_id) - .options( - selectinload(models_tickets.Ticket.category), - selectinload(models_tickets.Ticket.session), - ), - ) - return [ - schemas_tickets.Ticket( - id=ticket.id, - category_id=ticket.category_id, - session_id=ticket.session_id, - scanned=ticket.scanned, - category=schemas_tickets.Category( - id=ticket.category.id, - name=ticket.category.name, - price=ticket.category.price, - required_membership=ticket.category.required_membership, - event_id=ticket.category.event_id, - ), - session=schemas_tickets.Session( - id=ticket.session.id, - name=ticket.session.name, - start_time=ticket.session.start_time, - end_time=ticket.session.end_time, - event_id=ticket.session.event_id, - ), - user_id=ticket.user_id, - user=schemas_users.CoreUserSimple( - id=ticket.user.id, - name=ticket.user.name, - firstname=ticket.user.firstname, - account_type=ticket.user.account_type, - school_id=ticket.user.school_id, - ), - price=ticket.price, - ) - for ticket in result.scalars().all() - ] - - -async def get_tickets_by_event_id( - event_id: UUID, - db: AsyncSession, -) -> Sequence[schemas_tickets.Ticket]: - result = await db.execute( - select(models_tickets.Ticket) - .where(models_tickets.Ticket.category.event_id == event_id) - .options( - selectinload(models_tickets.Ticket.category), - selectinload(models_tickets.Ticket.session), - selectinload(models_tickets.Ticket.user), - ), - ) - return [ - schemas_tickets.Ticket( - id=ticket.id, - category_id=ticket.category_id, - session_id=ticket.session_id, - scanned=ticket.scanned, - category=schemas_tickets.Category( - id=ticket.category.id, - name=ticket.category.name, - price=ticket.category.price, - required_membership=ticket.category.required_membership, - event_id=ticket.category.event_id, - ), - session=schemas_tickets.Session( - id=ticket.session.id, - name=ticket.session.name, - start_time=ticket.session.start_time, - end_time=ticket.session.end_time, - event_id=ticket.session.event_id, - ), - user_id=ticket.user_id, - user=schemas_users.CoreUserSimple( - id=ticket.user.id, - name=ticket.user.name, - firstname=ticket.user.firstname, - account_type=ticket.user.account_type, - school_id=ticket.user.school_id, - ), - price=ticket.price, - ) - for ticket in result.scalars().all() - ] - - -async def get_ticket_by_id( - ticket_id: UUID, - db: AsyncSession, -) -> schemas_tickets.Ticket | None: - result = await db.execute( - select(models_tickets.Ticket) - .where(models_tickets.Ticket.id == ticket_id) - .options( - selectinload(models_tickets.Ticket.category), - selectinload(models_tickets.Ticket.session), - ), - ) - ticket = result.scalars().first() - if ticket is None: - return None - - return schemas_tickets.Ticket( - id=ticket.id, - category_id=ticket.category_id, - session_id=ticket.session_id, - scanned=ticket.scanned, - category=schemas_tickets.Category( - id=ticket.category.id, - name=ticket.category.name, - price=ticket.category.price, - required_membership=ticket.category.required_membership, - event_id=ticket.category.event_id, - ), - session=schemas_tickets.Session( - id=ticket.session.id, - name=ticket.session.name, - start_time=ticket.session.start_time, - end_time=ticket.session.end_time, - event_id=ticket.session.event_id, - ), - user_id=ticket.user_id, - user=schemas_users.CoreUserSimple( - id=ticket.user.id, - name=ticket.user.name, - firstname=ticket.user.firstname, - account_type=ticket.user.account_type, - school_id=ticket.user.school_id, - ), - price=ticket.price, - ) - - -async def mark_ticket_as_scanned( - ticket_id: UUID, - db: AsyncSession, -): - await db.execute( - update(models_tickets.Ticket) - .where(models_tickets.Ticket.id == ticket_id) - .values(scanned=True), - ) - - async def get_open_events( db: AsyncSession, ) -> Sequence[schemas_tickets.EventSimple]: @@ -379,3 +228,235 @@ async def create_checkout( expiration=expiration, ) db.add(db_checkout) + + +async def get_tickets_by_user_id( + user_id: str, + db: AsyncSession, +) -> Sequence[schemas_tickets.Ticket]: + result = await db.execute( + select(models_tickets.Ticket) + .where(models_tickets.Ticket.user_id == user_id) + .options( + selectinload(models_tickets.Ticket.category), + selectinload(models_tickets.Ticket.session), + ), + ) + return [ + schemas_tickets.Ticket( + id=ticket.id, + category_id=ticket.category_id, + session_id=ticket.session_id, + scanned=ticket.scanned, + category=schemas_tickets.Category( + id=ticket.category.id, + name=ticket.category.name, + price=ticket.category.price, + required_membership=ticket.category.required_membership, + event_id=ticket.category.event_id, + ), + session=schemas_tickets.Session( + id=ticket.session.id, + name=ticket.session.name, + start_time=ticket.session.start_time, + end_time=ticket.session.end_time, + event_id=ticket.session.event_id, + ), + user_id=ticket.user_id, + user=schemas_users.CoreUserSimple( + id=ticket.user.id, + name=ticket.user.name, + firstname=ticket.user.firstname, + account_type=ticket.user.account_type, + school_id=ticket.user.school_id, + ), + price=ticket.price, + ) + for ticket in result.scalars().all() + ] + + +async def get_tickets_by_event_id( + event_id: UUID, + db: AsyncSession, +) -> Sequence[schemas_tickets.Ticket]: + result = await db.execute( + select(models_tickets.Ticket) + .where(models_tickets.Ticket.category.event_id == event_id) + .options( + selectinload(models_tickets.Ticket.category), + selectinload(models_tickets.Ticket.session), + selectinload(models_tickets.Ticket.user), + ), + ) + return [ + schemas_tickets.Ticket( + id=ticket.id, + category_id=ticket.category_id, + session_id=ticket.session_id, + scanned=ticket.scanned, + category=schemas_tickets.Category( + id=ticket.category.id, + name=ticket.category.name, + price=ticket.category.price, + required_membership=ticket.category.required_membership, + event_id=ticket.category.event_id, + ), + session=schemas_tickets.Session( + id=ticket.session.id, + name=ticket.session.name, + start_time=ticket.session.start_time, + end_time=ticket.session.end_time, + event_id=ticket.session.event_id, + ), + user_id=ticket.user_id, + user=schemas_users.CoreUserSimple( + id=ticket.user.id, + name=ticket.user.name, + firstname=ticket.user.firstname, + account_type=ticket.user.account_type, + school_id=ticket.user.school_id, + ), + price=ticket.price, + ) + for ticket in result.scalars().all() + ] + + +async def get_ticket_by_id( + ticket_id: UUID, + db: AsyncSession, +) -> schemas_tickets.Ticket | None: + result = await db.execute( + select(models_tickets.Ticket) + .where(models_tickets.Ticket.id == ticket_id) + .options( + selectinload(models_tickets.Ticket.category), + selectinload(models_tickets.Ticket.session), + ), + ) + ticket = result.scalars().first() + if ticket is None: + return None + + return schemas_tickets.Ticket( + id=ticket.id, + category_id=ticket.category_id, + session_id=ticket.session_id, + scanned=ticket.scanned, + category=schemas_tickets.Category( + id=ticket.category.id, + name=ticket.category.name, + price=ticket.category.price, + required_membership=ticket.category.required_membership, + event_id=ticket.category.event_id, + ), + session=schemas_tickets.Session( + id=ticket.session.id, + name=ticket.session.name, + start_time=ticket.session.start_time, + end_time=ticket.session.end_time, + event_id=ticket.session.event_id, + ), + user_id=ticket.user_id, + user=schemas_users.CoreUserSimple( + id=ticket.user.id, + name=ticket.user.name, + firstname=ticket.user.firstname, + account_type=ticket.user.account_type, + school_id=ticket.user.school_id, + ), + price=ticket.price, + ) + + +async def mark_ticket_as_scanned( + ticket_id: UUID, + db: AsyncSession, +): + await db.execute( + update(models_tickets.Ticket) + .where(models_tickets.Ticket.id == ticket_id) + .values(scanned=True), + ) + + +async def count_tickets_by_event_id( + event_id: UUID, + db: AsyncSession, +) -> int: + result = await db.execute( + select(func.count()).where( + models_tickets.Ticket.event_id == event_id, + ), + ) + + return result.scalar() or 0 + + +async def count_tickets_by_category_id( + category_id: UUID, + db: AsyncSession, +) -> int: + result = await db.execute( + select(func.count()).where( + models_tickets.Ticket.category_id == category_id, + ), + ) + + return result.scalar() or 0 + + +async def count_tickets_by_session_id( + session_id: UUID, + db: AsyncSession, +) -> int: + result = await db.execute( + select(func.count()).where( + models_tickets.Ticket.session_id == session_id, + ), + ) + + return result.scalar() or 0 + + +async def count_valid_checkouts_by_event_id( + event_id: UUID, + db: AsyncSession, +) -> int: + result = await db.execute( + select(func.count()).where( + models_tickets.Checkout.event_id == event_id, + models_tickets.Checkout.expiration >= datetime.now(UTC), + ), + ) + + return result.scalar() or 0 + + +async def count_valid_checkouts_by_category_id( + category_id: UUID, + db: AsyncSession, +) -> int: + result = await db.execute( + select(func.count()).where( + models_tickets.Checkout.category_id == category_id, + models_tickets.Checkout.expiration >= datetime.now(UTC), + ), + ) + + return result.scalar() or 0 + + +async def count_valid_checkouts_by_session_id( + session_id: UUID, + db: AsyncSession, +) -> int: + result = await db.execute( + select(func.count()).where( + models_tickets.Checkout.session_id == session_id, + models_tickets.Checkout.expiration >= datetime.now(UTC), + ), + ) + + return result.scalar() or 0 diff --git a/app/core/tickets/endpoints_tickets.py b/app/core/tickets/endpoints_tickets.py index b5ae30315b..b951c0f6f8 100644 --- a/app/core/tickets/endpoints_tickets.py +++ b/app/core/tickets/endpoints_tickets.py @@ -16,7 +16,7 @@ from app.core.mypayment import cruds_mypayment from app.core.permissions.type_permissions import ModulePermissions -from app.core.tickets import cruds_tickets, schemas_tickets +from app.core.tickets import cruds_tickets, schemas_tickets, utils_tickets from app.core.tickets.factory_tickets import TicketsFactory from app.core.users.models_users import CoreUser from app.dependencies import ( @@ -99,7 +99,11 @@ async def get_event( name=session.name, start_time=session.start_time, end_time=session.end_time, - sold_out=await cruds_tickets.is_session_sold_out(session.id, db), + sold_out=await utils_tickets.is_session_sold_out( + session_id=session.id, + quota=session.quota, + db=db, + ), ) for session in event.sessions ], @@ -110,11 +114,19 @@ async def get_event( name=category.name, price=category.price, required_membership=category.required_membership, - sold_out=await cruds_tickets.is_category_sold_out(category.id, db), + sold_out=await utils_tickets.is_category_sold_out( + category_id=category.id, + quota=category.quota, + db=db, + ), ) for category in event.categories ], - sold_out=await cruds_tickets.is_event_sold_out(event.id, db), + sold_out=await utils_tickets.is_event_sold_out( + event_id=event.id, + quota=event.quota, + db=db, + ), open_datetime=event.open_datetime, close_datetime=event.close_datetime, ) diff --git a/app/core/tickets/models_tickets.py b/app/core/tickets/models_tickets.py index bc7641a270..e4b45d7fd7 100644 --- a/app/core/tickets/models_tickets.py +++ b/app/core/tickets/models_tickets.py @@ -77,6 +77,7 @@ class Ticket(Base): category_id: Mapped[UUID] = mapped_column(ForeignKey("tickets_category.id")) session_id: Mapped[UUID] = mapped_column(ForeignKey("tickets_session.id")) + event_id: Mapped[UUID] = mapped_column(ForeignKey("tickets_event.id")) user_id: Mapped[UUID] = mapped_column(ForeignKey("core_user.id")) diff --git a/app/core/tickets/utils_tickets.py b/app/core/tickets/utils_tickets.py new file mode 100644 index 0000000000..976b599e38 --- /dev/null +++ b/app/core/tickets/utils_tickets.py @@ -0,0 +1,72 @@ +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.tickets import cruds_tickets + + +async def is_event_sold_out( + event_id: UUID, + quota: int | None, + db: AsyncSession, +) -> bool: + if quota is None: + return False + + nb_valid_checkout_for_event = await cruds_tickets.count_valid_checkouts_by_event_id( + event_id=event_id, + db=db, + ) + + nb_tickets_sold_for_event = await cruds_tickets.count_tickets_by_event_id( + event_id=event_id, + db=db, + ) + + return (nb_valid_checkout_for_event + nb_tickets_sold_for_event) >= quota + + +async def is_category_sold_out( + category_id: UUID, + quota: int | None, + db: AsyncSession, +) -> bool: + if quota is None: + return False + + nb_valid_checkout_for_category = ( + await cruds_tickets.count_valid_checkouts_by_category_id( + category_id=category_id, + db=db, + ) + ) + + nb_tickets_sold_for_category = await cruds_tickets.count_tickets_by_category_id( + category_id=category_id, + db=db, + ) + + return (nb_valid_checkout_for_category + nb_tickets_sold_for_category) >= quota + + +async def is_session_sold_out( + session_id: UUID, + quota: int | None, + db: AsyncSession, +) -> bool: + if quota is None: + return False + + nb_valid_checkout_for_session = ( + await cruds_tickets.count_valid_checkouts_by_session_id( + session_id=session_id, + db=db, + ) + ) + + nb_tickets_sold_for_session = await cruds_tickets.count_tickets_by_session_id( + session_id=session_id, + db=db, + ) + + return (nb_valid_checkout_for_session + nb_tickets_sold_for_session) >= quota From d356260986d8e36eddcd293e6836b9a85737823b Mon Sep 17 00:00:00 2001 From: Foucauld Bellanger <63885990+Foukki@users.noreply.github.com> Date: Sat, 28 Mar 2026 14:35:27 +0100 Subject: [PATCH 33/72] syntax fix --- app/core/checkout/payment_tool.py | 2 +- app/core/tickets/factory_tickets.py | 3 +++ app/utils/initialization.py | 2 +- app/utils/loggers_tools/matrix_handler.py | 2 +- app/utils/loggers_tools/s3_handler.py | 2 +- 5 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/core/checkout/payment_tool.py b/app/core/checkout/payment_tool.py index 3b3bae0439..f11a01c60d 100644 --- a/app/core/checkout/payment_tool.py +++ b/app/core/checkout/payment_tool.py @@ -201,7 +201,7 @@ async def init_checkout( self._helloasso_slug, init_checkout_body, ) - except UnauthorizedException, BadRequestException: + except (UnauthorizedException, BadRequestException): # We know that HelloAsso may refuse some payer infos, like using the firstname "test" # Even when prefilling the payer infos,the user will be able to edit them on the payment page, # so we can safely retry without the payer infos diff --git a/app/core/tickets/factory_tickets.py b/app/core/tickets/factory_tickets.py index cd1a5332a1..75746fe8a0 100644 --- a/app/core/tickets/factory_tickets.py +++ b/app/core/tickets/factory_tickets.py @@ -5,6 +5,9 @@ class TicketsFactory(Factory): + + depends_on = [] + @classmethod async def run(cls, db: AsyncSession, settings: Settings) -> None: pass diff --git a/app/utils/initialization.py b/app/utils/initialization.py index 92455abd62..74da6ba112 100644 --- a/app/utils/initialization.py +++ b/app/utils/initialization.py @@ -205,7 +205,7 @@ def delete_core_data_crud_sync(schema: str, db: Session) -> None: CoreDataClass = TypeVar("CoreDataClass", bound=core_data.BaseCoreData) -def get_core_data_sync[CoreDataClass: core_data.BaseCoreData]( +def get_core_data_sync( core_data_class: type[CoreDataClass], db: Session, ) -> CoreDataClass: diff --git a/app/utils/loggers_tools/matrix_handler.py b/app/utils/loggers_tools/matrix_handler.py index d95a2b6ae1..a452848bee 100644 --- a/app/utils/loggers_tools/matrix_handler.py +++ b/app/utils/loggers_tools/matrix_handler.py @@ -1,6 +1,6 @@ import logging from logging import StreamHandler -from typing import override +from typing_extensions import override from app.utils.communication.matrix import Matrix diff --git a/app/utils/loggers_tools/s3_handler.py b/app/utils/loggers_tools/s3_handler.py index a64b0f0825..f731ece516 100644 --- a/app/utils/loggers_tools/s3_handler.py +++ b/app/utils/loggers_tools/s3_handler.py @@ -1,7 +1,7 @@ import string from datetime import UTC, datetime from logging import StreamHandler -from typing import override +from typing_extensions import override from app.types.s3_access import S3Access from app.utils.tools import get_random_string From 527a034abe29e4b1db20f07e979d7a53dae796e7 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sun, 29 Mar 2026 14:28:30 +0200 Subject: [PATCH 34/72] Fix --- app/core/tickets/models_tickets.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/core/tickets/models_tickets.py b/app/core/tickets/models_tickets.py index e4b45d7fd7..b26bf7eed3 100644 --- a/app/core/tickets/models_tickets.py +++ b/app/core/tickets/models_tickets.py @@ -49,7 +49,7 @@ class EventSession(Base): quota: Mapped[int | None] = mapped_column(default=None) - # event: Mapped["TicketEvent"] = relationship(back_populates="sessions", init=False) + event: Mapped["TicketEvent"] = relationship(back_populates="sessions", init=False) class Category(Base): @@ -67,7 +67,7 @@ class Category(Base): ForeignKey("core_association_membership.id"), ) - # event: Mapped["TicketEvent"] = relationship(back_populates="categories", init=False) + event: Mapped["TicketEvent"] = relationship(back_populates="categories", init=False) class Ticket(Base): From 6f9c44005c4f8fbbd2a7ea7046dfcc7622af97cc Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sun, 29 Mar 2026 14:54:45 +0200 Subject: [PATCH 35/72] Remove quota per checkout and user and sessions' endtime --- app/core/tickets/cruds_tickets.py | 10 ---------- app/core/tickets/endpoints_tickets.py | 1 - app/core/tickets/factory_tickets.py | 3 +-- app/core/tickets/models_tickets.py | 8 +------- app/core/tickets/schemas_tickets.py | 9 +-------- 5 files changed, 3 insertions(+), 28 deletions(-) diff --git a/app/core/tickets/cruds_tickets.py b/app/core/tickets/cruds_tickets.py index 2e71d945a2..22b6838890 100644 --- a/app/core/tickets/cruds_tickets.py +++ b/app/core/tickets/cruds_tickets.py @@ -88,15 +88,12 @@ async def get_event_by_id( open_datetime=event.open_datetime, close_datetime=event.close_datetime, quota=event.quota, - quota_per_checkout=event.quota_per_checkout, - quota_per_user=event.quota_per_user, store_id=event.store_id, sessions=[ schemas_tickets.SessionComplete( id=session.id, name=session.name, start_time=session.start_time, - end_time=session.end_time, event_id=session.event_id, quota=session.quota, ) @@ -125,8 +122,6 @@ async def create_event( id=event_id, store_id=event.store_id, name=event.name, - quota_per_user=event.quota_per_user, - quota_per_checkout=event.quota_per_checkout, quota=event.quota, open_datetime=event.open_datetime, close_datetime=event.close_datetime, @@ -136,7 +131,6 @@ async def create_event( event_id=event_id, name=session.name, start_time=session.start_time, - end_time=session.end_time, quota=session.quota, ) for session in event.sessions @@ -202,7 +196,6 @@ async def get_session_by_id( id=session.id, name=session.name, start_time=session.start_time, - end_time=session.end_time, event_id=session.event_id, quota=session.quota, ) @@ -259,7 +252,6 @@ async def get_tickets_by_user_id( id=ticket.session.id, name=ticket.session.name, start_time=ticket.session.start_time, - end_time=ticket.session.end_time, event_id=ticket.session.event_id, ), user_id=ticket.user_id, @@ -306,7 +298,6 @@ async def get_tickets_by_event_id( id=ticket.session.id, name=ticket.session.name, start_time=ticket.session.start_time, - end_time=ticket.session.end_time, event_id=ticket.session.event_id, ), user_id=ticket.user_id, @@ -355,7 +346,6 @@ async def get_ticket_by_id( id=ticket.session.id, name=ticket.session.name, start_time=ticket.session.start_time, - end_time=ticket.session.end_time, event_id=ticket.session.event_id, ), user_id=ticket.user_id, diff --git a/app/core/tickets/endpoints_tickets.py b/app/core/tickets/endpoints_tickets.py index b951c0f6f8..99a40e423a 100644 --- a/app/core/tickets/endpoints_tickets.py +++ b/app/core/tickets/endpoints_tickets.py @@ -98,7 +98,6 @@ async def get_event( id=session.id, name=session.name, start_time=session.start_time, - end_time=session.end_time, sold_out=await utils_tickets.is_session_sold_out( session_id=session.id, quota=session.quota, diff --git a/app/core/tickets/factory_tickets.py b/app/core/tickets/factory_tickets.py index 75746fe8a0..48d2140cef 100644 --- a/app/core/tickets/factory_tickets.py +++ b/app/core/tickets/factory_tickets.py @@ -5,9 +5,8 @@ class TicketsFactory(Factory): - depends_on = [] - + @classmethod async def run(cls, db: AsyncSession, settings: Settings) -> None: pass diff --git a/app/core/tickets/models_tickets.py b/app/core/tickets/models_tickets.py index b26bf7eed3..aedb635473 100644 --- a/app/core/tickets/models_tickets.py +++ b/app/core/tickets/models_tickets.py @@ -22,13 +22,8 @@ class TicketEvent(Base): open_datetime: Mapped[datetime] close_datetime: Mapped[datetime | None] - # Number of tickets a user can buy for this event - quota_per_user: Mapped[int | None] - # Number of tickets that can be bought in a single checkout for this event - quota_per_checkout: Mapped[int | None] - # Total number of tickets available + # Total number of tickets available, None means unlimited quota: Mapped[int | None] - # None means unlimited store: Mapped[models_mypayment.Store] = relationship(init=False) @@ -45,7 +40,6 @@ class EventSession(Base): name: Mapped[str] start_time: Mapped[datetime] - end_time: Mapped[datetime] quota: Mapped[int | None] = mapped_column(default=None) diff --git a/app/core/tickets/schemas_tickets.py b/app/core/tickets/schemas_tickets.py index 508897a9e8..aa9d7848b8 100644 --- a/app/core/tickets/schemas_tickets.py +++ b/app/core/tickets/schemas_tickets.py @@ -13,7 +13,6 @@ class Session(BaseModel): event_id: UUID name: str start_time: datetime - end_time: datetime class SessionComplete(Session): @@ -36,7 +35,7 @@ class SessionAdmin(SessionComplete): class SessionCreate(BaseModel): name: str start_time: datetime - end_time: datetime + quota: int | None @@ -86,8 +85,6 @@ class EventComplete(EventSimple): sessions: list[SessionComplete] categories: list[CategoryComplete] - quota_per_user: int | None - quota_per_checkout: int | None quota: int | None @@ -102,16 +99,12 @@ class EventAdmin(EventComplete): sessions: list[SessionAdmin] categories: list[CategoryAdmin] - quota_per_user: int | None - quota_per_checkout: int | None quota: int | None class EventCreate(BaseModel): store_id: UUID name: str - quota_per_user: int | None - quota_per_checkout: int | None quota: int | None open_datetime: datetime close_datetime: datetime | None From 4161069f8f22e963a7e6de1b9b6ae8a9d5fa965b Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sun, 29 Mar 2026 14:56:51 +0200 Subject: [PATCH 36/72] start_datetime --- app/core/tickets/models_tickets.py | 2 +- app/core/tickets/schemas_tickets.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/core/tickets/models_tickets.py b/app/core/tickets/models_tickets.py index aedb635473..ec3e45372d 100644 --- a/app/core/tickets/models_tickets.py +++ b/app/core/tickets/models_tickets.py @@ -39,7 +39,7 @@ class EventSession(Base): name: Mapped[str] - start_time: Mapped[datetime] + start_datetime: Mapped[datetime] quota: Mapped[int | None] = mapped_column(default=None) diff --git a/app/core/tickets/schemas_tickets.py b/app/core/tickets/schemas_tickets.py index aa9d7848b8..1c0ab5767c 100644 --- a/app/core/tickets/schemas_tickets.py +++ b/app/core/tickets/schemas_tickets.py @@ -12,7 +12,7 @@ class Session(BaseModel): id: UUID event_id: UUID name: str - start_time: datetime + start_datetime: datetime class SessionComplete(Session): @@ -34,7 +34,7 @@ class SessionAdmin(SessionComplete): class SessionCreate(BaseModel): name: str - start_time: datetime + start_datetime: datetime quota: int | None From 6f6e4cb69c738d5789248395b3e2783df19d24c0 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sun, 29 Mar 2026 14:59:38 +0200 Subject: [PATCH 37/72] fixup --- app/core/tickets/cruds_tickets.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/core/tickets/cruds_tickets.py b/app/core/tickets/cruds_tickets.py index 22b6838890..baea1d2a07 100644 --- a/app/core/tickets/cruds_tickets.py +++ b/app/core/tickets/cruds_tickets.py @@ -93,7 +93,7 @@ async def get_event_by_id( schemas_tickets.SessionComplete( id=session.id, name=session.name, - start_time=session.start_time, + start_datetime=session.start_datetime, event_id=session.event_id, quota=session.quota, ) @@ -130,7 +130,7 @@ async def create_event( id=uuid.uuid4(), event_id=event_id, name=session.name, - start_time=session.start_time, + start_datetime=session.start_datetime, quota=session.quota, ) for session in event.sessions @@ -195,7 +195,7 @@ async def get_session_by_id( return schemas_tickets.SessionComplete( id=session.id, name=session.name, - start_time=session.start_time, + start_datetime=session.start_datetime, event_id=session.event_id, quota=session.quota, ) @@ -251,7 +251,7 @@ async def get_tickets_by_user_id( session=schemas_tickets.Session( id=ticket.session.id, name=ticket.session.name, - start_time=ticket.session.start_time, + start_datetime=ticket.session.start_datetime, event_id=ticket.session.event_id, ), user_id=ticket.user_id, @@ -297,7 +297,7 @@ async def get_tickets_by_event_id( session=schemas_tickets.Session( id=ticket.session.id, name=ticket.session.name, - start_time=ticket.session.start_time, + start_datetime=ticket.session.start_datetime, event_id=ticket.session.event_id, ), user_id=ticket.user_id, @@ -345,7 +345,7 @@ async def get_ticket_by_id( session=schemas_tickets.Session( id=ticket.session.id, name=ticket.session.name, - start_time=ticket.session.start_time, + start_datetime=ticket.session.start_datetime, event_id=ticket.session.event_id, ), user_id=ticket.user_id, From 36ad32dbc8c363a0e959d05ac85e4ebe0626b32a Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sun, 29 Mar 2026 14:59:49 +0200 Subject: [PATCH 38/72] tickets_in_checkout and tickets_sold --- app/core/tickets/schemas_tickets.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/core/tickets/schemas_tickets.py b/app/core/tickets/schemas_tickets.py index 1c0ab5767c..6c9d81f93a 100644 --- a/app/core/tickets/schemas_tickets.py +++ b/app/core/tickets/schemas_tickets.py @@ -31,6 +31,9 @@ class SessionAdmin(SessionComplete): sold: int waiting: int + tickets_in_checkout: int + tickets_sold: int + class SessionCreate(BaseModel): name: str @@ -63,6 +66,9 @@ class CategoryAdmin(CategoryComplete): sold: int waiting: int + tickets_in_checkout: int + tickets_sold: int + class CategoryCreate(BaseModel): name: str @@ -99,7 +105,8 @@ class EventAdmin(EventComplete): sessions: list[SessionAdmin] categories: list[CategoryAdmin] - quota: int | None + tickets_in_checkout: int + tickets_sold: int class EventCreate(BaseModel): From a5dcc574e15bdcf616618ef82fca493a9716927c Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sun, 29 Mar 2026 15:05:32 +0200 Subject: [PATCH 39/72] admin --- app/core/tickets/endpoints_tickets.py | 56 ++++++++++++++++++++++++++- app/core/tickets/schemas_tickets.py | 6 --- 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/app/core/tickets/endpoints_tickets.py b/app/core/tickets/endpoints_tickets.py index 99a40e423a..cf62de18df 100644 --- a/app/core/tickets/endpoints_tickets.py +++ b/app/core/tickets/endpoints_tickets.py @@ -97,7 +97,7 @@ async def get_event( event_id=session.event_id, id=session.id, name=session.name, - start_time=session.start_time, + start_datetime=session.start_datetime, sold_out=await utils_tickets.is_session_sold_out( session_id=session.id, quota=session.quota, @@ -237,7 +237,59 @@ async def get_event_admin( raise HTTPException(404, "Event not found") # TODO: check if user has the right to manage the seller - return event + return schemas_tickets.EventAdmin( + id=event.id, + name=event.name, + store_id=event.store_id, + open_datetime=event.open_datetime, + close_datetime=event.close_datetime, + sessions=[ + schemas_tickets.SessionAdmin( + id=session.id, + event_id=session.event_id, + name=session.name, + start_datetime=session.start_datetime, + quota=session.quota, + tickets_in_checkout=await cruds_tickets.count_valid_checkouts_by_event_id( + event_id=event_id, + db=db, + ), + tickets_sold=await cruds_tickets.count_tickets_by_event_id( + event_id=event_id, + db=db, + ), + ) + for session in event.sessions + ], + categories=[ + schemas_tickets.CategoryAdmin( + id=category.id, + event_id=category.event_id, + name=category.name, + price=category.price, + required_membership=category.required_membership, + quota=category.quota, + tickets_in_checkout=await cruds_tickets.count_valid_checkouts_by_category_id( + category_id=category.id, + db=db, + ), + tickets_sold=await cruds_tickets.count_tickets_by_category_id( + category_id=category.id, + db=db, + ), + ) + for category in event.categories + ], + quota=event.quota, + tickets_in_checkout=await cruds_tickets.count_valid_checkouts_by_event_id( + event_id=event_id, + db=db, + ), + tickets_sold=await cruds_tickets.count_tickets_by_event_id( + event_id=event_id, + db=db, + ), + ) @router.post( diff --git a/app/core/tickets/schemas_tickets.py b/app/core/tickets/schemas_tickets.py index 6c9d81f93a..5bcaf9026d 100644 --- a/app/core/tickets/schemas_tickets.py +++ b/app/core/tickets/schemas_tickets.py @@ -28,9 +28,6 @@ class SessionPublic(Session): class SessionAdmin(SessionComplete): - sold: int - waiting: int - tickets_in_checkout: int tickets_sold: int @@ -63,9 +60,6 @@ class CategoryPublic(Category): class CategoryAdmin(CategoryComplete): - sold: int - waiting: int - tickets_in_checkout: int tickets_sold: int From 1b8e34bd1649b34d5897d5b7664825bffffb61d5 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sun, 29 Mar 2026 15:25:34 +0200 Subject: [PATCH 40/72] Mypayment: can_manage_events --- app/core/mypayment/models_mypayment.py | 2 + app/core/mypayment/schemas_mypayment.py | 3 ++ migrations/versions/67-mypayment.py | 51 +++++++++++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 migrations/versions/67-mypayment.py diff --git a/app/core/mypayment/models_mypayment.py b/app/core/mypayment/models_mypayment.py index 9968ee5c17..dd250a5d8a 100644 --- a/app/core/mypayment/models_mypayment.py +++ b/app/core/mypayment/models_mypayment.py @@ -235,6 +235,8 @@ class Seller(Base): can_cancel: Mapped[bool] can_manage_sellers: Mapped[bool] + can_manage_events: Mapped[bool] + user: Mapped[models_users.CoreUser] = relationship(init=False, lazy="joined") diff --git a/app/core/mypayment/schemas_mypayment.py b/app/core/mypayment/schemas_mypayment.py index 703f273a97..a9e4d674b0 100644 --- a/app/core/mypayment/schemas_mypayment.py +++ b/app/core/mypayment/schemas_mypayment.py @@ -117,6 +117,9 @@ class Seller(BaseModel): can_cancel: bool can_manage_sellers: bool + # Event module + can_manage_events: bool + user: schemas_users.CoreUserSimple diff --git a/migrations/versions/67-mypayment.py b/migrations/versions/67-mypayment.py new file mode 100644 index 0000000000..075f470b71 --- /dev/null +++ b/migrations/versions/67-mypayment.py @@ -0,0 +1,51 @@ +"""empty message + +Create Date: 2026-03-29 15:20:10.468941 +""" + +from collections.abc import Sequence +from typing import TYPE_CHECKING, Union + +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 = "de94c373f94a" +down_revision: str | None = "c052cfbe6d75" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "mypayment_seller", + sa.Column( + "can_manage_events", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ), + ) + + +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 1d8d32f9d0260bf9457ce80ae983d02d77665b6d Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sun, 29 Mar 2026 15:26:09 +0200 Subject: [PATCH 41/72] Migration --- .../versions/{67-tickets.py => 68-tickets.py} | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) rename migrations/versions/{67-tickets.py => 68-tickets.py} (91%) diff --git a/migrations/versions/67-tickets.py b/migrations/versions/68-tickets.py similarity index 91% rename from migrations/versions/67-tickets.py rename to migrations/versions/68-tickets.py index 0847b34be2..d232604c4a 100644 --- a/migrations/versions/67-tickets.py +++ b/migrations/versions/68-tickets.py @@ -26,12 +26,10 @@ def upgrade() -> None: op.create_table( "tickets_event", sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("store_id", sa.Uuid(), nullable=False), sa.Column("name", sa.String(), nullable=False), + sa.Column("store_id", sa.Uuid(), nullable=False), sa.Column("open_datetime", TZDateTime(), nullable=False), sa.Column("close_datetime", TZDateTime(), nullable=True), - sa.Column("quota_per_user", sa.Integer(), nullable=True), - sa.Column("quota_per_checkout", sa.Integer(), nullable=True), sa.Column("quota", sa.Integer(), nullable=True), sa.ForeignKeyConstraint( ["store_id"], @@ -62,8 +60,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("start_time", TZDateTime(), nullable=False), - sa.Column("end_time", TZDateTime(), nullable=False), + sa.Column("start_datetime", TZDateTime(), nullable=False), sa.Column("quota", sa.Integer(), nullable=True), sa.ForeignKeyConstraint( ["event_id"], @@ -76,6 +73,7 @@ def upgrade() -> None: sa.Column("id", sa.Uuid(), nullable=False), sa.Column("category_id", sa.Uuid(), nullable=False), sa.Column("session_id", sa.Uuid(), nullable=False), + sa.Column("event_id", sa.Uuid(), nullable=False), sa.Column("price", sa.Integer(), nullable=False), sa.Column("expiration", TZDateTime(), nullable=False), sa.Column("user_id", sa.String(), nullable=False), @@ -83,6 +81,10 @@ def upgrade() -> None: ["category_id"], ["tickets_category.id"], ), + sa.ForeignKeyConstraint( + ["event_id"], + ["tickets_event.id"], + ), sa.ForeignKeyConstraint( ["session_id"], ["tickets_session.id"], @@ -98,6 +100,7 @@ def upgrade() -> None: sa.Column("id", sa.Uuid(), nullable=False), sa.Column("category_id", sa.Uuid(), nullable=False), sa.Column("session_id", sa.Uuid(), nullable=False), + sa.Column("event_id", sa.Uuid(), nullable=False), sa.Column("user_id", sa.String(), nullable=False), sa.Column("price", sa.Integer(), nullable=False), sa.Column("scanned", sa.Boolean(), nullable=False), @@ -105,6 +108,10 @@ def upgrade() -> None: ["category_id"], ["tickets_category.id"], ), + sa.ForeignKeyConstraint( + ["event_id"], + ["tickets_event.id"], + ), sa.ForeignKeyConstraint( ["session_id"], ["tickets_session.id"], From 76aa91e5458e6fc48b152cbe337ca3abb9309645 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sun, 29 Mar 2026 15:26:26 +0200 Subject: [PATCH 42/72] Permissions --- app/core/tickets/endpoints_tickets.py | 102 ++++++++++++++++++++++---- app/core/tickets/schemas_tickets.py | 1 + 2 files changed, 89 insertions(+), 14 deletions(-) diff --git a/app/core/tickets/endpoints_tickets.py b/app/core/tickets/endpoints_tickets.py index cf62de18df..d512b32cd6 100644 --- a/app/core/tickets/endpoints_tickets.py +++ b/app/core/tickets/endpoints_tickets.py @@ -14,7 +14,7 @@ from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession -from app.core.mypayment import cruds_mypayment +from app.core.mypayment import cruds_mypayment, utils_mypayment from app.core.permissions.type_permissions import ModulePermissions from app.core.tickets import cruds_tickets, schemas_tickets, utils_tickets from app.core.tickets.factory_tickets import TicketsFactory @@ -24,6 +24,7 @@ is_user, is_user_allowed_to, ) +from app.types.exceptions import ObjectExpectedInDbNotFoundError from app.types.module import CoreModule router = APIRouter(tags=["Tickets"]) @@ -84,7 +85,6 @@ async def get_event( """ event = await cruds_tickets.get_event_by_id(event_id=event_id, db=db) - # TODO: indicate if the event is sold out if event is None: raise HTTPException(404, "Event not found") @@ -149,7 +149,6 @@ async def create_checkout( """ Create a checkout for an open event """ - # TODO: can we ask for multiple tickets? category = await cruds_tickets.get_category_by_id( category_id=checkout.category_id, db=db, @@ -236,7 +235,16 @@ async def get_event_admin( if event is None: raise HTTPException(404, "Event not found") - # TODO: check if user has the right to manage the seller + if not await utils_mypayment.can_user_manage_events( + user_id=user.id, + store_id=event.store_id, + db=db, + ): + raise HTTPException( + status_code=403, + detail="User is not authorized to manage store events", + ) + return schemas_tickets.EventAdmin( id=event.id, name=event.name, @@ -309,7 +317,16 @@ async def create_event( **The user should have the right to manage the event seller** """ - # TODO: check if user has the right to manage the seller + if not await utils_mypayment.can_user_manage_events( + user_id=user.id, + store_id=event_create.store_id, + db=db, + ): + raise HTTPException( + status_code=403, + detail="User is not authorized to manage store events", + ) + event_id = uuid.uuid4() await cruds_tickets.create_event( @@ -361,7 +378,19 @@ async def get_event_tickets( **The user should have the right to manage the event seller** """ - # TODO: check if user has the right to manage the seller + event = await cruds_tickets.get_event_by_id(event_id=event_id, db=db) + if event is None: + raise HTTPException(404, "Event not found") + + if not await utils_mypayment.can_user_manage_events( + user_id=user.id, + store_id=event.store_id, + db=db, + ): + raise HTTPException( + status_code=403, + detail="User is not authorized to manage store events", + ) return await cruds_tickets.get_tickets_by_event_id(event_id=event_id, db=db) @@ -383,14 +412,20 @@ async def get_event_tickets_csv( **The user should have the right to manage the event seller** """ - # TODO: check if user has the right to manage the seller - event = await cruds_tickets.get_event_by_id( - event_id=event_id, - db=db, - ) + event = await cruds_tickets.get_event_by_id(event_id=event_id, db=db) if event is None: raise HTTPException(404, "Event not found") + if not await utils_mypayment.can_user_manage_events( + user_id=user.id, + store_id=event.store_id, + db=db, + ): + raise HTTPException( + status_code=403, + detail="User is not authorized to manage store events", + ) + csv_io = StringIO() writer = csv.writer(csv_io, delimiter=";", quoting=csv.QUOTE_MINIMAL) @@ -469,7 +504,22 @@ async def check_ticket( if ticket is None: raise HTTPException(404, "Ticket not found") - # TODO: check if user has the right to manage the seller + event = await cruds_tickets.get_event_by_id(event_id=ticket.event_id, db=db) + if event is None: + raise ObjectExpectedInDbNotFoundError( + object_name="Event", + object_id=ticket.event_id, + ) + + if not await utils_mypayment.can_user_manage_events( + user_id=user.id, + store_id=event.store_id, + db=db, + ): + raise HTTPException( + status_code=403, + detail="User is not authorized to manage store events", + ) return ticket @@ -495,7 +545,22 @@ async def scan_ticket( if ticket is None: raise HTTPException(404, "Ticket not found") - # TODO: check if user has the right to manage the seller + event = await cruds_tickets.get_event_by_id(event_id=ticket.event_id, db=db) + if event is None: + raise ObjectExpectedInDbNotFoundError( + object_name="Event", + object_id=ticket.event_id, + ) + + if not await utils_mypayment.can_user_manage_events( + user_id=user.id, + store_id=event.store_id, + db=db, + ): + raise HTTPException( + status_code=403, + detail="User is not authorized to manage store events", + ) await cruds_tickets.mark_ticket_as_scanned(ticket_id=ticket_id, db=db) @@ -519,7 +584,6 @@ async def get_events_by_association( **The user should have the right to manage the event seller** """ - # TODO: check if user has the right to manage the association store = await cruds_mypayment.get_store_by_association_id( association_id=association_id, db=db, @@ -528,6 +592,16 @@ async def get_events_by_association( if store is None: raise HTTPException(400, "No seller associated with this association") + if not await utils_mypayment.can_user_manage_events( + user_id=user.id, + store_id=store.id, + db=db, + ): + raise HTTPException( + status_code=403, + detail="User is not authorized to manage store events", + ) + return await cruds_tickets.get_events_by_store_id( store_id=store.id, db=db, diff --git a/app/core/tickets/schemas_tickets.py b/app/core/tickets/schemas_tickets.py index 5bcaf9026d..0650a72e4e 100644 --- a/app/core/tickets/schemas_tickets.py +++ b/app/core/tickets/schemas_tickets.py @@ -118,6 +118,7 @@ class Ticket(BaseModel): price: int user_id: UUID + event_id: UUID category_id: UUID session_id: UUID From 8c72c83a71033738ff5ffd467c017cc2c18cdca1 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sun, 29 Mar 2026 16:21:35 +0200 Subject: [PATCH 43/72] Lock --- app/core/tickets/cruds_tickets.py | 43 ++++++++++++++++++++++++ app/core/tickets/endpoints_tickets.py | 47 +++++++++++++++++++++++++-- app/core/tickets/schemas_tickets.py | 8 +++-- 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/app/core/tickets/cruds_tickets.py b/app/core/tickets/cruds_tickets.py index baea1d2a07..ac620c3127 100644 --- a/app/core/tickets/cruds_tickets.py +++ b/app/core/tickets/cruds_tickets.py @@ -113,6 +113,46 @@ async def get_event_by_id( ) +async def acquire_event_lock_for_update( + event_id: UUID, + db: AsyncSession, +) -> schemas_tickets.EventWithoutSessionsAndCategories | None: + """ + Acquire a lock FOR UPDATE on the event row. + Until the end of the transaction, other: + - update + - delete + - and select FOR UPDATE + queries on the same row will be blocked until the lock is released. + + > FOR UPDATE causes the rows retrieved by the SELECT statement to be locked as though for update. This prevents them from being locked, modified or deleted by other transactions until the current transaction ends. + + By putting this lock on the beginning of an endpoint, + we unsure that all endpoint trying to acquire the same lock + will wait for the first lock to be released + """ + result = await db.execute( + select(models_tickets.TicketEvent) + .where( + models_tickets.TicketEvent.id == event_id, + ) + .with_for_update(), + ) + + event = result.scalars().first() + if event is None: + return None + + return schemas_tickets.EventWithoutSessionsAndCategories( + id=event.id, + name=event.name, + open_datetime=event.open_datetime, + close_datetime=event.close_datetime, + quota=event.quota, + store_id=event.store_id, + ) + + async def create_event( event_id: UUID, event: schemas_tickets.EventCreate, @@ -240,6 +280,7 @@ async def get_tickets_by_user_id( id=ticket.id, category_id=ticket.category_id, session_id=ticket.session_id, + event_id=ticket.event_id, scanned=ticket.scanned, category=schemas_tickets.Category( id=ticket.category.id, @@ -286,6 +327,7 @@ async def get_tickets_by_event_id( id=ticket.id, category_id=ticket.category_id, session_id=ticket.session_id, + event_id=ticket.event_id, scanned=ticket.scanned, category=schemas_tickets.Category( id=ticket.category.id, @@ -334,6 +376,7 @@ async def get_ticket_by_id( id=ticket.id, category_id=ticket.category_id, session_id=ticket.session_id, + event_id=ticket.event_id, scanned=ticket.scanned, category=schemas_tickets.Category( id=ticket.category.id, diff --git a/app/core/tickets/endpoints_tickets.py b/app/core/tickets/endpoints_tickets.py index d512b32cd6..966488cbfe 100644 --- a/app/core/tickets/endpoints_tickets.py +++ b/app/core/tickets/endpoints_tickets.py @@ -14,6 +14,7 @@ from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession +from app.core.memberships import utils_memberships from app.core.mypayment import cruds_mypayment, utils_mypayment from app.core.permissions.type_permissions import ModulePermissions from app.core.tickets import cruds_tickets, schemas_tickets, utils_tickets @@ -167,11 +168,53 @@ async def create_checkout( if session.event_id != event_id: raise HTTPException(400, "Session does not belong to the event") + if category.required_membership is not None: + membership = await utils_memberships.get_user_active_membership_to_association_membership( + association_membership_id=category.required_membership, + user_id=user.id, + db=db, + ) + if membership is None: + raise HTTPException( + 400, + "User does not have required membership to choose this category", + ) + + # By putting this lock: + # - we unsure that if an other endpoint execution acquired the lock before, this one will wait. + # - we guarantee that any other endpoint execution that tries to acquire the lock will need to wait until the end of this transaction. + event = await cruds_tickets.acquire_event_lock_for_update( + event_id=event_id, + db=db, + ) + + if event is None: + raise ObjectExpectedInDbNotFoundError( + object_name="Event", + object_id=event_id, + ) + price = category.price expiration = datetime.now(UTC) + timedelta(minutes=CHECKOUT_EXPIRATION_MINUTES) - # TODO: indicate if the event is sold out - # TODO: check required membership + if utils_tickets.is_event_sold_out( + event_id=event_id, + quota=event.quota, + db=db, + ): + raise HTTPException(400, "Event is sold out") + if utils_tickets.is_category_sold_out( + category_id=category.id, + quota=category.quota, + db=db, + ): + raise HTTPException(400, "Category is sold out") + if utils_tickets.is_session_sold_out( + session_id=session.id, + quota=session.quota, + db=db, + ): + raise HTTPException(400, "Session is sold out") await cruds_tickets.create_checkout( checkout_id=uuid.uuid4(), diff --git a/app/core/tickets/schemas_tickets.py b/app/core/tickets/schemas_tickets.py index 0650a72e4e..703662e0c8 100644 --- a/app/core/tickets/schemas_tickets.py +++ b/app/core/tickets/schemas_tickets.py @@ -81,12 +81,14 @@ class EventSimple(BaseModel): close_datetime: datetime | None -class EventComplete(EventSimple): +class EventWithoutSessionsAndCategories(EventSimple): + quota: int | None + + +class EventComplete(EventWithoutSessionsAndCategories): sessions: list[SessionComplete] categories: list[CategoryComplete] - quota: int | None - class EventPublic(EventSimple): sessions: list[SessionPublic] From 546204a2e75fcddcab3ef55364445d23690139ce Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sun, 29 Mar 2026 20:19:36 +0200 Subject: [PATCH 44/72] Mypayment: can_manage_events, fixup --- app/core/mypayment/cruds_mypayment.py | 4 ++++ app/core/mypayment/endpoints_mypayment.py | 3 +++ app/core/mypayment/schemas_mypayment.py | 2 ++ app/core/mypayment/utils_mypayment.py | 1 - tests/core/test_mypayment.py | 20 ++++++++++++++++++++ 5 files changed, 29 insertions(+), 1 deletion(-) diff --git a/app/core/mypayment/cruds_mypayment.py b/app/core/mypayment/cruds_mypayment.py index a25ce2a975..e69415a630 100644 --- a/app/core/mypayment/cruds_mypayment.py +++ b/app/core/mypayment/cruds_mypayment.py @@ -229,6 +229,7 @@ async def create_seller( can_see_history: bool, can_cancel: bool, can_manage_sellers: bool, + can_manage_events: bool, db: AsyncSession, ) -> None: wallet = models_mypayment.Seller( @@ -238,6 +239,7 @@ async def create_seller( can_see_history=can_see_history, can_cancel=can_cancel, can_manage_sellers=can_manage_sellers, + can_manage_events=can_manage_events, ) db.add(wallet) @@ -267,6 +269,7 @@ async def get_seller( can_see_history=result.can_see_history, can_cancel=result.can_cancel, can_manage_sellers=result.can_manage_sellers, + can_manage_events=result.can_manage_events, user=schemas_users.CoreUserSimple( id=result.user.id, firstname=result.user.firstname, @@ -298,6 +301,7 @@ async def get_sellers_by_store_id( can_see_history=seller.can_see_history, can_cancel=seller.can_cancel, can_manage_sellers=seller.can_manage_sellers, + can_manage_events=seller.can_manage_events, user=schemas_users.CoreUserSimple( id=seller.user.id, firstname=seller.user.firstname, diff --git a/app/core/mypayment/endpoints_mypayment.py b/app/core/mypayment/endpoints_mypayment.py index d5aaa3ea78..605ae626a9 100644 --- a/app/core/mypayment/endpoints_mypayment.py +++ b/app/core/mypayment/endpoints_mypayment.py @@ -473,6 +473,7 @@ async def confirm_structure_manager_transfer( can_see_history=True, can_cancel=True, can_manage_sellers=True, + can_manage_events=True, db=db, ) else: @@ -601,6 +602,7 @@ async def create_store( can_see_history=True, can_cancel=True, can_manage_sellers=True, + can_manage_events=True, db=db, ) @@ -1073,6 +1075,7 @@ async def create_store_seller( can_see_history=seller.can_see_history, can_cancel=seller.can_cancel, can_manage_sellers=seller.can_manage_sellers, + can_manage_events=seller.can_manage_events, db=db, ) diff --git a/app/core/mypayment/schemas_mypayment.py b/app/core/mypayment/schemas_mypayment.py index a9e4d674b0..d5cd768f04 100644 --- a/app/core/mypayment/schemas_mypayment.py +++ b/app/core/mypayment/schemas_mypayment.py @@ -100,6 +100,7 @@ class SellerCreation(BaseModel): can_see_history: bool can_cancel: bool can_manage_sellers: bool + can_manage_events: bool = False class SellerUpdate(BaseModel): @@ -107,6 +108,7 @@ class SellerUpdate(BaseModel): can_see_history: bool | None = None can_cancel: bool | None = None can_manage_sellers: bool | None = None + can_manage_events: bool | None = None class Seller(BaseModel): diff --git a/app/core/mypayment/utils_mypayment.py b/app/core/mypayment/utils_mypayment.py index 6136484692..0fc5e77b26 100644 --- a/app/core/mypayment/utils_mypayment.py +++ b/app/core/mypayment/utils_mypayment.py @@ -5,7 +5,6 @@ from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives.asymmetric import ed25519 -from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession from app.core.checkout import schemas_checkout diff --git a/tests/core/test_mypayment.py b/tests/core/test_mypayment.py index d849297b6a..ebac02c9e3 100644 --- a/tests/core/test_mypayment.py +++ b/tests/core/test_mypayment.py @@ -381,6 +381,7 @@ async def init_objects() -> None: can_see_history=True, can_cancel=True, can_manage_sellers=True, + can_manage_events=True, ) await add_object_to_db(manager_as_admin) @@ -526,6 +527,7 @@ async def init_objects() -> None: can_see_history=False, can_cancel=False, can_manage_sellers=False, + can_manage_events=True, ) await add_object_to_db(store_seller_no_permission) @@ -543,6 +545,7 @@ async def init_objects() -> None: can_see_history=False, can_cancel=True, can_manage_sellers=False, + can_manage_events=True, ) await add_object_to_db(store_seller_can_bank) @@ -560,6 +563,7 @@ async def init_objects() -> None: can_see_history=False, can_cancel=True, can_manage_sellers=False, + can_manage_events=True, ) await add_object_to_db(store_seller_can_cancel) @@ -577,6 +581,7 @@ async def init_objects() -> None: can_see_history=False, can_cancel=False, can_manage_sellers=True, + can_manage_events=True, ) await add_object_to_db(store_seller_can_manage_sellers) @@ -591,6 +596,7 @@ async def init_objects() -> None: can_see_history=True, can_cancel=False, can_manage_sellers=False, + can_manage_events=True, ) await add_object_to_db(store_seller_can_see_history_seller) store_seller_can_see_history_user_access_token = create_api_access_token( @@ -945,6 +951,7 @@ async def test_transfer_structure_manager_as_manager( can_see_history=False, can_cancel=False, can_manage_sellers=False, + can_manage_events=True, ) await add_object_to_db(seller) @@ -1449,6 +1456,7 @@ async def test_delete_store(client: TestClient): can_see_history=True, can_cancel=True, can_manage_sellers=True, + can_manage_events=True, ) await add_object_to_db(sellet) @@ -1514,6 +1522,7 @@ async def test_add_seller_for_non_existing_store(client: TestClient): "can_see_history": True, "can_cancel": True, "can_manage_sellers": True, + "can_manage_events": True, }, ) assert response.status_code == 404 @@ -1530,6 +1539,7 @@ async def test_add_seller_as_lambda(client: TestClient): "can_see_history": True, "can_cancel": True, "can_manage_sellers": True, + "can_manage_events": True, }, ) assert response.status_code == 403 @@ -1554,6 +1564,7 @@ async def test_add_seller_as_seller_with_permission(client: TestClient): "can_see_history": True, "can_cancel": True, "can_manage_sellers": True, + "can_manage_events": True, }, ) assert response.status_code == 201 @@ -1574,6 +1585,7 @@ async def test_add_seller_as_seller_without_permission(client: TestClient): "can_see_history": True, "can_cancel": True, "can_manage_sellers": True, + "can_manage_events": True, }, ) assert response.status_code == 403 @@ -1594,6 +1606,7 @@ async def test_add_already_existing_seller(client: TestClient): can_see_history=True, can_cancel=True, can_manage_sellers=False, + can_manage_events=True, ) await add_object_to_db(seller) @@ -1608,6 +1621,7 @@ async def test_add_already_existing_seller(client: TestClient): "can_see_history": True, "can_cancel": True, "can_manage_sellers": True, + "can_manage_events": True, }, ) assert response.status_code == 400 @@ -1669,6 +1683,7 @@ async def test_update_seller_of_non_existing_store(client: TestClient): "can_see_history": True, "can_cancel": False, "can_manage_sellers": False, + "can_manage_events": False, }, ) assert response.status_code == 404 @@ -1684,6 +1699,7 @@ async def test_update_seller_as_lambda(client: TestClient): "can_see_history": True, "can_cancel": False, "can_manage_sellers": False, + "can_manage_events": False, }, ) assert response.status_code == 403 @@ -1704,6 +1720,7 @@ async def test_update_seller_as_seller_without_permission(client: TestClient): "can_see_history": False, "can_cancel": False, "can_manage_sellers": False, + "can_manage_events": False, }, ) assert response.status_code == 403 @@ -1724,6 +1741,7 @@ async def test_update_non_existing_seller(client: TestClient): can_see_history=False, can_cancel=False, can_manage_sellers=False, + can_manage_events=True, ) await add_object_to_db(seller) response = client.patch( @@ -1751,6 +1769,7 @@ async def test_update_seller_as_seller_with_permission(client: TestClient): can_see_history=False, can_cancel=False, can_manage_sellers=False, + can_manage_events=True, ) await add_object_to_db(seller) response = client.patch( @@ -1859,6 +1878,7 @@ async def test_delete_seller_as_seller_with_permission(client: TestClient): can_see_history=False, can_cancel=False, can_manage_sellers=False, + can_manage_events=True, ) await add_object_to_db(seller) response = client.delete( From a4f4f8abfd0d15c402803a24e0a4c2577f70ea7b Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sun, 29 Mar 2026 22:54:16 +0200 Subject: [PATCH 45/72] Fix --- app/core/tickets/cruds_tickets.py | 6 +++--- app/core/tickets/endpoints_tickets.py | 8 ++++---- app/core/tickets/models_tickets.py | 8 ++++---- app/core/tickets/schemas_tickets.py | 2 +- app/core/tickets/utils_tickets.py | 5 +++++ 5 files changed, 17 insertions(+), 12 deletions(-) diff --git a/app/core/tickets/cruds_tickets.py b/app/core/tickets/cruds_tickets.py index ac620c3127..1da12b0199 100644 --- a/app/core/tickets/cruds_tickets.py +++ b/app/core/tickets/cruds_tickets.py @@ -97,7 +97,7 @@ async def get_event_by_id( event_id=session.event_id, quota=session.quota, ) - for session in sorted(event.sessions, key=lambda item: item.start_time) + for session in event.sessions ], categories=[ schemas_tickets.CategoryComplete( @@ -108,7 +108,7 @@ async def get_event_by_id( event_id=category.event_id, quota=category.quota, ) - for category in sorted(event.categories, key=lambda item: item.name) + for category in event.categories ], ) @@ -315,7 +315,7 @@ async def get_tickets_by_event_id( ) -> Sequence[schemas_tickets.Ticket]: result = await db.execute( select(models_tickets.Ticket) - .where(models_tickets.Ticket.category.event_id == event_id) + .where(models_tickets.Ticket.event_id == event_id) .options( selectinload(models_tickets.Ticket.category), selectinload(models_tickets.Ticket.session), diff --git a/app/core/tickets/endpoints_tickets.py b/app/core/tickets/endpoints_tickets.py index 966488cbfe..1c3212b874 100644 --- a/app/core/tickets/endpoints_tickets.py +++ b/app/core/tickets/endpoints_tickets.py @@ -183,6 +183,7 @@ async def create_checkout( # By putting this lock: # - we unsure that if an other endpoint execution acquired the lock before, this one will wait. # - we guarantee that any other endpoint execution that tries to acquire the lock will need to wait until the end of this transaction. + # Two endpoints require this lock: create a checkout and convert a checkout to ticket (in a payment callback) event = await cruds_tickets.acquire_event_lock_for_update( event_id=event_id, db=db, @@ -197,19 +198,19 @@ async def create_checkout( price = category.price expiration = datetime.now(UTC) + timedelta(minutes=CHECKOUT_EXPIRATION_MINUTES) - if utils_tickets.is_event_sold_out( + if await utils_tickets.is_event_sold_out( event_id=event_id, quota=event.quota, db=db, ): raise HTTPException(400, "Event is sold out") - if utils_tickets.is_category_sold_out( + if await utils_tickets.is_category_sold_out( category_id=category.id, quota=category.quota, db=db, ): raise HTTPException(400, "Category is sold out") - if utils_tickets.is_session_sold_out( + if await utils_tickets.is_session_sold_out( session_id=session.id, quota=session.quota, db=db, @@ -240,7 +241,6 @@ async def create_checkout( status_code=200, ) async def get_user_tickets( - event_id: UUID, user: CoreUser = Depends( is_user_allowed_to( [TicketsPermissions.buy_tickets], diff --git a/app/core/tickets/models_tickets.py b/app/core/tickets/models_tickets.py index ec3e45372d..e4a0fe9665 100644 --- a/app/core/tickets/models_tickets.py +++ b/app/core/tickets/models_tickets.py @@ -73,15 +73,15 @@ class Ticket(Base): session_id: Mapped[UUID] = mapped_column(ForeignKey("tickets_session.id")) event_id: Mapped[UUID] = mapped_column(ForeignKey("tickets_event.id")) - user_id: Mapped[UUID] = mapped_column(ForeignKey("core_user.id")) + user_id: Mapped[str] = mapped_column(ForeignKey("core_user.id")) price: Mapped[int] # in cents scanned: Mapped[bool] - category: Mapped["Category"] = relationship() - session: Mapped["EventSession"] = relationship() - user: Mapped[models_users.CoreUser] = relationship() + category: Mapped["Category"] = relationship(init=False) + session: Mapped["EventSession"] = relationship(init=False) + user: Mapped[models_users.CoreUser] = relationship(init=False) class Checkout(Base): diff --git a/app/core/tickets/schemas_tickets.py b/app/core/tickets/schemas_tickets.py index 703662e0c8..0abaa51c38 100644 --- a/app/core/tickets/schemas_tickets.py +++ b/app/core/tickets/schemas_tickets.py @@ -97,7 +97,7 @@ class EventPublic(EventSimple): sold_out: bool -class EventAdmin(EventComplete): +class EventAdmin(EventWithoutSessionsAndCategories): sessions: list[SessionAdmin] categories: list[CategoryAdmin] diff --git a/app/core/tickets/utils_tickets.py b/app/core/tickets/utils_tickets.py index 976b599e38..4b7caf5eed 100644 --- a/app/core/tickets/utils_tickets.py +++ b/app/core/tickets/utils_tickets.py @@ -23,6 +23,11 @@ async def is_event_sold_out( db=db, ) + tickets = await cruds_tickets.get_tickets_by_event_id( + event_id=event_id, + db=db, + ) + return (nb_valid_checkout_for_event + nb_tickets_sold_for_event) >= quota From 06999f46a7d5eb74763a7311839bd33cf8d3cea9 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sun, 29 Mar 2026 22:54:27 +0200 Subject: [PATCH 46/72] Add tests --- tests/core/test_tickets.py | 238 ++++++++++++++++++++++++++++++++++--- 1 file changed, 221 insertions(+), 17 deletions(-) diff --git a/tests/core/test_tickets.py b/tests/core/test_tickets.py index 041aa77046..2dbb659f0e 100644 --- a/tests/core/test_tickets.py +++ b/tests/core/test_tickets.py @@ -30,9 +30,16 @@ store: Store -ticket_event: models_tickets.TicketEvent -ticket_session: models_tickets.EventSession -ticket_category: models_tickets.Category +global_event: models_tickets.TicketEvent +event_session: models_tickets.EventSession +event_category: models_tickets.Category +event_sold_out_category: models_tickets.Category +event_sold_out_session: models_tickets.EventSession + +sold_out_event: models_tickets.TicketEvent +session_sold_out_event: models_tickets.EventSession +category_sold_out_event: models_tickets.Category + ticket: models_tickets.Ticket @@ -99,17 +106,16 @@ async def init_objects() -> None: ) await add_object_to_db(store) - global ticket_event, ticket_session, ticket_category + global global_event, event_session, event_category ticket_event_id = uuid.uuid4() - ticket_session = models_tickets.EventSession( + event_session = models_tickets.EventSession( id=uuid.uuid4(), event_id=ticket_event_id, name="Test Session", - start_time=datetime.now(tz=UTC) - timedelta(days=1), - end_time=datetime.now(tz=UTC) + timedelta(days=1), + start_datetime=datetime.now(tz=UTC) - timedelta(days=1), quota=None, ) - ticket_category = models_tickets.Category( + event_category = models_tickets.Category( id=uuid.uuid4(), event_id=ticket_event_id, name="Test Category", @@ -117,19 +123,96 @@ async def init_objects() -> None: price=1000, required_membership=None, ) - ticket_event = models_tickets.TicketEvent( + global_event = models_tickets.TicketEvent( id=uuid.uuid4(), store_id=store.id, - name="Test Event", + name="Test global_event", open_datetime=datetime.now(tz=UTC) - timedelta(days=1), close_datetime=datetime.now(tz=UTC) + timedelta(days=1), - quota_per_user=2, - quota_per_checkout=2, quota=10, - sessions=[ticket_session], - categories=[ticket_category], + sessions=[event_session], + categories=[event_category], ) - await add_object_to_db(ticket_event) + await add_object_to_db(global_event) + + global event_sold_out_category, event_sold_out_session + event_sold_out_category = models_tickets.Category( + id=uuid.uuid4(), + event_id=global_event.id, + name="Test global_event Sold Out Category", + quota=1, + price=1000, + required_membership=None, + ) + await add_object_to_db(event_sold_out_category) + ticket_sold_out_category = models_tickets.Ticket( + id=uuid.uuid4(), + category_id=event_sold_out_category.id, + session_id=event_session.id, + event_id=global_event.id, + user_id=user.id, + price=10, + scanned=False, + ) + await add_object_to_db(ticket_sold_out_category) + event_sold_out_session = models_tickets.EventSession( + id=uuid.uuid4(), + event_id=global_event.id, + name="Test global_event Sold Out Session", + start_datetime=datetime.now(tz=UTC) - timedelta(days=1), + quota=1, + ) + await add_object_to_db(event_sold_out_session) + ticket_sold_out_session = models_tickets.Ticket( + id=uuid.uuid4(), + category_id=event_category.id, + session_id=event_sold_out_session.id, + event_id=global_event.id, + user_id=user.id, + price=10, + scanned=False, + ) + await add_object_to_db(ticket_sold_out_session) + + global sold_out_event, session_sold_out_event, category_sold_out_event + ticket_sold_out_event_id = uuid.uuid4() + session_sold_out_event = models_tickets.EventSession( + id=uuid.uuid4(), + event_id=ticket_sold_out_event_id, + name="Test Session Sold Out", + start_datetime=datetime.now(tz=UTC) - timedelta(days=1), + quota=1, + ) + category_sold_out_event = models_tickets.Category( + id=uuid.uuid4(), + event_id=ticket_sold_out_event_id, + name="Test Category Sold Out", + quota=1, + price=1000, + required_membership=None, + ) + sold_out_event = models_tickets.TicketEvent( + id=ticket_sold_out_event_id, + store_id=store.id, + name="Test global_event Sold Out", + open_datetime=datetime.now(tz=UTC) - timedelta(days=1), + close_datetime=datetime.now(tz=UTC) + timedelta(days=1), + quota=1, + sessions=[session_sold_out_event], + categories=[category_sold_out_event], + ) + await add_object_to_db(sold_out_event) + user = await create_user_with_groups(groups=[]) + ticket_sold_out_event = models_tickets.Ticket( + id=uuid.uuid4(), + category_id=category_sold_out_event.id, + session_id=session_sold_out_event.id, + event_id=ticket_sold_out_event_id, + user_id=user.id, + price=10, + scanned=False, + ) + await add_object_to_db(ticket_sold_out_event) def test_get_open_events(client: TestClient): @@ -138,7 +221,7 @@ def test_get_open_events(client: TestClient): headers={"Authorization": f"Bearer {user_token}"}, ) assert response.status_code == 200 - assert len(response.json()) == 1 + assert len(response.json()) > 1 def test_get_event_with_non_existing_id(client: TestClient): @@ -151,7 +234,128 @@ def test_get_event_with_non_existing_id(client: TestClient): def test_get_event(client: TestClient): response = client.get( - f"/tickets/events/{ticket_event.id}", + f"/tickets/events/{global_event.id}", + headers={"Authorization": f"Bearer {user_token}"}, + ) + assert response.status_code == 200 + event = response.json() + assert event["id"] == str(global_event.id) + assert len(event["sessions"]) > 0 + assert len(event["categories"]) > 0 + assert event["sold_out"] is False + + +def test_get_sold_out_event(client: TestClient): + response = client.get( + f"/tickets/events/{sold_out_event.id}", + headers={"Authorization": f"Bearer {user_token}"}, + ) + assert response.status_code == 200 + event = response.json() + assert event["id"] == str(sold_out_event.id) + assert len(event["sessions"]) > 0 + assert len(event["categories"]) > 0 + assert event["sold_out"] is True + + +def test_create_checkout_with_invalid_category(client: TestClient): + response = client.post( + f"/tickets/events/{sold_out_event.id}/checkout", + headers={"Authorization": f"Bearer {user_token}"}, + json={ + "category_id": str(uuid.uuid4()), + "session_id": str(session_sold_out_event.id), + }, + ) + assert response.status_code == 404 + assert response.json()["detail"] == "Category not found" + + +def test_create_checkout_with_invalid_session(client: TestClient): + response = client.post( + f"/tickets/events/{sold_out_event.id}/checkout", + headers={"Authorization": f"Bearer {user_token}"}, + json={ + "category_id": str(category_sold_out_event.id), + "session_id": str(uuid.uuid4()), + }, + ) + assert response.status_code == 404 + assert response.json()["detail"] == "Session not found" + + +def test_create_checkout_with_category_from_another_event(client: TestClient): + response = client.post( + f"/tickets/events/{sold_out_event.id}/checkout", + headers={"Authorization": f"Bearer {user_token}"}, + json={ + "category_id": str(event_category.id), + "session_id": str(session_sold_out_event.id), + }, + ) + assert response.status_code == 400 + assert response.json()["detail"] == "Category does not belong to the event" + + +def test_create_checkout_with_session_from_another_event(client: TestClient): + response = client.post( + f"/tickets/events/{sold_out_event.id}/checkout", + headers={"Authorization": f"Bearer {user_token}"}, + json={ + "category_id": str(category_sold_out_event.id), + "session_id": str(event_session.id), + }, + ) + assert response.status_code == 400 + assert response.json()["detail"] == "Session does not belong to the event" + + +# TODO: test required membership + + +def test_create_checkout_with_sold_out_event(client: TestClient): + response = client.post( + f"/tickets/events/{sold_out_event.id}/checkout", + headers={"Authorization": f"Bearer {user_token}"}, + json={ + "category_id": str(category_sold_out_event.id), + "session_id": str(session_sold_out_event.id), + }, + ) + assert response.status_code == 400 + assert response.json()["detail"] == "Event is sold out" + + +def test_create_checkout_with_sold_out_category(client: TestClient): + response = client.post( + f"/tickets/events/{global_event.id}/checkout", + headers={"Authorization": f"Bearer {user_token}"}, + json={ + "category_id": str(event_sold_out_category.id), + "session_id": str(event_session.id), + }, + ) + assert response.status_code == 400 + assert response.json()["detail"] == "Category is sold out" + + +def test_create_checkout_with_sold_out_session(client: TestClient): + response = client.post( + f"/tickets/events/{global_event.id}/checkout", + headers={"Authorization": f"Bearer {user_token}"}, + json={ + "category_id": str(event_category.id), + "session_id": str(event_sold_out_session.id), + }, + ) + assert response.status_code == 400 + assert response.json()["detail"] == "Session is sold out" + + +def test_get_user_tickets(client: TestClient): + response = client.get( + "/tickets/user/me/tickets", headers={"Authorization": f"Bearer {user_token}"}, ) assert response.status_code == 200 + assert len(response.json()) > 1 From 2ea4343feac488a34319d196f866d2b6690c8c9b Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 30 Mar 2026 19:27:29 +0200 Subject: [PATCH 47/72] Tests and fix --- app/core/tickets/cruds_tickets.py | 38 +++- app/core/tickets/endpoints_tickets.py | 86 +++----- app/core/tickets/schemas_tickets.py | 2 +- app/core/tickets/utils_tickets.py | 61 +++++- tests/core/test_tickets.py | 287 +++++++++++++++++++++++++- 5 files changed, 400 insertions(+), 74 deletions(-) diff --git a/app/core/tickets/cruds_tickets.py b/app/core/tickets/cruds_tickets.py index 1da12b0199..373f103547 100644 --- a/app/core/tickets/cruds_tickets.py +++ b/app/core/tickets/cruds_tickets.py @@ -63,10 +63,16 @@ async def get_events_by_store_id( ] -async def get_event_by_id( +async def get_event_complete_by_id( event_id: UUID, db: AsyncSession, ) -> schemas_tickets.EventComplete | None: + """ + Return an EventComplete, loading complete sessions and categories objets from the database. + + If relationships are not needed, prefer `get_event_simple_by_id` + If a FOR UPDATE lock is needed, prefer `acquire_event_lock_for_update` + """ result = await db.execute( select(models_tickets.TicketEvent) .where( @@ -113,6 +119,35 @@ async def get_event_by_id( ) +async def get_event_simple_by_id( + event_id: UUID, + db: AsyncSession, +) -> schemas_tickets.EventSimple | None: + """ + Return an EventSimple, loading only the basic event information from the database. + + If relationships are needed, prefer `get_event_complete_by_id` + If a FOR UPDATE lock is needed, prefer `acquire_event_lock_for_update` + """ + result = await db.execute( + select(models_tickets.TicketEvent).where( + models_tickets.TicketEvent.id == event_id, + ), + ) + + event = result.scalars().first() + if event is None: + return None + + return schemas_tickets.EventSimple( + id=event.id, + name=event.name, + open_datetime=event.open_datetime, + close_datetime=event.close_datetime, + store_id=event.store_id, + ) + + async def acquire_event_lock_for_update( event_id: UUID, db: AsyncSession, @@ -366,6 +401,7 @@ async def get_ticket_by_id( .options( selectinload(models_tickets.Ticket.category), selectinload(models_tickets.Ticket.session), + selectinload(models_tickets.Ticket.user), ), ) ticket = result.scalars().first() diff --git a/app/core/tickets/endpoints_tickets.py b/app/core/tickets/endpoints_tickets.py index 1c3212b874..42079d2308 100644 --- a/app/core/tickets/endpoints_tickets.py +++ b/app/core/tickets/endpoints_tickets.py @@ -84,7 +84,7 @@ async def get_event( """ Get an event public details """ - event = await cruds_tickets.get_event_by_id(event_id=event_id, db=db) + event = await cruds_tickets.get_event_complete_by_id(event_id=event_id, db=db) if event is None: raise HTTPException(404, "Event not found") @@ -274,7 +274,7 @@ async def get_event_admin( **The user should have the right to manage the event seller** """ - event = await cruds_tickets.get_event_by_id(event_id=event_id, db=db) + event = await cruds_tickets.get_event_complete_by_id(event_id=event_id, db=db) if event is None: raise HTTPException(404, "Event not found") @@ -288,58 +288,9 @@ async def get_event_admin( detail="User is not authorized to manage store events", ) - return schemas_tickets.EventAdmin( - id=event.id, - name=event.name, - store_id=event.store_id, - open_datetime=event.open_datetime, - close_datetime=event.close_datetime, - sessions=[ - schemas_tickets.SessionAdmin( - id=session.id, - event_id=session.event_id, - name=session.name, - start_datetime=session.start_datetime, - quota=session.quota, - tickets_in_checkout=await cruds_tickets.count_valid_checkouts_by_event_id( - event_id=event_id, - db=db, - ), - tickets_sold=await cruds_tickets.count_tickets_by_event_id( - event_id=event_id, - db=db, - ), - ) - for session in event.sessions - ], - categories=[ - schemas_tickets.CategoryAdmin( - id=category.id, - event_id=category.event_id, - name=category.name, - price=category.price, - required_membership=category.required_membership, - quota=category.quota, - tickets_in_checkout=await cruds_tickets.count_valid_checkouts_by_category_id( - category_id=category.id, - db=db, - ), - tickets_sold=await cruds_tickets.count_tickets_by_category_id( - category_id=category.id, - db=db, - ), - ) - for category in event.categories - ], - quota=event.quota, - tickets_in_checkout=await cruds_tickets.count_valid_checkouts_by_event_id( - event_id=event_id, - db=db, - ), - tickets_sold=await cruds_tickets.count_tickets_by_event_id( - event_id=event_id, - db=db, - ), + return await utils_tickets.convert_to_event_admin( + event=event, + db=db, ) @@ -378,10 +329,19 @@ async def create_event( db=db, ) - return await cruds_tickets.get_event_by_id( + event_complete = await cruds_tickets.get_event_complete_by_id( event_id=event_id, db=db, ) + if event_complete is None: + raise ObjectExpectedInDbNotFoundError( + object_name="Event", + object_id=event_id, + ) + return await utils_tickets.convert_to_event_admin( + event=event_complete, + db=db, + ) # router.patch( @@ -421,7 +381,7 @@ async def get_event_tickets( **The user should have the right to manage the event seller** """ - event = await cruds_tickets.get_event_by_id(event_id=event_id, db=db) + event = await cruds_tickets.get_event_simple_by_id(event_id=event_id, db=db) if event is None: raise HTTPException(404, "Event not found") @@ -455,7 +415,7 @@ async def get_event_tickets_csv( **The user should have the right to manage the event seller** """ - event = await cruds_tickets.get_event_by_id(event_id=event_id, db=db) + event = await cruds_tickets.get_event_simple_by_id(event_id=event_id, db=db) if event is None: raise HTTPException(404, "Event not found") @@ -547,7 +507,7 @@ async def check_ticket( if ticket is None: raise HTTPException(404, "Ticket not found") - event = await cruds_tickets.get_event_by_id(event_id=ticket.event_id, db=db) + 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", @@ -588,7 +548,7 @@ async def scan_ticket( if ticket is None: raise HTTPException(404, "Ticket not found") - event = await cruds_tickets.get_event_by_id(event_id=ticket.event_id, db=db) + 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", @@ -605,9 +565,13 @@ async def scan_ticket( detail="User is not authorized to manage store events", ) - await cruds_tickets.mark_ticket_as_scanned(ticket_id=ticket_id, db=db) + if ticket.scanned: + raise HTTPException( + status_code=400, + detail="Ticket is already scanned", + ) - return ticket + await cruds_tickets.mark_ticket_as_scanned(ticket_id=ticket_id, db=db) @router.get( diff --git a/app/core/tickets/schemas_tickets.py b/app/core/tickets/schemas_tickets.py index 0abaa51c38..df09956bbf 100644 --- a/app/core/tickets/schemas_tickets.py +++ b/app/core/tickets/schemas_tickets.py @@ -118,7 +118,7 @@ class EventCreate(BaseModel): class Ticket(BaseModel): id: UUID price: int - user_id: UUID + user_id: str event_id: UUID category_id: UUID diff --git a/app/core/tickets/utils_tickets.py b/app/core/tickets/utils_tickets.py index 4b7caf5eed..8ae988b4da 100644 --- a/app/core/tickets/utils_tickets.py +++ b/app/core/tickets/utils_tickets.py @@ -2,7 +2,7 @@ from sqlalchemy.ext.asyncio import AsyncSession -from app.core.tickets import cruds_tickets +from app.core.tickets import cruds_tickets, schemas_tickets async def is_event_sold_out( @@ -75,3 +75,62 @@ async def is_session_sold_out( ) return (nb_valid_checkout_for_session + nb_tickets_sold_for_session) >= quota + + +async def convert_to_event_admin( + event: schemas_tickets.EventComplete, + db: AsyncSession, +): + return schemas_tickets.EventAdmin( + id=event.id, + name=event.name, + store_id=event.store_id, + open_datetime=event.open_datetime, + close_datetime=event.close_datetime, + sessions=[ + schemas_tickets.SessionAdmin( + id=session.id, + event_id=session.event_id, + name=session.name, + start_datetime=session.start_datetime, + quota=session.quota, + tickets_in_checkout=await cruds_tickets.count_valid_checkouts_by_event_id( + event_id=event.id, + db=db, + ), + tickets_sold=await cruds_tickets.count_tickets_by_event_id( + event_id=event.id, + db=db, + ), + ) + for session in event.sessions + ], + categories=[ + schemas_tickets.CategoryAdmin( + id=category.id, + event_id=category.event_id, + name=category.name, + price=category.price, + required_membership=category.required_membership, + quota=category.quota, + tickets_in_checkout=await cruds_tickets.count_valid_checkouts_by_category_id( + category_id=category.id, + db=db, + ), + tickets_sold=await cruds_tickets.count_tickets_by_category_id( + category_id=category.id, + db=db, + ), + ) + for category in event.categories + ], + quota=event.quota, + tickets_in_checkout=await cruds_tickets.count_valid_checkouts_by_event_id( + event_id=event.id, + db=db, + ), + tickets_sold=await cruds_tickets.count_tickets_by_event_id( + event_id=event.id, + db=db, + ), + ) diff --git a/tests/core/test_tickets.py b/tests/core/test_tickets.py index 2dbb659f0e..9603107449 100644 --- a/tests/core/test_tickets.py +++ b/tests/core/test_tickets.py @@ -7,7 +7,7 @@ from app.core.associations.models_associations import CoreAssociation from app.core.groups.groups_type import GroupType from app.core.memberships import models_memberships -from app.core.mypayment.models_mypayment import Store, Structure, Wallet +from app.core.mypayment import models_mypayment from app.core.mypayment.types_mypayment import WalletType from app.core.tickets import models_tickets from app.core.tickets.endpoints_tickets import TicketsPermissions @@ -24,10 +24,13 @@ membership: models_memberships.CoreAssociationMembership structure_manager_user: models_users.CoreUser -structure: Structure -wallet: Wallet +structure: models_mypayment.Structure +wallet: models_mypayment.Wallet core_association: CoreAssociation -store: Store +store: models_mypayment.Store + +seller_can_manage_event_user: models_users.CoreUser +seller_can_manage_event_user_token: str global_event: models_tickets.TicketEvent @@ -39,7 +42,7 @@ sold_out_event: models_tickets.TicketEvent session_sold_out_event: models_tickets.EventSession category_sold_out_event: models_tickets.Category - +ticket_sold_out_event: models_tickets.Ticket ticket: models_tickets.Ticket @@ -68,7 +71,7 @@ async def init_objects() -> None: ) await add_object_to_db(membership) structure_manager_user = await create_user_with_groups(groups=[]) - structure = Structure( + structure = models_mypayment.Structure( id=uuid.uuid4(), short_id="test", name="Test Structure", @@ -84,9 +87,9 @@ async def init_objects() -> None: association_membership_id=membership.id, ) await add_object_to_db(structure) - wallet = Wallet( + wallet = models_mypayment.Wallet( id=uuid.uuid4(), - type=WalletType.STORE, + type=models_mypayment.WalletType.STORE, balance=0, ) await add_object_to_db(wallet) @@ -96,7 +99,7 @@ async def init_objects() -> None: group_id=GroupType.admin, ) await add_object_to_db(core_association) - store = Store( + store = models_mypayment.Store( id=uuid.uuid4(), name="Test Store", structure_id=structure.id, @@ -106,6 +109,22 @@ async def init_objects() -> None: ) await add_object_to_db(store) + global seller_can_manage_event_user, seller_can_manage_event_user_token + seller_can_manage_event_user = await create_user_with_groups(groups=[]) + seller_can_manage_event_user_token = create_api_access_token( + seller_can_manage_event_user, + ) + seller = models_mypayment.Seller( + store_id=store.id, + user_id=seller_can_manage_event_user.id, + can_bank=False, + can_see_history=False, + can_cancel=False, + can_manage_sellers=False, + can_manage_events=True, + ) + await add_object_to_db(seller) + global global_event, event_session, event_category ticket_event_id = uuid.uuid4() event_session = models_tickets.EventSession( @@ -174,7 +193,11 @@ async def init_objects() -> None: ) await add_object_to_db(ticket_sold_out_session) - global sold_out_event, session_sold_out_event, category_sold_out_event + global \ + sold_out_event, \ + session_sold_out_event, \ + category_sold_out_event, \ + ticket_sold_out_event ticket_sold_out_event_id = uuid.uuid4() session_sold_out_event = models_tickets.EventSession( id=uuid.uuid4(), @@ -359,3 +382,247 @@ def test_get_user_tickets(client: TestClient): ) assert response.status_code == 200 assert len(response.json()) > 1 + + +# get_event_admin + + +def test_get_event_admin_invalid_event_id(client: TestClient): + response = client.get( + f"/tickets/admin/events/{uuid.uuid4()}", + headers={"Authorization": f"Bearer {seller_can_manage_event_user_token}"}, + ) + assert response.status_code == 404 + assert response.json()["detail"] == "Event not found" + + +def test_get_event_admin_as_non_authorised_seller(client: TestClient): + response = client.get( + f"/tickets/admin/events/{global_event.id}", + headers={"Authorization": f"Bearer {user_token}"}, + ) + assert response.status_code == 403 + assert response.json()["detail"] == "User is not authorized to manage store events" + + +def test_get_event_admin(client: TestClient): + response = client.get( + f"/tickets/admin/events/{sold_out_event.id}", + headers={"Authorization": f"Bearer {seller_can_manage_event_user_token}"}, + ) + assert response.status_code == 200 + event = response.json() + assert event["id"] == str(sold_out_event.id) + assert len(event["sessions"]) > 0 + assert len(event["categories"]) > 0 + assert event["tickets_sold"] == 1 + assert event["tickets_in_checkout"] == 0 + + +# create_event + + +def test_create_event_as_non_authorised_seller(client: TestClient): + response = client.post( + "/tickets/admin/events/", + headers={"Authorization": f"Bearer {user_token}"}, + json={ + "store_id": str(store.id), + "name": "Test Event", + "open_datetime": (datetime.now(tz=UTC) + timedelta(days=1)).isoformat(), + "close_datetime": (datetime.now(tz=UTC) + timedelta(days=2)).isoformat(), + "quota": 10, + "sessions": [], + "categories": [], + }, + ) + assert response.status_code == 403 + assert response.json()["detail"] == "User is not authorized to manage store events" + + +def test_create_event(client: TestClient): + response = client.post( + "/tickets/admin/events/", + headers={"Authorization": f"Bearer {seller_can_manage_event_user_token}"}, + json={ + "store_id": str(store.id), + "name": "Test Event", + "open_datetime": (datetime.now(tz=UTC) + timedelta(days=1)).isoformat(), + "close_datetime": (datetime.now(tz=UTC) + timedelta(days=2)).isoformat(), + "quota": 11, + "sessions": [ + { + "name": "Test Session", + "start_datetime": ( + datetime.now(tz=UTC) + timedelta(days=1) + ).isoformat(), + "quota": 10, + }, + ], + "categories": [ + { + "name": "Test Category", + "price": 1000, + "quota": 10, + "required_membership": None, + }, + ], + }, + ) + assert response.status_code == 201 + event = response.json() + assert len(event["sessions"]) == 1 + assert len(event["categories"]) == 1 + assert event["quota"] == 11 + assert event["tickets_sold"] == 0 + assert event["tickets_in_checkout"] == 0 + + +# get_event_tickets + + +def test_get_event_tickets_with_invalid_event_id(client: TestClient): + response = client.get( + f"/tickets/admin/events/{uuid.uuid4()}/tickets", + headers={"Authorization": f"Bearer {seller_can_manage_event_user_token}"}, + ) + assert response.status_code == 404 + assert response.json()["detail"] == "Event not found" + + +def test_get_event_tickets_as_non_authorised_seller(client: TestClient): + response = client.get( + f"/tickets/admin/events/{global_event.id}/tickets", + headers={"Authorization": f"Bearer {user_token}"}, + ) + assert response.status_code == 403 + assert response.json()["detail"] == "User is not authorized to manage store events" + + +def test_get_event_tickets(client: TestClient): + response = client.get( + f"/tickets/admin/events/{global_event.id}/tickets", + headers={"Authorization": f"Bearer {seller_can_manage_event_user_token}"}, + ) + assert response.status_code == 200 + tickets = response.json() + assert len(tickets) > 0 + assert tickets[0]["event_id"] == str(global_event.id) + + +# get_event_tickets_csv + + +def test_get_event_tickets_csv_with_invalid_event_id(client: TestClient): + response = client.get( + f"/tickets/admin/events/{uuid.uuid4()}/tickets/csv", + headers={"Authorization": f"Bearer {seller_can_manage_event_user_token}"}, + ) + assert response.status_code == 404 + assert response.json()["detail"] == "Event not found" + + +def test_get_event_tickets_csv_as_non_authorised_seller(client: TestClient): + response = client.get( + f"/tickets/admin/events/{global_event.id}/tickets/csv", + headers={"Authorization": f"Bearer {user_token}"}, + ) + assert response.status_code == 403 + assert response.json()["detail"] == "User is not authorized to manage store events" + + +def test_get_event_tickets_csv(client: TestClient): + response = client.get( + f"/tickets/admin/events/{global_event.id}/tickets/csv", + headers={"Authorization": f"Bearer {seller_can_manage_event_user_token}"}, + ) + assert response.status_code == 200 + + +# check_ticket + + +def test_check_ticket_with_invalid_ticket_id(client: TestClient): + response = client.post( + f"/tickets/admin/tickets/{uuid.uuid4()}/check", + headers={"Authorization": f"Bearer {seller_can_manage_event_user_token}"}, + ) + assert response.status_code == 404 + assert response.json()["detail"] == "Ticket not found" + + +def test_check_ticket_as_non_authorised_seller(client: TestClient): + response = client.post( + f"/tickets/admin/tickets/{ticket_sold_out_event.id}/check", + headers={"Authorization": f"Bearer {user_token}"}, + ) + assert response.status_code == 403 + assert response.json()["detail"] == "User is not authorized to manage store events" + + +def test_check_ticket(client: TestClient): + response = client.post( + f"/tickets/admin/tickets/{ticket_sold_out_event.id}/check", + headers={"Authorization": f"Bearer {seller_can_manage_event_user_token}"}, + ) + assert response.status_code == 200 + checked_ticket = response.json() + assert checked_ticket["id"] == str(ticket_sold_out_event.id) + assert checked_ticket["scanned"] is False + + +# scan_ticket + + +def test_scan_ticket_with_invalid_ticket_id(client: TestClient): + response = client.post( + f"/tickets/admin/tickets/{uuid.uuid4()}/scan", + headers={"Authorization": f"Bearer {seller_can_manage_event_user_token}"}, + ) + assert response.status_code == 404 + assert response.json()["detail"] == "Ticket not found" + + +def test_scan_ticket_as_non_authorised_seller(client: TestClient): + response = client.post( + f"/tickets/admin/tickets/{ticket_sold_out_event.id}/scan", + headers={"Authorization": f"Bearer {user_token}"}, + ) + assert response.status_code == 403 + assert response.json()["detail"] == "User is not authorized to manage store events" + + +def test_scan_ticket(client: TestClient): + response = client.post( + f"/tickets/admin/tickets/{ticket_sold_out_event.id}/scan", + headers={"Authorization": f"Bearer {seller_can_manage_event_user_token}"}, + ) + assert response.status_code == 204 + + response = client.post( + f"/tickets/admin/tickets/{ticket_sold_out_event.id}/scan", + headers={"Authorization": f"Bearer {seller_can_manage_event_user_token}"}, + ) + assert response.status_code == 400 + assert response.json()["detail"] == "Ticket is already scanned" + + +# get_events_by_association + + +def test_get_events_by_association_as_non_authorised_seller(client: TestClient): + response = client.get( + f"/tickets/admin/association/{core_association.id}/events", + headers={"Authorization": f"Bearer {user_token}"}, + ) + assert response.status_code == 403 + assert response.json()["detail"] == "User is not authorized to manage store events" + + +def test_get_events_by_association(client: TestClient): + response = client.get( + f"/tickets/admin/association/{core_association.id}/events", + headers={"Authorization": f"Bearer {seller_can_manage_event_user_token}"}, + ) + assert response.status_code == 200 + assert len(response.json()) > 1 From dd7429a392701c1554a2500b6d80a34f32e33667 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:37:02 +0200 Subject: [PATCH 48/72] /tickets/admin/store/{store_id}/events --- app/core/mypayment/cruds_mypayment.py | 12 ++++++++ app/core/tickets/endpoints_tickets.py | 40 +++++++++++++++++---------- app/core/tickets/utils_tickets.py | 34 +++++++++++++++++++---- tests/core/test_tickets.py | 13 ++++++++- 4 files changed, 79 insertions(+), 20 deletions(-) diff --git a/app/core/mypayment/cruds_mypayment.py b/app/core/mypayment/cruds_mypayment.py index e69415a630..ad44cd4fa5 100644 --- a/app/core/mypayment/cruds_mypayment.py +++ b/app/core/mypayment/cruds_mypayment.py @@ -210,6 +210,18 @@ async def get_store_by_association_id( return result.scalars().first() +async def get_store_by_id( + store_id: UUID, + db: AsyncSession, +) -> models_mypayment.Store | None: + result = await db.execute( + select(models_mypayment.Store).where( + models_mypayment.Store.id == store_id, + ), + ) + return result.scalars().first() + + async def get_stores_by_structure_id( db: AsyncSession, structure_id: UUID, diff --git a/app/core/tickets/endpoints_tickets.py b/app/core/tickets/endpoints_tickets.py index 42079d2308..46d2d5e531 100644 --- a/app/core/tickets/endpoints_tickets.py +++ b/app/core/tickets/endpoints_tickets.py @@ -574,6 +574,30 @@ async def scan_ticket( await cruds_tickets.mark_ticket_as_scanned(ticket_id=ticket_id, db=db) +@router.get( + "/tickets/admin/store/{store_id}/events", + response_model=list[schemas_tickets.EventSimple], + status_code=200, +) +async def get_events_by_store( + store_id: UUID, + user: CoreUser = Depends( + is_user(), + ), + db: AsyncSession = Depends(get_db), +): + store = await cruds_mypayment.get_store_by_id( + store_id=store_id, + db=db, + ) + + return await utils_tickets.get_events_from_store( + store=store, + user_id=user.id, + db=db, + ) + + @router.get( "/tickets/admin/association/{association_id}/events", response_model=list[schemas_tickets.EventSimple], @@ -595,21 +619,9 @@ async def get_events_by_association( association_id=association_id, db=db, ) - # TODO: maybe return an empty list - if store is None: - raise HTTPException(400, "No seller associated with this association") - if not await utils_mypayment.can_user_manage_events( + return await utils_tickets.get_events_from_store( + store=store, user_id=user.id, - store_id=store.id, - db=db, - ): - raise HTTPException( - status_code=403, - detail="User is not authorized to manage store events", - ) - - return await cruds_tickets.get_events_by_store_id( - store_id=store.id, db=db, ) diff --git a/app/core/tickets/utils_tickets.py b/app/core/tickets/utils_tickets.py index 8ae988b4da..cb2695f90f 100644 --- a/app/core/tickets/utils_tickets.py +++ b/app/core/tickets/utils_tickets.py @@ -1,7 +1,11 @@ from uuid import UUID +from fastapi import ( + HTTPException, +) from sqlalchemy.ext.asyncio import AsyncSession +from app.core.mypayment import utils_mypayment from app.core.tickets import cruds_tickets, schemas_tickets @@ -23,11 +27,6 @@ async def is_event_sold_out( db=db, ) - tickets = await cruds_tickets.get_tickets_by_event_id( - event_id=event_id, - db=db, - ) - return (nb_valid_checkout_for_event + nb_tickets_sold_for_event) >= quota @@ -134,3 +133,28 @@ async def convert_to_event_admin( db=db, ), ) + + +async def get_events_from_store( + store: schemas_tickets.Store | None, + user_id: str, + db: AsyncSession, +): + # TODO: maybe return an empty list + if store is None: + raise HTTPException(400, "No seller associated with this association") + + if not await utils_mypayment.can_user_manage_events( + user_id=user_id, + store_id=store.id, + db=db, + ): + raise HTTPException( + status_code=403, + detail="User is not authorized to manage store events", + ) + + return await cruds_tickets.get_events_by_store_id( + store_id=store.id, + db=db, + ) diff --git a/tests/core/test_tickets.py b/tests/core/test_tickets.py index 9603107449..84abc2fbe6 100644 --- a/tests/core/test_tickets.py +++ b/tests/core/test_tickets.py @@ -8,7 +8,6 @@ from app.core.groups.groups_type import GroupType from app.core.memberships import models_memberships from app.core.mypayment import models_mypayment -from app.core.mypayment.types_mypayment import WalletType from app.core.tickets import models_tickets from app.core.tickets.endpoints_tickets import TicketsPermissions from app.core.users import models_users @@ -607,6 +606,18 @@ def test_scan_ticket(client: TestClient): assert response.json()["detail"] == "Ticket is already scanned" +# get_events_by_store + + +def test_get_events_by_store(client: TestClient): + response = client.get( + f"/tickets/admin/store/{store.id}/events", + headers={"Authorization": f"Bearer {seller_can_manage_event_user_token}"}, + ) + assert response.status_code == 200 + assert len(response.json()) > 1 + + # get_events_by_association From ce101e1a749a225b87f5ae155c1d9bc019edf5c6 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 6 Apr 2026 16:06:56 +0200 Subject: [PATCH 49/72] Questions --- app/core/tickets/cruds_tickets.py | 59 ++++++ app/core/tickets/endpoints_tickets.py | 65 +++++- app/core/tickets/models_tickets.py | 30 +++ app/core/tickets/schemas_tickets.py | 59 ++++++ app/core/tickets/types_tickets.py | 7 + app/core/tickets/utils_tickets.py | 12 ++ tests/core/test_tickets.py | 278 +++++++++++++++++++++++++- 7 files changed, 505 insertions(+), 5 deletions(-) create mode 100644 app/core/tickets/types_tickets.py diff --git a/app/core/tickets/cruds_tickets.py b/app/core/tickets/cruds_tickets.py index 373f103547..6ffea888d4 100644 --- a/app/core/tickets/cruds_tickets.py +++ b/app/core/tickets/cruds_tickets.py @@ -81,6 +81,7 @@ async def get_event_complete_by_id( .options( selectinload(models_tickets.TicketEvent.sessions), selectinload(models_tickets.TicketEvent.categories), + selectinload(models_tickets.TicketEvent.questions), ), ) @@ -116,6 +117,18 @@ async def get_event_complete_by_id( ) for category in event.categories ], + questions=[ + schemas_tickets.Question( + id=question.id, + event_id=question.event_id, + question=question.question, + answer_type=question.answer_type, + price=question.price, + required=question.required, + disabled=question.disabled, + ) + for question in event.questions + ], ) @@ -188,6 +201,30 @@ async def acquire_event_lock_for_update( ) +async def get_questions_by_event_id( + event_id: UUID, + db: AsyncSession, +) -> Sequence[schemas_tickets.Question]: + result = await db.execute( + select(models_tickets.Question).where( + models_tickets.Question.event_id == event_id, + ), + ) + + return [ + schemas_tickets.Question( + id=question.id, + event_id=question.event_id, + question=question.question, + answer_type=question.answer_type, + price=question.price, + required=question.required, + disabled=question.disabled, + ) + for question in result.scalars().all() + ] + + async def create_event( event_id: UUID, event: schemas_tickets.EventCreate, @@ -221,6 +258,18 @@ async def create_event( ) for category in event.categories ], + questions=[ + models_tickets.Question( + id=uuid.uuid4(), + event_id=event_id, + question=question.question, + answer_type=question.answer_type, + price=question.price, + required=question.required, + disabled=False, + ) + for question in event.questions + ], ) db.add(db_event) @@ -284,6 +333,7 @@ async def create_checkout( session_id: UUID, price: int, expiration: datetime, + answers: list[schemas_tickets.Answer], db: AsyncSession, ): db_checkout = models_tickets.Checkout( @@ -294,6 +344,15 @@ async def create_checkout( session_id=session_id, price=price, expiration=expiration, + answers=[ + models_tickets.Answer( + id=uuid.uuid4(), + question_id=answer.question_id, + checkout_id=checkout_id, + answer=answer.answer_value, + ) + for answer in answers + ], # TODO: add answers ) db.add(db_checkout) diff --git a/app/core/tickets/endpoints_tickets.py b/app/core/tickets/endpoints_tickets.py index 46d2d5e531..ee83414675 100644 --- a/app/core/tickets/endpoints_tickets.py +++ b/app/core/tickets/endpoints_tickets.py @@ -19,6 +19,8 @@ from app.core.permissions.type_permissions import ModulePermissions from app.core.tickets import cruds_tickets, schemas_tickets, utils_tickets from app.core.tickets.factory_tickets import TicketsFactory +from app.core.tickets.models_tickets import Answer +from app.core.tickets.types_tickets import AnswerType from app.core.users.models_users import CoreUser from app.dependencies import ( get_db, @@ -122,6 +124,18 @@ async def get_event( ) for category in event.categories ], + questions=[ + schemas_tickets.QuestionPublic( + id=question.id, + event_id=question.event_id, + question=question.question, + answer_type=question.answer_type, + price=question.price, + required=question.required, + disabled=question.disabled, + ) + for question in event.questions + ], sold_out=await utils_tickets.is_event_sold_out( event_id=event.id, quota=event.quota, @@ -180,6 +194,54 @@ async def create_checkout( "User does not have required membership to choose this category", ) + price = 0 + + questions = await cruds_tickets.get_questions_by_event_id( + event_id=event_id, + db=db, + ) + questions_dict = {question.id: question for question in questions} + required_questions_ids = { + question.id for question in questions if question.required + } + answered_questions_ids = set() + + for answer in checkout.answers: + if answer.question_id in answered_questions_ids: + raise HTTPException( + 400, + f"Question with id {answer.question_id} is answered multiple times", + ) + answered_questions_ids.add(answer.question_id) + required_questions_ids.discard(answer.question_id) + + question = questions_dict.get(answer.question_id) + if question is None: + raise HTTPException( + 400, + f"Question with id {answer.question_id} not found for this event", + ) + if question.disabled: + raise HTTPException( + 400, + f"Question with id {answer.question_id} is disabled", + ) + + if question.answer_type != answer.answer_type: + raise HTTPException( + 400, + f"Answer type for question with id {answer.question_id} should be {question.answer_type.value}", + ) + + if question.price is not None: + price += question.price + + if len(required_questions_ids) > 0: + raise HTTPException( + 400, + f"Answers for questions {', '.join(str(q) for q in required_questions_ids)} are required", + ) + # By putting this lock: # - we unsure that if an other endpoint execution acquired the lock before, this one will wait. # - we guarantee that any other endpoint execution that tries to acquire the lock will need to wait until the end of this transaction. @@ -195,7 +257,7 @@ async def create_checkout( object_id=event_id, ) - price = category.price + price += category.price expiration = datetime.now(UTC) + timedelta(minutes=CHECKOUT_EXPIRATION_MINUTES) if await utils_tickets.is_event_sold_out( @@ -225,6 +287,7 @@ async def create_checkout( session_id=checkout.session_id, expiration=expiration, price=price, + answers=checkout.answers, db=db, ) diff --git a/app/core/tickets/models_tickets.py b/app/core/tickets/models_tickets.py index e4a0fe9665..a11b654464 100644 --- a/app/core/tickets/models_tickets.py +++ b/app/core/tickets/models_tickets.py @@ -5,6 +5,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from app.core.mypayment import models_mypayment +from app.core.tickets.types_tickets import AnswerType from app.core.users import models_users from app.types.sqlalchemy import Base, PrimaryKey @@ -29,6 +30,7 @@ class TicketEvent(Base): sessions: Mapped[list["EventSession"]] = relationship(back_populates="event") categories: Mapped[list["Category"]] = relationship(back_populates="event") + questions: Mapped[list["Question"]] = relationship() class EventSession(Base): @@ -64,6 +66,32 @@ class Category(Base): event: Mapped["TicketEvent"] = relationship(back_populates="categories", init=False) +class Question(Base): + __tablename__ = "tickets_question" + + id: Mapped[PrimaryKey] + event_id: Mapped[UUID] = mapped_column(ForeignKey("tickets_event.id")) + + question: Mapped[str] + answer_type: Mapped[AnswerType] + price: Mapped[int | None] # in cents + + required: Mapped[bool] + + disabled: Mapped[bool] + + +class Answer(Base): + __tablename__ = "tickets_answer" + + id: Mapped[PrimaryKey] + + question_id: Mapped[UUID] = mapped_column(ForeignKey("tickets_question.id")) + checkout_id: Mapped[UUID] = mapped_column(ForeignKey("tickets_checkout.id")) + + answer: Mapped[str] + + class Ticket(Base): __tablename__ = "tickets_ticket" @@ -99,5 +127,7 @@ class Checkout(Base): user_id: Mapped[str] = mapped_column(ForeignKey("core_user.id")) + answers: Mapped[list[Answer]] = relationship() + # Do we need this? user: Mapped[models_users.CoreUser] = relationship(init=False) diff --git a/app/core/tickets/schemas_tickets.py b/app/core/tickets/schemas_tickets.py index df09956bbf..8b6ef75aca 100644 --- a/app/core/tickets/schemas_tickets.py +++ b/app/core/tickets/schemas_tickets.py @@ -1,11 +1,15 @@ from datetime import datetime +from typing import Literal from uuid import UUID +from fastapi.encoders import jsonable_encoder from pydantic import ( BaseModel, ) +from app.core.tickets.types_tickets import AnswerType from app.core.users import schemas_users +from app.types.sqlalchemy import Base class Session(BaseModel): @@ -71,6 +75,31 @@ class CategoryCreate(BaseModel): required_membership: UUID | None +class Question(BaseModel): + id: UUID + event_id: UUID + question: str + answer_type: AnswerType + price: int | None + required: bool + disabled: bool + + +class QuestionPublic(Question): + pass + + +class QuestionAdmin(Question): + pass + + +class QuestionCreate(BaseModel): + question: str + answer_type: AnswerType + price: int | None + required: bool + + class EventSimple(BaseModel): id: UUID name: str @@ -88,11 +117,13 @@ class EventWithoutSessionsAndCategories(EventSimple): class EventComplete(EventWithoutSessionsAndCategories): sessions: list[SessionComplete] categories: list[CategoryComplete] + questions: list[Question] class EventPublic(EventSimple): sessions: list[SessionPublic] categories: list[CategoryPublic] + questions: list[QuestionPublic] sold_out: bool @@ -100,6 +131,7 @@ class EventPublic(EventSimple): class EventAdmin(EventWithoutSessionsAndCategories): sessions: list[SessionAdmin] categories: list[CategoryAdmin] + questions: list[QuestionAdmin] tickets_in_checkout: int tickets_sold: int @@ -113,6 +145,7 @@ class EventCreate(BaseModel): close_datetime: datetime | None sessions: list[SessionCreate] categories: list[CategoryCreate] + questions: list[QuestionCreate] class Ticket(BaseModel): @@ -131,9 +164,35 @@ class Ticket(BaseModel): user: schemas_users.CoreUserSimple +class Answer(BaseModel): + question_id: UUID + answer_type: AnswerType + answer: str | int | bool + + @property + def answer_value(self) -> str: + return str(self.answer) + + +class AnswerText(BaseModel): + answer_type: Literal[AnswerType.TEXT] + answer: str + + +class AnswerNumber(BaseModel): + answer_type: Literal[AnswerType.NUMBER] + answer: int + + +class AnswerBoolean(BaseModel): + answer_type: Literal[AnswerType.BOOLEAN] + answer: bool + + class Checkout(BaseModel): category_id: UUID session_id: UUID + answers: list[Answer] class CheckoutResponse(BaseModel): diff --git a/app/core/tickets/types_tickets.py b/app/core/tickets/types_tickets.py new file mode 100644 index 0000000000..30bcca15ed --- /dev/null +++ b/app/core/tickets/types_tickets.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class AnswerType(Enum): + TEXT = "text" + NUMBER = "number" + BOOLEAN = "boolean" diff --git a/app/core/tickets/utils_tickets.py b/app/core/tickets/utils_tickets.py index cb2695f90f..1b425a965a 100644 --- a/app/core/tickets/utils_tickets.py +++ b/app/core/tickets/utils_tickets.py @@ -123,6 +123,18 @@ async def convert_to_event_admin( ) for category in event.categories ], + questions=[ + schemas_tickets.QuestionAdmin( + id=question.id, + event_id=question.event_id, + question=question.question, + answer_type=question.answer_type, + price=question.price, + required=question.required, + disabled=question.disabled, + ) + for question in event.questions + ], quota=event.quota, tickets_in_checkout=await cruds_tickets.count_valid_checkouts_by_event_id( event_id=event.id, diff --git a/tests/core/test_tickets.py b/tests/core/test_tickets.py index 84abc2fbe6..764af8a688 100644 --- a/tests/core/test_tickets.py +++ b/tests/core/test_tickets.py @@ -10,6 +10,7 @@ from app.core.mypayment import models_mypayment from app.core.tickets import models_tickets from app.core.tickets.endpoints_tickets import TicketsPermissions +from app.core.tickets.types_tickets import AnswerType from app.core.users import models_users from tests.commons import ( add_object_to_db, @@ -37,6 +38,9 @@ event_category: models_tickets.Category event_sold_out_category: models_tickets.Category event_sold_out_session: models_tickets.EventSession +global_event_optionnal_question_id: uuid.UUID +global_event_disabled_question_id: uuid.UUID + sold_out_event: models_tickets.TicketEvent session_sold_out_event: models_tickets.EventSession @@ -124,7 +128,13 @@ async def init_objects() -> None: ) await add_object_to_db(seller) - global global_event, event_session, event_category + global \ + global_event, \ + event_session, \ + event_category, \ + global_event_optionnal_question_id, \ + global_event_disabled_question_id + ticket_event_id = uuid.uuid4() event_session = models_tickets.EventSession( id=uuid.uuid4(), @@ -141,6 +151,8 @@ async def init_objects() -> None: price=1000, required_membership=None, ) + global_event_optionnal_question_id = uuid.uuid4() + global_event_disabled_question_id = uuid.uuid4() global_event = models_tickets.TicketEvent( id=uuid.uuid4(), store_id=store.id, @@ -150,6 +162,26 @@ async def init_objects() -> None: quota=10, sessions=[event_session], categories=[event_category], + questions=[ + models_tickets.Question( + id=global_event_optionnal_question_id, + event_id=ticket_event_id, + question="Test Question", + required=False, + answer_type=AnswerType.TEXT, + price=100, + disabled=False, + ), + models_tickets.Question( + id=global_event_disabled_question_id, + event_id=ticket_event_id, + question="Test Disabled Question", + required=False, + answer_type=AnswerType.TEXT, + price=100, + disabled=True, + ), + ], ) await add_object_to_db(global_event) @@ -222,6 +254,7 @@ async def init_objects() -> None: quota=1, sessions=[session_sold_out_event], categories=[category_sold_out_event], + questions=[], ) await add_object_to_db(sold_out_event) user = await create_user_with_groups(groups=[]) @@ -287,6 +320,7 @@ def test_create_checkout_with_invalid_category(client: TestClient): json={ "category_id": str(uuid.uuid4()), "session_id": str(session_sold_out_event.id), + "answers": [], }, ) assert response.status_code == 404 @@ -300,6 +334,7 @@ def test_create_checkout_with_invalid_session(client: TestClient): json={ "category_id": str(category_sold_out_event.id), "session_id": str(uuid.uuid4()), + "answers": [], }, ) assert response.status_code == 404 @@ -313,6 +348,7 @@ def test_create_checkout_with_category_from_another_event(client: TestClient): json={ "category_id": str(event_category.id), "session_id": str(session_sold_out_event.id), + "answers": [], }, ) assert response.status_code == 400 @@ -326,15 +362,13 @@ def test_create_checkout_with_session_from_another_event(client: TestClient): json={ "category_id": str(category_sold_out_event.id), "session_id": str(event_session.id), + "answers": [], }, ) assert response.status_code == 400 assert response.json()["detail"] == "Session does not belong to the event" -# TODO: test required membership - - def test_create_checkout_with_sold_out_event(client: TestClient): response = client.post( f"/tickets/events/{sold_out_event.id}/checkout", @@ -342,6 +376,7 @@ def test_create_checkout_with_sold_out_event(client: TestClient): json={ "category_id": str(category_sold_out_event.id), "session_id": str(session_sold_out_event.id), + "answers": [], }, ) assert response.status_code == 400 @@ -355,6 +390,7 @@ def test_create_checkout_with_sold_out_category(client: TestClient): json={ "category_id": str(event_sold_out_category.id), "session_id": str(event_session.id), + "answers": [], }, ) assert response.status_code == 400 @@ -368,6 +404,230 @@ def test_create_checkout_with_sold_out_session(client: TestClient): json={ "category_id": str(event_category.id), "session_id": str(event_sold_out_session.id), + "answers": [], + }, + ) + assert response.status_code == 400 + assert response.json()["detail"] == "Session is sold out" + + +async def test_create_checkout_with_missing_membership(client: TestClient): + event_with_required_membership_session_id = uuid.uuid4() + event_with_required_membership_category_id = uuid.uuid4() + event_with_required_membership_id = uuid.uuid4() + event_with_required_membership = models_tickets.TicketEvent( + id=event_with_required_membership_id, + store_id=store.id, + name="Test Event with Required Membership", + open_datetime=datetime.now(tz=UTC) - timedelta(days=1), + close_datetime=datetime.now(tz=UTC) + timedelta(days=1), + quota=10, + sessions=[ + models_tickets.EventSession( + id=event_with_required_membership_session_id, + event_id=event_with_required_membership_id, + name="Test Session", + start_datetime=datetime.now(tz=UTC) - timedelta(days=1), + quota=None, + ), + ], + categories=[ + models_tickets.Category( + id=event_with_required_membership_category_id, + event_id=event_with_required_membership_id, + name="Test Category", + quota=None, + price=1000, + required_membership=membership.id, + ), + ], + questions=[], + ) + await add_object_to_db(event_with_required_membership) + response = client.post( + f"/tickets/events/{event_with_required_membership_id}/checkout", + headers={"Authorization": f"Bearer {user_token}"}, + json={ + "category_id": str(event_with_required_membership_category_id), + "session_id": str(event_with_required_membership_session_id), + "answers": [], + }, + ) + assert response.status_code == 400 + assert ( + response.json()["detail"] + == "User does not have required membership to choose this category" + ) + + +def test_create_checkout_with_answer_present_multiple_times(client: TestClient): + response = client.post( + f"/tickets/events/{global_event.id}/checkout", + headers={"Authorization": f"Bearer {user_token}"}, + json={ + "category_id": str(event_category.id), + "session_id": str(event_sold_out_session.id), + "answers": [ + { + "question_id": str(global_event_optionnal_question_id), + "answer_type": "text", + "answer": "Test Answer", + }, + { + "question_id": str(global_event_optionnal_question_id), + "answer_type": "text", + "answer": "Test Answer 2", + }, + ], + }, + ) + assert response.status_code == 400 + assert ( + response.json()["detail"] + == f"Question with id {global_event_optionnal_question_id} is answered multiple times" + ) + + +def test_create_checkout_with_invalid_question_id(client: TestClient): + invalid_id = uuid.uuid4() + + response = client.post( + f"/tickets/events/{global_event.id}/checkout", + headers={"Authorization": f"Bearer {user_token}"}, + json={ + "category_id": str(event_category.id), + "session_id": str(event_sold_out_session.id), + "answers": [ + { + "question_id": str(invalid_id), + "answer_type": "text", + "answer": "Test Answer", + }, + ], + }, + ) + assert response.status_code == 400 + assert ( + response.json()["detail"] + == f"Question with id {invalid_id} not found for this event" + ) + + +def test_create_checkout_with_disabled_question(client: TestClient): + response = client.post( + f"/tickets/events/{global_event.id}/checkout", + headers={"Authorization": f"Bearer {user_token}"}, + json={ + "category_id": str(event_category.id), + "session_id": str(event_sold_out_session.id), + "answers": [ + { + "question_id": str(global_event_disabled_question_id), + "answer_type": "text", + "answer": "Test Answer", + }, + ], + }, + ) + assert response.status_code == 400 + assert ( + response.json()["detail"] + == f"Question with id {global_event_disabled_question_id} is disabled" + ) + + +def test_create_checkout_with_invalid_answer_type(client: TestClient): + response = client.post( + f"/tickets/events/{global_event.id}/checkout", + headers={"Authorization": f"Bearer {user_token}"}, + json={ + "category_id": str(event_category.id), + "session_id": str(event_sold_out_session.id), + "answers": [ + { + "question_id": str(global_event_optionnal_question_id), + "answer_type": "number", + "answer": 3, + }, + ], + }, + ) + assert response.status_code == 400 + assert ( + response.json()["detail"] + == f"Answer type for question with id {global_event_optionnal_question_id} should be text" + ) + + +async def test_create_checkout_with_missing_required_question(client: TestClient): + event_with_required_question_session_id = uuid.uuid4() + event_with_required_question_category_id = uuid.uuid4() + event_with_required_question_id = uuid.uuid4() + question_id = uuid.uuid4() + + event_with_required_question = models_tickets.TicketEvent( + id=event_with_required_question_id, + store_id=store.id, + name="Test Event with Required question", + open_datetime=datetime.now(tz=UTC) - timedelta(days=1), + close_datetime=datetime.now(tz=UTC) + timedelta(days=1), + quota=10, + sessions=[ + models_tickets.EventSession( + id=event_with_required_question_session_id, + event_id=event_with_required_question_id, + name="Test Session", + start_datetime=datetime.now(tz=UTC) - timedelta(days=1), + quota=None, + ), + ], + categories=[ + models_tickets.Category( + id=event_with_required_question_category_id, + event_id=event_with_required_question_id, + name="Test Category", + quota=None, + price=1000, + required_membership=None, + ), + ], + questions=[ + models_tickets.Question( + id=question_id, + event_id=event_with_required_question_id, + question="Test Question", + answer_type=AnswerType.TEXT, + price=None, + required=True, + disabled=False, + ), + ], + ) + await add_object_to_db(event_with_required_question) + + response = client.post( + f"/tickets/events/{event_with_required_question_id}/checkout", + headers={"Authorization": f"Bearer {user_token}"}, + json={ + "category_id": str(event_with_required_question_category_id), + "session_id": str(event_with_required_question_session_id), + "answers": [], + }, + ) + assert response.status_code == 400 + assert ( + response.json()["detail"] == f"Answers for questions {question_id} are required" + ) + + +def test_create_checkout(client: TestClient): + response = client.post( + f"/tickets/events/{global_event.id}/checkout", + headers={"Authorization": f"Bearer {user_token}"}, + json={ + "category_id": str(event_category.id), + "session_id": str(event_sold_out_session.id), + "answers": [], }, ) assert response.status_code == 400 @@ -433,6 +693,7 @@ def test_create_event_as_non_authorised_seller(client: TestClient): "quota": 10, "sessions": [], "categories": [], + "questions": [], }, ) assert response.status_code == 403 @@ -466,6 +727,15 @@ def test_create_event(client: TestClient): "required_membership": None, }, ], + "questions": [ + { + "id": str(global_event_optionnal_question_id), + "question": "Test Question", + "required": False, + "answer_type": "text", + "price": 1000, + }, + ], }, ) assert response.status_code == 201 From 803ae6fab347f41467d3fc1513b8b3381c78370e Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 6 Apr 2026 16:19:08 +0200 Subject: [PATCH 50/72] Pass store_id to get_events_from_store Co-authored-by: Foucauld Bellanger <63885990+Foukki@users.noreply.github.com> --- app/core/tickets/endpoints_tickets.py | 12 ++++++++++-- app/core/tickets/utils_tickets.py | 14 ++++++-------- tests/core/test_tickets.py | 27 ++++++++++++++++++++++++++- 3 files changed, 42 insertions(+), 11 deletions(-) diff --git a/app/core/tickets/endpoints_tickets.py b/app/core/tickets/endpoints_tickets.py index ee83414675..087d151810 100644 --- a/app/core/tickets/endpoints_tickets.py +++ b/app/core/tickets/endpoints_tickets.py @@ -654,8 +654,12 @@ async def get_events_by_store( db=db, ) + # TODO: maybe return an empty list + if store is None: + raise HTTPException(404, "Store not found") + return await utils_tickets.get_events_from_store( - store=store, + store_id=store.id, user_id=user.id, db=db, ) @@ -683,8 +687,12 @@ async def get_events_by_association( db=db, ) + # TODO: maybe return an empty list + if store is None: + raise HTTPException(400, "No store associated with this association") + return await utils_tickets.get_events_from_store( - store=store, + store_id=store.id, user_id=user.id, db=db, ) diff --git a/app/core/tickets/utils_tickets.py b/app/core/tickets/utils_tickets.py index 1b425a965a..a9c8ce10c6 100644 --- a/app/core/tickets/utils_tickets.py +++ b/app/core/tickets/utils_tickets.py @@ -1,3 +1,5 @@ +import uuid +from typing import Sequence from uuid import UUID from fastapi import ( @@ -148,17 +150,13 @@ async def convert_to_event_admin( async def get_events_from_store( - store: schemas_tickets.Store | None, + store_id: uuid.UUID, user_id: str, db: AsyncSession, -): - # TODO: maybe return an empty list - if store is None: - raise HTTPException(400, "No seller associated with this association") - +) -> Sequence[schemas_tickets.EventSimple]: if not await utils_mypayment.can_user_manage_events( user_id=user_id, - store_id=store.id, + store_id=store_id, db=db, ): raise HTTPException( @@ -167,6 +165,6 @@ async def get_events_from_store( ) return await cruds_tickets.get_events_by_store_id( - store_id=store.id, + store_id=store_id, db=db, ) diff --git a/tests/core/test_tickets.py b/tests/core/test_tickets.py index 764af8a688..a0bd1b1522 100644 --- a/tests/core/test_tickets.py +++ b/tests/core/test_tickets.py @@ -8,6 +8,7 @@ from app.core.groups.groups_type import GroupType from app.core.memberships import models_memberships from app.core.mypayment import models_mypayment +from app.core.mypayment.types_mypayment import WalletType from app.core.tickets import models_tickets from app.core.tickets.endpoints_tickets import TicketsPermissions from app.core.tickets.types_tickets import AnswerType @@ -92,7 +93,7 @@ async def init_objects() -> None: await add_object_to_db(structure) wallet = models_mypayment.Wallet( id=uuid.uuid4(), - type=models_mypayment.WalletType.STORE, + type=WalletType.STORE, balance=0, ) await add_object_to_db(wallet) @@ -879,6 +880,15 @@ def test_scan_ticket(client: TestClient): # get_events_by_store +def test_get_events_by_store_with_invalid_store_id(client: TestClient): + response = client.get( + f"/tickets/admin/store/{uuid.uuid4()}/events", + headers={"Authorization": f"Bearer {seller_can_manage_event_user_token}"}, + ) + assert response.status_code == 404 + assert response.json()["detail"] == "Store not found" + + def test_get_events_by_store(client: TestClient): response = client.get( f"/tickets/admin/store/{store.id}/events", @@ -891,6 +901,21 @@ def test_get_events_by_store(client: TestClient): # get_events_by_association +async def test_get_events_by_association_with_no_store(client: TestClient): + core_association = CoreAssociation( + id=uuid.uuid4(), + name="Test Association", + group_id=GroupType.admin, + ) + await add_object_to_db(core_association) + response = client.get( + f"/tickets/admin/association/{core_association.id}/events", + headers={"Authorization": f"Bearer {user_token}"}, + ) + assert response.status_code == 400 + assert response.json()["detail"] == "No store associated with this association" + + def test_get_events_by_association_as_non_authorised_seller(client: TestClient): response = client.get( f"/tickets/admin/association/{core_association.id}/events", From 52894cff4562470586376ab3ded5ec82ef68f2fc Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 6 Apr 2026 16:21:33 +0200 Subject: [PATCH 51/72] Fix tests --- tests/core/test_tickets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/core/test_tickets.py b/tests/core/test_tickets.py index a0bd1b1522..cfe21d8c67 100644 --- a/tests/core/test_tickets.py +++ b/tests/core/test_tickets.py @@ -904,7 +904,7 @@ def test_get_events_by_store(client: TestClient): async def test_get_events_by_association_with_no_store(client: TestClient): core_association = CoreAssociation( id=uuid.uuid4(), - name="Test Association", + name="Test Association No Store", group_id=GroupType.admin, ) await add_object_to_db(core_association) From 4256bf2e7f0908b45b668b4ee1f5ae3ff13d8ad5 Mon Sep 17 00:00:00 2001 From: Foucauld Bellanger <63885990+Foukki@users.noreply.github.com> Date: Mon, 6 Apr 2026 11:14:49 +0200 Subject: [PATCH 52/72] feat : ticket in event # Conflicts: # app/core/tickets/endpoints_tickets.py # app/core/tickets/utils_tickets.py # migrations/versions/68-stores_coreassociations.py --- app/modules/calendar/endpoints_calendar.py | 36 +++++++++++++++++++--- app/modules/calendar/models_calendar.py | 6 ++++ app/modules/calendar/schemas_calendar.py | 10 +++++- app/modules/calendar/utils_calendar.py | 11 +++++-- 4 files changed, 56 insertions(+), 7 deletions(-) diff --git a/app/modules/calendar/endpoints_calendar.py b/app/modules/calendar/endpoints_calendar.py index d14800e2aa..b5513d09f4 100644 --- a/app/modules/calendar/endpoints_calendar.py +++ b/app/modules/calendar/endpoints_calendar.py @@ -24,6 +24,7 @@ is_user_allowed_to, is_user_in_association, ) +from app.core.tickets import cruds_tickets from app.modules.calendar import ( cruds_calendar, models_calendar, @@ -323,6 +324,20 @@ async def add_event( if settings.school.require_event_confirmation: decision = Decision.pending + # Gérer le cas où ticket_event_id est fourni + ticket_url_opening = event.ticket_url_opening + ticket_url = event.ticket_url + + if event.ticket_event_id: + # Récupérer le TicketEvent pour obtenir l'open_datetime + ticket_event = await cruds_tickets.get_event_simple_by_id( + event_id=event.ticket_event_id, + db=db, + ) + if ticket_event is None: + raise HTTPException(status_code=404, detail="Ticket event not found") + ticket_url_opening = ticket_event.open_datetime + db_event = models_calendar.Event( id=event_id, name=event.name, @@ -335,8 +350,9 @@ async def add_event( description=event.description, decision=decision, recurrence_rule=event.recurrence_rule, - ticket_url=event.ticket_url, - ticket_url_opening=event.ticket_url_opening, + ticket_url=ticket_url, + ticket_url_opening=ticket_url_opening, + ticket_event_id=event.ticket_event_id, notification=event.notification, ) @@ -349,15 +365,21 @@ async def add_event( raise NewlyAddedObjectInDbNotFoundError("event") if decision == Decision.approved: + # Déterminer les valeurs pour la feed news + feed_module = "tickets" if event.ticket_event_id else utils_calendar.root + feed_module_object_id = event.ticket_event_id if event.ticket_event_id else event_id + await utils_calendar.add_event_to_feed( event=created_event, db=db, notification_tool=notification_tool, + feed_module=feed_module, + feed_module_object_id=feed_module_object_id, ) if event.notification: ticket_date = ( - f", SG le {event.ticket_url_opening.strftime('%d/%m/%Y à %H:%M')}" - if event.ticket_url_opening + f", SG le {ticket_url_opening.strftime('%d/%m/%Y à %H:%M')}" + if ticket_url_opening else "" ) message = Message( @@ -501,10 +523,16 @@ async def confirm_event( ) if decision == Decision.approved: + # Déterminer les valeurs pour la feed news + feed_module = "tickets" if event.ticket_event_id else utils_calendar.root + feed_module_object_id = event.ticket_event_id if event.ticket_event_id else event.id + await utils_calendar.add_event_to_feed( event=event, db=db, notification_tool=notification_tool, + feed_module=feed_module, + feed_module_object_id=feed_module_object_id, ) if event.notification: association = await cruds_associations.get_association_by_id( diff --git a/app/modules/calendar/models_calendar.py b/app/modules/calendar/models_calendar.py index 8b673b8c55..5c95ce0d97 100644 --- a/app/modules/calendar/models_calendar.py +++ b/app/modules/calendar/models_calendar.py @@ -36,6 +36,12 @@ class Event(Base): ticket_url_opening: Mapped[datetime | None] notification: Mapped[bool] + # Référence à un TicketEvent optionnel (module tickets) + ticket_event_id: Mapped[UUID | None] = mapped_column( + ForeignKey("tickets_event.id"), + nullable=True, + ) + association: Mapped[CoreAssociation] = relationship("CoreAssociation", init=False) diff --git a/app/modules/calendar/schemas_calendar.py b/app/modules/calendar/schemas_calendar.py index 52b67fb892..94600771c2 100644 --- a/app/modules/calendar/schemas_calendar.py +++ b/app/modules/calendar/schemas_calendar.py @@ -26,13 +26,19 @@ class EventBase(BaseModel): class EventBaseCreation(EventBase): ticket_url: str | None = None + ticket_event_id: UUID | None = None @model_validator(mode="after") def check_ticket(self): + # Si ticket_event_id est fourni, on ignore les validations sur ticket_url + if self.ticket_event_id: + return self + + # Validation existante : ticket_url et ticket_url_opening doivent être fournis ensemble if (self.ticket_url_opening and not self.ticket_url) or ( self.ticket_url and not self.ticket_url_opening ): - raise ValueError + raise ValueError("ticket_url and ticket_url_opening must be provided together") return self @@ -45,6 +51,7 @@ class EventComplete(EventBase): class EventCompleteTicketUrl(EventComplete): ticket_url: str | None = None + ticket_event_id: UUID | None = None class EventTicketUrl(BaseModel): @@ -61,6 +68,7 @@ class EventEdit(BaseModel): recurrence_rule: str | None = None ticket_url_opening: datetime | None = None ticket_url: str | None = None + ticket_event_id: UUID | None = None notification: bool | None = None diff --git a/app/modules/calendar/utils_calendar.py b/app/modules/calendar/utils_calendar.py index 7a4b451c59..31e1f065f2 100644 --- a/app/modules/calendar/utils_calendar.py +++ b/app/modules/calendar/utils_calendar.py @@ -1,5 +1,6 @@ from collections.abc import Sequence from datetime import UTC, datetime +from uuid import UUID import aiofiles from icalendar import Calendar, Event, vRecur @@ -19,7 +20,13 @@ async def add_event_to_feed( event: models_calendar.Event, db: AsyncSession, notification_tool: NotificationTool, + feed_module: str | None = None, + feed_module_object_id: UUID | None = None, ): + # Utiliser les valeurs personnalisées si fournies, sinon utiliser les valeurs par défaut + module_value = feed_module if feed_module else root + module_object_id_value = feed_module_object_id if feed_module_object_id else event.id + await create_feed_news( title=event.name, start=event.start, @@ -27,8 +34,8 @@ async def add_event_to_feed( entity=event.association.name, location=event.location, action_start=event.ticket_url_opening, - module=root, - module_object_id=event.id, + module=module_value, + module_object_id=module_object_id_value, image_directory="event", image_id=event.id, require_feed_admin_approval=False, From d091a219b47c56597e2a98c0f98a3b4c4f4647b0 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 6 Apr 2026 16:27:28 +0200 Subject: [PATCH 53/72] fixup --- app/modules/calendar/factory_calendar.py | 2 ++ tests/modules/test_calendar.py | 3 +++ 2 files changed, 5 insertions(+) diff --git a/app/modules/calendar/factory_calendar.py b/app/modules/calendar/factory_calendar.py index c7cfba0f55..e9cbdea896 100644 --- a/app/modules/calendar/factory_calendar.py +++ b/app/modules/calendar/factory_calendar.py @@ -31,6 +31,7 @@ async def run(cls, db: AsyncSession, settings: Settings) -> None: ticket_url_opening=None, ticket_url=None, notification=False, + ticket_event_id=None, ) await cruds_calendar.add_event(db, event) @@ -49,6 +50,7 @@ async def run(cls, db: AsyncSession, settings: Settings) -> None: ticket_url_opening=None, ticket_url=None, notification=False, + ticket_event_id=None, ) await cruds_calendar.add_event(db, day_long_event) diff --git a/tests/modules/test_calendar.py b/tests/modules/test_calendar.py index dd897ffe1b..48325931bb 100644 --- a/tests/modules/test_calendar.py +++ b/tests/modules/test_calendar.py @@ -98,6 +98,7 @@ async def init_objects() -> None: + datetime.timedelta(days=6), ticket_url="url", notification=False, + ticket_event_id=None, ) await add_object_to_db(calendar_event) @@ -118,6 +119,7 @@ async def init_objects() -> None: - datetime.timedelta(days=6), ticket_url="url", notification=False, + ticket_event_id=None, ) await add_object_to_db(confirmed_calendar_event) @@ -137,6 +139,7 @@ async def init_objects() -> None: ticket_url_opening=None, ticket_url=None, notification=False, + ticket_event_id=None, ) await add_object_to_db(calendar_event_to_delete) From 43dbac465f11987aa28be50a05d393f386c143b7 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 6 Apr 2026 16:38:02 +0200 Subject: [PATCH 54/72] Lint and clean --- app/core/tickets/endpoints_tickets.py | 2 -- app/core/tickets/schemas_tickets.py | 2 -- app/core/tickets/utils_tickets.py | 2 +- app/modules/calendar/endpoints_calendar.py | 15 ++++++++------- app/modules/calendar/models_calendar.py | 2 -- app/modules/calendar/schemas_calendar.py | 11 ++++++++--- app/modules/calendar/utils_calendar.py | 7 ++++--- app/utils/loggers_tools/matrix_handler.py | 2 +- app/utils/loggers_tools/s3_handler.py | 2 +- migrations/versions/67-mypayment.py | 4 +--- migrations/versions/68-tickets.py | 2 +- 11 files changed, 25 insertions(+), 26 deletions(-) diff --git a/app/core/tickets/endpoints_tickets.py b/app/core/tickets/endpoints_tickets.py index 087d151810..bab812e48d 100644 --- a/app/core/tickets/endpoints_tickets.py +++ b/app/core/tickets/endpoints_tickets.py @@ -19,8 +19,6 @@ from app.core.permissions.type_permissions import ModulePermissions from app.core.tickets import cruds_tickets, schemas_tickets, utils_tickets from app.core.tickets.factory_tickets import TicketsFactory -from app.core.tickets.models_tickets import Answer -from app.core.tickets.types_tickets import AnswerType from app.core.users.models_users import CoreUser from app.dependencies import ( get_db, diff --git a/app/core/tickets/schemas_tickets.py b/app/core/tickets/schemas_tickets.py index 8b6ef75aca..1e98bc5279 100644 --- a/app/core/tickets/schemas_tickets.py +++ b/app/core/tickets/schemas_tickets.py @@ -2,14 +2,12 @@ from typing import Literal from uuid import UUID -from fastapi.encoders import jsonable_encoder from pydantic import ( BaseModel, ) from app.core.tickets.types_tickets import AnswerType from app.core.users import schemas_users -from app.types.sqlalchemy import Base class Session(BaseModel): diff --git a/app/core/tickets/utils_tickets.py b/app/core/tickets/utils_tickets.py index a9c8ce10c6..44f486c443 100644 --- a/app/core/tickets/utils_tickets.py +++ b/app/core/tickets/utils_tickets.py @@ -1,5 +1,5 @@ import uuid -from typing import Sequence +from collections.abc import Sequence from uuid import UUID from fastapi import ( diff --git a/app/modules/calendar/endpoints_calendar.py b/app/modules/calendar/endpoints_calendar.py index b5513d09f4..3678552e2f 100644 --- a/app/modules/calendar/endpoints_calendar.py +++ b/app/modules/calendar/endpoints_calendar.py @@ -12,6 +12,7 @@ from app.core.notification.schemas_notification import Message from app.core.notification.utils_notification import get_topic_by_root_and_identifier from app.core.permissions.type_permissions import ModulePermissions +from app.core.tickets import cruds_tickets from app.core.users import models_users from app.core.utils.config import Settings from app.core.utils.security import generate_token @@ -24,7 +25,6 @@ is_user_allowed_to, is_user_in_association, ) -from app.core.tickets import cruds_tickets from app.modules.calendar import ( cruds_calendar, models_calendar, @@ -324,12 +324,11 @@ async def add_event( if settings.school.require_event_confirmation: decision = Decision.pending - # Gérer le cas où ticket_event_id est fourni ticket_url_opening = event.ticket_url_opening ticket_url = event.ticket_url + # A TicketEvent id can be provided for this CalendarEvent if event.ticket_event_id: - # Récupérer le TicketEvent pour obtenir l'open_datetime ticket_event = await cruds_tickets.get_event_simple_by_id( event_id=event.ticket_event_id, db=db, @@ -365,9 +364,10 @@ async def add_event( raise NewlyAddedObjectInDbNotFoundError("event") if decision == Decision.approved: - # Déterminer les valeurs pour la feed news feed_module = "tickets" if event.ticket_event_id else utils_calendar.root - feed_module_object_id = event.ticket_event_id if event.ticket_event_id else event_id + feed_module_object_id = ( + event.ticket_event_id if event.ticket_event_id else event_id + ) await utils_calendar.add_event_to_feed( event=created_event, @@ -523,9 +523,10 @@ async def confirm_event( ) if decision == Decision.approved: - # Déterminer les valeurs pour la feed news feed_module = "tickets" if event.ticket_event_id else utils_calendar.root - feed_module_object_id = event.ticket_event_id if event.ticket_event_id else event.id + feed_module_object_id = ( + event.ticket_event_id if event.ticket_event_id else event.id + ) await utils_calendar.add_event_to_feed( event=event, diff --git a/app/modules/calendar/models_calendar.py b/app/modules/calendar/models_calendar.py index 5c95ce0d97..9fa47f4aab 100644 --- a/app/modules/calendar/models_calendar.py +++ b/app/modules/calendar/models_calendar.py @@ -36,10 +36,8 @@ class Event(Base): ticket_url_opening: Mapped[datetime | None] notification: Mapped[bool] - # Référence à un TicketEvent optionnel (module tickets) ticket_event_id: Mapped[UUID | None] = mapped_column( ForeignKey("tickets_event.id"), - nullable=True, ) association: Mapped[CoreAssociation] = relationship("CoreAssociation", init=False) diff --git a/app/modules/calendar/schemas_calendar.py b/app/modules/calendar/schemas_calendar.py index 94600771c2..a033313563 100644 --- a/app/modules/calendar/schemas_calendar.py +++ b/app/modules/calendar/schemas_calendar.py @@ -30,15 +30,20 @@ class EventBaseCreation(EventBase): @model_validator(mode="after") def check_ticket(self): - # Si ticket_event_id est fourni, on ignore les validations sur ticket_url + # If ticket_event_id is provided, we ignore the validations on ticket_url if self.ticket_event_id: + if self.ticket_url or self.ticket_url_opening: + raise ValueError( # noqa: TRY003 + "ticket_url and ticket_url_opening should not be provided when ticket_event_id is provided", + ) return self - # Validation existante : ticket_url et ticket_url_opening doivent être fournis ensemble if (self.ticket_url_opening and not self.ticket_url) or ( self.ticket_url and not self.ticket_url_opening ): - raise ValueError("ticket_url and ticket_url_opening must be provided together") + raise ValueError( # noqa: TRY003 + "ticket_url and ticket_url_opening must be provided together", + ) return self diff --git a/app/modules/calendar/utils_calendar.py b/app/modules/calendar/utils_calendar.py index 31e1f065f2..9c4c108c4c 100644 --- a/app/modules/calendar/utils_calendar.py +++ b/app/modules/calendar/utils_calendar.py @@ -23,9 +23,10 @@ async def add_event_to_feed( feed_module: str | None = None, feed_module_object_id: UUID | None = None, ): - # Utiliser les valeurs personnalisées si fournies, sinon utiliser les valeurs par défaut - module_value = feed_module if feed_module else root - module_object_id_value = feed_module_object_id if feed_module_object_id else event.id + module_value = feed_module if feed_module is not None else root + module_object_id_value = ( + feed_module_object_id if feed_module_object_id is not None else event.id + ) await create_feed_news( title=event.name, diff --git a/app/utils/loggers_tools/matrix_handler.py b/app/utils/loggers_tools/matrix_handler.py index a452848bee..d95a2b6ae1 100644 --- a/app/utils/loggers_tools/matrix_handler.py +++ b/app/utils/loggers_tools/matrix_handler.py @@ -1,6 +1,6 @@ import logging from logging import StreamHandler -from typing_extensions import override +from typing import override from app.utils.communication.matrix import Matrix diff --git a/app/utils/loggers_tools/s3_handler.py b/app/utils/loggers_tools/s3_handler.py index f731ece516..a64b0f0825 100644 --- a/app/utils/loggers_tools/s3_handler.py +++ b/app/utils/loggers_tools/s3_handler.py @@ -1,7 +1,7 @@ import string from datetime import UTC, datetime from logging import StreamHandler -from typing_extensions import override +from typing import override from app.types.s3_access import S3Access from app.utils.tools import get_random_string diff --git a/migrations/versions/67-mypayment.py b/migrations/versions/67-mypayment.py index 075f470b71..a197ad1a39 100644 --- a/migrations/versions/67-mypayment.py +++ b/migrations/versions/67-mypayment.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 @@ -12,8 +12,6 @@ import sqlalchemy as sa from alembic import op -from app.types.sqlalchemy import TZDateTime - # revision identifiers, used by Alembic. revision: str = "de94c373f94a" down_revision: str | None = "c052cfbe6d75" diff --git a/migrations/versions/68-tickets.py b/migrations/versions/68-tickets.py index d232604c4a..13a7655dca 100644 --- a/migrations/versions/68-tickets.py +++ b/migrations/versions/68-tickets.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 From b6848dfc4b67873083cc44af150e38fc2cf52344 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 6 Apr 2026 16:49:07 +0200 Subject: [PATCH 55/72] Add disabled fields --- app/core/tickets/cruds_tickets.py | 5 ++++- app/core/tickets/models_tickets.py | 8 +++++++- tests/core/test_tickets.py | 14 ++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/app/core/tickets/cruds_tickets.py b/app/core/tickets/cruds_tickets.py index 6ffea888d4..2b7cf3222d 100644 --- a/app/core/tickets/cruds_tickets.py +++ b/app/core/tickets/cruds_tickets.py @@ -235,6 +235,7 @@ async def create_event( store_id=event.store_id, name=event.name, quota=event.quota, + disabled=False, open_datetime=event.open_datetime, close_datetime=event.close_datetime, sessions=[ @@ -244,6 +245,7 @@ async def create_event( name=session.name, start_datetime=session.start_datetime, quota=session.quota, + disabled=False, ) for session in event.sessions ], @@ -255,6 +257,7 @@ async def create_event( quota=category.quota, price=category.price, required_membership=category.required_membership, + disabled=False, ) for category in event.categories ], @@ -352,7 +355,7 @@ async def create_checkout( answer=answer.answer_value, ) for answer in answers - ], # TODO: add answers + ], ) db.add(db_checkout) diff --git a/app/core/tickets/models_tickets.py b/app/core/tickets/models_tickets.py index a11b654464..83a770be57 100644 --- a/app/core/tickets/models_tickets.py +++ b/app/core/tickets/models_tickets.py @@ -26,6 +26,8 @@ class TicketEvent(Base): # Total number of tickets available, None means unlimited quota: Mapped[int | None] + disabled: Mapped[bool] + store: Mapped[models_mypayment.Store] = relationship(init=False) sessions: Mapped[list["EventSession"]] = relationship(back_populates="event") @@ -43,7 +45,9 @@ class EventSession(Base): start_datetime: Mapped[datetime] - quota: Mapped[int | None] = mapped_column(default=None) + quota: Mapped[int | None] + + disabled: Mapped[bool] event: Mapped["TicketEvent"] = relationship(back_populates="sessions", init=False) @@ -58,6 +62,8 @@ class Category(Base): quota: Mapped[int | None] + disabled: Mapped[bool] + price: Mapped[int] # in cents required_membership: Mapped[UUID | None] = mapped_column( ForeignKey("core_association_membership.id"), diff --git a/tests/core/test_tickets.py b/tests/core/test_tickets.py index cfe21d8c67..caefa239fb 100644 --- a/tests/core/test_tickets.py +++ b/tests/core/test_tickets.py @@ -143,12 +143,14 @@ async def init_objects() -> None: name="Test Session", start_datetime=datetime.now(tz=UTC) - timedelta(days=1), quota=None, + disabled=False, ) event_category = models_tickets.Category( id=uuid.uuid4(), event_id=ticket_event_id, name="Test Category", quota=None, + disabled=False, price=1000, required_membership=None, ) @@ -161,6 +163,7 @@ async def init_objects() -> None: open_datetime=datetime.now(tz=UTC) - timedelta(days=1), close_datetime=datetime.now(tz=UTC) + timedelta(days=1), quota=10, + disabled=False, sessions=[event_session], categories=[event_category], questions=[ @@ -192,6 +195,7 @@ async def init_objects() -> None: event_id=global_event.id, name="Test global_event Sold Out Category", quota=1, + disabled=False, price=1000, required_membership=None, ) @@ -212,6 +216,7 @@ async def init_objects() -> None: name="Test global_event Sold Out Session", start_datetime=datetime.now(tz=UTC) - timedelta(days=1), quota=1, + disabled=False, ) await add_object_to_db(event_sold_out_session) ticket_sold_out_session = models_tickets.Ticket( @@ -237,12 +242,14 @@ async def init_objects() -> None: name="Test Session Sold Out", start_datetime=datetime.now(tz=UTC) - timedelta(days=1), quota=1, + disabled=False, ) category_sold_out_event = models_tickets.Category( id=uuid.uuid4(), event_id=ticket_sold_out_event_id, name="Test Category Sold Out", quota=1, + disabled=False, price=1000, required_membership=None, ) @@ -253,6 +260,7 @@ async def init_objects() -> None: open_datetime=datetime.now(tz=UTC) - timedelta(days=1), close_datetime=datetime.now(tz=UTC) + timedelta(days=1), quota=1, + disabled=False, sessions=[session_sold_out_event], categories=[category_sold_out_event], questions=[], @@ -423,6 +431,7 @@ async def test_create_checkout_with_missing_membership(client: TestClient): open_datetime=datetime.now(tz=UTC) - timedelta(days=1), close_datetime=datetime.now(tz=UTC) + timedelta(days=1), quota=10, + disabled=False, sessions=[ models_tickets.EventSession( id=event_with_required_membership_session_id, @@ -430,6 +439,7 @@ async def test_create_checkout_with_missing_membership(client: TestClient): name="Test Session", start_datetime=datetime.now(tz=UTC) - timedelta(days=1), quota=None, + disabled=False, ), ], categories=[ @@ -438,6 +448,7 @@ async def test_create_checkout_with_missing_membership(client: TestClient): event_id=event_with_required_membership_id, name="Test Category", quota=None, + disabled=False, price=1000, required_membership=membership.id, ), @@ -573,6 +584,7 @@ async def test_create_checkout_with_missing_required_question(client: TestClient open_datetime=datetime.now(tz=UTC) - timedelta(days=1), close_datetime=datetime.now(tz=UTC) + timedelta(days=1), quota=10, + disabled=False, sessions=[ models_tickets.EventSession( id=event_with_required_question_session_id, @@ -580,6 +592,7 @@ async def test_create_checkout_with_missing_required_question(client: TestClient name="Test Session", start_datetime=datetime.now(tz=UTC) - timedelta(days=1), quota=None, + disabled=False, ), ], categories=[ @@ -588,6 +601,7 @@ async def test_create_checkout_with_missing_required_question(client: TestClient event_id=event_with_required_question_id, name="Test Category", quota=None, + disabled=False, price=1000, required_membership=None, ), From 3ca3a33d2ee310bb3b59c51cc90520bfe924c1cc Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:11:41 +0200 Subject: [PATCH 56/72] Disable --- app/core/tickets/cruds_tickets.py | 20 ++++++++++++++++++-- app/core/tickets/endpoints_tickets.py | 24 ++++++++++++++++++++++-- app/core/tickets/schemas_tickets.py | 4 ++++ app/core/tickets/utils_tickets.py | 3 +++ 4 files changed, 47 insertions(+), 4 deletions(-) diff --git a/app/core/tickets/cruds_tickets.py b/app/core/tickets/cruds_tickets.py index 2b7cf3222d..110bce151c 100644 --- a/app/core/tickets/cruds_tickets.py +++ b/app/core/tickets/cruds_tickets.py @@ -3,7 +3,7 @@ from datetime import UTC, datetime from uuid import UUID -from sqlalchemy import func, or_, update +from sqlalchemy import func, not_, or_, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from sqlalchemy.sql import select @@ -12,7 +12,7 @@ from app.core.users import schemas_users -async def get_open_events( +async def get_open_and_enabled_events( db: AsyncSession, ) -> Sequence[schemas_tickets.EventSimple]: """Return all open events from database""" @@ -26,6 +26,7 @@ async def get_open_events( models_tickets.TicketEvent.close_datetime.is_(None), models_tickets.TicketEvent.close_datetime > time, ), + not_(models_tickets.TicketEvent.disabled), ), ) return [ @@ -35,6 +36,7 @@ async def get_open_events( store_id=association.store_id, open_datetime=association.open_datetime, close_datetime=association.close_datetime, + disabled=association.disabled, ) for association in result.scalars().all() ] @@ -58,6 +60,7 @@ async def get_events_by_store_id( store_id=association.store_id, open_datetime=association.open_datetime, close_datetime=association.close_datetime, + disabled=association.disabled, ) for association in result.scalars().all() ] @@ -95,6 +98,7 @@ async def get_event_complete_by_id( open_datetime=event.open_datetime, close_datetime=event.close_datetime, quota=event.quota, + disabled=event.disabled, store_id=event.store_id, sessions=[ schemas_tickets.SessionComplete( @@ -103,6 +107,7 @@ async def get_event_complete_by_id( start_datetime=session.start_datetime, event_id=session.event_id, quota=session.quota, + disabled=session.disabled, ) for session in event.sessions ], @@ -114,6 +119,7 @@ async def get_event_complete_by_id( required_membership=category.required_membership, event_id=category.event_id, quota=category.quota, + disabled=category.disabled, ) for category in event.categories ], @@ -158,6 +164,7 @@ async def get_event_simple_by_id( open_datetime=event.open_datetime, close_datetime=event.close_datetime, store_id=event.store_id, + disabled=event.disabled, ) @@ -197,6 +204,7 @@ async def acquire_event_lock_for_update( open_datetime=event.open_datetime, close_datetime=event.close_datetime, quota=event.quota, + disabled=event.disabled, store_id=event.store_id, ) @@ -300,6 +308,7 @@ async def get_category_by_id( required_membership=category.required_membership, event_id=category.event_id, quota=category.quota, + disabled=category.disabled, ) @@ -325,6 +334,7 @@ async def get_session_by_id( start_datetime=session.start_datetime, event_id=session.event_id, quota=session.quota, + disabled=session.disabled, ) @@ -385,12 +395,14 @@ async def get_tickets_by_user_id( price=ticket.category.price, required_membership=ticket.category.required_membership, event_id=ticket.category.event_id, + disabled=ticket.category.disabled, ), session=schemas_tickets.Session( id=ticket.session.id, name=ticket.session.name, start_datetime=ticket.session.start_datetime, event_id=ticket.session.event_id, + disabled=ticket.session.disabled, ), user_id=ticket.user_id, user=schemas_users.CoreUserSimple( @@ -432,12 +444,14 @@ async def get_tickets_by_event_id( price=ticket.category.price, required_membership=ticket.category.required_membership, event_id=ticket.category.event_id, + disabled=ticket.category.disabled, ), session=schemas_tickets.Session( id=ticket.session.id, name=ticket.session.name, start_datetime=ticket.session.start_datetime, event_id=ticket.session.event_id, + disabled=ticket.session.disabled, ), user_id=ticket.user_id, user=schemas_users.CoreUserSimple( @@ -482,12 +496,14 @@ async def get_ticket_by_id( price=ticket.category.price, required_membership=ticket.category.required_membership, event_id=ticket.category.event_id, + disabled=ticket.category.disabled, ), session=schemas_tickets.Session( id=ticket.session.id, name=ticket.session.name, start_datetime=ticket.session.start_datetime, event_id=ticket.session.event_id, + disabled=ticket.session.disabled, ), user_id=ticket.user_id, user=schemas_users.CoreUserSimple( diff --git a/app/core/tickets/endpoints_tickets.py b/app/core/tickets/endpoints_tickets.py index bab812e48d..8ee7035e15 100644 --- a/app/core/tickets/endpoints_tickets.py +++ b/app/core/tickets/endpoints_tickets.py @@ -62,9 +62,11 @@ async def get_open_events( db: AsyncSession = Depends(get_db), ): """ - Return all open events + Return all open events. + + To be considered open, an event should have its opening date in the past and its closing date in the future or not defined. Moreover, we only return enabled events. """ - return await cruds_tickets.get_open_events(db=db) + return await cruds_tickets.get_open_and_enabled_events(db=db) @router.get( @@ -83,12 +85,18 @@ async def get_event( ): """ Get an event public details + + Only enabled sessions and categories are returned """ event = await cruds_tickets.get_event_complete_by_id(event_id=event_id, db=db) if event is None: raise HTTPException(404, "Event not found") + # TODO: do we return disabled events? + if event.disabled: + raise HTTPException(400, "Event is disabled") + return schemas_tickets.EventPublic( id=event.id, name=event.name, @@ -104,8 +112,10 @@ async def get_event( quota=session.quota, db=db, ), + disabled=session.disabled, ) for session in event.sessions + if not session.disabled ], categories=[ schemas_tickets.CategoryPublic( @@ -119,8 +129,10 @@ async def get_event( quota=category.quota, db=db, ), + disabled=category.disabled, ) for category in event.categories + if not category.disabled ], questions=[ schemas_tickets.QuestionPublic( @@ -141,6 +153,7 @@ async def get_event( ), open_datetime=event.open_datetime, close_datetime=event.close_datetime, + disabled=event.disabled, ) @@ -168,12 +181,16 @@ async def create_checkout( ) if category is None: raise HTTPException(404, "Category not found") + if category.disabled: + raise HTTPException(400, "Category is disabled") session = await cruds_tickets.get_session_by_id( session_id=checkout.session_id, db=db, ) if session is None: raise HTTPException(404, "Session not found") + if session.disabled: + raise HTTPException(400, "Session is disabled") if category.event_id != event_id: raise HTTPException(400, "Category does not belong to the event") @@ -255,6 +272,9 @@ async def create_checkout( object_id=event_id, ) + if event.disabled: + raise HTTPException(400, "Event is disabled") + price += category.price expiration = datetime.now(UTC) + timedelta(minutes=CHECKOUT_EXPIRATION_MINUTES) diff --git a/app/core/tickets/schemas_tickets.py b/app/core/tickets/schemas_tickets.py index 1e98bc5279..b687cd27ee 100644 --- a/app/core/tickets/schemas_tickets.py +++ b/app/core/tickets/schemas_tickets.py @@ -15,6 +15,7 @@ class Session(BaseModel): event_id: UUID name: str start_datetime: datetime + disabled: bool class SessionComplete(Session): @@ -47,6 +48,7 @@ class Category(BaseModel): name: str price: int required_membership: UUID | None + disabled: bool class CategoryComplete(Category): @@ -107,6 +109,8 @@ class EventSimple(BaseModel): open_datetime: datetime close_datetime: datetime | None + disabled: bool + class EventWithoutSessionsAndCategories(EventSimple): quota: int | None diff --git a/app/core/tickets/utils_tickets.py b/app/core/tickets/utils_tickets.py index 44f486c443..4bc3d0b19b 100644 --- a/app/core/tickets/utils_tickets.py +++ b/app/core/tickets/utils_tickets.py @@ -95,6 +95,7 @@ async def convert_to_event_admin( name=session.name, start_datetime=session.start_datetime, quota=session.quota, + disabled=session.disabled, tickets_in_checkout=await cruds_tickets.count_valid_checkouts_by_event_id( event_id=event.id, db=db, @@ -114,6 +115,7 @@ async def convert_to_event_admin( price=category.price, required_membership=category.required_membership, quota=category.quota, + disabled=category.disabled, tickets_in_checkout=await cruds_tickets.count_valid_checkouts_by_category_id( category_id=category.id, db=db, @@ -138,6 +140,7 @@ async def convert_to_event_admin( for question in event.questions ], quota=event.quota, + disabled=event.disabled, tickets_in_checkout=await cruds_tickets.count_valid_checkouts_by_event_id( event_id=event.id, db=db, From 873ec1cf94c6feb3c7e40846e6111b47b4a5bca2 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:26:58 +0200 Subject: [PATCH 57/72] Ensure event is open to authorize checkout creation --- app/core/tickets/endpoints_tickets.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/core/tickets/endpoints_tickets.py b/app/core/tickets/endpoints_tickets.py index 8ee7035e15..159d3a8395 100644 --- a/app/core/tickets/endpoints_tickets.py +++ b/app/core/tickets/endpoints_tickets.py @@ -160,7 +160,7 @@ async def get_event( @router.post( "/tickets/events/{event_id}/checkout", response_model=schemas_tickets.CheckoutResponse, - status_code=200, + status_code=201, ) async def create_checkout( event_id: UUID, @@ -275,6 +275,11 @@ async def create_checkout( if event.disabled: raise HTTPException(400, "Event is disabled") + if event.open_datetime > datetime.now(UTC): + raise HTTPException(400, "Event is not open yet") + if event.close_datetime is not None and event.close_datetime <= datetime.now(UTC): + raise HTTPException(400, "Event is closed") + price += category.price expiration = datetime.now(UTC) + timedelta(minutes=CHECKOUT_EXPIRATION_MINUTES) From 68c8404c187dd25373c98b8f92fe9706cbb5fa94 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:27:10 +0200 Subject: [PATCH 58/72] Test disabled --- tests/core/test_tickets.py | 210 +++++++++++++++++++++++++++++++++++-- 1 file changed, 202 insertions(+), 8 deletions(-) diff --git a/tests/core/test_tickets.py b/tests/core/test_tickets.py index caefa239fb..a4afc78400 100644 --- a/tests/core/test_tickets.py +++ b/tests/core/test_tickets.py @@ -37,6 +37,9 @@ global_event: models_tickets.TicketEvent event_session: models_tickets.EventSession event_category: models_tickets.Category +event_disabled_category: models_tickets.Category +event_disabled_session: models_tickets.EventSession + event_sold_out_category: models_tickets.Category event_sold_out_session: models_tickets.EventSession global_event_optionnal_question_id: uuid.UUID @@ -129,12 +132,7 @@ async def init_objects() -> None: ) await add_object_to_db(seller) - global \ - global_event, \ - event_session, \ - event_category, \ - global_event_optionnal_question_id, \ - global_event_disabled_question_id + global global_event, event_session, event_category ticket_event_id = uuid.uuid4() event_session = models_tickets.EventSession( @@ -154,6 +152,27 @@ async def init_objects() -> None: price=1000, required_membership=None, ) + + global event_disabled_category, event_disabled_session + event_disabled_category = models_tickets.Category( + id=uuid.uuid4(), + event_id=ticket_event_id, + name="Test Disabled Category", + quota=None, + disabled=True, + price=1000, + required_membership=None, + ) + event_disabled_session = models_tickets.EventSession( + id=uuid.uuid4(), + event_id=ticket_event_id, + name="Test Disabled Session", + start_datetime=datetime.now(tz=UTC) - timedelta(days=1), + quota=None, + disabled=True, + ) + + global global_event_optionnal_question_id, global_event_disabled_question_id global_event_optionnal_question_id = uuid.uuid4() global_event_disabled_question_id = uuid.uuid4() global_event = models_tickets.TicketEvent( @@ -164,8 +183,8 @@ async def init_objects() -> None: close_datetime=datetime.now(tz=UTC) + timedelta(days=1), quota=10, disabled=False, - sessions=[event_session], - categories=[event_category], + sessions=[event_session, event_disabled_session], + categories=[event_category, event_disabled_category], questions=[ models_tickets.Question( id=global_event_optionnal_question_id, @@ -336,6 +355,20 @@ def test_create_checkout_with_invalid_category(client: TestClient): assert response.json()["detail"] == "Category not found" +def test_create_checkout_with_disabled_category(client: TestClient): + response = client.post( + f"/tickets/events/{global_event.id}/checkout", + headers={"Authorization": f"Bearer {user_token}"}, + json={ + "category_id": str(event_disabled_category.id), + "session_id": str(event_session.id), + "answers": [], + }, + ) + assert response.status_code == 400 + assert response.json()["detail"] == "Category is disabled" + + def test_create_checkout_with_invalid_session(client: TestClient): response = client.post( f"/tickets/events/{sold_out_event.id}/checkout", @@ -350,6 +383,167 @@ def test_create_checkout_with_invalid_session(client: TestClient): assert response.json()["detail"] == "Session not found" +def test_create_checkout_with_disabled_session(client: TestClient): + response = client.post( + f"/tickets/events/{global_event.id}/checkout", + headers={"Authorization": f"Bearer {user_token}"}, + json={ + "category_id": str(event_category.id), + "session_id": str(event_disabled_session.id), + "answers": [], + }, + ) + assert response.status_code == 400 + assert response.json()["detail"] == "Session is disabled" + + +async def test_create_checkout_with_disabled_event(client: TestClient): + event_id = uuid.uuid4() + category_id = uuid.uuid4() + session_id = uuid.uuid4() + event = models_tickets.TicketEvent( + id=event_id, + store_id=store.id, + name="Test Disabled Event", + open_datetime=datetime.now(tz=UTC) - timedelta(days=1), + close_datetime=datetime.now(tz=UTC) + timedelta(days=1), + quota=10, + disabled=True, + sessions=[ + models_tickets.EventSession( + id=session_id, + event_id=event_id, + name="Test Session", + start_datetime=datetime.now(tz=UTC) - timedelta(days=1), + quota=None, + disabled=False, + ), + ], + categories=[ + models_tickets.Category( + id=category_id, + event_id=event_id, + name="Test Category", + quota=None, + disabled=False, + price=1000, + required_membership=None, + ), + ], + questions=[], + ) + await add_object_to_db(event) + response = client.post( + f"/tickets/events/{event_id}/checkout", + headers={"Authorization": f"Bearer {user_token}"}, + json={ + "category_id": str(category_id), + "session_id": str(session_id), + "answers": [], + }, + ) + assert response.status_code == 400 + assert response.json()["detail"] == "Event is disabled" + + +async def test_create_checkout_with_not_open_event(client: TestClient): + event_id = uuid.uuid4() + category_id = uuid.uuid4() + session_id = uuid.uuid4() + event = models_tickets.TicketEvent( + id=event_id, + store_id=store.id, + name="Test Disabled Event", + open_datetime=datetime.now(tz=UTC) + timedelta(days=1), + close_datetime=datetime.now(tz=UTC) + timedelta(days=2), + quota=10, + disabled=False, + sessions=[ + models_tickets.EventSession( + id=session_id, + event_id=event_id, + name="Test Session", + start_datetime=datetime.now(tz=UTC) - timedelta(days=1), + quota=None, + disabled=False, + ), + ], + categories=[ + models_tickets.Category( + id=category_id, + event_id=event_id, + name="Test Category", + quota=None, + disabled=False, + price=1000, + required_membership=None, + ), + ], + questions=[], + ) + await add_object_to_db(event) + response = client.post( + f"/tickets/events/{event_id}/checkout", + headers={"Authorization": f"Bearer {user_token}"}, + json={ + "category_id": str(category_id), + "session_id": str(session_id), + "answers": [], + }, + ) + assert response.status_code == 400 + assert response.json()["detail"] == "Event is not open yet" + + +async def test_create_checkout_with_closed_event(client: TestClient): + event_id = uuid.uuid4() + category_id = uuid.uuid4() + session_id = uuid.uuid4() + event = models_tickets.TicketEvent( + id=event_id, + store_id=store.id, + name="Test Disabled Event", + open_datetime=datetime.now(tz=UTC) - timedelta(days=2), + close_datetime=datetime.now(tz=UTC) - timedelta(days=1), + quota=10, + disabled=False, + sessions=[ + models_tickets.EventSession( + id=session_id, + event_id=event_id, + name="Test Session", + start_datetime=datetime.now(tz=UTC) - timedelta(days=1), + quota=None, + disabled=False, + ), + ], + categories=[ + models_tickets.Category( + id=category_id, + event_id=event_id, + name="Test Category", + quota=None, + disabled=False, + price=1000, + required_membership=None, + ), + ], + questions=[], + ) + await add_object_to_db(event) + response = client.post( + f"/tickets/events/{event_id}/checkout", + headers={"Authorization": f"Bearer {user_token}"}, + json={ + "category_id": str(category_id), + "session_id": str(session_id), + "answers": [], + }, + ) + assert response.status_code == 400 + assert response.json()["detail"] == "Event is closed" + + def test_create_checkout_with_category_from_another_event(client: TestClient): response = client.post( f"/tickets/events/{sold_out_event.id}/checkout", From 904d79153eb515f49d6c98b7d024073235f41659 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:27:16 +0200 Subject: [PATCH 59/72] Fix create checkout test --- tests/core/test_tickets.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/core/test_tickets.py b/tests/core/test_tickets.py index a4afc78400..57704adadd 100644 --- a/tests/core/test_tickets.py +++ b/tests/core/test_tickets.py @@ -835,12 +835,19 @@ def test_create_checkout(client: TestClient): headers={"Authorization": f"Bearer {user_token}"}, json={ "category_id": str(event_category.id), - "session_id": str(event_sold_out_session.id), - "answers": [], + "session_id": str(event_session.id), + "answers": [ + { + "question_id": str(global_event_optionnal_question_id), + "answer_type": "text", + "answer": "Test Answer", + }, + ], }, ) - assert response.status_code == 400 - assert response.json()["detail"] == "Session is sold out" + assert response.status_code == 201 + # Price of the event + price of the optionnal question + assert response.json()["price"] == 1000 + 100 def test_get_user_tickets(client: TestClient): From 4cb8ec61fd8c90af4d079e65d6ee3e9c1b51a407 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 6 Apr 2026 18:12:44 +0200 Subject: [PATCH 60/72] Edit Event, Session and Category --- app/core/tickets/cruds_tickets.py | 36 +++++ app/core/tickets/endpoints_tickets.py | 162 +++++++++++++++++++--- app/core/tickets/models_tickets.py | 2 - app/core/tickets/schemas_tickets.py | 22 +++ tests/core/test_tickets.py | 191 ++++++++++++++++++++++++++ 5 files changed, 393 insertions(+), 20 deletions(-) diff --git a/app/core/tickets/cruds_tickets.py b/app/core/tickets/cruds_tickets.py index 110bce151c..8f4c940834 100644 --- a/app/core/tickets/cruds_tickets.py +++ b/app/core/tickets/cruds_tickets.py @@ -607,3 +607,39 @@ async def count_valid_checkouts_by_session_id( ) return result.scalar() or 0 + + +async def update_event( + event_id: UUID, + event_update: schemas_tickets.EventUpdate, + db: AsyncSession, +): + await db.execute( + update(models_tickets.TicketEvent) + .where(models_tickets.TicketEvent.id == event_id) + .values(**event_update.dict(exclude_unset=True)), + ) + + +async def update_session( + session_id: UUID, + session_update: schemas_tickets.SessionUpdate, + db: AsyncSession, +): + await db.execute( + update(models_tickets.EventSession) + .where(models_tickets.EventSession.id == session_id) + .values(**session_update.dict(exclude_unset=True)), + ) + + +async def update_category( + category_id: UUID, + category_update: schemas_tickets.CategoryUpdate, + db: AsyncSession, +): + await db.execute( + update(models_tickets.Category) + .where(models_tickets.Category.id == category_id) + .values(**category_update.dict(exclude_unset=True)), + ) diff --git a/app/core/tickets/endpoints_tickets.py b/app/core/tickets/endpoints_tickets.py index 159d3a8395..09ef51b4dd 100644 --- a/app/core/tickets/endpoints_tickets.py +++ b/app/core/tickets/endpoints_tickets.py @@ -430,24 +430,150 @@ async def create_event( ) -# router.patch( -# "/tickets/admin/events/{event_id}", -# response_model=schemas_tickets.EventComplete, -# status_code=204, -# ) -# async def edit_event( -# event_id: UUID, -# event_edit: schemas_tickets.EventCreate, -# user: CoreUser = Depends( -# is_user(), -# ), -# db: AsyncSession = Depends(get_db), -# ): -# """ -# Edit one event for admin -# """ -# # TODO: an open event should not be editable -# pass +@router.patch( + "/tickets/admin/events/{event_id}", + status_code=204, +) +async def update_event( + event_id: UUID, + event_update: schemas_tickets.EventUpdate, + user: CoreUser = Depends( + is_user(), + ), + db: AsyncSession = Depends(get_db), +): + """ + Edit one event for admin + """ + event = await cruds_tickets.get_event_simple_by_id(event_id=event_id, db=db) + if event is None: + raise HTTPException(404, "Event not found") + + if not await utils_mypayment.can_user_manage_events( + user_id=user.id, + store_id=event.store_id, + db=db, + ): + raise HTTPException( + status_code=403, + detail="User is not authorized to manage store's events", + ) + + await cruds_tickets.update_event( + event_id=event_id, + event_update=event_update, + db=db, + ) + + +@router.patch( + "/tickets/admin/events/{event_id}/sessions/{session_id}", + status_code=204, +) +async def update_session( + event_id: UUID, + session_id: UUID, + session_update: schemas_tickets.SessionUpdate, + user: CoreUser = Depends( + is_user(), + ), + db: AsyncSession = Depends(get_db), +): + """ + Edit one event for admin + """ + event = await cruds_tickets.get_event_simple_by_id(event_id=event_id, db=db) + if event is None: + raise HTTPException(404, "Event not found") + + if not await utils_mypayment.can_user_manage_events( + user_id=user.id, + store_id=event.store_id, + db=db, + ): + raise HTTPException( + status_code=403, + detail="User is not authorized to manage store's events", + ) + + session = await cruds_tickets.get_session_by_id(session_id=session_id, db=db) + if session is None or session.event_id != event_id: + raise HTTPException(404, "Session not found") + + nb_checkouts = await cruds_tickets.count_valid_checkouts_by_session_id( + session_id=session_id, + db=db, + ) + nb_tickets = await cruds_tickets.count_tickets_by_session_id( + session_id=session_id, + db=db, + ) + if nb_checkouts + nb_tickets > 0: + raise HTTPException( + 400, + "Cannot update session with checkouts or tickets", + ) + + await cruds_tickets.update_session( + session_id=session_id, + session_update=session_update, + db=db, + ) + + +@router.patch( + "/tickets/admin/events/{event_id}/categories/{category_id}", + status_code=204, +) +async def update_category( + event_id: UUID, + category_id: UUID, + category_update: schemas_tickets.CategoryUpdate, + user: CoreUser = Depends( + is_user(), + ), + db: AsyncSession = Depends(get_db), +): + """ + Edit one event for admin + """ + event = await cruds_tickets.get_event_simple_by_id(event_id=event_id, db=db) + if event is None: + raise HTTPException(404, "Event not found") + + if not await utils_mypayment.can_user_manage_events( + user_id=user.id, + store_id=event.store_id, + db=db, + ): + raise HTTPException( + status_code=403, + detail="User is not authorized to manage store's events", + ) + + category = await cruds_tickets.get_category_by_id(category_id=category_id, db=db) + if category is None or category.event_id != event_id: + raise HTTPException(404, "Category not found") + + nb_checkouts = await cruds_tickets.count_valid_checkouts_by_category_id( + category_id=category_id, + db=db, + ) + nb_tickets = await cruds_tickets.count_tickets_by_category_id( + category_id=category_id, + db=db, + ) + if nb_checkouts + nb_tickets > 0: + raise HTTPException( + 400, + "Cannot update category with checkouts or tickets", + ) + + await cruds_tickets.update_category( + category_id=category_id, + category_update=category_update, + db=db, + ) @router.get( diff --git a/app/core/tickets/models_tickets.py b/app/core/tickets/models_tickets.py index 83a770be57..2e71692c6f 100644 --- a/app/core/tickets/models_tickets.py +++ b/app/core/tickets/models_tickets.py @@ -9,8 +9,6 @@ from app.core.users import models_users from app.types.sqlalchemy import Base, PrimaryKey -# TODO: do we want to be able to disable sessions or prices? - class TicketEvent(Base): __tablename__ = "tickets_event" diff --git a/app/core/tickets/schemas_tickets.py b/app/core/tickets/schemas_tickets.py index b687cd27ee..837ee6a6d0 100644 --- a/app/core/tickets/schemas_tickets.py +++ b/app/core/tickets/schemas_tickets.py @@ -42,6 +42,13 @@ class SessionCreate(BaseModel): quota: int | None +class SessionUpdate(BaseModel): + name: str | None = None + start_datetime: datetime | None = None + quota: int | None = None + disabled: bool | None = None + + class Category(BaseModel): id: UUID event_id: UUID @@ -75,6 +82,14 @@ class CategoryCreate(BaseModel): required_membership: UUID | None +class CategoryUpdate(BaseModel): + name: str | None = None + price: int | None = None + quota: int | None = None + required_membership: UUID | None = None + disabled: bool | None = None + + class Question(BaseModel): id: UUID event_id: UUID @@ -150,6 +165,13 @@ class EventCreate(BaseModel): questions: list[QuestionCreate] +class EventUpdate(BaseModel): + name: str | None = None + quota: int | None = None + open_datetime: datetime | None = None + close_datetime: datetime | None = None + + class Ticket(BaseModel): id: UUID price: int diff --git a/tests/core/test_tickets.py b/tests/core/test_tickets.py index 57704adadd..4e5fca0417 100644 --- a/tests/core/test_tickets.py +++ b/tests/core/test_tickets.py @@ -963,6 +963,197 @@ def test_create_event(client: TestClient): assert event["tickets_in_checkout"] == 0 +# update_event + + +def test_update_event_non_existing_event(client: TestClient): + response = client.patch( + f"/tickets/admin/events/{uuid.uuid4()}", + headers={"Authorization": f"Bearer {seller_can_manage_event_user_token}"}, + json={ + "name": "Updated Test Event", + }, + ) + assert response.status_code == 404 + assert response.json()["detail"] == "Event not found" + + +def test_update_event_as_non_authorised_seller(client: TestClient): + response = client.patch( + f"/tickets/admin/events/{global_event.id}", + headers={"Authorization": f"Bearer {user_token}"}, + json={ + "name": "Updated Test Event", + }, + ) + assert response.status_code == 403 + assert ( + response.json()["detail"] == "User is not authorized to manage store's events" + ) + + +def test_update_event(client: TestClient): + response = client.patch( + f"/tickets/admin/events/{global_event.id}", + headers={"Authorization": f"Bearer {seller_can_manage_event_user_token}"}, + json={ + "name": "Updated Test Event", + }, + ) + assert response.status_code == 204 + + +# update_session + + +def test_update_session_with_non_existing_event(client: TestClient): + response = client.patch( + f"/tickets/admin/events/{uuid.uuid4()}/sessions/{event_session.id}", + headers={"Authorization": f"Bearer {seller_can_manage_event_user_token}"}, + json={ + "name": "Updated Test Session", + }, + ) + assert response.status_code == 404 + assert response.json()["detail"] == "Event not found" + + +def test_update_session_as_non_authorised_seller(client: TestClient): + response = client.patch( + f"/tickets/admin/events/{global_event.id}/sessions/{event_session.id}", + headers={"Authorization": f"Bearer {user_token}"}, + json={ + "name": "Updated Test Session", + }, + ) + assert response.status_code == 403 + assert ( + response.json()["detail"] == "User is not authorized to manage store's events" + ) + + +def test_update_session_with_non_existing_session(client: TestClient): + response = client.patch( + f"/tickets/admin/events/{global_event.id}/sessions/{uuid.uuid4()}", + headers={"Authorization": f"Bearer {seller_can_manage_event_user_token}"}, + json={ + "name": "Updated Test Session", + }, + ) + assert response.status_code == 404 + assert response.json()["detail"] == "Session not found" + + +def test_update_session_with_existing_tickets(client: TestClient): + response = client.patch( + f"/tickets/admin/events/{global_event.id}/sessions/{event_session.id}", + headers={"Authorization": f"Bearer {seller_can_manage_event_user_token}"}, + json={ + "name": "Updated Test Session", + }, + ) + assert response.status_code == 400 + assert ( + response.json()["detail"] == "Cannot update session with checkouts or tickets" + ) + + +async def test_update_session(client: TestClient): + session_without_tickets = models_tickets.EventSession( + id=uuid.uuid4(), + event_id=global_event.id, + name="Test Session without tickets", + start_datetime=datetime.now(tz=UTC) - timedelta(days=1), + quota=None, + disabled=False, + ) + await add_object_to_db(session_without_tickets) + response = client.patch( + f"/tickets/admin/events/{global_event.id}/sessions/{session_without_tickets.id}", + headers={"Authorization": f"Bearer {seller_can_manage_event_user_token}"}, + json={ + "name": "Updated Test Session", + }, + ) + assert response.status_code == 204 + + +# update_category + + +def test_update_category_with_non_existing_event(client: TestClient): + response = client.patch( + f"/tickets/admin/events/{uuid.uuid4()}/categories/{event_category.id}", + headers={"Authorization": f"Bearer {seller_can_manage_event_user_token}"}, + json={ + "name": "Updated Test Category", + }, + ) + assert response.status_code == 404 + assert response.json()["detail"] == "Event not found" + + +def test_update_category_as_non_authorised_seller(client: TestClient): + response = client.patch( + f"/tickets/admin/events/{global_event.id}/categories/{event_category.id}", + headers={"Authorization": f"Bearer {user_token}"}, + json={ + "name": "Updated Test Category", + }, + ) + assert response.status_code == 403 + assert ( + response.json()["detail"] == "User is not authorized to manage store's events" + ) + + +def test_update_category_with_non_existing_category(client: TestClient): + response = client.patch( + f"/tickets/admin/events/{global_event.id}/categories/{uuid.uuid4()}", + headers={"Authorization": f"Bearer {seller_can_manage_event_user_token}"}, + json={ + "name": "Updated Test Category", + }, + ) + assert response.status_code == 404 + assert response.json()["detail"] == "Category not found" + + +def test_update_category_with_existing_tickets(client: TestClient): + response = client.patch( + f"/tickets/admin/events/{global_event.id}/categories/{event_category.id}", + headers={"Authorization": f"Bearer {seller_can_manage_event_user_token}"}, + json={ + "name": "Updated Test Category", + }, + ) + assert response.status_code == 400 + assert ( + response.json()["detail"] == "Cannot update category with checkouts or tickets" + ) + + +async def test_update_category(client: TestClient): + category_without_tickets = models_tickets.Category( + id=uuid.uuid4(), + event_id=global_event.id, + name="Test Category without tickets", + quota=None, + disabled=False, + price=1000, + required_membership=None, + ) + await add_object_to_db(category_without_tickets) + response = client.patch( + f"/tickets/admin/events/{global_event.id}/categories/{category_without_tickets.id}", + headers={"Authorization": f"Bearer {seller_can_manage_event_user_token}"}, + json={ + "name": "Updated Test Category", + }, + ) + assert response.status_code == 204 + + # get_event_tickets From f2bd4a7b6e4dfa01089c7439fcb20acf85ae5c3a Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 6 Apr 2026 18:24:32 +0200 Subject: [PATCH 61/72] Patch questions --- app/core/tickets/cruds_tickets.py | 56 ++++++++++++++++++++++-- app/core/tickets/endpoints_tickets.py | 51 ++++++++++++++++++++++ app/core/tickets/schemas_tickets.py | 8 ++++ tests/core/test_tickets.py | 62 +++++++++++++++++++++++++++ 4 files changed, 174 insertions(+), 3 deletions(-) diff --git a/app/core/tickets/cruds_tickets.py b/app/core/tickets/cruds_tickets.py index 8f4c940834..6c1be9b669 100644 --- a/app/core/tickets/cruds_tickets.py +++ b/app/core/tickets/cruds_tickets.py @@ -209,6 +209,31 @@ async def acquire_event_lock_for_update( ) +async def get_question_by_id( + question_id: UUID, + db: AsyncSession, +) -> schemas_tickets.Question | None: + result = await db.execute( + select(models_tickets.Question).where( + models_tickets.Question.id == question_id, + ), + ) + + question = result.scalars().first() + if question is None: + return None + + return schemas_tickets.Question( + id=question.id, + event_id=question.event_id, + question=question.question, + answer_type=question.answer_type, + price=question.price, + required=question.required, + disabled=question.disabled, + ) + + async def get_questions_by_event_id( event_id: UUID, db: AsyncSession, @@ -609,6 +634,19 @@ async def count_valid_checkouts_by_session_id( return result.scalar() or 0 +async def count_answers_by_question_id( + question_id: UUID, + db: AsyncSession, +) -> int: + result = await db.execute( + select(func.count()).where( + models_tickets.Answer.question_id == question_id, + ), + ) + + return result.scalar() or 0 + + async def update_event( event_id: UUID, event_update: schemas_tickets.EventUpdate, @@ -617,7 +655,7 @@ async def update_event( await db.execute( update(models_tickets.TicketEvent) .where(models_tickets.TicketEvent.id == event_id) - .values(**event_update.dict(exclude_unset=True)), + .values(**event_update.model_dump(exclude_unset=True)), ) @@ -629,7 +667,7 @@ async def update_session( await db.execute( update(models_tickets.EventSession) .where(models_tickets.EventSession.id == session_id) - .values(**session_update.dict(exclude_unset=True)), + .values(**session_update.model_dump(exclude_unset=True)), ) @@ -641,5 +679,17 @@ async def update_category( await db.execute( update(models_tickets.Category) .where(models_tickets.Category.id == category_id) - .values(**category_update.dict(exclude_unset=True)), + .values(**category_update.model_dump(exclude_unset=True)), + ) + + +async def update_question( + question_id: UUID, + question_update: schemas_tickets.QuestionUpdate, + db: AsyncSession, +): + await db.execute( + update(models_tickets.Question) + .where(models_tickets.Question.id == question_id) + .values(**question_update.model_dump(exclude_unset=True)), ) diff --git a/app/core/tickets/endpoints_tickets.py b/app/core/tickets/endpoints_tickets.py index 09ef51b4dd..c2c6a47fd4 100644 --- a/app/core/tickets/endpoints_tickets.py +++ b/app/core/tickets/endpoints_tickets.py @@ -576,6 +576,57 @@ async def update_category( ) +@router.patch( + "/tickets/admin/events/{event_id}/questions/{question_id}", + status_code=204, +) +async def update_question( + event_id: UUID, + question_id: UUID, + question_update: schemas_tickets.QuestionUpdate, + user: CoreUser = Depends( + is_user(), + ), + db: AsyncSession = Depends(get_db), +): + """ + Edit one event for admin + """ + event = await cruds_tickets.get_event_simple_by_id(event_id=event_id, db=db) + if event is None: + raise HTTPException(404, "Event not found") + + if not await utils_mypayment.can_user_manage_events( + user_id=user.id, + store_id=event.store_id, + db=db, + ): + raise HTTPException( + status_code=403, + detail="User is not authorized to manage store's events", + ) + + question = await cruds_tickets.get_question_by_id(question_id=question_id, db=db) + if question is None or question.event_id != event_id: + raise HTTPException(404, "Question not found") + + nb_answers = await cruds_tickets.count_answers_by_question_id( + question_id=question_id, + db=db, + ) + if nb_answers > 0: + raise HTTPException( + 400, + "Cannot update question with answers", + ) + + await cruds_tickets.update_question( + question_id=question_id, + question_update=question_update, + db=db, + ) + + @router.get( "/tickets/admin/events/{event_id}/tickets", response_model=list[schemas_tickets.Ticket], diff --git a/app/core/tickets/schemas_tickets.py b/app/core/tickets/schemas_tickets.py index 837ee6a6d0..9310edf2db 100644 --- a/app/core/tickets/schemas_tickets.py +++ b/app/core/tickets/schemas_tickets.py @@ -115,6 +115,14 @@ class QuestionCreate(BaseModel): required: bool +class QuestionUpdate(BaseModel): + question: str | None = None + answer_type: AnswerType | None = None + price: int | None = None + required: bool | None = None + disabled: bool | None = None + + class EventSimple(BaseModel): id: UUID name: str diff --git a/tests/core/test_tickets.py b/tests/core/test_tickets.py index 4e5fca0417..ef9ea9084b 100644 --- a/tests/core/test_tickets.py +++ b/tests/core/test_tickets.py @@ -1154,6 +1154,68 @@ async def test_update_category(client: TestClient): assert response.status_code == 204 +# update_question + + +def test_update_question_with_non_existing_event(client: TestClient): + response = client.patch( + f"/tickets/admin/events/{uuid.uuid4()}/questions/{global_event_optionnal_question_id}", + headers={"Authorization": f"Bearer {seller_can_manage_event_user_token}"}, + json={ + "question": "Updated Test Question", + }, + ) + assert response.status_code == 404 + assert response.json()["detail"] == "Event not found" + + +def test_update_question_as_non_authorised_seller(client: TestClient): + response = client.patch( + f"/tickets/admin/events/{global_event.id}/questions/{global_event_optionnal_question_id}", + headers={"Authorization": f"Bearer {user_token}"}, + json={ + "question": "Updated Test Question", + }, + ) + assert response.status_code == 403 + assert ( + response.json()["detail"] == "User is not authorized to manage store's events" + ) + + +def test_update_question_with_non_existing_question(client: TestClient): + response = client.patch( + f"/tickets/admin/events/{global_event.id}/questions/{uuid.uuid4()}", + headers={"Authorization": f"Bearer {seller_can_manage_event_user_token}"}, + json={ + "question": "Updated Test Question", + }, + ) + assert response.status_code == 404 + assert response.json()["detail"] == "Question not found" + + +async def test_update_question(client: TestClient): + question_without_tickets = models_tickets.Question( + id=uuid.uuid4(), + event_id=global_event.id, + question="Test Question without tickets", + answer_type=AnswerType.TEXT, + price=None, + required=False, + disabled=False, + ) + await add_object_to_db(question_without_tickets) + response = client.patch( + f"/tickets/admin/events/{global_event.id}/questions/{question_without_tickets.id}", + headers={"Authorization": f"Bearer {seller_can_manage_event_user_token}"}, + json={ + "question": "Updated Test Question", + }, + ) + assert response.status_code == 204 + + # get_event_tickets From e9bf4a80ebea10484e4d5aac1c7894e37f62e4d2 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 6 Apr 2026 18:49:18 +0200 Subject: [PATCH 62/72] Pass NewsEdit to allow exclude unset when updating feed's news --- app/core/feed/utils_feed.py | 18 +++--------------- app/core/tickets/endpoints_tickets.py | 17 +++++++++++++++++ app/modules/calendar/utils_calendar.py | 15 +++++++++------ 3 files changed, 29 insertions(+), 21 deletions(-) diff --git a/app/core/feed/utils_feed.py b/app/core/feed/utils_feed.py index 3f3e588a7a..fd9586ca0b 100644 --- a/app/core/feed/utils_feed.py +++ b/app/core/feed/utils_feed.py @@ -78,12 +78,7 @@ async def create_feed_news( async def edit_feed_news( module: str, module_object_id: uuid.UUID, - title: str | None, - start: datetime | None, - end: datetime | None, - entity: str | None, - location: str | None, - action_start: datetime | None, + news_edit: schemas_feed.NewsEdit, require_feed_admin_approval: bool, db: AsyncSession, notification_tool: NotificationTool, @@ -103,14 +98,7 @@ async def edit_feed_news( await cruds_feed.edit_news_by_module_object_id( module=module, module_object_id=module_object_id, - news_edit=schemas_feed.NewsEdit( - title=title, - start=start, - end=end, - entity=entity, - location=location, - action_start=action_start, - ), + news_edit=news_edit, db=db, ) if require_feed_admin_approval: @@ -123,7 +111,7 @@ async def edit_feed_news( if require_feed_admin_approval: message = Message( title="🔔 Feed - a news has been modified", - content=f"{entity} has modified {title}", + content=f"{news_edit.entity} has modified {news_edit.title}", action_module="feed", ) permission = await cruds_permissions.get_permissions_by_permission_name( diff --git a/app/core/tickets/endpoints_tickets.py b/app/core/tickets/endpoints_tickets.py index c2c6a47fd4..2bbe3ef5af 100644 --- a/app/core/tickets/endpoints_tickets.py +++ b/app/core/tickets/endpoints_tickets.py @@ -14,6 +14,7 @@ from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession +from app.core.feed import schemas_feed, utils_feed from app.core.memberships import utils_memberships from app.core.mypayment import cruds_mypayment, utils_mypayment from app.core.permissions.type_permissions import ModulePermissions @@ -22,11 +23,13 @@ from app.core.users.models_users import CoreUser from app.dependencies import ( get_db, + get_notification_tool, 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 router = APIRouter(tags=["Tickets"]) @@ -441,6 +444,7 @@ async def update_event( is_user(), ), db: AsyncSession = Depends(get_db), + notification_tool: NotificationTool = Depends(get_notification_tool), ): """ Edit one event for admin @@ -459,6 +463,19 @@ async def update_event( detail="User is not authorized to manage store's events", ) + if event_update.open_datetime is not None: + # We want to update the datetime in the feed + await utils_feed.edit_feed_news( + module=core_module.root, + module_object_id=event.id, + news_edit=schemas_feed.NewsEdit( + action_start=event_update.open_datetime, + ), + require_feed_admin_approval=False, + db=db, + notification_tool=notification_tool, + ) + await cruds_tickets.update_event( event_id=event_id, event_update=event_update, diff --git a/app/modules/calendar/utils_calendar.py b/app/modules/calendar/utils_calendar.py index 9c4c108c4c..5a02d6ebc7 100644 --- a/app/modules/calendar/utils_calendar.py +++ b/app/modules/calendar/utils_calendar.py @@ -6,6 +6,7 @@ from icalendar import Calendar, Event, vRecur from sqlalchemy.ext.asyncio import AsyncSession +from app.core.feed import schemas_feed from app.core.feed.utils_feed import create_feed_news, edit_feed_news from app.core.utils.config import Settings from app.modules.calendar import models_calendar @@ -53,12 +54,14 @@ async def edit_event_feed_news( await edit_feed_news( module=root, module_object_id=event.id, - title=event.name, - start=event.start, - end=event.end, - entity=event.association.name, - location=event.location, - action_start=event.ticket_url_opening, + news_edit=schemas_feed.NewsEdit( + title=event.name, + start=event.start, + end=event.end, + entity=event.association.name, + location=event.location, + action_start=event.ticket_url_opening, + ), require_feed_admin_approval=False, db=db, notification_tool=notification_tool, From e0331fcc5eaddd39ec4440c8566073a3043856cf Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 6 Apr 2026 19:42:52 +0200 Subject: [PATCH 63/72] Refactorize: remove Tickets --- app/core/tickets/cruds_tickets.py | 115 ++++++++++++++++++++++++----- app/core/tickets/models_tickets.py | 31 +++----- app/core/tickets/utils_tickets.py | 37 +++------- tests/core/test_tickets.py | 19 +++-- 4 files changed, 133 insertions(+), 69 deletions(-) diff --git a/app/core/tickets/cruds_tickets.py b/app/core/tickets/cruds_tickets.py index 6c1be9b669..ebb626f817 100644 --- a/app/core/tickets/cruds_tickets.py +++ b/app/core/tickets/cruds_tickets.py @@ -391,6 +391,8 @@ async def create_checkout( ) for answer in answers ], + scanned=False, + paid=False, ) db.add(db_checkout) @@ -400,11 +402,11 @@ async def get_tickets_by_user_id( db: AsyncSession, ) -> Sequence[schemas_tickets.Ticket]: result = await db.execute( - select(models_tickets.Ticket) - .where(models_tickets.Ticket.user_id == user_id) + select(models_tickets.Checkout) + .where(models_tickets.Checkout.user_id == user_id) .options( - selectinload(models_tickets.Ticket.category), - selectinload(models_tickets.Ticket.session), + selectinload(models_tickets.Checkout.category), + selectinload(models_tickets.Checkout.session), ), ) return [ @@ -448,12 +450,12 @@ async def get_tickets_by_event_id( db: AsyncSession, ) -> Sequence[schemas_tickets.Ticket]: result = await db.execute( - select(models_tickets.Ticket) - .where(models_tickets.Ticket.event_id == event_id) + select(models_tickets.Checkout) + .where(models_tickets.Checkout.event_id == event_id) .options( - selectinload(models_tickets.Ticket.category), - selectinload(models_tickets.Ticket.session), - selectinload(models_tickets.Ticket.user), + selectinload(models_tickets.Checkout.category), + selectinload(models_tickets.Checkout.session), + selectinload(models_tickets.Checkout.user), ), ) return [ @@ -497,12 +499,12 @@ async def get_ticket_by_id( db: AsyncSession, ) -> schemas_tickets.Ticket | None: result = await db.execute( - select(models_tickets.Ticket) - .where(models_tickets.Ticket.id == ticket_id) + select(models_tickets.Checkout) + .where(models_tickets.Checkout.id == ticket_id) .options( - selectinload(models_tickets.Ticket.category), - selectinload(models_tickets.Ticket.session), - selectinload(models_tickets.Ticket.user), + selectinload(models_tickets.Checkout.category), + selectinload(models_tickets.Checkout.session), + selectinload(models_tickets.Checkout.user), ), ) ticket = result.scalars().first() @@ -547,8 +549,8 @@ async def mark_ticket_as_scanned( db: AsyncSession, ): await db.execute( - update(models_tickets.Ticket) - .where(models_tickets.Ticket.id == ticket_id) + update(models_tickets.Checkout) + .where(models_tickets.Checkout.id == ticket_id) .values(scanned=True), ) @@ -559,7 +561,8 @@ async def count_tickets_by_event_id( ) -> int: result = await db.execute( select(func.count()).where( - models_tickets.Ticket.event_id == event_id, + models_tickets.Checkout.event_id == event_id, + models_tickets.Checkout.paid, ), ) @@ -572,7 +575,8 @@ async def count_tickets_by_category_id( ) -> int: result = await db.execute( select(func.count()).where( - models_tickets.Ticket.category_id == category_id, + models_tickets.Checkout.category_id == category_id, + models_tickets.Checkout.paid, ), ) @@ -585,7 +589,8 @@ async def count_tickets_by_session_id( ) -> int: result = await db.execute( select(func.count()).where( - models_tickets.Ticket.session_id == session_id, + models_tickets.Checkout.session_id == session_id, + models_tickets.Checkout.paid, ), ) @@ -596,10 +601,14 @@ async def count_valid_checkouts_by_event_id( event_id: UUID, db: AsyncSession, ) -> int: + """ + Count only unpaid checkouts that are not expired + """ result = await db.execute( select(func.count()).where( models_tickets.Checkout.event_id == event_id, models_tickets.Checkout.expiration >= datetime.now(UTC), + not_(models_tickets.Checkout.paid), ), ) @@ -610,10 +619,14 @@ async def count_valid_checkouts_by_category_id( category_id: UUID, db: AsyncSession, ) -> int: + """ + Count only unpaid checkouts that are not expired + """ result = await db.execute( select(func.count()).where( models_tickets.Checkout.category_id == category_id, models_tickets.Checkout.expiration >= datetime.now(UTC), + not_(models_tickets.Checkout.paid), ), ) @@ -624,10 +637,74 @@ async def count_valid_checkouts_by_session_id( session_id: UUID, db: AsyncSession, ) -> int: + """ + Count only unpaid checkouts that are not expired + """ result = await db.execute( select(func.count()).where( models_tickets.Checkout.session_id == session_id, models_tickets.Checkout.expiration >= datetime.now(UTC), + not_(models_tickets.Checkout.paid), + ), + ) + + return result.scalar() or 0 + + +async def count_valid_checkouts_and_tickets_by_event_id( + event_id: UUID, + db: AsyncSession, +) -> int: + """ + Count unpaid checkouts that are not expired and paid tickets + """ + result = await db.execute( + select(func.count()).where( + models_tickets.Checkout.event_id == event_id, + or_( + models_tickets.Checkout.paid, + models_tickets.Checkout.expiration >= datetime.now(UTC), + ), + ), + ) + + return result.scalar() or 0 + + +async def count_valid_checkouts_and_tickets_by_category_id( + category_id: UUID, + db: AsyncSession, +) -> int: + """ + Count unpaid checkouts that are not expired and paid tickets + """ + result = await db.execute( + select(func.count()).where( + models_tickets.Checkout.category_id == category_id, + or_( + models_tickets.Checkout.paid, + models_tickets.Checkout.expiration >= datetime.now(UTC), + ), + ), + ) + + return result.scalar() or 0 + + +async def count_valid_checkouts_and_tickets_by_session_id( + session_id: UUID, + db: AsyncSession, +) -> int: + """ + Count unpaid checkouts that are not expired and paid tickets + """ + result = await db.execute( + select(func.count()).where( + models_tickets.Checkout.session_id == session_id, + or_( + models_tickets.Checkout.paid, + models_tickets.Checkout.expiration >= datetime.now(UTC), + ), ), ) diff --git a/app/core/tickets/models_tickets.py b/app/core/tickets/models_tickets.py index 2e71692c6f..91aa1472bc 100644 --- a/app/core/tickets/models_tickets.py +++ b/app/core/tickets/models_tickets.py @@ -96,27 +96,11 @@ class Answer(Base): answer: Mapped[str] -class Ticket(Base): - __tablename__ = "tickets_ticket" - - id: Mapped[PrimaryKey] - - category_id: Mapped[UUID] = mapped_column(ForeignKey("tickets_category.id")) - session_id: Mapped[UUID] = mapped_column(ForeignKey("tickets_session.id")) - event_id: Mapped[UUID] = mapped_column(ForeignKey("tickets_event.id")) - - user_id: Mapped[str] = mapped_column(ForeignKey("core_user.id")) - - price: Mapped[int] # in cents - - scanned: Mapped[bool] - - category: Mapped["Category"] = relationship(init=False) - session: Mapped["EventSession"] = relationship(init=False) - user: Mapped[models_users.CoreUser] = relationship(init=False) - - class Checkout(Base): + """ + A checkout represents a pending or validated ticket purchase. + """ + __tablename__ = "tickets_checkout" id: Mapped[PrimaryKey] @@ -131,7 +115,14 @@ class Checkout(Base): user_id: Mapped[str] = mapped_column(ForeignKey("core_user.id")) + # If a checkout is paid we should consider the user has a ticket + paid: Mapped[bool] + # We can mark the corresponding ticket as scanned + scanned: Mapped[bool] + answers: Mapped[list[Answer]] = relationship() # Do we need this? user: Mapped[models_users.CoreUser] = relationship(init=False) + category: Mapped["Category"] = relationship(init=False) + session: Mapped["EventSession"] = relationship(init=False) diff --git a/app/core/tickets/utils_tickets.py b/app/core/tickets/utils_tickets.py index 4bc3d0b19b..9ae87232cc 100644 --- a/app/core/tickets/utils_tickets.py +++ b/app/core/tickets/utils_tickets.py @@ -19,17 +19,14 @@ async def is_event_sold_out( if quota is None: return False - nb_valid_checkout_for_event = await cruds_tickets.count_valid_checkouts_by_event_id( - event_id=event_id, - db=db, - ) - - nb_tickets_sold_for_event = await cruds_tickets.count_tickets_by_event_id( - event_id=event_id, - db=db, + nb_valid_checkouts_and_tickets_by_event_id = ( + await cruds_tickets.count_valid_checkouts_and_tickets_by_event_id( + event_id=event_id, + db=db, + ) ) - return (nb_valid_checkout_for_event + nb_tickets_sold_for_event) >= quota + return nb_valid_checkouts_and_tickets_by_event_id >= quota async def is_category_sold_out( @@ -40,19 +37,14 @@ async def is_category_sold_out( if quota is None: return False - nb_valid_checkout_for_category = ( - await cruds_tickets.count_valid_checkouts_by_category_id( + nb_valid_checkouts_and_tickets_by_category_id = ( + await cruds_tickets.count_valid_checkouts_and_tickets_by_category_id( category_id=category_id, db=db, ) ) - nb_tickets_sold_for_category = await cruds_tickets.count_tickets_by_category_id( - category_id=category_id, - db=db, - ) - - return (nb_valid_checkout_for_category + nb_tickets_sold_for_category) >= quota + return nb_valid_checkouts_and_tickets_by_category_id >= quota async def is_session_sold_out( @@ -63,19 +55,14 @@ async def is_session_sold_out( if quota is None: return False - nb_valid_checkout_for_session = ( - await cruds_tickets.count_valid_checkouts_by_session_id( + nb_valid_checkouts_and_tickets_by_session_id = ( + await cruds_tickets.count_valid_checkouts_and_tickets_by_session_id( session_id=session_id, db=db, ) ) - nb_tickets_sold_for_session = await cruds_tickets.count_tickets_by_session_id( - session_id=session_id, - db=db, - ) - - return (nb_valid_checkout_for_session + nb_tickets_sold_for_session) >= quota + return nb_valid_checkouts_and_tickets_by_session_id >= quota async def convert_to_event_admin( diff --git a/tests/core/test_tickets.py b/tests/core/test_tickets.py index ef9ea9084b..de40a858f5 100644 --- a/tests/core/test_tickets.py +++ b/tests/core/test_tickets.py @@ -49,9 +49,9 @@ sold_out_event: models_tickets.TicketEvent session_sold_out_event: models_tickets.EventSession category_sold_out_event: models_tickets.Category -ticket_sold_out_event: models_tickets.Ticket +ticket_sold_out_event: models_tickets.Checkout -ticket: models_tickets.Ticket +ticket: models_tickets.Checkout @pytest_asyncio.fixture(scope="module", autouse=True) @@ -219,7 +219,7 @@ async def init_objects() -> None: required_membership=None, ) await add_object_to_db(event_sold_out_category) - ticket_sold_out_category = models_tickets.Ticket( + ticket_sold_out_category = models_tickets.Checkout( id=uuid.uuid4(), category_id=event_sold_out_category.id, session_id=event_session.id, @@ -227,6 +227,9 @@ async def init_objects() -> None: user_id=user.id, price=10, scanned=False, + paid=True, + expiration=datetime.now(tz=UTC) + timedelta(hours=1), + answers=[], ) await add_object_to_db(ticket_sold_out_category) event_sold_out_session = models_tickets.EventSession( @@ -238,7 +241,7 @@ async def init_objects() -> None: disabled=False, ) await add_object_to_db(event_sold_out_session) - ticket_sold_out_session = models_tickets.Ticket( + ticket_sold_out_session = models_tickets.Checkout( id=uuid.uuid4(), category_id=event_category.id, session_id=event_sold_out_session.id, @@ -246,6 +249,9 @@ async def init_objects() -> None: user_id=user.id, price=10, scanned=False, + paid=True, + expiration=datetime.now(tz=UTC) + timedelta(hours=1), + answers=[], ) await add_object_to_db(ticket_sold_out_session) @@ -286,7 +292,7 @@ async def init_objects() -> None: ) await add_object_to_db(sold_out_event) user = await create_user_with_groups(groups=[]) - ticket_sold_out_event = models_tickets.Ticket( + ticket_sold_out_event = models_tickets.Checkout( id=uuid.uuid4(), category_id=category_sold_out_event.id, session_id=session_sold_out_event.id, @@ -294,6 +300,9 @@ async def init_objects() -> None: user_id=user.id, price=10, scanned=False, + paid=True, + expiration=datetime.now(tz=UTC) + timedelta(hours=1), + answers=[], ) await add_object_to_db(ticket_sold_out_event) From 5d1695e8eedfddce1fc4043366c3598830a81bcb Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 6 Apr 2026 19:50:38 +0200 Subject: [PATCH 64/72] Migration --- migrations/versions/68-tickets.py | 72 +++++++++++++++++++++---------- 1 file changed, 50 insertions(+), 22 deletions(-) diff --git a/migrations/versions/68-tickets.py b/migrations/versions/68-tickets.py index 13a7655dca..04537fc5db 100644 --- a/migrations/versions/68-tickets.py +++ b/migrations/versions/68-tickets.py @@ -22,7 +22,6 @@ def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### op.create_table( "tickets_event", sa.Column("id", sa.Uuid(), nullable=False), @@ -31,6 +30,7 @@ def upgrade() -> None: sa.Column("open_datetime", TZDateTime(), nullable=False), sa.Column("close_datetime", TZDateTime(), nullable=True), sa.Column("quota", sa.Integer(), nullable=True), + sa.Column("disabled", sa.Boolean(), nullable=False), sa.ForeignKeyConstraint( ["store_id"], ["mypayment_store.id"], @@ -43,6 +43,7 @@ def upgrade() -> None: sa.Column("event_id", sa.Uuid(), nullable=False), sa.Column("name", sa.String(), nullable=False), sa.Column("quota", sa.Integer(), nullable=True), + sa.Column("disabled", sa.Boolean(), nullable=False), sa.Column("price", sa.Integer(), nullable=False), sa.Column("required_membership", sa.Uuid(), nullable=True), sa.ForeignKeyConstraint( @@ -55,6 +56,25 @@ def upgrade() -> None: ), sa.PrimaryKeyConstraint("id"), ) + op.create_table( + "tickets_question", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("event_id", sa.Uuid(), nullable=False), + sa.Column("question", sa.String(), nullable=False), + sa.Column( + "answer_type", + sa.Enum("TEXT", "NUMBER", "BOOLEAN", name="answertype"), + nullable=False, + ), + sa.Column("price", sa.Integer(), nullable=True), + sa.Column("required", sa.Boolean(), nullable=False), + sa.Column("disabled", sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint( + ["event_id"], + ["tickets_event.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) op.create_table( "tickets_session", sa.Column("id", sa.Uuid(), nullable=False), @@ -62,6 +82,7 @@ def upgrade() -> None: sa.Column("name", sa.String(), nullable=False), sa.Column("start_datetime", TZDateTime(), nullable=False), sa.Column("quota", sa.Integer(), nullable=True), + sa.Column("disabled", sa.Boolean(), nullable=False), sa.ForeignKeyConstraint( ["event_id"], ["tickets_event.id"], @@ -77,6 +98,8 @@ def upgrade() -> None: sa.Column("price", sa.Integer(), nullable=False), sa.Column("expiration", TZDateTime(), nullable=False), sa.Column("user_id", sa.String(), nullable=False), + sa.Column("paid", sa.Boolean(), nullable=False), + sa.Column("scanned", sa.Boolean(), nullable=False), sa.ForeignKeyConstraint( ["category_id"], ["tickets_category.id"], @@ -96,41 +119,46 @@ def upgrade() -> None: sa.PrimaryKeyConstraint("id"), ) op.create_table( - "tickets_ticket", + "tickets_answer", sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("category_id", sa.Uuid(), nullable=False), - sa.Column("session_id", sa.Uuid(), nullable=False), - sa.Column("event_id", sa.Uuid(), nullable=False), - sa.Column("user_id", sa.String(), nullable=False), - sa.Column("price", sa.Integer(), nullable=False), - sa.Column("scanned", sa.Boolean(), nullable=False), + sa.Column("question_id", sa.Uuid(), nullable=False), + sa.Column("checkout_id", sa.Uuid(), nullable=False), + sa.Column("answer", sa.String(), nullable=False), sa.ForeignKeyConstraint( - ["category_id"], - ["tickets_category.id"], - ), - sa.ForeignKeyConstraint( - ["event_id"], - ["tickets_event.id"], - ), - sa.ForeignKeyConstraint( - ["session_id"], - ["tickets_session.id"], + ["checkout_id"], + ["tickets_checkout.id"], ), sa.ForeignKeyConstraint( - ["user_id"], - ["core_user.id"], + ["question_id"], + ["tickets_question.id"], ), sa.PrimaryKeyConstraint("id"), ) - # ### end Alembic commands ### + op.add_column( + "calendar_events", sa.Column("ticket_event_id", sa.Uuid(), nullable=True), + ) + op.create_foreign_key( + "calendar_events_ticket_event_id_fkey", + "calendar_events", + "tickets_event", + ["ticket_event_id"], + ["id"], + ) def downgrade() -> None: + op.drop_constraint( + "calendar_events_ticket_event_id_fkey", + "calendar_events", + type_="foreignkey", + ) + op.drop_table("tickets_answer") + op.drop_table("tickets_question") op.drop_table("tickets_checkout") - op.drop_table("tickets_ticket") op.drop_table("tickets_session") op.drop_table("tickets_category") op.drop_table("tickets_event") + sa.Enum("TEXT", "NUMBER", "BOOLEAN", name="answertype").drop(op.get_bind()) def pre_test_upgrade( From 94ee8ff142531ff9fef72277bce4d2a4d27a50af Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 6 Apr 2026 19:53:03 +0200 Subject: [PATCH 65/72] Utils check_answer_validity_and_calculate_price --- app/core/tickets/endpoints_tickets.py | 46 +--------------------- app/core/tickets/utils_tickets.py | 56 +++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 44 deletions(-) diff --git a/app/core/tickets/endpoints_tickets.py b/app/core/tickets/endpoints_tickets.py index 2bbe3ef5af..9b4fd965bd 100644 --- a/app/core/tickets/endpoints_tickets.py +++ b/app/core/tickets/endpoints_tickets.py @@ -212,53 +212,11 @@ async def create_checkout( "User does not have required membership to choose this category", ) - price = 0 - - questions = await cruds_tickets.get_questions_by_event_id( + price = await utils_tickets.check_answer_validity_and_calculate_price( event_id=event_id, + checkout=checkout, db=db, ) - questions_dict = {question.id: question for question in questions} - required_questions_ids = { - question.id for question in questions if question.required - } - answered_questions_ids = set() - - for answer in checkout.answers: - if answer.question_id in answered_questions_ids: - raise HTTPException( - 400, - f"Question with id {answer.question_id} is answered multiple times", - ) - answered_questions_ids.add(answer.question_id) - required_questions_ids.discard(answer.question_id) - - question = questions_dict.get(answer.question_id) - if question is None: - raise HTTPException( - 400, - f"Question with id {answer.question_id} not found for this event", - ) - if question.disabled: - raise HTTPException( - 400, - f"Question with id {answer.question_id} is disabled", - ) - - if question.answer_type != answer.answer_type: - raise HTTPException( - 400, - f"Answer type for question with id {answer.question_id} should be {question.answer_type.value}", - ) - - if question.price is not None: - price += question.price - - if len(required_questions_ids) > 0: - raise HTTPException( - 400, - f"Answers for questions {', '.join(str(q) for q in required_questions_ids)} are required", - ) # By putting this lock: # - we unsure that if an other endpoint execution acquired the lock before, this one will wait. diff --git a/app/core/tickets/utils_tickets.py b/app/core/tickets/utils_tickets.py index 9ae87232cc..db16211145 100644 --- a/app/core/tickets/utils_tickets.py +++ b/app/core/tickets/utils_tickets.py @@ -158,3 +158,59 @@ async def get_events_from_store( store_id=store_id, db=db, ) + + +async def check_answer_validity_and_calculate_price( + event_id: UUID, + checkout: schemas_tickets.Checkout, + db: AsyncSession, +) -> int: + price = 0 + + questions = await cruds_tickets.get_questions_by_event_id( + event_id=event_id, + db=db, + ) + questions_dict = {question.id: question for question in questions} + required_questions_ids = { + question.id for question in questions if question.required + } + answered_questions_ids = set() + + for answer in checkout.answers: + if answer.question_id in answered_questions_ids: + raise HTTPException( + 400, + f"Question with id {answer.question_id} is answered multiple times", + ) + answered_questions_ids.add(answer.question_id) + required_questions_ids.discard(answer.question_id) + + question = questions_dict.get(answer.question_id) + if question is None: + raise HTTPException( + 400, + f"Question with id {answer.question_id} not found for this event", + ) + if question.disabled: + raise HTTPException( + 400, + f"Question with id {answer.question_id} is disabled", + ) + + if question.answer_type != answer.answer_type: + raise HTTPException( + 400, + f"Answer type for question with id {answer.question_id} should be {question.answer_type.value}", + ) + + if question.price is not None: + price += question.price + + if len(required_questions_ids) > 0: + raise HTTPException( + 400, + f"Answers for questions {', '.join(str(q) for q in required_questions_ids)} are required", + ) + + return price From ceb5f66540a0e085feb3b57b02f7f406d92e2b4d Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 6 Apr 2026 19:53:07 +0200 Subject: [PATCH 66/72] Lint --- migrations/versions/68-tickets.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/migrations/versions/68-tickets.py b/migrations/versions/68-tickets.py index 04537fc5db..d91524cf09 100644 --- a/migrations/versions/68-tickets.py +++ b/migrations/versions/68-tickets.py @@ -135,7 +135,8 @@ def upgrade() -> None: sa.PrimaryKeyConstraint("id"), ) op.add_column( - "calendar_events", sa.Column("ticket_event_id", sa.Uuid(), nullable=True), + "calendar_events", + sa.Column("ticket_event_id", sa.Uuid(), nullable=True), ) op.create_foreign_key( "calendar_events_ticket_event_id_fkey", From 6519d5a0f24dba4841aa89569ae634c9fea7cb13 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:54:42 +0200 Subject: [PATCH 67/72] Lint --- app/core/mypayment/utils_mypayment.py | 1 + tests/core/test_mypayment.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/app/core/mypayment/utils_mypayment.py b/app/core/mypayment/utils_mypayment.py index 0fc5e77b26..6136484692 100644 --- a/app/core/mypayment/utils_mypayment.py +++ b/app/core/mypayment/utils_mypayment.py @@ -5,6 +5,7 @@ from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives.asymmetric import ed25519 +from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession from app.core.checkout import schemas_checkout diff --git a/tests/core/test_mypayment.py b/tests/core/test_mypayment.py index ebac02c9e3..b79a5257fd 100644 --- a/tests/core/test_mypayment.py +++ b/tests/core/test_mypayment.py @@ -21,7 +21,6 @@ from app.core.mypayment.coredata_mypayment import ( MyPaymentBankAccountHolder, ) -from app.core.mypayment.schemas_mypayment import QRCodeContentData from app.core.mypayment.endpoints_mypayment import MyPaymentPermissions from app.core.mypayment.schemas_mypayment import ( SecuredContentData, From 881c911576ac633c52334d4336c22cddbb49c62c Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 6 Apr 2026 22:50:22 +0200 Subject: [PATCH 68/72] Rebase migrations --- ...stores_coreassociations.py => 67-stores_coreassociations.py} | 2 +- migrations/versions/{67-mypayment.py => 69-mypayment.py} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename migrations/versions/{66-stores_coreassociations.py => 67-stores_coreassociations.py} (96%) rename migrations/versions/{67-mypayment.py => 69-mypayment.py} (100%) diff --git a/migrations/versions/66-stores_coreassociations.py b/migrations/versions/67-stores_coreassociations.py similarity index 96% rename from migrations/versions/66-stores_coreassociations.py rename to migrations/versions/67-stores_coreassociations.py index e30966c34e..b648e06881 100644 --- a/migrations/versions/66-stores_coreassociations.py +++ b/migrations/versions/67-stores_coreassociations.py @@ -14,7 +14,7 @@ # revision identifiers, used by Alembic. revision: str = "146db8dcb23e" -down_revision: str | None = "562adbd796ae" +down_revision: str | None = "46fbbcee7237" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None diff --git a/migrations/versions/67-mypayment.py b/migrations/versions/69-mypayment.py similarity index 100% rename from migrations/versions/67-mypayment.py rename to migrations/versions/69-mypayment.py From 639b6d31602d21bfa89253b6c528697db0e1ea9f Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 6 Apr 2026 23:03:32 +0200 Subject: [PATCH 69/72] Payment link --- app/core/mypayment/schemas_mypayment.py | 2 + app/core/tickets/endpoints_tickets.py | 51 ++++++++++++++++++++++++- app/core/tickets/schemas_tickets.py | 2 + tests/core/test_tickets.py | 21 ++++++++++ 4 files changed, 75 insertions(+), 1 deletion(-) diff --git a/app/core/mypayment/schemas_mypayment.py b/app/core/mypayment/schemas_mypayment.py index d5cd768f04..3184752112 100644 --- a/app/core/mypayment/schemas_mypayment.py +++ b/app/core/mypayment/schemas_mypayment.py @@ -362,7 +362,9 @@ class RequestInfo(BaseModel): total: int name: str note: str | None + # Module root module: str + # Id of the object from the module, this id will be passed to the module in the transaction callback object_id: UUID diff --git a/app/core/tickets/endpoints_tickets.py b/app/core/tickets/endpoints_tickets.py index 9b4fd965bd..abd295b8a2 100644 --- a/app/core/tickets/endpoints_tickets.py +++ b/app/core/tickets/endpoints_tickets.py @@ -14,16 +14,22 @@ from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession +from app.core.checkout.payment_tool import PaymentTool +from app.core.checkout.types_checkout import HelloAssoConfigName from app.core.feed import schemas_feed, utils_feed from app.core.memberships import utils_memberships -from app.core.mypayment import cruds_mypayment, utils_mypayment +from app.core.mypayment import cruds_mypayment, schemas_mypayment, utils_mypayment from app.core.permissions.type_permissions import ModulePermissions 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.models_users import CoreUser +from app.core.utils.config import Settings from app.dependencies import ( get_db, get_notification_tool, + get_payment_tool, + get_settings, is_user, is_user_allowed_to, ) @@ -174,6 +180,11 @@ async def create_checkout( ), ), db: AsyncSession = Depends(get_db), + notification_tool: NotificationTool = Depends(get_notification_tool), + settings: Settings = Depends(get_settings), + payment_tool: PaymentTool = Depends( + get_payment_tool(HelloAssoConfigName.MYPAYMENT), + ), ): """ Create a checkout for an open event @@ -263,6 +274,7 @@ async def create_checkout( ): raise HTTPException(400, "Session is sold out") + checkout_id = uuid.uuid4() await cruds_tickets.create_checkout( checkout_id=uuid.uuid4(), event_id=event_id, @@ -275,6 +287,43 @@ async def create_checkout( db=db, ) + if checkout.payment_method == "myempay": + await utils_mypayment.request_transaction( + request_info=schemas_mypayment.RequestInfo( + user_id=user.id, + store_id=event.store_id, + total=price, + name=f"Ticket for event {event.name}", + note="Ticket purchase", + module=core_module.root, + object_id=checkout_id, + ), + db=db, + notification_tool=notification_tool, + settings=settings, + ) + else: + await utils_mypayment.request_store_transfer( + transfer_info=schemas_mypayment.StoreTransferInfo( + store_id=event.store_id, + module=core_module.root, + amount=price, + object_id=checkout_id, + redirect_url="", + ), + user=schemas_users.CoreUser( + id=user.id, + name=user.name, + firstname=user.firstname, + account_type=user.account_type, + school_id=user.school_id, + email=user.email, + ), + db=db, + settings=settings, + payment_tool=payment_tool, + ) + # TODO: return the payment id return schemas_tickets.CheckoutResponse( price=price, diff --git a/app/core/tickets/schemas_tickets.py b/app/core/tickets/schemas_tickets.py index 9310edf2db..a2a0af60ce 100644 --- a/app/core/tickets/schemas_tickets.py +++ b/app/core/tickets/schemas_tickets.py @@ -225,6 +225,8 @@ class Checkout(BaseModel): category_id: UUID session_id: UUID answers: list[Answer] + # TODO: use an enum + payment_method: str class CheckoutResponse(BaseModel): diff --git a/tests/core/test_tickets.py b/tests/core/test_tickets.py index de40a858f5..c2cd3b6610 100644 --- a/tests/core/test_tickets.py +++ b/tests/core/test_tickets.py @@ -358,6 +358,7 @@ def test_create_checkout_with_invalid_category(client: TestClient): "category_id": str(uuid.uuid4()), "session_id": str(session_sold_out_event.id), "answers": [], + "payment_method": "myempay", }, ) assert response.status_code == 404 @@ -372,6 +373,7 @@ def test_create_checkout_with_disabled_category(client: TestClient): "category_id": str(event_disabled_category.id), "session_id": str(event_session.id), "answers": [], + "payment_method": "myempay", }, ) assert response.status_code == 400 @@ -386,6 +388,7 @@ def test_create_checkout_with_invalid_session(client: TestClient): "category_id": str(category_sold_out_event.id), "session_id": str(uuid.uuid4()), "answers": [], + "payment_method": "myempay", }, ) assert response.status_code == 404 @@ -400,6 +403,7 @@ def test_create_checkout_with_disabled_session(client: TestClient): "category_id": str(event_category.id), "session_id": str(event_disabled_session.id), "answers": [], + "payment_method": "myempay", }, ) assert response.status_code == 400 @@ -449,6 +453,7 @@ async def test_create_checkout_with_disabled_event(client: TestClient): "category_id": str(category_id), "session_id": str(session_id), "answers": [], + "payment_method": "myempay", }, ) assert response.status_code == 400 @@ -498,6 +503,7 @@ async def test_create_checkout_with_not_open_event(client: TestClient): "category_id": str(category_id), "session_id": str(session_id), "answers": [], + "payment_method": "myempay", }, ) assert response.status_code == 400 @@ -547,6 +553,7 @@ async def test_create_checkout_with_closed_event(client: TestClient): "category_id": str(category_id), "session_id": str(session_id), "answers": [], + "payment_method": "myempay", }, ) assert response.status_code == 400 @@ -561,6 +568,7 @@ def test_create_checkout_with_category_from_another_event(client: TestClient): "category_id": str(event_category.id), "session_id": str(session_sold_out_event.id), "answers": [], + "payment_method": "myempay", }, ) assert response.status_code == 400 @@ -575,6 +583,7 @@ def test_create_checkout_with_session_from_another_event(client: TestClient): "category_id": str(category_sold_out_event.id), "session_id": str(event_session.id), "answers": [], + "payment_method": "myempay", }, ) assert response.status_code == 400 @@ -589,6 +598,7 @@ def test_create_checkout_with_sold_out_event(client: TestClient): "category_id": str(category_sold_out_event.id), "session_id": str(session_sold_out_event.id), "answers": [], + "payment_method": "myempay", }, ) assert response.status_code == 400 @@ -603,6 +613,7 @@ def test_create_checkout_with_sold_out_category(client: TestClient): "category_id": str(event_sold_out_category.id), "session_id": str(event_session.id), "answers": [], + "payment_method": "myempay", }, ) assert response.status_code == 400 @@ -617,6 +628,7 @@ def test_create_checkout_with_sold_out_session(client: TestClient): "category_id": str(event_category.id), "session_id": str(event_sold_out_session.id), "answers": [], + "payment_method": "myempay", }, ) assert response.status_code == 400 @@ -666,6 +678,7 @@ async def test_create_checkout_with_missing_membership(client: TestClient): "category_id": str(event_with_required_membership_category_id), "session_id": str(event_with_required_membership_session_id), "answers": [], + "payment_method": "myempay", }, ) assert response.status_code == 400 @@ -694,6 +707,7 @@ def test_create_checkout_with_answer_present_multiple_times(client: TestClient): "answer": "Test Answer 2", }, ], + "payment_method": "myempay", }, ) assert response.status_code == 400 @@ -719,6 +733,7 @@ def test_create_checkout_with_invalid_question_id(client: TestClient): "answer": "Test Answer", }, ], + "payment_method": "myempay", }, ) assert response.status_code == 400 @@ -742,6 +757,7 @@ def test_create_checkout_with_disabled_question(client: TestClient): "answer": "Test Answer", }, ], + "payment_method": "myempay", }, ) assert response.status_code == 400 @@ -765,6 +781,7 @@ def test_create_checkout_with_invalid_answer_type(client: TestClient): "answer": 3, }, ], + "payment_method": "myempay", }, ) assert response.status_code == 400 @@ -830,6 +847,7 @@ async def test_create_checkout_with_missing_required_question(client: TestClient "category_id": str(event_with_required_question_category_id), "session_id": str(event_with_required_question_session_id), "answers": [], + "payment_method": "myempay", }, ) assert response.status_code == 400 @@ -852,8 +870,11 @@ def test_create_checkout(client: TestClient): "answer": "Test Answer", }, ], + "payment_method": "test", }, ) + # TODO + # assert response.json() == "" assert response.status_code == 201 # Price of the event + price of the optionnal question assert response.json()["price"] == 1000 + 100 From 4fca01ca06c8c794833550c58a540036ad4cc7b9 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Tue, 7 Apr 2026 19:28:48 +0200 Subject: [PATCH 70/72] Fix payment --- app/core/mypayment/schemas_mypayment.py | 2 - app/core/mypayment/utils_mypayment.py | 13 ++++++ app/core/tickets/endpoints_tickets.py | 62 ++++++++++--------------- app/core/tickets/schemas_tickets.py | 6 ++- 4 files changed, 42 insertions(+), 41 deletions(-) diff --git a/app/core/mypayment/schemas_mypayment.py b/app/core/mypayment/schemas_mypayment.py index 3184752112..30935d46f6 100644 --- a/app/core/mypayment/schemas_mypayment.py +++ b/app/core/mypayment/schemas_mypayment.py @@ -1,5 +1,4 @@ from datetime import datetime -from typing import Literal from uuid import UUID from pydantic import ( @@ -11,7 +10,6 @@ from app.core.memberships import schemas_memberships from app.core.mypayment.types_mypayment import ( HistoryType, - MyPaymentCallType, RequestStatus, TransactionStatus, TransactionType, diff --git a/app/core/mypayment/utils_mypayment.py b/app/core/mypayment/utils_mypayment.py index 6136484692..da625423a2 100644 --- a/app/core/mypayment/utils_mypayment.py +++ b/app/core/mypayment/utils_mypayment.py @@ -383,3 +383,16 @@ async def call_mypayment_callback( hyperion_error_logger.exception( f"MyPayment: call to module {module_root} request callback ({id_name}: {call_id}) failed", ) + + +async def can_user_manage_events( + user_id: str, + store_id: UUID, + db: AsyncSession, +): + seller = await cruds_mypayment.get_seller( + user_id=user_id, + store_id=store_id, + db=db, + ) + return seller is not None and seller.can_manage_events diff --git a/app/core/tickets/endpoints_tickets.py b/app/core/tickets/endpoints_tickets.py index abd295b8a2..92f03c4e09 100644 --- a/app/core/tickets/endpoints_tickets.py +++ b/app/core/tickets/endpoints_tickets.py @@ -287,47 +287,35 @@ async def create_checkout( db=db, ) - if checkout.payment_method == "myempay": - await utils_mypayment.request_transaction( - request_info=schemas_mypayment.RequestInfo( - user_id=user.id, - store_id=event.store_id, - total=price, - name=f"Ticket for event {event.name}", - note="Ticket purchase", - module=core_module.root, - object_id=checkout_id, - ), - db=db, - notification_tool=notification_tool, - settings=settings, - ) - else: - await utils_mypayment.request_store_transfer( - transfer_info=schemas_mypayment.StoreTransferInfo( - store_id=event.store_id, - module=core_module.root, - amount=price, - object_id=checkout_id, - redirect_url="", - ), - user=schemas_users.CoreUser( - id=user.id, - name=user.name, - firstname=user.firstname, - account_type=user.account_type, - school_id=user.school_id, - email=user.email, - ), - db=db, - settings=settings, - payment_tool=payment_tool, - ) + payment_url = await utils_mypayment.request_payment( + payment_type=checkout.mypayment_request_method, + payment_info=schemas_mypayment.PaymentInfo( + store_id=event.store_id, + total=price, + name=f"event {event.name}", + note=f"Ticket for {event.name}", + module=core_module.root, + object_id=checkout_id, + redirect_url=checkout.mypayment_transfer_redirect_url, + ), + db=db, + user=schemas_users.CoreUser( + id=user.id, + name=user.name, + firstname=user.firstname, + account_type=user.account_type, + school_id=user.school_id, + email=user.email, + ), + notification_tool=notification_tool, + settings=settings, + payment_tool=payment_tool, + ) - # TODO: return the payment id return schemas_tickets.CheckoutResponse( price=price, expiration=expiration, + payment_url=payment_url.url if payment_url is not None else None, ) diff --git a/app/core/tickets/schemas_tickets.py b/app/core/tickets/schemas_tickets.py index a2a0af60ce..8f6767d4e7 100644 --- a/app/core/tickets/schemas_tickets.py +++ b/app/core/tickets/schemas_tickets.py @@ -6,6 +6,7 @@ BaseModel, ) +from app.core.mypayment.types_mypayment import MyPaymentCallType from app.core.tickets.types_tickets import AnswerType from app.core.users import schemas_users @@ -225,10 +226,11 @@ class Checkout(BaseModel): category_id: UUID session_id: UUID answers: list[Answer] - # TODO: use an enum - payment_method: str + mypayment_request_method: MyPaymentCallType + mypayment_transfer_redirect_url: str class CheckoutResponse(BaseModel): price: int expiration: datetime + payment_url: str | None From 53883d0c6835296b5f487c908504c4e92c221009 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Tue, 7 Apr 2026 19:42:10 +0200 Subject: [PATCH 71/72] Mypayment callback --- app/core/tickets/cruds_tickets.py | 11 ++++ app/core/tickets/endpoints_tickets.py | 56 ++++++++++------- app/core/tickets/utils_tickets.py | 15 +++++ tests/core/test_tickets.py | 90 ++++++++++++++++++++------- 4 files changed, 127 insertions(+), 45 deletions(-) diff --git a/app/core/tickets/cruds_tickets.py b/app/core/tickets/cruds_tickets.py index ebb626f817..455703c29e 100644 --- a/app/core/tickets/cruds_tickets.py +++ b/app/core/tickets/cruds_tickets.py @@ -397,6 +397,17 @@ async def create_checkout( db.add(db_checkout) +async def mark_checkout_as_paid( + checkout_id: UUID, + db: AsyncSession, +): + await db.execute( + update(models_tickets.Checkout) + .where(models_tickets.Checkout.id == checkout_id) + .values(paid=True), + ) + + async def get_tickets_by_user_id( user_id: str, db: AsyncSession, diff --git a/app/core/tickets/endpoints_tickets.py b/app/core/tickets/endpoints_tickets.py index 92f03c4e09..83d99af45c 100644 --- a/app/core/tickets/endpoints_tickets.py +++ b/app/core/tickets/endpoints_tickets.py @@ -44,6 +44,7 @@ tag="Tickets", router=router, factory=TicketsFactory(), + mypayment_callback=utils_tickets.mypayment_callback_callback, ) CHECKOUT_EXPIRATION_MINUTES = 15 @@ -287,30 +288,37 @@ async def create_checkout( db=db, ) - payment_url = await utils_mypayment.request_payment( - payment_type=checkout.mypayment_request_method, - payment_info=schemas_mypayment.PaymentInfo( - store_id=event.store_id, - total=price, - name=f"event {event.name}", - note=f"Ticket for {event.name}", - module=core_module.root, - object_id=checkout_id, - redirect_url=checkout.mypayment_transfer_redirect_url, - ), - db=db, - user=schemas_users.CoreUser( - id=user.id, - name=user.name, - firstname=user.firstname, - account_type=user.account_type, - school_id=user.school_id, - email=user.email, - ), - notification_tool=notification_tool, - settings=settings, - payment_tool=payment_tool, - ) + payment_url = None + if price == 0: + await cruds_tickets.mark_checkout_as_paid( + checkout_id=checkout_id, + db=db, + ) + else: + payment_url = await utils_mypayment.request_payment( + payment_type=checkout.mypayment_request_method, + payment_info=schemas_mypayment.PaymentInfo( + store_id=event.store_id, + total=price, + name=f"event {event.name}", + note=f"Ticket for {event.name}", + module=core_module.root, + object_id=checkout_id, + redirect_url=checkout.mypayment_transfer_redirect_url, + ), + db=db, + user=schemas_users.CoreUser( + id=user.id, + name=user.name, + firstname=user.firstname, + account_type=user.account_type, + school_id=user.school_id, + email=user.email, + ), + notification_tool=notification_tool, + settings=settings, + payment_tool=payment_tool, + ) return schemas_tickets.CheckoutResponse( price=price, diff --git a/app/core/tickets/utils_tickets.py b/app/core/tickets/utils_tickets.py index db16211145..494607fb8d 100644 --- a/app/core/tickets/utils_tickets.py +++ b/app/core/tickets/utils_tickets.py @@ -11,6 +11,21 @@ from app.core.tickets import cruds_tickets, schemas_tickets +async def mypayment_callback_callback( + checkout_id: UUID, + db: AsyncSession, +) -> None: + """ + Callback called by MyPayment when the payment status of a checkout changes. + + It will update the checkout and the associated tickets status according to the payment status. + """ + await cruds_tickets.mark_checkout_as_paid( + checkout_id=checkout_id, + db=db, + ) + + async def is_event_sold_out( event_id: UUID, quota: int | None, diff --git a/tests/core/test_tickets.py b/tests/core/test_tickets.py index c2cd3b6610..4ed2f8bda8 100644 --- a/tests/core/test_tickets.py +++ b/tests/core/test_tickets.py @@ -37,6 +37,7 @@ global_event: models_tickets.TicketEvent event_session: models_tickets.EventSession event_category: models_tickets.Category +free_event_category: models_tickets.Category event_disabled_category: models_tickets.Category event_disabled_session: models_tickets.EventSession @@ -132,7 +133,7 @@ async def init_objects() -> None: ) await add_object_to_db(seller) - global global_event, event_session, event_category + global global_event, event_session, event_category, free_event_category ticket_event_id = uuid.uuid4() event_session = models_tickets.EventSession( @@ -152,6 +153,15 @@ async def init_objects() -> None: price=1000, required_membership=None, ) + free_event_category = models_tickets.Category( + id=uuid.uuid4(), + event_id=ticket_event_id, + name="Test Free Category", + quota=None, + disabled=False, + price=0, + required_membership=None, + ) global event_disabled_category, event_disabled_session event_disabled_category = models_tickets.Category( @@ -184,7 +194,7 @@ async def init_objects() -> None: quota=10, disabled=False, sessions=[event_session, event_disabled_session], - categories=[event_category, event_disabled_category], + categories=[event_category, event_disabled_category, free_event_category], questions=[ models_tickets.Question( id=global_event_optionnal_question_id, @@ -358,7 +368,8 @@ def test_create_checkout_with_invalid_category(client: TestClient): "category_id": str(uuid.uuid4()), "session_id": str(session_sold_out_event.id), "answers": [], - "payment_method": "myempay", + "mypayment_request_method": "transfer", + "mypayment_transfer_redirect_url": "http://localhost:3000/payment_callback", }, ) assert response.status_code == 404 @@ -373,7 +384,8 @@ def test_create_checkout_with_disabled_category(client: TestClient): "category_id": str(event_disabled_category.id), "session_id": str(event_session.id), "answers": [], - "payment_method": "myempay", + "mypayment_request_method": "transfer", + "mypayment_transfer_redirect_url": "http://localhost:3000/payment_callback", }, ) assert response.status_code == 400 @@ -388,7 +400,8 @@ def test_create_checkout_with_invalid_session(client: TestClient): "category_id": str(category_sold_out_event.id), "session_id": str(uuid.uuid4()), "answers": [], - "payment_method": "myempay", + "mypayment_request_method": "transfer", + "mypayment_transfer_redirect_url": "http://localhost:3000/payment_callback", }, ) assert response.status_code == 404 @@ -403,7 +416,8 @@ def test_create_checkout_with_disabled_session(client: TestClient): "category_id": str(event_category.id), "session_id": str(event_disabled_session.id), "answers": [], - "payment_method": "myempay", + "mypayment_request_method": "transfer", + "mypayment_transfer_redirect_url": "http://localhost:3000/payment_callback", }, ) assert response.status_code == 400 @@ -453,7 +467,8 @@ async def test_create_checkout_with_disabled_event(client: TestClient): "category_id": str(category_id), "session_id": str(session_id), "answers": [], - "payment_method": "myempay", + "mypayment_request_method": "transfer", + "mypayment_transfer_redirect_url": "http://localhost:3000/payment_callback", }, ) assert response.status_code == 400 @@ -503,7 +518,8 @@ async def test_create_checkout_with_not_open_event(client: TestClient): "category_id": str(category_id), "session_id": str(session_id), "answers": [], - "payment_method": "myempay", + "mypayment_request_method": "transfer", + "mypayment_transfer_redirect_url": "http://localhost:3000/payment_callback", }, ) assert response.status_code == 400 @@ -553,7 +569,8 @@ async def test_create_checkout_with_closed_event(client: TestClient): "category_id": str(category_id), "session_id": str(session_id), "answers": [], - "payment_method": "myempay", + "mypayment_request_method": "transfer", + "mypayment_transfer_redirect_url": "http://localhost:3000/payment_callback", }, ) assert response.status_code == 400 @@ -568,7 +585,8 @@ def test_create_checkout_with_category_from_another_event(client: TestClient): "category_id": str(event_category.id), "session_id": str(session_sold_out_event.id), "answers": [], - "payment_method": "myempay", + "mypayment_request_method": "transfer", + "mypayment_transfer_redirect_url": "http://localhost:3000/payment_callback", }, ) assert response.status_code == 400 @@ -583,7 +601,8 @@ def test_create_checkout_with_session_from_another_event(client: TestClient): "category_id": str(category_sold_out_event.id), "session_id": str(event_session.id), "answers": [], - "payment_method": "myempay", + "mypayment_request_method": "transfer", + "mypayment_transfer_redirect_url": "http://localhost:3000/payment_callback", }, ) assert response.status_code == 400 @@ -598,7 +617,8 @@ def test_create_checkout_with_sold_out_event(client: TestClient): "category_id": str(category_sold_out_event.id), "session_id": str(session_sold_out_event.id), "answers": [], - "payment_method": "myempay", + "mypayment_request_method": "transfer", + "mypayment_transfer_redirect_url": "http://localhost:3000/payment_callback", }, ) assert response.status_code == 400 @@ -613,7 +633,8 @@ def test_create_checkout_with_sold_out_category(client: TestClient): "category_id": str(event_sold_out_category.id), "session_id": str(event_session.id), "answers": [], - "payment_method": "myempay", + "mypayment_request_method": "transfer", + "mypayment_transfer_redirect_url": "http://localhost:3000/payment_callback", }, ) assert response.status_code == 400 @@ -628,7 +649,8 @@ def test_create_checkout_with_sold_out_session(client: TestClient): "category_id": str(event_category.id), "session_id": str(event_sold_out_session.id), "answers": [], - "payment_method": "myempay", + "mypayment_request_method": "transfer", + "mypayment_transfer_redirect_url": "http://localhost:3000/payment_callback", }, ) assert response.status_code == 400 @@ -678,7 +700,8 @@ async def test_create_checkout_with_missing_membership(client: TestClient): "category_id": str(event_with_required_membership_category_id), "session_id": str(event_with_required_membership_session_id), "answers": [], - "payment_method": "myempay", + "mypayment_request_method": "transfer", + "mypayment_transfer_redirect_url": "http://localhost:3000/payment_callback", }, ) assert response.status_code == 400 @@ -707,7 +730,8 @@ def test_create_checkout_with_answer_present_multiple_times(client: TestClient): "answer": "Test Answer 2", }, ], - "payment_method": "myempay", + "mypayment_request_method": "transfer", + "mypayment_transfer_redirect_url": "http://localhost:3000/payment_callback", }, ) assert response.status_code == 400 @@ -733,7 +757,8 @@ def test_create_checkout_with_invalid_question_id(client: TestClient): "answer": "Test Answer", }, ], - "payment_method": "myempay", + "mypayment_request_method": "transfer", + "mypayment_transfer_redirect_url": "http://localhost:3000/payment_callback", }, ) assert response.status_code == 400 @@ -757,7 +782,8 @@ def test_create_checkout_with_disabled_question(client: TestClient): "answer": "Test Answer", }, ], - "payment_method": "myempay", + "mypayment_request_method": "transfer", + "mypayment_transfer_redirect_url": "http://localhost:3000/payment_callback", }, ) assert response.status_code == 400 @@ -781,7 +807,8 @@ def test_create_checkout_with_invalid_answer_type(client: TestClient): "answer": 3, }, ], - "payment_method": "myempay", + "mypayment_request_method": "transfer", + "mypayment_transfer_redirect_url": "http://localhost:3000/payment_callback", }, ) assert response.status_code == 400 @@ -847,7 +874,8 @@ async def test_create_checkout_with_missing_required_question(client: TestClient "category_id": str(event_with_required_question_category_id), "session_id": str(event_with_required_question_session_id), "answers": [], - "payment_method": "myempay", + "mypayment_request_method": "transfer", + "mypayment_transfer_redirect_url": "http://localhost:3000/payment_callback", }, ) assert response.status_code == 400 @@ -870,7 +898,8 @@ def test_create_checkout(client: TestClient): "answer": "Test Answer", }, ], - "payment_method": "test", + "mypayment_request_method": "transfer", + "mypayment_transfer_redirect_url": "http://localhost:3000/payment_callback", }, ) # TODO @@ -880,6 +909,25 @@ def test_create_checkout(client: TestClient): assert response.json()["price"] == 1000 + 100 +def test_create_checkout_for_free_event(client: TestClient): + response = client.post( + f"/tickets/events/{global_event.id}/checkout", + headers={"Authorization": f"Bearer {user_token}"}, + json={ + "category_id": str(free_event_category.id), + "session_id": str(event_session.id), + "answers": [], + "mypayment_request_method": "transfer", + "mypayment_transfer_redirect_url": "http://localhost:3000/payment_callback", + }, + ) + # TODO + # assert response.json() == "" + assert response.status_code == 201 + # Price of the event + price of the optionnal question + assert response.json()["price"] == 0 + + def test_get_user_tickets(client: TestClient): response = client.get( "/tickets/user/me/tickets", From 9bbc920669a543eb1dcd7641515ad02187e3aa08 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Tue, 7 Apr 2026 22:25:10 +0200 Subject: [PATCH 72/72] Validate category price --- app/core/tickets/schemas_tickets.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/app/core/tickets/schemas_tickets.py b/app/core/tickets/schemas_tickets.py index 8f6767d4e7..763cb8c6fb 100644 --- a/app/core/tickets/schemas_tickets.py +++ b/app/core/tickets/schemas_tickets.py @@ -4,6 +4,7 @@ from pydantic import ( BaseModel, + field_validator, ) from app.core.mypayment.types_mypayment import MyPaymentCallType @@ -82,6 +83,14 @@ class CategoryCreate(BaseModel): quota: int | None required_membership: UUID | None + @field_validator("price") + def null_or_greater_than_one_euro(cls, v: int) -> int: + if v == 0: + return v + if v <= 100: + raise ValueError("Price must be zero or greater than one euro") # noqa: TRY003 + return v + class CategoryUpdate(BaseModel): name: str | None = None @@ -90,6 +99,14 @@ class CategoryUpdate(BaseModel): required_membership: UUID | None = None disabled: bool | None = None + @field_validator("price") + def null_or_greater_than_one_euro(cls, v: int | None) -> int | None: + if v == 0 or v is None: + return v + if v <= 100: + raise ValueError("Price must be zero or greater than one euro") # noqa: TRY003 + return v + class Question(BaseModel): id: UUID @@ -234,3 +251,8 @@ class CheckoutResponse(BaseModel): price: int expiration: datetime payment_url: str | None + + +class TicketTransfer(BaseModel): + ticket_id: UUID + email: str