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/checkout/endpoints_checkout.py b/app/core/checkout/endpoints_checkout.py index dca3a57b1c..b14b0e1daa 100644 --- a/app/core/checkout/endpoints_checkout.py +++ b/app/core/checkout/endpoints_checkout.py @@ -134,7 +134,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 +147,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/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/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/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/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..ad44cd4fa5 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,15 +9,19 @@ 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 ( +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 @@ -194,6 +198,30 @@ 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_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, @@ -213,6 +241,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( @@ -222,6 +251,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) @@ -251,6 +281,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, @@ -282,6 +313,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, @@ -704,6 +736,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() ] @@ -722,6 +756,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) @@ -742,7 +778,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( @@ -757,7 +793,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( @@ -799,13 +849,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( @@ -949,6 +1022,147 @@ 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, + 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..605ae626a9 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 @@ -37,34 +38,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.models_converter import structure_model_to_schema 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 +91,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 @@ -91,18 +104,25 @@ generate_pdf_from_template, get_core_data, get_file_from_data, + is_user_member_of_an_association, patch_identity_in_text, set_core_data, ) 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, + checkout_callback=validate_transfer_callback, factory=MyPaymentFactory(), + permissions=MyPaymentPermissions, ) @@ -110,13 +130,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", @@ -460,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: @@ -500,6 +514,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, @@ -526,6 +541,36 @@ async def create_store( detail="Store with this name already exists in this structure", ) + if store.association_id is not None: + 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", + ) + 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() await cruds_mypayment.create_wallet( @@ -541,6 +586,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, @@ -556,6 +602,7 @@ async def create_store( can_see_history=True, can_cancel=True, can_manage_sellers=True, + can_manage_events=True, db=db, ) @@ -574,6 +621,7 @@ async def create_store( wallet_id=store_db.wallet_id, creation=store_db.creation, structure=structure, + association_id=store_db.association_id, ) @@ -745,18 +793,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( @@ -776,6 +818,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, ) @@ -814,7 +857,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. @@ -845,6 +888,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, ), ) @@ -1031,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, ) @@ -1224,7 +1269,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 +1326,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 +1364,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 +1426,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 +1458,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 +1503,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 +1544,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 +1757,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 +1887,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), @@ -1936,6 +1981,8 @@ async def init_ha_transfer( wallet_id=user_payment.wallet_id, creation=datetime.now(UTC), confirmed=False, + module=None, + object_id=None, ), ) @@ -1997,7 +2044,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 +2131,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 +2310,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 +2342,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 +2534,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 +2663,269 @@ 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.SignedContent, + 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.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", + ) + 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, + 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="Wrong type of wallet's owner", + ) + + 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.REQUEST, + 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], @@ -3092,7 +3373,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), @@ -3109,7 +3390,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 1d639cc02d..ff9d75d510 100644 --- a/app/core/mypayment/exceptions_mypayment.py +++ b/app/core/mypayment/exceptions_mypayment.py @@ -29,3 +29,33 @@ 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", + ) + + +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/factory_mypayment.py b/app/core/mypayment/factory_mypayment.py index dec2297102..a8c4c57b3c 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] @@ -67,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 = [] @@ -88,9 +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[ + 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) diff --git a/app/core/mypayment/models_mypayment.py b/app/core/mypayment/models_mypayment.py index b6af28ce2f..dd250a5d8a 100644 --- a/app/core/mypayment/models_mypayment.py +++ b/app/core/mypayment/models_mypayment.py @@ -170,6 +170,11 @@ class Store(Base): ) creation: Mapped[datetime] + association_id: Mapped[UUID | None] = mapped_column( + ForeignKey("associations_associations.id"), + unique=True, + ) + structure: Mapped[Structure] = relationship(init=False, lazy="joined") @@ -177,13 +182,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"), @@ -206,6 +212,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" @@ -223,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 8825a457de..30935d46f6 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, @@ -66,6 +67,7 @@ class StructureTranfert(BaseModel): class StoreBase(BaseModel): name: str + association_id: UUID | None = None class StoreSimple(StoreBase): @@ -96,6 +98,7 @@ class SellerCreation(BaseModel): can_see_history: bool can_cancel: bool can_manage_sellers: bool + can_manage_events: bool = False class SellerUpdate(BaseModel): @@ -103,6 +106,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): @@ -113,6 +117,9 @@ class Seller(BaseModel): can_cancel: bool can_manage_sellers: bool + # Event module + can_manage_events: bool + user: schemas_users.CoreUserSimple @@ -143,6 +150,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 @@ -163,31 +176,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 @@ -253,6 +241,8 @@ class Transfer(BaseModel): total: int # Stored in cents creation: datetime confirmed: bool + module: str | None + object_id: UUID | None class RefundBase(BaseModel): @@ -342,3 +332,73 @@ 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 RequestInfo(BaseModel): + store_id: UUID + 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 + + +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. + + 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/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/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() 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 01b613f86c..da625423a2 100644 --- a/app/core/mypayment/utils_mypayment.py +++ b/app/core/mypayment/utils_mypayment.py @@ -1,25 +1,39 @@ 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.memberships import schemas_memberships +from app.core.checkout.payment_tool import PaymentTool 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.models_mypayment import UserPayment -from app.core.mypayment.schemas_mypayment import ( - QRCodeContentData, -) -from app.core.mypayment.types_mypayment import ( +from app.core.mypayment.exceptions_mypayment import ( + PaymentUserNotFoundError, 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 SecuredContentData +from app.core.mypayment.types_mypayment import ( + 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 +41,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: SecuredContentData, wallet_device_id: UUID, request_id: str, ) -> bool: @@ -45,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( @@ -89,6 +110,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,121 +147,252 @@ 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, + ) -def structure_model_to_schema( - structure: models_mypayment.Structure, -) -> schemas_mypayment.Structure: +async def request_transaction( + user: schemas_users.CoreUser, + request_info: schemas_mypayment.RequestInfo, + db: AsyncSession, + notification_tool: NotificationTool, + settings: Settings, +) -> None: """ - Convert a structure model to a schema. + Create a transaction request for a user from a store. """ - 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, + payment_user = await cruds_mypayment.get_user_payment(user.id, db) + if not payment_user: + raise PaymentUserNotFoundError(user.id) + await cruds_mypayment.create_request( + db=db, + request=schemas_mypayment.Request( + id=uuid4(), + wallet_id=payment_user.wallet_id, + creation=datetime.now(UTC), + 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, ), - 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, + ) + message = Message( + 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, + message=message, ) -def refund_model_to_schema( - refund: models_mypayment.Refund, -) -> schemas_mypayment.Refund: +async def request_store_transfer( + user: schemas_users.CoreUser, + transfer_info: schemas_mypayment.StoreTransferInfo, + db: AsyncSession, + payment_tool: PaymentTool, + settings: Settings, +) -> schemas_checkout.PaymentUrl: """ - Convert a refund model to a schema. + Create a direct transfer to a store """ - 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, + 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, + ) + -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 - ], +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, + 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", + ) + + +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/__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..455703c29e --- /dev/null +++ b/app/core/tickets/cruds_tickets.py @@ -0,0 +1,783 @@ +import uuid +from collections.abc import Sequence +from datetime import UTC, datetime +from uuid import UUID + +from sqlalchemy import func, not_, 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_open_and_enabled_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, + ), + not_(models_tickets.TicketEvent.disabled), + ), + ) + return [ + schemas_tickets.EventSimple( + id=association.id, + name=association.name, + store_id=association.store_id, + open_datetime=association.open_datetime, + close_datetime=association.close_datetime, + disabled=association.disabled, + ) + 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, + open_datetime=association.open_datetime, + close_datetime=association.close_datetime, + disabled=association.disabled, + ) + for association in result.scalars().all() + ] + + +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( + models_tickets.TicketEvent.id == event_id, + ) + .options( + selectinload(models_tickets.TicketEvent.sessions), + selectinload(models_tickets.TicketEvent.categories), + selectinload(models_tickets.TicketEvent.questions), + ), + ) + + event = result.scalars().first() + if event is None: + return None + + return schemas_tickets.EventComplete( + id=event.id, + name=event.name, + 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( + id=session.id, + name=session.name, + start_datetime=session.start_datetime, + event_id=session.event_id, + quota=session.quota, + disabled=session.disabled, + ) + for session in event.sessions + ], + categories=[ + schemas_tickets.CategoryComplete( + id=category.id, + name=category.name, + price=category.price, + required_membership=category.required_membership, + event_id=category.event_id, + quota=category.quota, + disabled=category.disabled, + ) + 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 + ], + ) + + +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, + disabled=event.disabled, + ) + + +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, + disabled=event.disabled, + store_id=event.store_id, + ) + + +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, +) -> 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, + db: AsyncSession, +): + db_event = models_tickets.TicketEvent( + id=event_id, + store_id=event.store_id, + name=event.name, + quota=event.quota, + disabled=False, + open_datetime=event.open_datetime, + close_datetime=event.close_datetime, + sessions=[ + models_tickets.EventSession( + id=uuid.uuid4(), + event_id=event_id, + name=session.name, + start_datetime=session.start_datetime, + quota=session.quota, + disabled=False, + ) + 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, + disabled=False, + ) + 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) + + +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, + quota=category.quota, + disabled=category.disabled, + ) + + +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.EventSession).where( + models_tickets.EventSession.id == session_id, + ), + ) + + session = result.scalars().first() + if session is None: + return None + + return schemas_tickets.SessionComplete( + id=session.id, + name=session.name, + start_datetime=session.start_datetime, + event_id=session.event_id, + quota=session.quota, + disabled=session.disabled, + ) + + +async def create_checkout( + checkout_id: UUID, + user_id: str, + event_id: UUID, + category_id: UUID, + session_id: UUID, + price: int, + expiration: datetime, + answers: list[schemas_tickets.Answer], + db: AsyncSession, +): + db_checkout = models_tickets.Checkout( + id=checkout_id, + event_id=event_id, + user_id=user_id, + category_id=category_id, + 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 + ], + scanned=False, + paid=False, + ) + 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, +) -> Sequence[schemas_tickets.Ticket]: + result = await db.execute( + select(models_tickets.Checkout) + .where(models_tickets.Checkout.user_id == user_id) + .options( + selectinload(models_tickets.Checkout.category), + selectinload(models_tickets.Checkout.session), + ), + ) + return [ + schemas_tickets.Ticket( + 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, + name=ticket.category.name, + 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( + 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.Checkout) + .where(models_tickets.Checkout.event_id == event_id) + .options( + selectinload(models_tickets.Checkout.category), + selectinload(models_tickets.Checkout.session), + selectinload(models_tickets.Checkout.user), + ), + ) + return [ + schemas_tickets.Ticket( + 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, + name=ticket.category.name, + 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( + 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.Checkout) + .where(models_tickets.Checkout.id == ticket_id) + .options( + selectinload(models_tickets.Checkout.category), + selectinload(models_tickets.Checkout.session), + selectinload(models_tickets.Checkout.user), + ), + ) + 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, + event_id=ticket.event_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, + 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( + 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.Checkout) + .where(models_tickets.Checkout.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.Checkout.event_id == event_id, + models_tickets.Checkout.paid, + ), + ) + + 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.Checkout.category_id == category_id, + models_tickets.Checkout.paid, + ), + ) + + 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.Checkout.session_id == session_id, + models_tickets.Checkout.paid, + ), + ) + + return result.scalar() or 0 + + +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), + ), + ) + + return result.scalar() or 0 + + +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), + ), + ) + + return result.scalar() or 0 + + +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), + ), + ), + ) + + 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, + db: AsyncSession, +): + await db.execute( + update(models_tickets.TicketEvent) + .where(models_tickets.TicketEvent.id == event_id) + .values(**event_update.model_dump(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.model_dump(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.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 new file mode 100644 index 0000000000..83d99af45c --- /dev/null +++ b/app/core/tickets/endpoints_tickets.py @@ -0,0 +1,918 @@ +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 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, 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, +) +from app.types.exceptions import ObjectExpectedInDbNotFoundError +from app.types.module import CoreModule +from app.utils.communication.notifications import NotificationTool + +router = APIRouter(tags=["Tickets"]) + +core_module = CoreModule( + root="ticket", + tag="Tickets", + router=router, + factory=TicketsFactory(), + mypayment_callback=utils_tickets.mypayment_callback_callback, +) + +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. + + 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_and_enabled_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 + + 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, + store_id=event.store_id, + sessions=[ + schemas_tickets.SessionPublic( + event_id=session.event_id, + id=session.id, + name=session.name, + start_datetime=session.start_datetime, + sold_out=await utils_tickets.is_session_sold_out( + session_id=session.id, + quota=session.quota, + db=db, + ), + disabled=session.disabled, + ) + for session in event.sessions + if not session.disabled + ], + 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 utils_tickets.is_category_sold_out( + category_id=category.id, + quota=category.quota, + db=db, + ), + disabled=category.disabled, + ) + for category in event.categories + if not category.disabled + ], + 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, + db=db, + ), + open_datetime=event.open_datetime, + close_datetime=event.close_datetime, + disabled=event.disabled, + ) + + +@router.post( + "/tickets/events/{event_id}/checkout", + response_model=schemas_tickets.CheckoutResponse, + status_code=201, +) +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), + 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 + """ + 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") + 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") + 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", + ) + + price = await utils_tickets.check_answer_validity_and_calculate_price( + event_id=event_id, + checkout=checkout, + db=db, + ) + + # 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, + ) + + if event is None: + raise ObjectExpectedInDbNotFoundError( + object_name="Event", + object_id=event_id, + ) + + 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) + + 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 await utils_tickets.is_category_sold_out( + category_id=category.id, + quota=category.quota, + db=db, + ): + raise HTTPException(400, "Category is sold out") + if await utils_tickets.is_session_sold_out( + session_id=session.id, + quota=session.quota, + db=db, + ): + raise HTTPException(400, "Session is sold out") + + checkout_id = uuid.uuid4() + 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, + expiration=expiration, + price=price, + answers=checkout.answers, + db=db, + ) + + 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, + expiration=expiration, + payment_url=payment_url.url if payment_url is not None else None, + ) + + +@router.get( + "/tickets/user/me/tickets", + response_model=list[schemas_tickets.Ticket], + status_code=200, +) +async def get_user_tickets( + 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_complete_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 utils_tickets.convert_to_event_admin( + event=event, + db=db, + ) + + +@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** + """ + 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( + event_id=event_id, + event=event_create, + db=db, + ) + + 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( + "/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), + notification_tool: NotificationTool = Depends(get_notification_tool), +): + """ + 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", + ) + + 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, + 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.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], + 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** + """ + 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 events", + ) + + 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_class=FileResponse, + 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** + """ + 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 events", + ) + + 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", + "User ID", + "User Name", + "User Firstname", + "User Account Type", + "User School ID", + ], + ) + + 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, + ticket.user_id, + ticket.user.name, + ticket.user.firstname, + ticket.user.account_type, + ticket.user.school_id, + ], + ) + + 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.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") + + event = await cruds_tickets.get_event_simple_by_id(event_id=ticket.event_id, db=db) + if event is None: + raise ObjectExpectedInDbNotFoundError( + object_name="Event", + object_id=ticket.event_id, + ) + + 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 + + +@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") + + event = await cruds_tickets.get_event_simple_by_id(event_id=ticket.event_id, db=db) + if event is None: + raise ObjectExpectedInDbNotFoundError( + object_name="Event", + object_id=ticket.event_id, + ) + + 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", + ) + + if ticket.scanned: + raise HTTPException( + status_code=400, + detail="Ticket is already scanned", + ) + + 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, + ) + + # 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_id=store.id, + user_id=user.id, + db=db, + ) + + +@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** + """ + 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 store associated with this association") + + return await utils_tickets.get_events_from_store( + store_id=store.id, + user_id=user.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..48d2140cef --- /dev/null +++ b/app/core/tickets/factory_tickets.py @@ -0,0 +1,16 @@ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.utils.config import Settings +from app.types.factory import Factory + + +class TicketsFactory(Factory): + depends_on = [] + + @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..91aa1472bc --- /dev/null +++ b/app/core/tickets/models_tickets.py @@ -0,0 +1,128 @@ +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.tickets.types_tickets import AnswerType +from app.core.users import models_users +from app.types.sqlalchemy import Base, PrimaryKey + + +class TicketEvent(Base): + __tablename__ = "tickets_event" + + id: Mapped[PrimaryKey] + name: Mapped[str] + + store_id: Mapped[UUID] = mapped_column(ForeignKey("mypayment_store.id")) + + open_datetime: Mapped[datetime] + close_datetime: Mapped[datetime | None] + + # 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") + categories: Mapped[list["Category"]] = relationship(back_populates="event") + questions: Mapped[list["Question"]] = relationship() + + +class EventSession(Base): + __tablename__ = "tickets_session" + + id: Mapped[PrimaryKey] + event_id: Mapped[UUID] = mapped_column(ForeignKey("tickets_event.id")) + + name: Mapped[str] + + start_datetime: Mapped[datetime] + + quota: Mapped[int | None] + + disabled: Mapped[bool] + + 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] + + disabled: Mapped[bool] + + price: Mapped[int] # in cents + required_membership: Mapped[UUID | None] = mapped_column( + ForeignKey("core_association_membership.id"), + ) + + 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 Checkout(Base): + """ + A checkout represents a pending or validated ticket purchase. + """ + + __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")) + + event_id: Mapped[UUID] = mapped_column(ForeignKey("tickets_event.id")) + + price: Mapped[int] # in cents + expiration: Mapped[datetime] + + 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/schemas_tickets.py b/app/core/tickets/schemas_tickets.py new file mode 100644 index 0000000000..763cb8c6fb --- /dev/null +++ b/app/core/tickets/schemas_tickets.py @@ -0,0 +1,258 @@ +from datetime import datetime +from typing import Literal +from uuid import UUID + +from pydantic import ( + BaseModel, + field_validator, +) + +from app.core.mypayment.types_mypayment import MyPaymentCallType +from app.core.tickets.types_tickets import AnswerType +from app.core.users import schemas_users + + +class Session(BaseModel): + id: UUID + event_id: UUID + name: str + start_datetime: datetime + disabled: bool + + +class SessionComplete(Session): + """ + Correspond to a Session in the database + """ + + quota: int | None + + +class SessionPublic(Session): + sold_out: bool + + +class SessionAdmin(SessionComplete): + tickets_in_checkout: int + tickets_sold: int + + +class SessionCreate(BaseModel): + name: str + start_datetime: datetime + + 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 + name: str + price: int + required_membership: UUID | None + disabled: bool + + +class CategoryComplete(Category): + """ + Correspond to a Category in the database + """ + + quota: int | None + + +class CategoryPublic(Category): + sold_out: bool + + +class CategoryAdmin(CategoryComplete): + tickets_in_checkout: int + tickets_sold: int + + +class CategoryCreate(BaseModel): + name: str + price: int + 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 + price: int | None = None + quota: int | None = None + 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 + 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 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 + + store_id: UUID + + open_datetime: datetime + close_datetime: datetime | None + + disabled: bool + + +class EventWithoutSessionsAndCategories(EventSimple): + quota: int | None + + +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 + + +class EventAdmin(EventWithoutSessionsAndCategories): + sessions: list[SessionAdmin] + categories: list[CategoryAdmin] + questions: list[QuestionAdmin] + + tickets_in_checkout: int + tickets_sold: int + + +class EventCreate(BaseModel): + store_id: UUID + name: str + quota: int | None + open_datetime: datetime + close_datetime: datetime | None + sessions: list[SessionCreate] + categories: list[CategoryCreate] + 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 + user_id: str + + event_id: UUID + category_id: UUID + session_id: UUID + + scanned: bool + + category: Category + session: Session + 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] + mypayment_request_method: MyPaymentCallType + mypayment_transfer_redirect_url: str + + +class CheckoutResponse(BaseModel): + price: int + expiration: datetime + payment_url: str | None + + +class TicketTransfer(BaseModel): + ticket_id: UUID + email: str 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 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 new file mode 100644 index 0000000000..494607fb8d --- /dev/null +++ b/app/core/tickets/utils_tickets.py @@ -0,0 +1,231 @@ +import uuid +from collections.abc import Sequence +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 + + +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, + db: AsyncSession, +) -> bool: + if quota is None: + return False + + 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_checkouts_and_tickets_by_event_id >= quota + + +async def is_category_sold_out( + category_id: UUID, + quota: int | None, + db: AsyncSession, +) -> bool: + if quota is None: + return False + + 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, + ) + ) + + return nb_valid_checkouts_and_tickets_by_category_id >= quota + + +async def is_session_sold_out( + session_id: UUID, + quota: int | None, + db: AsyncSession, +) -> bool: + if quota is None: + return False + + 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, + ) + ) + + return nb_valid_checkouts_and_tickets_by_session_id >= 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, + disabled=session.disabled, + 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, + disabled=category.disabled, + 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 + ], + 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, + disabled=event.disabled, + 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, + ), + ) + + +async def get_events_from_store( + store_id: uuid.UUID, + user_id: str, + db: AsyncSession, +) -> Sequence[schemas_tickets.EventSimple]: + 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, + ) + + +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 diff --git a/app/modules/calendar/endpoints_calendar.py b/app/modules/calendar/endpoints_calendar.py index d14800e2aa..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 @@ -323,6 +324,19 @@ async def add_event( if settings.school.require_event_confirmation: decision = Decision.pending + 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: + 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 +349,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 +364,22 @@ async def add_event( raise NewlyAddedObjectInDbNotFoundError("event") if decision == Decision.approved: + 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,17 @@ async def confirm_event( ) if decision == Decision.approved: + 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/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/app/modules/calendar/models_calendar.py b/app/modules/calendar/models_calendar.py index 8b673b8c55..9fa47f4aab 100644 --- a/app/modules/calendar/models_calendar.py +++ b/app/modules/calendar/models_calendar.py @@ -36,6 +36,10 @@ class Event(Base): ticket_url_opening: Mapped[datetime | None] notification: Mapped[bool] + ticket_event_id: Mapped[UUID | None] = mapped_column( + ForeignKey("tickets_event.id"), + ) + 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..a033313563 100644 --- a/app/modules/calendar/schemas_calendar.py +++ b/app/modules/calendar/schemas_calendar.py @@ -26,13 +26,24 @@ 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): + # 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 + if (self.ticket_url_opening and not self.ticket_url) or ( self.ticket_url and not self.ticket_url_opening ): - raise ValueError + raise ValueError( # noqa: TRY003 + "ticket_url and ticket_url_opening must be provided together", + ) return self @@ -45,6 +56,7 @@ class EventComplete(EventBase): class EventCompleteTicketUrl(EventComplete): ticket_url: str | None = None + ticket_event_id: UUID | None = None class EventTicketUrl(BaseModel): @@ -61,6 +73,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..5a02d6ebc7 100644 --- a/app/modules/calendar/utils_calendar.py +++ b/app/modules/calendar/utils_calendar.py @@ -1,10 +1,12 @@ from collections.abc import Sequence from datetime import UTC, datetime +from uuid import UUID import aiofiles 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 @@ -19,7 +21,14 @@ 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, ): + 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, start=event.start, @@ -27,8 +36,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, @@ -45,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, 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/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/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 3b87068e6d..68152a6a8a 100644 --- a/app/types/module.py +++ b/app/types/module.py @@ -1,4 +1,5 @@ from collections.abc import Awaitable, Callable +from uuid import UUID from fastapi import APIRouter from sqlalchemy.ext.asyncio import AsyncSession @@ -17,11 +18,16 @@ def __init__( tag: str, factory: Factory | None, router: APIRouter | None = None, - payment_callback: Callable[ + checkout_callback: Callable[ [schemas_checkout.CheckoutPayment, AsyncSession], Awaitable[None], ] | None = None, + mypayment_callback: Callable[ + [UUID, AsyncSession], + Awaitable[None], + ] + | None = None, registred_topics: list[Topic] | None = None, permissions: type[ModulePermissions] | None = None, ): @@ -38,10 +44,13 @@ 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 self.registred_topics = registred_topics self.factory = factory self.permissions = permissions @@ -56,11 +65,16 @@ 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], ] | None = None, + mypayment_callback: Callable[ + [UUID, AsyncSession], + Awaitable[None], + ] + | None = None, registred_topics: list[Topic] | None = None, permissions: type[ModulePermissions] | None = None, ): @@ -81,10 +95,13 @@ 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 self.registred_topics = registred_topics self.factory = factory self.permissions = permissions 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/migrations/versions/49-compress_images.py b/migrations/versions/49-compress_images.py index dc7ac39860..45dd3a5b2b 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,7 @@ 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() + print() # noqa: T201 def downgrade() -> None: 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/migrations/versions/67-stores_coreassociations.py b/migrations/versions/67-stores_coreassociations.py new file mode 100644 index 0000000000..b648e06881 --- /dev/null +++ b/migrations/versions/67-stores_coreassociations.py @@ -0,0 +1,61 @@ +"""empty message + +Create Date: 2026-03-01 11:41:22.994301 +""" + +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 = "146db8dcb23e" +down_revision: str | None = "46fbbcee7237" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "mypayment_store", + sa.Column( + "association_id", + sa.Uuid(), + nullable=True, + ), + ) + op.create_foreign_key( + None, + "mypayment_store", + "associations_associations", + ["association_id"], + ["id"], + ) + op.create_unique_constraint(None, "mypayment_store", ["association_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 diff --git a/migrations/versions/68-tickets.py b/migrations/versions/68-tickets.py new file mode 100644 index 0000000000..d91524cf09 --- /dev/null +++ b/migrations/versions/68-tickets.py @@ -0,0 +1,176 @@ +"""empty message + +Create Date: 2026-03-27 23:23:07.797594 +""" + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pytest_alembic import MigrationContext + +import sqlalchemy as sa +from alembic import op + +from app.types.sqlalchemy import TZDateTime + +# revision identifiers, used by Alembic. +revision: str = "c052cfbe6d75" +down_revision: str | None = "146db8dcb23e" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "tickets_event", + sa.Column("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", sa.Integer(), nullable=True), + sa.Column("disabled", sa.Boolean(), nullable=False), + 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("disabled", sa.Boolean(), nullable=False), + 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_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), + sa.Column("event_id", sa.Uuid(), nullable=False), + 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"], + ), + 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("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), + sa.Column("paid", sa.Boolean(), nullable=False), + sa.Column("scanned", sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint( + ["category_id"], + ["tickets_category.id"], + ), + sa.ForeignKeyConstraint( + ["event_id"], + ["tickets_event.id"], + ), + sa.ForeignKeyConstraint( + ["session_id"], + ["tickets_session.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["core_user.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "tickets_answer", + sa.Column("id", sa.Uuid(), 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( + ["checkout_id"], + ["tickets_checkout.id"], + ), + sa.ForeignKeyConstraint( + ["question_id"], + ["tickets_question.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + 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_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( + alembic_runner: "MigrationContext", + alembic_connection: sa.Connection, +) -> None: + pass + + +def test_upgrade( + alembic_runner: "MigrationContext", + alembic_connection: sa.Connection, +) -> None: + pass diff --git a/migrations/versions/69-mypayment.py b/migrations/versions/69-mypayment.py new file mode 100644 index 0000000000..a197ad1a39 --- /dev/null +++ b/migrations/versions/69-mypayment.py @@ -0,0 +1,49 @@ +"""empty message + +Create Date: 2026-03-29 15:20:10.468941 +""" + +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 = "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 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_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, ) diff --git a/tests/core/test_mypayment.py b/tests/core/test_mypayment.py index 9820d8a611..b79a5257fd 100644 --- a/tests/core/test_mypayment.py +++ b/tests/core/test_mypayment.py @@ -10,24 +10,38 @@ ) from fastapi.testclient import TestClient 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 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.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, + SignedContent, +) from app.core.mypayment.types_mypayment import ( + RequestStatus, TransactionStatus, TransactionType, TransferType, WalletDeviceStatus, WalletType, ) -from app.core.mypayment.utils_mypayment import LATEST_TOS +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 from tests.commons import ( add_coredata_to_db, add_object_to_db, @@ -37,6 +51,8 @@ get_TestingSessionLocal, ) +TEST_MODULE_ROOT = "tests" + bde_group: models_groups.CoreGroup admin_user: models_users.CoreUser @@ -62,6 +78,9 @@ 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 association_membership_user: models_memberships.CoreAssociationUserMembership structure: models_mypayment.Structure @@ -72,6 +91,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 +108,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 +127,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( [], @@ -113,6 +145,18 @@ 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_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=core_association_group.id, + ) + await add_object_to_db(core_association) + global association_membership association_membership = models_memberships.CoreAssociationMembership( id=uuid4(), @@ -306,6 +350,7 @@ async def init_objects() -> None: name="Test Store", structure_id=structure.id, creation=datetime.now(UTC), + association_id=core_association.id, ) await add_object_to_db(store) store2 = models_mypayment.Store( @@ -314,14 +359,17 @@ async def init_objects() -> None: name="Test Store 2", structure_id=structure2.id, creation=datetime.now(UTC), + association_id=None, ) await add_object_to_db(store2) + 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=None, ) await add_object_to_db(store3) @@ -332,6 +380,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) @@ -352,6 +401,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 +494,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) @@ -460,6 +526,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) @@ -475,8 +542,9 @@ 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, + can_manage_events=True, ) await add_object_to_db(store_seller_can_bank) @@ -494,6 +562,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) @@ -511,6 +580,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) @@ -525,6 +595,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( @@ -599,6 +670,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( @@ -792,12 +907,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=new_core_association.id, ) await add_object_to_db(new_store) new_wallet2 = models_mypayment.Wallet( @@ -806,12 +928,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=new2_core_association.id, ) await add_object_to_db(new_store2_where_new_manager_already_seller) seller = models_mypayment.Seller( @@ -821,6 +950,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) @@ -870,18 +1000,82 @@ 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 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) + 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", + headers={ + "Authorization": f"Bearer {structure_manager_and_association_member_user_token}", + }, + json={ + "name": "test_create_store Test Store", + "association_id": str(create_store_core_association.id), }, ) assert response.status_code == 201 @@ -889,7 +1083,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 @@ -901,6 +1097,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 @@ -913,6 +1110,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 @@ -1160,7 +1358,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): @@ -1235,12 +1433,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=delete_store_core_association.id, ) await add_object_to_db(new_store) sellet = models_mypayment.Seller( @@ -1250,6 +1455,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) @@ -1269,12 +1475,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=update_store_core_association.id, ) await add_object_to_db(new_store) response = client.patch( @@ -1308,6 +1521,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 @@ -1324,6 +1538,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 @@ -1348,6 +1563,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 @@ -1368,6 +1584,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 @@ -1388,6 +1605,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) @@ -1402,6 +1620,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 @@ -1463,6 +1682,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 @@ -1478,6 +1698,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 @@ -1498,6 +1719,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 @@ -1518,6 +1740,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( @@ -1545,6 +1768,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( @@ -1653,6 +1877,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( @@ -2389,7 +2614,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), @@ -2424,7 +2649,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), @@ -2466,7 +2691,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), @@ -2502,7 +2727,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), @@ -2572,7 +2797,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), @@ -2605,7 +2830,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), @@ -2638,7 +2863,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), @@ -2946,6 +3171,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", @@ -3211,3 +3467,503 @@ 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_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_request_with_wrong_wallet_device( + client: TestClient, +): + 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) + + validation_data = SecuredContentData( + id=proposed_request.id, + key=wrong_wallet_device.id, + iat=datetime.now(UTC), + tot=proposed_request.total, + store=True, + ) + 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" + ) + + +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, + name="Test request", + store_note="", + module=TEST_MODULE_ROOT, + object_id=uuid4(), + transaction_id=None, + total=1000, + status=RequestStatus.ACCEPTED, + creation=datetime.now(UTC), + ) + await add_object_to_db(non_proposed_request) + + validation_data = SecuredContentData( + id=non_proposed_request.id, + key=ecl_user_wallet_device.id, + iat=datetime.now(UTC), + tot=non_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/{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" + + +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 = 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_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_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, +): + # 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, +): + # 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() + + +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 diff --git a/tests/core/test_tickets.py b/tests/core/test_tickets.py new file mode 100644 index 0000000000..4ed2f8bda8 --- /dev/null +++ b/tests/core/test_tickets.py @@ -0,0 +1,1480 @@ +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 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 +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: models_mypayment.Structure +wallet: models_mypayment.Wallet +core_association: CoreAssociation +store: models_mypayment.Store + +seller_can_manage_event_user: models_users.CoreUser +seller_can_manage_event_user_token: str + + +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 + +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 +category_sold_out_event: models_tickets.Category +ticket_sold_out_event: models_tickets.Checkout + +ticket: models_tickets.Checkout + + +@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 = models_mypayment.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 = models_mypayment.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 = models_mypayment.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 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, free_event_category + + ticket_event_id = uuid.uuid4() + event_session = models_tickets.EventSession( + id=uuid.uuid4(), + event_id=ticket_event_id, + 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, + ) + 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( + 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( + id=uuid.uuid4(), + store_id=store.id, + name="Test global_event", + 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, event_disabled_session], + categories=[event_category, event_disabled_category, free_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) + + 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, + disabled=False, + price=1000, + required_membership=None, + ) + await add_object_to_db(event_sold_out_category) + ticket_sold_out_category = models_tickets.Checkout( + 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, + 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( + 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, + disabled=False, + ) + await add_object_to_db(event_sold_out_session) + ticket_sold_out_session = models_tickets.Checkout( + 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, + paid=True, + expiration=datetime.now(tz=UTC) + timedelta(hours=1), + answers=[], + ) + 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 + 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, + 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, + ) + 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, + disabled=False, + 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=[]) + ticket_sold_out_event = models_tickets.Checkout( + 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, + paid=True, + expiration=datetime.now(tz=UTC) + timedelta(hours=1), + answers=[], + ) + await add_object_to_db(ticket_sold_out_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/{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), + "answers": [], + "mypayment_request_method": "transfer", + "mypayment_transfer_redirect_url": "http://localhost:3000/payment_callback", + }, + ) + assert response.status_code == 404 + 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": [], + "mypayment_request_method": "transfer", + "mypayment_transfer_redirect_url": "http://localhost:3000/payment_callback", + }, + ) + 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", + headers={"Authorization": f"Bearer {user_token}"}, + json={ + "category_id": str(category_sold_out_event.id), + "session_id": str(uuid.uuid4()), + "answers": [], + "mypayment_request_method": "transfer", + "mypayment_transfer_redirect_url": "http://localhost:3000/payment_callback", + }, + ) + assert response.status_code == 404 + 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": [], + "mypayment_request_method": "transfer", + "mypayment_transfer_redirect_url": "http://localhost:3000/payment_callback", + }, + ) + 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": [], + "mypayment_request_method": "transfer", + "mypayment_transfer_redirect_url": "http://localhost:3000/payment_callback", + }, + ) + 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": [], + "mypayment_request_method": "transfer", + "mypayment_transfer_redirect_url": "http://localhost:3000/payment_callback", + }, + ) + 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": [], + "mypayment_request_method": "transfer", + "mypayment_transfer_redirect_url": "http://localhost:3000/payment_callback", + }, + ) + 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", + headers={"Authorization": f"Bearer {user_token}"}, + json={ + "category_id": str(event_category.id), + "session_id": str(session_sold_out_event.id), + "answers": [], + "mypayment_request_method": "transfer", + "mypayment_transfer_redirect_url": "http://localhost:3000/payment_callback", + }, + ) + 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), + "answers": [], + "mypayment_request_method": "transfer", + "mypayment_transfer_redirect_url": "http://localhost:3000/payment_callback", + }, + ) + assert response.status_code == 400 + assert response.json()["detail"] == "Session does not belong to the event" + + +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), + "answers": [], + "mypayment_request_method": "transfer", + "mypayment_transfer_redirect_url": "http://localhost:3000/payment_callback", + }, + ) + 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), + "answers": [], + "mypayment_request_method": "transfer", + "mypayment_transfer_redirect_url": "http://localhost:3000/payment_callback", + }, + ) + 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), + "answers": [], + "mypayment_request_method": "transfer", + "mypayment_transfer_redirect_url": "http://localhost:3000/payment_callback", + }, + ) + 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, + disabled=False, + 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, + disabled=False, + ), + ], + categories=[ + models_tickets.Category( + id=event_with_required_membership_category_id, + event_id=event_with_required_membership_id, + name="Test Category", + quota=None, + disabled=False, + 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": [], + "mypayment_request_method": "transfer", + "mypayment_transfer_redirect_url": "http://localhost:3000/payment_callback", + }, + ) + 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", + }, + ], + "mypayment_request_method": "transfer", + "mypayment_transfer_redirect_url": "http://localhost:3000/payment_callback", + }, + ) + 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", + }, + ], + "mypayment_request_method": "transfer", + "mypayment_transfer_redirect_url": "http://localhost:3000/payment_callback", + }, + ) + 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", + }, + ], + "mypayment_request_method": "transfer", + "mypayment_transfer_redirect_url": "http://localhost:3000/payment_callback", + }, + ) + 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, + }, + ], + "mypayment_request_method": "transfer", + "mypayment_transfer_redirect_url": "http://localhost:3000/payment_callback", + }, + ) + 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, + disabled=False, + 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, + disabled=False, + ), + ], + categories=[ + models_tickets.Category( + id=event_with_required_question_category_id, + event_id=event_with_required_question_id, + name="Test Category", + quota=None, + disabled=False, + 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": [], + "mypayment_request_method": "transfer", + "mypayment_transfer_redirect_url": "http://localhost:3000/payment_callback", + }, + ) + 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_session.id), + "answers": [ + { + "question_id": str(global_event_optionnal_question_id), + "answer_type": "text", + "answer": "Test Answer", + }, + ], + "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"] == 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", + headers={"Authorization": f"Bearer {user_token}"}, + ) + 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": [], + "questions": [], + }, + ) + 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, + }, + ], + "questions": [ + { + "id": str(global_event_optionnal_question_id), + "question": "Test Question", + "required": False, + "answer_type": "text", + "price": 1000, + }, + ], + }, + ) + 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 + + +# 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 + + +# 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 + + +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_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", + headers={"Authorization": f"Bearer {seller_can_manage_event_user_token}"}, + ) + assert response.status_code == 200 + assert len(response.json()) > 1 + + +# 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 No Store", + 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", + 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 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)