diff --git a/app/core/associations/endpoints_associations.py b/app/core/associations/endpoints_associations.py index 9667a59133..5eee7b2f34 100644 --- a/app/core/associations/endpoints_associations.py +++ b/app/core/associations/endpoints_associations.py @@ -1,6 +1,6 @@ import uuid -from fastapi import APIRouter, Depends, HTTPException, UploadFile +from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession @@ -19,6 +19,7 @@ ) from app.types.content_type import ContentType from app.types.module import CoreModule +from app.types.upload import FILE_RESPONSE, UploadFile from app.utils.tools import ( compress_and_save_image_file, get_file_from_data, @@ -195,6 +196,7 @@ async def create_association_logo( @router.get( "/associations/{association_id}/logo", response_class=FileResponse, + responses=FILE_RESPONSE, status_code=200, ) async def read_association_logo( diff --git a/app/core/core_endpoints/endpoints_core.py b/app/core/core_endpoints/endpoints_core.py index 18f69b0d6d..c1ebf7299f 100644 --- a/app/core/core_endpoints/endpoints_core.py +++ b/app/core/core_endpoints/endpoints_core.py @@ -8,6 +8,7 @@ get_settings, ) from app.types.module import CoreModule +from app.types.upload import FILE_RESPONSE from app.utils.tools import patch_identity_in_text router = APIRouter(tags=["Core"]) @@ -197,6 +198,7 @@ async def get_variables(settings: Settings = Depends(get_settings)): @router.get( "/favicon.ico", response_class=FileResponse, + responses=FILE_RESPONSE, status_code=200, ) async def get_favicon(): diff --git a/app/core/feed/endpoints_feed.py b/app/core/feed/endpoints_feed.py index b425b16b07..b005196f26 100644 --- a/app/core/feed/endpoints_feed.py +++ b/app/core/feed/endpoints_feed.py @@ -14,6 +14,7 @@ is_user_allowed_to, ) from app.types.module import CoreModule +from app.types.upload import FILE_RESPONSE from app.utils.tools import get_file_from_data router = APIRouter(tags=["Feed"]) @@ -51,6 +52,7 @@ async def get_published_news( @router.get( "/feed/news/{news_id}/image", response_class=FileResponse, + responses=FILE_RESPONSE, status_code=200, ) async def get_news_image( diff --git a/app/core/groups/endpoints_groups.py b/app/core/groups/endpoints_groups.py index a8fc6079a9..9049bb0c69 100644 --- a/app/core/groups/endpoints_groups.py +++ b/app/core/groups/endpoints_groups.py @@ -7,7 +7,7 @@ import logging import uuid -from fastapi import APIRouter, Depends, HTTPException, UploadFile +from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession @@ -25,6 +25,7 @@ ) from app.types.content_type import ContentType from app.types.module import CoreModule +from app.types.upload import FILE_RESPONSE, UploadFile from app.utils.communication.notifications import NotificationManager from app.utils.tools import ( compress_and_save_image_file, @@ -393,6 +394,7 @@ async def create_group_logo( @router.get( "/groups/{group_id}/logo", response_class=FileResponse, + responses=FILE_RESPONSE, status_code=200, ) async def read_user_profile_picture( diff --git a/app/core/mypayment/endpoints_mypayment.py b/app/core/mypayment/endpoints_mypayment.py index b9b022bc32..0fe6758f66 100644 --- a/app/core/mypayment/endpoints_mypayment.py +++ b/app/core/mypayment/endpoints_mypayment.py @@ -100,6 +100,7 @@ from app.types.exceptions import ObjectExpectedInDbNotFoundError from app.types.module import CoreModule from app.types.scopes_type import ScopeType +from app.types.upload import FILE_RESPONSE from app.utils.auth.auth_utils import get_user_id_from_token_with_scopes from app.utils.communication.notifications import NotificationTool from app.utils.mail.mailworker import send_email @@ -3083,6 +3084,7 @@ async def get_structure_invoices( @router.get( "/mypayment/invoices/{invoice_id}", response_class=FileResponse, + responses=FILE_RESPONSE, ) async def download_invoice( invoice_id: UUID, diff --git a/app/core/mypayment/schemas_mypayment.py b/app/core/mypayment/schemas_mypayment.py index 9f1881cc34..ee76cace7f 100644 --- a/app/core/mypayment/schemas_mypayment.py +++ b/app/core/mypayment/schemas_mypayment.py @@ -1,9 +1,11 @@ from datetime import datetime +from typing import Annotated from uuid import UUID from pydantic import ( BaseModel, Field, + WithJsonSchema, model_validator, ) @@ -209,7 +211,15 @@ class WalletDevice(WalletDeviceBase): class WalletDeviceCreation(WalletDeviceBase): - ed25519_public_key: bytes + # A `bytes` field serializes to the JSON Schema 2020-12 form + # `{"type": "string", "contentMediaType": "application/octet-stream"}`, which + # breaks OpenAPI/Swagger client generators. The value is a base64-encoded + # public key (decoded in the endpoint), so we describe it as an OAS base64 + # string instead. + ed25519_public_key: Annotated[ + bytes, + WithJsonSchema({"type": "string", "format": "byte"}), + ] class TransactionBase(BaseModel): diff --git a/app/core/tickets/endpoints_tickets.py b/app/core/tickets/endpoints_tickets.py index 2e89b0b9cc..1fb9dad526 100644 --- a/app/core/tickets/endpoints_tickets.py +++ b/app/core/tickets/endpoints_tickets.py @@ -39,6 +39,7 @@ ) from app.types.exceptions import ObjectExpectedInDbNotFoundError from app.types.module import CoreModule +from app.types.upload import FILE_RESPONSE from app.utils.communication.notifications import NotificationTool from app.utils.mail.mailworker import send_email @@ -1121,6 +1122,7 @@ async def get_event_tickets( @router.get( "/tickets/admin/events/{event_id}/tickets/csv", response_class=FileResponse, + responses=FILE_RESPONSE, status_code=200, ) async def get_event_tickets_csv( diff --git a/app/core/users/endpoints_users.py b/app/core/users/endpoints_users.py index 31a48e8004..1582686928 100644 --- a/app/core/users/endpoints_users.py +++ b/app/core/users/endpoints_users.py @@ -10,10 +10,8 @@ BackgroundTasks, Body, Depends, - File, HTTPException, Query, - UploadFile, ) from fastapi.responses import FileResponse, RedirectResponse from sqlalchemy.ext.asyncio import AsyncSession @@ -45,6 +43,7 @@ from app.types.exceptions import UserWithEmailAlreadyExistError from app.types.module import CoreModule from app.types.s3_access import S3Access +from app.types.upload import FILE_RESPONSE, UploadFile from app.utils.communication.notifications import NotificationManager from app.utils.mail.mailworker import send_email from app.utils.tools import ( @@ -1105,7 +1104,7 @@ async def update_user_as_super_admin( status_code=201, ) async def create_current_user_profile_picture( - image: UploadFile = File(...), + image: UploadFile, user: models_users.CoreUser = Depends(is_user()), ): """ @@ -1135,6 +1134,7 @@ async def create_current_user_profile_picture( @router.get( "/users/me/profile-picture", response_class=FileResponse, + responses=FILE_RESPONSE, status_code=200, ) async def read_own_profile_picture( @@ -1154,6 +1154,7 @@ async def read_own_profile_picture( @router.get( "/users/{user_id}/profile-picture", response_class=FileResponse, + responses=FILE_RESPONSE, status_code=200, ) async def read_user_profile_picture( diff --git a/app/core/users/factory_users.py b/app/core/users/factory_users.py index db97dd28b4..692fe1f8d2 100644 --- a/app/core/users/factory_users.py +++ b/app/core/users/factory_users.py @@ -126,6 +126,16 @@ async def create_core_users(cls, db: AsyncSession): ) await cruds_users.create_user(db=db, user=user) for group in user_info.groups: + # A demo user may reference a group that does not exist in the + # database (e.g. a group only created by a later migration). + # We skip those memberships instead of failing the whole factory. + if await cruds_groups.get_group_by_id(db=db, group_id=group) is None: + hyperion_error_logger.warning( + "Skipping membership for demo user %s: group %s does not exist", + user.email, + group, + ) + continue await cruds_groups.create_membership( db=db, membership=CoreMembership( diff --git a/app/modules/advert/endpoints_advert.py b/app/modules/advert/endpoints_advert.py index 12f330c294..ff0126f1ab 100644 --- a/app/modules/advert/endpoints_advert.py +++ b/app/modules/advert/endpoints_advert.py @@ -2,7 +2,7 @@ import uuid from datetime import UTC, datetime -from fastapi import Depends, File, HTTPException, Query, UploadFile +from fastapi import Depends, HTTPException, Query from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession @@ -27,6 +27,7 @@ from app.modules.advert.factory_advert import AdvertFactory from app.types.content_type import ContentType from app.types.module import Module +from app.types.upload import FILE_RESPONSE, UploadFile from app.utils.communication.notifications import NotificationManager, NotificationTool from app.utils.tools import ( compress_and_save_image_file, @@ -284,6 +285,7 @@ async def delete_advert( @module.router.get( "/advert/adverts/{advert_id}/picture", response_class=FileResponse, + responses=FILE_RESPONSE, status_code=200, ) async def read_advert_image( @@ -318,7 +320,7 @@ async def read_advert_image( ) async def create_advert_image( advert_id: uuid.UUID, - image: UploadFile = File(...), + image: UploadFile, user: models_users.CoreUser = Depends( is_user_allowed_to([AdvertPermissions.access_adverts]), ), diff --git a/app/modules/calendar/endpoints_calendar.py b/app/modules/calendar/endpoints_calendar.py index ce911f4916..2250073219 100644 --- a/app/modules/calendar/endpoints_calendar.py +++ b/app/modules/calendar/endpoints_calendar.py @@ -1,7 +1,7 @@ import uuid from datetime import UTC, datetime -from fastapi import Depends, File, HTTPException, UploadFile +from fastapi import Depends, HTTPException from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession @@ -37,6 +37,7 @@ from app.types.content_type import ContentType from app.types.exceptions import NewlyAddedObjectInDbNotFoundError from app.types.module import Module +from app.types.upload import FILE_RESPONSE, UploadFile from app.utils.communication.notifications import NotificationManager, NotificationTool from app.utils.tools import ( compress_and_save_image_file, @@ -189,6 +190,7 @@ async def get_event_ticket_url( @module.router.get( "/calendar/events/{event_id}/image", response_class=FileResponse, + responses=FILE_RESPONSE, status_code=200, ) async def get_event_image( @@ -237,7 +239,7 @@ async def get_event_image( ) async def create_event_image( event_id: uuid.UUID, - image: UploadFile = File(...), + image: UploadFile, user: models_users.CoreUser = Depends( is_user_allowed_to([CalendarPermissions.access_calendar]), ), @@ -732,6 +734,7 @@ async def recreate_ical_file( @module.router.get( "/calendar/ical", response_class=FileResponse, + responses=FILE_RESPONSE, status_code=200, ) async def get_icalendar_file( diff --git a/app/modules/campaign/endpoints_campaign.py b/app/modules/campaign/endpoints_campaign.py index 10871f24bd..7e55b7350e 100644 --- a/app/modules/campaign/endpoints_campaign.py +++ b/app/modules/campaign/endpoints_campaign.py @@ -4,7 +4,7 @@ from datetime import UTC, datetime from anyio import Path -from fastapi import Depends, File, HTTPException, UploadFile +from fastapi import Depends, HTTPException from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession @@ -27,6 +27,7 @@ from app.types import standard_responses from app.types.content_type import ContentType from app.types.module import Module +from app.types.upload import FILE_RESPONSE, UploadFile from app.utils.tools import ( compress_and_save_image_file, get_file_from_data, @@ -824,7 +825,7 @@ async def get_stats_for_section( ) async def create_campaigns_logo( list_id: str, - image: UploadFile = File(...), + image: UploadFile, user: models_users.CoreUser = Depends( is_user_allowed_to([CampaignPermissions.manage_campaign]), ), @@ -872,6 +873,7 @@ async def create_campaigns_logo( @module.router.get( "/campaign/lists/{list_id}/logo", response_class=FileResponse, + responses=FILE_RESPONSE, status_code=200, ) async def read_campaigns_logo( diff --git a/app/modules/cdr/endpoints_cdr.py b/app/modules/cdr/endpoints_cdr.py index 25339d02d6..ffd7050781 100644 --- a/app/modules/cdr/endpoints_cdr.py +++ b/app/modules/cdr/endpoints_cdr.py @@ -48,6 +48,7 @@ ) from app.types.exceptions import ObjectExpectedInDbNotFoundError from app.types.module import Module +from app.types.upload import FILE_RESPONSE from app.types.websocket import ( HyperionWebsocketsRoom, WebsocketConnectionManager, @@ -539,6 +540,7 @@ async def generate_and_send_results( "/cdr/sellers/{seller_id}/results/", status_code=200, response_class=FileResponse, + responses=FILE_RESPONSE, ) async def send_seller_results( seller_id: UUID, diff --git a/app/modules/cinema/endpoints_cinema.py b/app/modules/cinema/endpoints_cinema.py index af4c2d5677..0e7d462091 100644 --- a/app/modules/cinema/endpoints_cinema.py +++ b/app/modules/cinema/endpoints_cinema.py @@ -3,7 +3,7 @@ from datetime import UTC, datetime, timedelta import httpx -from fastapi import Depends, File, HTTPException, UploadFile +from fastapi import Depends, HTTPException from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession @@ -25,6 +25,7 @@ from app.types.content_type import ContentType from app.types.module import Module from app.types.scheduler import Scheduler +from app.types.upload import FILE_RESPONSE, UploadFile from app.utils.communication.date_manager import ( get_date_day, get_date_month, @@ -224,7 +225,7 @@ async def delete_session( ) async def create_campaigns_logo( session_id: str, - image: UploadFile = File(...), + image: UploadFile, user: models_users.CoreUser = Depends( is_user_allowed_to([CinemaPermissions.manage_sessions]), ), @@ -259,6 +260,7 @@ async def create_campaigns_logo( @module.router.get( "/cinema/sessions/{session_id}/poster", response_class=FileResponse, + responses=FILE_RESPONSE, status_code=200, ) async def read_session_poster( diff --git a/app/modules/ph/endpoints_ph.py b/app/modules/ph/endpoints_ph.py index c737873e3c..3f24d36aa5 100644 --- a/app/modules/ph/endpoints_ph.py +++ b/app/modules/ph/endpoints_ph.py @@ -1,7 +1,7 @@ import uuid from datetime import UTC, datetime, time, timedelta -from fastapi import Depends, File, HTTPException, UploadFile +from fastapi import Depends, HTTPException from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession @@ -20,6 +20,7 @@ from app.types.content_type import ContentType from app.types.module import Module from app.types.scheduler import Scheduler +from app.types.upload import FILE_RESPONSE, UploadFile from app.utils.communication.notifications import NotificationTool from app.utils.tools import ( delete_file_from_data, @@ -57,6 +58,7 @@ class PHPermissions(ModulePermissions): @module.router.get( "/ph/{paper_id}/pdf", response_class=FileResponse, + responses=FILE_RESPONSE, status_code=200, ) async def get_paper_pdf( @@ -179,7 +181,7 @@ async def create_paper( ) async def create_paper_pdf_and_cover( paper_id: uuid.UUID, - pdf: UploadFile = File(...), + pdf: UploadFile, user: models_users.CoreUser = Depends( is_user_allowed_to([PHPermissions.manage_ph]), ), diff --git a/app/modules/phonebook/endpoints_phonebook.py b/app/modules/phonebook/endpoints_phonebook.py index bfe04d1118..84fb688699 100644 --- a/app/modules/phonebook/endpoints_phonebook.py +++ b/app/modules/phonebook/endpoints_phonebook.py @@ -1,7 +1,7 @@ import logging import uuid -from fastapi import Depends, File, HTTPException, UploadFile +from fastapi import Depends, HTTPException from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession @@ -21,6 +21,7 @@ from app.types import standard_responses from app.types.content_type import ContentType from app.types.module import Module +from app.types.upload import FILE_RESPONSE, UploadFile from app.utils.tools import ( compress_and_save_image_file, get_file_from_data, @@ -772,7 +773,7 @@ async def delete_membership( ) async def create_association_logo( association_id: str, - image: UploadFile = File(), + image: UploadFile, user: models_users.CoreUser = Depends( is_user_allowed_to([PhonebookPermissions.access_phonebook]), ), @@ -820,6 +821,7 @@ async def create_association_logo( @module.router.get( "/phonebook/associations/{association_id}/picture", response_class=FileResponse, + responses=FILE_RESPONSE, status_code=200, ) async def read_association_logo( diff --git a/app/modules/raffle/endpoints_raffle.py b/app/modules/raffle/endpoints_raffle.py index f96b80ae0a..1fa4ca7a0f 100644 --- a/app/modules/raffle/endpoints_raffle.py +++ b/app/modules/raffle/endpoints_raffle.py @@ -1,7 +1,7 @@ import logging import uuid -from fastapi import Depends, File, HTTPException, UploadFile +from fastapi import Depends, HTTPException from fastapi.responses import FileResponse from redis import Redis from sqlalchemy.ext.asyncio import AsyncSession @@ -22,6 +22,7 @@ from app.types import standard_responses from app.types.content_type import ContentType from app.types.module import Module +from app.types.upload import FILE_RESPONSE, UploadFile from app.utils.redis import locker_get, locker_set from app.utils.tools import ( compress_and_save_image_file, @@ -225,7 +226,7 @@ async def get_raffle_stats( ) async def create_current_raffle_logo( raffle_id: str, - image: UploadFile = File(...), + image: UploadFile, user: models_users.CoreUser = Depends( is_user_allowed_to([RafflePermissions.access_raffle]), ), @@ -272,6 +273,7 @@ async def create_current_raffle_logo( @module.router.get( "/tombola/raffles/{raffle_id}/logo", response_class=FileResponse, + responses=FILE_RESPONSE, status_code=200, ) async def read_raffle_logo( @@ -779,7 +781,7 @@ async def get_prizes_by_raffleid( ) async def create_prize_picture( prize_id: str, - image: UploadFile = File(...), + image: UploadFile, user: models_users.CoreUser = Depends( is_user_allowed_to([RafflePermissions.access_raffle]), ), @@ -830,6 +832,7 @@ async def create_prize_picture( @module.router.get( "/tombola/prizes/{prize_id}/picture", response_class=FileResponse, + responses=FILE_RESPONSE, status_code=200, ) async def read_prize_logo( diff --git a/app/modules/raid/endpoints_raid.py b/app/modules/raid/endpoints_raid.py index 7b973a72b9..1aae03e6df 100644 --- a/app/modules/raid/endpoints_raid.py +++ b/app/modules/raid/endpoints_raid.py @@ -3,7 +3,7 @@ from datetime import UTC, date, datetime from anyio import Path -from fastapi import Depends, File, HTTPException, UploadFile +from fastapi import Depends, HTTPException from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession @@ -29,6 +29,7 @@ ) from app.types.content_type import ContentType from app.types.module import Module +from app.types.upload import FILE_RESPONSE, UploadFile from app.utils.tools import ( delete_all_folder_from_data, get_core_data, @@ -401,7 +402,7 @@ async def delete_all_teams( ) async def upload_document( document_type: DocumentType, - file: UploadFile = File(...), + file: UploadFile, user: models_users.CoreUser = Depends( is_user_allowed_to([RaidPermissions.access_raid]), ), @@ -454,6 +455,7 @@ async def upload_document( @module.router.get( "/raid/document/{document_id}", response_class=FileResponse, + responses=FILE_RESPONSE, status_code=200, ) async def read_document( @@ -994,6 +996,7 @@ async def get_payment_url( @module.router.get( "/raid/security_files_zip", response_class=FileResponse, + responses=FILE_RESPONSE, status_code=200, ) async def download_security_files_zip( @@ -1018,6 +1021,7 @@ async def download_security_files_zip( @module.router.get( "/raid/team_files_zip", response_class=FileResponse, + responses=FILE_RESPONSE, status_code=200, ) async def download_team_files_zip( diff --git a/app/modules/recommendation/endpoints_recommendation.py b/app/modules/recommendation/endpoints_recommendation.py index a2affbef93..7ec7354903 100644 --- a/app/modules/recommendation/endpoints_recommendation.py +++ b/app/modules/recommendation/endpoints_recommendation.py @@ -1,7 +1,7 @@ import uuid from datetime import UTC, datetime -from fastapi import APIRouter, Depends, File, HTTPException, UploadFile +from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession @@ -21,6 +21,7 @@ from app.types import standard_responses from app.types.content_type import ContentType from app.types.module import Module +from app.types.upload import FILE_RESPONSE, UploadFile from app.utils.tools import ( compress_and_save_image_file, get_file_from_data, @@ -150,6 +151,7 @@ async def delete_recommendation( @module.router.get( "/recommendation/recommendations/{recommendation_id}/picture", response_class=FileResponse, + responses=FILE_RESPONSE, status_code=200, ) async def read_recommendation_image( @@ -186,7 +188,7 @@ async def read_recommendation_image( ) async def create_recommendation_image( recommendation_id: uuid.UUID, - image: UploadFile = File(), + image: UploadFile, user: models_users.CoreUser = Depends( is_user_allowed_to([RecommendationPermissions.manage_recommendation]), ), diff --git a/app/modules/sport_competition/endpoints_sport_competition.py b/app/modules/sport_competition/endpoints_sport_competition.py index 5e2be78c6b..404792d4d1 100644 --- a/app/modules/sport_competition/endpoints_sport_competition.py +++ b/app/modules/sport_competition/endpoints_sport_competition.py @@ -3,7 +3,7 @@ from io import BytesIO from uuid import UUID, uuid4 -from fastapi import Body, Depends, File, HTTPException, Query, Response, UploadFile +from fastapi import Body, Depends, HTTPException, Query, Response from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession @@ -66,6 +66,7 @@ ) from app.types.content_type import ContentType from app.types.module import Module +from app.types.upload import FILE_RESPONSE, UploadFile from app.utils.tools import ( delete_file_from_data, get_file_from_data, @@ -2033,6 +2034,7 @@ async def get_participants_for_school( @module.router.get( "/competition/participants/users/{user_id}/certificate", response_class=FileResponse, + responses=FILE_RESPONSE, ) async def download_participant_certificate( user_id: str, @@ -2237,7 +2239,7 @@ async def join_sport( ) async def upload_participant_certificate( sport_id: UUID, - certificate: UploadFile = File(...), + certificate: UploadFile, db: AsyncSession = Depends(get_db), user: models_users.CoreUser = Depends( is_user_allowed_to([SportCompetitionPermissions.access_sport_competition]), @@ -4451,6 +4453,7 @@ async def register_to_volunteer_shift( @module.router.get( "/competition/data-export/users", response_class=FileResponse, + responses=FILE_RESPONSE, status_code=200, ) async def export_competition_users_data( @@ -4522,6 +4525,7 @@ async def export_competition_users_data( @module.router.get( "/competition/data-export/schools/{school_id}/users", response_class=FileResponse, + responses=FILE_RESPONSE, status_code=200, ) async def export_school_competition_users_data( @@ -4602,6 +4606,7 @@ async def export_school_competition_users_data( @module.router.get( "/competition/data-export/participants/captains", response_class=FileResponse, + responses=FILE_RESPONSE, status_code=200, ) async def export_participants_captains_data( @@ -4659,6 +4664,7 @@ async def export_participants_captains_data( @module.router.get( "/competition/data-export/schools/{school_id}/quotas", response_class=FileResponse, + responses=FILE_RESPONSE, status_code=200, ) async def export_school_quotas_data( @@ -4727,6 +4733,7 @@ async def export_school_quotas_data( @module.router.get( "/competition/data-export/sports/{sport_id}/quotas", response_class=FileResponse, + responses=FILE_RESPONSE, status_code=200, ) async def export_sport_quotas_data( @@ -4783,6 +4790,7 @@ async def export_sport_quotas_data( @module.router.get( "/competition/data-export/sports/{sport_id}/participants", response_class=FileResponse, + responses=FILE_RESPONSE, status_code=200, ) async def export_sport_participants_data( diff --git a/app/types/upload.py b/app/types/upload.py new file mode 100644 index 0000000000..efcd64c82c --- /dev/null +++ b/app/types/upload.py @@ -0,0 +1,58 @@ +from typing import Annotated, Any + +from fastapi import File +from fastapi import UploadFile as _UploadFile +from pydantic import WithJsonSchema + +# Drop-in replacement for `fastapi.UploadFile` for a **required** uploaded file. +# Use it bare in a path operation, no `File(...)` default needed: +# +# from app.types.upload import UploadFile +# +# async def endpoint(image: UploadFile): ... +# +# Two things are baked in: +# +# 1. `File()` — so the parameter is read from multipart/form-data. Keeping it +# inside `Annotated` (rather than as a `= File(...)` default) is required: +# a default value drops the annotation metadata below, which would revert the +# schema to the broken form for single-file parameters. +# +# 2. `WithJsonSchema({"type": "string", "format": "binary"})` — since FastAPI +# 0.129.1 (PR fastapi/fastapi#14953) `UploadFile` fields are serialized with +# the JSON Schema 2020-12 / OAS 3.1 form +# `{"type": "string", "contentMediaType": "application/octet-stream"}` instead +# of the OAS 3.0 `{"type": "string", "format": "binary"}`. That is +# spec-compliant and will not be reverted upstream, but Swagger UI and several +# OpenAPI client generators still rely on `format: "binary"` to recognize a +# file field; without it they render a plain text input and generate `string` +# instead of a file type. We restore it with the recommended workaround from +# fastapi/fastapi#14975. +UploadFile = Annotated[ + _UploadFile, + File(), + WithJsonSchema({"type": "string", "format": "binary"}), +] + + +# OpenAPI `responses=` entry for path operations that stream a binary file with +# FastAPI's `FileResponse`. FastAPI cannot infer a media type for `FileResponse` +# (it is resolved at runtime from the served file), so the generated success +# response has no `content` block at all, and OpenAPI/Swagger client generators +# fail on it. We declare a generic binary body explicitly, mirroring the +# `format: "binary"` handling used for `UploadFile` above. +# +# Runtime behaviour is unaffected: the endpoints return their own `FileResponse` +# with the real media type; this only documents the response in the schema. +# +# Usage: +# @router.get(..., response_class=FileResponse, responses=FILE_RESPONSE) +FILE_RESPONSE: dict[int | str, dict[str, Any]] = { + 200: { + "content": { + "application/octet-stream": { + "schema": {"type": "string", "format": "binary"}, + }, + }, + }, +}