feat(saints-dictons): table saint_du_jour + API + import standalone 366j

- Nouveau modèle SaintDuJour (mois+jour+saints_json, indépendant de l'année)
- Router /api/saints et /api/saints/jour (mois+jour → liste de prénoms)
- Script standalone import_webapp_db.py : saints_du_jour.json → saint_du_jour,
  dictons_du_jour.json → dicton ; modes replace/append, --dry-run, --region
- Données JSON 366 jours : saints_du_jour.json + dictons_du_jour.json
- Scripts scraping/export calendrier_lunaire/saints_dictons/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-22 19:54:47 +01:00
parent a9f0556d73
commit 0d3bf205b1
9 changed files with 8886 additions and 0 deletions

View File

@@ -49,6 +49,7 @@ from app.routers import ( # noqa
media,
tools,
dictons,
saints,
astuces,
recoltes,
lunar,
@@ -64,6 +65,7 @@ app.include_router(settings.router, prefix="/api")
app.include_router(media.router, prefix="/api")
app.include_router(tools.router, prefix="/api")
app.include_router(dictons.router, prefix="/api")
app.include_router(saints.router, prefix="/api")
app.include_router(astuces.router, prefix="/api")
app.include_router(recoltes.router, prefix="/api")
app.include_router(lunar.router, prefix="/api")

View File

@@ -9,3 +9,4 @@ from app.models.dicton import Dicton # noqa
from app.models.astuce import Astuce # noqa
from app.models.recolte import Recolte, Observation # noqa
from app.models.meteo import MeteoStation, MeteoOpenMeteo # noqa
from app.models.saint import SaintDuJour # noqa

View File

@@ -0,0 +1,14 @@
from typing import Optional
from sqlmodel import Field, SQLModel
class SaintDuJour(SQLModel, table=True):
"""Saints fêtés pour un jour donné (indépendant de l'année)."""
__tablename__ = "saint_du_jour"
id: Optional[int] = Field(default=None, primary_key=True)
mois: int = Field(index=True) # 1-12
jour: int = Field(index=True) # 1-31
saints_json: str = Field(default="[]") # JSON array : ["St-Basile", "St-Grégoire", ...]
source_url: Optional[str] = None # URL source de scraping

View File

@@ -0,0 +1,57 @@
"""Router saints — consultation des saints du jour (indépendant de l'année)."""
import json
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, Query
from sqlmodel import Session, select
from app.database import get_session
from app.models.saint import SaintDuJour
router = APIRouter(tags=["saints"])
@router.get("/saints", response_model=List[SaintDuJour])
def list_saints(
mois: Optional[int] = Query(None, ge=1, le=12),
jour: Optional[int] = Query(None, ge=1, le=31),
session: Session = Depends(get_session),
):
"""Liste les saints. Filtrer par mois et/ou jour."""
q = select(SaintDuJour)
if mois is not None:
q = q.where(SaintDuJour.mois == mois)
if jour is not None:
q = q.where(SaintDuJour.jour == jour)
q = q.order_by(SaintDuJour.mois, SaintDuJour.jour)
return session.exec(q).all()
@router.get("/saints/jour", response_model=dict)
def get_saints_du_jour(
mois: int = Query(..., ge=1, le=12),
jour: int = Query(..., ge=1, le=31),
session: Session = Depends(get_session),
) -> dict[str, Any]:
"""Retourne les saints et leur liste parsée pour un jour précis."""
row = session.exec(
select(SaintDuJour).where(
SaintDuJour.mois == mois,
SaintDuJour.jour == jour,
)
).first()
if not row:
return {"mois": mois, "jour": jour, "saints": []}
try:
saints_list = json.loads(row.saints_json)
except (json.JSONDecodeError, TypeError):
saints_list = []
return {
"mois": row.mois,
"jour": row.jour,
"saints": saints_list,
"source_url": row.source_url,
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,113 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from pathlib import Path
def _parse_mmdd(mmdd: str) -> tuple[int, int]:
if len(mmdd) != 4 or not mmdd.isdigit():
raise ValueError(f"MMDD invalide: {mmdd}")
month = int(mmdd[:2])
day = int(mmdd[2:])
if not (1 <= month <= 12 and 1 <= day <= 31):
raise ValueError(f"MMDD hors plage: {mmdd}")
return month, day
def _as_list(value: object) -> list[str]:
if not value:
return []
if isinstance(value, list):
out: list[str] = []
for item in value:
txt = str(item).strip()
if txt and txt not in out:
out.append(txt)
return out
txt = str(value).strip()
return [txt] if txt else []
def export_files(source_file: Path, saints_out: Path, dictons_out: Path) -> tuple[int, int]:
source = json.loads(source_file.read_text(encoding="utf-8"))
rows = source.get("data", [])
saints_rows: list[dict] = []
dictons_rows: list[dict] = []
for row in rows:
mmdd = str(row.get("mmdd", "")).strip()
if not mmdd:
continue
try:
month, day = _parse_mmdd(mmdd)
except ValueError:
continue
saints = _as_list(row.get("saints"))
dictons = _as_list(row.get("dictons"))
source_url = row.get("source_url")
if saints:
saints_rows.append(
{
"mois": month,
"jour": day,
"saints": saints,
"source_url": source_url,
}
)
if dictons:
dictons_rows.append(
{
"mois": month,
"jour": day,
"dictons": dictons,
"source_url": source_url,
}
)
saints_rows.sort(key=lambda r: (r["mois"], r["jour"]))
dictons_rows.sort(key=lambda r: (r["mois"], r["jour"]))
saints_out.parent.mkdir(parents=True, exist_ok=True)
dictons_out.parent.mkdir(parents=True, exist_ok=True)
saints_out.write_text(json.dumps(saints_rows, ensure_ascii=False, indent=2), encoding="utf-8")
dictons_out.write_text(json.dumps(dictons_rows, ensure_ascii=False, indent=2), encoding="utf-8")
return len(saints_rows), len(dictons_rows)
def main() -> int:
parser = argparse.ArgumentParser(
description="Génère saints_du_jour.json et dictons_du_jour.json depuis saints_YYYY.json."
)
parser.add_argument(
"--source",
default="calendrier_lunaire/saints_dictons/saints_2026.json",
help="Source JSON issue du scraper annuel",
)
parser.add_argument(
"--saints-out",
default="calendrier_lunaire/saints_dictons/saints_du_jour.json",
help="Fichier JSON de sortie pour les saints",
)
parser.add_argument(
"--dictons-out",
default="calendrier_lunaire/saints_dictons/dictons_du_jour.json",
help="Fichier JSON de sortie pour les dictons",
)
args = parser.parse_args()
source_file = Path(args.source)
saints_out = Path(args.saints_out)
dictons_out = Path(args.dictons_out)
saints_count, dictons_count = export_files(source_file, saints_out, dictons_out)
print(f"Saints exportés : {saints_count} jours -> {saints_out}")
print(f"Dictons exportés : {dictons_count} jours -> {dictons_out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,191 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
def _ensure_schema(conn: sqlite3.Connection) -> None:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS saint_du_jour (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mois INTEGER NOT NULL,
jour INTEGER NOT NULL,
saints_json TEXT NOT NULL,
source_url TEXT,
updated_at TEXT NOT NULL,
UNIQUE(mois, jour)
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS dicton (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mois INTEGER NOT NULL,
jour INTEGER,
texte TEXT NOT NULL,
region TEXT
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_dicton_mois_jour ON dicton(mois, jour)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_saint_du_jour_mois_jour ON saint_du_jour(mois, jour)")
def _load_json_rows(path: Path, required_key: str) -> list[dict]:
raw = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(raw, list):
raise ValueError(f"{path}: format JSON attendu = liste d'objets")
rows: list[dict] = []
for item in raw:
if not isinstance(item, dict):
continue
if required_key not in item:
continue
rows.append(item)
return rows
def _import_saints(conn: sqlite3.Connection, saints_rows: list[dict], mode: str) -> int:
now = datetime.now(timezone.utc).isoformat()
inserted = 0
if mode == "replace":
conn.execute("DELETE FROM saint_du_jour")
for row in saints_rows:
mois = int(row["mois"])
jour = int(row["jour"])
saints = row.get("saints") or []
if not isinstance(saints, list):
saints = [str(saints)]
saints = [str(x).strip() for x in saints if str(x).strip()]
if not saints:
continue
saints_json = json.dumps(saints, ensure_ascii=False)
source_url = row.get("source_url")
conn.execute(
"""
INSERT INTO saint_du_jour (mois, jour, saints_json, source_url, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(mois, jour)
DO UPDATE SET
saints_json = excluded.saints_json,
source_url = excluded.source_url,
updated_at = excluded.updated_at
""",
(mois, jour, saints_json, source_url, now),
)
inserted += 1
return inserted
def _import_dictons(conn: sqlite3.Connection, dicton_rows: list[dict], mode: str, region: str | None) -> int:
inserted = 0
if mode == "replace":
if region:
conn.execute("DELETE FROM dicton WHERE region = ?", (region,))
else:
conn.execute("DELETE FROM dicton")
for row in dicton_rows:
mois = int(row["mois"])
jour = int(row["jour"])
dictons = row.get("dictons") or []
if not isinstance(dictons, list):
dictons = [str(dictons)]
dictons = [str(x).strip() for x in dictons if str(x).strip()]
if not dictons:
continue
for texte in dictons:
if mode == "append":
exists = conn.execute(
"""
SELECT 1
FROM dicton
WHERE mois = ? AND jour = ? AND texte = ? AND COALESCE(region, '') = COALESCE(?, '')
LIMIT 1
""",
(mois, jour, texte, region),
).fetchone()
if exists:
continue
conn.execute(
"INSERT INTO dicton (mois, jour, texte, region) VALUES (?, ?, ?, ?)",
(mois, jour, texte, region),
)
inserted += 1
return inserted
def main() -> int:
parser = argparse.ArgumentParser(
description="Importe saints du jour + dictons dans SQLite (hors webapp)."
)
parser.add_argument(
"--db",
default="data/jardin.db",
help="Chemin SQLite cible",
)
parser.add_argument(
"--saints-json",
default="calendrier_lunaire/saints_dictons/saints_du_jour.json",
help="JSON saints_du_jour",
)
parser.add_argument(
"--dictons-json",
default="calendrier_lunaire/saints_dictons/dictons_du_jour.json",
help="JSON dictons_du_jour",
)
parser.add_argument(
"--mode",
choices=["replace", "append"],
default="replace",
help="replace: purge puis recharge ; append: ajoute sans doublons",
)
parser.add_argument(
"--region",
default="National",
help="Région stockée dans table dicton (vide pour NULL)",
)
args = parser.parse_args()
db_path = Path(args.db)
saints_path = Path(args.saints_json)
dictons_path = Path(args.dictons_json)
region = args.region.strip() or None
saints_rows = _load_json_rows(saints_path, "saints")
dicton_rows = _load_json_rows(dictons_path, "dictons")
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path)
try:
_ensure_schema(conn)
conn.execute("BEGIN")
saints_count = _import_saints(conn, saints_rows, args.mode)
dictons_count = _import_dictons(conn, dicton_rows, args.mode, region)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
print(f"Import terminé ({args.mode})")
print(f" Saints importés: {saints_count}")
print(f" Dictons importés: {dictons_count} (region={region!r})")
print(f" Base: {db_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,307 @@
#!/usr/bin/env python3
"""
Import des saints et dictons dans la base de données de la webapp Jardin.
Les données sont INDÉPENDANTES DE L'ANNÉE : stockées par (mois, jour) uniquement.
Sources JSON :
- saints_du_jour.json → table saint_du_jour
- dictons_du_jour.json → table dicton
Usage :
# Import complet (remplace les données existantes)
python import_webapp_db.py
# Import vers une autre DB
python import_webapp_db.py --db /chemin/vers/jardin.db
# Mode append (ajoute sans supprimer les existants)
python import_webapp_db.py --mode append
# Seulement les saints ou seulement les dictons
python import_webapp_db.py --only saints
python import_webapp_db.py --only dictons
# Tagger les dictons avec une région
python import_webapp_db.py --region "Auvergne"
# Prévisualisation sans modification
python import_webapp_db.py --dry-run
"""
import argparse
import json
import sqlite3
import sys
from datetime import datetime, timezone
from pathlib import Path
# Chemins par défaut relatifs à ce script
_HERE = Path(__file__).parent
_REPO_ROOT = _HERE.parent.parent # racine du projet jardin/
DEFAULT_DB = _REPO_ROOT / "data" / "jardin.db"
DEFAULT_SAINTS_JSON = _HERE / "saints_du_jour.json"
DEFAULT_DICTONS_JSON = _HERE / "dictons_du_jour.json"
# ─────────────────────────────────────────────
# Schémas SQL attendus par la webapp
# ─────────────────────────────────────────────
SQL_CREATE_SAINT_DU_JOUR = """
CREATE TABLE IF NOT EXISTS saint_du_jour (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mois INTEGER NOT NULL,
jour INTEGER NOT NULL,
saints_json TEXT NOT NULL DEFAULT '[]',
source_url TEXT,
UNIQUE (mois, jour)
)
"""
SQL_CREATE_INDEX_SAINT = """
CREATE INDEX IF NOT EXISTS idx_saint_du_jour_mois_jour
ON saint_du_jour (mois, jour)
"""
# La table dicton existe déjà dans la webapp (SQLModel).
# Colonnes: id, mois, jour, texte, region
# On s'assure juste qu'elle existe (ne recrée pas si déjà présente).
SQL_CREATE_DICTON = """
CREATE TABLE IF NOT EXISTS dicton (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mois INTEGER NOT NULL,
jour INTEGER,
texte TEXT NOT NULL,
region TEXT
)
"""
SQL_CREATE_INDEX_DICTON = """
CREATE INDEX IF NOT EXISTS idx_dicton_mois_jour ON dicton (mois, jour)
"""
# ─────────────────────────────────────────────
# Chargement JSON
# ─────────────────────────────────────────────
def load_json(path: Path, label: str) -> list:
if not path.exists():
print(f"[ERREUR] Fichier introuvable : {path}", file=sys.stderr)
sys.exit(1)
with path.open(encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, list):
print(f"[ERREUR] {label} : attendu une liste JSON, obtenu {type(data).__name__}", file=sys.stderr)
sys.exit(1)
print(f" {label} : {len(data)} entrées chargées depuis {path.name}")
return data
# ─────────────────────────────────────────────
# Import saints
# ─────────────────────────────────────────────
def import_saints(conn: sqlite3.Connection, data: list, mode: str, dry_run: bool) -> int:
"""Importe les saints dans saint_du_jour. Retourne le nombre d'entrées traitées."""
conn.execute(SQL_CREATE_SAINT_DU_JOUR)
conn.execute(SQL_CREATE_INDEX_SAINT)
if mode == "replace" and not dry_run:
conn.execute("DELETE FROM saint_du_jour")
print(" [replace] saint_du_jour vidée")
count = 0
skipped = 0
for entry in data:
mois = entry.get("mois")
jour = entry.get("jour")
saints = entry.get("saints", [])
source_url = entry.get("source_url")
# Validation
if not isinstance(mois, int) or not isinstance(jour, int):
continue
if not (1 <= mois <= 12 and 1 <= jour <= 31):
continue
# Normaliser en liste de strings non vides
if isinstance(saints, str):
saints = [saints]
saints = [s.strip() for s in saints if isinstance(s, str) and s.strip()]
saints_json = json.dumps(saints, ensure_ascii=False)
if dry_run:
count += 1
continue
if mode == "append":
existing = conn.execute(
"SELECT id FROM saint_du_jour WHERE mois=? AND jour=?", (mois, jour)
).fetchone()
if existing:
skipped += 1
continue
conn.execute(
"INSERT OR REPLACE INTO saint_du_jour (mois, jour, saints_json, source_url) VALUES (?,?,?,?)",
(mois, jour, saints_json, source_url),
)
count += 1
if skipped:
print(f" saints ignorés (mode append, déjà présents) : {skipped}")
return count
# ─────────────────────────────────────────────
# Import dictons
# ─────────────────────────────────────────────
def import_dictons(conn: sqlite3.Connection, data: list, mode: str, region: str | None, dry_run: bool) -> int:
"""Importe les dictons dans la table dicton. Retourne le nombre d'entrées insérées."""
conn.execute(SQL_CREATE_DICTON)
conn.execute(SQL_CREATE_INDEX_DICTON)
if mode == "replace" and not dry_run:
conn.execute("DELETE FROM dicton")
print(" [replace] dicton vidée")
count = 0
for entry in data:
mois = entry.get("mois")
jour = entry.get("jour") # peut être None
dictons = entry.get("dictons", [])
# Validation
if not isinstance(mois, int):
continue
if not (1 <= mois <= 12):
continue
if jour is not None and not (1 <= jour <= 31):
continue
# Normaliser
if isinstance(dictons, str):
dictons = [dictons]
dictons = [d.strip() for d in dictons if isinstance(d, str) and d.strip()]
for texte in dictons:
if dry_run:
count += 1
continue
if mode == "append":
existing = conn.execute(
"SELECT id FROM dicton WHERE mois=? AND jour IS ? AND texte=?",
(mois, jour, texte),
).fetchone()
if existing:
continue
conn.execute(
"INSERT INTO dicton (mois, jour, texte, region) VALUES (?,?,?,?)",
(mois, jour, texte, region),
)
count += 1
return count
# ─────────────────────────────────────────────
# Point d'entrée
# ─────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="Import saints et dictons dans la BDD webapp Jardin (indépendant de l'année)"
)
parser.add_argument("--db", default=str(DEFAULT_DB),
help=f"Chemin vers jardin.db (défaut: {DEFAULT_DB})")
parser.add_argument("--saints-json", default=str(DEFAULT_SAINTS_JSON),
help=f"Fichier saints_du_jour.json (défaut: {DEFAULT_SAINTS_JSON.name})")
parser.add_argument("--dictons-json", default=str(DEFAULT_DICTONS_JSON),
help=f"Fichier dictons_du_jour.json (défaut: {DEFAULT_DICTONS_JSON.name})")
parser.add_argument("--mode", choices=["replace", "append"], default="replace",
help="replace = purge + recharge (défaut) | append = ajoute sans doublons")
parser.add_argument("--only", choices=["saints", "dictons", "both"], default="both",
help="Importer uniquement saints, dictons, ou les deux (défaut: both)")
parser.add_argument("--region", default=None,
help="Tag région pour les dictons (ex: 'Auvergne', 'National'). Vide = NULL")
parser.add_argument("--dry-run", action="store_true",
help="Simulation : affiche les comptages sans modifier la BDD")
args = parser.parse_args()
db_path = Path(args.db)
if not db_path.exists():
print(f"[ERREUR] Base de données introuvable : {db_path}", file=sys.stderr)
print(" → Vérifiez que la webapp a démarré au moins une fois pour créer la BDD.")
sys.exit(1)
region = args.region if args.region else None
do_saints = args.only in ("saints", "both")
do_dictons = args.only in ("dictons", "both")
print(f"\n=== Import saints/dictons → {db_path} ===")
print(f" Mode : {args.mode}")
print(f" Scope : {args.only}")
if region:
print(f" Région: {region}")
if args.dry_run:
print(" *** DRY-RUN — aucune modification en BDD ***")
print()
# Charger les JSON nécessaires
saints_data = load_json(Path(args.saints_json), "saints_du_jour") if do_saints else []
dictons_data = load_json(Path(args.dictons_json), "dictons_du_jour") if do_dictons else []
print()
# Connexion SQLite
conn = sqlite3.connect(str(db_path))
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=OFF") # pas de cascade gestion manuelle
saints_count = 0
dictons_count = 0
try:
conn.execute("BEGIN")
if do_saints:
saints_count = import_saints(conn, saints_data, args.mode, args.dry_run)
if do_dictons:
dictons_count = import_dictons(conn, dictons_data, args.mode, region, args.dry_run)
if not args.dry_run:
conn.commit()
else:
conn.rollback()
except Exception as exc:
conn.rollback()
print(f"\n[ERREUR] Transaction annulée : {exc}", file=sys.stderr)
raise
finally:
conn.close()
# Résumé
print()
print("=== Résultat ===")
if do_saints:
prefix = "[DRY-RUN] " if args.dry_run else ""
print(f" {prefix}Saints importés : {saints_count} jours → table saint_du_jour")
if do_dictons:
prefix = "[DRY-RUN] " if args.dry_run else ""
print(f" {prefix}Dictons importés : {dictons_count} entrées → table dicton")
print(f" Base : {db_path}")
if args.dry_run:
print("\n → Relancez sans --dry-run pour appliquer les changements.")
else:
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
print(f"\n Import terminé le {ts}")
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff