diff --git a/app/__init__.py b/app/__init__.py index 2a26f37..fbedfe9 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -24,6 +24,15 @@ from flask_sqlalchemy import 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): """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() 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["day_type_fr"] = _day_type_fr diff --git a/app/models.py b/app/models.py index 0f3238a..587f659 100644 --- a/app/models.py +++ b/app/models.py @@ -37,6 +37,9 @@ class WorkEntry(db.Model): 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: """ @@ -86,6 +89,50 @@ class TimeSlot(db.Model): 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): diff --git a/docs/onboarding.md b/docs/onboarding.md index 52f29a5..58f3fca 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -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). - **`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). +- **`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. --- diff --git a/tests/test_app_factory.py b/tests/test_app_factory.py index 2ab1ebe..b531b53 100644 --- a/tests/test_app_factory.py +++ b/tests/test_app_factory.py @@ -9,3 +9,8 @@ def test_app_fixture_uses_one_in_memory_database_connection(app): assert str(db.engine.url) == "sqlite:///:memory:" assert isinstance(db.engine.pool, StaticPool) 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 diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..ec1f31b --- /dev/null +++ b/tests/test_models.py @@ -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")