Compare commits
1 Commits
tests-sqli
...
e9f73c7562
| Author | SHA1 | Date | |
|---|---|---|---|
| e9f73c7562 |
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
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