From 55c5d539a88d7437ba45374b800d0980364c2401 Mon Sep 17 00:00:00 2001 From: AdamThomas Date: Sun, 20 Sep 2026 20:51:01 +0200 Subject: [PATCH] =?UTF-8?q?Badges=20:=20permettre=20d'agrandir=20le=20badg?= =?UTF-8?q?e=20proportionnellement=20=C3=A0=20l'image?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le format déduit de l'image importée (300 dpi supposés, étape précédente) donne un badge minuscule pour une image à résolution web — ajout d'un contrôle « largeur du badge (mm) » dans l'écran de personnalisation : la hauteur suit proportionnellement (ratio de l'image). PUT /editions/{id}/badges/template/size écrase la taille déduite de l'image ; la réimporter la recalcule à nouveau. Vérifié : pytest (250 passés), ruff, tsc --noEmit, eslint, vite build, smoke — tous verts. Co-Authored-By: Claude Sonnet 5 --- backend/app/api/badges.py | 23 ++++++++++ backend/app/api/schemas.py | 9 ++++ backend/app/i18n/locales/fr.json | 1 + backend/tests/test_badges_api.py | 31 ++++++++++++++ frontend/src/api/hooks.ts | 11 +++++ frontend/src/api/http-client.ts | 6 +++ frontend/src/api/mock/mock-client.ts | 16 +++++++ frontend/src/api/schema.ts | 7 ++++ frontend/src/api/types.ts | 4 ++ frontend/src/i18n/locales/fr.json | 3 ++ frontend/src/pages/BadgesPage.tsx | 63 ++++++++++++++++++++++++++++ 11 files changed, 174 insertions(+) diff --git a/backend/app/api/badges.py b/backend/app/api/badges.py index b176bc3..bf4571e 100644 --- a/backend/app/api/badges.py +++ b/backend/app/api/badges.py @@ -22,6 +22,7 @@ from app.api.schemas import ( BadgeFieldRead, BadgeFieldsSet, BadgeGenerateRequest, + BadgeSizeUpdate, BadgeTemplateRead, ) from app.auth.deps import require_coordinator @@ -219,6 +220,28 @@ def set_fields( return _fields(session, edition.id) +@router.put("/template/size", response_model=BadgeTemplateRead) +def set_size( + payload: BadgeSizeUpdate, + edition: Edition = Depends(get_edition), + _guard: Account = Depends(require_coordinator), + session: Session = Depends(get_session), +) -> BadgeTemplateRead: + """Redimensionne le badge indépendamment de l'image (ex. une image + importée à basse résolution donne un format minuscule à 300 dpi + supposés — l'utilisateur l'agrandit ici). Écrase la taille déduite de + l'image ; ré-importer l'image la recalcule à nouveau depuis ses pixels + (`set_front_image`).""" + if payload.badge_width_mm <= 0 or payload.badge_height_mm <= 0: + raise HTTPException(status_code=422, detail={"code": "badge.invalid_size"}) + template = _get_or_create_template(session, edition.id) + template.front_image_width_mm = payload.badge_width_mm + template.front_image_height_mm = payload.badge_height_mm + session.add(template) + session.commit() + return _template_read(template, _fields(session, edition.id)) + + # --------------------------------------------------------------------------- # # Génération du PDF # # --------------------------------------------------------------------------- # diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index c862db5..2e5dd64 100644 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -374,6 +374,15 @@ class BadgeTemplateRead(ApiModel): fields: list[BadgeFieldRead] +class BadgeSizeUpdate(ApiModel): + """Ajuste la taille du badge indépendamment d'un nouvel import d'image — + ex. agrandir proportionnellement une image importée trop petite (voir + `app/api/badges.py:set_size`). Écrase la taille déduite de l'image.""" + + badge_width_mm: float + badge_height_mm: float + + class BadgeGenerateRequest(ApiModel): # None/absent = tous les bénévoles de l'édition. volunteer_ids: list[uuid.UUID] | None = None diff --git a/backend/app/i18n/locales/fr.json b/backend/app/i18n/locales/fr.json index 4cc6301..a476e3a 100644 --- a/backend/app/i18n/locales/fr.json +++ b/backend/app/i18n/locales/fr.json @@ -60,6 +60,7 @@ "badge.empty_image": "L'image est vide.", "badge.image_too_large": "L'image est trop volumineuse.", "badge.duplicate_field_type": "Le champ « {field_type} » ne peut apparaître qu'une fois.", + "badge.invalid_size": "La taille du badge doit être strictement positive.", "badge.no_fields": "Aucun champ n'est configuré sur le recto du badge.", "badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.", "badge.role.referent": "Référent", diff --git a/backend/tests/test_badges_api.py b/backend/tests/test_badges_api.py index fe05106..dac5c48 100644 --- a/backend/tests/test_badges_api.py +++ b/backend/tests/test_badges_api.py @@ -97,6 +97,37 @@ def test_uploading_an_unsupported_image_type_is_rejected(client, edition): assert resp.json()["detail"]["code"] == "badge.image_type_not_supported" +def test_resizing_the_badge_scales_it_independently_of_the_image(client, edition): + client.put( + f"{_badges_url(edition)}/template/front-image", + files={"file": ("front.png", _PNG, "image/png")}, + ) + # Image 1×1 px à 300 dpi supposés ⇒ badge minuscule (~0,08 mm) : on + # l'agrandit à un format exploitable, en conservant le ratio (ici 1:1). + resp = client.put( + f"{_badges_url(edition)}/template/size", + json={"badge_width_mm": 90, "badge_height_mm": 90}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["badge_width_mm"] == 90 + assert body["badge_height_mm"] == 90 + + # Persisté : une relecture du gabarit renvoie toujours la taille agrandie. + again = client.get(f"{_badges_url(edition)}/template").json() + assert again["badge_width_mm"] == 90 + assert again["badge_height_mm"] == 90 + + +def test_resizing_the_badge_to_a_non_positive_size_is_rejected(client, edition): + resp = client.put( + f"{_badges_url(edition)}/template/size", + json={"badge_width_mm": 0, "badge_height_mm": 54}, + ) + assert resp.status_code == 422 + assert resp.json()["detail"]["code"] == "badge.invalid_size" + + def test_setting_the_same_field_type_twice_is_rejected(client, edition): resp = client.put( f"{_badges_url(edition)}/template/fields", diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index c812d45..ed12288 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -19,6 +19,7 @@ import type { AvailabilitySet, BadgeFieldsSet, BadgeGenerateRequest, + BadgeSizeUpdate, DayPartCreate, DayPartUpdate, EditionUpdate, @@ -535,6 +536,16 @@ export function useSetBadgeFields() { }); } +/** Redimensionne le badge indépendamment de l'image (agrandissement + * proportionnel d'une image importée trop petite). */ +export function useSetBadgeSize() { + const invalidate = useInvalidateBadgeTemplate(); + return useMutation({ + mutationFn: (body: BadgeSizeUpdate) => api.badges.setSize(edition, body), + onSuccess: invalidate, + }); +} + /** Génère la planche PDF ; ne fait que retourner le fichier (le composant * appelant déclenche le téléchargement). */ export function useGenerateBadges() { diff --git a/frontend/src/api/http-client.ts b/frontend/src/api/http-client.ts index bf4ba2c..5519d28 100644 --- a/frontend/src/api/http-client.ts +++ b/frontend/src/api/http-client.ts @@ -9,6 +9,7 @@ import type { AvailabilitySet, BadgeFieldsSet, BadgeGenerateRequest, + BadgeSizeUpdate, DayPartCreate, DayPartUpdate, EditionUpdate, @@ -247,6 +248,11 @@ export function createHttpApi(baseUrl: string): PlanningApi { method: 'PUT', body: JSON.stringify(body), }), + setSize: (editionId, body: BadgeSizeUpdate) => + request(`${base(editionId)}/badges/template/size`, { + method: 'PUT', + body: JSON.stringify(body), + }), generate: async (editionId, body: BadgeGenerateRequest) => { const blob = await requestBlob(`${base(editionId)}/badges/generate`, { method: 'POST', diff --git a/frontend/src/api/mock/mock-client.ts b/frontend/src/api/mock/mock-client.ts index 1834f69..37dc6c5 100644 --- a/frontend/src/api/mock/mock-client.ts +++ b/frontend/src/api/mock/mock-client.ts @@ -13,6 +13,7 @@ import type { BadgeFieldRead, BadgeFieldsSet, BadgeGenerateRequest, + BadgeSizeUpdate, BadgeTemplateRead, DayPartCreate, DayPartRead, @@ -1401,6 +1402,21 @@ export function createMockApi(): PlanningApi { state.badgeTemplate.fields = fields; return delay(fields); }, + setSize: (_edition, body: BadgeSizeUpdate) => { + if (body.badge_width_mm <= 0 || body.badge_height_mm <= 0) { + throw new ApiError(422, 'badge.invalid_size'); + } + state.badgeTemplate.frontImageWidthMm = body.badge_width_mm; + state.badgeTemplate.frontImageHeightMm = body.badge_height_mm; + return delay({ + edition_id: state.edition.id, + has_front_image: state.badgeTemplate.frontImage !== null, + has_back_image: state.badgeTemplate.backImage !== null, + badge_width_mm: body.badge_width_mm, + badge_height_mm: body.badge_height_mm, + fields: state.badgeTemplate.fields, + }); + }, generate: (_edition, body: BadgeGenerateRequest) => { if (state.badgeTemplate.fields.length === 0) { throw new ApiError(422, 'badge.no_fields'); diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 07f0185..0dea494 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -458,6 +458,13 @@ export interface BadgeGenerateRequest { volunteer_ids?: string[] | null; } +/** Redimensionne le badge indépendamment d'un nouvel import d'image — ex. + * agrandir proportionnellement une image importée à basse résolution. */ +export interface BadgeSizeUpdate { + badge_width_mm: number; + badge_height_mm: number; +} + // --------------------------------------------------------------------------- // // Authentification // // --------------------------------------------------------------------------- // diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 3c67397..a56b021 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -16,6 +16,7 @@ import type { BadgeFieldRead, BadgeFieldsSet, BadgeGenerateRequest, + BadgeSizeUpdate, BadgeTemplateRead, DayPartCreate, DayPartRead, @@ -146,6 +147,9 @@ export interface PlanningApi { backImageBlob(editionId: string): Promise; /** Remplace l'intégralité des champs du recto. */ setFields(editionId: string, body: BadgeFieldsSet): Promise; + /** Redimensionne le badge indépendamment de l'image (agrandissement + * proportionnel d'une image importée trop petite). */ + setSize(editionId: string, body: BadgeSizeUpdate): Promise; /** Génère la planche PDF (recto personnalisé + verso fixe). */ generate(editionId: string, body: BadgeGenerateRequest): Promise; }; diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 5951678..7b163b7 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -341,6 +341,8 @@ "badges.field.role.placeholder": "Référent", "badges.field.size": "Taille", "badges.editor.hint": "Badge {width} × {height} mm. Glissez un champ pour le déplacer, tirez son coin bas-droit pour le redimensionner.", + "badges.size.width": "Largeur du badge (mm)", + "badges.size.height": "× {height} mm (hauteur proportionnelle)", "badges.image.choose": "Choisir une image", "badges.image.replace": "Remplacer l'image", "badges.uploading": "Envoi…", @@ -356,6 +358,7 @@ "badge.empty_image": "L'image est vide.", "badge.image_too_large": "L'image est trop volumineuse.", "badge.duplicate_field_type": "Ce champ ne peut apparaître qu'une fois.", + "badge.invalid_size": "La taille du badge doit être strictement positive.", "badge.no_fields": "Activez au moins un champ sur le recto avant de générer.", "badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection." } diff --git a/frontend/src/pages/BadgesPage.tsx b/frontend/src/pages/BadgesPage.tsx index 31c62ce..1222ac8 100644 --- a/frontend/src/pages/BadgesPage.tsx +++ b/frontend/src/pages/BadgesPage.tsx @@ -5,6 +5,7 @@ import { useBadgeTemplate, useGenerateBadges, useSetBadgeFields, + useSetBadgeSize, useUploadBadgeBackImage, useUploadBadgeFrontImage, useVolunteers, @@ -116,6 +117,7 @@ function BadgeCustomizeTab() { const uploadFront = useUploadBadgeFrontImage(); const uploadBack = useUploadBadgeBackImage(); const setFields = useSetBadgeFields(); + const setSize = useSetBadgeSize(); const [fields, setFieldsState] = useState | null>(null); const [selected, setSelected] = useState(null); @@ -169,6 +171,14 @@ function BadgeCustomizeTab() { error={uploadFront.error} onPick={(file) => uploadFront.mutate(file)} /> + + setSize.mutate({ badge_width_mm: width_mm, badge_height_mm: height_mm }) + } + />
void; +}) { + const rounded = Math.round(badgeW * 10) / 10; + const [width, setWidth] = useState(String(rounded)); + + useEffect(() => setWidth(String(rounded)), [rounded]); + + const apply = () => { + const w = Number(width); + if (!Number.isFinite(w) || w <= 0) { + setWidth(String(rounded)); + return; + } + const ratio = badgeH / badgeW; + onApply(w, Math.round(w * ratio * 10) / 10); + }; + + return ( +
+ {t('badges.size.width')} + setWidth(e.target.value)} + onBlur={apply} + onKeyDown={(e) => { + if (e.key === 'Enter') apply(); + }} + className="w-[64px] border-2 border-ink bg-cream px-[6px] py-[3px] font-mono text-[11px]" + /> + + {t('badges.size.height', { height: Math.round(badgeH * 10) / 10 })} + +
+ ); +} + function FieldBox({ field, badgeW,