before gemiin
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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 |
|
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 |