Formulaire public : logo et texte d'introduction personnalisables
FormTemplate porte désormais un logo (image, PUT/GET .../form/logo — GET public comme .../form/public, affiché sur /inscription) et un texte d'introduction libre (PATCH .../form, description désormais optionnelle comme is_open). Écran de configuration (/inscriptions) : nouveau panneau « Personnalisation » pour importer le logo et rédiger le texte. Le formulaire public affiche le logo en grand au-dessus du panneau et le texte juste sous le titre. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
48d3deec65
commit
957a004623
@@ -28,6 +28,7 @@ from fastapi import (
|
||||
File,
|
||||
Form,
|
||||
HTTPException,
|
||||
Response,
|
||||
UploadFile,
|
||||
status,
|
||||
)
|
||||
@@ -96,7 +97,11 @@ def _enabled_fields(session: Session, edition_id: uuid.UUID) -> list[FormFieldTy
|
||||
|
||||
def _template_read(template: FormTemplate, fields: list[FormFieldType]) -> FormTemplateRead:
|
||||
return FormTemplateRead(
|
||||
edition_id=template.edition_id, is_open=template.is_open, fields=fields
|
||||
edition_id=template.edition_id,
|
||||
is_open=template.is_open,
|
||||
fields=fields,
|
||||
has_logo=template.logo is not None,
|
||||
description=template.description,
|
||||
)
|
||||
|
||||
|
||||
@@ -126,19 +131,66 @@ def set_fields(
|
||||
|
||||
|
||||
@router.patch("", response_model=FormTemplateRead)
|
||||
def set_open(
|
||||
def update_form(
|
||||
payload: FormTemplateUpdate,
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> FormTemplateRead:
|
||||
template = _get_or_create_template(session, edition.id)
|
||||
template.is_open = payload.is_open
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(template, field, value)
|
||||
session.add(template)
|
||||
session.commit()
|
||||
return _template_read(template, _enabled_fields(session, edition.id))
|
||||
|
||||
|
||||
async def _read_logo(file: UploadFile) -> tuple[bytes, str]:
|
||||
content_type = file.content_type or ""
|
||||
if content_type not in _ALLOWED_PHOTO_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"code": "form.logo_type_not_supported"}
|
||||
)
|
||||
content = await file.read()
|
||||
if not content:
|
||||
raise HTTPException(status_code=400, detail={"code": "form.logo_empty"})
|
||||
if len(content) > _MAX_PHOTO_BYTES:
|
||||
raise HTTPException(status_code=413, detail={"code": "form.logo_too_large"})
|
||||
return content, content_type
|
||||
|
||||
|
||||
@router.put("/logo", response_model=FormTemplateRead)
|
||||
async def set_logo(
|
||||
file: UploadFile = File(...),
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> FormTemplateRead:
|
||||
template = _get_or_create_template(session, edition.id)
|
||||
content, content_type = await _read_logo(file)
|
||||
template.logo = content
|
||||
template.logo_content_type = content_type
|
||||
session.add(template)
|
||||
session.commit()
|
||||
return _template_read(template, _enabled_fields(session, edition.id))
|
||||
|
||||
|
||||
@router.get("/logo")
|
||||
def get_logo(
|
||||
edition: Edition = Depends(get_edition),
|
||||
session: Session = Depends(get_session),
|
||||
) -> Response:
|
||||
"""Public (aucune authentification) : affiché sur `/inscription`, comme
|
||||
`GET .../form/public` — voir le commentaire du module."""
|
||||
template = _get_or_create_template(session, edition.id)
|
||||
if template.logo is None:
|
||||
raise HTTPException(status_code=404, detail={"code": "error.not_found"})
|
||||
return Response(
|
||||
content=template.logo,
|
||||
media_type=template.logo_content_type or "application/octet-stream",
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Formulaire public (aucune authentification) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -175,7 +227,12 @@ def get_public_form(
|
||||
]
|
||||
|
||||
return PublicFormConfig(
|
||||
is_open=template.is_open, fields=fields, positions=positions, artists=artists
|
||||
is_open=template.is_open,
|
||||
fields=fields,
|
||||
positions=positions,
|
||||
artists=artists,
|
||||
has_logo=template.logo is not None,
|
||||
description=template.description,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -491,10 +491,16 @@ class FormTemplateRead(ApiModel):
|
||||
edition_id: uuid.UUID
|
||||
is_open: bool
|
||||
fields: list[FormFieldType]
|
||||
has_logo: bool
|
||||
description: str | None
|
||||
|
||||
|
||||
class FormTemplateUpdate(ApiModel):
|
||||
is_open: bool
|
||||
"""`None`/absent = inchangé pour chaque champ (`exclude_unset`) — permet
|
||||
de changer `is_open` sans toucher `description`, et inversement."""
|
||||
|
||||
is_open: bool | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class PublicPositionOption(ApiModel):
|
||||
@@ -517,6 +523,8 @@ class PublicFormConfig(ApiModel):
|
||||
fields: list[FormFieldType]
|
||||
positions: list[PublicPositionOption]
|
||||
artists: list[PublicArtistOption]
|
||||
has_logo: bool
|
||||
description: str | None
|
||||
|
||||
|
||||
class FormSubmissionResult(ApiModel):
|
||||
|
||||
@@ -79,6 +79,9 @@
|
||||
"form.invalid_payload": "Données du formulaire invalides.",
|
||||
"form.photo_type_not_supported": "La photo doit être au format PNG ou JPEG.",
|
||||
"form.photo_too_large": "La photo est trop volumineuse.",
|
||||
"form.logo_type_not_supported": "Le logo doit être au format PNG ou JPEG.",
|
||||
"form.logo_empty": "Le logo est vide.",
|
||||
"form.logo_too_large": "Le logo est trop volumineux.",
|
||||
"form.too_many_position_wishes": "Deux vœux de poste au maximum.",
|
||||
"form.too_many_artists_same_day": "Un seul artiste souhaité par soir de festival.",
|
||||
"position_sheets.no_positions": "Aucun poste ne correspond à cette sélection.",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Logo et description du formulaire public
|
||||
|
||||
Revision ID: 0014_form_logo
|
||||
Revises: 0013_signup_form
|
||||
Create Date: 2026-09-23
|
||||
|
||||
Habillage de la page publique (`/inscription`, `FormTemplate`) : `logo`/
|
||||
`logo_content_type` (image, stockée en base comme les autres images de
|
||||
l'appli) et `description` (texte libre). Voir `app/api/forms.py`.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0014_form_logo"
|
||||
down_revision: str | None = "0013_signup_form"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("form_template", sa.Column("logo", sa.LargeBinary(), nullable=True))
|
||||
op.add_column(
|
||||
"form_template", sa.Column("logo_content_type", sa.String(), nullable=True)
|
||||
)
|
||||
op.add_column("form_template", sa.Column("description", sa.String(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("form_template", "description")
|
||||
op.drop_column("form_template", "logo_content_type")
|
||||
op.drop_column("form_template", "logo")
|
||||
@@ -466,7 +466,11 @@ class BadgeField(TimestampMixin, table=True):
|
||||
class FormTemplate(TimestampMixin, table=True):
|
||||
"""Configuration du formulaire public d'une édition. Une ligne par
|
||||
édition (créée à la demande). `is_open` coupe les nouvelles soumissions
|
||||
sans effacer la configuration (les champs activés restent enregistrés)."""
|
||||
sans effacer la configuration (les champs activés restent enregistrés).
|
||||
`logo`/`logo_content_type` et `description` habillent la page publique
|
||||
(`/inscription`) — aucun rapport avec les champs collectés par
|
||||
`FormField` ; stockage en base comme les autres images de l'appli
|
||||
(badges, photo de bénévole), pas de volume de fichiers à gérer."""
|
||||
|
||||
__tablename__ = "form_template"
|
||||
|
||||
@@ -478,6 +482,9 @@ class FormTemplate(TimestampMixin, table=True):
|
||||
default=True,
|
||||
sa_column=Column(Boolean, nullable=False, server_default="true"),
|
||||
)
|
||||
logo: bytes | None = Field(default=None, sa_column=Column(LargeBinary))
|
||||
logo_content_type: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class FormField(SQLModel, table=True):
|
||||
|
||||
@@ -59,6 +59,71 @@ def test_closing_the_form(client, edition):
|
||||
assert resp.json()["is_open"] is False
|
||||
|
||||
|
||||
def test_setting_description_does_not_touch_is_open(client, edition):
|
||||
client.patch(_form_url(edition), json={"is_open": False})
|
||||
resp = client.patch(_form_url(edition), json={"description": "Bienvenue !"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["description"] == "Bienvenue !"
|
||||
assert resp.json()["is_open"] is False # inchangé, absent du corps
|
||||
|
||||
|
||||
def test_clearing_description_with_an_explicit_null(client, edition):
|
||||
client.patch(_form_url(edition), json={"description": "Bienvenue !"})
|
||||
resp = client.patch(_form_url(edition), json={"description": None})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["description"] is None
|
||||
|
||||
|
||||
def test_logo_is_coordinator_only(edition, make_account, as_account):
|
||||
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||||
lead_client = as_account(lead)
|
||||
resp = lead_client.put(
|
||||
_form_url(edition, "/logo"), files={"file": ("logo.png", io.BytesIO(_PNG), "image/png")}
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_no_logo_by_default(client, raw_client, edition):
|
||||
assert client.get(_form_url(edition)).json()["has_logo"] is False
|
||||
resp = raw_client.get(_form_url(edition, "/logo"))
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_uploading_a_logo_and_reading_it_back_publicly(client, raw_client, edition):
|
||||
resp = client.put(
|
||||
_form_url(edition, "/logo"), files={"file": ("logo.png", io.BytesIO(_PNG), "image/png")}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["has_logo"] is True
|
||||
|
||||
assert client.get(_form_url(edition)).json()["has_logo"] is True
|
||||
|
||||
logo = raw_client.get(_form_url(edition, "/logo"))
|
||||
assert logo.status_code == 200
|
||||
assert logo.headers["content-type"] == "image/png"
|
||||
assert logo.content == _PNG
|
||||
|
||||
|
||||
def test_uploading_an_unsupported_logo_type_is_rejected(client, edition):
|
||||
resp = client.put(
|
||||
_form_url(edition, "/logo"),
|
||||
files={"file": ("logo.gif", b"not-a-real-gif", "image/gif")},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "form.logo_type_not_supported"
|
||||
|
||||
|
||||
def test_public_config_reports_the_logo_and_description(client, raw_client, edition):
|
||||
client.patch(_form_url(edition), json={"description": "Bienvenue au festival !"})
|
||||
client.put(
|
||||
_form_url(edition, "/logo"), files={"file": ("logo.png", io.BytesIO(_PNG), "image/png")}
|
||||
)
|
||||
|
||||
body = raw_client.get(_form_url(edition, "/public")).json()
|
||||
assert body["description"] == "Bienvenue au festival !"
|
||||
assert body["has_logo"] is True
|
||||
|
||||
|
||||
def test_public_form_hides_positions_and_artists_unless_enabled(
|
||||
client, raw_client, edition, session
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user