Ajoute le modèle WorkplacePresenceEvent pour stocker les événements de présence reçus de Home Assistant, avec clé d'idempotence unique, lien vers une journée et, facultativement, une plage horaire. Active les contraintes de clés étrangères sur chaque connexion SQLite et documente le schéma dans l'onboarding. Couvre le tout par des tests de modèle et de factory. Co-authored-by: OpenAI/GPT-5.6-Luna-Pro <vibecoder@antoineve.me>
152 lines
6.3 KiB
Python
152 lines
6.3 KiB
Python
from datetime import UTC, date, datetime, time
|
|
|
|
import sqlalchemy as sa
|
|
import sqlalchemy.orm as so
|
|
|
|
from app import db
|
|
|
|
|
|
class WorkEntry(db.Model):
|
|
"""
|
|
Représente une entrée de journal de travail pour une journée unique.
|
|
|
|
Cette classe stocke les informations relatives à une journée de travail,
|
|
notamment la date, le type de journée (WORK, TT, GARDE, ASTREINTE, etc.),
|
|
les profils de trajet domicile-travail, le véhicule utilisé, un commentaire
|
|
et les plages horaires associées.
|
|
|
|
Invariants :
|
|
- La date est unique (une seule entrée par jour).
|
|
"""
|
|
|
|
__tablename__ = "work_entries"
|
|
|
|
id: so.Mapped[int] = so.mapped_column(primary_key=True)
|
|
date: so.Mapped[date] = so.mapped_column(sa.Date, unique=True, nullable=False)
|
|
journey_profile_id: so.Mapped[str | None] = so.mapped_column(sa.String(64), nullable=True)
|
|
motor_vehicle_id: so.Mapped[str | None] = so.mapped_column(sa.String(64), nullable=True)
|
|
day_type: so.Mapped[str] = so.mapped_column(sa.String(16), nullable=False, default="WORK")
|
|
comment: so.Mapped[str | None] = so.mapped_column(sa.Text, nullable=True)
|
|
created_at: so.Mapped[datetime] = so.mapped_column(
|
|
sa.DateTime, default=lambda: datetime.now(UTC)
|
|
)
|
|
updated_at: so.Mapped[datetime] = so.mapped_column(
|
|
sa.DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)
|
|
)
|
|
|
|
time_slots: so.Mapped[list["TimeSlot"]] = so.relationship(
|
|
back_populates="entry", cascade="all, delete-orphan", order_by="TimeSlot.start_time"
|
|
)
|
|
presence_events: so.Mapped[list["WorkplacePresenceEvent"]] = so.relationship(
|
|
back_populates="entry", cascade="all, delete-orphan"
|
|
)
|
|
|
|
def total_minutes(self) -> int:
|
|
"""
|
|
Calcule la durée totale travaillée dans la journée en minutes.
|
|
|
|
Cette méthode somme la durée de toutes les plages horaires (`TimeSlot`)
|
|
associées à cette entrée. Elle gère le passage de minuit : si l'heure de fin
|
|
d'une plage est inférieure ou égale à son heure de début, la plage est
|
|
considérée comme se terminant le lendemain (ajout de 24 heures).
|
|
|
|
Retour :
|
|
int : La durée totale en minutes.
|
|
"""
|
|
total = 0
|
|
for slot in self.time_slots:
|
|
start = slot.start_time.hour * 60 + slot.start_time.minute
|
|
end = slot.end_time.hour * 60 + slot.end_time.minute
|
|
if end <= start:
|
|
end += 24 * 60
|
|
total += end - start
|
|
return total
|
|
|
|
def total_hours_str(self) -> str:
|
|
"""
|
|
Retourne la durée totale travaillée sous forme de chaîne formatée (ex: "7h45").
|
|
|
|
Retour :
|
|
str : La durée formatée au format "HhMM".
|
|
"""
|
|
minutes = self.total_minutes()
|
|
return f"{minutes // 60}h{minutes % 60:02d}"
|
|
|
|
|
|
class TimeSlot(db.Model):
|
|
"""
|
|
Représente une plage horaire de travail au sein d'une journée.
|
|
|
|
Chaque plage possède une heure de début et une heure de fin. Elle est rattachée
|
|
à une entrée de journal (`WorkEntry`).
|
|
"""
|
|
|
|
__tablename__ = "time_slots"
|
|
|
|
id: so.Mapped[int] = so.mapped_column(primary_key=True)
|
|
entry_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey("work_entries.id"), nullable=False)
|
|
start_time: so.Mapped[time] = so.mapped_column(sa.Time, nullable=False)
|
|
end_time: so.Mapped[time] = so.mapped_column(sa.Time, nullable=False)
|
|
|
|
entry: so.Mapped["WorkEntry"] = so.relationship(back_populates="time_slots")
|
|
presence_events: so.Mapped[list["WorkplacePresenceEvent"]] = so.relationship(
|
|
back_populates="time_slot", passive_deletes=True
|
|
)
|
|
|
|
|
|
class WorkplacePresenceEvent(db.Model):
|
|
"""Événement de présence reçu de Home Assistant.
|
|
|
|
``received_at`` est un instant normalisé en UTC, stocké naïf selon la convention
|
|
actuelle de l'application. À l'inverse, ``occurred_at`` est l'heure murale naïve
|
|
dans ``Europe/Paris`` et ``local_date`` est le jour local dérivé de cette heure.
|
|
Cette distinction est volontaire : elle sera utilisée par le service métier futur
|
|
pour rattacher les arrivées et départs aux journées, notamment autour de minuit.
|
|
"""
|
|
|
|
__tablename__ = "workplace_presence_events"
|
|
__table_args__ = (
|
|
sa.CheckConstraint("event_type IN ('arrival', 'departure')", name="ck_presence_event_type"),
|
|
sa.Index("ix_presence_events_local_date", "local_date"),
|
|
sa.Index("ix_presence_events_entry_id", "entry_id"),
|
|
)
|
|
|
|
id: so.Mapped[int] = so.mapped_column(primary_key=True)
|
|
idempotency_key: so.Mapped[str] = so.mapped_column(sa.String(255), unique=True, nullable=False)
|
|
event_type: so.Mapped[str] = so.mapped_column(sa.String(9), nullable=False)
|
|
received_at: so.Mapped[datetime] = so.mapped_column(sa.DateTime, nullable=False)
|
|
# Heure locale Europe/Paris, sans fuseau : ne pas la traiter comme un instant UTC.
|
|
occurred_at: so.Mapped[datetime] = so.mapped_column(sa.DateTime, nullable=False)
|
|
local_date: so.Mapped[date] = so.mapped_column(sa.Date, nullable=False)
|
|
entry_id: so.Mapped[int] = so.mapped_column(
|
|
sa.ForeignKey("work_entries.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
time_slot_id: so.Mapped[int | None] = so.mapped_column(
|
|
sa.ForeignKey("time_slots.id", ondelete="SET NULL"), nullable=True
|
|
)
|
|
created_at: so.Mapped[datetime] = so.mapped_column(
|
|
sa.DateTime, default=lambda: datetime.now(UTC), nullable=False
|
|
)
|
|
processed_at: so.Mapped[datetime | None] = so.mapped_column(sa.DateTime, nullable=True)
|
|
|
|
entry: so.Mapped["WorkEntry"] = so.relationship(back_populates="presence_events")
|
|
time_slot: so.Mapped["TimeSlot | None"] = so.relationship(
|
|
back_populates="presence_events", passive_deletes=True
|
|
)
|
|
|
|
|
|
class LeaveBalance(db.Model):
|
|
"""
|
|
Représente le solde annuel des congés et RTT pour une année donnée.
|
|
|
|
Stocke les quotas initiaux/totaux de congés payés et de RTT alloués pour l'année.
|
|
Par défaut, un utilisateur bénéficie de 28 jours de congés et 19 jours de RTT.
|
|
"""
|
|
|
|
__tablename__ = "leave_balance"
|
|
|
|
id: so.Mapped[int] = so.mapped_column(primary_key=True)
|
|
year: so.Mapped[int] = so.mapped_column(sa.Integer, unique=True, nullable=False)
|
|
conges_total: so.Mapped[int] = so.mapped_column(sa.Integer, default=28)
|
|
rtt_total: so.Mapped[int] = so.mapped_column(sa.Integer, default=19)
|