Ajout de la webapp Docker (FastAPI + HTML/JS vanilla)

- Backend FastAPI avec auth par cookie (users dans config.json)
- Upload PDF drag & drop, progression en temps réel (SSE)
- Identification des séries via Ollama (config URL dans config.json)
- Téléchargement ICS par série + historique des traitements
- Bouton vider le cache (site web + mapping LLM)
- Docker Swarm ready (docker-compose.yml + Dockerfile)
- Compatible iOS/Android/PC (responsive mobile-first)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sylvain
2026-03-08 13:08:18 +01:00
parent 325d676ccf
commit d4067e9105
10 changed files with 1478 additions and 0 deletions
+287
View File
@@ -0,0 +1,287 @@
"""
app.py - Backend FastAPI pour planning2ics web app.
"""
import asyncio
import json
import secrets
import uuid
from datetime import datetime
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, Depends, File, HTTPException, Request, Response, UploadFile, Cookie
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
import core
# ── Chemins ───────────────────────────────────────────────────────────────────
CONFIG_PATH = Path("/app/config.json")
DATA_DIR = Path("/app/data")
def load_config() -> dict:
with open(CONFIG_PATH) as f:
return json.load(f)
# ── App ───────────────────────────────────────────────────────────────────────
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app):
# Création des répertoires de données au démarrage
for _d in ["cache", "jobs", "uploads"]:
(DATA_DIR / _d).mkdir(parents=True, exist_ok=True)
yield
app = FastAPI(title="planning2ics", docs_url=None, redoc_url=None, lifespan=lifespan)
app.mount("/static", StaticFiles(directory="/app/static"), name="static")
# ── Auth ──────────────────────────────────────────────────────────────────────
sessions: dict[str, str] = {} # token → username
def get_current_user(session: Optional[str] = Cookie(default=None)) -> str:
if not session or session not in sessions:
raise HTTPException(status_code=401, detail="Non authentifié")
return sessions[session]
# ── Pages ─────────────────────────────────────────────────────────────────────
@app.get("/")
async def root():
return FileResponse("/app/static/index.html")
@app.get("/api/health")
async def health():
return {"status": "ok"}
# ── Auth endpoints ────────────────────────────────────────────────────────────
@app.post("/api/auth/login")
async def login(request: Request, response: Response):
data = await request.json()
config = load_config()
username = data.get("username", "")
password = data.get("password", "")
for user in config["auth"]["users"]:
if user["username"] == username and user["password"] == password:
token = secrets.token_hex(32)
sessions[token] = username
response.set_cookie(
key="session", value=token,
httponly=True, samesite="lax", max_age=86400 * 7
)
return {"ok": True, "username": username}
raise HTTPException(status_code=401, detail="Identifiants incorrects")
@app.post("/api/auth/logout")
async def logout(response: Response, session: Optional[str] = Cookie(default=None)):
if session and session in sessions:
del sessions[session]
response.delete_cookie("session")
return {"ok": True}
@app.get("/api/auth/me")
async def me(user: str = Depends(get_current_user)):
return {"username": user}
# ── Config publique ───────────────────────────────────────────────────────────
@app.get("/api/config")
async def public_config(user: str = Depends(get_current_user)):
cfg = load_config()
return {
"ollama_url": cfg["ollama"]["url"],
"cluster_model": cfg["ollama"]["cluster_model"],
"local_model": cfg["ollama"]["local_model"],
}
# ── Traitement PDF ────────────────────────────────────────────────────────────
jobs: dict[str, dict] = {}
@app.post("/api/process")
async def start_processing(
files: list[UploadFile],
user: str = Depends(get_current_user),
):
job_id = str(uuid.uuid4())
queue = asyncio.Queue()
upload_dir = DATA_DIR / "uploads" / job_id
upload_dir.mkdir(parents=True)
saved_paths = []
pdf_names = []
for file in files:
if not file.filename.lower().endswith('.pdf'):
continue
dest = upload_dir / file.filename
dest.write_bytes(await file.read())
saved_paths.append(dest)
pdf_names.append(file.filename)
if not saved_paths:
raise HTTPException(400, "Aucun fichier PDF valide fourni")
jobs[job_id] = {
"status": "running",
"queue": queue,
"result": None,
"created_at": datetime.now().isoformat(),
"pdf_names": pdf_names,
"user": user,
}
asyncio.create_task(_run_processing(job_id, saved_paths, queue))
return {"job_id": job_id}
async def _run_processing(job_id: str, pdf_paths: list, queue: asyncio.Queue):
loop = asyncio.get_running_loop()
config = load_config()
def log(msg: str):
asyncio.run_coroutine_threadsafe(
queue.put({"type": "progress", "message": msg}), loop
)
try:
result = await loop.run_in_executor(
None, lambda: core.process_pdfs(pdf_paths, config, DATA_DIR, log)
)
# Sauvegarder les ICS
output_dir = DATA_DIR / "jobs" / job_id
output_dir.mkdir(parents=True, exist_ok=True)
series_list = []
for series_title, data in result.items():
(output_dir / data['filename']).write_bytes(data['bytes'])
series_list.append({
"name": series_title,
"filename": data['filename'],
"event_count": data['event_count'],
})
meta = {
"job_id": job_id,
"created_at": jobs[job_id]["created_at"],
"pdf_names": jobs[job_id]["pdf_names"],
"series": series_list,
}
(output_dir / "metadata.json").write_text(
json.dumps(meta, ensure_ascii=False, indent=2)
)
jobs[job_id]["status"] = "done"
jobs[job_id]["result"] = series_list
await queue.put({"type": "done", "series": series_list})
except Exception as e:
import traceback
traceback.print_exc()
jobs[job_id]["status"] = "error"
await queue.put({"type": "error", "message": str(e)})
@app.get("/api/progress/{job_id}")
async def progress_stream(job_id: str, user: str = Depends(get_current_user)):
if job_id not in jobs:
raise HTTPException(404, "Job introuvable")
async def event_stream():
q = jobs[job_id]["queue"]
while True:
try:
msg = await asyncio.wait_for(q.get(), timeout=30)
yield f"data: {json.dumps(msg, ensure_ascii=False)}\n\n"
if msg["type"] in ("done", "error"):
break
except asyncio.TimeoutError:
yield "data: {\"type\":\"ping\"}\n\n"
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# ── Jobs ──────────────────────────────────────────────────────────────────────
@app.get("/api/jobs")
async def list_jobs(user: str = Depends(get_current_user)):
result = []
jobs_dir = DATA_DIR / "jobs"
if jobs_dir.exists():
for d in sorted(jobs_dir.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True):
meta = d / "metadata.json"
if meta.exists():
result.append(json.loads(meta.read_text()))
return result
@app.get("/api/jobs/{job_id}")
async def get_job(job_id: str, user: str = Depends(get_current_user)):
if job_id in jobs:
j = jobs[job_id]
return {
"job_id": job_id,
"status": j["status"],
"pdf_names": j["pdf_names"],
"series": j.get("result"),
"created_at": j["created_at"],
}
meta = DATA_DIR / "jobs" / job_id / "metadata.json"
if meta.exists():
return json.loads(meta.read_text())
raise HTTPException(404, "Job introuvable")
@app.get("/api/download/{job_id}/{filename}")
async def download_ics(
job_id: str, filename: str, user: str = Depends(get_current_user)
):
# Sécurité : empêcher path traversal
filename = Path(filename).name
ics_path = DATA_DIR / "jobs" / job_id / filename
if not ics_path.exists():
raise HTTPException(404, "Fichier introuvable")
return FileResponse(
ics_path, media_type="text/calendar", filename=filename,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# ── Cache ─────────────────────────────────────────────────────────────────────
@app.get("/api/cache/status")
async def cache_status(user: str = Depends(get_current_user)):
cache_dir = DATA_DIR / "cache"
return {
"website_cached": (cache_dir / "website_catalog.json").exists(),
"series_cached": (cache_dir / "series_mapping.json").exists(),
}
@app.delete("/api/cache")
async def clear_cache(user: str = Depends(get_current_user)):
cache_dir = DATA_DIR / "cache"
deleted = []
for name in ["website_catalog.json", "series_mapping.json"]:
p = cache_dir / name
if p.exists():
p.unlink()
deleted.append(name)
return {"deleted": deleted}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)