Pointage enregistré et statistiques de présence
ci-cd / build-backend (push) Successful in 29s
ci-cd / build-frontend (push) Successful in 19s
ci-cd / deploy (push) Successful in 12s

Étape 58 : le présent / absent du référent est enregistré sur
l'affectation (Assignment.attendance + heure, migration 0030,
PUT .../assignments/{id}/attendance) au lieu de disparaître au
rechargement.

Étape 59 : section « Présence » de l'écran Statistiques — créneaux en
cours et créneaux terminés, par poste, rafraîchie toutes les 30 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-28 08:06:32 +01:00
co-authored by Claude Opus 5.5
parent a37f6c2c41
commit c57c70569c
19 changed files with 600 additions and 22 deletions
+72
View File
@@ -0,0 +1,72 @@
"""Pointage présent / absent d'un bénévole sur son créneau
(`PUT .../assignments/{id}/attendance`) : enregistré en base, par le
référent du poste ou la coordination."""
from app.models import Assignment, Role
from tests.factories import dt_at, make_position, make_shift, make_volunteer
def _assignment(session, edition, lead=None):
position = make_position(session, edition)
if lead is not None:
position.lead_account_id = lead.id
session.add(position)
session.commit()
shift = make_shift(session, edition, position, start=dt_at(10, 18), end=dt_at(10, 22))
volunteer = make_volunteer(session, edition)
assignment = Assignment(edition_id=edition.id, volunteer_id=volunteer.id, shift_id=shift.id)
session.add(assignment)
session.commit()
session.refresh(assignment)
return assignment
def _url(edition, assignment):
return f"/editions/{edition.id}/assignments/{assignment.id}/attendance"
def test_lead_marks_attendance_and_it_is_kept(edition, session, make_account, as_account):
lead = make_account(Role.LEAD)
assignment = _assignment(session, edition, lead)
client = as_account(lead)
resp = client.put(_url(edition, assignment), json={"attendance": "PRESENT"})
assert resp.status_code == 200
assert resp.json()["attendance"] == "PRESENT"
assert resp.json()["attendance_marked_at"] is not None
# Relu ensuite (rechargement de l'écran) : le pointage est toujours là.
(listed,) = client.get(f"/editions/{edition.id}/assignments").json()
assert listed["attendance"] == "PRESENT"
def test_attendance_can_be_switched_and_cleared(client, edition, session):
assignment = _assignment(session, edition)
assert client.put(_url(edition, assignment), json={"attendance": "ABSENT"}).json()[
"attendance"
] == "ABSENT"
cleared = client.put(_url(edition, assignment), json={"attendance": None}).json()
assert cleared["attendance"] is None
assert cleared["attendance_marked_at"] is None
def test_lead_of_another_position_cannot_mark(edition, session, make_account, as_account):
assignment = _assignment(session, edition, make_account(Role.LEAD))
resp = as_account(make_account(Role.LEAD)).put(
_url(edition, assignment), json={"attendance": "PRESENT"}
)
assert resp.status_code == 403
def test_volunteer_cannot_mark(edition, session, make_account, as_account):
assignment = _assignment(session, edition)
resp = as_account(make_account(Role.VOLUNTEER)).put(
_url(edition, assignment), json={"attendance": "PRESENT"}
)
assert resp.status_code == 403
def test_unknown_status_is_rejected(client, edition, session):
assignment = _assignment(session, edition)
resp = client.put(_url(edition, assignment), json={"attendance": "LATE"})
assert resp.status_code == 422
+46
View File
@@ -187,3 +187,49 @@ def test_filled_slots_never_count_overstaffing(client, edition, session):
assert position["assigned"] == 3
assert position["filled"] == 2
assert position["ideal_slots"] == 4
def test_attendance_splits_past_and_live_shifts_per_position(
client, edition, session, monkeypatch
):
import app.api.stats as stats_api
from app.models import AttendanceStatus
# Maintenant : le 10 à 20:00 — 14h–18h terminé, 18h–22h en cours,
# 22h–02h à venir (ignoré).
monkeypatch.setattr(stats_api, "local_now", lambda: dt_at(10, 20))
bar = make_position(session, edition, name="Bar")
parking = make_position(session, edition, name="Parking")
past = make_shift(session, edition, bar, start=dt_at(10, 14), end=dt_at(10, 18))
live = make_shift(session, edition, bar, start=dt_at(10, 18), end=dt_at(10, 22))
later = make_shift(session, edition, parking, start=dt_at(10, 22), end=dt_at(11, 2))
def assign(shift, name, attendance=None):
volunteer = make_volunteer(session, edition, first_name=name)
session.add(
Assignment(
edition_id=edition.id,
volunteer_id=volunteer.id,
shift_id=shift.id,
attendance=attendance,
)
)
assign(past, "A", AttendanceStatus.PRESENT)
assign(past, "B", AttendanceStatus.ABSENT)
assign(past, "C")
assign(live, "D", AttendanceStatus.PRESENT)
assign(live, "E")
assign(later, "F", AttendanceStatus.PRESENT)
session.commit()
att = client.get(_url(edition)).json()["attendance"]
assert att["past"] == {"assigned": 3, "present": 1, "absent": 1, "unmarked": 1}
assert att["live"] == {"assigned": 2, "present": 1, "absent": 0, "unmarked": 1}
assert att["live_shifts"] == 1
# Parking n'a encore aucun créneau commencé : absent du détail.
(row,) = att["by_position"]
assert row["name"] == "Bar"
assert row["past"]["assigned"] == 3
assert row["live"]["present"] == 1
assert row["live_shifts"] == 1