mosdiary 0.1.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.
mosdiary/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .client import MOSDiaryClient
2
+
3
+ __all__ = ('MOSDiaryClient')
mosdiary/client.py ADDED
@@ -0,0 +1,671 @@
1
+ from asyncio import sleep, TimeoutError
2
+ from collections.abc import Awaitable, Callable, Sequence
3
+ from datetime import date
4
+ from html import unescape
5
+ from inspect import isawaitable
6
+ from io import BytesIO
7
+ from re import DOTALL, IGNORECASE, search
8
+ from time import time
9
+ from types import TracebackType
10
+ from typing import Literal
11
+
12
+ from aiohttp import (
13
+ ClientConnectionError,
14
+ ClientResponse,
15
+ ClientSession,
16
+ ClientSSLError,
17
+ ServerFingerprintMismatch,
18
+ )
19
+ from pydantic import ValidationError
20
+ from yarl import URL
21
+
22
+ from .exceptions import (
23
+ AuthenticationRequiredException,
24
+ InvalidLoginParameterException,
25
+ InvalidResponseException,
26
+ InvalidVerificationCodeException,
27
+ LoginTokenMissingException,
28
+ QRLoginExpiredException,
29
+ QRLoginInitializationException,
30
+ QRLoginStateException,
31
+ TwoFactorRequiredException,
32
+ VerificationAttemptsExhaustedException,
33
+ VerificationCodeExpiredException,
34
+ )
35
+ from .enums import ScheduleEventType
36
+ from .types.profile import UserInfo, Family, ProfileDeatail
37
+ from .types.homework import Homework
38
+ from .types.marks import Mark, SubjectMark
39
+ from .types.lessons import LessonDay
40
+ from .types.schedule_events import ScheduleEvent, parse_schedule_event
41
+
42
+
43
+ ScheduleEventInclude = Literal[
44
+ 'marks',
45
+ 'homework',
46
+ 'absence_reason_id',
47
+ 'health_status',
48
+ 'nonattendance_reason_id',
49
+ ]
50
+ ALL_SCHEDULE_EVENT_INCLUDES: tuple[ScheduleEventInclude, ...] = (
51
+ 'marks',
52
+ 'homework',
53
+ 'absence_reason_id',
54
+ 'health_status',
55
+ 'nonattendance_reason_id',
56
+ )
57
+
58
+
59
+ class MOSDiaryClient:
60
+ def __init__(self, aupd_token: str | None = None, timeout: float = 30, user_agent: str | None = None):
61
+ """
62
+ Клиент МЭШ
63
+
64
+ Args:
65
+ aupd_token (`str | None`): Токен (кука) авторизации
66
+ timeout (`float`): Тайм-аут запросов в секундах.
67
+ user_agent (`str | None`): User-Agent для запросов.
68
+
69
+ Returns:
70
+ None: Создаёт клиент без выполнения сетевых запросов.
71
+ """
72
+
73
+ self.aupd_token = aupd_token
74
+ """Токен (кука) авторизации"""
75
+ self.timeout = timeout
76
+ self.user_agent = user_agent
77
+
78
+ self.id: int | None = None
79
+ """ID аккаунта МЭШ"""
80
+
81
+ self.uid: str | None = None
82
+ """UID персоны МЭШ"""
83
+
84
+ self._session: ClientSession | None = None
85
+
86
+ async def __aenter__(self) -> 'MOSDiaryClient':
87
+ """Открыть HTTP-сессию клиента.
88
+
89
+ Returns:
90
+ MOSDiaryClient: Текущий экземпляр клиента.
91
+ """
92
+ self._ensure_session()
93
+ return self
94
+
95
+ async def __aexit__(self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None,
96
+ ) -> None:
97
+ """Закрыть HTTP-сессию при выходе из контекстного менеджера.
98
+
99
+ Returns:
100
+ None
101
+ """
102
+ await self.close()
103
+
104
+ def _ensure_session(self) -> ClientSession:
105
+ """Вернуть действующую HTTP-сессию, создав её при необходимости.
106
+
107
+ Returns:
108
+ ClientSession: Открытая сессия `aiohttp`.
109
+ """
110
+ if self._session is None or self._session.closed:
111
+ headers = {'User-Agent': self.user_agent} if self.user_agent is not None else None
112
+ self._session = ClientSession(headers=headers)
113
+
114
+ return self._session
115
+
116
+ async def close(self) -> None:
117
+ """Закрыть HTTP-сессию клиента.
118
+
119
+ Returns:
120
+ None
121
+ """
122
+ if self._session is None:
123
+ return
124
+
125
+ session = self._session
126
+ self._session = None
127
+ if not session.closed:
128
+ await session.close()
129
+
130
+ async def _send_request(
131
+ self,
132
+ method: Literal['GET', 'POST'],
133
+ endpoint: str,
134
+ return_type: Literal['dict', 'response', 'bool', 'text'] = 'dict',
135
+ use_auth: bool = True,
136
+ **kwargs,
137
+ ) -> dict | ClientResponse | bool | str:
138
+ """Отправить HTTP-запрос к API МЭШ.
139
+
140
+ Returns:
141
+ dict | ClientResponse | bool | str: JSON-объект, необработанный
142
+ ответ, признак успешного статуса или текст ответа - в
143
+ зависимости от `return_type`.
144
+ """
145
+ if return_type not in ('dict', 'response', 'bool', 'text'):
146
+ raise ValueError(f'Неизвестный тип возврата: {return_type}')
147
+
148
+ url = (
149
+ 'https://school.mos.ru/api/' + endpoint
150
+ if not endpoint.startswith('http')
151
+ else endpoint
152
+ )
153
+ headers = dict(kwargs.pop('headers', {}) or {})
154
+ cookies = dict(kwargs.pop('cookies', {}) or {})
155
+ if use_auth and self.aupd_token is not None:
156
+ cookies['aupd_token'] = self.aupd_token
157
+ headers.setdefault('Authorization', f'Bearer {self.aupd_token}')
158
+ headers.setdefault('Accept', 'application/json, text/plain, */*')
159
+ headers.setdefault('X-mes-subsystem', 'familyweb')
160
+ elif use_auth and self.aupd_token is None:
161
+ raise AuthenticationRequiredException('Этот метод требует авторизации. Заполните поле aupd_token в MOSDiaryClient.')
162
+
163
+ retry_number = 0
164
+
165
+ while True:
166
+ try:
167
+ response = await self._ensure_session().request(
168
+ method,
169
+ url,
170
+ headers=headers,
171
+ cookies=cookies,
172
+ timeout=self.timeout,
173
+ **kwargs,
174
+ )
175
+
176
+ if return_type == 'response':
177
+ return response
178
+
179
+ async with response:
180
+ if return_type == 'bool':
181
+ return 200 <= response.status < 300
182
+
183
+ response.raise_for_status()
184
+ if return_type == 'text':
185
+ return await response.text()
186
+
187
+ data = await response.json()
188
+ if not isinstance(data, dict):
189
+ raise InvalidResponseException('Ответ API не является JSON-объектом')
190
+ return data
191
+ except (ClientSSLError, ServerFingerprintMismatch):
192
+ raise
193
+ except (ClientConnectionError, TimeoutError):
194
+ # POST could already have been processed, and a streamed body
195
+ # could already be consumed. Neither can be safely replayed.
196
+ if (
197
+ method != 'GET'
198
+ or 'data' in kwargs
199
+ or retry_number >= 2
200
+ ):
201
+ raise
202
+
203
+ await sleep(0.5 * 2 ** retry_number)
204
+ retry_number += 1
205
+
206
+
207
+ async def logout(self)-> bool:
208
+ """
209
+ Завершает сессию, сбрасывает `self.aupd_token`, `self.id` и `self.uid`
210
+
211
+ Returns:
212
+ bool:
213
+ Выполнен ли запрос успешно
214
+ """
215
+ success = await self._send_request('GET', 'https://school.mos.ru/v3/auth/logout', return_type='bool')
216
+ if success:
217
+ self.id = None
218
+ self.uid = None
219
+ self.aupd_token = None
220
+ await self.close()
221
+ return True
222
+ return False
223
+
224
+
225
+ async def refresh_session(self, role_id: int = 1)-> str:
226
+ """
227
+ Обновить `aupd_token` в текущей авторизованной HTTP-сессии.
228
+
229
+ Требует cookies, созданные QR-входом в этом же экземпляре клиента;
230
+ одного сохранённого `aupd_token` недостаточно.
231
+
232
+ Args:
233
+ role_id (`int = 1`): Ваш ID роли. 1 - ученик
234
+ Returns:
235
+ str:
236
+ Токен авторизации
237
+ """
238
+ aupd_token = await self._send_request(
239
+ 'GET',
240
+ 'https://school.mos.ru/v2/token/refresh',
241
+ return_type='text',
242
+ params={'roleId': role_id, 'subsystem': 2},
243
+ headers={
244
+ 'Accept': 'application/json, text/plain, */*',
245
+ 'CrossDomain': 'true',
246
+ 'Referer': 'https://school.mos.ru/auth/callback'
247
+ },
248
+ )
249
+ self.aupd_token = aupd_token
250
+ return aupd_token
251
+
252
+
253
+ async def start_qr_login(self, return_type: Literal['link', 'qr', 'link&qr'] = 'link')-> tuple[str, int] | tuple[bytes, int] | tuple[str, bytes, int]:
254
+ """
255
+ Начать вход по QR-коду. После используйте `wait_qr_login(expires)`
256
+
257
+ Args:
258
+ return_type (`Literal`): Вернуть ссылку, PNG-изображение QR-кода
259
+ либо ссылку и изображение.
260
+
261
+ Returns:
262
+ tuple[str, int] | tuple[bytes, int] | tuple[str, bytes, int]:
263
+ Запрошенные данные и время истечения QR-кода в Unix time.
264
+ """
265
+ if return_type not in ('link', 'qr', 'link&qr'):
266
+ raise InvalidLoginParameterException(f'Неизвестный тип возвращаемого результата: {return_type}')
267
+
268
+ response = await self._send_request(
269
+ 'GET',
270
+ 'https://school.mos.ru/v3/auth/sudir/login',
271
+ return_type='response',
272
+ use_auth=False,
273
+ )
274
+ async with response:
275
+ response.raise_for_status()
276
+ html = await response.text()
277
+
278
+ match = search(r'url=([^"\'>]+)', html, IGNORECASE)
279
+ if match is None:
280
+ raise QRLoginInitializationException('МЭШ не вернул ссылку для авторизации')
281
+
282
+ response = await self._send_request(
283
+ 'GET',
284
+ unescape(match.group(1)),
285
+ return_type='response',
286
+ use_auth=False,
287
+ headers={'Referer': 'https://school.mos.ru/'}
288
+ )
289
+ async with response:
290
+ response.raise_for_status()
291
+
292
+ data = await self._poll_qr_login()
293
+ if data.get('command') != 'showQRCode':
294
+ raise QRLoginInitializationException('МЭШ не создал QR-код для входа')
295
+
296
+ link = data.get('link')
297
+ expires = data.get('expires')
298
+ if not isinstance(link, str) or not isinstance(expires, int):
299
+ raise QRLoginInitializationException('МЭШ вернул некорректные данные QR-кода')
300
+
301
+ if return_type == 'link':
302
+ return link, expires
303
+
304
+ from segno import make_qr
305
+ with BytesIO() as image:
306
+ make_qr(link, error='m', boost_error=False).save(image, kind='png', scale=4)
307
+ qr = image.getvalue()
308
+
309
+ if return_type == 'link&qr':
310
+ return link, qr, expires
311
+ return qr, expires
312
+
313
+
314
+ async def _poll_qr_login(self)-> dict:
315
+ """Получить текущее состояние QR-входа.
316
+
317
+ Returns:
318
+ dict: Ответ протокола QR-авторизации МЭШ.
319
+ """
320
+ return await self._send_request(
321
+ 'GET',
322
+ 'https://login.mos.ru/sps/login/methods/headless/qrCode/pull',
323
+ use_auth=False,
324
+ headers={
325
+ 'Accept': 'application/json, text/javascript, */*; q=0.01',
326
+ 'X-Requested-With': 'XMLHttpRequest',
327
+ }
328
+ )
329
+
330
+
331
+ async def wait_qr_login(
332
+ self,
333
+ expires: int,
334
+ get_code: Callable[[], str | Awaitable[str]] | None = None,
335
+ verification_method: Literal['sms', 'email', 'flash_call'] = 'flash_call',
336
+ trust_browser: bool = False,
337
+ )-> tuple[str, str]:
338
+ """
339
+ Дождаться подтверждения QR-входа и вернуть токены AUPD.
340
+
341
+ Args:
342
+ expires (`int`): Время истечения QR-кода в Unix time.
343
+ get_code (`Callable | None`): Функция, возвращающая код второго фактора. Может быть асинхронной.
344
+ verification_method (`Literal`): Получить код по SMS, почте или звонком.
345
+ trust_browser (`bool`): Не запрашивать второй фактор при следующих входах из этой сессии.
346
+
347
+ Returns:
348
+ tuple[str, str]: Пара `(aupd_token, aupd_refresh_token)`
349
+ """
350
+ try:
351
+ method_name = {
352
+ 'flash_call': 'flashCall',
353
+ 'email': 'email',
354
+ 'sms': 'sms',
355
+ }[verification_method]
356
+ except KeyError:
357
+ raise InvalidLoginParameterException(f'Неизвестный способ подтверждения: {verification_method}') from None
358
+
359
+ while time() < expires:
360
+ data = await self._poll_qr_login()
361
+ command = data.get('command')
362
+
363
+ if command == 'needComplete':
364
+ break
365
+ if command == 'needRefresh':
366
+ raise QRLoginStateException(command, data.get('cause', 'unknown'))
367
+ if command not in ('showQRCode', 'askForConfirm'):
368
+ raise QRLoginStateException(command)
369
+
370
+ await sleep(1)
371
+ else:
372
+ raise QRLoginExpiredException('Срок действия QR-кода истёк')
373
+
374
+ cookie_jar = self._ensure_session().cookie_jar
375
+ headers = {'Origin': 'https://login.mos.ru'}
376
+ origin_cookie = cookie_jar.filter_cookies(
377
+ URL('https://login.mos.ru/sps/login/methods/password')
378
+ ).get('origin')
379
+ if origin_cookie is not None and '|' in origin_cookie.value:
380
+ headers['Referer'] = (
381
+ 'https://login.mos.ru/sps/login/methods/password?bo='
382
+ f'{origin_cookie.value.split("|", 1)[1]}'
383
+ )
384
+
385
+ response = await self._send_request(
386
+ 'POST',
387
+ 'https://login.mos.ru/sps/login/methods/qrCode/complete',
388
+ return_type='response',
389
+ use_auth=False,
390
+ data={},
391
+ headers=headers
392
+ )
393
+ async with response:
394
+ response.raise_for_status()
395
+
396
+ if response.url.path.startswith('/sps/login/methods2/'):
397
+ if get_code is None:
398
+ raise TwoFactorRequiredException('Mos ID требует второй фактор. Передайте get_code в wait_qr_login() и повторите вход по новому QR-коду')
399
+
400
+ back_url = response.url.query.get('bo')
401
+ if back_url is None:
402
+ raise QRLoginStateException('missing_back_url')
403
+
404
+ if response.url.name != method_name:
405
+ referer = str(response.url)
406
+ response = await self._send_request(
407
+ 'GET',
408
+ str(URL(f'https://login.mos.ru/sps/login/methods2/{method_name}').with_query(bo=back_url)),
409
+ return_type='response',
410
+ use_auth=False,
411
+ headers={'Referer': referer},
412
+ )
413
+ async with response:
414
+ response.raise_for_status()
415
+
416
+ if verification_method == 'flash_call':
417
+ referer = str(response.url)
418
+ response = await self._send_request(
419
+ 'POST',
420
+ str(URL('https://login.mos.ru/sps/login/method/flashCall').with_query(bo=back_url)),
421
+ return_type='response',
422
+ use_auth=False,
423
+ data={},
424
+ headers={
425
+ 'Origin': 'https://login.mos.ru',
426
+ 'Referer': referer,
427
+ },
428
+ )
429
+ async with response:
430
+ response.raise_for_status()
431
+
432
+ code_field = 'code'
433
+ code_length = 4
434
+ code_url = URL('https://login.mos.ru/sps/login/method/flashCall/code').with_query(bo=back_url)
435
+ code_paths = (
436
+ '/sps/login/method/flashCall',
437
+ '/sps/login/method/flashCall/code',
438
+ )
439
+ else:
440
+ code_field = f'{method_name}-code'
441
+ code_length = 6
442
+ code_url = URL(f'https://login.mos.ru/sps/login/methods/{method_name}').with_query(bo=back_url)
443
+ code_paths = (
444
+ f'/sps/login/methods2/{method_name}',
445
+ f'/sps/login/methods/{method_name}'
446
+ )
447
+
448
+ while response.url.path in code_paths:
449
+ code = get_code()
450
+ if isawaitable(code):
451
+ code = await code
452
+ if (
453
+ not isinstance(code, str)
454
+ or len(code) != code_length
455
+ or not code.isdigit()
456
+ ):
457
+ raise InvalidVerificationCodeException(f'Код подтверждения должен состоять из {code_length} цифр')
458
+
459
+ referer = str(response.url)
460
+ response = await self._send_request(
461
+ 'POST',
462
+ str(code_url),
463
+ return_type='response',
464
+ use_auth=False,
465
+ data={code_field: code},
466
+ headers={
467
+ 'Origin': 'https://login.mos.ru',
468
+ 'Referer': referer,
469
+ },
470
+ )
471
+ async with response:
472
+ response.raise_for_status()
473
+ if response.url.path in code_paths:
474
+ config = search(
475
+ r'var vrfCodeConf\s*=\s*(\{.*?\});',
476
+ await response.text(),
477
+ DOTALL,
478
+ )
479
+ if config is not None:
480
+ error = search(r'"error"\s*:\s*"([^"]+)"', config[1])
481
+ ttl = search(r'"ttl"\s*:\s*(\d+)', config[1])
482
+ attempts = search(r'"attemptsLeft"\s*:\s*(\d+)', config[1])
483
+ if (
484
+ (error is not None and search(r'ист[её]к|просроч', error[1], IGNORECASE))
485
+ or (ttl is not None and int(ttl[1]) == 0)
486
+ ):
487
+ raise VerificationCodeExpiredException('Срок действия кода подтверждения истёк')
488
+ if attempts is not None and int(attempts[1]) == 0:
489
+ raise VerificationAttemptsExhaustedException('Попытки ввода кода подтверждения исчерпаны')
490
+
491
+ if response.url.path == '/sps/login/ur/askToTrust':
492
+ response = await self._send_request(
493
+ 'POST',
494
+ str(response.url),
495
+ return_type='response',
496
+ use_auth=False,
497
+ data={'action': 'trust' if trust_browser else 'do_not_trust'},
498
+ headers={
499
+ 'Origin': 'https://login.mos.ru',
500
+ 'Referer': str(response.url),
501
+ },
502
+ )
503
+ async with response:
504
+ response.raise_for_status()
505
+
506
+ token_cookie = None
507
+ refresh_cookie = None
508
+ for redirect_response in (*response.history, response):
509
+ token_cookie = redirect_response.cookies.get('aupd_token') or token_cookie
510
+ refresh_cookie = redirect_response.cookies.get('aupd_refresh_token') or refresh_cookie
511
+
512
+ for cookie in cookie_jar:
513
+ if cookie.key == 'aupd_token':
514
+ token_cookie = cookie
515
+ elif cookie.key == 'aupd_refresh_token':
516
+ refresh_cookie = cookie
517
+
518
+ if token_cookie is None or refresh_cookie is None:
519
+ cookie_names = sorted(cookie.key for cookie in cookie_jar)
520
+ raise LoginTokenMissingException(f'МЭШ не вернул токены после авторизации. Последний адрес: {response.url.with_query(None)}; cookies: {cookie_names}')
521
+
522
+ aupd_token = token_cookie.value
523
+ aupd_refresh_token = refresh_cookie.value
524
+ self.aupd_token = aupd_token
525
+ return aupd_token, aupd_refresh_token
526
+
527
+
528
+ async def get_detail_info(self)-> ProfileDeatail:
529
+ """Получить подробную информацию о текущем пользователе.
530
+
531
+ Returns:
532
+ ProfileDeatail: Подробные данные профиля.
533
+ """
534
+ return ProfileDeatail.model_validate(await self._send_request('GET', 'https://school.mos.ru/v3/userinfo'))
535
+
536
+
537
+ async def get_family_info(self)-> Family:
538
+ """Получить информацию о себе, как о члене семьи.
539
+
540
+ При вызове автоматически заполняет `self.id`.
541
+
542
+ Returns:
543
+ Family: Данные профиля, детей и представителей семьи.
544
+ """
545
+ resp = await self._send_request('GET', 'family/web/v1/profile')
546
+ self.id = resp.get('profile')['id']
547
+ return Family.model_validate(resp)
548
+
549
+
550
+ async def get_base_info(self)-> UserInfo:
551
+ """Получить базовую информацию о текущем пользователе.
552
+
553
+ При вызове автоматически заполняет `self.uid`.
554
+
555
+ Returns:
556
+ UserInfo: Основные данные учётной записи МЭШ.
557
+ """
558
+ info = UserInfo.model_validate(await self._send_request('GET', 'https://school.mos.ru/v1/oauth/userinfo'))
559
+ self.uid = info.person_uid
560
+ return info
561
+
562
+
563
+ async def get_lessons(self, dates: list[date])-> list[LessonDay]:
564
+ """Получить расписание уроков на указанные даты.
565
+
566
+ Returns:
567
+ list[LessonDay]: Расписание, сгруппированное по дням.
568
+ """
569
+ if self.id is None:
570
+ await self.get_family_info()
571
+ response = await self._send_request(
572
+ 'GET', 'family/web/v1/schedule/short',
573
+ params={
574
+ 'student_id': self.id,
575
+ 'dates': ','.join(lesson_date.isoformat() for lesson_date in dates)
576
+ }
577
+ )
578
+ return [LessonDay.model_validate(day) for day in response['payload']]
579
+
580
+
581
+ async def get_schedule_events(
582
+ self,
583
+ from_date: date,
584
+ to_date: date,
585
+ include: Sequence[ScheduleEventInclude] | None = None,
586
+ source_types: Sequence[ScheduleEventType] | None = None,
587
+ )-> list[ScheduleEvent]:
588
+ """Получить события расписания за указанный период.
589
+
590
+ По умолчанию в `include` и `source_types` передаются все
591
+ поддерживаемые значения.
592
+
593
+ Returns:
594
+ list[ScheduleEvent]: Уроки и другие поддерживаемые события периода.
595
+ """
596
+ if self.id is None:
597
+ await self.get_family_info()
598
+ if self.uid is None:
599
+ await self.get_base_info()
600
+
601
+ selected_includes = ALL_SCHEDULE_EVENT_INCLUDES if include is None else include
602
+ selected_source_types = tuple(ScheduleEventType) if source_types is None else source_types
603
+
604
+ response = await self._send_request(
605
+ 'GET', 'eventcalendar/v1/api/events',
606
+ params={
607
+ 'person_ids': self.uid,
608
+ 'begin_date': from_date.isoformat(),
609
+ 'end_date': to_date.isoformat(),
610
+ 'expand': ','.join(selected_includes),
611
+ 'source_types': ','.join(selected_source_types),
612
+ },
613
+ headers={
614
+ 'Profile-Id': str(self.id),
615
+ 'X-Mes-Role': 'student',
616
+ 'X-mes-subsystem': 'familyweb'
617
+ }
618
+ )
619
+ try:
620
+ schedule_events = response['response']
621
+ if not isinstance(schedule_events, list):
622
+ raise TypeError('Поле response не является списком')
623
+ return [parse_schedule_event(schedule_event) for schedule_event in schedule_events]
624
+ except (KeyError, TypeError, ValidationError, ValueError) as error:
625
+ raise InvalidResponseException(
626
+ 'МЭШ вернул некорректный список событий расписания'
627
+ ) from error
628
+
629
+
630
+ async def get_homework(self, from_date: date, to_date: date)-> list[Homework]:
631
+ """
632
+ Получить Д/З
633
+
634
+ Args:
635
+ from_date (`date`): От даты
636
+ to_date (`date`): До даты
637
+
638
+ Returns:
639
+ list[Homework]: Домашние задания за указанный период.
640
+ """
641
+ if self.id is None:
642
+ await self.get_family_info()
643
+
644
+ return [Homework.model_validate(homework) for homework in (await self._send_request('GET', f'family/web/v1/homeworks?from={from_date}&to={to_date}&student_id={self.id}'))['payload']]
645
+
646
+
647
+ async def get_marks(self)-> list[SubjectMark]:
648
+ """Получить оценки по всем предметам.
649
+
650
+ Returns:
651
+ list[SubjectMark]: Сводные оценки и учебные периоды по предметам.
652
+ """
653
+ if self.id is None:
654
+ await self.get_family_info()
655
+ return [SubjectMark.model_validate(mark) for mark in (await self._send_request('GET', f'family/web/v1/subject_marks?student_id={self.id}'))['payload']]
656
+
657
+
658
+ async def get_date_marks(self, from_date: date, to_date: date)-> list[Mark]:
659
+ """
660
+ Получить оценки по дате
661
+
662
+ Args:
663
+ from_date (`date`): От даты
664
+ to_date (`date`): До даты
665
+
666
+ Returns:
667
+ list[Mark]: Оценки, выставленные за указанный период.
668
+ """
669
+ if self.id is None:
670
+ await self.get_family_info()
671
+ return [Mark.model_validate(mark) for mark in (await self._send_request('GET', f'family/web/v1/marks?student_id={self.id}&from={from_date}&to={to_date}'))['payload']]