diff --git a/.github/workflows/lintandformat.yml b/.github/workflows/lintandformat.yml index 05e36a8c53..34f73e81ba 100644 --- a/.github/workflows/lintandformat.yml +++ b/.github/workflows/lintandformat.yml @@ -46,6 +46,9 @@ jobs: ruff check --output-format=github ruff format --check + - name: Type checking using Ty + run: ty check --output-format=github --error=all + - name: Cache .mypy_cache folder id: mypy_cache uses: actions/cache@v4.3.0 diff --git a/app/app.py b/app/app.py index 9dbd97cd7f..26fe21c5fc 100644 --- a/app/app.py +++ b/app/app.py @@ -650,7 +650,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[LifespanState]: use_route_path_as_operation_ids(app) app.add_middleware( - CORSMiddleware, # ty:ignore[invalid-argument-type] + CORSMiddleware, allow_origins=settings.CORS_ORIGINS, allow_credentials=True, allow_methods=["*"], diff --git a/app/core/checkout/endpoints_checkout.py b/app/core/checkout/endpoints_checkout.py index 84af7002b2..48d2863bdf 100644 --- a/app/core/checkout/endpoints_checkout.py +++ b/app/core/checkout/endpoints_checkout.py @@ -2,15 +2,16 @@ import uuid from fastapi import APIRouter, Depends, HTTPException, Request -from helloasso_python.models.hello_asso_api_v5_models_api_notifications_api_notification_type import ( - HelloAssoApiV5ModelsApiNotificationsApiNotificationType, -) from pydantic import TypeAdapter, ValidationError from sqlalchemy.ext.asyncio import AsyncSession from app.core.checkout import cruds_checkout, models_checkout, schemas_checkout from app.core.checkout.types_checkout import ( + FormNotificationResultContent, NotificationResultContent, + OrderNotificationResultContent, + OrganizationNotificationResultContent, + PayementNotificationResultContent, ) from app.dependencies import get_db from app.module import all_modules @@ -62,103 +63,94 @@ async def webhook( status_code=400, detail="Could not validate the webhook body", ) - if ( - content.eventType - == HelloAssoApiV5ModelsApiNotificationsApiNotificationType.ORDER - ): - pass - if ( - content.eventType - == HelloAssoApiV5ModelsApiNotificationsApiNotificationType.PAYMENT - ): - # We may receive the webhook multiple times, we only want to save a CheckoutPayment - # in the database the first time - existing_checkout_payment_model = ( - await cruds_checkout.get_checkout_payment_by_hello_asso_payment_id( - hello_asso_payment_id=content.data.id, # ty:ignore[unresolved-attribute] - db=db, - ) - ) - if existing_checkout_payment_model is not None: - hyperion_error_logger.debug( - f"Payment: ignoring webhook call for helloasso checkout payment id {content.data.id} as it already exists in the database", # ty:ignore[unresolved-attribute] + match content: + case OrganizationNotificationResultContent(): + pass + case OrderNotificationResultContent(): + pass + case FormNotificationResultContent(): + pass + case PayementNotificationResultContent(): + # We may receive the webhook multiple times, we only want to save a CheckoutPayment + # in the database the first time + existing_checkout_payment_model = ( + await cruds_checkout.get_checkout_payment_by_hello_asso_payment_id( + hello_asso_payment_id=content.data.id, + db=db, + ) ) - return + if existing_checkout_payment_model is not None: + hyperion_error_logger.debug( + f"Payment: ignoring webhook call for helloasso checkout payment id {content.data.id} as it already exists in the database", + ) + return - # If no metadata are included, this should not be a checkout we initiated - if not checkout_metadata: - hyperion_error_logger.info( - "Payment: missing checkout_metadata", - ) - return + # If no metadata are included, this should not be a checkout we initiated + if not checkout_metadata: + hyperion_error_logger.info( + "Payment: missing checkout_metadata", + ) + return - checkout = await cruds_checkout.get_checkout_by_id( - checkout_id=uuid.UUID(checkout_metadata.hyperion_checkout_id), - db=db, - ) - # If a metadata with a checkout was present in the request but we can not find the checkout, - # we should raise an error - if not checkout: - hyperion_error_logger.error( - f"Payment: could not find checkout (hyperion_checkout_id: {checkout_metadata.hyperion_checkout_id}) in database for payment HelloAsso payment_id: {content.data.id}", # ty:ignore[unresolved-attribute] - ) - raise HTTPException( - status_code=400, - detail=f"Could not find checkout {checkout_metadata.hyperion_checkout_id} in database", + checkout = await cruds_checkout.get_checkout_by_id( + checkout_id=uuid.UUID(checkout_metadata.hyperion_checkout_id), + db=db, ) + # If a metadata with a checkout was present in the request but we can not find the checkout, + # we should raise an error + if not checkout: + hyperion_error_logger.error( + f"Payment: could not find checkout (hyperion_checkout_id: {checkout_metadata.hyperion_checkout_id}) in database for payment HelloAsso payment_id: {content.data.id}", + ) + raise HTTPException( + status_code=400, + detail=f"Could not find checkout {checkout_metadata.hyperion_checkout_id} in database", + ) - if checkout.secret != checkout_metadata.secret: - hyperion_error_logger.error( - f"Payment: secret mismatch for checkout (hyperion_checkout_id: {checkout_metadata.hyperion_checkout_id}, HelloAsso checkout_id: {checkout.id})", + if checkout.secret != checkout_metadata.secret: + hyperion_error_logger.error( + f"Payment: secret mismatch for checkout (hyperion_checkout_id: {checkout_metadata.hyperion_checkout_id}, HelloAsso checkout_id: {checkout.id})", + ) + raise HTTPException( + status_code=400, + detail="Secret mismatch", + ) + + checkout_payment_model = models_checkout.CheckoutPayment( + id=uuid.uuid4(), + checkout_id=checkout.id, + paid_amount=content.data.amount, + tip_amount=content.data.amountTip, + hello_asso_payment_id=content.data.id, ) - raise HTTPException( - status_code=400, - detail="Secret mismatch", + await cruds_checkout.create_checkout_payment( + checkout_payment=checkout_payment_model, + db=db, ) - checkout_payment_model = models_checkout.CheckoutPayment( - id=uuid.uuid4(), - checkout_id=checkout.id, - paid_amount=content.data.amount, # ty:ignore[unresolved-attribute] - tip_amount=content.data.amountTip, # ty:ignore[unresolved-attribute] - hello_asso_payment_id=content.data.id, # ty:ignore[unresolved-attribute] - ) - await cruds_checkout.create_checkout_payment( - checkout_payment=checkout_payment_model, - db=db, - ) - - hyperion_error_logger.info( - f"Payment: checkout payment added to db for checkout (hyperion_checkout_id: {checkout_metadata.hyperion_checkout_id}, HelloAsso checkout_id: {checkout.id})", - ) - - # If a callback is defined for the module, we want to call it - try: - for module in all_modules: - if module.root == checkout.module: - 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})", - ) - return - hyperion_error_logger.info( - f"Payment: calling module {checkout.module} payment callback", - ) - checkout_payment_schema = schemas_checkout.CheckoutPayment( - id=checkout_payment_model.id, - paid_amount=checkout_payment_model.paid_amount, - checkout_id=checkout_payment_model.checkout_id, - ) - 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", - ) - return - hyperion_error_logger.info( - f"Payment: callback for checkout (hyperion_checkout_id: {checkout_metadata.hyperion_checkout_id}, HelloAsso checkout_id: {checkout.id}) was not called for module {checkout.module}", - ) - except Exception: - hyperion_error_logger.exception( - f"Payment: call to module {checkout.module} payment callback for checkout (hyperion_checkout_id: {checkout_metadata.hyperion_checkout_id}, HelloAsso checkout_id: {checkout.id}) failed", + f"Payment: checkout payment added to db for checkout (hyperion_checkout_id: {checkout_metadata.hyperion_checkout_id}, HelloAsso checkout_id: {checkout.id})", ) + + # If a callback is defined for the module, we want to call it + try: + for module in all_modules: + if module.root == checkout.module: + if module.checkout_callback is not None: + hyperion_error_logger.info( + f"Payment: calling module {checkout.module} payment callback", + ) + checkout_payment_schema = ( + schemas_checkout.CheckoutPayment.model_validate( + checkout_payment_model.__dict__, + ) + ) + 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", + ) + return + except Exception: + hyperion_error_logger.exception( + f"Payment: call to module {checkout.module} payment callback for checkout (hyperion_checkout_id: {checkout_metadata.hyperion_checkout_id}, HelloAsso checkout_id: {checkout.id}) failed", + ) diff --git a/app/core/checkout/payment_tool.py b/app/core/checkout/payment_tool.py index fb033e8e85..e5f0284c7d 100644 --- a/app/core/checkout/payment_tool.py +++ b/app/core/checkout/payment_tool.py @@ -164,7 +164,7 @@ async def init_checkout( # Thus we catch any exception and log it, then reraise it try: payer: HelloAssoApiV5ModelsCartsCheckoutPayer | None = None - if payer_user: + if payer_user is not None: payer = HelloAssoApiV5ModelsCartsCheckoutPayer( first_name=payer_user.firstname, last_name=payer_user.name, @@ -172,7 +172,8 @@ async def init_checkout( date_of_birth=datetime.combine( payer_user.birthday, datetime.min.time(), - ).replace(tzinfo=UTC) + tzinfo=UTC, + ) if payer_user.birthday else None, ) @@ -193,7 +194,7 @@ async def init_checkout( secret=secret, hyperion_checkout_id=str(checkout_model_id), ).model_dump(), - ) # ty:ignore[missing-argument] + ) # ty:ignore[missing-argument] # See https://github.com/astral-sh/ty/issues/1438 response: HelloAssoApiV5ModelsCartsInitCheckoutResponse with ApiClient(configuration) as api_client: @@ -211,25 +212,27 @@ async def init_checkout( hyperion_error_logger.exception( f"Payment: failed to init a checkout with HA for module {module} and name {checkout_name} (no payer info provided).", ) - else: - payer_user_name = f"{payer_user.firstname} {payer_user.name}" - hyperion_error_logger.warning( - f"Payment: failed to init a checkout with HA for module {module} and name {checkout_name}. Retrying without payer infos for {payer_user_name}", + raise + + payer_user_name = f"{payer_user.firstname} {payer_user.name}" + hyperion_error_logger.warning( + f"Payment: failed to init a checkout with HA for module {module} and name {checkout_name}. Retrying without payer infos for {payer_user_name}", + ) + + init_checkout_body.payer = None + try: + response = checkout_api.organizations_organization_slug_checkout_intents_post( + self._helloasso_slug, + init_checkout_body, + ) + except UnauthorizedException: + # HelloAsso returned a 401 unauthorized again + hyperion_error_logger.exception( + f"Payment: failed to init a checkout with HA for module {module} and name {checkout_name}, with and without payer {payer_user_name} infos", ) + raise - init_checkout_body.payer = None - try: - response = checkout_api.organizations_organization_slug_checkout_intents_post( - self._helloasso_slug, - init_checkout_body, - ) - except UnauthorizedException: - # HelloAsso returned a 401 unauthorized again - hyperion_error_logger.exception( - f"Payment: failed to init a checkout with HA for module {module} and name {checkout_name}, with and without payer {payer_user_name} infos", - ) - - if response and response.id: + if response and response.id and response.redirect_url: checkout_model = models_checkout.Checkout( id=checkout_model_id, module=module, @@ -246,7 +249,7 @@ async def init_checkout( payment_url=response.redirect_url or "", ) hyperion_error_logger.error( - f"Payment: failed to init a checkout with HA for module {module} and name {checkout_name}. No checkout id returned", + f"Payment: failed to init a checkout with HA for module {module} and name {checkout_name}. No checkout id or redirect URL returned", ) raise MissingHelloAssoCheckoutIdError() # noqa: TRY301 diff --git a/app/core/google_api/google_api.py b/app/core/google_api/google_api.py index 0575ef9e95..aabf34a445 100644 --- a/app/core/google_api/google_api.py +++ b/app/core/google_api/google_api.py @@ -246,7 +246,7 @@ def create_folder(self, folder_name: str, parent_folder_id: GoogleId) -> GoogleI "mimeType": "application/vnd.google-apps.folder", "parents": [parent_folder_id], } - response = self._drive.files().create(body=file_metadata).execute() + response = self._drive.files().create(body=file_metadata).execute() # ty:ignore[unresolved-attribute] folder_id: GoogleId = response.get("id") return folder_id @@ -270,7 +270,7 @@ async def upload_file( mimetype=mimetype, ) response = ( - self._drive.files().create(body=file_metadata, media_body=media).execute() + self._drive.files().create(body=file_metadata, media_body=media).execute() # ty:ignore[unresolved-attribute] ) uploaded_file_id: GoogleId = response.get("id") return uploaded_file_id @@ -288,7 +288,7 @@ def replace_file( mimetype="application/pdf", ) response = ( - self._drive.files() + self._drive.files() # ty:ignore[unresolved-attribute] .update(fileId=file_id, body=file_metadata, media_body=media) .execute() ) @@ -296,4 +296,4 @@ def replace_file( return result def delete_file(self, file_id: GoogleId) -> None: - self._drive.files().delete(fileId=file_id).execute() + self._drive.files().delete(fileId=file_id).execute() # ty:ignore[unresolved-attribute] diff --git a/app/core/mypayment/endpoints_mypayment.py b/app/core/mypayment/endpoints_mypayment.py index dfcc3ab7f1..988c047ed5 100644 --- a/app/core/mypayment/endpoints_mypayment.py +++ b/app/core/mypayment/endpoints_mypayment.py @@ -1861,6 +1861,8 @@ async def get_user_wallet_history( transaction_type = HistoryType.DIRECT_TRANSACTION elif transaction.transaction_type == TransactionType.REQUEST: transaction_type = HistoryType.REQUEST_TRANSACTION + else: + raise UnexpectedError("Unknown transaction type") # noqa: TRY003 if transaction.credited_wallet_id == user_payment.wallet_id: # The user received the transaction direction = HistoryDirection.CREDITED @@ -2596,6 +2598,7 @@ async def refund_transaction( ) if wallet_previously_credited.user is not None: + wallet_previously_debited_name: str = "Unknown" if wallet_previously_debited.user is not None: wallet_previously_debited_name = wallet_previously_debited.user.full_name elif wallet_previously_debited.store is not None: diff --git a/app/core/utils/log.py b/app/core/utils/log.py index aa23ad8751..bf6ee021c3 100644 --- a/app/core/utils/log.py +++ b/app/core/utils/log.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import Any -import uvicorn +import uvicorn.logging from app.core.utils.config import Settings diff --git a/app/modules/amap/endpoints_amap.py b/app/modules/amap/endpoints_amap.py index 1526fc7e45..82c4ada6d9 100644 --- a/app/modules/amap/endpoints_amap.py +++ b/app/modules/amap/endpoints_amap.py @@ -11,6 +11,7 @@ from app.core.permissions.type_permissions import ModulePermissions from app.core.users import cruds_users, models_users, schemas_users from app.core.users.endpoints_users import read_user +from app.core.users.schemas_users import CoreUserSimple from app.dependencies import ( get_db, get_notification_tool, @@ -20,7 +21,9 @@ ) from app.modules.amap import cruds_amap, models_amap, schemas_amap from app.modules.amap.factory_amap import AmapFactory +from app.modules.amap.schemas_amap import ProductComplete from app.modules.amap.types_amap import DeliveryStatusType +from app.types.exceptions import ObjectExpectedInDbNotFoundError from app.types.module import Module from app.utils.communication.notifications import NotificationTool from app.utils.redis import locker_get, locker_set @@ -417,10 +420,33 @@ async def get_order_by_id( products = await cruds_amap.get_products_of_order(db=db, order_id=order_id) return schemas_amap.OrderReturn( - productsdetail=products, delivery_name=order.delivery.name, + productsdetail=[ + schemas_amap.ProductQuantity( + quantity=product.quantity, + product=ProductComplete( + name=product.product.name, + price=product.product.price, + category=product.product.category, + id=product.product.id, + ), + ) + for product in products + ], + user=schemas_users.CoreUserSimple( + id=order.user.id, + name=order.user.name, + firstname=order.user.firstname, + nickname=order.user.nickname, + school_id=order.user.school_id, + account_type=order.user.account_type, + ), + delivery_id=order.delivery_id, + collection_slot=order.collection_slot, + order_id=order.order_id, + amount=order.amount, + ordering_date=order.ordering_date, delivery_date=order.delivery.delivery_date, - **order.__dict__, ) @@ -539,13 +565,39 @@ async def add_order_to_delievery( ) if orderret is None: - raise HTTPException(status_code=404, detail="added order not found") + raise ObjectExpectedInDbNotFoundError( + object_name="Order", + object_id=db_order.order_id, + ) return schemas_amap.OrderReturn( - productsdetail=productsret, - delivery_name=orderret.delivery.name, + user=CoreUserSimple( + id=orderret.user.id, + account_type=orderret.user.account_type, + school_id=orderret.user.school_id, + name=orderret.user.name, + firstname=orderret.user.firstname, + nickname=orderret.user.nickname, + ), + productsdetail=[ + schemas_amap.ProductQuantity( + quantity=product.quantity, + product=ProductComplete( + name=product.product.name, + price=product.product.price, + category=product.product.category, + id=product.product.id, + ), + ) + for product in productsret + ], + delivery_id=orderret.delivery_id, + collection_slot=orderret.collection_slot, + order_id=orderret.order_id, + amount=orderret.amount, + ordering_date=orderret.ordering_date, delivery_date=orderret.delivery.delivery_date, - **orderret.__dict__, + delivery_name=orderret.delivery.name, ) finally: locker_set(redis_client=redis_client, key=redis_key, lock=False) @@ -610,7 +662,7 @@ async def edit_order_from_delivery( ): raise HTTPException(status_code=400, detail="Invalid request") - amount = 0.0 + amount = 0 for product_id, product_quantity in zip( order.products_ids, order.products_quantity, @@ -624,11 +676,16 @@ async def edit_order_from_delivery( db_order = schemas_amap.OrderComplete( order_id=order_id, ordering_date=previous_order.ordering_date, - delivery_date=delivery.delivery_date, delivery_id=previous_order.delivery_id, user_id=previous_order.user_id, amount=amount, - **order.model_dump(), + products_ids=order.products_ids, + collection_slot=order.collection_slot + if order.collection_slot is not None + else previous_order.collection_slot, + products_quantity=order.products_quantity + if order.products_quantity is not None + else previous_order.products_quantity, ) previous_amount = previous_order.amount @@ -1059,10 +1116,33 @@ async def get_orders_of_user( raise HTTPException(status_code=404, detail="at least one order not found") res.append( schemas_amap.OrderReturn( - productsdetail=products, delivery_date=order.delivery.delivery_date, delivery_name=order.delivery.name, - **order.__dict__, + user=CoreUserSimple( + id=order.user.id, + account_type=order.user.account_type, + school_id=order.user.school_id, + name=order.user.name, + firstname=order.user.firstname, + nickname=order.user.nickname, + ), + productsdetail=[ + schemas_amap.ProductQuantity( + quantity=product.quantity, + product=ProductComplete( + name=product.product.name, + price=product.product.price, + category=product.product.category, + id=product.product.id, + ), + ) + for product in products + ], + delivery_id=order.delivery_id, + collection_slot=order.collection_slot, + order_id=order.order_id, + amount=order.amount, + ordering_date=order.ordering_date, ), ) return res diff --git a/app/modules/cdr/utils_cdr.py b/app/modules/cdr/utils_cdr.py index b18c6500af..b0cc5ead24 100644 --- a/app/modules/cdr/utils_cdr.py +++ b/app/modules/cdr/utils_cdr.py @@ -437,6 +437,7 @@ def write_product_headers( custom_cols = prod_struct["custom_cols"] needs_validation = prod_struct["needs_validation"] + end_col = 0 if variants_info: start_col = variants_info[0]["qty_col"] end_col = ( diff --git a/app/modules/flappybird/endpoints_flappybird.py b/app/modules/flappybird/endpoints_flappybird.py index d1e68b2ade..c41e582e81 100644 --- a/app/modules/flappybird/endpoints_flappybird.py +++ b/app/modules/flappybird/endpoints_flappybird.py @@ -6,7 +6,7 @@ from app.core.groups.groups_type import AccountType from app.core.permissions.type_permissions import ModulePermissions -from app.core.users import models_users +from app.core.users import models_users, schemas_users from app.dependencies import get_db, is_user_allowed_to from app.modules.flappybird import ( cruds_flappybird, @@ -80,7 +80,14 @@ async def get_current_user_flappybird_personal_best( ) return schemas_flappybird.FlappyBirdScoreCompleteFeedBack( value=user_personal_best_table.value, - user=user_personal_best_table.user, + user=schemas_users.CoreUserSimple( + id=user_personal_best_table.user.id, + account_type=user_personal_best_table.user.account_type, + school_id=user_personal_best_table.user.school_id, + name=user_personal_best_table.user.name, + firstname=user_personal_best_table.user.firstname, + nickname=user_personal_best_table.user.nickname, + ), creation_time=user_personal_best_table.creation_time, position=position, ) diff --git a/app/modules/loan/endpoints_loan.py b/app/modules/loan/endpoints_loan.py index 06ff4eebf4..c364ee5a77 100644 --- a/app/modules/loan/endpoints_loan.py +++ b/app/modules/loan/endpoints_loan.py @@ -9,7 +9,7 @@ from app.core.groups.groups_type import AccountType from app.core.notification.schemas_notification import Message from app.core.permissions.type_permissions import ModulePermissions -from app.core.users import models_users +from app.core.users import models_users, schemas_users from app.dependencies import ( get_db, get_notification_tool, @@ -233,8 +233,28 @@ async def get_loans_by_loaner( loans.append( schemas_loan.Loan( items_qty=items_qty_ret, - loaner=loaner, - **loan.__dict__, + borrower_id=loan.borrower_id, + loaner_id=loan.loaner_id, + start=loan.start, + end=loan.end, + notes=loan.notes, + caution=loan.caution, + returned=loan.returned, + returned_date=loan.returned_date, + id=loan.id, + borrower=schemas_users.CoreUserSimple( + id=loan.borrower.id, + account_type=loan.borrower.account_type, + school_id=loan.borrower.school_id, + name=loan.borrower.name, + firstname=loan.borrower.firstname, + nickname=loan.borrower.nickname, + ), + loaner=schemas_loan.Loaner( + id=loan.loaner.id, + name=loan.loaner.name, + group_manager_id=loan.loaner.group_manager_id, + ), ), ) @@ -738,6 +758,8 @@ async def update_loan( detail="Invalid user_id", ) + items: list[tuple[models_loan.Item, int]] = [] + # If a new list of items was provided, we need to mark old items as available and new items as not available if loan_update.items_borrowed: for old_item in loan.items: @@ -755,8 +777,6 @@ async def update_loan( # We remove the old items from the database await cruds_loan.delete_loan_content_by_loan_id(loan_id=loan_id, db=db) - items: list[tuple[models_loan.Item, int]] = [] - # All items should be valid, available and belong to the loaner for item_borrowed in loan_update.items_borrowed: item_id: str = item_borrowed.item_id @@ -796,14 +816,11 @@ async def update_loan( # We make a list of every new item with the quantity borrowed to update the loaned quantity and create the loaned content items.append((item, quantity)) - try: - await cruds_loan.update_loan( - loan_id=loan_id, - loan_update=loan_update, - db=db, - ) - except ValueError as error: - raise HTTPException(status_code=422, detail=str(error)) + await cruds_loan.update_loan( + loan_id=loan_id, + loan_update=loan_update, + db=db, + ) for item, quantity in items: # We add each item to the loan @@ -959,7 +976,7 @@ async def extend_loan( status_code=400, detail="Invalid loan_id", ) - end = loan.end + # The user should be a member of the loaner's manager group if not is_user_member_of_any_group(user, [loan.loaner.group_manager_id]): raise HTTPException( @@ -967,16 +984,15 @@ async def extend_loan( detail=f"Unauthorized to manage {loan.loaner_id} loaner", ) + end = loan.end if loan_extend.end is not None: end = loan_extend.end - loan_update = schemas_loan.LoanUpdate( - end=end, - ) elif loan_extend.duration is not None: end = loan.end + timedelta(seconds=loan_extend.duration) - loan_update = schemas_loan.LoanUpdate( - end=end, - ) + + loan_update = schemas_loan.LoanUpdate( + end=end, + ) await cruds_loan.update_loan( loan_id=loan_id, diff --git a/app/modules/raffle/endpoints_raffle.py b/app/modules/raffle/endpoints_raffle.py index 194593af6a..f96b80ae0a 100644 --- a/app/modules/raffle/endpoints_raffle.py +++ b/app/modules/raffle/endpoints_raffle.py @@ -9,7 +9,7 @@ from app.core.groups import cruds_groups from app.core.groups.groups_type import AccountType from app.core.permissions.type_permissions import ModulePermissions -from app.core.users import cruds_users, models_users +from app.core.users import cruds_users, models_users, schemas_users from app.core.users.endpoints_users import read_user from app.dependencies import ( get_db, @@ -210,6 +210,7 @@ async def get_raffle_stats( amount_raised = sum( [ticket.pack_ticket.price / ticket.pack_ticket.pack_size for ticket in tickets], ) + amount_raised = int(amount_raised) return schemas_raffle.RaffleStats( tickets_sold=tickets_sold, @@ -903,7 +904,18 @@ async def get_cash_by_id( # We want to return a balance of 0 but we don't want to add it to the database # An admin AMAP has indeed to add a cash to the user the first time # TODO: this is a strange behaviour - return schemas_raffle.CashComplete(balance=0, user_id=user_id, user=user_db) + return schemas_raffle.CashComplete( + balance=0, + user_id=user_id, + user=schemas_users.CoreUserSimple( + id=user_db.id, + account_type=user_db.account_type, + school_id=user_db.school_id, + name=user_db.name, + firstname=user_db.firstname, + nickname=user_db.nickname, + ), + ) raise HTTPException( status_code=403, detail="Users that are not member of the group admin can only access the endpoint for their own user_id.", diff --git a/app/modules/seed_library/endpoints_seed_library.py b/app/modules/seed_library/endpoints_seed_library.py index 83bfb4a98c..9f712df8b2 100644 --- a/app/modules/seed_library/endpoints_seed_library.py +++ b/app/modules/seed_library/endpoints_seed_library.py @@ -72,7 +72,7 @@ async def get_all_species_types( Return all available types of species from SpeciesType enum. """ return schemas_seed_library.SpeciesTypesReturn( - species_type=[species_type.value for species_type in SpeciesType], + species_type=list(SpeciesType), ) @@ -331,13 +331,13 @@ async def create_plant( "Species not found", ) date = datetime.now(tz=UTC) - if species_reference: - reference = f"{species_reference.prefix}-{date.day:02}-{date.month:02}-{str(date.year)[2:]}-" - plant_number = await cruds_seed_library.count_plants_created_today( - reference, - db, - ) - reference = f"{species_reference.prefix}-{date.day:02}-{date.month:02}-{str(date.year)[2:]}-{plant_number:03}" + + reference = f"{species_reference.prefix}-{date.day:02}-{date.month:02}-{str(date.year)[2:]}-" + plant_number = await cruds_seed_library.count_plants_created_today( + reference, + db, + ) + reference = f"{species_reference.prefix}-{date.day:02}-{date.month:02}-{str(date.year)[2:]}-{plant_number:03}" plant = schemas_seed_library.PlantComplete( id=uuid.uuid4(), diff --git a/app/modules/seed_library/models_seed_library.py b/app/modules/seed_library/models_seed_library.py index 96e3e5ad8f..aa222b3bdf 100644 --- a/app/modules/seed_library/models_seed_library.py +++ b/app/modules/seed_library/models_seed_library.py @@ -17,10 +17,10 @@ class Species(Base): id: Mapped[PrimaryKey] prefix: Mapped[str] = mapped_column(unique=True) # 3 letters name: Mapped[str] = mapped_column(unique=True) - difficulty: Mapped[int | None] + difficulty: Mapped[int] card: Mapped[str | None] nb_seeds_recommended: Mapped[int | None] - species_type: Mapped[SpeciesType | None] + species_type: Mapped[SpeciesType] start_season: Mapped[date | None] end_season: Mapped[date | None] time_maturation: Mapped[int | None] # number of days @@ -35,7 +35,7 @@ class Plant(Base): ForeignKey("seed_library_species.id"), ) propagation_method: Mapped[PropagationMethod] - nb_seeds_envelope: Mapped[int | None] + nb_seeds_envelope: Mapped[int] ancestor_id: Mapped[uuid.UUID | None] = mapped_column( ForeignKey("seed_library_plants.id"), ) @@ -45,7 +45,7 @@ class Plant(Base): ForeignKey("core_user.id"), index=True, ) - confidential: Mapped[bool | None] + confidential: Mapped[bool] nickname: Mapped[str | None] planting_date: Mapped[date | None] borrowing_date: Mapped[date | None] diff --git a/app/modules/sport_competition/endpoints_sport_competition.py b/app/modules/sport_competition/endpoints_sport_competition.py index c06938c892..5e2be78c6b 100644 --- a/app/modules/sport_competition/endpoints_sport_competition.py +++ b/app/modules/sport_competition/endpoints_sport_competition.py @@ -2193,6 +2193,7 @@ async def join_sport( status_code=400, detail="Maximum number of substitutes in the team reached", ) + team_id = participant_info.team_id elif participant_info.team_id is not None: raise HTTPException( @@ -2211,6 +2212,8 @@ async def join_sport( ) await cruds_sport_competition.add_team(new_team, db) + team_id = new_team.id + participant = schemas_sport_competition.Participant( user_id=user.user_id, sport_id=sport_id, @@ -2219,7 +2222,7 @@ async def join_sport( license=participant_info.license, substitute=participant_info.substitute, is_license_valid=False, - team_id=participant_info.team_id or new_team.id, + team_id=team_id, ) await cruds_sport_competition.add_participant( participant, diff --git a/app/modules/sport_competition/utils/data_exporter/global_exporter.py b/app/modules/sport_competition/utils/data_exporter/global_exporter.py index 8db88ec631..5c25bc7bdf 100644 --- a/app/modules/sport_competition/utils/data_exporter/global_exporter.py +++ b/app/modules/sport_competition/utils/data_exporter/global_exporter.py @@ -66,6 +66,7 @@ def build_data_rows( col_idx: int, ) -> tuple[list[list[str | int]], list[int]]: data_rows: list[list[str | int]] = [] + thick_columns = [len(FIXED_COLUMNS) - 1] for user in users: user_purchases = users_purchases.get(user.user.id, []) row: list[str | int] = [""] * col_idx diff --git a/app/modules/sport_competition/utils/data_exporter/school_participants_exporter.py b/app/modules/sport_competition/utils/data_exporter/school_participants_exporter.py index 093b8b170d..2140a52048 100644 --- a/app/modules/sport_competition/utils/data_exporter/school_participants_exporter.py +++ b/app/modules/sport_competition/utils/data_exporter/school_participants_exporter.py @@ -76,6 +76,7 @@ def build_data_rows( col_idx: int, ) -> tuple[list[list[str | int]], list[int]]: data_rows: list[list[str | int]] = [] + thick_columns = [len(FIXED_COLUMNS) - 1] for user in users: user_purchases = users_purchases.get(user.user.id, []) row: list[str | int] = [""] * col_idx diff --git a/app/types/websocket.py b/app/types/websocket.py index 26eac804ae..f9d2f7a02f 100644 --- a/app/types/websocket.py +++ b/app/types/websocket.py @@ -179,9 +179,9 @@ async def _subscribe_and_listen_to_channel(self, room_id: HyperionWebsocketsRoom f"Websocket: subscribed broadcaster to channel {room_id} for worker {os.getpid()}", ) - async for event in subscriber: # type: ignore[union-attr] # Should be fixed by https://github.com/encode/broadcaster/issues/136 + async for event in subscriber: # type: ignore[union-attr] # Should be fixed by https://github.com/encode/broadcaster/issues/136 # ty:ignore[not-iterable] await self._consume_events_from_broadcaster( - message_str=event.message, # type: ignore[union-attr] # Should be fixed by https://github.com/encode/broadcaster/issues/136 + message_str=event.message, # type: ignore[union-attr] # Should be fixed by https://github.com/encode/broadcaster/issues/136 # ty:ignore[unresolved-attribute] room_id=room_id, ) diff --git a/app/utils/communication/notifications.py b/app/utils/communication/notifications.py index df6337ba9f..23f3046330 100644 --- a/app/utils/communication/notifications.py +++ b/app/utils/communication/notifications.py @@ -166,8 +166,8 @@ def _send_firebase_push_notification_by_topic( if not self.use_firebase: return + topic = str(topic_id) try: - topic = str(topic_id) message = messaging.Message( topic=topic, data={"action_module": message_content.action_module}, diff --git a/app/utils/initialization.py b/app/utils/initialization.py index b21528ff03..9db227985b 100644 --- a/app/utils/initialization.py +++ b/app/utils/initialization.py @@ -1,6 +1,7 @@ import asyncio import logging import os +import uuid from collections.abc import Callable from typing import ParamSpec, TypeVar @@ -147,7 +148,7 @@ def set_core_data_crud_sync( def get_school_by_id_sync( - school_id: str, + school_id: uuid.UUID, db: Session, ) -> models_schools.CoreSchool | None: """ @@ -341,9 +342,7 @@ async def use_lock_for_workers[**P, R]( elif redis_client.set(key, "1", nx=True, ex=120): # We acquired the lock, we execute the function - logger.info( - f"Running {job_function.__name__}", # ty:ignore[unresolved-attribute] - ) + logger.info(f"Running {getattr(job_function, '__name__', repr(job_function))}") await execute_async_or_sync_method(job_function, *args, **kwargs) @@ -363,7 +362,7 @@ async def use_lock_for_workers[**P, R]( # As an `unlock_key` is provided, we will wait until an other worker has finished executing `job_function` while redis_client.get(unlock_key) is None: logger.debug( - f"Waiting for {job_function.__name__} to finish", # ty:ignore[unresolved-attribute] + f"Waiting for {getattr(job_function, '__name__', repr(job_function))} to finish", ) await asyncio.sleep(1) diff --git a/app/utils/tools.py b/app/utils/tools.py index 42c3f02918..b79b572ae6 100644 --- a/app/utils/tools.py +++ b/app/utils/tools.py @@ -201,6 +201,7 @@ async def ensure_file_properties( An HTTP Exception will be raised if an error occurs. The file will not be saved nor modified. + Return the content type of the file. """ if accepted_content_types is None: # Accept only images by default diff --git a/migrations/versions/26-account_types.py b/migrations/versions/26-account_types.py index f5b970becd..e9aa607afd 100644 --- a/migrations/versions/26-account_types.py +++ b/migrations/versions/26-account_types.py @@ -211,7 +211,7 @@ def upgrade() -> None: } module_awareness = ModuleVisibilityAwareness( - roots={group_visibility.root for group_visibility in group_visibilities}, + roots=list({group_visibility.root for group_visibility in group_visibilities}), ) conn.execute( diff --git a/migrations/versions/71-seed_non_optionals.py b/migrations/versions/71-seed_non_optionals.py new file mode 100644 index 0000000000..310b644433 --- /dev/null +++ b/migrations/versions/71-seed_non_optionals.py @@ -0,0 +1,177 @@ +"""empty message + +Create Date: 2026-04-13 11:44:05.698432 +""" + +import uuid +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from sqlalchemy.dialects import postgresql + +if TYPE_CHECKING: + from pytest_alembic import MigrationContext + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "3108c3bc5425" +down_revision: str | None = "9bb79b2466f9" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute( + """ + UPDATE seed_library_plants + SET nb_seeds_envelope = 0 + WHERE nb_seeds_envelope IS NULL + """, + ) + op.alter_column( + "seed_library_plants", + "nb_seeds_envelope", + existing_type=sa.INTEGER(), + nullable=False, + ) + op.execute( + """ + UPDATE seed_library_plants + SET confidential = FALSE + WHERE confidential IS NULL + """, + ) + op.alter_column( + "seed_library_plants", + "confidential", + existing_type=sa.BOOLEAN(), + nullable=False, + ) + op.execute( + """ + UPDATE seed_library_species + SET difficulty = 0 + WHERE difficulty IS NULL + """, + ) + op.alter_column( + "seed_library_species", + "difficulty", + existing_type=sa.INTEGER(), + nullable=False, + ) + op.execute( + """ + UPDATE seed_library_species + SET species_type = 'other' + WHERE species_type IS NULL + """, + ) + op.alter_column( + "seed_library_species", + "species_type", + existing_type=postgresql.ENUM( + "aromatic", + "vegetables", + "interior", + "fruit", + "cactus", + "ornamental", + "succulent", + "other", + name="speciestype", + ), + nullable=False, + ) + + +def downgrade() -> None: + op.alter_column( + "seed_library_species", + "species_type", + existing_type=postgresql.ENUM( + "aromatic", + "vegetables", + "interior", + "fruit", + "cactus", + "ornamental", + "succulent", + "other", + name="speciestype", + ), + nullable=True, + ) + op.alter_column( + "seed_library_species", + "difficulty", + existing_type=sa.INTEGER(), + nullable=True, + ) + op.alter_column( + "seed_library_plants", + "confidential", + existing_type=sa.BOOLEAN(), + nullable=True, + ) + op.alter_column( + "seed_library_plants", + "nb_seeds_envelope", + existing_type=sa.INTEGER(), + nullable=True, + ) + + +def pre_test_upgrade( + alembic_runner: "MigrationContext", + alembic_connection: sa.Connection, +) -> None: + species_id = uuid.uuid4() + plant_id = uuid.uuid4() + + # Insert species with NULL values (future NOT NULL fields) + alembic_runner.insert_into( + "seed_library_species", + { + "id": species_id, + "prefix": "TST", + "name": f"Test species {species_id}", + "difficulty": None, # will be backfilled to 0 + "card": None, + "nb_seeds_recommended": None, + "species_type": None, # will be backfilled to 'other' + "start_season": None, + "end_season": None, + "time_maturation": None, + }, + ) + + # Insert plant with NULL values (future NOT NULL fields) + alembic_runner.insert_into( + "seed_library_plants", + { + "id": plant_id, + "reference": f"REF-{plant_id}", + "state": "waiting", # adjust if enum differs + "species_id": species_id, + "propagation_method": "seed", # adjust if enum differs + "nb_seeds_envelope": None, # will be backfilled to 0 + "ancestor_id": None, + "previous_note": None, + "current_note": None, + "borrower_id": None, + "confidential": None, # will be backfilled to False + "nickname": None, + "planting_date": None, + "borrowing_date": None, + }, + ) + + +def test_upgrade( + alembic_runner: "MigrationContext", + alembic_connection: sa.Connection, +) -> None: + pass diff --git a/pyproject.toml b/pyproject.toml index 16d5d0aeb4..8c48e773fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -200,3 +200,6 @@ exclude_also = [] skip_covered = true show_missing = true + +[tool.ty.rules] +all = "error" diff --git a/requirements-dev.txt b/requirements-dev.txt index 7a23e09373..a44d33b8da 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -9,6 +9,7 @@ pytest-cov==7.1.0 pytest-mock==3.15.1 pytest==9.0.3 ruff==0.15.10 +ty==0.0.29 types-Authlib==1.6.11.20260418 types-fpdf2==2.8.4.20260408 types-psutil==7.2.2.20260408