last
7
.env
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
APP_ENV=development
|
||||||
|
API_HOST=0.0.0.0
|
||||||
|
API_PORT=8018
|
||||||
|
FRONTEND_PORT=8081
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
DATABASE_URL=sqlite:///backend/data/suivi.db
|
||||||
|
VITE_API_URL=/api
|
||||||
4
.gitignore
vendored
@@ -1,8 +1,8 @@
|
|||||||
.venv/
|
.venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
backend/data/
|
#backend/data/
|
||||||
backend/logs/
|
backend/logs/
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
.env
|
#.env
|
||||||
|
|||||||
@@ -1,16 +1,34 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, HTTPException
|
from fastapi import APIRouter, Body, HTTPException, UploadFile, File
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
from backend.app.core.config import BackendConfig, CONFIG_PATH, load_config
|
from backend.app.core.config import BackendConfig, CONFIG_PATH, load_config
|
||||||
|
from backend.app.db.database import DEFAULT_DATABASE_PATH
|
||||||
|
|
||||||
router = APIRouter(prefix="/config", tags=["config"])
|
router = APIRouter(prefix="/config", tags=["config"])
|
||||||
|
|
||||||
# Chemin vers la config frontend
|
# Chemins possibles vers la config frontend (dev + docker)
|
||||||
FRONTEND_CONFIG_PATH = Path(__file__).resolve().parent.parent.parent.parent / "frontend" / "config_frontend.json"
|
FRONTEND_CONFIG_PATH = (
|
||||||
|
Path(__file__).resolve().parent.parent.parent.parent / "frontend" / "public" / "config_frontend.json"
|
||||||
|
)
|
||||||
|
FRONTEND_CONFIG_FALLBACK_PATH = (
|
||||||
|
Path(__file__).resolve().parent.parent.parent.parent / "backend" / "config_frontend.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_frontend_config_path() -> Path | None:
|
||||||
|
if FRONTEND_CONFIG_PATH.exists():
|
||||||
|
return FRONTEND_CONFIG_PATH
|
||||||
|
if FRONTEND_CONFIG_FALLBACK_PATH.exists():
|
||||||
|
return FRONTEND_CONFIG_FALLBACK_PATH
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/backend", response_model=BackendConfig)
|
@router.get("/backend", response_model=BackendConfig)
|
||||||
@@ -23,8 +41,21 @@ def read_backend_config() -> BackendConfig:
|
|||||||
def update_backend_config(payload: dict = Body(...)) -> BackendConfig:
|
def update_backend_config(payload: dict = Body(...)) -> BackendConfig:
|
||||||
current = load_config()
|
current = load_config()
|
||||||
try:
|
try:
|
||||||
# validation via Pydantic avant écriture
|
# Fusion profonde des configs (nécessaire pour les modèles imbriqués Pydantic v2)
|
||||||
updated = current.model_copy(update=payload)
|
def deep_merge(base: dict, update: dict) -> dict:
|
||||||
|
result = base.copy()
|
||||||
|
for key, value in update.items():
|
||||||
|
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
||||||
|
result[key] = deep_merge(result[key], value)
|
||||||
|
else:
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Convertir en dict, fusionner, puis revalider
|
||||||
|
current_dict = current.model_dump()
|
||||||
|
merged = deep_merge(current_dict, payload)
|
||||||
|
updated = BackendConfig.model_validate(merged)
|
||||||
|
|
||||||
CONFIG_PATH.write_text(updated.model_dump_json(indent=2), encoding="utf-8")
|
CONFIG_PATH.write_text(updated.model_dump_json(indent=2), encoding="utf-8")
|
||||||
load_config.cache_clear()
|
load_config.cache_clear()
|
||||||
return load_config()
|
return load_config()
|
||||||
@@ -35,9 +66,10 @@ def update_backend_config(payload: dict = Body(...)) -> BackendConfig:
|
|||||||
@router.get("/frontend")
|
@router.get("/frontend")
|
||||||
def read_frontend_config() -> dict:
|
def read_frontend_config() -> dict:
|
||||||
"""Retourne la configuration frontend."""
|
"""Retourne la configuration frontend."""
|
||||||
if not FRONTEND_CONFIG_PATH.exists():
|
config_path = _get_frontend_config_path()
|
||||||
|
if not config_path:
|
||||||
raise HTTPException(status_code=404, detail="Config frontend introuvable")
|
raise HTTPException(status_code=404, detail="Config frontend introuvable")
|
||||||
return json.loads(FRONTEND_CONFIG_PATH.read_text(encoding="utf-8"))
|
return json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
@router.put("/frontend")
|
@router.put("/frontend")
|
||||||
@@ -46,8 +78,9 @@ def update_frontend_config(payload: dict = Body(...)) -> dict:
|
|||||||
try:
|
try:
|
||||||
# Charger la config actuelle
|
# Charger la config actuelle
|
||||||
current = {}
|
current = {}
|
||||||
if FRONTEND_CONFIG_PATH.exists():
|
config_path = _get_frontend_config_path()
|
||||||
current = json.loads(FRONTEND_CONFIG_PATH.read_text(encoding="utf-8"))
|
if config_path and config_path.exists():
|
||||||
|
current = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
# Fusion profonde des configs
|
# Fusion profonde des configs
|
||||||
def deep_merge(base: dict, update: dict) -> dict:
|
def deep_merge(base: dict, update: dict) -> dict:
|
||||||
@@ -60,19 +93,87 @@ def update_frontend_config(payload: dict = Body(...)) -> dict:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
updated = deep_merge(current, payload)
|
updated = deep_merge(current, payload)
|
||||||
FRONTEND_CONFIG_PATH.write_text(
|
target_paths = []
|
||||||
json.dumps(updated, indent=2, ensure_ascii=False),
|
if FRONTEND_CONFIG_PATH.parent.exists():
|
||||||
encoding="utf-8"
|
target_paths.append(FRONTEND_CONFIG_PATH)
|
||||||
)
|
if FRONTEND_CONFIG_FALLBACK_PATH.parent.exists():
|
||||||
|
target_paths.append(FRONTEND_CONFIG_FALLBACK_PATH)
|
||||||
|
if not target_paths:
|
||||||
|
target_paths.append(FRONTEND_CONFIG_FALLBACK_PATH)
|
||||||
|
|
||||||
# Mettre à jour aussi dans public/ pour le frontend dev
|
for target in target_paths:
|
||||||
public_config = FRONTEND_CONFIG_PATH.parent / "public" / "config_frontend.json"
|
target.write_text(
|
||||||
if public_config.parent.exists():
|
|
||||||
public_config.write_text(
|
|
||||||
json.dumps(updated, indent=2, ensure_ascii=False),
|
json.dumps(updated, indent=2, ensure_ascii=False),
|
||||||
encoding="utf-8"
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
return updated
|
return updated
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc))
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Database Backup ====================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/database/info")
|
||||||
|
def database_info() -> dict:
|
||||||
|
"""Retourne les informations sur la base de données."""
|
||||||
|
if not DEFAULT_DATABASE_PATH.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Base de données introuvable")
|
||||||
|
|
||||||
|
stat = DEFAULT_DATABASE_PATH.stat()
|
||||||
|
return {
|
||||||
|
"path": str(DEFAULT_DATABASE_PATH),
|
||||||
|
"filename": DEFAULT_DATABASE_PATH.name,
|
||||||
|
"size_bytes": stat.st_size,
|
||||||
|
"size_mb": round(stat.st_size / (1024 * 1024), 2),
|
||||||
|
"modified_at": datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/database/backup")
|
||||||
|
def download_database():
|
||||||
|
"""Télécharge une copie de la base de données."""
|
||||||
|
if not DEFAULT_DATABASE_PATH.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Base de données introuvable")
|
||||||
|
|
||||||
|
# Créer une copie temporaire pour éviter les problèmes de lock
|
||||||
|
backup_path = DEFAULT_DATABASE_PATH.parent / f"backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.db"
|
||||||
|
shutil.copy2(DEFAULT_DATABASE_PATH, backup_path)
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
path=backup_path,
|
||||||
|
filename=f"suivi_produit_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.db",
|
||||||
|
media_type="application/x-sqlite3",
|
||||||
|
background=None, # Nettoyer après envoi
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/database/restore")
|
||||||
|
async def restore_database(file: UploadFile = File(...)) -> dict:
|
||||||
|
"""Restaure la base de données depuis un fichier uploadé."""
|
||||||
|
if not file.filename.endswith(".db"):
|
||||||
|
raise HTTPException(status_code=400, detail="Le fichier doit être un .db")
|
||||||
|
|
||||||
|
# Vérifier la taille (max 100MB)
|
||||||
|
content = await file.read()
|
||||||
|
if len(content) > 100 * 1024 * 1024:
|
||||||
|
raise HTTPException(status_code=400, detail="Fichier trop volumineux (max 100MB)")
|
||||||
|
|
||||||
|
# Créer un backup avant restauration
|
||||||
|
if DEFAULT_DATABASE_PATH.exists():
|
||||||
|
backup_before = DEFAULT_DATABASE_PATH.parent / f"before_restore_{datetime.now().strftime('%Y%m%d_%H%M%S')}.db"
|
||||||
|
shutil.copy2(DEFAULT_DATABASE_PATH, backup_before)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Écrire le nouveau fichier
|
||||||
|
with open(DEFAULT_DATABASE_PATH, "wb") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Base de données restaurée avec succès",
|
||||||
|
"size_bytes": len(content),
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Erreur lors de la restauration: {exc}")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from backend.app.api.deps import get_db
|
from backend.app.api.deps import get_db
|
||||||
@@ -46,22 +46,25 @@ def update_product(product_id: int, payload: schemas.ProductUpdate, db: Session
|
|||||||
return crud.update_product(db, product, payload)
|
return crud.update_product(db, product, payload)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
||||||
def delete_product(product_id: int, db: Session = Depends(get_db)) -> None:
|
def delete_product(product_id: int, db: Session = Depends(get_db)) -> Response:
|
||||||
product = crud.get_product(db, product_id)
|
product = crud.get_product(db, product_id)
|
||||||
if not product:
|
if not product:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Produit introuvable")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Produit introuvable")
|
||||||
# suppression définitive en base
|
# suppression définitive en base
|
||||||
crud.remove_product(db, product)
|
crud.remove_product(db, product)
|
||||||
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{product_id}/snapshots", response_model=list[schemas.ProductSnapshotRead])
|
@router.get("/{product_id}/snapshots", response_model=list[schemas.ProductSnapshotRead])
|
||||||
def list_snapshots(
|
def list_snapshots(
|
||||||
product_id: int,
|
product_id: int,
|
||||||
limit: int = 30,
|
days: int | None = None,
|
||||||
|
limit: int = 1000,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> list[schemas.ProductSnapshotRead]:
|
) -> list[schemas.ProductSnapshotRead]:
|
||||||
|
"""Retourne les snapshots d'un produit, filtrés par nombre de jours."""
|
||||||
product = crud.get_product(db, product_id)
|
product = crud.get_product(db, product_id)
|
||||||
if not product:
|
if not product:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Produit introuvable")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Produit introuvable")
|
||||||
return crud.list_snapshots(db, product_id, limit=limit)
|
return crud.list_snapshots(db, product_id, days=days, limit=limit)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
||||||
from pydantic import BaseModel, HttpUrl
|
from pydantic import BaseModel, HttpUrl
|
||||||
|
|
||||||
|
from backend.app.core.scheduler import get_scheduler_status, trigger_next_run
|
||||||
from backend.app.scraper.runner import scrape_all, scrape_preview, scrape_product
|
from backend.app.scraper.runner import scrape_all, scrape_preview, scrape_product
|
||||||
|
|
||||||
router = APIRouter(prefix="/scrape", tags=["scrape"])
|
router = APIRouter(prefix="/scrape", tags=["scrape"])
|
||||||
@@ -35,3 +36,15 @@ def trigger_single(product_id: int, background_tasks: BackgroundTasks):
|
|||||||
def trigger_all(background_tasks: BackgroundTasks):
|
def trigger_all(background_tasks: BackgroundTasks):
|
||||||
background_tasks.add_task(scrape_all)
|
background_tasks.add_task(scrape_all)
|
||||||
return {"statut": "planifie_tout"}
|
return {"statut": "planifie_tout"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/scheduler/status")
|
||||||
|
def scheduler_status():
|
||||||
|
"""Retourne l'état actuel du scheduler de scraping automatique."""
|
||||||
|
return get_scheduler_status()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/scheduler/trigger")
|
||||||
|
def scheduler_trigger():
|
||||||
|
"""Force le prochain scrape planifié à s'exécuter maintenant."""
|
||||||
|
return trigger_next_run()
|
||||||
|
|||||||
66
backend/app/api/routes_stats.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"""Endpoint pour les statistiques système."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import psutil
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/stats", tags=["stats"])
|
||||||
|
|
||||||
|
|
||||||
|
class SystemStats(BaseModel):
|
||||||
|
"""Statistiques système."""
|
||||||
|
|
||||||
|
cpu_percent: float
|
||||||
|
memory_mb: float
|
||||||
|
memory_percent: float
|
||||||
|
data_size_mb: float
|
||||||
|
|
||||||
|
|
||||||
|
def get_directory_size(path: Path) -> int:
|
||||||
|
"""Calcule la taille totale d'un répertoire en bytes."""
|
||||||
|
total = 0
|
||||||
|
if path.exists() and path.is_dir():
|
||||||
|
for entry in path.rglob("*"):
|
||||||
|
if entry.is_file():
|
||||||
|
try:
|
||||||
|
total += entry.stat().st_size
|
||||||
|
except (OSError, PermissionError):
|
||||||
|
pass
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=SystemStats)
|
||||||
|
def get_stats() -> SystemStats:
|
||||||
|
"""Retourne les statistiques système du backend."""
|
||||||
|
# CPU et mémoire du process courant
|
||||||
|
process = psutil.Process(os.getpid())
|
||||||
|
cpu_percent = process.cpu_percent(interval=0.1)
|
||||||
|
memory_info = process.memory_info()
|
||||||
|
memory_mb = memory_info.rss / (1024 * 1024)
|
||||||
|
|
||||||
|
# Mémoire système totale pour calculer le pourcentage
|
||||||
|
total_memory = psutil.virtual_memory().total
|
||||||
|
memory_percent = (memory_info.rss / total_memory) * 100
|
||||||
|
|
||||||
|
# Taille des dossiers data et logs
|
||||||
|
base_path = Path("/app/backend")
|
||||||
|
if not base_path.exists():
|
||||||
|
# Fallback pour le développement local
|
||||||
|
base_path = Path(__file__).parent.parent.parent
|
||||||
|
|
||||||
|
data_path = base_path / "data"
|
||||||
|
logs_path = base_path / "logs"
|
||||||
|
|
||||||
|
data_size = get_directory_size(data_path) + get_directory_size(logs_path)
|
||||||
|
data_size_mb = data_size / (1024 * 1024)
|
||||||
|
|
||||||
|
return SystemStats(
|
||||||
|
cpu_percent=round(cpu_percent, 1),
|
||||||
|
memory_mb=round(memory_mb, 1),
|
||||||
|
memory_percent=round(memory_percent, 1),
|
||||||
|
data_size_mb=round(data_size_mb, 1),
|
||||||
|
)
|
||||||
@@ -44,4 +44,4 @@ class BackendConfig(BaseModel):
|
|||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
def load_config() -> BackendConfig:
|
def load_config() -> BackendConfig:
|
||||||
# on met en cache pour éviter de recharger le fichier à chaque requête
|
# on met en cache pour éviter de recharger le fichier à chaque requête
|
||||||
return BackendConfig.parse_file(CONFIG_PATH)
|
return BackendConfig.model_validate_json(CONFIG_PATH.read_text(encoding="utf-8"))
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
from apscheduler.triggers.interval import IntervalTrigger
|
from apscheduler.triggers.interval import IntervalTrigger
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -17,12 +19,49 @@ def start_scheduler() -> None:
|
|||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
interval = config.scrape.interval_minutes
|
interval = config.scrape.interval_minutes
|
||||||
|
# Premier run après l'intervalle défini (pas immédiatement au démarrage)
|
||||||
|
first_run = datetime.now() + timedelta(minutes=interval)
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
scrape_all,
|
scrape_all,
|
||||||
trigger=IntervalTrigger(minutes=interval),
|
trigger=IntervalTrigger(minutes=interval),
|
||||||
id="scheduled-scrape-all",
|
id="scheduled-scrape-all",
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
next_run_time=None,
|
next_run_time=first_run,
|
||||||
)
|
)
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
logger.info("Scheduler démarré avec un intervalle de %s minutes", interval)
|
logger.info("Scheduler démarré avec un intervalle de {} minutes (prochain run: {})", interval, first_run.strftime("%H:%M:%S"))
|
||||||
|
|
||||||
|
|
||||||
|
def get_scheduler_status() -> dict:
|
||||||
|
"""Retourne l'état actuel du scheduler."""
|
||||||
|
job = scheduler.get_job("scheduled-scrape-all")
|
||||||
|
config = load_config()
|
||||||
|
|
||||||
|
status = {
|
||||||
|
"running": scheduler.running,
|
||||||
|
"interval_minutes": config.scrape.interval_minutes,
|
||||||
|
"job_exists": job is not None,
|
||||||
|
"next_run_time": None,
|
||||||
|
"next_run_in_minutes": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
if job and job.next_run_time:
|
||||||
|
status["next_run_time"] = job.next_run_time.isoformat()
|
||||||
|
# Calculer le temps restant
|
||||||
|
now = datetime.now(job.next_run_time.tzinfo)
|
||||||
|
delta = job.next_run_time - now
|
||||||
|
status["next_run_in_minutes"] = round(delta.total_seconds() / 60, 1)
|
||||||
|
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
def trigger_next_run() -> dict:
|
||||||
|
"""Force le prochain scrape à s'exécuter maintenant."""
|
||||||
|
job = scheduler.get_job("scheduled-scrape-all")
|
||||||
|
if not job:
|
||||||
|
return {"success": False, "error": "Job non trouvé"}
|
||||||
|
|
||||||
|
# Modifier le job pour s'exécuter maintenant
|
||||||
|
scheduler.modify_job("scheduled-scrape-all", next_run_time=datetime.now())
|
||||||
|
logger.info("Prochain scrape programmé pour maintenant")
|
||||||
|
return {"success": True, "message": "Scrape programmé pour exécution immédiate"}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ def create_product_with_snapshot(
|
|||||||
"description",
|
"description",
|
||||||
"carateristique",
|
"carateristique",
|
||||||
"details",
|
"details",
|
||||||
|
"categorie_amazon",
|
||||||
]
|
]
|
||||||
snapshot_data = {k: data_dict.pop(k) for k in snapshot_fields if k in data_dict}
|
snapshot_data = {k: data_dict.pop(k) for k in snapshot_fields if k in data_dict}
|
||||||
|
|
||||||
@@ -118,15 +119,23 @@ def remove_product(db: Session, product: models.Product) -> None:
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
def list_snapshots(db: Session, product_id: int, limit: int = 30) -> list[models.ProductSnapshot]:
|
def list_snapshots(
|
||||||
return (
|
db: Session, product_id: int, days: int | None = None, limit: int = 1000
|
||||||
db.query(models.ProductSnapshot)
|
) -> list[models.ProductSnapshot]:
|
||||||
.filter(models.ProductSnapshot.produit_id == product_id)
|
"""Retourne les snapshots d'un produit, filtrés par nombre de jours si spécifié."""
|
||||||
.order_by(models.ProductSnapshot.scrape_le.desc())
|
from datetime import datetime, timedelta
|
||||||
.limit(limit)
|
|
||||||
.all()
|
query = db.query(models.ProductSnapshot).filter(
|
||||||
|
models.ProductSnapshot.produit_id == product_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Filtrer par date si days est spécifié
|
||||||
|
if days is not None and days > 0:
|
||||||
|
cutoff_date = datetime.utcnow() - timedelta(days=days)
|
||||||
|
query = query.filter(models.ProductSnapshot.scrape_le >= cutoff_date)
|
||||||
|
|
||||||
|
return query.order_by(models.ProductSnapshot.scrape_le.desc()).limit(limit).all()
|
||||||
|
|
||||||
|
|
||||||
def get_latest_snapshot(db: Session, product_id: int) -> models.ProductSnapshot | None:
|
def get_latest_snapshot(db: Session, product_id: int) -> models.ProductSnapshot | None:
|
||||||
return (
|
return (
|
||||||
@@ -215,6 +224,7 @@ def _enrich_product_with_snapshot(db: Session, product: models.Product) -> dict:
|
|||||||
"description": snapshot.description,
|
"description": snapshot.description,
|
||||||
"carateristique": carateristique,
|
"carateristique": carateristique,
|
||||||
"details": details,
|
"details": details,
|
||||||
|
"categorie_amazon": snapshot.categorie_amazon,
|
||||||
"dernier_scrape": snapshot.scrape_le,
|
"dernier_scrape": snapshot.scrape_le,
|
||||||
"statut_scrap": snapshot.statut_scrap,
|
"statut_scrap": snapshot.statut_scrap,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class Product(Base):
|
|||||||
cree_le = Column(DateTime, default=datetime.utcnow)
|
cree_le = Column(DateTime, default=datetime.utcnow)
|
||||||
modifie_le = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
modifie_le = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
snapshots = relationship("ProductSnapshot", back_populates="product")
|
snapshots = relationship("ProductSnapshot", back_populates="product", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
class ScrapeRun(Base):
|
class ScrapeRun(Base):
|
||||||
@@ -67,6 +67,7 @@ class ProductSnapshot(Base):
|
|||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
carateristique = Column(Text, nullable=True) # JSON object
|
carateristique = Column(Text, nullable=True) # JSON object
|
||||||
details = Column(Text, nullable=True) # JSON object
|
details = Column(Text, nullable=True) # JSON object
|
||||||
|
categorie_amazon = Column(Text, nullable=True) # Catégorie depuis breadcrumb Amazon
|
||||||
chemin_json_brut = Column(Text, nullable=True)
|
chemin_json_brut = Column(Text, nullable=True)
|
||||||
statut_scrap = Column(String(32), default="ok")
|
statut_scrap = Column(String(32), default="ok")
|
||||||
message_erreur = Column(Text, nullable=True)
|
message_erreur = Column(Text, nullable=True)
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, HttpUrl
|
from pydantic import BaseModel, ConfigDict, HttpUrl, field_validator
|
||||||
|
|
||||||
|
|
||||||
class ProductBase(BaseModel):
|
class ProductBase(BaseModel):
|
||||||
@@ -30,13 +31,12 @@ class ProductUpdate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ProductRead(ProductBase):
|
class ProductRead(ProductBase):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
cree_le: datetime
|
cree_le: datetime
|
||||||
modifie_le: datetime
|
modifie_le: datetime
|
||||||
|
|
||||||
class Config:
|
|
||||||
orm_mode = True
|
|
||||||
|
|
||||||
|
|
||||||
class ProductSnapshotBase(BaseModel):
|
class ProductSnapshotBase(BaseModel):
|
||||||
prix_actuel: Optional[float]
|
prix_actuel: Optional[float]
|
||||||
@@ -55,22 +55,48 @@ class ProductSnapshotBase(BaseModel):
|
|||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
carateristique: Optional[dict[str, Any]] = None
|
carateristique: Optional[dict[str, Any]] = None
|
||||||
details: Optional[dict[str, Any]] = None
|
details: Optional[dict[str, Any]] = None
|
||||||
|
categorie_amazon: Optional[str] = None
|
||||||
statut_scrap: Optional[str]
|
statut_scrap: Optional[str]
|
||||||
message_erreur: Optional[str]
|
message_erreur: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
class ProductSnapshotRead(ProductSnapshotBase):
|
class ProductSnapshotRead(ProductSnapshotBase):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
produit_id: int
|
produit_id: int
|
||||||
scrape_le: datetime
|
scrape_le: datetime
|
||||||
|
|
||||||
class Config:
|
@field_validator("a_propos", mode="before")
|
||||||
orm_mode = True
|
@classmethod
|
||||||
|
def parse_a_propos(cls, v: Any) -> list[str] | None:
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
if isinstance(v, str):
|
||||||
|
try:
|
||||||
|
return json.loads(v)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("carateristique", "details", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def parse_json_dict(cls, v: Any) -> dict[str, Any] | None:
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
if isinstance(v, str):
|
||||||
|
try:
|
||||||
|
return json.loads(v)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
class ProductWithSnapshot(ProductBase):
|
class ProductWithSnapshot(ProductBase):
|
||||||
"""Produit enrichi avec les données du dernier snapshot."""
|
"""Produit enrichi avec les données du dernier snapshot."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
cree_le: datetime
|
cree_le: datetime
|
||||||
modifie_le: datetime
|
modifie_le: datetime
|
||||||
@@ -92,12 +118,10 @@ class ProductWithSnapshot(ProductBase):
|
|||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
carateristique: Optional[dict[str, Any]] = None
|
carateristique: Optional[dict[str, Any]] = None
|
||||||
details: Optional[dict[str, Any]] = None
|
details: Optional[dict[str, Any]] = None
|
||||||
|
categorie_amazon: Optional[str] = None
|
||||||
dernier_scrape: Optional[datetime] = None
|
dernier_scrape: Optional[datetime] = None
|
||||||
statut_scrap: Optional[str] = None
|
statut_scrap: Optional[str] = None
|
||||||
|
|
||||||
class Config:
|
|
||||||
orm_mode = True
|
|
||||||
|
|
||||||
|
|
||||||
class ProductCreateWithSnapshot(ProductBase):
|
class ProductCreateWithSnapshot(ProductBase):
|
||||||
"""Création d'un produit avec données de snapshot initiales (depuis preview)."""
|
"""Création d'un produit avec données de snapshot initiales (depuis preview)."""
|
||||||
@@ -119,3 +143,4 @@ class ProductCreateWithSnapshot(ProductBase):
|
|||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
carateristique: Optional[dict[str, Any]] = None
|
carateristique: Optional[dict[str, Any]] = None
|
||||||
details: Optional[dict[str, Any]] = None
|
details: Optional[dict[str, Any]] = None
|
||||||
|
categorie_amazon: Optional[str] = None
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from fastapi import FastAPI
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from backend.app.api import routes_config, routes_debug, routes_products, routes_scrape
|
from backend.app.api import routes_config, routes_debug, routes_products, routes_scrape, routes_stats
|
||||||
from backend.app.core.logging import logger
|
from backend.app.core.logging import logger
|
||||||
from backend.app.core.scheduler import start_scheduler
|
from backend.app.core.scheduler import start_scheduler
|
||||||
from backend.app.db.database import Base, engine
|
from backend.app.db.database import Base, engine
|
||||||
@@ -15,19 +15,26 @@ load_dotenv()
|
|||||||
|
|
||||||
app = FastAPI(title="suivi_produit")
|
app = FastAPI(title="suivi_produit")
|
||||||
|
|
||||||
|
app_env = getenv("APP_ENV", "development")
|
||||||
|
|
||||||
# CORS pour le frontend
|
# CORS pour le frontend
|
||||||
app.add_middleware(
|
cors_kwargs = {
|
||||||
CORSMiddleware,
|
"allow_credentials": True,
|
||||||
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
|
"allow_methods": ["*"],
|
||||||
allow_credentials=True,
|
"allow_headers": ["*"],
|
||||||
allow_methods=["*"],
|
}
|
||||||
allow_headers=["*"],
|
if app_env == "development":
|
||||||
)
|
cors_kwargs["allow_origin_regex"] = r"https?://(localhost|127\\.0\\.0\\.1|10\\.0\\.1\\.109)(:\\d+)?"
|
||||||
|
else:
|
||||||
|
cors_kwargs["allow_origins"] = ["http://localhost:5173", "http://127.0.0.1:5173"]
|
||||||
|
|
||||||
|
app.add_middleware(CORSMiddleware, **cors_kwargs)
|
||||||
|
|
||||||
app.include_router(routes_products.router)
|
app.include_router(routes_products.router)
|
||||||
app.include_router(routes_scrape.router)
|
app.include_router(routes_scrape.router)
|
||||||
app.include_router(routes_config.router)
|
app.include_router(routes_config.router)
|
||||||
app.include_router(routes_debug.router)
|
app.include_router(routes_debug.router)
|
||||||
|
app.include_router(routes_stats.router)
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
|
|||||||
10235
backend/app/samples/debug/10_20260120_025306_capture.html
Normal file
BIN
backend/app/samples/debug/10_20260120_025306_capture.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
10280
backend/app/samples/debug/10_20260120_184424_capture.html
Normal file
BIN
backend/app/samples/debug/10_20260120_184424_capture.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
10244
backend/app/samples/debug/10_20260120_190206_capture.html
Normal file
BIN
backend/app/samples/debug/10_20260120_190206_capture.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
10277
backend/app/samples/debug/10_20260120_195248_capture.html
Normal file
BIN
backend/app/samples/debug/10_20260120_195248_capture.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
10284
backend/app/samples/debug/10_20260120_195340_capture.html
Normal file
BIN
backend/app/samples/debug/10_20260120_195340_capture.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
10277
backend/app/samples/debug/10_20260120_205248_capture.html
Normal file
BIN
backend/app/samples/debug/10_20260120_205248_capture.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
10234
backend/app/samples/debug/10_20260120_213030_capture.html
Normal file
BIN
backend/app/samples/debug/10_20260120_213030_capture.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
10091
backend/app/samples/debug/11_20260120_025313_capture.html
Normal file
BIN
backend/app/samples/debug/11_20260120_025313_capture.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
10072
backend/app/samples/debug/11_20260120_184430_capture.html
Normal file
BIN
backend/app/samples/debug/11_20260120_184430_capture.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
10029
backend/app/samples/debug/11_20260120_190212_capture.html
Normal file
BIN
backend/app/samples/debug/11_20260120_190212_capture.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
10019
backend/app/samples/debug/11_20260120_195255_capture.html
Normal file
BIN
backend/app/samples/debug/11_20260120_195255_capture.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
10019
backend/app/samples/debug/11_20260120_195348_capture.html
Normal file
BIN
backend/app/samples/debug/11_20260120_195348_capture.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
10053
backend/app/samples/debug/11_20260120_205255_capture.html
Normal file
BIN
backend/app/samples/debug/11_20260120_205255_capture.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
10064
backend/app/samples/debug/11_20260120_213037_capture.html
Normal file
BIN
backend/app/samples/debug/11_20260120_213037_capture.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
9655
backend/app/samples/debug/12_20260120_025320_capture.html
Normal file
BIN
backend/app/samples/debug/12_20260120_025320_capture.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
9654
backend/app/samples/debug/12_20260120_184436_capture.html
Normal file
BIN
backend/app/samples/debug/12_20260120_184436_capture.png
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
9629
backend/app/samples/debug/12_20260120_190219_capture.html
Normal file
BIN
backend/app/samples/debug/12_20260120_190219_capture.png
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
9629
backend/app/samples/debug/12_20260120_195301_capture.html
Normal file
BIN
backend/app/samples/debug/12_20260120_195301_capture.png
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
9654
backend/app/samples/debug/12_20260120_195353_capture.html
Normal file
BIN
backend/app/samples/debug/12_20260120_195353_capture.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
9629
backend/app/samples/debug/12_20260120_205302_capture.html
Normal file
BIN
backend/app/samples/debug/12_20260120_205302_capture.png
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
9629
backend/app/samples/debug/12_20260120_213044_capture.html
Normal file
BIN
backend/app/samples/debug/12_20260120_213044_capture.png
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
10427
backend/app/samples/debug/13_20260120_025326_capture.html
Normal file
BIN
backend/app/samples/debug/13_20260120_025326_capture.png
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
10461
backend/app/samples/debug/13_20260120_184443_capture.html
Normal file
BIN
backend/app/samples/debug/13_20260120_184443_capture.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
10454
backend/app/samples/debug/13_20260120_190226_capture.html
Normal file
BIN
backend/app/samples/debug/13_20260120_190226_capture.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
10460
backend/app/samples/debug/13_20260120_195307_capture.html
Normal file
BIN
backend/app/samples/debug/13_20260120_195307_capture.png
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
10373
backend/app/samples/debug/13_20260120_195358_capture.html
Normal file
BIN
backend/app/samples/debug/13_20260120_195358_capture.png
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
10460
backend/app/samples/debug/13_20260120_205308_capture.html
Normal file
BIN
backend/app/samples/debug/13_20260120_205308_capture.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
10442
backend/app/samples/debug/13_20260120_213051_capture.html
Normal file
BIN
backend/app/samples/debug/13_20260120_213051_capture.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
9646
backend/app/samples/debug/14_20260120_184450_capture.html
Normal file
BIN
backend/app/samples/debug/14_20260120_184450_capture.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
9639
backend/app/samples/debug/14_20260120_190233_capture.html
Normal file
BIN
backend/app/samples/debug/14_20260120_190233_capture.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
9628
backend/app/samples/debug/14_20260120_195314_capture.html
Normal file
BIN
backend/app/samples/debug/14_20260120_195314_capture.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
9639
backend/app/samples/debug/14_20260120_195404_capture.html
Normal file
BIN
backend/app/samples/debug/14_20260120_195404_capture.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
9621
backend/app/samples/debug/14_20260120_205314_capture.html
Normal file
BIN
backend/app/samples/debug/14_20260120_205314_capture.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
9616
backend/app/samples/debug/14_20260120_213058_capture.html
Normal file
BIN
backend/app/samples/debug/14_20260120_213058_capture.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
9621
backend/app/samples/debug/15_20260120_184455_capture.html
Normal file
BIN
backend/app/samples/debug/15_20260120_184455_capture.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
9632
backend/app/samples/debug/15_20260120_190240_capture.html
Normal file
BIN
backend/app/samples/debug/15_20260120_190240_capture.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
9621
backend/app/samples/debug/15_20260120_195320_capture.html
Normal file
BIN
backend/app/samples/debug/15_20260120_195320_capture.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
9623
backend/app/samples/debug/15_20260120_195409_capture.html
Normal file
BIN
backend/app/samples/debug/15_20260120_195409_capture.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
9621
backend/app/samples/debug/15_20260120_205319_capture.html
Normal file
BIN
backend/app/samples/debug/15_20260120_205319_capture.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
9598
backend/app/samples/debug/15_20260120_213103_capture.html
Normal file
BIN
backend/app/samples/debug/15_20260120_213103_capture.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
9067
backend/app/samples/debug/1_20260120_023624_capture.html
Normal file
BIN
backend/app/samples/debug/1_20260120_023624_capture.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
8655
backend/app/samples/debug/1_20260120_025211_capture.html
Normal file
BIN
backend/app/samples/debug/1_20260120_025211_capture.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
9096
backend/app/samples/debug/1_20260120_184325_capture.html
Normal file
BIN
backend/app/samples/debug/1_20260120_184325_capture.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
9085
backend/app/samples/debug/1_20260120_190108_capture.html
Normal file
BIN
backend/app/samples/debug/1_20260120_190108_capture.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |