before gemiin
@@ -65,7 +65,17 @@
|
|||||||
"Bash(find:*)",
|
"Bash(find:*)",
|
||||||
"Bash(.venv/bin/python:*)",
|
"Bash(.venv/bin/python:*)",
|
||||||
"Bash(.venv/bin/pip install:*)",
|
"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": [
|
"additionalDirectories": [
|
||||||
"/home/gilles/Documents/vscode/jardin/frontend/src",
|
"/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
|
- [ ] 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]
|
- [ ] 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:
|
- Note tests backend:
|
||||||
- `pytest backend/tests/test_tools.py` reste bloque dans ce contexte d'execution,
|
- `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.
|
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
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ EXPECTED_COLUMNS: dict[str, list[tuple[str, str, str | None]]] = {
|
|||||||
"tool": [
|
"tool": [
|
||||||
("photo_url", "TEXT", None),
|
("photo_url", "TEXT", None),
|
||||||
("video_url", "TEXT", None),
|
("video_url", "TEXT", None),
|
||||||
|
("notice_texte", "TEXT", None),
|
||||||
("notice_fichier_url", "TEXT", None),
|
("notice_fichier_url", "TEXT", None),
|
||||||
("boutique_nom", "TEXT", None),
|
("boutique_nom", "TEXT", None),
|
||||||
("boutique_url", "TEXT", None),
|
("boutique_url", "TEXT", None),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ class Tool(SQLModel, table=True):
|
|||||||
categorie: Optional[str] = None # beche|fourche|griffe|arrosage|taille|autre
|
categorie: Optional[str] = None # beche|fourche|griffe|arrosage|taille|autre
|
||||||
photo_url: Optional[str] = None
|
photo_url: Optional[str] = None
|
||||||
video_url: Optional[str] = None
|
video_url: Optional[str] = None
|
||||||
|
notice_texte: Optional[str] = None
|
||||||
notice_fichier_url: Optional[str] = None
|
notice_fichier_url: Optional[str] = None
|
||||||
boutique_nom: Optional[str] = None
|
boutique_nom: Optional[str] = None
|
||||||
boutique_url: Optional[str] = None
|
boutique_url: Optional[str] = None
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import os
|
import os
|
||||||
|
import unicodedata
|
||||||
import uuid
|
import uuid
|
||||||
from typing import List, Optional
|
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 pydantic import BaseModel
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
@@ -16,9 +17,91 @@ class MediaPatch(BaseModel):
|
|||||||
entity_id: Optional[int] = None
|
entity_id: Optional[int] = None
|
||||||
titre: Optional[str] = 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"])
|
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:
|
def _save_webp(data: bytes, max_px: int) -> str:
|
||||||
try:
|
try:
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@@ -47,7 +130,7 @@ async def upload_file(file: UploadFile = File(...)):
|
|||||||
name = _save_webp(data, 1200)
|
name = _save_webp(data, 1200)
|
||||||
thumb = _save_webp(data, 300)
|
thumb = _save_webp(data, 300)
|
||||||
return {"url": f"/uploads/{name}", "thumbnail_url": f"/uploads/{thumb}"}
|
return {"url": f"/uploads/{name}", "thumbnail_url": f"/uploads/{thumb}"}
|
||||||
else:
|
|
||||||
name = f"{uuid.uuid4()}_{file.filename}"
|
name = f"{uuid.uuid4()}_{file.filename}"
|
||||||
path = os.path.join(UPLOAD_DIR, name)
|
path = os.path.join(UPLOAD_DIR, name)
|
||||||
with open(path, "wb") as f:
|
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."""
|
"""Retourne tous les médias, filtrés optionnellement par entity_type."""
|
||||||
q = select(Media).order_by(Media.created_at.desc())
|
q = select(Media).order_by(Media.created_at.desc())
|
||||||
if entity_type:
|
if entity_type:
|
||||||
q = q.where(Media.entity_type == entity_type)
|
q = q.where(Media.entity_type.in_(_entity_type_candidates(entity_type)))
|
||||||
return session.exec(q).all()
|
rows = session.exec(q).all()
|
||||||
|
_canonicalize_rows(rows, session)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
@router.get("/media", response_model=List[Media])
|
@router.get("/media", response_model=List[Media])
|
||||||
@@ -73,15 +158,19 @@ def list_media(
|
|||||||
entity_id: int = Query(...),
|
entity_id: int = Query(...),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
return session.exec(
|
rows = session.exec(
|
||||||
select(Media).where(
|
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()
|
).all()
|
||||||
|
_canonicalize_rows(rows, session)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
@router.post("/media", response_model=Media, status_code=status.HTTP_201_CREATED)
|
@router.post("/media", response_model=Media, status_code=status.HTTP_201_CREATED)
|
||||||
def create_media(m: Media, session: Session = Depends(get_session)):
|
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.add(m)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(m)
|
session.refresh(m)
|
||||||
@@ -93,7 +182,12 @@ def update_media(id: int, payload: MediaPatch, session: Session = Depends(get_se
|
|||||||
m = session.get(Media, id)
|
m = session.get(Media, id)
|
||||||
if not m:
|
if not m:
|
||||||
raise HTTPException(404, "Media introuvable")
|
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)
|
setattr(m, k, v)
|
||||||
session.add(m)
|
session.add(m)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ def list_plants(
|
|||||||
categorie: Optional[str] = Query(None),
|
categorie: Optional[str] = Query(None),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
q = select(Plant)
|
q = select(Plant).order_by(Plant.nom_commun, Plant.variete, Plant.id)
|
||||||
if categorie:
|
if categorie:
|
||||||
q = q.where(Plant.categorie == categorie)
|
q = q.where(Plant.categorie == categorie)
|
||||||
return session.exec(q).all()
|
return session.exec(q).all()
|
||||||
|
|||||||
@@ -1,18 +1,28 @@
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from starlette.background import BackgroundTask
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
from app.database import get_session
|
from app.database import get_session
|
||||||
from app.models.settings import UserSettings
|
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"])
|
router = APIRouter(tags=["réglages"])
|
||||||
|
|
||||||
_PREV_CPU_USAGE_USEC: int | None = None
|
_PREV_CPU_USAGE_USEC: int | None = None
|
||||||
_PREV_CPU_TS: float | 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:
|
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")
|
@router.get("/settings")
|
||||||
def get_settings(session: Session = Depends(get_session)):
|
def get_settings(session: Session = Depends(get_session)):
|
||||||
rows = session.exec(select(UserSettings)).all()
|
rows = session.exec(select(UserSettings)).all()
|
||||||
@@ -161,3 +233,51 @@ def get_debug_system_stats() -> dict[str, Any]:
|
|||||||
"memory": _memory_stats(),
|
"memory": _memory_stats(),
|
||||||
"disk": _disk_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(
|
def list_tasks(
|
||||||
statut: Optional[str] = None,
|
statut: Optional[str] = None,
|
||||||
garden_id: Optional[int] = None,
|
garden_id: Optional[int] = None,
|
||||||
|
planting_id: Optional[int] = None,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
q = select(Task)
|
q = select(Task)
|
||||||
@@ -19,6 +20,9 @@ def list_tasks(
|
|||||||
q = q.where(Task.statut == statut)
|
q = q.where(Task.statut == statut)
|
||||||
if garden_id:
|
if garden_id:
|
||||||
q = q.where(Task.garden_id == 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()
|
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
|
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):
|
def test_get_plant(client):
|
||||||
r = client.post("/api/plants", json={"nom_commun": "Basilic"})
|
r = client.post("/api/plants", json={"nom_commun": "Basilic"})
|
||||||
id = r.json()["id"]
|
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"})
|
r2 = client.put(f"/api/tasks/{id}", json={"titre": "À faire", "statut": "fait"})
|
||||||
assert r2.status_code == 200
|
assert r2.status_code == 200
|
||||||
assert r2.json()["statut"] == "fait"
|
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.status_code == 201
|
||||||
assert r.json()["video_url"] == "/uploads/demo-outil.mp4"
|
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."
|
||||||
|
|||||||
@@ -35,12 +35,21 @@ Dossier dédié: `calendrier_lunaire/saints_dictons/`
|
|||||||
- `calendrier_lunaire/saints_dictons/saint_dicton_year_scraper.py`
|
- `calendrier_lunaire/saints_dictons/saint_dicton_year_scraper.py`
|
||||||
- Exemple de sortie:
|
- Exemple de sortie:
|
||||||
- `calendrier_lunaire/saints_dictons/saints_2026.json`
|
- `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
|
### Fonctions/évolutions intégrées
|
||||||
- Format JSON cible: `date`, `saints[]`, `dictons[]`
|
- Format JSON cible: `date`, `saints[]`, `dictons[]`
|
||||||
- Support de formats de date multiples
|
- Support de formats de date multiples
|
||||||
- Ajout de logs de progression dans le scraper
|
- Ajout de logs de progression dans le scraper
|
||||||
- Enregistrement JSON (pas uniquement affichage terminal)
|
- 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
|
## 3) Prévisions météo Open-Meteo
|
||||||
|
|
||||||
@@ -103,3 +112,26 @@ Dossier: `test_yolo/`
|
|||||||
- Plan d'amélioration: `amelioration.md`
|
- Plan d'amélioration: `amelioration.md`
|
||||||
- Plan météo/astuces: `avancement.md` (contient plan + logs de session)
|
- 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 |
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 230 KiB After Width: | Height: | Size: 230 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 44 B |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 81 KiB |