Écrans bénévole et référent redessinés, messages aux bénévoles
ci-cd / build-backend (push) Successful in 29s
ci-cd / build-frontend (push) Successful in 21s
ci-cd / deploy (push) Successful in 12s

Étape 55 : écrans /mobile-benevole et /mobile-referent d'après la maquette
(design/Planning bénévole mobile) — créneau en cours ou prochain mis en
avant, planning par journée de festival avec frise horaire, compteurs et
pointage du référent par créneau.

Étape 56 : message de l'organisation (Edition.volunteer_message, onglet
« Message aux bénévoles ») et message de chaque poste (Position.message,
saisi par le référent), migration 0029, affichés sur l'écran bénévole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-27 23:17:14 +01:00
co-authored by Claude Opus 5.5
parent 4a3f93e137
commit 16d8fb44a7
21 changed files with 1305 additions and 274 deletions
+13 -1
View File
@@ -14,7 +14,7 @@ from PIL import Image, UnidentifiedImageError
from sqlalchemy import delete
from sqlmodel import Session, select
from app.models import Artist, Position, VolunteerArtist, VolunteerPositionWish
from app.models import Artist, Position, VolunteerArtist, VolunteerPositionWish, local_now
def owned_or_404(instance, edition_id: uuid.UUID):
@@ -119,3 +119,15 @@ async def read_photo(photo: UploadFile | None) -> tuple[bytes, str] | None:
status_code=422, detail={"code": "form.photo_type_not_supported"}
) from exc
return content, content_type
def set_message(target: object, field: str, value: str | None) -> None:
"""Message affiché aux bénévoles (`Edition.volunteer_message`,
`Position.message`) : espaces de bord retirés, vide = plus de message.
`{field}_updated_at` n'est daté que si le texte change vraiment — il dit
aux bénévoles de quand date le message."""
text = (value or "").strip() or None
if text == getattr(target, field):
return
setattr(target, field, text)
setattr(target, f"{field}_updated_at", local_now() if text else None)
+5 -1
View File
@@ -3,6 +3,7 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session
from app.api._common import set_message
from app.api.deps import get_edition
from app.api.schemas import EditionRead, EditionUpdate
from app.auth.deps import current_account_in_edition, require_coordinator
@@ -27,7 +28,10 @@ def update_edition(
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Edition:
for field, value in payload.model_dump(exclude_unset=True).items():
changes = payload.model_dump(exclude_unset=True)
if "volunteer_message" in changes:
set_message(edition, "volunteer_message", changes.pop("volunteer_message"))
for field, value in changes.items():
setattr(edition, field, value)
if edition.end_date < edition.start_date:
raise HTTPException(
+3 -1
View File
@@ -10,7 +10,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlmodel import Session, select
from app.api._common import owned_or_404
from app.api._common import owned_or_404, set_message
from app.api._shiftgen import generate_missing_shifts
from app.api.deps import get_edition
from app.api.schemas import (
@@ -103,6 +103,8 @@ def update_position(
ensure_can_write_position(account, position)
changes = payload.model_dump(exclude_unset=True)
_check_lead_account(session, edition, changes.get("lead_account_id"))
if "message" in changes:
set_message(position, "message", changes.pop("message"))
for field, value in changes.items():
setattr(position, field, value)
session.commit()
+16
View File
@@ -35,16 +35,25 @@ class EditionRead(ApiModel):
start_date: dt.date
end_date: dt.date
retention_days: int | None
volunteer_message: str | None
volunteer_message_updated_at: dt.datetime | None
created_at: dt.datetime
updated_at: dt.datetime
# Plafond d'un message (organisation, poste) : quelques paragraphes, pas un
# document.
MESSAGE_MAX_LENGTH = 2000
class EditionUpdate(ApiModel):
name: str | None = None
year: int | None = None
start_date: dt.date | None = None
end_date: dt.date | None = None
retention_days: int | None = None
# Vide ou blanc = plus de message.
volunteer_message: str | None = Field(default=None, max_length=MESSAGE_MAX_LENGTH)
# --------------------------------------------------------------------------- #
# Volunteer #
@@ -141,6 +150,9 @@ class VolunteerPlanningEntry(ApiModel):
# pas rattaché à une fiche bénévole (pas de téléphone à donner alors).
referent_name: str | None
referent_phone: str | None
# Message du poste (voir `Position.message`).
position_message: str | None
position_message_updated_at: dt.datetime | None
# --------------------------------------------------------------------------- #
@@ -187,6 +199,8 @@ class PositionUpdate(ApiModel):
location: str | None = None
category: PositionCategory | None = None
lead_account_id: uuid.UUID | None = None
# Vide ou blanc = plus de message.
message: str | None = Field(default=None, max_length=MESSAGE_MAX_LENGTH)
class PositionRead(ApiModel):
@@ -196,6 +210,8 @@ class PositionRead(ApiModel):
location: str | None
category: PositionCategory
lead_account_id: uuid.UUID | None
message: str | None
message_updated_at: dt.datetime | None
created_at: dt.datetime
updated_at: dt.datetime
+2
View File
@@ -317,6 +317,8 @@ def get_volunteer_planning(
position_location=p.location,
referent_name=f"{r.first_name} {r.last_name}" if r else None,
referent_phone=r.phone if r else None,
position_message=p.message,
position_message_updated_at=p.message_updated_at,
)
for a, s, p, r in rows
]
@@ -0,0 +1,37 @@
"""Messages aux bénévoles
Revision ID: 0029_volunteer_messages
Revises: 0028_badge_field_z_order
Create Date: 2026-10-28
`edition.volunteer_message` : message de l'organisation, le même pour tous
les bénévoles, saisi par la coordination. `position.message` : message du
poste (consignes…), saisi par son référent, montré aux bénévoles affectés.
Chacun avec la date de sa dernière modification (`*_updated_at`).
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0029_volunteer_messages"
down_revision: str | None = "0028_badge_field_z_order"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("edition", sa.Column("volunteer_message", sa.String(), nullable=True))
op.add_column(
"edition", sa.Column("volunteer_message_updated_at", sa.DateTime(), nullable=True)
)
op.add_column("position", sa.Column("message", sa.String(), nullable=True))
op.add_column("position", sa.Column("message_updated_at", sa.DateTime(), nullable=True))
def downgrade() -> None:
op.drop_column("position", "message_updated_at")
op.drop_column("position", "message")
op.drop_column("edition", "volunteer_message_updated_at")
op.drop_column("edition", "volunteer_message")
+9
View File
@@ -92,6 +92,11 @@ class Edition(TimestampMixin, table=True):
end_date: dt.date
# Durée de rétention des données nominatives, en jours. None = pas de purge.
retention_days: int | None = None
# Message de l'organisation, le même pour tous les bénévoles (écran
# bénévole) — saisi par la coordination. `_updated_at` : date affichée
# sous le message, posée par l'API à chaque changement de texte.
volunteer_message: str | None = None
volunteer_message_updated_at: dt.datetime | None = None
day_parts: list["DayPart"] = Relationship(
back_populates="edition", sa_relationship_kwargs=_PASSIVE
@@ -326,6 +331,10 @@ class Position(TimestampMixin, table=True):
ondelete="SET NULL",
nullable=True,
)
# Message du poste (consignes…), saisi par son référent ou la
# coordination, montré aux bénévoles affectés au poste.
message: str | None = None
message_updated_at: dt.datetime | None = None
shifts: list["Shift"] = Relationship(
back_populates="position", sa_relationship_kwargs=_PASSIVE