Badges : permettre d'agrandir le badge proportionnellement à l'image
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
bc165757ca
commit
55c5d539a8
@@ -22,6 +22,7 @@ from app.api.schemas import (
|
|||||||
BadgeFieldRead,
|
BadgeFieldRead,
|
||||||
BadgeFieldsSet,
|
BadgeFieldsSet,
|
||||||
BadgeGenerateRequest,
|
BadgeGenerateRequest,
|
||||||
|
BadgeSizeUpdate,
|
||||||
BadgeTemplateRead,
|
BadgeTemplateRead,
|
||||||
)
|
)
|
||||||
from app.auth.deps import require_coordinator
|
from app.auth.deps import require_coordinator
|
||||||
@@ -219,6 +220,28 @@ def set_fields(
|
|||||||
return _fields(session, edition.id)
|
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 #
|
# Génération du PDF #
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -374,6 +374,15 @@ class BadgeTemplateRead(ApiModel):
|
|||||||
fields: list[BadgeFieldRead]
|
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):
|
class BadgeGenerateRequest(ApiModel):
|
||||||
# None/absent = tous les bénévoles de l'édition.
|
# None/absent = tous les bénévoles de l'édition.
|
||||||
volunteer_ids: list[uuid.UUID] | None = None
|
volunteer_ids: list[uuid.UUID] | None = None
|
||||||
|
|||||||
@@ -60,6 +60,7 @@
|
|||||||
"badge.empty_image": "L'image est vide.",
|
"badge.empty_image": "L'image est vide.",
|
||||||
"badge.image_too_large": "L'image est trop volumineuse.",
|
"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.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_fields": "Aucun champ n'est configuré sur le recto du badge.",
|
||||||
"badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.",
|
"badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.",
|
||||||
"badge.role.referent": "Référent",
|
"badge.role.referent": "Référent",
|
||||||
|
|||||||
@@ -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"
|
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):
|
def test_setting_the_same_field_type_twice_is_rejected(client, edition):
|
||||||
resp = client.put(
|
resp = client.put(
|
||||||
f"{_badges_url(edition)}/template/fields",
|
f"{_badges_url(edition)}/template/fields",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import type {
|
|||||||
AvailabilitySet,
|
AvailabilitySet,
|
||||||
BadgeFieldsSet,
|
BadgeFieldsSet,
|
||||||
BadgeGenerateRequest,
|
BadgeGenerateRequest,
|
||||||
|
BadgeSizeUpdate,
|
||||||
DayPartCreate,
|
DayPartCreate,
|
||||||
DayPartUpdate,
|
DayPartUpdate,
|
||||||
EditionUpdate,
|
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
|
/** Génère la planche PDF ; ne fait que retourner le fichier (le composant
|
||||||
* appelant déclenche le téléchargement). */
|
* appelant déclenche le téléchargement). */
|
||||||
export function useGenerateBadges() {
|
export function useGenerateBadges() {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
AvailabilitySet,
|
AvailabilitySet,
|
||||||
BadgeFieldsSet,
|
BadgeFieldsSet,
|
||||||
BadgeGenerateRequest,
|
BadgeGenerateRequest,
|
||||||
|
BadgeSizeUpdate,
|
||||||
DayPartCreate,
|
DayPartCreate,
|
||||||
DayPartUpdate,
|
DayPartUpdate,
|
||||||
EditionUpdate,
|
EditionUpdate,
|
||||||
@@ -247,6 +248,11 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(body),
|
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) => {
|
generate: async (editionId, body: BadgeGenerateRequest) => {
|
||||||
const blob = await requestBlob(`${base(editionId)}/badges/generate`, {
|
const blob = await requestBlob(`${base(editionId)}/badges/generate`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
BadgeFieldRead,
|
BadgeFieldRead,
|
||||||
BadgeFieldsSet,
|
BadgeFieldsSet,
|
||||||
BadgeGenerateRequest,
|
BadgeGenerateRequest,
|
||||||
|
BadgeSizeUpdate,
|
||||||
BadgeTemplateRead,
|
BadgeTemplateRead,
|
||||||
DayPartCreate,
|
DayPartCreate,
|
||||||
DayPartRead,
|
DayPartRead,
|
||||||
@@ -1401,6 +1402,21 @@ export function createMockApi(): PlanningApi {
|
|||||||
state.badgeTemplate.fields = fields;
|
state.badgeTemplate.fields = fields;
|
||||||
return delay(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<BadgeTemplateRead>({
|
||||||
|
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) => {
|
generate: (_edition, body: BadgeGenerateRequest) => {
|
||||||
if (state.badgeTemplate.fields.length === 0) {
|
if (state.badgeTemplate.fields.length === 0) {
|
||||||
throw new ApiError(422, 'badge.no_fields');
|
throw new ApiError(422, 'badge.no_fields');
|
||||||
|
|||||||
@@ -458,6 +458,13 @@ export interface BadgeGenerateRequest {
|
|||||||
volunteer_ids?: string[] | null;
|
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 //
|
// Authentification //
|
||||||
// --------------------------------------------------------------------------- //
|
// --------------------------------------------------------------------------- //
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
BadgeFieldRead,
|
BadgeFieldRead,
|
||||||
BadgeFieldsSet,
|
BadgeFieldsSet,
|
||||||
BadgeGenerateRequest,
|
BadgeGenerateRequest,
|
||||||
|
BadgeSizeUpdate,
|
||||||
BadgeTemplateRead,
|
BadgeTemplateRead,
|
||||||
DayPartCreate,
|
DayPartCreate,
|
||||||
DayPartRead,
|
DayPartRead,
|
||||||
@@ -146,6 +147,9 @@ export interface PlanningApi {
|
|||||||
backImageBlob(editionId: string): Promise<Blob | null>;
|
backImageBlob(editionId: string): Promise<Blob | null>;
|
||||||
/** Remplace l'intégralité des champs du recto. */
|
/** Remplace l'intégralité des champs du recto. */
|
||||||
setFields(editionId: string, body: BadgeFieldsSet): Promise<BadgeFieldRead[]>;
|
setFields(editionId: string, body: BadgeFieldsSet): Promise<BadgeFieldRead[]>;
|
||||||
|
/** Redimensionne le badge indépendamment de l'image (agrandissement
|
||||||
|
* proportionnel d'une image importée trop petite). */
|
||||||
|
setSize(editionId: string, body: BadgeSizeUpdate): Promise<BadgeTemplateRead>;
|
||||||
/** Génère la planche PDF (recto personnalisé + verso fixe). */
|
/** Génère la planche PDF (recto personnalisé + verso fixe). */
|
||||||
generate(editionId: string, body: BadgeGenerateRequest): Promise<Blob>;
|
generate(editionId: string, body: BadgeGenerateRequest): Promise<Blob>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -341,6 +341,8 @@
|
|||||||
"badges.field.role.placeholder": "Référent",
|
"badges.field.role.placeholder": "Référent",
|
||||||
"badges.field.size": "Taille",
|
"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.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.choose": "Choisir une image",
|
||||||
"badges.image.replace": "Remplacer l'image",
|
"badges.image.replace": "Remplacer l'image",
|
||||||
"badges.uploading": "Envoi…",
|
"badges.uploading": "Envoi…",
|
||||||
@@ -356,6 +358,7 @@
|
|||||||
"badge.empty_image": "L'image est vide.",
|
"badge.empty_image": "L'image est vide.",
|
||||||
"badge.image_too_large": "L'image est trop volumineuse.",
|
"badge.image_too_large": "L'image est trop volumineuse.",
|
||||||
"badge.duplicate_field_type": "Ce champ ne peut apparaître qu'une fois.",
|
"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_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."
|
"badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
useBadgeTemplate,
|
useBadgeTemplate,
|
||||||
useGenerateBadges,
|
useGenerateBadges,
|
||||||
useSetBadgeFields,
|
useSetBadgeFields,
|
||||||
|
useSetBadgeSize,
|
||||||
useUploadBadgeBackImage,
|
useUploadBadgeBackImage,
|
||||||
useUploadBadgeFrontImage,
|
useUploadBadgeFrontImage,
|
||||||
useVolunteers,
|
useVolunteers,
|
||||||
@@ -116,6 +117,7 @@ function BadgeCustomizeTab() {
|
|||||||
const uploadFront = useUploadBadgeFrontImage();
|
const uploadFront = useUploadBadgeFrontImage();
|
||||||
const uploadBack = useUploadBadgeBackImage();
|
const uploadBack = useUploadBadgeBackImage();
|
||||||
const setFields = useSetBadgeFields();
|
const setFields = useSetBadgeFields();
|
||||||
|
const setSize = useSetBadgeSize();
|
||||||
|
|
||||||
const [fields, setFieldsState] = useState<Record<BadgeFieldType, FieldState> | null>(null);
|
const [fields, setFieldsState] = useState<Record<BadgeFieldType, FieldState> | null>(null);
|
||||||
const [selected, setSelected] = useState<BadgeFieldType | null>(null);
|
const [selected, setSelected] = useState<BadgeFieldType | null>(null);
|
||||||
@@ -169,6 +171,14 @@ function BadgeCustomizeTab() {
|
|||||||
error={uploadFront.error}
|
error={uploadFront.error}
|
||||||
onPick={(file) => uploadFront.mutate(file)}
|
onPick={(file) => uploadFront.mutate(file)}
|
||||||
/>
|
/>
|
||||||
|
<BadgeSizeControl
|
||||||
|
badgeW={badgeW}
|
||||||
|
badgeH={badgeH}
|
||||||
|
pending={setSize.isPending}
|
||||||
|
onApply={(width_mm, height_mm) =>
|
||||||
|
setSize.mutate({ badge_width_mm: width_mm, badge_height_mm: height_mm })
|
||||||
|
}
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
className="relative mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center select-none"
|
className="relative mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center select-none"
|
||||||
style={{
|
style={{
|
||||||
@@ -286,6 +296,59 @@ function ImageUploadRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Largeur du badge en mm — la hauteur suit proportionnellement (ratio de
|
||||||
|
* l'image importée). Sert à agrandir un badge devenu minuscule parce que
|
||||||
|
* l'image de fond n'était pas à une résolution d'impression (300 dpi
|
||||||
|
* supposés à l'import, voir `useBadgeTemplate`). */
|
||||||
|
function BadgeSizeControl({
|
||||||
|
badgeW,
|
||||||
|
badgeH,
|
||||||
|
pending,
|
||||||
|
onApply,
|
||||||
|
}: {
|
||||||
|
badgeW: number;
|
||||||
|
badgeH: number;
|
||||||
|
pending: boolean;
|
||||||
|
onApply: (width_mm: number, height_mm: number) => 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 (
|
||||||
|
<div className="mt-2 flex items-center gap-[8px]">
|
||||||
|
<FieldLabel>{t('badges.size.width')}</FieldLabel>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={5}
|
||||||
|
step={1}
|
||||||
|
value={width}
|
||||||
|
disabled={pending}
|
||||||
|
onChange={(e) => 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]"
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-[9.5px] opacity-60">
|
||||||
|
{t('badges.size.height', { height: Math.round(badgeH * 10) / 10 })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FieldBox({
|
function FieldBox({
|
||||||
field,
|
field,
|
||||||
badgeW,
|
badgeW,
|
||||||
|
|||||||
Reference in New Issue
Block a user