feat: persister les événements de présence et activer les clés étrangères SQLite
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>
This commit is contained in:
@@ -24,6 +24,15 @@ from flask_sqlalchemy import SQLAlchemy
|
|||||||
db = SQLAlchemy()
|
db = SQLAlchemy()
|
||||||
|
|
||||||
|
|
||||||
|
def _enable_sqlite_foreign_keys(dbapi_connection, connection_record):
|
||||||
|
"""Active les contraintes de clés étrangères sur chaque connexion SQLite."""
|
||||||
|
cursor = dbapi_connection.cursor()
|
||||||
|
try:
|
||||||
|
cursor.execute("PRAGMA foreign_keys=ON")
|
||||||
|
finally:
|
||||||
|
cursor.close()
|
||||||
|
|
||||||
|
|
||||||
def _migrate_db(app):
|
def _migrate_db(app):
|
||||||
"""Applique les migrations de schéma manquantes de manière incrémentale (sans Alembic).
|
"""Applique les migrations de schéma manquantes de manière incrémentale (sans Alembic).
|
||||||
|
|
||||||
@@ -182,6 +191,9 @@ def create_app(
|
|||||||
app.config["HOME_ASSISTANT"] = get_home_assistant_config()
|
app.config["HOME_ASSISTANT"] = get_home_assistant_config()
|
||||||
|
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
|
with app.app_context():
|
||||||
|
if db.engine.dialect.name == "sqlite":
|
||||||
|
sa.event.listen(db.engine, "connect", _enable_sqlite_foreign_keys)
|
||||||
app.jinja_env.filters["date_fr"] = _date_fr
|
app.jinja_env.filters["date_fr"] = _date_fr
|
||||||
app.jinja_env.filters["day_type_fr"] = _day_type_fr
|
app.jinja_env.filters["day_type_fr"] = _day_type_fr
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ class WorkEntry(db.Model):
|
|||||||
time_slots: so.Mapped[list["TimeSlot"]] = so.relationship(
|
time_slots: so.Mapped[list["TimeSlot"]] = so.relationship(
|
||||||
back_populates="entry", cascade="all, delete-orphan", order_by="TimeSlot.start_time"
|
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:
|
def total_minutes(self) -> int:
|
||||||
"""
|
"""
|
||||||
@@ -86,6 +89,50 @@ class TimeSlot(db.Model):
|
|||||||
end_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")
|
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):
|
class LeaveBalance(db.Model):
|
||||||
|
|||||||
@@ -120,6 +120,14 @@ La base de données stocke l'activité saisie par l'utilisateur :
|
|||||||
- **`work_entries`** : Une ligne par jour saisi (date, type de jour, ID du trajet, ID du véhicule, commentaire, timestamps).
|
- **`work_entries`** : Une ligne par jour saisi (date, type de jour, ID du trajet, ID du véhicule, commentaire, timestamps).
|
||||||
- **`time_slots`** : Les plages horaires travaillées associées à une journée (heure de début, heure de fin, ID de l'entrée).
|
- **`time_slots`** : Les plages horaires travaillées associées à une journée (heure de début, heure de fin, ID de l'entrée).
|
||||||
- **`leave_balance`** : Les quotas annuels de congés et de RTT (année, total congés, total RTT).
|
- **`leave_balance`** : Les quotas annuels de congés et de RTT (année, total congés, total RTT).
|
||||||
|
- **`workplace_presence_events`** : Les événements reçus de Home Assistant, avec une clé d'idempotence unique et leurs liens vers une journée et, facultativement, une plage horaire.
|
||||||
|
|
||||||
|
Les événements de présence stockent `received_at` comme un timestamp UTC naïf,
|
||||||
|
conformément à la convention existante des métadonnées. `occurred_at` est différent :
|
||||||
|
il représente une heure locale Europe/Paris naïve, et `local_date` est le jour local
|
||||||
|
qui en est dérivé. Une arrivée non encore rattachée à une plage est identifiée par
|
||||||
|
`event_type = "arrival"` et `time_slot_id IS NULL`; la signification de
|
||||||
|
`processed_at` sera précisée par le service métier de l'étape suivante.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -9,3 +9,8 @@ def test_app_fixture_uses_one_in_memory_database_connection(app):
|
|||||||
assert str(db.engine.url) == "sqlite:///:memory:"
|
assert str(db.engine.url) == "sqlite:///:memory:"
|
||||||
assert isinstance(db.engine.pool, StaticPool)
|
assert isinstance(db.engine.pool, StaticPool)
|
||||||
assert sa.inspect(db.engine).has_table("work_entries")
|
assert sa.inspect(db.engine).has_table("work_entries")
|
||||||
|
|
||||||
|
|
||||||
|
def test_sqlite_foreign_keys_are_enabled(app):
|
||||||
|
with app.app_context():
|
||||||
|
assert db.session.scalar(sa.text("PRAGMA foreign_keys")) == 1
|
||||||
|
|||||||
111
tests/test_models.py
Normal file
111
tests/test_models.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
from datetime import date, datetime, time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
from app.models import TimeSlot, WorkEntry, WorkplacePresenceEvent
|
||||||
|
|
||||||
|
|
||||||
|
def make_entry() -> WorkEntry:
|
||||||
|
return WorkEntry(date=date(2026, 8, 13), day_type="WORK")
|
||||||
|
|
||||||
|
|
||||||
|
def make_event(entry: WorkEntry, key: str = "ha-arrival-1") -> WorkplacePresenceEvent:
|
||||||
|
return WorkplacePresenceEvent(
|
||||||
|
idempotency_key=key,
|
||||||
|
event_type="arrival",
|
||||||
|
received_at=datetime(2026, 8, 13, 6, 23, 10),
|
||||||
|
occurred_at=datetime(2026, 8, 13, 8, 23, 10),
|
||||||
|
local_date=date(2026, 8, 13),
|
||||||
|
entry=entry,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_presence_event_creation_and_relations(app):
|
||||||
|
with app.app_context():
|
||||||
|
entry = make_entry()
|
||||||
|
slot = TimeSlot(start_time=time(8), end_time=time(12), entry=entry)
|
||||||
|
event = make_event(entry)
|
||||||
|
event.time_slot = slot
|
||||||
|
db.session.add(entry)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
assert event.entry is entry
|
||||||
|
assert event in entry.presence_events
|
||||||
|
assert event.time_slot is slot
|
||||||
|
assert event in slot.presence_events
|
||||||
|
assert event.processed_at is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_idempotency_key_is_unique(app):
|
||||||
|
with app.app_context():
|
||||||
|
entry = make_entry()
|
||||||
|
db.session.add_all([entry, make_event(entry), make_event(entry, "ha-arrival-1")])
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
db.session.commit()
|
||||||
|
db.session.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_type_check_constraint(app):
|
||||||
|
with app.app_context():
|
||||||
|
entry = make_entry()
|
||||||
|
event = make_event(entry)
|
||||||
|
event.event_type = "unknown"
|
||||||
|
db.session.add_all([entry, event])
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
db.session.commit()
|
||||||
|
db.session.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
def test_time_slot_link_is_nullable_and_set_null_on_slot_delete(app):
|
||||||
|
with app.app_context():
|
||||||
|
entry = make_entry()
|
||||||
|
slot = TimeSlot(start_time=time(8), end_time=time(12), entry=entry)
|
||||||
|
event = make_event(entry)
|
||||||
|
event.time_slot = slot
|
||||||
|
db.session.add(entry)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
db.session.delete(slot)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
assert db.session.get(WorkplacePresenceEvent, event.id).time_slot_id is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_events_cascade_when_work_entry_is_deleted(app):
|
||||||
|
with app.app_context():
|
||||||
|
entry = make_entry()
|
||||||
|
db.session.add(make_event(entry))
|
||||||
|
db.session.commit()
|
||||||
|
event_id = entry.presence_events[0].id
|
||||||
|
|
||||||
|
db.session.delete(entry)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
assert db.session.get(WorkplacePresenceEvent, event_id) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_all_adds_presence_table_without_losing_existing_entries(app):
|
||||||
|
with app.app_context():
|
||||||
|
db.session.add(make_entry())
|
||||||
|
db.session.commit()
|
||||||
|
db.session.execute(sa.text("DROP TABLE workplace_presence_events"))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
db.create_all()
|
||||||
|
|
||||||
|
assert db.session.scalar(sa.select(sa.func.count()).select_from(WorkEntry)) == 1
|
||||||
|
assert sa.inspect(db.engine).has_table("workplace_presence_events")
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_all_creates_all_tables_on_empty_sqlite_database(app):
|
||||||
|
with app.app_context():
|
||||||
|
db.drop_all()
|
||||||
|
db.create_all()
|
||||||
|
|
||||||
|
inspector = sa.inspect(db.engine)
|
||||||
|
assert inspector.has_table("work_entries")
|
||||||
|
assert inspector.has_table("time_slots")
|
||||||
|
assert inspector.has_table("workplace_presence_events")
|
||||||
Reference in New Issue
Block a user