Compare commits
5 Commits
55387f4b0e
...
9db5cbf236
| Author | SHA1 | Date | |
|---|---|---|---|
| 9db5cbf236 | |||
| fb33540bb0 | |||
| 155de270dc | |||
| 0d3bf205b1 | |||
| a9f0556d73 |
@@ -65,7 +65,17 @@
|
||||
"Bash(find:*)",
|
||||
"Bash(.venv/bin/python:*)",
|
||||
"Bash(.venv/bin/pip install:*)",
|
||||
"Bash(grep:*)"
|
||||
"Bash(grep:*)",
|
||||
"Bash(wc:*)",
|
||||
"Bash(DB=/home/gilles/Documents/vscode/jardin/data/jardin.db)",
|
||||
"Bash(__NEW_LINE_c71d992d355b0a42__ echo \"=== Comptages ===\")",
|
||||
"Bash(__NEW_LINE_c71d992d355b0a42__ echo \"\")",
|
||||
"Bash(__NEW_LINE_bc37df477a517ffd__ echo \"\")",
|
||||
"Bash(__NEW_LINE_cef0a7fc7759860e__ echo \"\")",
|
||||
"Bash(docker compose restart:*)",
|
||||
"Bash(docker compose build:*)",
|
||||
"Bash(__NEW_LINE_5f780afd9b58590d__ echo \"\")",
|
||||
"Read(//home/gilles/.claude/projects/-home-gilles-Documents-vscode-jardin/**)"
|
||||
],
|
||||
"additionalDirectories": [
|
||||
"/home/gilles/Documents/vscode/jardin/frontend/src",
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# 🌿 Jardin — Application de gestion de jardins
|
||||
|
||||
Interface web **mobile-first** pour gérer jardins, cultures, tâches et calendrier lunaire, avec détection d'espèces via IA.
|
||||
Thème visuel : **Gruvbox Dark Seventies**.
|
||||
|
||||
## 🏗️ Architecture du Projet
|
||||
|
||||
Le projet est composé de trois services principaux orchestrés par Docker Compose :
|
||||
|
||||
1. **Backend (FastAPI)** : API REST gérant la logique métier, la base de données (SQLite/SQLModel) et l'intégration des services (lunaire, météo).
|
||||
2. **Frontend (Vue 3)** : Interface utilisateur réactive avec Vite, Pinia pour le store, et Tailwind CSS pour le style.
|
||||
3. **AI Service (FastAPI + YOLO)** : Service spécialisé dans la détection et classification de plantes via un modèle YOLOv8 (`ultralytics`).
|
||||
4. **Redis** : Utilisé pour le cache et les tâches planifiées.
|
||||
|
||||
## 🚀 Démarrage Rapide
|
||||
|
||||
### Avec Docker (Recommandé)
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up --build
|
||||
```
|
||||
- **Application** : [http://localhost:8061](http://localhost:8061)
|
||||
- **API Documentation (Swagger)** : [http://localhost:8060/docs](http://localhost:8060/docs)
|
||||
|
||||
### Développement Local
|
||||
|
||||
#### Backend
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
# Variables d'env par défaut pour le dev local
|
||||
export DATABASE_URL=sqlite:///./data/jardin.db
|
||||
export UPLOAD_DIR=./data/uploads
|
||||
uvicorn app.main:app --reload --port 8060
|
||||
```
|
||||
|
||||
#### Frontend
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev -- --port 8061
|
||||
```
|
||||
|
||||
#### AI Service
|
||||
```bash
|
||||
cd ai-service
|
||||
pip install -r requirements.txt
|
||||
uvicorn main:app --reload --port 8070
|
||||
```
|
||||
|
||||
## 🛠️ Commandes de Test et Qualité
|
||||
|
||||
### Backend
|
||||
```bash
|
||||
cd backend
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
### Frontend
|
||||
```bash
|
||||
cd frontend
|
||||
npm run lint # Vérification TypeScript (vue-tsc)
|
||||
```
|
||||
|
||||
## 📋 Conventions de Développement
|
||||
|
||||
- **Langue** : Code en anglais (variables, fonctions, routes), commentaires et documentation en français.
|
||||
- **Style Backend** : PEP 8. Utilisation de `SQLModel` pour les modèles de données (union de SQLAlchemy et Pydantic).
|
||||
- **Style Frontend** : Composition API (Vue 3). Utilisation de TypeScript obligatoire. Tailwind CSS pour le styling atomique.
|
||||
- **API** : Préfixe `/api` pour tous les endpoints. Documentation automatique via Swagger.
|
||||
- **Base de données** : SQLite par défaut pour la simplicité et la portabilité (située dans `data/jardin.db`).
|
||||
- **Media** : Les images uploadées sont stockées dans `data/uploads/` et servies via `/uploads`.
|
||||
|
||||
## 📂 Structure des Données (Modèles SQLModel)
|
||||
|
||||
- `Garden` : Jardins (nom, dimensions, exposition, géolocalisation).
|
||||
- `Plant` : Bibliothèque de plantes (nom, famille, exigences).
|
||||
- `Variety` : Variétés spécifiques de plantes.
|
||||
- `Planting` : Instances de plantation dans un jardin (date, état, position).
|
||||
- `Task` : Tâches à accomplir (arrosage, taille, etc.).
|
||||
- `Settings` : Paramètres utilisateur (lat/long pour météo/lune).
|
||||
- `Meteo` : Données météo locales et prévisions.
|
||||
- `Lunar` : Calculs de phases et conseils lunaires.
|
||||
|
||||
## 🤖 Service IA
|
||||
|
||||
Le service de détection utilise le modèle `foduucom/plant-leaf-detection-and-classification` via YOLOv8.
|
||||
Endpoint : `POST /detect` acceptant une image.
|
||||
Il est intégré au backend via le router `identify`.
|
||||
@@ -82,3 +82,5 @@ bibliotehque photo:
|
||||
- [ ] methode simple pour mettre a jours la base de donnée ; brainstorming
|
||||
|
||||
- [ ] mise a jours bdd via api puis je peut ajouter des script dans mon openclaw]
|
||||
|
||||
- [ ] ajouter des etoiles 1 à 5 si j'ai été satisfait de la plante
|
||||
@@ -7457,3 +7457,43 @@ You've hit your limit · resets 7pm (Europe/Paris)
|
||||
- Note tests backend:
|
||||
- `pytest backend/tests/test_tools.py` reste bloque dans ce contexte d'execution,
|
||||
mais les changements de schema/code compilent et la colonne DB est presente.
|
||||
|
||||
## Mise a jour Codex - 2026-02-22 (Planning, Settings, Saints/Dictons, Outils)
|
||||
|
||||
### Planning (frontend)
|
||||
- Fichier: `frontend/src/views/PlanningView.vue`
|
||||
- Vue planning etendue a 4 semaines (28 jours)
|
||||
- Navigation par boutons `Prev`, `Today`, `Next`
|
||||
- Selection d'une case/jour avec panneau "Detail du jour"
|
||||
- Ajout de marqueurs visuels (petits ronds colores) dans les cases pour signaler les taches non terminees
|
||||
|
||||
### Outils: notice en texte libre
|
||||
- Fichier: `frontend/src/views/OutilsView.vue`
|
||||
- Remplacement du champ "notice fichier texte" par une zone de texte (`notice_texte`)
|
||||
- Affichage de la notice texte sur la carte outil
|
||||
- Compatibilite conservee pour l'existant (`notice_fichier_url` en fallback)
|
||||
- Test backend ajoute:
|
||||
- `backend/tests/test_tools.py::test_tool_with_notice_texte`
|
||||
|
||||
### Settings: backup ZIP + test API backend
|
||||
- Backend:
|
||||
- `backend/app/routers/settings.py`
|
||||
- nouvel endpoint `GET /api/settings/backup/download`
|
||||
- archive ZIP contenant: base SQLite, uploads (images/videos), fichiers texte utiles, `manifest.json`
|
||||
- Frontend:
|
||||
- `frontend/src/api/settings.ts`: `downloadBackup()`
|
||||
- `frontend/src/views/ReglagesView.vue`:
|
||||
- bouton "Telecharger la sauvegarde (.zip)"
|
||||
- section "Test API backend" avec liens rapides:
|
||||
- `/docs`, `/redoc`, `/api/health`
|
||||
|
||||
### Saints / dictons (hors webapp)
|
||||
- Dossier: `calendrier_lunaire/saints_dictons/`
|
||||
- Fichiers JSON generes:
|
||||
- `saints_du_jour.json`
|
||||
- `dictons_du_jour.json`
|
||||
- Scripts ajoutes:
|
||||
- `export_saints_dictons_json.py` (source `saints_2026.json` -> 2 JSON separes)
|
||||
- `import_saints_dictons_db.py` (import SQLite `replace`/`append`)
|
||||
- Import teste sur base temporaire:
|
||||
- resultat: `366` jours de saints + `366` dictons importes
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -40,6 +40,7 @@ EXPECTED_COLUMNS: dict[str, list[tuple[str, str, str | None]]] = {
|
||||
"tool": [
|
||||
("photo_url", "TEXT", None),
|
||||
("video_url", "TEXT", None),
|
||||
("notice_texte", "TEXT", None),
|
||||
("notice_fichier_url", "TEXT", None),
|
||||
("boutique_nom", "TEXT", None),
|
||||
("boutique_url", "TEXT", None),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -10,6 +10,7 @@ class Tool(SQLModel, table=True):
|
||||
categorie: Optional[str] = None # beche|fourche|griffe|arrosage|taille|autre
|
||||
photo_url: Optional[str] = None
|
||||
video_url: Optional[str] = None
|
||||
notice_texte: Optional[str] = None
|
||||
notice_fichier_url: Optional[str] = None
|
||||
boutique_nom: Optional[str] = None
|
||||
boutique_url: Optional[str] = None
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import os
|
||||
import unicodedata
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, File, HTTPException, Query, UploadFile, status
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import Session, select
|
||||
|
||||
@@ -16,9 +17,91 @@ class MediaPatch(BaseModel):
|
||||
entity_id: Optional[int] = None
|
||||
titre: Optional[str] = None
|
||||
|
||||
|
||||
CANONICAL_ENTITY_TYPES = {
|
||||
"jardin",
|
||||
"plante",
|
||||
"adventice",
|
||||
"outil",
|
||||
"plantation",
|
||||
"bibliotheque",
|
||||
}
|
||||
|
||||
ENTITY_TYPE_ALIASES = {
|
||||
# Canonique
|
||||
"jardin": "jardin",
|
||||
"plante": "plante",
|
||||
"adventice": "adventice",
|
||||
"outil": "outil",
|
||||
"plantation": "plantation",
|
||||
"bibliotheque": "bibliotheque",
|
||||
# Variantes FR
|
||||
"jardins": "jardin",
|
||||
"plantes": "plante",
|
||||
"adventices": "adventice",
|
||||
"outils": "outil",
|
||||
"plantations": "plantation",
|
||||
"bibliotheques": "bibliotheque",
|
||||
"bibliotheque_media": "bibliotheque",
|
||||
# Variantes EN (courantes via API)
|
||||
"garden": "jardin",
|
||||
"gardens": "jardin",
|
||||
"plant": "plante",
|
||||
"plants": "plante",
|
||||
"weed": "adventice",
|
||||
"weeds": "adventice",
|
||||
"tool": "outil",
|
||||
"tools": "outil",
|
||||
"planting": "plantation",
|
||||
"plantings": "plantation",
|
||||
"library": "bibliotheque",
|
||||
"media_library": "bibliotheque",
|
||||
}
|
||||
|
||||
router = APIRouter(tags=["media"])
|
||||
|
||||
|
||||
def _normalize_token(value: str) -> str:
|
||||
token = (value or "").strip().lower()
|
||||
token = unicodedata.normalize("NFKD", token).encode("ascii", "ignore").decode("ascii")
|
||||
return token.replace("-", "_").replace(" ", "_")
|
||||
|
||||
|
||||
def _normalize_entity_type(value: str, *, strict: bool = True) -> str:
|
||||
token = _normalize_token(value)
|
||||
canonical = ENTITY_TYPE_ALIASES.get(token, token)
|
||||
if canonical in CANONICAL_ENTITY_TYPES:
|
||||
return canonical
|
||||
if strict:
|
||||
allowed = ", ".join(sorted(CANONICAL_ENTITY_TYPES))
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"entity_type invalide: '{value}'. Valeurs autorisees: {allowed}",
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _entity_type_candidates(value: str) -> set[str]:
|
||||
canonical = _normalize_entity_type(value, strict=True)
|
||||
candidates = {canonical}
|
||||
for alias, target in ENTITY_TYPE_ALIASES.items():
|
||||
if target == canonical:
|
||||
candidates.add(alias)
|
||||
return candidates
|
||||
|
||||
|
||||
def _canonicalize_rows(rows: List[Media], session: Session) -> None:
|
||||
changed = False
|
||||
for media in rows:
|
||||
normalized = _normalize_entity_type(media.entity_type, strict=False)
|
||||
if normalized in CANONICAL_ENTITY_TYPES and normalized != media.entity_type:
|
||||
media.entity_type = normalized
|
||||
session.add(media)
|
||||
changed = True
|
||||
if changed:
|
||||
session.commit()
|
||||
|
||||
|
||||
def _save_webp(data: bytes, max_px: int) -> str:
|
||||
try:
|
||||
from PIL import Image
|
||||
@@ -47,7 +130,7 @@ async def upload_file(file: UploadFile = File(...)):
|
||||
name = _save_webp(data, 1200)
|
||||
thumb = _save_webp(data, 300)
|
||||
return {"url": f"/uploads/{name}", "thumbnail_url": f"/uploads/{thumb}"}
|
||||
else:
|
||||
|
||||
name = f"{uuid.uuid4()}_{file.filename}"
|
||||
path = os.path.join(UPLOAD_DIR, name)
|
||||
with open(path, "wb") as f:
|
||||
@@ -63,8 +146,10 @@ def list_all_media(
|
||||
"""Retourne tous les médias, filtrés optionnellement par entity_type."""
|
||||
q = select(Media).order_by(Media.created_at.desc())
|
||||
if entity_type:
|
||||
q = q.where(Media.entity_type == entity_type)
|
||||
return session.exec(q).all()
|
||||
q = q.where(Media.entity_type.in_(_entity_type_candidates(entity_type)))
|
||||
rows = session.exec(q).all()
|
||||
_canonicalize_rows(rows, session)
|
||||
return rows
|
||||
|
||||
|
||||
@router.get("/media", response_model=List[Media])
|
||||
@@ -73,15 +158,19 @@ def list_media(
|
||||
entity_id: int = Query(...),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
return session.exec(
|
||||
rows = session.exec(
|
||||
select(Media).where(
|
||||
Media.entity_type == entity_type, Media.entity_id == entity_id
|
||||
Media.entity_type.in_(_entity_type_candidates(entity_type)),
|
||||
Media.entity_id == entity_id,
|
||||
)
|
||||
).all()
|
||||
_canonicalize_rows(rows, session)
|
||||
return rows
|
||||
|
||||
|
||||
@router.post("/media", response_model=Media, status_code=status.HTTP_201_CREATED)
|
||||
def create_media(m: Media, session: Session = Depends(get_session)):
|
||||
m.entity_type = _normalize_entity_type(m.entity_type, strict=True)
|
||||
session.add(m)
|
||||
session.commit()
|
||||
session.refresh(m)
|
||||
@@ -93,7 +182,12 @@ def update_media(id: int, payload: MediaPatch, session: Session = Depends(get_se
|
||||
m = session.get(Media, id)
|
||||
if not m:
|
||||
raise HTTPException(404, "Media introuvable")
|
||||
for k, v in payload.model_dump(exclude_none=True).items():
|
||||
|
||||
updates = payload.model_dump(exclude_none=True)
|
||||
if "entity_type" in updates:
|
||||
updates["entity_type"] = _normalize_entity_type(updates["entity_type"], strict=True)
|
||||
|
||||
for k, v in updates.items():
|
||||
setattr(m, k, v)
|
||||
session.add(m)
|
||||
session.commit()
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.database import get_session
|
||||
@@ -13,6 +14,7 @@ router = APIRouter(tags=["météo"])
|
||||
|
||||
def _station_daily_summary(session: Session, iso_date: str) -> Optional[dict]:
|
||||
"""Agrège les mesures horaires d'une journée en résumé."""
|
||||
try:
|
||||
rows = session.exec(
|
||||
text(
|
||||
"SELECT temp_ext, t_min, t_max, pluie_mm, vent_kmh, humidite "
|
||||
@@ -20,6 +22,8 @@ def _station_daily_summary(session: Session, iso_date: str) -> Optional[dict]:
|
||||
),
|
||||
params={"d": iso_date},
|
||||
).fetchall()
|
||||
except OperationalError:
|
||||
return None
|
||||
|
||||
if not rows:
|
||||
return None
|
||||
@@ -46,12 +50,15 @@ def _station_daily_summary(session: Session, iso_date: str) -> Optional[dict]:
|
||||
|
||||
def _station_current_row(session: Session) -> Optional[dict]:
|
||||
"""Dernière mesure station (max 2h d'ancienneté)."""
|
||||
try:
|
||||
row = session.exec(
|
||||
text(
|
||||
"SELECT temp_ext, humidite, pression, pluie_mm, vent_kmh, vent_dir, uv, solaire, date_heure "
|
||||
"FROM meteostation WHERE type='current' ORDER BY date_heure DESC LIMIT 1"
|
||||
)
|
||||
).fetchone()
|
||||
except OperationalError:
|
||||
return None
|
||||
|
||||
if not row:
|
||||
return None
|
||||
@@ -64,6 +71,7 @@ def _station_current_row(session: Session) -> Optional[dict]:
|
||||
|
||||
|
||||
def _open_meteo_day(session: Session, iso_date: str) -> Optional[dict]:
|
||||
try:
|
||||
row = session.exec(
|
||||
text(
|
||||
"SELECT t_min, t_max, pluie_mm, vent_kmh, wmo, label, humidite_moy, sol_0cm, etp_mm "
|
||||
@@ -71,6 +79,8 @@ def _open_meteo_day(session: Session, iso_date: str) -> Optional[dict]:
|
||||
),
|
||||
params={"d": iso_date},
|
||||
).fetchone()
|
||||
except OperationalError:
|
||||
return None
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
@@ -12,7 +12,7 @@ def list_plants(
|
||||
categorie: Optional[str] = Query(None),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
q = select(Plant)
|
||||
q = select(Plant).order_by(Plant.nom_commun, Plant.variete, Plant.id)
|
||||
if categorie:
|
||||
q = q.where(Plant.categorie == categorie)
|
||||
return session.exec(q).all()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -1,18 +1,28 @@
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
import json
|
||||
import tempfile
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import FileResponse
|
||||
from starlette.background import BackgroundTask
|
||||
from sqlmodel import Session, select
|
||||
from app.database import get_session
|
||||
from app.models.settings import UserSettings
|
||||
from app.config import UPLOAD_DIR
|
||||
from app.config import DATABASE_URL, UPLOAD_DIR
|
||||
|
||||
router = APIRouter(tags=["réglages"])
|
||||
|
||||
_PREV_CPU_USAGE_USEC: int | None = None
|
||||
_PREV_CPU_TS: float | None = None
|
||||
_TEXT_EXTENSIONS = {
|
||||
".txt", ".md", ".markdown", ".json", ".csv", ".log", ".ini", ".yaml", ".yml", ".xml"
|
||||
}
|
||||
|
||||
|
||||
def _read_int_from_paths(paths: list[str]) -> int | None:
|
||||
@@ -113,6 +123,68 @@ def _disk_stats() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _safe_remove(path: str) -> None:
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _resolve_sqlite_db_path() -> Path | None:
|
||||
prefix = "sqlite:///"
|
||||
if not DATABASE_URL.startswith(prefix):
|
||||
return None
|
||||
raw = DATABASE_URL[len(prefix):]
|
||||
if not raw:
|
||||
return None
|
||||
db_path = Path(raw)
|
||||
if db_path.is_absolute():
|
||||
return db_path
|
||||
return (Path.cwd() / db_path).resolve()
|
||||
|
||||
|
||||
def _zip_directory(zipf: zipfile.ZipFile, source_dir: Path, arc_prefix: str) -> int:
|
||||
count = 0
|
||||
if not source_dir.is_dir():
|
||||
return count
|
||||
for root, _, files in os.walk(source_dir):
|
||||
root_path = Path(root)
|
||||
for name in files:
|
||||
file_path = root_path / name
|
||||
if not file_path.is_file():
|
||||
continue
|
||||
rel = file_path.relative_to(source_dir)
|
||||
arcname = str(Path(arc_prefix) / rel)
|
||||
zipf.write(file_path, arcname=arcname)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def _zip_data_text_files(
|
||||
zipf: zipfile.ZipFile,
|
||||
data_root: Path,
|
||||
db_path: Path | None,
|
||||
uploads_dir: Path,
|
||||
) -> int:
|
||||
count = 0
|
||||
if not data_root.is_dir():
|
||||
return count
|
||||
for root, _, files in os.walk(data_root):
|
||||
root_path = Path(root)
|
||||
for name in files:
|
||||
file_path = root_path / name
|
||||
if db_path and file_path == db_path:
|
||||
continue
|
||||
if uploads_dir in file_path.parents:
|
||||
continue
|
||||
if file_path.suffix.lower() not in _TEXT_EXTENSIONS:
|
||||
continue
|
||||
rel = file_path.relative_to(data_root)
|
||||
zipf.write(file_path, arcname=str(Path("data_text") / rel))
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
@router.get("/settings")
|
||||
def get_settings(session: Session = Depends(get_session)):
|
||||
rows = session.exec(select(UserSettings)).all()
|
||||
@@ -161,3 +233,51 @@ def get_debug_system_stats() -> dict[str, Any]:
|
||||
"memory": _memory_stats(),
|
||||
"disk": _disk_stats(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/settings/backup/download")
|
||||
def download_backup_zip() -> FileResponse:
|
||||
now = datetime.now(timezone.utc)
|
||||
ts = now.strftime("%Y%m%d_%H%M%S")
|
||||
db_path = _resolve_sqlite_db_path()
|
||||
uploads_dir = Path(UPLOAD_DIR).resolve()
|
||||
data_root = db_path.parent if db_path else uploads_dir.parent
|
||||
|
||||
fd, tmp_zip_path = tempfile.mkstemp(prefix=f"jardin_backup_{ts}_", suffix=".zip")
|
||||
os.close(fd)
|
||||
tmp_zip = Path(tmp_zip_path)
|
||||
|
||||
stats = {
|
||||
"database_files": 0,
|
||||
"upload_files": 0,
|
||||
"text_files": 0,
|
||||
}
|
||||
|
||||
with zipfile.ZipFile(tmp_zip, mode="w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as zipf:
|
||||
if db_path and db_path.is_file():
|
||||
zipf.write(db_path, arcname=f"db/{db_path.name}")
|
||||
stats["database_files"] = 1
|
||||
|
||||
stats["upload_files"] = _zip_directory(zipf, uploads_dir, "uploads")
|
||||
stats["text_files"] = _zip_data_text_files(zipf, data_root, db_path, uploads_dir)
|
||||
|
||||
manifest = {
|
||||
"generated_at_utc": now.isoformat(),
|
||||
"database_url": DATABASE_URL,
|
||||
"paths": {
|
||||
"database_path": str(db_path) if db_path else None,
|
||||
"uploads_path": str(uploads_dir),
|
||||
"data_root": str(data_root),
|
||||
},
|
||||
"included": stats,
|
||||
"text_extensions": sorted(_TEXT_EXTENSIONS),
|
||||
}
|
||||
zipf.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2))
|
||||
|
||||
download_name = f"jardin_backup_{ts}.zip"
|
||||
return FileResponse(
|
||||
path=str(tmp_zip),
|
||||
media_type="application/zip",
|
||||
filename=download_name,
|
||||
background=BackgroundTask(_safe_remove, str(tmp_zip)),
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ router = APIRouter(tags=["tâches"])
|
||||
def list_tasks(
|
||||
statut: Optional[str] = None,
|
||||
garden_id: Optional[int] = None,
|
||||
planting_id: Optional[int] = None,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
q = select(Task)
|
||||
@@ -19,6 +20,9 @@ def list_tasks(
|
||||
q = q.where(Task.statut == statut)
|
||||
if garden_id:
|
||||
q = q.where(Task.garden_id == garden_id)
|
||||
if planting_id:
|
||||
q = q.where(Task.planting_id == planting_id)
|
||||
q = q.order_by(Task.echeance, Task.created_at.desc())
|
||||
return session.exec(q).all()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
def test_create_media_normalizes_english_entity_type(client):
|
||||
r = client.post(
|
||||
"/api/media",
|
||||
json={
|
||||
"entity_type": "plant",
|
||||
"entity_id": 12,
|
||||
"url": "/uploads/test.webp",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
assert r.json()["entity_type"] == "plante"
|
||||
|
||||
|
||||
def test_list_media_accepts_alias_entity_type_filter(client):
|
||||
client.post(
|
||||
"/api/media",
|
||||
json={
|
||||
"entity_type": "plante",
|
||||
"entity_id": 99,
|
||||
"url": "/uploads/test2.webp",
|
||||
},
|
||||
)
|
||||
r = client.get("/api/media", params={"entity_type": "plant", "entity_id": 99})
|
||||
assert r.status_code == 200
|
||||
assert len(r.json()) == 1
|
||||
assert r.json()[0]["entity_type"] == "plante"
|
||||
@@ -12,6 +12,16 @@ def test_list_plants(client):
|
||||
assert len(r.json()) == 2
|
||||
|
||||
|
||||
def test_allow_same_common_name_with_different_varieties(client):
|
||||
client.post("/api/plants", json={"nom_commun": "Tomate", "variete": "Roma"})
|
||||
client.post("/api/plants", json={"nom_commun": "Tomate", "variete": "Andine Cornue"})
|
||||
r = client.get("/api/plants")
|
||||
assert r.status_code == 200
|
||||
tomates = [p for p in r.json() if p["nom_commun"] == "Tomate"]
|
||||
assert len(tomates) == 2
|
||||
assert {p.get("variete") for p in tomates} == {"Roma", "Andine Cornue"}
|
||||
|
||||
|
||||
def test_get_plant(client):
|
||||
r = client.post("/api/plants", json={"nom_commun": "Basilic"})
|
||||
id = r.json()["id"]
|
||||
|
||||
@@ -26,3 +26,13 @@ def test_update_task_statut(client):
|
||||
r2 = client.put(f"/api/tasks/{id}", json={"titre": "À faire", "statut": "fait"})
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["statut"] == "fait"
|
||||
|
||||
|
||||
def test_filter_tasks_by_planting_id(client):
|
||||
client.post("/api/tasks", json={"titre": "Template arrosage", "statut": "template"})
|
||||
client.post("/api/tasks", json={"titre": "Arroser rang 1", "statut": "a_faire", "planting_id": 10})
|
||||
client.post("/api/tasks", json={"titre": "Arroser rang 2", "statut": "a_faire", "planting_id": 11})
|
||||
r = client.get("/api/tasks?planting_id=10")
|
||||
assert r.status_code == 200
|
||||
assert len(r.json()) == 1
|
||||
assert r.json()[0]["planting_id"] == 10
|
||||
|
||||
@@ -28,3 +28,15 @@ def test_tool_with_video_url(client):
|
||||
)
|
||||
assert r.status_code == 201
|
||||
assert r.json()["video_url"] == "/uploads/demo-outil.mp4"
|
||||
|
||||
|
||||
def test_tool_with_notice_texte(client):
|
||||
r = client.post(
|
||||
"/api/tools",
|
||||
json={
|
||||
"nom": "Sécateur",
|
||||
"notice_texte": "Aiguiser la lame tous les 3 mois.",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
assert r.json()["notice_texte"] == "Aiguiser la lame tous les 3 mois."
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -35,12 +35,21 @@ Dossier dédié: `calendrier_lunaire/saints_dictons/`
|
||||
- `calendrier_lunaire/saints_dictons/saint_dicton_year_scraper.py`
|
||||
- Exemple de sortie:
|
||||
- `calendrier_lunaire/saints_dictons/saints_2026.json`
|
||||
- Exports JSON séparés:
|
||||
- `calendrier_lunaire/saints_dictons/saints_du_jour.json`
|
||||
- `calendrier_lunaire/saints_dictons/dictons_du_jour.json`
|
||||
- Scripts hors webapp:
|
||||
- `calendrier_lunaire/saints_dictons/export_saints_dictons_json.py`
|
||||
- `calendrier_lunaire/saints_dictons/import_saints_dictons_db.py`
|
||||
|
||||
### Fonctions/évolutions intégrées
|
||||
- Format JSON cible: `date`, `saints[]`, `dictons[]`
|
||||
- Support de formats de date multiples
|
||||
- Ajout de logs de progression dans le scraper
|
||||
- Enregistrement JSON (pas uniquement affichage terminal)
|
||||
- Génération de 2 jeux de données dédiés (saints / dictons)
|
||||
- Import automatisé en SQLite (`replace` ou `append`)
|
||||
- Création table `saint_du_jour` si absente + alimentation table `dicton`
|
||||
|
||||
## 3) Prévisions météo Open-Meteo
|
||||
|
||||
@@ -103,3 +112,26 @@ Dossier: `test_yolo/`
|
||||
- Plan d'amélioration: `amelioration.md`
|
||||
- Plan météo/astuces: `avancement.md` (contient plan + logs de session)
|
||||
|
||||
## 8) Webapp - évolutions récentes
|
||||
|
||||
### Planning
|
||||
- `frontend/src/views/PlanningView.vue`
|
||||
- Passage en vue 4 semaines (28 jours)
|
||||
- Navigation par période: `Prev`, `Today`, `Next`
|
||||
- Sélection d'un jour avec panneau "Détail du jour"
|
||||
- Marqueurs visuels par tâches non terminées (ronds colorés par priorité)
|
||||
|
||||
### Outils
|
||||
- `frontend/src/views/OutilsView.vue`
|
||||
- Le champ notice est désormais une zone de texte libre (`notice_texte`)
|
||||
- Conserve compatibilité lecture des anciennes notices fichier (`notice_fichier_url`)
|
||||
- Test backend ajouté: `backend/tests/test_tools.py::test_tool_with_notice_texte`
|
||||
|
||||
### Réglages
|
||||
- `backend/app/routers/settings.py`
|
||||
- `frontend/src/views/ReglagesView.vue`
|
||||
- Sauvegarde ZIP téléchargeable (BDD + uploads + fichiers texte + manifeste)
|
||||
- Liens rapides de test API backend:
|
||||
- Swagger: `/docs`
|
||||
- ReDoc: `/redoc`
|
||||
- Santé: `/api/health`
|
||||
|
||||
|
Before Width: | Height: | Size: 254 KiB After Width: | Height: | Size: 254 KiB |
|
Before Width: | Height: | Size: 187 KiB After Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 284 KiB After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 44 B |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 174 KiB After Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 44 B |
|
Before Width: | Height: | Size: 282 KiB After Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 44 B |
|
After Width: | Height: | Size: 44 B |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 247 KiB After Width: | Height: | Size: 247 KiB |
|
After Width: | Height: | Size: 44 B |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 215 KiB After Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 187 KiB After Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 44 B |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 44 B |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 191 KiB After Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |