librus-python-api 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. librus_python_api/__init__.py +243 -0
  2. librus_python_api/_notification_bootstrap.py +124 -0
  3. librus_python_api/_notification_codec.py +396 -0
  4. librus_python_api/_storage.py +403 -0
  5. librus_python_api/_windows_filesystem.py +390 -0
  6. librus_python_api/announcements.py +159 -0
  7. librus_python_api/attachment_routes.py +114 -0
  8. librus_python_api/attachments.py +297 -0
  9. librus_python_api/attendance.py +182 -0
  10. librus_python_api/attendance_frequency.py +112 -0
  11. librus_python_api/budget.py +79 -0
  12. librus_python_api/checkpoint.py +61 -0
  13. librus_python_api/completed_lessons.py +216 -0
  14. librus_python_api/config.py +1410 -0
  15. librus_python_api/detail_fields.py +50 -0
  16. librus_python_api/diagnostics.py +25 -0
  17. librus_python_api/exceptions.py +172 -0
  18. librus_python_api/files.py +242 -0
  19. librus_python_api/grade_parsers.py +169 -0
  20. librus_python_api/grade_records.py +454 -0
  21. librus_python_api/homework_range.py +41 -0
  22. librus_python_api/lifecycle.py +24 -0
  23. librus_python_api/markup.py +147 -0
  24. librus_python_api/message_content.py +230 -0
  25. librus_python_api/messages.py +288 -0
  26. librus_python_api/models.py +1160 -0
  27. librus_python_api/modern_body.py +75 -0
  28. librus_python_api/modern_mailbox.py +459 -0
  29. librus_python_api/modern_messages.py +276 -0
  30. librus_python_api/notification_models.py +145 -0
  31. librus_python_api/notification_persistence.py +1243 -0
  32. librus_python_api/notification_workflow.py +337 -0
  33. librus_python_api/notifications.py +216 -0
  34. librus_python_api/parsers.py +232 -0
  35. librus_python_api/parsing.py +49 -0
  36. librus_python_api/persistence.py +419 -0
  37. librus_python_api/py.typed +0 -0
  38. librus_python_api/recipients.py +271 -0
  39. librus_python_api/scheduler.py +287 -0
  40. librus_python_api/school_reads.py +400 -0
  41. librus_python_api/sending.py +125 -0
  42. librus_python_api/service.py +2285 -0
  43. librus_python_api/timetable.py +261 -0
  44. librus_python_api/transport.py +956 -0
  45. librus_python_api-1.0.0.dist-info/METADATA +262 -0
  46. librus_python_api-1.0.0.dist-info/RECORD +48 -0
  47. librus_python_api-1.0.0.dist-info/WHEEL +4 -0
  48. librus_python_api-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1410 @@
