diff --git a/app/app.py b/app/app.py index de892b91c4..3a3ffdb64c 100644 --- a/app/app.py +++ b/app/app.py @@ -52,7 +52,7 @@ from app.utils.state import LifespanState if TYPE_CHECKING: - from redis import Redis + from redis.asyncio import Redis from app.types.factory import Factory @@ -400,21 +400,21 @@ async def initialize_notification_topics( ) -def use_route_path_as_operation_ids(app: FastAPI) -> None: +def use_route_path_as_operation_id(route: APIRoute) -> str: """ - Simplify operation IDs so that generated API clients have simpler function names. + Simplify operation ID so that generated API clients have simpler function names. Theses names may be used by API clients to generate function names. The operation_id will have the format "method_path", like "get_users_me". See https://fastapi.tiangolo.com/advanced/path-operation-advanced-configuration/ """ - for route in app.routes: - if isinstance(route, APIRoute): - # The operation_id should be unique. - # It is possible to set multiple methods for the same endpoint method but it's not considered a good practice. - method = "_".join(route.methods) - route.operation_id = method.lower() + route.path.replace("/", "_") + if route.methods: + # The operation_id should be unique. + # It is possible to set multiple methods for the same endpoint method but it's not considered a good practice. + method = "_".join(route.methods) + return method.lower() + route.path.replace("/", "_") + return route.name def init_db( @@ -645,9 +645,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[LifespanState]: title="Hyperion", version=settings.HYPERION_VERSION, lifespan=lifespan, + custom_generate_unique_id=use_route_path_as_operation_id, ) app.include_router(api.api_router) - use_route_path_as_operation_ids(app) app.add_middleware( CORSMiddleware, @@ -702,7 +702,7 @@ async def logging_middleware( # We test the ip address with the redis limiter process = True if redis_client and settings.ENABLE_RATE_LIMITER: # If redis is configured - process, log = limiter( + process, log = await limiter( redis_client, ip_address, settings.REDIS_LIMIT, diff --git a/app/core/checkout/payment_tool.py b/app/core/checkout/payment_tool.py index 4daa04f8f6..e0ddc5a5b7 100644 --- a/app/core/checkout/payment_tool.py +++ b/app/core/checkout/payment_tool.py @@ -166,10 +166,10 @@ async def init_checkout( payer: HelloAssoApiV5ModelsCartsCheckoutPayer | None = None if payer_user is not None: payer = HelloAssoApiV5ModelsCartsCheckoutPayer( - first_name=payer_user.firstname, # ty:ignore[unknown-argument] - last_name=payer_user.name, # ty:ignore[unknown-argument] + first_name=payer_user.firstname, + last_name=payer_user.name, email=payer_user.email, - date_of_birth=datetime.combine( # ty:ignore[unknown-argument] + date_of_birth=datetime.combine( payer_user.birthday, datetime.min.time(), tzinfo=UTC, @@ -182,19 +182,19 @@ async def init_checkout( secret = security.generate_token(nbytes=12) init_checkout_body = HelloAssoApiV5ModelsCartsInitCheckoutBody( - total_amount=checkout_amount, # ty:ignore[unknown-argument] - initial_amount=checkout_amount, # ty:ignore[unknown-argument] - item_name=checkout_name, # ty:ignore[unknown-argument] - back_url=redirection_uri, # ty:ignore[unknown-argument] - error_url=redirection_uri, # ty:ignore[unknown-argument] - return_url=redirection_uri, # ty:ignore[unknown-argument] - contains_donation=False, # ty:ignore[unknown-argument] + total_amount=checkout_amount, + initial_amount=checkout_amount, + item_name=checkout_name, + back_url=redirection_uri, + error_url=redirection_uri, + return_url=redirection_uri, + contains_donation=False, payer=payer, metadata=schemas_checkout.HelloAssoCheckoutMetadata( secret=secret, hyperion_checkout_id=str(checkout_model_id), ).model_dump(), - ) # ty:ignore[missing-argument] # See https://github.com/astral-sh/ty/issues/1438 + ) response: HelloAssoApiV5ModelsCartsInitCheckoutResponse with ApiClient(configuration) as api_client: diff --git a/app/core/permissions/endpoints_permissions.py b/app/core/permissions/endpoints_permissions.py index 913b80902b..cbf7ec7265 100644 --- a/app/core/permissions/endpoints_permissions.py +++ b/app/core/permissions/endpoints_permissions.py @@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.permissions import cruds_permissions, schemas_permissions +from app.core.permissions.factory_permissions import CorePermissionsFactory from app.dependencies import ( get_db, is_user, @@ -29,7 +30,7 @@ root="permissions", tag="Permissions", router=router, - factory=None, + factory=CorePermissionsFactory(), ) hyperion_security_logger = logging.getLogger("hyperion.security") diff --git a/app/core/permissions/factory_permissions.py b/app/core/permissions/factory_permissions.py new file mode 100644 index 0000000000..691fdf6184 --- /dev/null +++ b/app/core/permissions/factory_permissions.py @@ -0,0 +1,31 @@ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.groups.groups_type import GroupType +from app.core.permissions import cruds_permissions, schemas_permissions +from app.core.utils.config import Settings +from app.module import permissions_list +from app.types.factory import Factory + + +class CorePermissionsFactory(Factory): + depends_on = [] + + @classmethod + async def run(cls, db: AsyncSession, settings: Settings) -> None: + for permission in permissions_list: + await cruds_permissions.create_group_permission( + permission=schemas_permissions.CoreGroupPermission( + permission_name=permission, + group_id=GroupType.admin.value, + ), + db=db, + ) + await db.commit() + + @classmethod + async def should_run(cls, db: AsyncSession): + permissions = await cruds_permissions.get_permissions( + permissions_list, + db, + ) + return len(permissions) == 0 diff --git a/app/core/utils/config.py b/app/core/utils/config.py index 6f65a16f2d..31dd64d88e 100644 --- a/app/core/utils/config.py +++ b/app/core/utils/config.py @@ -65,9 +65,9 @@ class School(BaseModel): # On registration, user whose email match these regex will be automatically assigned to the corresponding account type # Use simple quotes to avoid escaping the regex # Ex: `student_email_regex: '^[\w\-.]*@domain.fr$'` - student_email_regex: Pattern - staff_email_regex: Pattern | None = None - former_student_email_regex: Pattern | None = None + student_email_regex: Pattern[str] + staff_email_regex: Pattern[str] | None = None + former_student_email_regex: Pattern[str] | None = None # If event should be confirmed by a moderator before being added to the calendar require_event_confirmation: bool = True diff --git a/app/dependencies.py b/app/dependencies.py index 04533ef8de..aa520e770f 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -14,7 +14,7 @@ async def get_users(db: AsyncSession = Depends(get_db)): from uuid import UUID import calypsso -import redis +import redis.asyncio as redis import starlette import starlette.datastructures from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException, Request @@ -101,7 +101,7 @@ async def init_state( SessionLocal = init_SessionLocal(engine) - redis_client = init_redis_client( + redis_client = await init_redis_client( settings=settings, hyperion_error_logger=hyperion_error_logger, ) @@ -145,7 +145,7 @@ async def disconnect_state( This methode should be called as a dependency as tests may need to run additional steps """ - disconnect_redis_client(GLOBAL_STATE["redis_client"]) + await disconnect_redis_client(GLOBAL_STATE["redis_client"]) await disconnect_scheduler(GLOBAL_STATE["scheduler"]) await disconnect_websocket_connection_manager(GLOBAL_STATE["ws_manager"]) diff --git a/app/modules/amap/endpoints_amap.py b/app/modules/amap/endpoints_amap.py index ca3259d43d..d1e4a37180 100644 --- a/app/modules/amap/endpoints_amap.py +++ b/app/modules/amap/endpoints_amap.py @@ -3,7 +3,7 @@ from datetime import UTC, datetime from fastapi import Depends, HTTPException, Response -from redis import Redis +from redis.asyncio import Redis from sqlalchemy.ext.asyncio import AsyncSession from app.core.groups.groups_type import AccountType @@ -532,12 +532,12 @@ async def add_order_to_delievery( raise HTTPException(status_code=400, detail="You can't order nothing") redis_key = "amap_" + order.user_id - if not isinstance(redis_client, Redis) or locker_get( + if not isinstance(redis_client, Redis) or await locker_get( redis_client=redis_client, key=redis_key, ): raise HTTPException(status_code=429, detail="Too fast !") - locker_set(redis_client=redis_client, key=redis_key, lock=True) + await locker_set(redis_client=redis_client, key=redis_key, lock=True) try: await cruds_amap.add_order_to_delivery( @@ -600,7 +600,7 @@ async def add_order_to_delievery( delivery_name=orderret.delivery.name, ) finally: - locker_set(redis_client=redis_client, key=redis_key, lock=False) + await locker_set(redis_client=redis_client, key=redis_key, lock=False) @module.router.patch( @@ -694,12 +694,12 @@ async def edit_order_from_delivery( raise HTTPException(status_code=404, detail="No cash found") redis_key = "amap_" + previous_order.user_id - if not isinstance(redis_client, Redis) or locker_get( + if not isinstance(redis_client, Redis) or await locker_get( redis_client=redis_client, key=redis_key, ): raise HTTPException(status_code=429, detail="Too fast !") - locker_set(redis_client=redis_client, key=redis_key, lock=True) + await locker_set(redis_client=redis_client, key=redis_key, lock=True) try: await cruds_amap.edit_order_with_products( @@ -727,7 +727,7 @@ async def edit_order_from_delivery( ) finally: - locker_set(redis_client=redis_client, key=redis_key, lock=False) + await locker_set(redis_client=redis_client, key=redis_key, lock=False) @module.router.delete( @@ -778,12 +778,12 @@ async def remove_order( redis_key = "amap_" + order.user_id - if not isinstance(redis_client, Redis) or locker_get( + if not isinstance(redis_client, Redis) or await locker_get( redis_client=redis_client, key=redis_key, ): raise HTTPException(status_code=429, detail="Too fast !") - locker_set(redis_client=redis_client, key=redis_key, lock=True) + await locker_set(redis_client=redis_client, key=redis_key, lock=True) try: await cruds_amap.remove_order( @@ -801,7 +801,7 @@ async def remove_order( return Response(status_code=204) finally: - locker_set(redis_client=redis_client, key=redis_key, lock=False) + await locker_set(redis_client=redis_client, key=redis_key, lock=False) @module.router.post( diff --git a/app/modules/cdr/utils_cdr.py b/app/modules/cdr/utils_cdr.py index b0cc5ead24..2136fb2ac9 100644 --- a/app/modules/cdr/utils_cdr.py +++ b/app/modules/cdr/utils_cdr.py @@ -370,7 +370,7 @@ def build_data_rows( users: list[models_users.CoreUser], users_purchases: dict[str, list[models_cdr.Purchase]], users_answers: dict[str, list[models_cdr.CustomData]], - product_structure: dict, + product_structure: dict, # ty:ignore[missing-type-argument] col_idx: int, ): data_rows = [] @@ -413,7 +413,7 @@ def build_data_rows( def write_fixed_headers( worksheet: xlsxwriter.Workbook.worksheet_class, fixed_columns: list[str], - formats: dict, + formats: dict, # ty:ignore[missing-type-argument] ): for col, title in enumerate(fixed_columns): worksheet.merge_range(0, col, 2, col, title, formats["header"]["base"]) @@ -421,9 +421,9 @@ def write_fixed_headers( def write_product_headers( worksheet: xlsxwriter.Workbook.worksheet_class, - product_structure: dict, + product_structure: dict, # ty:ignore[missing-type-argument] fixed_columns: list[str], - formats: dict, + formats: dict, # ty:ignore[missing-type-argument] max_lens: list[int], ): product_end_cols = [ @@ -542,10 +542,10 @@ def write_product_headers( def write_data_rows( worksheet: xlsxwriter.Workbook.worksheet_class, - data_rows: list, + data_rows: list, # ty:ignore[missing-type-argument] product_end_cols: list[int], variant_end_cols: list[int], - formats: dict, + formats: dict, # ty:ignore[missing-type-argument] max_lens: list[int], start_row: int = 3, ): @@ -600,9 +600,9 @@ def write_to_excel( worksheet_name: str, fixed_columns: list[str], product_structure, - data_rows: list, + data_rows: list, # ty:ignore[missing-type-argument] col_idx: int, - formats: dict, + formats: dict, # ty:ignore[missing-type-argument] ): worksheet = workbook.add_worksheet(worksheet_name) max_lens = [len(c) for c in fixed_columns] + [0] * (col_idx - len(fixed_columns)) diff --git a/app/modules/raffle/endpoints_raffle.py b/app/modules/raffle/endpoints_raffle.py index 1fa4ca7a0f..d902e88256 100644 --- a/app/modules/raffle/endpoints_raffle.py +++ b/app/modules/raffle/endpoints_raffle.py @@ -3,7 +3,7 @@ from fastapi import Depends, HTTPException from fastapi.responses import FileResponse -from redis import Redis +from redis.asyncio import Redis from sqlalchemy.ext.asyncio import AsyncSession from app.core.groups import cruds_groups @@ -522,13 +522,13 @@ async def buy_ticket( redis_key = "raffle_" + user.id - if not isinstance(redis_client, Redis) or locker_get( + if not isinstance(redis_client, Redis) or await locker_get( redis_client=redis_client, key=redis_key, ): raise HTTPException(status_code=429, detail="Too fast !") - locker_set(redis_client=redis_client, key=redis_key, lock=True) + await locker_set(redis_client=redis_client, key=redis_key, lock=True) try: new_amount = balance.balance - pack_ticket.price @@ -550,7 +550,7 @@ async def buy_ticket( return tickets finally: - locker_set(redis_client=redis_client, key=redis_key, lock=False) + await locker_set(redis_client=redis_client, key=redis_key, lock=False) @module.router.get( @@ -1002,12 +1002,12 @@ async def edit_cash_by_id( redis_key = "raffle_" + user_id - if not isinstance(redis_client, Redis) or locker_get( + if not isinstance(redis_client, Redis) or await locker_get( redis_client=redis_client, key=redis_key, ): raise HTTPException(status_code=403, detail="Too fast !") - locker_set(redis_client=redis_client, key=redis_key, lock=True) + await locker_set(redis_client=redis_client, key=redis_key, lock=True) try: await cruds_raffle.edit_cash( @@ -1016,7 +1016,7 @@ async def edit_cash_by_id( db=db, ) finally: - locker_set(redis_client=redis_client, key=redis_key, lock=False) + await locker_set(redis_client=redis_client, key=redis_key, lock=False) @module.router.post( diff --git a/app/modules/recommendation/cruds_recommendation.py b/app/modules/recommendation/cruds_recommendation.py index 23bce676c0..6fce5059a9 100644 --- a/app/modules/recommendation/cruds_recommendation.py +++ b/app/modules/recommendation/cruds_recommendation.py @@ -1,6 +1,6 @@ import uuid from collections.abc import Sequence -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Any, cast from sqlalchemy import delete, select, update from sqlalchemy.ext.asyncio import AsyncSession @@ -40,7 +40,7 @@ async def update_recommendation( .where(models_recommendation.Recommendation.id == recommendation_id) .values(**recommendation.model_dump(exclude_none=True)), ) - if cast("CursorResult", result).rowcount == 1: + if cast("CursorResult[Any]", result).rowcount == 1: await db.flush() else: await db.rollback() @@ -56,7 +56,7 @@ async def delete_recommendation( models_recommendation.Recommendation.id == recommendation_id, ), ) - if cast("CursorResult", result).rowcount == 1: + if cast("CursorResult[Any]", result).rowcount == 1: await db.flush() else: await db.rollback() diff --git a/app/modules/sport_competition/utils/data_exporter/captain_exporter.py b/app/modules/sport_competition/utils/data_exporter/captain_exporter.py index e14b6a7d4c..40025f958b 100644 --- a/app/modules/sport_competition/utils/data_exporter/captain_exporter.py +++ b/app/modules/sport_competition/utils/data_exporter/captain_exporter.py @@ -28,7 +28,7 @@ def build_data_rows( captains: list[schemas_sport_competition.ParticipantComplete], sports: list[schemas_sport_competition.Sport], -) -> tuple[list[list], list[int]]: +) -> tuple[list[list], list[int]]: # ty:ignore[missing-type-argument] data_rows = [] thick_columns = [len(FIXED_COLUMNS) - 1] @@ -62,7 +62,7 @@ def build_data_rows( def write_fixed_headers( worksheet: xlsxwriter.Workbook.worksheet_class, - formats: dict, + formats: dict, # ty:ignore[missing-type-argument] ): for col, title in enumerate(FIXED_COLUMNS): worksheet.merge_range(0, col, 1, col, title, formats["header"]["base"]) @@ -70,9 +70,9 @@ def write_fixed_headers( def write_to_excel( workbook: xlsxwriter.Workbook, - data_rows: list, + data_rows: list, # ty:ignore[missing-type-argument] thick_columns: list[int], - formats: dict, + formats: dict, # ty:ignore[missing-type-argument] ): worksheet = workbook.add_worksheet("Données") columns_max_length = [len(c) for c in FIXED_COLUMNS] diff --git a/app/modules/sport_competition/utils/data_exporter/commons.py b/app/modules/sport_competition/utils/data_exporter/commons.py index 5d58f05175..f3216ca903 100644 --- a/app/modules/sport_competition/utils/data_exporter/commons.py +++ b/app/modules/sport_competition/utils/data_exporter/commons.py @@ -144,9 +144,9 @@ def make_format( def write_data_rows( worksheet: xlsxwriter.Workbook.worksheet_class, - data_rows: list, + data_rows: list, # ty:ignore[missing-type-argument] thick_columns: list[int], - formats: dict, + formats: dict, # ty:ignore[missing-type-argument] columns_max_length: list[int], start_row: int = 5, ): 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 5c25bc7bdf..469083e525 100644 --- a/app/modules/sport_competition/utils/data_exporter/global_exporter.py +++ b/app/modules/sport_competition/utils/data_exporter/global_exporter.py @@ -62,7 +62,7 @@ def build_data_rows( users_participant: dict[str, schemas_sport_competition.ParticipantComplete] | None, users_purchases: dict[str, list[schemas_sport_competition.PurchaseComplete]], users_payments: dict[str, list[schemas_sport_competition.PaymentComplete]] | None, - product_structure: tuple[list, int] | None, + product_structure: tuple[list, int] | None, # ty:ignore[missing-type-argument] col_idx: int, ) -> tuple[list[list[str | int]], list[int]]: data_rows: list[list[str | int]] = [] @@ -146,7 +146,7 @@ def build_data_rows( def write_fixed_headers( worksheet: xlsxwriter.Workbook.worksheet_class, - formats: dict, + formats: dict, # ty:ignore[missing-type-argument] ): worksheet.merge_range( 0, @@ -162,7 +162,7 @@ def write_fixed_headers( def write_participant_headers( worksheet: xlsxwriter.Workbook.worksheet_class, - formats: dict, + formats: dict, # ty:ignore[missing-type-argument] columns_max_length: list[int], ): worksheet.merge_range( @@ -180,7 +180,7 @@ def write_participant_headers( def write_payment_headers( worksheet: xlsxwriter.Workbook.worksheet_class, - formats: dict, + formats: dict, # ty:ignore[missing-type-argument] start_index: int, columns_max_length: list[int], ): @@ -199,8 +199,8 @@ def write_payment_headers( def write_product_headers( worksheet: xlsxwriter.Workbook.worksheet_class, - product_structure: tuple[list, int], - formats: dict, + product_structure: tuple[list, int], # ty:ignore[missing-type-argument] + formats: dict, # ty:ignore[missing-type-argument] start_index: int, columns_max_length: list[int], ) -> tuple[list[int], list[int]]: @@ -286,11 +286,11 @@ def write_product_headers( def write_to_excel( parameters: list[ExcelExportParams], workbook: xlsxwriter.Workbook, - product_structure: tuple[list, int] | None, - data_rows: list, + product_structure: tuple[list, int] | None, # ty:ignore[missing-type-argument] + data_rows: list, # ty:ignore[missing-type-argument] thick_columns: list[int], col_idx: int, - formats: dict, + formats: dict, # ty:ignore[missing-type-argument] ): worksheet = workbook.add_worksheet("Données") columns_max_length = [len(c) for c in FIXED_COLUMNS] + [0] * ( @@ -369,7 +369,7 @@ def construct_users_excel_with_parameters( ), ) - product_structure: tuple[list, int] | None = None + product_structure: tuple[list, int] | None = None # ty:ignore[missing-type-argument] col_idx = len(FIXED_COLUMNS) if ExcelExportParams.purchases in parameters and products is not None: products.sort( 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 2140a52048..72ad006af2 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 @@ -72,7 +72,7 @@ def build_data_rows( users_participant: dict[str, schemas_sport_competition.ParticipantComplete] | None, users_purchases: dict[str, list[schemas_sport_competition.PurchaseComplete]], users_payments: dict[str, list[schemas_sport_competition.PaymentComplete]] | None, - product_structure: tuple[list, int] | None, + product_structure: tuple[list, int] | None, # ty:ignore[missing-type-argument] col_idx: int, ) -> tuple[list[list[str | int]], list[int]]: data_rows: list[list[str | int]] = [] @@ -159,7 +159,7 @@ def build_data_rows( def write_fixed_headers( worksheet: xlsxwriter.Workbook.worksheet_class, - formats: dict, + formats: dict, # ty:ignore[missing-type-argument] ): worksheet.merge_range( 0, @@ -175,7 +175,7 @@ def write_fixed_headers( def write_participant_headers( worksheet: xlsxwriter.Workbook.worksheet_class, - formats: dict, + formats: dict, # ty:ignore[missing-type-argument] columns_max_length: list[int], ): worksheet.merge_range( @@ -193,7 +193,7 @@ def write_participant_headers( def write_payment_headers( worksheet: xlsxwriter.Workbook.worksheet_class, - formats: dict, + formats: dict, # ty:ignore[missing-type-argument] start_index: int, columns_max_length: list[int], ): @@ -212,8 +212,8 @@ def write_payment_headers( def write_product_headers( worksheet: xlsxwriter.Workbook.worksheet_class, - product_structure: tuple[list, int], - formats: dict, + product_structure: tuple[list, int], # ty:ignore[missing-type-argument] + formats: dict, # ty:ignore[missing-type-argument] start_index: int, columns_max_length: list[int], ) -> tuple[list[int], list[int]]: @@ -299,11 +299,11 @@ def write_product_headers( def write_to_excel( parameters: list[ExcelExportParams], workbook: xlsxwriter.Workbook, - product_structure: tuple[list, int] | None, - data_rows: list, + product_structure: tuple[list, int] | None, # ty:ignore[missing-type-argument] + data_rows: list, # ty:ignore[missing-type-argument] thick_columns: list[int], col_idx: int, - formats: dict, + formats: dict, # ty:ignore[missing-type-argument] ): worksheet = workbook.add_worksheet("Données") columns_max_length = [len(c) for c in FIXED_COLUMNS] + [0] * ( @@ -377,7 +377,7 @@ def construct_school_users_excel_with_parameters( u.user.firstname.lower(), ), ) - product_structure: tuple[list, int] | None = None + product_structure: tuple[list, int] | None = None # ty:ignore[missing-type-argument] col_idx = len(FIXED_COLUMNS) if ExcelExportParams.purchases in parameters and products is not None: products.sort( diff --git a/app/modules/sport_competition/utils/data_exporter/school_quotas_exporter.py b/app/modules/sport_competition/utils/data_exporter/school_quotas_exporter.py index c6fff82bc2..3c2aa5cb0d 100644 --- a/app/modules/sport_competition/utils/data_exporter/school_quotas_exporter.py +++ b/app/modules/sport_competition/utils/data_exporter/school_quotas_exporter.py @@ -31,8 +31,8 @@ def build_data_rows( school_product_quotas: list[schemas_sport_competition.SchoolProductQuota], sports: list[schemas_sport_competition.Sport], products: list[schemas_sport_competition.ProductComplete], -) -> tuple[list[list], list[list[int]]]: - data_rows: list[list] = [[], [], []] +) -> tuple[list[list], list[list[int]]]: # ty:ignore[missing-type-argument] + data_rows: list[list] = [[], [], []] # ty:ignore[missing-type-argument] thick_columns: list[list[int]] = [[], [], []] # First column index sport_dict = {sport.id: sport for sport in sports} @@ -102,7 +102,7 @@ def build_data_rows( def write_sports_headers( worksheet: xlsxwriter.Workbook.worksheet_class, - formats: dict, + formats: dict, # ty:ignore[missing-type-argument] ): for col_idx, column in enumerate(SPORT_COLUMNS): worksheet.write(0, col_idx, column, formats["header"]["base"]) @@ -110,7 +110,7 @@ def write_sports_headers( def write_generals_headers( worksheet: xlsxwriter.Workbook.worksheet_class, - formats: dict, + formats: dict, # ty:ignore[missing-type-argument] ): for col_idx, column in enumerate(GENERAL_COLUMNS): worksheet.write(0, col_idx, column, formats["header"]["base"]) @@ -118,7 +118,7 @@ def write_generals_headers( def write_products_headers( worksheet: xlsxwriter.Workbook.worksheet_class, - formats: dict, + formats: dict, # ty:ignore[missing-type-argument] ): for col_idx, column in enumerate(PRODUCT_COLUMNS): worksheet.write(0, col_idx, column, formats["header"]["base"]) @@ -126,9 +126,9 @@ def write_products_headers( def write_general_quota_sheet( worksheet: xlsxwriter.Workbook.worksheet_class, - data_rows: list, + data_rows: list, # ty:ignore[missing-type-argument] thick_columns: list[int], - formats: dict, + formats: dict, # ty:ignore[missing-type-argument] ): write_generals_headers(worksheet, formats) columns_max_length = [len(c) for c in SPORT_COLUMNS] @@ -146,9 +146,9 @@ def write_general_quota_sheet( def write_sport_quota_sheet( worksheet: xlsxwriter.Workbook.worksheet_class, - data_rows: list, + data_rows: list, # ty:ignore[missing-type-argument] thick_columns: list[int], - formats: dict, + formats: dict, # ty:ignore[missing-type-argument] ): write_sports_headers(worksheet, formats) columns_max_length = [len(c) for c in SPORT_COLUMNS] @@ -166,9 +166,9 @@ def write_sport_quota_sheet( def write_product_quota_sheet( worksheet: xlsxwriter.Workbook.worksheet_class, - data_rows: list, + data_rows: list, # ty:ignore[missing-type-argument] thick_columns: list[int], - formats: dict, + formats: dict, # ty:ignore[missing-type-argument] ): write_products_headers(worksheet, formats) columns_max_length = [len(c) for c in SPORT_COLUMNS] @@ -186,9 +186,9 @@ def write_product_quota_sheet( def write_to_excel( workbook: xlsxwriter.Workbook, - data_rows: list[list], + data_rows: list[list], # ty:ignore[missing-type-argument] thick_columns: list[list[int]], - formats: dict, + formats: dict, # ty:ignore[missing-type-argument] ): if len(data_rows[0]) > 0: general_worksheet = workbook.add_worksheet("Quotas généraux") diff --git a/app/modules/sport_competition/utils/data_exporter/sport_participants_exporter.py b/app/modules/sport_competition/utils/data_exporter/sport_participants_exporter.py index 3148bcc746..e1db3ec39e 100644 --- a/app/modules/sport_competition/utils/data_exporter/sport_participants_exporter.py +++ b/app/modules/sport_competition/utils/data_exporter/sport_participants_exporter.py @@ -71,7 +71,7 @@ def build_data_rows( def write_fixed_headers( worksheet: xlsxwriter.Workbook.worksheet_class, - formats: dict, + formats: dict, # ty:ignore[missing-type-argument] ): for col, title in enumerate(FIXED_COLUMNS): worksheet.write(1, col, title, formats["header"]["base"]) @@ -79,9 +79,9 @@ def write_fixed_headers( def write_to_excel( workbook: xlsxwriter.Workbook, - data_rows: list, + data_rows: list, # ty:ignore[missing-type-argument] thick_columns: list[int], - formats: dict, + formats: dict, # ty:ignore[missing-type-argument] ): worksheet = workbook.add_worksheet("Données") columns_max_length = [len(c) for c in FIXED_COLUMNS] diff --git a/app/modules/sport_competition/utils/data_exporter/sport_quotas_exporter.py b/app/modules/sport_competition/utils/data_exporter/sport_quotas_exporter.py index a708e7d5f8..5c0013385b 100644 --- a/app/modules/sport_competition/utils/data_exporter/sport_quotas_exporter.py +++ b/app/modules/sport_competition/utils/data_exporter/sport_quotas_exporter.py @@ -23,7 +23,7 @@ def build_data_rows( school_sports_quotas: list[schemas_sport_competition.SchoolSportQuota], schools: list[schemas_sport_competition.SchoolExtension], -) -> tuple[list, list[int]]: +) -> tuple[list, list[int]]: # ty:ignore[missing-type-argument] data_rows = [] school_dict = {school.school_id: school.school for school in schools} @@ -50,7 +50,7 @@ def build_data_rows( def write_fixed_headers( worksheet: xlsxwriter.Workbook.worksheet_class, - formats: dict, + formats: dict, # ty:ignore[missing-type-argument] ): for col_idx, column in enumerate(FIXED_COLUMNS): worksheet.write(0, col_idx, column, formats["header"]["base"]) @@ -58,9 +58,9 @@ def write_fixed_headers( def write_to_excel( workbook: xlsxwriter.Workbook, - data_rows: list, + data_rows: list, # ty:ignore[missing-type-argument] thick_columns: list[int], - formats: dict, + formats: dict, # ty:ignore[missing-type-argument] ): worksheet = workbook.add_worksheet("Quotas") write_fixed_headers(worksheet, formats) diff --git a/app/types/scheduler.py b/app/types/scheduler.py index b5ae28635e..e32662fba6 100644 --- a/app/types/scheduler.py +++ b/app/types/scheduler.py @@ -143,7 +143,7 @@ def __init__(self): # ArqWorker, in charge of scheduling and executing tasks self.worker: Worker | None = None # Task will contain the asyncio task that runs the worker - self.task: asyncio.Task | None = None + self.task: asyncio.Task[None] | None = None async def start( self, @@ -254,7 +254,7 @@ def __init__(self): # ArqWorker, in charge of scheduling and executing tasks self.worker: Worker | None = None # Task will contain the asyncio task that runs the worker - self.task: asyncio.Task | None = None + self.task: asyncio.Task[None] | None = None # Pointer to the get_db dependency async def start( diff --git a/app/types/sqlalchemy.py b/app/types/sqlalchemy.py index 003e15c746..029a02b0e7 100644 --- a/app/types/sqlalchemy.py +++ b/app/types/sqlalchemy.py @@ -15,7 +15,7 @@ SessionLocalType = Callable[[], AsyncSession] -class TZDateTime(TypeDecorator): +class TZDateTime(TypeDecorator[datetime.datetime]): """ Custom SQLAlchemy type for storing timezone-aware timestamps as timezone-naive UTC timestamps. We use this custom type because sqlite doesn't support datetime with timezone diff --git a/app/types/websocket.py b/app/types/websocket.py index 81ec37a23f..0d68c99662 100644 --- a/app/types/websocket.py +++ b/app/types/websocket.py @@ -76,7 +76,7 @@ def __init__(self, settings: Settings): # We keep a reference to the listening tasks for each room # to be able to stop listening to a room when there is no more connection - self.listening_tasks: dict[HyperionWebsocketsRoom, asyncio.Task] = {} + self.listening_tasks: dict[HyperionWebsocketsRoom, asyncio.Task[None]] = {} async def connect_broadcaster(self): await self.broadcaster.connect() diff --git a/app/utils/initialization.py b/app/utils/initialization.py index 9db227985b..92e7c2c8cb 100644 --- a/app/utils/initialization.py +++ b/app/utils/initialization.py @@ -6,7 +6,7 @@ from typing import ParamSpec, TypeVar import psutil -import redis +import redis.asyncio as redis from pydantic import ValidationError from sqlalchemy import Connection, MetaData, delete, select from sqlalchemy.engine import Engine, create_engine @@ -340,7 +340,7 @@ async def use_lock_for_workers[**P, R]( ): await execute_async_or_sync_method(job_function, *args, **kwargs) - elif redis_client.set(key, "1", nx=True, ex=120): + elif await redis_client.set(key, "1", nx=True, ex=120): # We acquired the lock, we execute the function logger.info(f"Running {getattr(job_function, '__name__', repr(job_function))}") @@ -348,19 +348,19 @@ async def use_lock_for_workers[**P, R]( if unlock_key is not None: # We set the unlock_key for other workers to resume operation - redis_client.set(unlock_key, "1") + await redis_client.set(unlock_key, "1") # After 60 seconds we remove the key for both performance and reloading issues # we assume other jobs won't take more than 60 seconds and will check this key before expiration - redis_client.expire(unlock_key, 60) + await redis_client.expire(unlock_key, 60) # After 60 seconds we remove the key for both performance and reloading issues # we assume other jobs won't take more than 60 seconds and will check this key before expiration - redis_client.expire(key, 60) + await redis_client.expire(key, 60) elif unlock_key: # 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: + while await redis_client.get(unlock_key) is None: logger.debug( f"Waiting for {getattr(job_function, '__name__', repr(job_function))} to finish", ) diff --git a/app/utils/loggers_tools/matrix_handler.py b/app/utils/loggers_tools/matrix_handler.py index d95a2b6ae1..45084f42db 100644 --- a/app/utils/loggers_tools/matrix_handler.py +++ b/app/utils/loggers_tools/matrix_handler.py @@ -1,13 +1,13 @@ import logging -from logging import StreamHandler -from typing import override +from logging import LogRecord, StreamHandler +from typing import TextIO, override from app.utils.communication.matrix import Matrix hyperion_error_logger = logging.getLogger("hyperion.error") -class MatrixHandler(StreamHandler): +class MatrixHandler(StreamHandler[TextIO]): """ A logging handler which sends log records to a Matrix server. @@ -38,7 +38,7 @@ def __init__( ) @override - def emit(self, record): + def emit(self, record: LogRecord) -> None: if self.enabled: msg = self.format(record) try: diff --git a/app/utils/loggers_tools/s3_handler.py b/app/utils/loggers_tools/s3_handler.py index a64b0f0825..3a706fa766 100644 --- a/app/utils/loggers_tools/s3_handler.py +++ b/app/utils/loggers_tools/s3_handler.py @@ -1,7 +1,7 @@ import string from datetime import UTC, datetime -from logging import StreamHandler -from typing import override +from logging import LogRecord, StreamHandler +from typing import TextIO, override from app.types.s3_access import S3Access from app.utils.tools import get_random_string @@ -9,7 +9,7 @@ alphanum = string.ascii_lowercase + string.digits -class S3LogHandler(StreamHandler): +class S3LogHandler(StreamHandler[TextIO]): def __init__( self, failure_logger: str, @@ -28,7 +28,7 @@ def __init__( ) @override - def emit(self, record): + def emit(self, record: LogRecord) -> None: filename: str | None = getattr(record, "s3_filename", None) subfolder: str | None = getattr(record, "s3_subfolder", None) retention: int = getattr(record, "s3_retention", 0) diff --git a/app/utils/redis.py b/app/utils/redis.py index 6765c034e5..ee273e5c06 100644 --- a/app/utils/redis.py +++ b/app/utils/redis.py @@ -1,12 +1,12 @@ -import redis +import redis.asyncio as redis -def limiter(redis_client: redis.Redis, key: str, limit: int, window: int): +async def limiter(redis_client: redis.Redis, key: str, limit: int, window: int): """Simple fixed window rate limiter, returns a couple of booleans: the first is True if the request can be processed, False otherwise; the second indicates if an alert should be issued. key should be an ip address or a user id""" # Fixed window: see https://konghq.com/blog/how-to-design-a-scalable-rate-limiting-algorithm. - nb = redis_client.incr(key) + nb = await redis_client.incr(key) if nb == 1: - redis_client.expire(key, window) + await redis_client.expire(key, window) elif nb == limit: return ( False, @@ -17,12 +17,12 @@ def limiter(redis_client: redis.Redis, key: str, limit: int, window: int): return True, False -def locker_get(redis_client: redis.Redis, key: str): - value = redis_client.get(key) +async def locker_get(redis_client: redis.Redis, key: str): + value = await redis_client.get(key) if value is None: return False return bool(int(value)) -def locker_set(redis_client: redis.Redis, key: str, lock: bool): - redis_client.set(key, int(lock)) +async def locker_set(redis_client: redis.Redis, key: str, lock: bool): + await redis_client.set(key, int(lock)) diff --git a/app/utils/state.py b/app/utils/state.py index 0d5d755749..792cdcbc89 100644 --- a/app/utils/state.py +++ b/app/utils/state.py @@ -3,7 +3,8 @@ from typing import Any, TypedDict import calypsso -import redis +import redis.asyncio as redis +from redis.exceptions import ConnectionError as RedisConnectionError from sqlalchemy.ext.asyncio import ( AsyncEngine, AsyncSession, @@ -76,7 +77,7 @@ def init_SessionLocal(engine: AsyncEngine) -> SessionLocalType: ) -def init_redis_client( +async def init_redis_client( settings: Settings, hyperion_error_logger: logging.Logger, ) -> redis.Redis | None: @@ -93,18 +94,19 @@ def init_redis_client( password=settings.REDIS_PASSWORD, socket_keepalive=True, ) - redis_client.ping() # Test the connection - except redis.exceptions.ConnectionError: + await redis_client.ping() # Test the connection + except RedisConnectionError: hyperion_error_logger.exception( "Redis connection error: Check the Redis configuration or the Redis server", ) + return redis_client -def disconnect_redis_client(redis_client: redis.Redis | None) -> None: +async def disconnect_redis_client(redis_client: redis.Redis | None) -> None: if redis_client is not None: - redis_client.flushdb() - redis_client.close() + await redis_client.flushdb() + await redis_client.close() async def init_scheduler( diff --git a/migrations/README.md b/migrations/README.md index 384779380c..934de205bd 100644 --- a/migrations/README.md +++ b/migrations/README.md @@ -75,7 +75,7 @@ from sqlalchemy.dialects import postgresql You need to use: ```python -postgresql.ENUM(name="availableassociationmembership", create_type=False), +postgresql.ENUM(name="availableassociationmembership", create_type=False) ``` ### Convert str id to uuid diff --git a/migrations/env.py b/migrations/env.py index 1c95504451..df4d60be76 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -124,7 +124,7 @@ def run_migrations_online() -> None: We requires a *connection* (`Connection` or `AsyncConnection`) object. You may obtain one from an *connectable* calling the [`connect` method](https://docs.sqlalchemy.org/en/20/core/connections.html#sqlalchemy.engine.Engine.connect). """ - connection: None | Connection | AsyncConnection = config.attributes.get( + connection: Connection | AsyncConnection | None = config.attributes.get( "connection", None, ) diff --git a/migrations/versions/21-fix_phonebook.py b/migrations/versions/21-fix_phonebook.py index afc365be6d..390fb6d96c 100644 --- a/migrations/versions/21-fix_phonebook.py +++ b/migrations/versions/21-fix_phonebook.py @@ -40,7 +40,7 @@ class RoleTags(Enum): resp_part = "Respo Partenariats" -def define_order_of_memberships(memberships: list[sa.Row[Any]]) -> list[list]: +def define_order_of_memberships(memberships: list[sa.Row[Any]]) -> list[list[Any]]: """ Use tag systeme to introduce a coherent member_order to older membership """ diff --git a/pyproject.toml b/pyproject.toml index 915d16c5a4..30bbeb3de9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,6 +95,7 @@ ignore = [ "TC002", # "Move into type-checking block" "TC003", # "Move into type-checking block" "UP037", # Remove quotes from type annotations + "PLR0917", # "Too many positional arguments" ] # Allow fix for all enabled rules (when `--fix`) is provided. diff --git a/requirements-dev.txt b/requirements-dev.txt index 17435ee4ed..20ad638198 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,17 +1,16 @@ -r requirements.txt aiosqlite==0.22.1 -boto3-stubs[essential]==1.43.24 +boto3-stubs[essential]==1.43.56 google-auth-stubs==0.3.0 mypy[faster-cache]==2.1.0 pytest-alembic==0.12.1 pytest-asyncio==1.4.0 pytest-cov==7.1.0 pytest-mock==3.15.1 -pytest==9.0.3 -ruff==0.15.16 -ty==0.0.44 -types-Authlib==1.6.11.20260518 -types-fpdf2==2.8.4.20260518 +pytest==9.1.1 +ruff==0.16.0 +ty==0.0.63 +types-Authlib==1.7.2.20260724 +types-fpdf2==2.8.4.20260724 types-psutil==7.2.2.20260518 -types-redis==4.6.0.20241004 -types-requests==2.33.0.20260518 \ No newline at end of file +types-requests==2.33.0.20260712 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index f487459191..4e2db19980 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,38 +1,38 @@ -alembic==1.18.4 # database migrations -anyio==4.13.0 -arq==0.28.0 # Scheduler +alembic==1.18.5 # database migrations +anyio==4.14.2 +arq==0.28.0 # Scheduler, [not maintained, see https://github.com/python-arq/arq/issues/510] asyncpg==0.31.0 # PostgreSQL adapter for asynchronous operations authlib==1.7.2 bcrypt==5.0.0 # password hashing -boto3==1.43.24 # S3 storage +boto3==1.43.56 # S3 storage broadcaster==0.3.1 # Working with websockets with multiple workers. calypsso-proximapp==2.8.0 email-validator==2.3.0 -Faker==40.21.0 -fastapi[standard]==0.136.3 -firebase-admin==7.4.0 # Firebase is used for push notification -google-api-python-client==2.197.0 +Faker==40.36.0 +fastapi[standard]==0.140.0 +firebase-admin==7.5.0 # Firebase is used for push notification +google-api-python-client==2.198.0 google-auth-oauthlib==1.4.0 helloasso-python==1.0.8 -httpx2==2.3.0 -icalendar==7.1.2 +httpx2==2.9.1 +icalendar==7.2.2 jellyfish==1.2.1 # String Matching Jinja2==3.1.6 # template engine for html files -phonenumbers==9.0.32 # Used for phone number validation +phonenumbers==9.0.35 # Used for phone number validation psutil==7.2.2 # psutil is used to determine the number of Hyperion workers psycopg[binary]==3.3.4 # PostgreSQL adapter for *synchronous* operations at startup (database initializations & migrations) pydantic-extra-types==2.11.1 -pydantic-settings==2.14.1 +pydantic-settings==2.14.2 pydantic==2.13.4 -pyjwt[crypto]==2.13.0 # generate and verify the JWT tokens, imported as `jwt` -PyMuPDF==1.27.2.3 # PDF processing, imported as `fitz` -pypdf==6.13.0 -python-multipart==0.0.32 # a form data parser, as oauth flow requires form-data parameters -redis==5.3.1 +pyjwt[crypto]==2.13.0 # generate and verify the JWT tokens, imported as `jwt` +PyMuPDF==1.28.0 # PDF processing, imported as `fitz` +pypdf==6.14.2 +python-multipart==0.0.32 # a form data parser, as oauth flow requires form-data parameters +redis==5.3.1 # arq requires redis<6 requests==2.34.2 sqlalchemy-utils == 0.42.1 # used to identify foreign keys when merging users -SQLAlchemy[asyncio]==2.0.50 # [asyncio] allows greenlet to be installed on Apple M1 devices. +SQLAlchemy[asyncio]==2.0.51 # [asyncio] allows greenlet to be installed on Apple M1 devices. unidecode==1.4.0 -uvicorn[standard]==0.49.0 +uvicorn[standard]==0.51.0 weasyprint==69.0 # HTML to PDF converter xlsxwriter==3.2.9 diff --git a/tests/commons.py b/tests/commons.py index 5bf017e64b..7622d1087e 100644 --- a/tests/commons.py +++ b/tests/commons.py @@ -57,7 +57,7 @@ async def override_init_state( SessionLocal = init_test_SessionLocal(engine=engine) - redis_client = init_redis_client( + redis_client = await init_redis_client( settings=settings, hyperion_error_logger=hyperion_error_logger, ) diff --git a/tests/core/test_checkout.py b/tests/core/test_checkout.py index 201460c0ff..5b0922a819 100644 --- a/tests/core/test_checkout.py +++ b/tests/core/test_checkout.py @@ -440,7 +440,7 @@ async def test_payment_tool_init_checkout( mock_checkout_api = mocker.MagicMock() mock_checkout_api.organizations_organization_slug_checkout_intents_post.return_value = HelloAssoApiV5ModelsCartsInitCheckoutResponse( id=7, - redirect_url=redirect_url, # ty:ignore[unknown-argument] + redirect_url=redirect_url, ) mocker.patch( "app.core.checkout.payment_tool.CheckoutApi", @@ -502,7 +502,7 @@ def init_a_checkout_side_effect( raise UnauthorizedException return HelloAssoApiV5ModelsCartsInitCheckoutResponse( id=7, - redirect_url=redirect_url, # ty:ignore[unknown-argument] + redirect_url=redirect_url, ) mocker.patch.object(