Voxxium commited on
Commit
4d946b0
·
verified ·
1 Parent(s): 951392d

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +389 -0
app.py ADDED
@@ -0,0 +1,389 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ API PCN + PRONOTE — HuggingFace Spaces Free Tier
3
+ Endpoints REST pour scraper ENT Paris Classe Numérique et PRONOTE.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import logging
10
+ from datetime import datetime, timezone
11
+ from typing import Optional
12
+ from enum import Enum
13
+
14
+ from fastapi import FastAPI, HTTPException, Query, Depends
15
+ from fastapi.middleware.cors import CORSMiddleware
16
+ from pydantic import BaseModel, Field
17
+
18
+ from pcn import Config as PCNConfig, ENTClient
19
+ from pronote_client import Config as PronoteConfig, PronoteClient
20
+
21
+ logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s")
22
+ log = logging.getLogger("api")
23
+
24
+ app = FastAPI(
25
+ title="PCN + PRONOTE API",
26
+ description="API REST pour récupérer notifications, messages, notes, devoirs, EDT depuis ENT PCN et PRONOTE.",
27
+ version="1.0.0",
28
+ docs_url="/",
29
+ )
30
+
31
+ app.add_middleware(
32
+ CORSMiddleware,
33
+ allow_origins=["*"],
34
+ allow_methods=["*"],
35
+ allow_headers=["*"],
36
+ )
37
+
38
+
39
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
40
+ # Modèles Pydantic
41
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
42
+
43
+
44
+ class Credentials(BaseModel):
45
+ login: str = Field(..., description="Identifiant")
46
+ password: str = Field(..., description="Mot de passe")
47
+
48
+
49
+ class PCNRequest(Credentials):
50
+ hours: int = Field(24, ge=1, le=720, description="Fenêtre temporelle en heures")
51
+ fetch_body: bool = Field(True, description="Récupérer le corps des messages")
52
+ fetch_attachments: bool = Field(False, description="Télécharger les pièces jointes")
53
+
54
+
55
+ class PronoteRequest(Credentials):
56
+ pronote_url: str = Field(..., description="URL PRONOTE élève")
57
+ ent: str = Field("", description="ENT (ex: ent_parisclassenumerique, vide=direct)")
58
+ hours: int = Field(168, ge=1, le=2160, description="Fenêtre temporelle en heures")
59
+ fetch_attachments: bool = Field(False, description="Télécharger les pièces jointes")
60
+
61
+
62
+ class PronoteModules(str, Enum):
63
+ grades = "grades"
64
+ homework = "homework"
65
+ timetable = "timetable"
66
+ messages = "messages"
67
+ absences = "absences"
68
+ info = "info"
69
+
70
+
71
+ class NotifFilter(str, Enum):
72
+ MESSAGERIE = "MESSAGERIE"
73
+ BLOG = "BLOG"
74
+ ACTUALITES = "ACTUALITES"
75
+ EXERCIZER = "EXERCIZER"
76
+ COMMUNITIES = "COMMUNITIES"
77
+ WIKI = "WIKI"
78
+ SCRAPBOOK = "SCRAPBOOK"
79
+ TIMELINEGENERATOR = "TIMELINEGENERATOR"
80
+
81
+
82
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
83
+ # Helpers
84
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
85
+
86
+
87
+ def _pcn_client(req: PCNRequest) -> ENTClient:
88
+ cfg = PCNConfig()
89
+ cfg.login = req.login
90
+ cfg.password = req.password
91
+ cfg.hours_back = req.hours
92
+ cfg.fetch_body = req.fetch_body
93
+ cfg.fetch_attachments = req.fetch_attachments
94
+ client = ENTClient(cfg)
95
+ try:
96
+ client.login()
97
+ except SystemExit:
98
+ raise HTTPException(401, "Échec connexion PCN — identifiants invalides")
99
+ except Exception as e:
100
+ raise HTTPException(502, f"Erreur connexion PCN : {e}")
101
+ return client
102
+
103
+
104
+ def _pronote_client(req: PronoteRequest) -> PronoteClient:
105
+ cfg = PronoteConfig()
106
+ cfg.pronote_url = req.pronote_url
107
+ cfg.login = req.login
108
+ cfg.password = req.password
109
+ cfg.ent = req.ent
110
+ cfg.hours_back = req.hours
111
+ cfg.fetch_attachments = req.fetch_attachments
112
+ cfg.dry_run = not req.fetch_attachments
113
+ client = PronoteClient(cfg)
114
+ try:
115
+ client.login()
116
+ except SystemExit:
117
+ raise HTTPException(401, "Échec connexion PRONOTE — identifiants ou URL invalides")
118
+ except Exception as e:
119
+ raise HTTPException(502, f"Erreur connexion PRONOTE : {e}")
120
+ return client
121
+
122
+
123
+ def _serialize(obj):
124
+ """Convertit dataclasses en dicts récursivement."""
125
+ from dataclasses import asdict, is_dataclass
126
+ if is_dataclass(obj):
127
+ return asdict(obj)
128
+ if isinstance(obj, list):
129
+ return [_serialize(x) for x in obj]
130
+ return obj
131
+
132
+
133
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
134
+ # Health
135
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
136
+
137
+
138
+ @app.get("/health", tags=["Système"])
139
+ def health():
140
+ return {"status": "ok", "timestamp": datetime.now(timezone.utc).isoformat()}
141
+
142
+
143
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
144
+ # PCN — Endpoints
145
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
146
+
147
+
148
+ @app.post("/pcn/all", tags=["PCN"], summary="Tout récupérer (notifications + messages)")
149
+ def pcn_all(req: PCNRequest):
150
+ client = _pcn_client(req)
151
+ try:
152
+ notifs = client.fetch_notifications()
153
+ raw = client.fetch_messages()
154
+ msgs = client.process(raw)
155
+ report = client.build_report(notifs, msgs)
156
+ return _serialize(report)
157
+ finally:
158
+ client.close()
159
+
160
+
161
+ @app.post("/pcn/notifications", tags=["PCN"], summary="Notifications récentes")
162
+ def pcn_notifications(
163
+ req: PCNRequest,
164
+ type_filter: Optional[list[NotifFilter]] = Query(None, alias="type", description="Filtrer par type"),
165
+ sender: Optional[str] = Query(None, description="Filtrer par expéditeur (contient)"),
166
+ limit: int = Query(100, ge=1, le=500, description="Nombre max de résultats"),
167
+ ):
168
+ client = _pcn_client(req)
169
+ try:
170
+ notifs = client.fetch_notifications()
171
+
172
+ if type_filter:
173
+ allowed = {t.value for t in type_filter}
174
+ notifs = [n for n in notifs if n.type in allowed]
175
+
176
+ if sender:
177
+ s = sender.lower()
178
+ notifs = [n for n in notifs if s in n.sender.lower()]
179
+
180
+ notifs = notifs[:limit]
181
+ return {
182
+ "count": len(notifs),
183
+ "notifications": _serialize(notifs),
184
+ }
185
+ finally:
186
+ client.close()
187
+
188
+
189
+ @app.post("/pcn/messages", tags=["PCN"], summary="Messages non lus")
190
+ def pcn_messages(
191
+ req: PCNRequest,
192
+ sender: Optional[str] = Query(None, description="Filtrer par expéditeur (contient)"),
193
+ subject: Optional[str] = Query(None, description="Filtrer par sujet (contient)"),
194
+ role: Optional[str] = Query(None, description="Filtrer par rôle (Teacher, Student, Relative…)"),
195
+ has_attachments: Optional[bool] = Query(None, description="Avec pièces jointes uniquement"),
196
+ limit: int = Query(50, ge=1, le=200),
197
+ ):
198
+ client = _pcn_client(req)
199
+ try:
200
+ raw = client.fetch_messages()
201
+ msgs = client.process(raw)
202
+
203
+ if sender:
204
+ s = sender.lower()
205
+ msgs = [m for m in msgs if s in m.sender.lower()]
206
+ if subject:
207
+ s = subject.lower()
208
+ msgs = [m for m in msgs if s in m.subject.lower()]
209
+ if role:
210
+ r = role.lower()
211
+ msgs = [m for m in msgs if r in m.role.lower()]
212
+ if has_attachments is not None:
213
+ msgs = [m for m in msgs if m.has_attachments == has_attachments]
214
+
215
+ msgs = msgs[:limit]
216
+ return {
217
+ "count": len(msgs),
218
+ "messages": _serialize(msgs),
219
+ }
220
+ finally:
221
+ client.close()
222
+
223
+
224
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
225
+ # PRONOTE — Endpoints
226
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
227
+
228
+
229
+ @app.post("/pronote/all", tags=["PRONOTE"], summary="Tout récupérer")
230
+ def pronote_all(
231
+ req: PronoteRequest,
232
+ modules: Optional[list[PronoteModules]] = Query(None, description="Modules à récupérer (défaut: tous)"),
233
+ ):
234
+ client = _pronote_client(req)
235
+ try:
236
+ mods = {m.value for m in modules} if modules else {"grades", "homework", "timetable", "messages", "absences", "info"}
237
+
238
+ grades = client.fetch_grades() if "grades" in mods else []
239
+ homework = client.fetch_homework() if "homework" in mods else []
240
+ timetable = client.fetch_timetable() if "timetable" in mods else []
241
+ messages = client.fetch_messages() if "messages" in mods else []
242
+ absences = client.fetch_absences() if "absences" in mods else []
243
+ info = client.fetch_info() if "info" in mods else []
244
+
245
+ report = client.build_report(grades, homework, timetable, messages, absences, info)
246
+ return _serialize(report)
247
+ finally:
248
+ client.close()
249
+
250
+
251
+ @app.post("/pronote/grades", tags=["PRONOTE"], summary="Notes")
252
+ def pronote_grades(
253
+ req: PronoteRequest,
254
+ subject: Optional[str] = Query(None, description="Filtrer par matière (contient)"),
255
+ min_grade: Optional[float] = Query(None, description="Note minimale"),
256
+ max_grade: Optional[float] = Query(None, description="Note maximale"),
257
+ limit: int = Query(100, ge=1, le=500),
258
+ ):
259
+ client = _pronote_client(req)
260
+ try:
261
+ grades = client.fetch_grades()
262
+
263
+ if subject:
264
+ s = subject.lower()
265
+ grades = [g for g in grades if s in g.subject.lower()]
266
+ if min_grade is not None:
267
+ grades = [g for g in grades if _parse_num(g.grade) >= min_grade]
268
+ if max_grade is not None:
269
+ grades = [g for g in grades if _parse_num(g.grade) <= max_grade]
270
+
271
+ grades = grades[:limit]
272
+ return {"count": len(grades), "grades": _serialize(grades)}
273
+ finally:
274
+ client.close()
275
+
276
+
277
+ @app.post("/pronote/homework", tags=["PRONOTE"], summary="Devoirs")
278
+ def pronote_homework(
279
+ req: PronoteRequest,
280
+ subject: Optional[str] = Query(None, description="Filtrer par matière"),
281
+ done: Optional[bool] = Query(None, description="Filtrer fait/non fait"),
282
+ limit: int = Query(100, ge=1, le=500),
283
+ ):
284
+ client = _pronote_client(req)
285
+ try:
286
+ hw = client.fetch_homework()
287
+
288
+ if subject:
289
+ s = subject.lower()
290
+ hw = [h for h in hw if s in h.subject.lower()]
291
+ if done is not None:
292
+ hw = [h for h in hw if h.done == done]
293
+
294
+ hw = hw[:limit]
295
+ return {"count": len(hw), "homework": _serialize(hw)}
296
+ finally:
297
+ client.close()
298
+
299
+
300
+ @app.post("/pronote/timetable", tags=["PRONOTE"], summary="Emploi du temps (14 jours)")
301
+ def pronote_timetable(
302
+ req: PronoteRequest,
303
+ subject: Optional[str] = Query(None, description="Filtrer par matière"),
304
+ teacher: Optional[str] = Query(None, description="Filtrer par professeur"),
305
+ cancelled: Optional[bool] = Query(None, description="Cours annulés uniquement"),
306
+ date: Optional[str] = Query(None, description="Filtrer par date (YYYY-MM-DD)"),
307
+ ):
308
+ client = _pronote_client(req)
309
+ try:
310
+ lessons = client.fetch_timetable()
311
+
312
+ if subject:
313
+ s = subject.lower()
314
+ lessons = [l for l in lessons if s in l.subject.lower()]
315
+ if teacher:
316
+ t = teacher.lower()
317
+ lessons = [l for l in lessons if t in l.teacher.lower()]
318
+ if cancelled is not None:
319
+ lessons = [l for l in lessons if l.is_cancelled == cancelled]
320
+ if date:
321
+ lessons = [l for l in lessons if l.start.startswith(date)]
322
+
323
+ return {"count": len(lessons), "timetable": _serialize(lessons)}
324
+ finally:
325
+ client.close()
326
+
327
+
328
+ @app.post("/pronote/messages", tags=["PRONOTE"], summary="Messages")
329
+ def pronote_messages(
330
+ req: PronoteRequest,
331
+ sender: Optional[str] = Query(None, description="Filtrer par expéditeur"),
332
+ unread: Optional[bool] = Query(None, description="Non lus uniquement"),
333
+ limit: int = Query(50, ge=1, le=200),
334
+ ):
335
+ client = _pronote_client(req)
336
+ try:
337
+ msgs = client.fetch_messages()
338
+
339
+ if sender:
340
+ s = sender.lower()
341
+ msgs = [m for m in msgs if s in m.sender.lower()]
342
+ if unread is not None:
343
+ msgs = [m for m in msgs if (not m.read) == unread]
344
+
345
+ msgs = msgs[:limit]
346
+ return {"count": len(msgs), "messages": _serialize(msgs)}
347
+ finally:
348
+ client.close()
349
+
350
+
351
+ @app.post("/pronote/absences", tags=["PRONOTE"], summary="Absences")
352
+ def pronote_absences(
353
+ req: PronoteRequest,
354
+ justified: Optional[bool] = Query(None, description="Justifiée ou non"),
355
+ ):
356
+ client = _pronote_client(req)
357
+ try:
358
+ absences = client.fetch_absences()
359
+
360
+ if justified is not None:
361
+ absences = [a for a in absences if a.justified == justified]
362
+
363
+ return {"count": len(absences), "absences": _serialize(absences)}
364
+ finally:
365
+ client.close()
366
+
367
+
368
+ @app.post("/pronote/info", tags=["PRONOTE"], summary="Informations scolaires")
369
+ def pronote_info(
370
+ req: PronoteRequest,
371
+ limit: int = Query(50, ge=1, le=200),
372
+ ):
373
+ client = _pronote_client(req)
374
+ try:
375
+ info = client.fetch_info()
376
+ info = info[:limit]
377
+ return {"count": len(info), "info": _serialize(info)}
378
+ finally:
379
+ client.close(
380
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
381
+ # Utils
382
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
383
+
384
+
385
+ def _parse_num(val: str) -> float:
386
+ try:
387
+ return float(val.replace(",", "."))
388
+ except (ValueError, AttributeError):
389
+ return 0.0