Pointage enregistré et statistiques de présence
É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:
co-authored by
Claude Opus 5.5
parent
a37f6c2c41
commit
c57c70569c
@@ -17,6 +17,7 @@ from app.api.schemas import (
|
||||
AssignmentValidateRequest,
|
||||
AssignmentValidateResult,
|
||||
AssignmentWriteResult,
|
||||
AttendanceUpdate,
|
||||
)
|
||||
from app.auth.deps import (
|
||||
current_account_in_edition,
|
||||
@@ -24,7 +25,7 @@ from app.auth.deps import (
|
||||
require_staff,
|
||||
)
|
||||
from app.db import get_session
|
||||
from app.models import Account, Assignment, Edition, Role, Shift, Volunteer
|
||||
from app.models import Account, Assignment, Edition, Role, Shift, Volunteer, local_now
|
||||
from app.validation import has_blocking, validate
|
||||
from app.validation.loader import build_validation_input
|
||||
|
||||
@@ -206,6 +207,30 @@ def update_assignment(
|
||||
return AssignmentWriteResult(assignment=assignment, warnings=warnings)
|
||||
|
||||
|
||||
@router.put("/{assignment_id}/attendance", response_model=AssignmentRead)
|
||||
def set_attendance(
|
||||
assignment_id: uuid.UUID,
|
||||
payload: AttendanceUpdate,
|
||||
edition: Edition = Depends(get_edition),
|
||||
account: Account = Depends(current_account_in_edition),
|
||||
session: Session = Depends(get_session),
|
||||
) -> Assignment:
|
||||
"""Pointe un bénévole présent ou absent sur son créneau (`None` annule) —
|
||||
le référent du poste ou la coordination. Enregistré en base : le
|
||||
pointage survit à un rechargement et se retrouve sur tout appareil."""
|
||||
assignment = owned_or_404(
|
||||
session.get(Assignment, assignment_id), edition.id
|
||||
)
|
||||
ensure_can_write_shift(
|
||||
account, session, session.get(Shift, assignment.shift_id)
|
||||
)
|
||||
assignment.attendance = payload.attendance
|
||||
assignment.attendance_marked_at = local_now() if payload.attendance else None
|
||||
session.commit()
|
||||
session.refresh(assignment)
|
||||
return assignment
|
||||
|
||||
|
||||
@router.delete("/{assignment_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_assignment(
|
||||
assignment_id: uuid.UUID,
|
||||
|
||||
@@ -7,6 +7,7 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models import (
|
||||
AssignmentSource,
|
||||
AttendanceStatus,
|
||||
BadgeAlign,
|
||||
BadgeFace,
|
||||
BadgeFieldType,
|
||||
@@ -305,10 +306,17 @@ class AssignmentRead(ApiModel):
|
||||
shift_id: uuid.UUID
|
||||
source: AssignmentSource
|
||||
locked: bool
|
||||
attendance: AttendanceStatus | None
|
||||
attendance_marked_at: dt.datetime | None
|
||||
created_at: dt.datetime
|
||||
updated_at: dt.datetime
|
||||
|
||||
|
||||
class AttendanceUpdate(ApiModel):
|
||||
# `None` = annuler le pointage.
|
||||
attendance: AttendanceStatus | None
|
||||
|
||||
|
||||
class AssignmentWriteResult(ApiModel):
|
||||
assignment: AssignmentRead
|
||||
warnings: list[Violation] = Field(default_factory=list)
|
||||
@@ -758,9 +766,41 @@ class StatsNeeds(ApiModel):
|
||||
volunteers_with_assignment: int
|
||||
|
||||
|
||||
class StatsAttendanceCounts(ApiModel):
|
||||
"""Pointage (`Assignment.attendance`) d'un ensemble d'affectations :
|
||||
`assigned` = `present` + `absent` + `unmarked`."""
|
||||
|
||||
assigned: int = 0
|
||||
present: int = 0
|
||||
absent: int = 0
|
||||
unmarked: int = 0
|
||||
|
||||
|
||||
class StatsAttendancePosition(ApiModel):
|
||||
position_id: uuid.UUID
|
||||
name: str
|
||||
# Créneaux terminés (`ends_at <= as_of`) et créneaux en cours.
|
||||
past: StatsAttendanceCounts
|
||||
live: StatsAttendanceCounts
|
||||
live_shifts: int
|
||||
|
||||
|
||||
class StatsAttendance(ApiModel):
|
||||
"""Présents / absents pointés par les référents, par poste : sur les
|
||||
créneaux déjà terminés et sur ceux en cours à `as_of` (heure du calcul,
|
||||
locale). Les créneaux à venir ne comptent pas."""
|
||||
|
||||
as_of: dt.datetime
|
||||
past: StatsAttendanceCounts
|
||||
live: StatsAttendanceCounts
|
||||
live_shifts: int
|
||||
by_position: list[StatsAttendancePosition]
|
||||
|
||||
|
||||
class EditionStats(ApiModel):
|
||||
registrations: StatsRegistrations
|
||||
answers: StatsAnswers
|
||||
payments: StatsPayments
|
||||
artist_wishes: list[StatsArtistWish]
|
||||
needs: StatsNeeds
|
||||
attendance: StatsAttendance
|
||||
|
||||
@@ -16,6 +16,9 @@ from app.api.schemas import (
|
||||
EditionStats,
|
||||
StatsAnswers,
|
||||
StatsArtistWish,
|
||||
StatsAttendance,
|
||||
StatsAttendanceCounts,
|
||||
StatsAttendancePosition,
|
||||
StatsNeedDay,
|
||||
StatsNeedPosition,
|
||||
StatsNeeds,
|
||||
@@ -30,6 +33,7 @@ from app.models import (
|
||||
Account,
|
||||
Artist,
|
||||
Assignment,
|
||||
AttendanceStatus,
|
||||
DayPart,
|
||||
Edition,
|
||||
FeePaymentStatus,
|
||||
@@ -108,6 +112,59 @@ def _payments(
|
||||
)
|
||||
|
||||
|
||||
def _count(counts: StatsAttendanceCounts, assignment: Assignment) -> None:
|
||||
counts.assigned += 1
|
||||
if assignment.attendance == AttendanceStatus.PRESENT:
|
||||
counts.present += 1
|
||||
elif assignment.attendance == AttendanceStatus.ABSENT:
|
||||
counts.absent += 1
|
||||
else:
|
||||
counts.unmarked += 1
|
||||
|
||||
|
||||
def _attendance(
|
||||
positions: list[Position], shifts: list[Shift], assignments: list[Assignment]
|
||||
) -> StatsAttendance:
|
||||
"""Pointage par poste, sur les créneaux terminés et ceux en cours."""
|
||||
now = local_now()
|
||||
past = StatsAttendanceCounts()
|
||||
live = StatsAttendanceCounts()
|
||||
by_position = {
|
||||
p.id: StatsAttendancePosition(
|
||||
position_id=p.id,
|
||||
name=p.name,
|
||||
past=StatsAttendanceCounts(),
|
||||
live=StatsAttendanceCounts(),
|
||||
live_shifts=0,
|
||||
)
|
||||
for p in positions
|
||||
}
|
||||
# Créneau terminé ⇒ « past », en cours ⇒ « live », à venir : ignoré.
|
||||
phase: dict = {}
|
||||
for s in shifts:
|
||||
if s.ends_at <= now:
|
||||
phase[s.id] = ("past", s.position_id)
|
||||
elif s.starts_at <= now:
|
||||
phase[s.id] = ("live", s.position_id)
|
||||
by_position[s.position_id].live_shifts += 1
|
||||
for a in assignments:
|
||||
if a.shift_id not in phase:
|
||||
continue
|
||||
when, position_id = phase[a.shift_id]
|
||||
row = by_position[position_id]
|
||||
_count(past if when == "past" else live, a)
|
||||
_count(row.past if when == "past" else row.live, a)
|
||||
return StatsAttendance(
|
||||
as_of=now,
|
||||
past=past,
|
||||
live=live,
|
||||
live_shifts=sum(r.live_shifts for r in by_position.values()),
|
||||
by_position=[
|
||||
r for r in by_position.values() if r.past.assigned or r.live.assigned or r.live_shifts
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=EditionStats)
|
||||
def get_stats(
|
||||
edition: Edition = Depends(get_edition),
|
||||
@@ -256,4 +313,5 @@ def get_stats(
|
||||
payments=_payments(volunteers, pending_unfinished),
|
||||
artist_wishes=artist_stats,
|
||||
needs=needs,
|
||||
attendance=_attendance(positions, shifts, assignments),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Pointage des bénévoles
|
||||
|
||||
Revision ID: 0030_assignment_attendance
|
||||
Revises: 0029_volunteer_messages
|
||||
Create Date: 2026-10-28
|
||||
|
||||
`assignment.attendance` (`PRESENT`/`ABSENT`, `NULL` = pas pointé) et
|
||||
`attendance_marked_at` : le pointage du référent était gardé dans le
|
||||
navigateur seulement et se perdait au rechargement.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0030_assignment_attendance"
|
||||
down_revision: str | None = "0029_volunteer_messages"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
_ATTENDANCE = sa.Enum("PRESENT", "ABSENT", name="attendancestatus", native_enum=False)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("assignment", sa.Column("attendance", _ATTENDANCE, nullable=True))
|
||||
op.add_column(
|
||||
"assignment", sa.Column("attendance_marked_at", sa.DateTime(), nullable=True)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("assignment", "attendance_marked_at")
|
||||
op.drop_column("assignment", "attendance")
|
||||
@@ -27,6 +27,7 @@ from app.models.domain import (
|
||||
)
|
||||
from app.models.enums import (
|
||||
AssignmentSource,
|
||||
AttendanceStatus,
|
||||
BadgeAlign,
|
||||
BadgeFace,
|
||||
BadgeFieldType,
|
||||
@@ -42,6 +43,7 @@ __all__ = [
|
||||
"Artist",
|
||||
"Assignment",
|
||||
"AssignmentSource",
|
||||
"AttendanceStatus",
|
||||
"BadgeAlign",
|
||||
"BadgeFace",
|
||||
"BadgeField",
|
||||
|
||||
@@ -17,6 +17,7 @@ from sqlmodel import Field, Relationship, SQLModel, UniqueConstraint
|
||||
from app.models.base import TimestampMixin
|
||||
from app.models.enums import (
|
||||
AssignmentSource,
|
||||
AttendanceStatus,
|
||||
BadgeAlign,
|
||||
BadgeFace,
|
||||
BadgeFieldType,
|
||||
@@ -414,6 +415,14 @@ class Assignment(TimestampMixin, table=True):
|
||||
default=False,
|
||||
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
||||
)
|
||||
# Pointage par le référent du poste (ou la coordination) : `None` tant
|
||||
# que personne n'a pointé. `attendance_marked_at` : heure du dernier
|
||||
# pointage, affichée sur l'écran référent.
|
||||
attendance: AttendanceStatus | None = Field(
|
||||
default=None,
|
||||
sa_column=Column(SAEnum(AttendanceStatus, native_enum=False), nullable=True),
|
||||
)
|
||||
attendance_marked_at: dt.datetime | None = None
|
||||
|
||||
volunteer: Volunteer = Relationship(back_populates="assignments")
|
||||
shift: Shift = Relationship(back_populates="assignments")
|
||||
|
||||
@@ -18,6 +18,13 @@ class AssignmentSource(enum.StrEnum):
|
||||
MANUAL = "MANUAL"
|
||||
|
||||
|
||||
class AttendanceStatus(enum.StrEnum):
|
||||
"""Pointage d'un bénévole sur son créneau, par le référent du poste."""
|
||||
|
||||
PRESENT = "PRESENT"
|
||||
ABSENT = "ABSENT"
|
||||
|
||||
|
||||
class PositionCategory(enum.StrEnum):
|
||||
BAR = "BAR"
|
||||
TICKETING = "TICKETING"
|
||||
|
||||
Reference in New Issue
Block a user