1
+ """Central network route catalogue and transport configuration.
2
+
3
+ Add fixed upstream paths here, never in parsers or account methods. The catalogue
4
+ records source-informed routes with explicit offline/live evidence separation.
5
+ There is intentionally no public arbitrary authenticated URL interface.
6
+ """
7
+
8
+ import base64
9
+ import calendar
10
+ import json
11
+ import re
12
+ import ssl
13
+ import unicodedata
14
+ from collections.abc import Mapping
15
+ from dataclasses import dataclass
16
+ from datetime import date, timedelta
17
+ from enum import StrEnum
18
+ from html import escape
19
+ from types import MappingProxyType
20
+ from typing import Annotated, Any, Literal, Self
21
+ from urllib.parse import urlencode, urlsplit
22
+
23
+ from pydantic import (
24
+ BaseModel,
25
+ ConfigDict,
26
+ Field,
27
+ SecretStr,
28
+ ValidationError,
29
+ ValidationInfo,
30
+ field_validator,
31
+ model_validator,
32
+ )
33
+
34
+ from librus_python_api.exceptions import ErrorKind, LibrusError
35
+ from librus_python_api.models import (
36
+ AttendanceView,
37
+ GradeView,
38
+ HomeworkRangeRequest,
39
+ MessageFolder,
40
+ ModernRecipientReference,
41
+ ModernSendSubmission,
42
+ RecipientReference,
43
+ SendSubmission,
44
+ )
45
+
46
+ HttpMethod = Literal["GET", "POST"]
47
+
48
+
49
+ class SideEffect(StrEnum):
50
+ NONE = "none"
51
+ AUTHENTICATION = "authentication"
52
+ MARK_READ = "mark_read"
53
+ CONSUME_EVENTS = "consume_events"
54
+ SEND_MESSAGE = "send_message"
55
+ SELECT_VIEW = "select_view"
56
+
57
+
58
+ class Evidence(StrEnum):
59
+ SYNTHETIC_ONLY = "synthetic_only"
60
+ SOURCE_INFORMED = "source_informed"
61
+ INDEPENDENTLY_OBSERVED = "independently_observed"
62
+
63
+
64
+ @dataclass(frozen=True, slots=True)
65
+ class Endpoint:
66
+ """Fixed route metadata, not a claim that an HTTP verb is retry-safe.
67
+
68
+ Evidence describes the source of the wire contract, not live compatibility
69
+ at any later date. Every future endpoint also needs a contract evidence note.
70
+ """
71
+
72
+ operation_id: str
73
+ method: HttpMethod
74
+ path: str
75
+ side_effect: SideEffect
76
+ retry_safe: bool
77
+ evidence: Evidence
78
+ origin: Literal["synergia", "api", "download", "messages"] = "synergia"
79
+
80
+ def __post_init__(self) -> None:
81
+ if not re.fullmatch(r"[a-z][a-z0-9_]{0,79}", self.operation_id):
82
+ raise LibrusError(ErrorKind.INVALID_INPUT)
83
+ if self.method not in ("GET", "POST"):
84
+ raise LibrusError(ErrorKind.INVALID_INPUT)
85
+ if not re.fullmatch(r"/(?:[A-Za-z0-9_{}.-]+/)*[A-Za-z0-9_{}.-]*", self.path):
86
+ raise LibrusError(ErrorKind.INVALID_INPUT)
87
+ if any(segment in (".", "..") for segment in self.path.split("/")):
88
+ raise LibrusError(ErrorKind.INVALID_INPUT)
89
+ if len(self.path) > 256:
90
+ raise LibrusError(ErrorKind.INVALID_INPUT)
91
+ if not isinstance(self.side_effect, SideEffect):
92
+ raise LibrusError(ErrorKind.INVALID_INPUT)
93
+ if not isinstance(self.evidence, Evidence):
94
+ raise LibrusError(ErrorKind.INVALID_INPUT)
95
+ if type(self.retry_safe) is not bool:
96
+ raise LibrusError(ErrorKind.INVALID_INPUT)
97
+ if self.side_effect != SideEffect.NONE and self.retry_safe:
98
+ raise LibrusError(ErrorKind.INVALID_INPUT)
99
+ if self.origin not in ("synergia", "api", "download", "messages"):
100
+ raise LibrusError(ErrorKind.INVALID_INPUT)
101
+
102
+
103
+ # Future login, JSON, messaging, and HTML routes all belong in this catalogue.
104
+ # Contract checks compare every method/path against the versioned OpenAPI YAML.
105
+ UPSTREAM_ORIGINS = MappingProxyType(
106
+ {
107
+ "synergia": "https://synergia.librus.pl",
108
+ "api": "https://api.librus.pl",
109
+ "download": "https://sandbox.librus.pl",
110
+ "messages": "https://wiadomosci.librus.pl",
111
+ }
112
+ )
113
+ OAUTH_QUERY = (("client_id", "46"),)
114
+ SESSION_COOKIE = "oauth_token"
115
+ AUTH_COOKIES = frozenset({SESSION_COOKIE, "DZIENNIKSID", "SDZIENNIKSID"})
116
+ USER_AGENT = "librus-python-api/0.4 (independent client)"
117
+ ENDPOINTS: Mapping[str, Endpoint] = MappingProxyType(
118
+ {
119
+ item.operation_id: item
120
+ for item in (
121
+ Endpoint(
122
+ "modern_launch",
123
+ "GET",
124
+ "/wiadomosci3",
125
+ SideEffect.AUTHENTICATION,
126
+ False,
127
+ Evidence.INDEPENDENTLY_OBSERVED,
128
+ ),
129
+ Endpoint(
130
+ "modern_handoff",
131
+ "GET",
132
+ "/{namespace}/MultiDomainLogon/token/{token}/login/{login}/target/{target}/from/{source}",
133
+ SideEffect.AUTHENTICATION,
134
+ False,
135
+ Evidence.INDEPENDENTLY_OBSERVED,
136
+ "messages",
137
+ ),
138
+ Endpoint(
139
+ "modern_identity",
140
+ "GET",
141
+ "/api/me",
142
+ SideEffect.NONE,
143
+ False,
144
+ Evidence.INDEPENDENTLY_OBSERVED,
145
+ "messages",
146
+ ),
147
+ Endpoint(
148
+ "modern_recipient_types",
149
+ "GET",
150
+ "/api/receivers/types",
151
+ SideEffect.NONE,
152
+ False,
153
+ Evidence.INDEPENDENTLY_OBSERVED,
154
+ "messages",
155
+ ),
156
+ Endpoint(
157
+ "modern_recipients",
158
+ "GET",
159
+ "/api/receivers/groups/students-and-attendants",
160
+ SideEffect.NONE,
161
+ False,
162
+ Evidence.INDEPENDENTLY_OBSERVED,
163
+ "messages",
164
+ ),
165
+ Endpoint(
166
+ "modern_send_message",
167
+ "POST",
168
+ "/api/messages",
169
+ SideEffect.SEND_MESSAGE,
170
+ False,
171
+ Evidence.INDEPENDENTLY_OBSERVED,
172
+ "messages",
173
+ ),
174
+ Endpoint(
175
+ "modern_school_recipients",
176
+ "GET",
177
+ "/api/receivers/groups/school-employees",
178
+ SideEffect.NONE,
179
+ False,
180
+ Evidence.INDEPENDENTLY_OBSERVED,
181
+ "messages",
182
+ ),
183
+ Endpoint(
184
+ "modern_class_parents",
185
+ "GET",
186
+ "/api/receivers/groups/class-parents",
187
+ SideEffect.NONE,
188
+ False,
189
+ Evidence.SOURCE_INFORMED,
190
+ "messages",
191
+ ),
192
+ Endpoint(
193
+ "modern_messages_received",
194
+ "GET",
195
+ "/api/inbox/messages",
196
+ SideEffect.NONE,
197
+ False,
198
+ Evidence.INDEPENDENTLY_OBSERVED,
199
+ "messages",
200
+ ),
201
+ Endpoint(
202
+ "modern_messages_sent",
203
+ "GET",
204
+ "/api/outbox/messages",
205
+ SideEffect.NONE,
206
+ False,
207
+ Evidence.INDEPENDENTLY_OBSERVED,
208
+ "messages",
209
+ ),
210
+ Endpoint(
211
+ "modern_content_received",
212
+ "GET",
213
+ "/api/inbox/messages/{id}",
214
+ SideEffect.MARK_READ,
215
+ False,
216
+ Evidence.INDEPENDENTLY_OBSERVED,
217
+ "messages",
218
+ ),
219
+ Endpoint(
220
+ "modern_content_sent",
221
+ "GET",
222
+ "/api/outbox/messages/{id}",
223
+ SideEffect.NONE,
224
+ False,
225
+ Evidence.INDEPENDENTLY_OBSERVED,
226
+ "messages",
227
+ ),
228
+ Endpoint(
229
+ "consume_schedule_events",
230
+ "GET",
231
+ "/terminarz/dodane_od_ostatniego_logowania",
232
+ SideEffect.CONSUME_EVENTS,
233
+ False,
234
+ Evidence.SOURCE_INFORMED,
235
+ ),
236
+ Endpoint(
237
+ "attachment_resolve",
238
+ "GET",
239
+ "/wiadomosci/pobierz_zalacznik/{message_id}/{file_id}",
240
+ SideEffect.NONE,
241
+ False,
242
+ Evidence.SOURCE_INFORMED,
243
+ ),
244
+ Endpoint(
245
+ "attachment_download",
246
+ "GET",
247
+ "/GetFile/{key}/get",
248
+ SideEffect.NONE,
249
+ False,
250
+ Evidence.SOURCE_INFORMED,
251
+ "download",
252
+ ),
253
+ Endpoint(
254
+ "login_portal",
255
+ "GET",
256
+ "/loguj/portalRodzina",
257
+ SideEffect.AUTHENTICATION,
258
+ False,
259
+ Evidence.INDEPENDENTLY_OBSERVED,
260
+ ),
261
+ Endpoint(
262
+ "modern_attachment_resolve",
263
+ "GET",
264
+ "/api/attachments/{file_id}/messages/{message_id}",
265
+ SideEffect.NONE,
266
+ False,
267
+ Evidence.INDEPENDENTLY_OBSERVED,
268
+ "messages",
269
+ ),
270
+ Endpoint(
271
+ "modern_archive_attachment_resolve",
272
+ "GET",
273
+ "/api/archive/attachments/{file_id}/messages/{message_id}",
274
+ SideEffect.NONE,
275
+ False,
276
+ Evidence.SOURCE_INFORMED,
277
+ "messages",
278
+ ),
279
+ Endpoint(
280
+ "login_authorization",
281
+ "GET",
282
+ "/OAuth/Authorization",
283
+ SideEffect.AUTHENTICATION,
284
+ False,
285
+ Evidence.INDEPENDENTLY_OBSERVED,
286
+ "api",
287
+ ),
288
+ Endpoint(
289
+ "login_submit",
290
+ "POST",
291
+ "/OAuth/Authorization",
292
+ SideEffect.AUTHENTICATION,
293
+ False,
294
+ Evidence.INDEPENDENTLY_OBSERVED,
295
+ "api",
296
+ ),
297
+ Endpoint(
298
+ "login_continue",
299
+ "GET",
300
+ "/OAuth/Authorization/2FA",
301
+ SideEffect.AUTHENTICATION,
302
+ False,
303
+ Evidence.INDEPENDENTLY_OBSERVED,
304
+ "api",
305
+ ),
306
+ Endpoint(
307
+ "login_callback",
308
+ "GET",
309
+ "/loguj",
310
+ SideEffect.AUTHENTICATION,
311
+ False,
312
+ Evidence.SYNTHETIC_ONLY,
313
+ ),
314
+ Endpoint(
315
+ "login_perform",
316
+ "GET",
317
+ "/OAuth/Authorization/PerformLogin",
318
+ SideEffect.AUTHENTICATION,
319
+ False,
320
+ Evidence.INDEPENDENTLY_OBSERVED,
321
+ "api",
322
+ ),
323
+ Endpoint(
324
+ "login_grant",
325
+ "GET",
326
+ "/OAuth/Authorization/Grant",
327
+ SideEffect.AUTHENTICATION,
328
+ False,
329
+ Evidence.INDEPENDENTLY_OBSERVED,
330
+ "api",
331
+ ),
332
+ Endpoint(
333
+ "login_landing",
334
+ "GET",
335
+ "/",
336
+ SideEffect.AUTHENTICATION,
337
+ False,
338
+ Evidence.SYNTHETIC_ONLY,
339
+ ),
340
+ Endpoint(
341
+ "notification_counts",
342
+ "GET",
343
+ "/uczen/index",
344
+ SideEffect.AUTHENTICATION,
345
+ False,
346
+ Evidence.SOURCE_INFORMED,
347
+ ),
348
+ Endpoint(
349
+ "identity",
350
+ "GET",
351
+ "/gateway/api/2.0/Me",
352
+ SideEffect.NONE,
353
+ True,
354
+ Evidence.INDEPENDENTLY_OBSERVED,
355
+ ),
356
+ Endpoint(
357
+ "student_information",
358
+ "GET",
359
+ "/informacja",
360
+ SideEffect.NONE,
361
+ True,
362
+ Evidence.INDEPENDENTLY_OBSERVED,
363
+ ),
364
+ Endpoint(
365
+ "final_grades",
366
+ "GET",
367
+ "/przegladaj_oceny/uczen",
368
+ SideEffect.NONE,
369
+ True,
370
+ Evidence.INDEPENDENTLY_OBSERVED,
371
+ ),
372
+ Endpoint(
373
+ "grades",
374
+ "POST",
375
+ "/przegladaj_oceny/uczen",
376
+ SideEffect.SELECT_VIEW,
377
+ False,
378
+ Evidence.INDEPENDENTLY_OBSERVED,
379
+ ),
380
+ Endpoint(
381
+ "attendance",
382
+ "POST",
383
+ "/przegladaj_nb/uczen",
384
+ SideEffect.SELECT_VIEW,
385
+ False,
386
+ Evidence.INDEPENDENTLY_OBSERVED,
387
+ ),
388
+ Endpoint(
389
+ "attendance_detail",
390
+ "GET",
391
+ "/przegladaj_nb/szczegoly/{id}",
392
+ SideEffect.NONE,
393
+ True,
394
+ Evidence.INDEPENDENTLY_OBSERVED,
395
+ ),
396
+ Endpoint(
397
+ "gateway_attendance",
398
+ "GET",
399
+ "/gateway/api/2.0/Attendances",
400
+ SideEffect.NONE,
401
+ True,
402
+ Evidence.INDEPENDENTLY_OBSERVED,
403
+ ),
404
+ Endpoint(
405
+ "attendance_lesson",
406
+ "GET",
407
+ "/gateway/api/2.0/Lessons/{id}",
408
+ SideEffect.NONE,
409
+ True,
410
+ Evidence.INDEPENDENTLY_OBSERVED,
411
+ ),
412
+ Endpoint(
413
+ "attendance_subject",
414
+ "GET",
415
+ "/gateway/api/2.0/Subjects/{id}",
416
+ SideEffect.NONE,
417
+ True,
418
+ Evidence.INDEPENDENTLY_OBSERVED,
419
+ ),
420
+ Endpoint(
421
+ "timetable",
422
+ "POST",
423
+ "/przegladaj_plan_lekcji",
424
+ SideEffect.SELECT_VIEW,
425
+ False,
426
+ Evidence.INDEPENDENTLY_OBSERVED,
427
+ ),
428
+ Endpoint(
429
+ "announcements",
430
+ "GET",
431
+ "/ogloszenia",
432
+ SideEffect.NONE,
433
+ True,
434
+ Evidence.INDEPENDENTLY_OBSERVED,
435
+ ),
436
+ Endpoint(
437
+ "agenda",
438
+ "POST",
439
+ "/terminarz/",
440
+ SideEffect.SELECT_VIEW,
441
+ False,
442
+ Evidence.INDEPENDENTLY_OBSERVED,
443
+ ),
444
+ Endpoint(
445
+ "agenda_detail",
446
+ "GET",
447
+ "/terminarz/szczegoly/{id}",
448
+ SideEffect.NONE,
449
+ True,
450
+ Evidence.INDEPENDENTLY_OBSERVED,
451
+ ),
452
+ Endpoint(
453
+ "homework",
454
+ "POST",
455
+ "/moje_zadania",
456
+ SideEffect.SELECT_VIEW,
457
+ False,
458
+ Evidence.INDEPENDENTLY_OBSERVED,
459
+ ),
460
+ Endpoint(
461
+ "completed_lessons",
462
+ "POST",
463
+ "/zrealizowane_lekcje",
464
+ SideEffect.SELECT_VIEW,
465
+ False,
466
+ Evidence.SOURCE_INFORMED,
467
+ ),
468
+ # Internal ordinary-read discovery only. No public note capability is
469
+ # enabled until independently observed populated semantics exist.
470
+ Endpoint(
471
+ "behaviour_notes_probe",
472
+ "GET",
473
+ "/uwagi",
474
+ SideEffect.NONE,
475
+ False,
476
+ Evidence.SOURCE_INFORMED,
477
+ ),
478
+ Endpoint(
479
+ "homework_detail",
480
+ "GET",
481
+ "/moje_zadania/podglad/{id}",
482
+ SideEffect.NONE,
483
+ True,
484
+ Evidence.INDEPENDENTLY_OBSERVED,
485
+ ),
486
+ Endpoint(
487
+ "messages_received",
488
+ "POST",
489
+ "/wiadomosci/1/5",
490
+ SideEffect.SELECT_VIEW,
491
+ False,
492
+ Evidence.INDEPENDENTLY_OBSERVED,
493
+ ),
494
+ Endpoint(
495
+ "messages_sent",
496
+ "POST",
497
+ "/wiadomosci/1/6",
498
+ SideEffect.SELECT_VIEW,
499
+ False,
500
+ Evidence.INDEPENDENTLY_OBSERVED,
501
+ ),
502
+ Endpoint(
503
+ "send_message",
504
+ "POST",
505
+ "/wiadomosci/1/6",
506
+ SideEffect.SEND_MESSAGE,
507
+ False,
508
+ Evidence.SOURCE_INFORMED,
509
+ ),
510
+ Endpoint(
511
+ "recipient_groups",
512
+ "GET",
513
+ "/wiadomosci/2/6",
514
+ SideEffect.SELECT_VIEW,
515
+ False,
516
+ Evidence.INDEPENDENTLY_OBSERVED,
517
+ ),
518
+ Endpoint(
519
+ "message_content_received",
520
+ "GET",
521
+ "/wiadomosci/1/5/{id}",
522
+ SideEffect.MARK_READ,
523
+ False,
524
+ Evidence.INDEPENDENTLY_OBSERVED,
525
+ ),
526
+ Endpoint(
527
+ "message_content_sent",
528
+ "GET",
529
+ "/wiadomosci/1/6/{id}",
530
+ SideEffect.NONE,
531
+ False,
532
+ Evidence.INDEPENDENTLY_OBSERVED,
533
+ ),
534
+ Endpoint(
535
+ "recipients",
536
+ "POST",
537
+ "/getRecipients",
538
+ SideEffect.SELECT_VIEW,
539
+ False,
540
+ Evidence.INDEPENDENTLY_OBSERVED,
541
+ ),
542
+ )
543
+ }
544
+ )
545
+ # Semantic HTML labels are configuration, not positional parser assumptions.
546
+ PROFILE_LABELS = MappingProxyType(
547
+ {
548
+ "Uczeń": "name",
549
+ "Imię i nazwisko": "name",
550
+ "Imię i nazwisko ucznia": "name",
551
+ "Klasa": "class_name",
552
+ "Numer w dzienniku": "register_number",
553
+ "Nr w dzienniku": "register_number",
554
+ "Wychowawca": "tutor",
555
+ "Szkoła": "school",
556
+ }
557
+ )
558
+
559
+ # HTML summary headers omit the two leading body cells: expander and subject.
560
+ # These labels/layout rules are source-informed requirements, not live evidence.
561
+ GRADE_SUMMARY_HEADERS = MappingProxyType(
562
+ {
563
+ "Ocena śródroczna z pierwszego okresu": "midterm",
564
+ "Przewidywana ocena roczna": "predicted_annual",
565
+ "Ocena roczna": "annual",
566
+ }
567
+ )
568
+ GRADE_BODY_PREFIX_COLUMNS = 2
569
+ GRADE_MAX_COLUMNS = 64
570
+ GRADE_MAX_SUBJECTS = 128
571
+ GRADE_MAX_VALUE_LENGTH = 1024
572
+ GRADE_MERGED_SUBJECTS = frozenset({"Zachowanie"})
573
+ GRADE_INLINE_DETAIL_LABEL = "Ocena"
574
+ GRADE_VIEW_FIELDS = MappingProxyType(
575
+ {
576
+ "all": "zmiany_logowanie_wszystkie",
577
+ "week": "zmiany_logowanie_tydzien",
578
+ "last_login": "zmiany_logowanie",
579
+ }
580
+ )
581
+ GRADE_CURRENT_HEADER = "Oceny bieżące"
582
+ GRADE_EMPTY_MARKERS = frozenset({"", "-", "Brak ocen"})
583
+ GRADE_AVERAGE_HEADERS = MappingProxyType(
584
+ {"Średnia ocen": 1, "Średnia ocen z drugiego okresu": 2, "Średnia roczna": 0}
585
+ )
586
+ GRADE_MAX_RECORDS = 2048
587
+ GRADE_MAX_METADATA_LENGTH = 8192
588
+ GRADE_MAX_METADATA_FIELDS = 32
589
+ GRADE_MAX_WINDOW_DAYS = 371
590
+ GRADE_PERIOD_HEADERS = MappingProxyType(
591
+ {
592
+ "Ocena śródroczna z pierwszego okresu": 1,
593
+ "Ocena śródroczna z drugiego okresu": 2,
594
+ "Ocena roczna": 0,
595
+ }
596
+ )
597
+ GRADE_PREDICTED_ANNUAL_HEADER = "Przewidywana ocena roczna"
598
+ GRADE_PREDICTED_PERIOD_HEADERS = MappingProxyType(
599
+ {
600
+ "Przewidywana ocena śródroczna z pierwszego okresu": 1,
601
+ "Przewidywana ocena śródroczna z drugiego okresu": 2,
602
+ }
603
+ )
604
+ GRADE_PUBLICATION_DATE_LABEL = "opublikowano:"
605
+ GRADE_PUBLICATION_TEACHER_LABEL = "nauczyciel:"
606
+ GRADE_PUBLICATION_PERIOD_LABELS = MappingProxyType(
607
+ {
608
+ "pierwszy okres": 1,
609
+ "pierwszego okresu": 1,
610
+ "I okres": 1,
611
+ "drugi okres": 2,
612
+ "drugiego okresu": 2,
613
+ "II okres": 2,
614
+ }
615
+ )
616
+
617
+ ATTENDANCE_VIEW_FORMS = MappingProxyType(
618
+ {
619
+ "all": ("zmiany_logowanie_wszystkie", ""),
620
+ "week": ("zmiany_logowanie_tydzien", "zmiany_logowanie_tydzien"),
621
+ "last_login": ("zmiany_logowanie", "zmiany_logowanie"),
622
+ }
623
+ )
624
+ ATTENDANCE_SEMESTER_LABELS = MappingProxyType(
625
+ {
626
+ "i okres": 1,
627
+ "ii okres": 2,
628
+ "pierwszy okres": 1,
629
+ "drugi okres": 2,
630
+ "i semestr": 1,
631
+ "ii semestr": 2,
632
+ "okres 1": 1,
633
+ "okres 2": 2,
634
+ }
635
+ )
636
+ ATTENDANCE_EMPTY_MARKERS = frozenset({"", "-", "Brak nieobecności"})
637
+ ATTENDANCE_MAX_RECORDS = 2048
638
+ ATTENDANCE_MAX_WINDOW_DAYS = 371
639
+ # Collection recognition and detail retrieval share this central path family.
640
+ ATTENDANCE_DETAIL_PATH_PREFIX = "/przegladaj_nb/szczegoly/"
641
+ ATTENDANCE_DETAIL_MAX_FIELDS = 32
642
+ ATTENDANCE_METADATA_CACHE_SIZE = 256
643
+ ATTENDANCE_RESULT_CACHE_SIZE = 64
644
+ ATTENDANCE_METADATA_TTL_SECONDS = 3600
645
+
646
+ ANNOUNCEMENT_MAX_ITEMS = 256
647
+ ANNOUNCEMENT_MAX_FIELD_LENGTH = 1024
648
+ ANNOUNCEMENT_MAX_CONTENT_LENGTH = 65536
649
+ ANNOUNCEMENT_MAX_TOTAL_TEXT_LENGTH = 262144
650
+ ANNOUNCEMENT_TABLE_CLASSES = frozenset(
651
+ {"decorated", "big", "center", "printable", "margin-top"}
652
+ )
653
+ ANNOUNCEMENT_LABELS = MappingProxyType(
654
+ {
655
+ "dodał": "author",
656
+ "autor": "author",
657
+ "data publikacji": "date_text",
658
+ "data": "date_text",
659
+ "treść": "content",
660
+ }
661
+ )
662
+ ANNOUNCEMENT_EMPTY_CLASSES = frozenset(
663
+ {"container", "border-red", "resizeable", "center"}
664
+ )
665
+ ANNOUNCEMENT_EMPTY_MARKERS = frozenset({"brak ogłoszeń", "nie ma ogłoszeń"})
666
+ TIMETABLE_MAX_PERIODS = 32
667
+ TIMETABLE_MAX_LESSONS_PER_SLOT = 16
668
+ TIMETABLE_MAX_CHANGES_PER_SLOT = 16
669
+ TIMETABLE_WEEK_FIELD = "tydzien"
670
+ TIMETABLE_DATE_ATTRIBUTE = "data-date"
671
+ TIMETABLE_START_ATTRIBUTE = "data-time_from"
672
+ TIMETABLE_END_ATTRIBUTE = "data-time_to"
673
+
674
+ SCHOOL_MAX_ITEMS = 2048
675
+ CHECKPOINT_TIMEOUT_SECONDS = 5.0
676
+ CHECKPOINT_MAX_TIMEOUT_SECONDS = 30.0
677
+ SCHEDULE_RESPONSE_VERSION = 1
678
+ SCHEDULE_MAX_EVENTS = 1024
679
+ NOTIFICATION_MAX_MENU_ITEMS = 64
680
+ NOTIFICATION_MAX_COUNT = 1000000
681
+ NOTIFICATION_DESTINATIONS = MappingProxyType(
682
+ {
683
+ "/przegladaj_oceny/uczen": "grades",
684
+ "/przegladaj_nb/uczen": "attendance",
685
+ "/wiadomosci": "messages",
686
+ "/ogloszenia": "announcements",
687
+ "/terminarz": "agenda",
688
+ "/moje_zadania": "homework",
689
+ }
690
+ )
691
+ SCHEDULE_EVENT_HEADERS = ("lp.", "czas dodania", "rodzaj zdarzenia", "dane")
692
+ SCHEDULE_EMPTY_LABEL = "Brak zdarzeń"
693
+ SCHOOL_MAX_CONTENT_LENGTH = 65536
694
+ SCHOOL_MAX_TOTAL_TEXT_LENGTH = 262144
695
+ SCHOOL_MAX_DETAIL_FIELDS = 64
696
+ SCHOOL_MAX_FIELD_LENGTH = 1024
697
+ SCHOOL_MAX_TOOLTIP_LENGTH = 8192
698
+ # Tooltips carry one line per <br>; a long description can span dozens.
699
+ SCHOOL_MAX_TOOLTIP_LINES = 256
700
+ # Observed agenda tooltip labels. Lines after "Opis" continue the description
701
+ # until the next label, so numbered description lines never become fields.
702
+ AGENDA_TOOLTIP_LABELS = frozenset({"Nauczyciel", "Opis", "Data dodania"})
703
+ AGENDA_DESCRIPTION_LABEL = "Opis"
704
+ HOMEWORK_MAX_COLUMNS = 32
705
+ # Observed header labels. Date columns span two cells: a date and its weekday.
706
+ HOMEWORK_COLUMNS = MappingProxyType(
707
+ {
708
+ "Przedmiot": "subject",
709
+ "Nauczyciel": "teacher",
710
+ "Temat": "topic",
711
+ "Kategoria": "category",
712
+ "Data zadania": "assigned",
713
+ "Termin wykonania": "due",
714
+ "Status przesyłania rozwiązania": "submission_status",
715
+ "Opcje": "options",
716
+ }
717
+ )
718
+ HOMEWORK_DONE_PATTERN = (
719
+ r"Zadanie oznaczono jako wykonane \(([0-9]{4}-[0-9]{2}-[0-9]{2}), "
720
+ r"([0-9]{2}:[0-9]{2})\)"
721
+ )
722
+ HOMEWORK_MARK_DONE_HANDLER = (
723
+ r"\s*showConfirmQuestion\(\s*[0-9]{1,64}\s*,\s*[0-9]{1,64}\s*\);?\s*"
724
+ )
725
+ WEEKDAY_LABELS = ("pon.", "wt.", "śr.", "czw.", "pt.", "sob.", "ndz.")
726
+ # Observed page-level notices shown instead of the requested content.
727
+ PAGE_NOTICES = MappingProxyType(
728
+ {
729
+ "Ten widok został wyłączony przez administratora szkoły.": (
730
+ ErrorKind.VIEW_DISABLED
731
+ ),
732
+ "Wybrano nieprawidłowy zakres daty.": ErrorKind.INVALID_INPUT,
733
+ }
734
+ )
735
+ COMPLETED_LESSONS_MAX_WINDOW_DAYS = 371
736
+ COMPLETED_LESSONS_MAX_PAGE_COUNT = 1000
737
+ COMPLETED_LESSONS_MAX_PAGE_ITEMS = 256
738
+ COMPLETED_LESSONS_MAX_BATCH_PAGES = 8
739
+ COMPLETED_LESSONS_MAX_BATCH_ITEMS = 256
740
+ AGENDA_DETAIL_PATH_PREFIX = "/terminarz/szczegoly/"
741
+ HOMEWORK_DETAIL_PATH_PREFIX = "/moje_zadania/podglad/"
742
+ MESSAGE_MAX_PAGE_COUNT = 1000
743
+ MESSAGE_MAX_PAGE_ITEMS = 50
744
+ MESSAGE_MAX_BATCH_PAGES = 8
745
+ MESSAGE_MAX_BATCH_ITEMS = 256
746
+ MESSAGE_MAX_CURSOR_IDS = 2000
747
+ MESSAGE_MAX_FIELD_LENGTH = 4096
748
+ MESSAGE_MAX_TOTAL_TEXT_LENGTH = 262144
749
+ MESSAGE_EMPTY_TEXT = "Brak wiadomości"
750
+ MESSAGE_INFORMATION_NOTICES = frozenset(
751
+ {
752
+ "Korzystasz ze starej wersji modułu Wiadomości, która nie jest już rozwijana "
753
+ "i nie zawiera wszystkich dostępnych funkcji. Przejdź do ustawień i włącz "
754
+ "opcję: Używaj nowego systemu wiadomości."
755
+ }
756
+ )
757
+ MESSAGE_HEADER_LABELS = MappingProxyType(
758
+ {MessageFolder.RECEIVED: "Nadawca", MessageFolder.SENT: "Adresat"}
759
+ )
760
+ MESSAGE_PAGE_FIELDS = frozenset({"numer_strony105", "porcjowanie_pojemnik105"})
761
+ # Reference and attachment paths are inert data, never arbitrary fetched URLs.
762
+ MESSAGE_REFERENCE_PREFIXES = MappingProxyType(
763
+ {MessageFolder.RECEIVED: "/wiadomosci/1/5/", MessageFolder.SENT: "/wiadomosci/1/6/"}
764
+ )
765
+ MESSAGE_ATTACHMENT_PATH_PREFIX = "/wiadomosci/pobierz_zalacznik/"
766
+ MESSAGE_MAX_CONTENT_LENGTH = 65536
767
+ MESSAGE_MAX_ATTACHMENTS = 20
768
+ MESSAGE_MAX_RECIPIENT_RECEIPTS = 256
769
+ MESSAGE_MAX_RECEIPT_TEXT_LENGTH = 131072
770
+ SEND_MAX_RECIPIENTS = 50
771
+ SEND_MAX_SUBJECT_CHARACTERS = 200
772
+ SEND_MAX_BODY_CHARACTERS = 15000
773
+ SEND_MAX_REQUEST_BYTES = 64 * 1024
774
+ SEND_ACCEPTED_TEXT = "Wiadomość została wysłana."
775
+ SEND_REJECTED_TEXT = "Wiadomość nie została wysłana."
776
+ MODERN_DIRECTORY_TYPE_PATTERN = re.compile(r"[A-Za-z][A-Za-z0-9]{0,63}")
777
+ MODERN_HANDOFF_PATTERN = re.compile(
778
+ r"/(?P<namespace>pobierz[0-9]{1,3})/MultiDomainLogon/token/(?P<token>[A-Za-z0-9]{32,256})/login/(?P<login>[A-Za-z0-9_=+-]{1,512})/target/L25vd3k/from/c3luZXJnaWE"
779
+ )
780
+ MODERN_TERMINAL_PATHS = frozenset({"/nowy", "/nowy/"})
781
+ MODERN_DIRECTORY_QUERIES = MappingProxyType(
782
+ {
783
+ "modern_recipient_types": (("includeClass", "true"),),
784
+ "modern_recipients": (("receiverType", "parentsCouncil"),),
785
+ }
786
+ )
787
+ MODERN_SUPPORTED_RECIPIENT_TYPE = "parentsCouncil"
788
+ MODERN_RECIPIENT_OPERATIONS = MappingProxyType(
789
+ {
790
+ **{
791
+ name: "modern_recipients"
792
+ for name in (
793
+ "parentsCouncil",
794
+ "students",
795
+ "parents",
796
+ "guardians",
797
+ "parents,guardians",
798
+ )
799
+ },
800
+ **{
801
+ name: "modern_school_recipients"
802
+ for name in (
803
+ "schoolParentsCouncil",
804
+ "teachers",
805
+ "headteachers",
806
+ "pedagogue",
807
+ "admin",
808
+ "secretary",
809
+ "sadmin",
810
+ "tutors",
811
+ "librarian",
812
+ "otherSpecialist",
813
+ "careerAdvisor",
814
+ "speechTherapist",
815
+ "counsellor",
816
+ "specialCounsellor",
817
+ "psychologist",
818
+ "educationalTherapist",
819
+ "sensoryIntegrationTherapist",
820
+ "surdopedagogue",
821
+ "typhlopedagogue",
822
+ )
823
+ },
824
+ "classParents": "modern_class_parents",
825
+ }
826
+ )
827
+ MODERN_SUPPORTED_ACCOUNT_GROUPS = frozenset({"5", "8", "9"})
828
+ MODERN_MAX_CLASSES = 128
829
+ MODERN_MAX_RECIPIENTS = 2048
830
+ MODERN_MAX_TYPES = 32
831
+ MODERN_MAX_LABEL = 1024
832
+ MODERN_MAX_TOTAL_TEXT = 128 * 1024
833
+
834
+
835
+ def modern_directory_query(
836
+ identifier: str, *, include_virtual: bool = False
837
+ ) -> dict[str, str]:
838
+ if type(identifier) is not str or identifier not in MODERN_RECIPIENT_OPERATIONS:
839
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
840
+ if type(include_virtual) is not bool or (
841
+ include_virtual and identifier not in {"students", "parents,guardians"}
842
+ ):
843
+ raise LibrusError(ErrorKind.INVALID_INPUT)
844
+ expanded = identifier
845
+ if include_virtual:
846
+ expanded += (
847
+ ",virtualStudents"
848
+ if identifier == "students"
849
+ else ",virtualParents,virtualGuardians"
850
+ )
851
+ return {"receiverType": expanded}
852
+
853
+
854
+ def modern_mailbox_query(
855
+ folder: MessageFolder, page: int, page_size: int
856
+ ) -> dict[str, str]:
857
+ if (
858
+ not isinstance(folder, MessageFolder)
859
+ or type(page) is not int
860
+ or not 1 <= page <= 1000
861
+ or type(page_size) is not int
862
+ or not 1 <= page_size <= 50
863
+ ):
864
+ raise LibrusError(ErrorKind.INVALID_INPUT)
865
+ return {"page": str(page), "limit": str(page_size)}
866
+
867
+
868
+ def validate_modern_query(operation: str, query: Mapping[str, str]) -> None:
869
+ """Allow only fixed bounded directory or ordinary mailbox selections."""
870
+ if not isinstance(query, Mapping) or any(
871
+ type(v) is not str for v in query.values()
872
+ ):
873
+ raise LibrusError(ErrorKind.INVALID_INPUT)
874
+ if operation in {"modern_messages_received", "modern_messages_sent"}:
875
+ if set(query) != {"page", "limit"} or any(
876
+ re.fullmatch(r"[1-9][0-9]{0,3}", query[name]) is None
877
+ for name in ("page", "limit")
878
+ ):
879
+ raise LibrusError(ErrorKind.INVALID_INPUT)
880
+ modern_mailbox_query(
881
+ MessageFolder.RECEIVED, int(query["page"]), int(query["limit"])
882
+ )
883
+ return
884
+ allowed = [
885
+ modern_directory_query(name)
886
+ for name, route in MODERN_RECIPIENT_OPERATIONS.items()
887
+ if route == operation
888
+ ]
889
+ if operation == "modern_recipients":
890
+ allowed.extend(
891
+ modern_directory_query(name, include_virtual=True)
892
+ for name in ("students", "parents,guardians")
893
+ )
894
+ if dict(query) not in allowed:
895
+ raise LibrusError(ErrorKind.INVALID_INPUT)
896
+
897
+
898
+ MODERN_REJECTION_CODES = frozenset(
899
+ {"DUPLICATED_RECEIVERS", "THE_RECEIVER_CANNOT_RECEIVE_A_NOTE_COPY"}
900
+ )
901
+ ATTACHMENT_MAX_BYTES = 50 * 1024 * 1024
902
+ ATTACHMENT_CHUNK_BYTES = 64 * 1024
903
+ ATTACHMENT_MAX_LOCATION_LENGTH = 2048
904
+ ATTACHMENT_KEY_PATTERN = re.compile(r"[A-Za-z0-9_.~-]{1,512}\Z")
905
+ ATTACHMENT_REDIRECT_PATH = "/GetFile/{key}"
906
+
907
+ RECIPIENT_FORM_FIELDS = frozenset(
908
+ {"typAdresata", "poprzednia", "tabZaznaczonych", "czyWirtualneKlasy", "idGrupy"}
909
+ )
910
+ RECIPIENT_GROUP_TYPE_PATTERN = re.compile(r"[a-z][a-z0-9_]{0,63}\Z")
911
+ RECIPIENT_MAX_GROUPS = 32
912
+ RECIPIENT_MAX_ITEMS = 2048
913
+ RECIPIENT_MAX_LABEL_LENGTH = 1024
914
+ RECIPIENT_MAX_TOTAL_TEXT_LENGTH = 131072
915
+ RECIPIENT_UNSUPPORTED_TYPES = frozenset({"grupa"})
916
+ RECIPIENT_SELECTION_PROMPT = "Wybierz grupę"
917
+ RECIPIENT_CLASS_UNAVAILABLE_NOTICE = (
918
+ "Uczeń nie jest przydzielony do klasy. W celu wyjaśnienia sytuacji prosimy "
919
+ "o kontakt ze szkołą"
920
+ )
921
+
922
+
923
+ def recipient_form(group_type: str, *, selection_id: str = "0") -> dict[str, str]:
924
+ if (
925
+ type(group_type) is not str
926
+ or RECIPIENT_GROUP_TYPE_PATTERN.fullmatch(group_type) is None
927
+ or type(selection_id) is not str
928
+ or re.fullmatch(r"0|[1-9][0-9]{0,63}", selection_id) is None
929
+ or (selection_id != "0" and group_type != "grupa")
930
+ ):
931
+ raise LibrusError(ErrorKind.INVALID_INPUT)
932
+ return {
933
+ "typAdresata": group_type,
934
+ "poprzednia": "5",
935
+ "tabZaznaczonych": "",
936
+ "czyWirtualneKlasy": "false",
937
+ "idGrupy": selection_id,
938
+ }
939
+
940
+
941
+ def encode_send_form(submission: SendSubmission, account: str) -> bytes:
942
+ """Exact bounded legacy send variant; text is never silently transformed."""
943
+ if (
944
+ not isinstance(submission, SendSubmission)
945
+ or type(submission.recipients) is not tuple
946
+ or not 1 <= len(submission.recipients) <= SEND_MAX_RECIPIENTS
947
+ ):
948
+ raise LibrusError(ErrorKind.INVALID_INPUT)
949
+ for value, limit, whitespace in (
950
+ (submission.subject, SEND_MAX_SUBJECT_CHARACTERS, ""),
951
+ (submission.body, SEND_MAX_BODY_CHARACTERS, "\r\n\t"),
952
+ ):
953
+ if (
954
+ type(value) is not str
955
+ or not 1 <= len(value) <= limit
956
+ or not value.strip()
957
+ or any(
958
+ unicodedata.category(c) in {"Cc", "Cs"} and c not in whitespace
959
+ for c in value
960
+ )
961
+ ):
962
+ raise LibrusError(ErrorKind.INVALID_INPUT)
963
+ seen: set[str] = set()
964
+ fields = [("filtrUzytkownikow", "0"), ("idPojemnika", "")]
965
+ for reference in submission.recipients:
966
+ if (
967
+ not isinstance(reference, RecipientReference)
968
+ or reference.account != account
969
+ or type(reference.identifier) is not str
970
+ or re.fullmatch(r"[0-9]{1,64}", reference.identifier) is None
971
+ or reference.identifier in seen
972
+ ):
973
+ raise LibrusError(ErrorKind.INVALID_INPUT)
974
+ recipient_form(reference.group_type, selection_id=reference.selection_id)
975
+ seen.add(reference.identifier)
976
+ fields.append(("DoKogo", reference.identifier))
977
+ fields.extend(
978
+ (
979
+ ("Rodzaj", "0"),
980
+ ("temat", submission.subject),
981
+ ("tresc", submission.body),
982
+ ("poprzednia", "5"),
983
+ ("fileStorageIdentifier", ""),
984
+ ("wyslij", "Wyślij"),
985
+ )
986
+ )
987
+ payload = urlencode(fields, encoding="utf-8", errors="strict").encode("ascii")
988
+ if len(payload) > SEND_MAX_REQUEST_BYTES:
989
+ raise LibrusError(ErrorKind.INVALID_INPUT)
990
+ return payload
991
+
992
+
993
+ def encode_modern_send(submission: ModernSendSubmission, account: str) -> bytes:
994
+ """Source-informed ordinary school JSON, never OSIN/group/CC/BCC sending."""
995
+ if (
996
+ not isinstance(submission, ModernSendSubmission)
997
+ or type(submission.recipients) is not tuple
998
+ or not 1 <= len(submission.recipients) <= SEND_MAX_RECIPIENTS
999
+ ):
1000
+ raise LibrusError(ErrorKind.INVALID_INPUT)
1001
+ for value, limit, whitespace in (
1002
+ (submission.subject, SEND_MAX_SUBJECT_CHARACTERS, ""),
1003
+ (submission.body, SEND_MAX_BODY_CHARACTERS, "\r\n\t"),
1004
+ ):
1005
+ if (
1006
+ type(value) is not str
1007
+ or not 1 <= len(value) <= limit
1008
+ or not value.strip()
1009
+ or any(
1010
+ unicodedata.category(c) in {"Cc", "Cs"} and c not in whitespace
1011
+ for c in value
1012
+ )
1013
+ ):
1014
+ raise LibrusError(ErrorKind.INVALID_INPUT)
1015
+ seen: set[str] = set()
1016
+ recipients = []
1017
+ for reference in submission.recipients:
1018
+ if (
1019
+ not isinstance(reference, ModernRecipientReference)
1020
+ or reference.account != account
1021
+ or type(reference.recipient_type) is not str
1022
+ or reference.recipient_type not in MODERN_RECIPIENT_OPERATIONS
1023
+ or type(reference.class_label) is not str
1024
+ or (
1025
+ MODERN_RECIPIENT_OPERATIONS.get(reference.recipient_type)
1026
+ == "modern_recipients"
1027
+ and not reference.class_label.strip()
1028
+ )
1029
+ or (
1030
+ MODERN_RECIPIENT_OPERATIONS.get(reference.recipient_type)
1031
+ != "modern_recipients"
1032
+ and reference.class_label != ""
1033
+ )
1034
+ or len(reference.class_label) > MODERN_MAX_LABEL
1035
+ ):
1036
+ raise LibrusError(ErrorKind.INVALID_INPUT)
1037
+ modern_directory_query(
1038
+ reference.recipient_type, include_virtual=reference.include_virtual
1039
+ )
1040
+ if (
1041
+ any(
1042
+ type(v) is not str or re.fullmatch(r"[0-9]{1,64}", v) is None
1043
+ for v in (reference.account_id, reference.user_id)
1044
+ )
1045
+ or reference.account_id in seen
1046
+ ):
1047
+ raise LibrusError(ErrorKind.INVALID_INPUT)
1048
+ seen.add(reference.account_id)
1049
+ recipients.append({"accountId": reference.account_id})
1050
+ payload = json.dumps(
1051
+ {
1052
+ "receivers": {"schoolReceivers": recipients},
1053
+ "topic": base64.b64encode(submission.subject.encode()).decode("ascii"),
1054
+ # The modern reader inserts decoded content as sanitized HTML.
1055
+ # Preserve plain-text literals instead of letting tags disappear.
1056
+ "content": base64.b64encode(
1057
+ escape(submission.body, quote=False).encode()
1058
+ ).decode("ascii"),
1059
+ "storageId": None,
1060
+ "category": "normal",
1061
+ },
1062
+ separators=(",", ":"),
1063
+ ensure_ascii=True,
1064
+ ).encode("ascii")
1065
+ if len(payload) > SEND_MAX_REQUEST_BYTES:
1066
+ raise LibrusError(ErrorKind.INVALID_INPUT)
1067
+ return payload
1068
+
1069
+
1070
+ def message_page_form(folder: MessageFolder, page: int) -> dict[str, str]:
1071
+ if (
1072
+ not isinstance(folder, MessageFolder)
1073
+ or type(page) is not int
1074
+ or not 0 <= page < MESSAGE_MAX_PAGE_COUNT
1075
+ ):
1076
+ raise LibrusError(ErrorKind.INVALID_INPUT)
1077
+ return {"numer_strony105": str(page), "porcjowanie_pojemnik105": "105"}
1078
+
1079
+
1080
+ def grade_view_form(view: GradeView) -> dict[str, str]:
1081
+ """The grade page's view selector; selecting a view changes upstream state."""
1082
+ if not isinstance(view, GradeView):
1083
+ raise LibrusError(ErrorKind.INVALID_INPUT)
1084
+ return {GRADE_VIEW_FIELDS[view.value]: "1"}
1085
+
1086
+
1087
+ def attendance_view_form(view: AttendanceView) -> dict[str, str]:
1088
+ if not isinstance(view, AttendanceView):
1089
+ raise LibrusError(ErrorKind.INVALID_INPUT)
1090
+ key, value = ATTENDANCE_VIEW_FORMS[view.value]
1091
+ return {key: value}
1092
+
1093
+
1094
+ def agenda_form(year: int, month: int) -> dict[str, str]:
1095
+ if (
1096
+ type(year) is not int
1097
+ or type(month) is not int
1098
+ or not (2001 <= year <= 2100 and 1 <= month <= 12)
1099
+ ):
1100
+ raise LibrusError(ErrorKind.INVALID_INPUT)
1101
+ return {"rok": str(year), "miesiac": f"{month:02d}"}
1102
+
1103
+
1104
+ def _one_month_after(day: date) -> date:
1105
+ year, month = (day.year + 1, 1) if day.month == 12 else (day.year, day.month + 1)
1106
+ return date(year, month, min(day.day, calendar.monthrange(year, month)[1]))
1107
+
1108
+
1109
+ def homework_form(start: date, end: date) -> dict[str, str]:
1110
+ """The upstream form accepts at most one calendar month per selection."""
1111
+ if (
1112
+ type(start) is not date
1113
+ or type(end) is not date
1114
+ or start.year > 9998
1115
+ or not start <= end <= _one_month_after(start)
1116
+ ):
1117
+ raise LibrusError(ErrorKind.INVALID_INPUT)
1118
+ return {
1119
+ "dataOd": start.isoformat(),
1120
+ "dataDo": end.isoformat(),
1121
+ "przedmiot": "-1",
1122
+ "status": "-1",
1123
+ }
1124
+
1125
+
1126
+ def homework_range_forms(request: HomeworkRangeRequest) -> tuple[dict[str, str], ...]:
1127
+ """Plan disjoint inclusive windows before login; never silently clip a range."""
1128
+ if (
1129
+ not isinstance(request, HomeworkRangeRequest)
1130
+ or type(request.start) is not date
1131
+ or type(request.end) is not date
1132
+ or request.start.year > 9998
1133
+ or request.end.year > 9998
1134
+ or not 0 <= (request.end - request.start).days <= 370
1135
+ or type(request.max_windows) is not int
1136
+ or not 1 <= request.max_windows <= 13
1137
+ or type(request.max_items) is not int
1138
+ or not 1 <= request.max_items <= 4096
1139
+ ):
1140
+ raise LibrusError(ErrorKind.INVALID_INPUT)
1141
+ forms: list[dict[str, str]] = []
1142
+ start = request.start
1143
+ while start <= request.end:
1144
+ if len(forms) == request.max_windows:
1145
+ raise LibrusError(ErrorKind.LIMIT)
1146
+ end = min(request.end, _one_month_after(start))
1147
+ forms.append(homework_form(start, end))
1148
+ start = end + timedelta(days=1)
1149
+ return tuple(forms)
1150
+
1151
+
1152
+ def timetable_form(monday: date) -> dict[str, str]:
1153
+ """Central fixed week-selection wire form; no arbitrary date/form input."""
1154
+ if type(monday) is not date or monday.weekday() != 0 or monday > date(9999, 12, 25):
1155
+ raise LibrusError(ErrorKind.INVALID_INPUT)
1156
+ sunday = monday + timedelta(days=6)
1157
+ return {TIMETABLE_WEEK_FIELD: f"{monday.isoformat()}_{sunday.isoformat()}"}
1158
+
1159
+
1160
+ def completed_lessons_form(start: date, end: date, page: int) -> dict[str, str]:
1161
+ if (
1162
+ type(start) is not date
1163
+ or type(end) is not date
1164
+ or not 0 <= (end - start).days < COMPLETED_LESSONS_MAX_WINDOW_DAYS
1165
+ or type(page) is not int
1166
+ or not 0 <= page < COMPLETED_LESSONS_MAX_PAGE_COUNT
1167
+ ):
1168
+ raise LibrusError(ErrorKind.INVALID_INPUT)
1169
+ return {
1170
+ "data1": start.isoformat(),
1171
+ "data2": end.isoformat(),
1172
+ "filtruj_id_przedmiotu": "-1",
1173
+ "numer_strony1001": str(page),
1174
+ "porcjowanie_pojemnik1001": "1001",
1175
+ }
1176
+
1177
+
1178
+ # Field names each view-selection POST may carry; the transport rejects others.
1179
+ FORM_FIELDS: Mapping[str, frozenset[str]] = MappingProxyType(
1180
+ {
1181
+ "grades": frozenset(GRADE_VIEW_FIELDS.values()),
1182
+ "attendance": frozenset(key for key, _ in ATTENDANCE_VIEW_FORMS.values()),
1183
+ "timetable": frozenset({TIMETABLE_WEEK_FIELD}),
1184
+ "agenda": frozenset({"rok", "miesiac"}),
1185
+ "homework": frozenset({"dataOd", "dataDo", "przedmiot", "status"}),
1186
+ "completed_lessons": frozenset(
1187
+ {
1188
+ "data1",
1189
+ "data2",
1190
+ "filtruj_id_przedmiotu",
1191
+ "numer_strony1001",
1192
+ "porcjowanie_pojemnik1001",
1193
+ }
1194
+ ),
1195
+ "messages_received": MESSAGE_PAGE_FIELDS,
1196
+ "messages_sent": MESSAGE_PAGE_FIELDS,
1197
+ "recipients": RECIPIENT_FORM_FIELDS,
1198
+ }
1199
+ )
1200
+ FORM_MAX_VALUE_LENGTH = 64
1201
+
1202
+
1203
+ # Source-informed stable type-ID policy, never inferred from names or symbols.
1204
+ ATTENDANCE_TYPE_KINDS = MappingProxyType(
1205
+ {
1206
+ "1": "absence",
1207
+ "2": "late",
1208
+ "3": "excused",
1209
+ "4": "exemption",
1210
+ "100": "present",
1211
+ "1266": "excursion",
1212
+ "2022": "contest",
1213
+ "2829": "training",
1214
+ }
1215
+ )
1216
+
1217
+
1218
+ class _ValidatedConfig(BaseModel):
1219
+ model_config = ConfigDict(
1220
+ frozen=True, strict=True, extra="forbid", hide_input_in_errors=True
1221
+ )
1222
+
1223
+ def __init__(self, **data: Any) -> None:
1224
+ failed = False
1225
+ try:
1226
+ super().__init__(**data)
1227
+ except ValidationError:
1228
+ failed = True
1229
+ # Raise outside the handler so the raw validation error is not chained.
1230
+ if failed:
1231
+ raise LibrusError(ErrorKind.INVALID_INPUT)
1232
+
1233
+
1234
+ PositiveCount = Annotated[int, Field(gt=0)]
1235
+ QueueCount = Annotated[int, Field(ge=0)]
1236
+ PositiveFinite = Annotated[float, Field(gt=0, allow_inf_nan=False)]
1237
+
1238
+
1239
+ class TransportLimits(_ValidatedConfig):
1240
+ """Per-request bounds used by the local transport evaluation.
1241
+
1242
+ These do not implement service-wide scheduling or operation budgets. Those
1243
+ are required before enabling any supported upstream operation.
1244
+ """
1245
+
1246
+ response_max_bytes: PositiveCount = 4 * 1024 * 1024
1247
+ request_timeout_seconds: PositiveFinite = 30.0
1248
+ connect_timeout_seconds: PositiveFinite = 10.0
1249
+ max_redirects: PositiveCount = 10
1250
+ max_cookies: PositiveCount = 128
1251
+ parse_max_bytes: PositiveCount = 256 * 1024
1252
+ cooldown_seconds: PositiveFinite = 60.0
1253
+ attachment_idle_timeout_seconds: PositiveFinite = 15.0
1254
+
1255
+ @model_validator(mode="after")
1256
+ def validate_deadlines(self) -> Self:
1257
+ if self.connect_timeout_seconds > self.request_timeout_seconds:
1258
+ raise ValueError("Connect timeout exceeds request timeout")
1259
+ return self
1260
+
1261
+
1262
+ class SchedulerLimits(_ValidatedConfig):
1263
+ """Bounded service-local traffic policy, not Librus-approved quotas.
1264
+
1265
+ Queue limits count waiting requests, separately from active requests. Rate
1266
+ tokens count every admitted attempt, including future auth/redirect/retry
1267
+ requests. Parent and student logins each occupy their own account slot.
1268
+ A shared ten-token burst accommodates sequential cold-login hops; refill at
1269
+ five tokens/second bounds sustained traffic without one-second hop delays.
1270
+ """
1271
+
1272
+ requests_per_second: PositiveFinite = 5.0
1273
+ burst: PositiveCount = 10
1274
+ active_requests: PositiveCount = 2
1275
+ active_requests_per_account: PositiveCount = 1
1276
+ queued_requests: QueueCount = 32
1277
+ queued_requests_per_account: QueueCount = 8
1278
+ accounts: PositiveCount = 16
1279
+ operations: PositiveCount = 32
1280
+ operations_per_account: PositiveCount = 8
1281
+
1282
+ @model_validator(mode="after")
1283
+ def validate_account_limits(self) -> Self:
1284
+ if self.active_requests_per_account > self.active_requests:
1285
+ raise ValueError("Account concurrency exceeds global concurrency")
1286
+ if self.queued_requests_per_account > self.queued_requests:
1287
+ raise ValueError("Account queue exceeds global queue")
1288
+ if self.operations_per_account > self.operations:
1289
+ raise ValueError("Account operation bound exceeds global bound")
1290
+ return self
1291
+
1292
+
1293
+ class OperationLimits(_ValidatedConfig):
1294
+ """Whole-operation request/deadline bounds, including scheduler queue wait."""
1295
+
1296
+ max_requests: PositiveCount = 32
1297
+ timeout_seconds: PositiveFinite = 120.0
1298
+ max_response_bytes: PositiveCount = 4 * 1024 * 1024
1299
+
1300
+
1301
+ class AccountCredentials(_ValidatedConfig):
1302
+ """Explicit login secrets and optional independent identity expectations."""
1303
+
1304
+ login: SecretStr = Field(repr=False)
1305
+ password: SecretStr = Field(repr=False)
1306
+ expected_owner_id: str | None = Field(default=None, repr=False)
1307
+ expected_student_id: str | None = Field(default=None, repr=False)
1308
+
1309
+ def __init__(
1310
+ self,
1311
+ *,
1312
+ login: str | SecretStr,
1313
+ password: str | SecretStr,
1314
+ expected_owner_id: str | None = None,
1315
+ expected_student_id: str | None = None,
1316
+ ) -> None:
1317
+ super().__init__(
1318
+ login=login,
1319
+ password=password,
1320
+ expected_owner_id=expected_owner_id,
1321
+ expected_student_id=expected_student_id,
1322
+ )
1323
+
1324
+ @model_validator(mode="after")
1325
+ def validate_account(self) -> Self:
1326
+ if not 1 <= len(self.login.get_secret_value()) <= 256:
1327
+ raise ValueError("Invalid login length")
1328
+ if not 1 <= len(self.password.get_secret_value()) <= 1024:
1329
+ raise ValueError("Invalid password length")
1330
+ for value in (self.expected_owner_id, self.expected_student_id):
1331
+ if value is not None and not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", value):
1332
+ raise ValueError("Invalid expected identity")
1333
+ return self
1334
+
1335
+
1336
+ DEFAULT_OPERATION_LIMITS = OperationLimits()
1337
+
1338
+
1339
+ class ConnectionSettings(_ValidatedConfig):
1340
+ """Explicit verified TLS/proxy settings; HTTP is allowed only on loopback.
1341
+
1342
+ Origin overrides support local fixture servers, not arbitrary authenticated
1343
+ destinations. Redirects must additionally match the fixed route catalogue.
1344
+ """
1345
+
1346
+ model_config = ConfigDict(
1347
+ frozen=True,
1348
+ strict=True,
1349
+ extra="forbid",
1350
+ hide_input_in_errors=True,
1351
+ arbitrary_types_allowed=True,
1352
+ )
1353
+ synergia_origin: str = UPSTREAM_ORIGINS["synergia"]
1354
+ api_origin: str = UPSTREAM_ORIGINS["api"]
1355
+ download_origin: str = UPSTREAM_ORIGINS["download"]
1356
+ messages_origin: str = UPSTREAM_ORIGINS["messages"]
1357
+ proxy_url: SecretStr | None = Field(default=None, repr=False)
1358
+ ssl_context: ssl.SSLContext | None = Field(default=None, repr=False)
1359
+
1360
+ @field_validator(
1361
+ "synergia_origin", "api_origin", "download_origin", "messages_origin"
1362
+ )
1363
+ @classmethod
1364
+ def validate_origin(cls, value: str, info: ValidationInfo) -> str:
1365
+ assert info.field_name is not None
1366
+ parsed = urlsplit(value)
1367
+ port = parsed.port
1368
+ if (
1369
+ parsed.username is not None
1370
+ or parsed.password is not None
1371
+ or parsed.query
1372
+ or parsed.fragment
1373
+ or parsed.path not in ("", "/")
1374
+ ):
1375
+ raise ValueError("Only origin URLs are allowed")
1376
+ local = parsed.hostname in ("localhost", "127.0.0.1", "::1")
1377
+ expected = urlsplit(
1378
+ UPSTREAM_ORIGINS[info.field_name.removesuffix("_origin")]
1379
+ ).hostname
1380
+ official = parsed.hostname == expected
1381
+ if not local and not (
1382
+ official and parsed.scheme == "https" and port in (None, 443)
1383
+ ):
1384
+ raise ValueError("Destination is not an approved origin")
1385
+ if parsed.scheme not in ("http", "https"):
1386
+ raise ValueError("Unsupported URL scheme")
1387
+ return value.rstrip("/")
1388
+
1389
+ @model_validator(mode="after")
1390
+ def validate_security(self) -> Self:
1391
+ context = self.ssl_context
1392
+ if context is not None and (
1393
+ context.verify_mode != ssl.CERT_REQUIRED
1394
+ or not context.check_hostname
1395
+ or context.minimum_version < ssl.TLSVersion.TLSv1_2
1396
+ ):
1397
+ raise ValueError("TLS verification and TLS 1.2 or newer are required")
1398
+ if self.proxy_url is not None:
1399
+ parsed = urlsplit(self.proxy_url.get_secret_value())
1400
+ if parsed.scheme not in ("http", "https") or not parsed.hostname:
1401
+ raise ValueError("Unsupported explicit proxy")
1402
+ return self
1403
+
1404
+ def origin(self, endpoint: Endpoint) -> str:
1405
+ return {
1406
+ "api": self.api_origin,
1407
+ "synergia": self.synergia_origin,
1408
+ "download": self.download_origin,
1409
+ "messages": self.messages_origin,
1410
+ }[endpoint.origin]