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.
- librus_python_api/__init__.py +243 -0
- librus_python_api/_notification_bootstrap.py +124 -0
- librus_python_api/_notification_codec.py +396 -0
- librus_python_api/_storage.py +403 -0
- librus_python_api/_windows_filesystem.py +390 -0
- librus_python_api/announcements.py +159 -0
- librus_python_api/attachment_routes.py +114 -0
- librus_python_api/attachments.py +297 -0
- librus_python_api/attendance.py +182 -0
- librus_python_api/attendance_frequency.py +112 -0
- librus_python_api/budget.py +79 -0
- librus_python_api/checkpoint.py +61 -0
- librus_python_api/completed_lessons.py +216 -0
- librus_python_api/config.py +1410 -0
- librus_python_api/detail_fields.py +50 -0
- librus_python_api/diagnostics.py +25 -0
- librus_python_api/exceptions.py +172 -0
- librus_python_api/files.py +242 -0
- librus_python_api/grade_parsers.py +169 -0
- librus_python_api/grade_records.py +454 -0
- librus_python_api/homework_range.py +41 -0
- librus_python_api/lifecycle.py +24 -0
- librus_python_api/markup.py +147 -0
- librus_python_api/message_content.py +230 -0
- librus_python_api/messages.py +288 -0
- librus_python_api/models.py +1160 -0
- librus_python_api/modern_body.py +75 -0
- librus_python_api/modern_mailbox.py +459 -0
- librus_python_api/modern_messages.py +276 -0
- librus_python_api/notification_models.py +145 -0
- librus_python_api/notification_persistence.py +1243 -0
- librus_python_api/notification_workflow.py +337 -0
- librus_python_api/notifications.py +216 -0
- librus_python_api/parsers.py +232 -0
- librus_python_api/parsing.py +49 -0
- librus_python_api/persistence.py +419 -0
- librus_python_api/py.typed +0 -0
- librus_python_api/recipients.py +271 -0
- librus_python_api/scheduler.py +287 -0
- librus_python_api/school_reads.py +400 -0
- librus_python_api/sending.py +125 -0
- librus_python_api/service.py +2285 -0
- librus_python_api/timetable.py +261 -0
- librus_python_api/transport.py +956 -0
- librus_python_api-1.0.0.dist-info/METADATA +262 -0
- librus_python_api-1.0.0.dist-info/RECORD +48 -0
- librus_python_api-1.0.0.dist-info/WHEEL +4 -0
- 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]
|