Files
tableau-de-bord/app/__init__.py
Antoine Van Elstraete e9f73c7562 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>
2026-08-13 17:36:26 +02:00

213 lines
7.6 KiB
Python

"""Package principal de l'application Flask.
Ce package initialise l'application Flask en utilisant le "Factory Pattern" (via la fonction `create_app`).
Il configure également la base de données SQLite (via Flask-SQLAlchemy), charge la configuration TOML,
enregistre les blueprints de routes, applique les migrations de schéma manuelles, et définit les filtres Jinja2 personnalisés.
Architecture et composants clés :
1. Factory Pattern : `create_app` permet d'instancier l'application de manière isolée, facilitant les tests.
2. Base de données : SQLite stockée dans `instance/worklog.db`.
3. Migration de schéma : Gérée manuellement par `_migrate_db` sans utiliser Alembic.
4. Filtres Jinja2 :
- `date_fr` : Formate une date Python en chaîne lisible en français (ex: "mercredi 11 mars 2026").
- `day_type_fr` : Traduit les codes internes des types de jours (ex: "WORK" -> "Travail").
"""
import os
import tomllib
from collections.abc import Mapping
import sqlalchemy as sa
from flask import Flask
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).
Cette fonction vérifie l'état actuel de la base de données SQLite avant d'exécuter
des instructions DDL (comme `ALTER TABLE`). Elle permet d'éviter les erreurs si la table
`work_entries` n'existe pas encore (auquel cas `db.create_all()` s'en charge) ou si la
colonne `motor_vehicle_id` a déjà été ajoutée lors d'un démarrage précédent.
Paramètres:
app (Flask): L'instance de l'application Flask en cours d'initialisation.
"""
import sqlite3
with app.app_context():
if db.engine.url.database in (None, ":memory:"):
return # Une base mémoire est initialisée par create_all().
db_path = os.path.join(app.instance_path, "worklog.db")
if not os.path.exists(db_path):
return # Nouvelle DB, create_all() s'en charge
conn = sqlite3.connect(db_path)
tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
if "work_entries" not in tables:
conn.close()
return # Table pas encore créée, create_all() s'en charge
columns = {row[1] for row in conn.execute("PRAGMA table_info(work_entries)").fetchall()}
conn.close()
with app.app_context():
engine = db.engine
if "motor_vehicle_id" not in columns:
with engine.connect() as conn:
conn.execute(
sa.text("ALTER TABLE work_entries ADD COLUMN motor_vehicle_id VARCHAR(64)")
)
conn.commit()
_JOURS_FR = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"]
_MOIS_FR = [
"",
"janvier",
"février",
"mars",
"avril",
"mai",
"juin",
"juillet",
"août",
"septembre",
"octobre",
"novembre",
"décembre",
]
_DAY_TYPE_LABELS = {
"WORK": "Travail",
"TT": "Télétravail",
"GARDE": "Garde",
"ASTREINTE": "Astreinte",
"FORMATION": "Formation",
"RTT": "RTT",
"CONGE": "Congé",
"MALADE": "Maladie",
"FERIE": "Férié",
}
def _day_type_fr(code):
"""Filtre Jinja2 pour traduire un code de type de jour en libellé français.
Exemple:
`{{ 'WORK' | day_type_fr }}` -> "Travail"
Paramètres:
code (str): Le code interne du type de jour (ex: "WORK", "TT", "GARDE").
Retourne:
str: Le libellé en français correspondant, ou le code d'origine si aucune traduction n'est définie.
"""
return _DAY_TYPE_LABELS.get(code, code)
def _date_fr(d):
"""Filtre Jinja2 pour formater une date en français lisible.
Exemple:
`{{ entry.date | date_fr }}` -> "mercredi 11 mars 2026"
Paramètres:
d (datetime.date): L'objet date à formater.
Retourne:
str: La date formatée en français (jour de la semaine, jour du mois, mois en toutes lettres, année).
"""
jour = _JOURS_FR[d.weekday()]
mois = _MOIS_FR[d.month]
return f"{jour} {d.day} {mois} {d.year}"
def create_app(
config_path: str | None = None,
*,
database_uri: str | None = None,
engine_options: Mapping[str, object] | None = None,
) -> Flask:
"""Factory de création et de configuration de l'application Flask.
Cette fonction réalise les étapes suivantes :
1. Instancie l'application Flask avec le support des configurations relatives à l'instance.
2. Crée le dossier d'instance s'il n'existe pas.
3. Configure l'URI de la base de données SQLite (`instance/worklog.db`).
4. Charge la configuration TOML depuis `config.toml` (ou le chemin spécifié).
5. Initialise l'extension Flask-SQLAlchemy (`db`).
6. Enregistre les filtres Jinja2 personnalisés (`date_fr` et `day_type_fr`).
7. Enregistre les blueprints de routes (`dashboard`, `entries`, `reports`).
8. Exécute la migration de schéma manuelle (`_migrate_db`) puis crée les tables manquantes (`db.create_all()`).
Paramètres:
config_path (str | None): Chemin optionnel vers le fichier de configuration TOML.
Par défaut, cherche `config.toml` à la racine du projet.
database_uri (str | None): URI SQLAlchemy à utiliser à la place de la base SQLite
de l'instance. Cette option est appliquée avant l'initialisation
de Flask-SQLAlchemy.
engine_options (Mapping[str, object] | None): Options SQLAlchemy appliquées avant
l'initialisation de Flask-SQLAlchemy.
Retourne:
Flask: L'instance de l'application Flask configurée et prête à l'emploi.
"""
app = Flask(__name__, instance_relative_config=True)
os.makedirs(app.instance_path, exist_ok=True)
if database_uri is None:
database_uri = f"sqlite:///{os.path.join(app.instance_path, 'worklog.db')}"
app.config["SQLALCHEMY_DATABASE_URI"] = database_uri
if engine_options is not None:
app.config["SQLALCHEMY_ENGINE_OPTIONS"] = engine_options
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-change-in-prod")
# Load TOML config
if config_path is None:
config_path = os.path.join(os.path.dirname(app.root_path), "config.toml")
if os.path.exists(config_path):
with open(config_path, "rb") as f:
app.config["TOML"] = tomllib.load(f)
else:
app.config["TOML"] = {}
from app.config_loader import get_home_assistant_config
with app.app_context():
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
from app.routes.dashboard import bp as dashboard_bp
from app.routes.entries import bp as entries_bp
from app.routes.reports import bp as reports_bp
app.register_blueprint(dashboard_bp)
app.register_blueprint(entries_bp)
app.register_blueprint(reports_bp)
with app.app_context():
_migrate_db(app)
db.create_all()
return app