PyNote / app.py
Voxxium's picture
Create app.py
4d946b0 verified
"""
API PCN + PRONOTE β€” HuggingFace Spaces Free Tier
Endpoints REST pour scraper ENT Paris Classe NumΓ©rique et PRONOTE.
"""
from __future__ import annotations
import os
import logging
from datetime import datetime, timezone
from typing import Optional
from enum import Enum
from fastapi import FastAPI, HTTPException, Query, Depends
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
from pcn import Config as PCNConfig, ENTClient
from pronote_client import Config as PronoteConfig, PronoteClient
logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s")
log = logging.getLogger("api")
app = FastAPI(
title="PCN + PRONOTE API",
description="API REST pour rΓ©cupΓ©rer notifications, messages, notes, devoirs, EDT depuis ENT PCN et PRONOTE.",
version="1.0.0",
docs_url="/",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Modèles Pydantic
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class Credentials(BaseModel):
login: str = Field(..., description="Identifiant")
password: str = Field(..., description="Mot de passe")
class PCNRequest(Credentials):
hours: int = Field(24, ge=1, le=720, description="FenΓͺtre temporelle en heures")
fetch_body: bool = Field(True, description="RΓ©cupΓ©rer le corps des messages")
fetch_attachments: bool = Field(False, description="Télécharger les pièces jointes")
class PronoteRequest(Credentials):
pronote_url: str = Field(..., description="URL PRONOTE élève")
ent: str = Field("", description="ENT (ex: ent_parisclassenumerique, vide=direct)")
hours: int = Field(168, ge=1, le=2160, description="FenΓͺtre temporelle en heures")
fetch_attachments: bool = Field(False, description="Télécharger les pièces jointes")
class PronoteModules(str, Enum):
grades = "grades"
homework = "homework"
timetable = "timetable"
messages = "messages"
absences = "absences"
info = "info"
class NotifFilter(str, Enum):
MESSAGERIE = "MESSAGERIE"
BLOG = "BLOG"
ACTUALITES = "ACTUALITES"
EXERCIZER = "EXERCIZER"
COMMUNITIES = "COMMUNITIES"
WIKI = "WIKI"
SCRAPBOOK = "SCRAPBOOK"
TIMELINEGENERATOR = "TIMELINEGENERATOR"
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Helpers
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def _pcn_client(req: PCNRequest) -> ENTClient:
cfg = PCNConfig()
cfg.login = req.login
cfg.password = req.password
cfg.hours_back = req.hours
cfg.fetch_body = req.fetch_body
cfg.fetch_attachments = req.fetch_attachments
client = ENTClient(cfg)
try:
client.login()
except SystemExit:
raise HTTPException(401, "Γ‰chec connexion PCN β€” identifiants invalides")
except Exception as e:
raise HTTPException(502, f"Erreur connexion PCN : {e}")
return client
def _pronote_client(req: PronoteRequest) -> PronoteClient:
cfg = PronoteConfig()
cfg.pronote_url = req.pronote_url
cfg.login = req.login
cfg.password = req.password
cfg.ent = req.ent
cfg.hours_back = req.hours
cfg.fetch_attachments = req.fetch_attachments
cfg.dry_run = not req.fetch_attachments
client = PronoteClient(cfg)
try:
client.login()
except SystemExit:
raise HTTPException(401, "Γ‰chec connexion PRONOTE β€” identifiants ou URL invalides")
except Exception as e:
raise HTTPException(502, f"Erreur connexion PRONOTE : {e}")
return client
def _serialize(obj):
"""Convertit dataclasses en dicts rΓ©cursivement."""
from dataclasses import asdict, is_dataclass
if is_dataclass(obj):
return asdict(obj)
if isinstance(obj, list):
return [_serialize(x) for x in obj]
return obj
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Health
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@app.get("/health", tags=["Système"])
def health():
return {"status": "ok", "timestamp": datetime.now(timezone.utc).isoformat()}
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# PCN β€” Endpoints
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@app.post("/pcn/all", tags=["PCN"], summary="Tout rΓ©cupΓ©rer (notifications + messages)")
def pcn_all(req: PCNRequest):
client = _pcn_client(req)
try:
notifs = client.fetch_notifications()
raw = client.fetch_messages()
msgs = client.process(raw)
report = client.build_report(notifs, msgs)
return _serialize(report)
finally:
client.close()
@app.post("/pcn/notifications", tags=["PCN"], summary="Notifications rΓ©centes")
def pcn_notifications(
req: PCNRequest,
type_filter: Optional[list[NotifFilter]] = Query(None, alias="type", description="Filtrer par type"),
sender: Optional[str] = Query(None, description="Filtrer par expΓ©diteur (contient)"),
limit: int = Query(100, ge=1, le=500, description="Nombre max de rΓ©sultats"),
):
client = _pcn_client(req)
try:
notifs = client.fetch_notifications()
if type_filter:
allowed = {t.value for t in type_filter}
notifs = [n for n in notifs if n.type in allowed]
if sender:
s = sender.lower()
notifs = [n for n in notifs if s in n.sender.lower()]
notifs = notifs[:limit]
return {
"count": len(notifs),
"notifications": _serialize(notifs),
}
finally:
client.close()
@app.post("/pcn/messages", tags=["PCN"], summary="Messages non lus")
def pcn_messages(
req: PCNRequest,
sender: Optional[str] = Query(None, description="Filtrer par expΓ©diteur (contient)"),
subject: Optional[str] = Query(None, description="Filtrer par sujet (contient)"),
role: Optional[str] = Query(None, description="Filtrer par rΓ΄le (Teacher, Student, Relative…)"),
has_attachments: Optional[bool] = Query(None, description="Avec pièces jointes uniquement"),
limit: int = Query(50, ge=1, le=200),
):
client = _pcn_client(req)
try:
raw = client.fetch_messages()
msgs = client.process(raw)
if sender:
s = sender.lower()
msgs = [m for m in msgs if s in m.sender.lower()]
if subject:
s = subject.lower()
msgs = [m for m in msgs if s in m.subject.lower()]
if role:
r = role.lower()
msgs = [m for m in msgs if r in m.role.lower()]
if has_attachments is not None:
msgs = [m for m in msgs if m.has_attachments == has_attachments]
msgs = msgs[:limit]
return {
"count": len(msgs),
"messages": _serialize(msgs),
}
finally:
client.close()
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# PRONOTE β€” Endpoints
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@app.post("/pronote/all", tags=["PRONOTE"], summary="Tout rΓ©cupΓ©rer")
def pronote_all(
req: PronoteRequest,
modules: Optional[list[PronoteModules]] = Query(None, description="Modules Γ  rΓ©cupΓ©rer (dΓ©faut: tous)"),
):
client = _pronote_client(req)
try:
mods = {m.value for m in modules} if modules else {"grades", "homework", "timetable", "messages", "absences", "info"}
grades = client.fetch_grades() if "grades" in mods else []
homework = client.fetch_homework() if "homework" in mods else []
timetable = client.fetch_timetable() if "timetable" in mods else []
messages = client.fetch_messages() if "messages" in mods else []
absences = client.fetch_absences() if "absences" in mods else []
info = client.fetch_info() if "info" in mods else []
report = client.build_report(grades, homework, timetable, messages, absences, info)
return _serialize(report)
finally:
client.close()
@app.post("/pronote/grades", tags=["PRONOTE"], summary="Notes")
def pronote_grades(
req: PronoteRequest,
subject: Optional[str] = Query(None, description="Filtrer par matière (contient)"),
min_grade: Optional[float] = Query(None, description="Note minimale"),
max_grade: Optional[float] = Query(None, description="Note maximale"),
limit: int = Query(100, ge=1, le=500),
):
client = _pronote_client(req)
try:
grades = client.fetch_grades()
if subject:
s = subject.lower()
grades = [g for g in grades if s in g.subject.lower()]
if min_grade is not None:
grades = [g for g in grades if _parse_num(g.grade) >= min_grade]
if max_grade is not None:
grades = [g for g in grades if _parse_num(g.grade) <= max_grade]
grades = grades[:limit]
return {"count": len(grades), "grades": _serialize(grades)}
finally:
client.close()
@app.post("/pronote/homework", tags=["PRONOTE"], summary="Devoirs")
def pronote_homework(
req: PronoteRequest,
subject: Optional[str] = Query(None, description="Filtrer par matière"),
done: Optional[bool] = Query(None, description="Filtrer fait/non fait"),
limit: int = Query(100, ge=1, le=500),
):
client = _pronote_client(req)
try:
hw = client.fetch_homework()
if subject:
s = subject.lower()
hw = [h for h in hw if s in h.subject.lower()]
if done is not None:
hw = [h for h in hw if h.done == done]
hw = hw[:limit]
return {"count": len(hw), "homework": _serialize(hw)}
finally:
client.close()
@app.post("/pronote/timetable", tags=["PRONOTE"], summary="Emploi du temps (14 jours)")
def pronote_timetable(
req: PronoteRequest,
subject: Optional[str] = Query(None, description="Filtrer par matière"),
teacher: Optional[str] = Query(None, description="Filtrer par professeur"),
cancelled: Optional[bool] = Query(None, description="Cours annulΓ©s uniquement"),
date: Optional[str] = Query(None, description="Filtrer par date (YYYY-MM-DD)"),
):
client = _pronote_client(req)
try:
lessons = client.fetch_timetable()
if subject:
s = subject.lower()
lessons = [l for l in lessons if s in l.subject.lower()]
if teacher:
t = teacher.lower()
lessons = [l for l in lessons if t in l.teacher.lower()]
if cancelled is not None:
lessons = [l for l in lessons if l.is_cancelled == cancelled]
if date:
lessons = [l for l in lessons if l.start.startswith(date)]
return {"count": len(lessons), "timetable": _serialize(lessons)}
finally:
client.close()
@app.post("/pronote/messages", tags=["PRONOTE"], summary="Messages")
def pronote_messages(
req: PronoteRequest,
sender: Optional[str] = Query(None, description="Filtrer par expΓ©diteur"),
unread: Optional[bool] = Query(None, description="Non lus uniquement"),
limit: int = Query(50, ge=1, le=200),
):
client = _pronote_client(req)
try:
msgs = client.fetch_messages()
if sender:
s = sender.lower()
msgs = [m for m in msgs if s in m.sender.lower()]
if unread is not None:
msgs = [m for m in msgs if (not m.read) == unread]
msgs = msgs[:limit]
return {"count": len(msgs), "messages": _serialize(msgs)}
finally:
client.close()
@app.post("/pronote/absences", tags=["PRONOTE"], summary="Absences")
def pronote_absences(
req: PronoteRequest,
justified: Optional[bool] = Query(None, description="JustifiΓ©e ou non"),
):
client = _pronote_client(req)
try:
absences = client.fetch_absences()
if justified is not None:
absences = [a for a in absences if a.justified == justified]
return {"count": len(absences), "absences": _serialize(absences)}
finally:
client.close()
@app.post("/pronote/info", tags=["PRONOTE"], summary="Informations scolaires")
def pronote_info(
req: PronoteRequest,
limit: int = Query(50, ge=1, le=200),
):
client = _pronote_client(req)
try:
info = client.fetch_info()
info = info[:limit]
return {"count": len(info), "info": _serialize(info)}
finally:
client.close(
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Utils
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def _parse_num(val: str) -> float:
try:
return float(val.replace(",", "."))
except (ValueError, AttributeError):
return 0.0