| """ |
| 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=["*"], |
| ) |
|
|
|
|
| |
| |
| |
|
|
|
|
| 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" |
|
|
|
|
| |
| |
| |
|
|
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
|
|
| @app.get("/health", tags=["Système"]) |
| def health(): |
| return {"status": "ok", "timestamp": datetime.now(timezone.utc).isoformat()} |
|
|
|
|
| |
| |
| |
|
|
|
|
| @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() |
|
|
|
|
| |
| |
| |
|
|
|
|
| @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( |
| |
| |
| |
|
|
|
|
| def _parse_num(val: str) -> float: |
| try: |
| return float(val.replace(",", ".")) |
| except (ValueError, AttributeError): |
| return 0.0 |