pyedookit 0.3.2__tar.gz → 0.4.0__tar.gz

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 (37) hide show
  1. {pyedookit-0.3.2 → pyedookit-0.4.0}/PKG-INFO +13 -8
  2. {pyedookit-0.3.2 → pyedookit-0.4.0}/README.md +12 -7
  3. {pyedookit-0.3.2 → pyedookit-0.4.0}/pyproject.toml +1 -1
  4. {pyedookit-0.3.2 → pyedookit-0.4.0}/src/pyedookit/__init__.py +2 -0
  5. pyedookit-0.4.0/src/pyedookit/auth.py +206 -0
  6. {pyedookit-0.3.2 → pyedookit-0.4.0}/src/pyedookit/client.py +54 -37
  7. pyedookit-0.4.0/src/pyedookit/session.py +87 -0
  8. pyedookit-0.4.0/src/pyedookit/transport.py +229 -0
  9. {pyedookit-0.3.2 → pyedookit-0.4.0}/tests/fakes.py +13 -2
  10. {pyedookit-0.3.2 → pyedookit-0.4.0}/tests/test_auth.py +45 -30
  11. pyedookit-0.4.0/tests/test_client.py +246 -0
  12. pyedookit-0.4.0/tests/test_session.py +76 -0
  13. pyedookit-0.4.0/tests/test_transport.py +184 -0
  14. pyedookit-0.3.2/src/pyedookit/auth.py +0 -379
  15. pyedookit-0.3.2/src/pyedookit/session.py +0 -70
  16. pyedookit-0.3.2/tests/test_client.py +0 -125
  17. pyedookit-0.3.2/tests/test_session.py +0 -54
  18. {pyedookit-0.3.2 → pyedookit-0.4.0}/.gitignore +0 -0
  19. {pyedookit-0.3.2 → pyedookit-0.4.0}/LICENSE +0 -0
  20. {pyedookit-0.3.2 → pyedookit-0.4.0}/src/pyedookit/exceptions.py +0 -0
  21. {pyedookit-0.3.2 → pyedookit-0.4.0}/src/pyedookit/models.py +0 -0
  22. {pyedookit-0.3.2 → pyedookit-0.4.0}/src/pyedookit/parsers/__init__.py +0 -0
  23. {pyedookit-0.3.2 → pyedookit-0.4.0}/src/pyedookit/parsers/dates.py +0 -0
  24. {pyedookit-0.3.2 → pyedookit-0.4.0}/src/pyedookit/parsers/events.py +0 -0
  25. {pyedookit-0.3.2 → pyedookit-0.4.0}/src/pyedookit/parsers/fingerprint.py +0 -0
  26. {pyedookit-0.3.2 → pyedookit-0.4.0}/src/pyedookit/parsers/payments.py +0 -0
  27. {pyedookit-0.3.2 → pyedookit-0.4.0}/src/pyedookit/parsers/timetable.py +0 -0
  28. {pyedookit-0.3.2 → pyedookit-0.4.0}/src/pyedookit/py.typed +0 -0
  29. {pyedookit-0.3.2 → pyedookit-0.4.0}/tests/__init__.py +0 -0
  30. {pyedookit-0.3.2 → pyedookit-0.4.0}/tests/fixtures/payments_structure.html +0 -0
  31. {pyedookit-0.3.2 → pyedookit-0.4.0}/tests/fixtures/timetable_real_structure.html +0 -0
  32. {pyedookit-0.3.2 → pyedookit-0.4.0}/tests/fixtures/upcoming_events_structure.html +0 -0
  33. {pyedookit-0.3.2 → pyedookit-0.4.0}/tests/test_dates.py +0 -0
  34. {pyedookit-0.3.2 → pyedookit-0.4.0}/tests/test_events_parser.py +0 -0
  35. {pyedookit-0.3.2 → pyedookit-0.4.0}/tests/test_fingerprint.py +0 -0
  36. {pyedookit-0.3.2 → pyedookit-0.4.0}/tests/test_payments_parser.py +0 -0
  37. {pyedookit-0.3.2 → pyedookit-0.4.0}/tests/test_timetable_parser.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pyedookit
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: Async client for the Edookit parent portal (edookit.net)
5
5
  Project-URL: Homepage, https://github.com/TFoxik/pyedookit
6
6
  Author: TFoxik
@@ -70,17 +70,22 @@ asyncio.run(main())
70
70
  ### Sessions
71
71
 
72
72
  The password is only used by `login()`. Store `session.to_json()` instead,
73
- and treat it like a password: it holds the Plus4U cookie, which signs in
74
- for about 19 days. Load it with `Session.from_json()`.
73
+ and treat it like a password: it holds the Plus4U sign-in, which lasts about
74
+ 19 days (`session.plus4u_expires_at` tells you when, or is `None` if that's
75
+ unknown or already past). Load it with
76
+ `Session.from_json()`; sessions stored by 0.3.x still load.
75
77
 
76
- The edookit session itself lasts 30-60 minutes. When it runs out, call
77
- `refresh(session)` to get a new one from the Plus4U cookie. When `refresh()`
78
- raises `AuthError`, the Plus4U cookie has expired too and you need `login()`
79
- again.
78
+ `EdookitClient` renews the short-lived edookit session by itself and
79
+ retries once. Pass `on_session_update` to persist each renewed session:
80
+
81
+ client = EdookitClient(session, on_session_update=lambda s: save(s.to_json()))
82
+
83
+ When the Plus4U sign-in has expired too, calls raise `AuthError` and you
84
+ need `login()` again.
80
85
 
81
86
  | Exception | Meaning | What to do |
82
87
  |---|---|---|
83
- | `SessionExpired` | the edookit session ran out | `refresh()` and retry |
88
+ | `SessionExpired` | still expired right after a renewal | retry later |
84
89
  | `AuthError` | bad credentials, or the Plus4U cookie expired | `login()` |
85
90
  | `EdookitConnectionError` | network error or 30 s timeout | retry later |
86
91
  | `ParseError` | the portal's markup changed | the parser needs updating |
@@ -48,17 +48,22 @@ asyncio.run(main())
48
48
  ### Sessions
49
49
 
50
50
  The password is only used by `login()`. Store `session.to_json()` instead,
51
- and treat it like a password: it holds the Plus4U cookie, which signs in
52
- for about 19 days. Load it with `Session.from_json()`.
51
+ and treat it like a password: it holds the Plus4U sign-in, which lasts about
52
+ 19 days (`session.plus4u_expires_at` tells you when, or is `None` if that's
53
+ unknown or already past). Load it with
54
+ `Session.from_json()`; sessions stored by 0.3.x still load.
53
55
 
54
- The edookit session itself lasts 30-60 minutes. When it runs out, call
55
- `refresh(session)` to get a new one from the Plus4U cookie. When `refresh()`
56
- raises `AuthError`, the Plus4U cookie has expired too and you need `login()`
57
- again.
56
+ `EdookitClient` renews the short-lived edookit session by itself and
57
+ retries once. Pass `on_session_update` to persist each renewed session:
58
+
59
+ client = EdookitClient(session, on_session_update=lambda s: save(s.to_json()))
60
+
61
+ When the Plus4U sign-in has expired too, calls raise `AuthError` and you
62
+ need `login()` again.
58
63
 
59
64
  | Exception | Meaning | What to do |
60
65
  |---|---|---|
61
- | `SessionExpired` | the edookit session ran out | `refresh()` and retry |
66
+ | `SessionExpired` | still expired right after a renewal | retry later |
62
67
  | `AuthError` | bad credentials, or the Plus4U cookie expired | `login()` |
63
68
  | `EdookitConnectionError` | network error or 30 s timeout | retry later |
64
69
  | `ParseError` | the portal's markup changed | the parser needs updating |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pyedookit"
7
- version = "0.3.2"
7
+ version = "0.4.0"
8
8
  description = "Async client for the Edookit parent portal (edookit.net)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -10,11 +10,13 @@ from .exceptions import (
10
10
  from .models import PORTAL_TZ, Lesson, PaymentRequest, Payments, SchoolEvent, Term
11
11
  from .parsers.fingerprint import content_fingerprint
12
12
  from .session import Session
13
+ from .transport import CookieStore
13
14
 
14
15
  __all__ = [
15
16
  "CAPTURE_PAGES",
16
17
  "PORTAL_TZ",
17
18
  "AuthError",
19
+ "CookieStore",
18
20
  "EdookitClient",
19
21
  "EdookitConnectionError",
20
22
  "EdookitError",
@@ -0,0 +1,206 @@
1
+ from __future__ import annotations
2
+
3
+ import json as _json
4
+ import re
5
+ import secrets
6
+
7
+ import aiohttp
8
+ from yarl import URL
9
+
10
+ from . import transport
11
+ from .exceptions import AuthError
12
+ from .session import PLUS4U_HOST, Session, school_base, school_host, validate_school
13
+ from .transport import CookieStore, Response
14
+
15
+ _PLUS4U_OIDC_BASE = (
16
+ "https://uuidentity.plus4u.net/uu-oidc-maing02/bb977a99f4cc4c37a2afce3fd599d0a7"
17
+ )
18
+ _AUTH_URL = f"{_PLUS4U_OIDC_BASE}/oidc/auth"
19
+ _AUTH_PASSWORD_URL = f"{_PLUS4U_OIDC_BASE}/authPassword/authenticate"
20
+ _REALM_CODE = "uuIdentityPasswordAuthNRealm"
21
+
22
+ # Confirmed against a real portal: the OIDC client_id is per-school, not a
23
+ # single value shared across every Edookit install (third-party docs this
24
+ # was originally inferred from implied otherwise). Each school's /user/login
25
+ # page embeds it in a small inline JS config block, e.g.
26
+ # uu_app_oidc_providers_oidcg02_client_id: "983960dcb7b84b34b9aecc5194381d56"
27
+ # so it has to be scraped fresh per school rather than hardcoded.
28
+ _CLIENT_ID_RE = re.compile(
29
+ r"uu_app_oidc_providers_oidcg02_client_id\s*:\s*['\"]([^'\"]+)['\"]"
30
+ )
31
+
32
+ # Edookit sends the browser back to one of these paths when a session has
33
+ # expired or was never valid; shared with client.py so "did we get logged
34
+ # out" is checked the same way everywhere.
35
+ LOGIN_MARKERS = ("/user/login", "/user/oidc")
36
+
37
+
38
+ def is_login_url(url: str) -> bool:
39
+ return any(marker in url for marker in LOGIN_MARKERS)
40
+
41
+
42
+ def _callback_url(school: str) -> str:
43
+ return f"{school_base(school)}/user/oidc-login-callback"
44
+
45
+
46
+ def _auth_params(
47
+ school: str, client_id: str, state: str, *, prompt_none: bool = False
48
+ ) -> dict[str, str]:
49
+ params = {
50
+ "response_type": "code",
51
+ "client_id": client_id,
52
+ "redirect_uri": _callback_url(school),
53
+ "scope": "openid",
54
+ "state": state,
55
+ }
56
+ if prompt_none:
57
+ params["prompt"] = "none"
58
+ return params
59
+
60
+
61
+ async def _discover_client_id(
62
+ http: aiohttp.ClientSession, school: str, cookies: CookieStore
63
+ ) -> str:
64
+ """Scrape the school's own /user/login page for its Plus4U client_id.
65
+
66
+ Its Set-Cookie response - PHPSESSID etc. - lands in `cookies` and so
67
+ joins the rest of the login flow, rather than being discarded.
68
+ """
69
+ result = await transport.request(http, "GET", f"{school_base(school)}/user/login", cookies)
70
+ match = _CLIENT_ID_RE.search(result.text)
71
+ if not match:
72
+ raise AuthError(f"could not find a Plus4U client_id on {school}'s login page")
73
+ return match.group(1)
74
+
75
+
76
+ def _session_from_cookies(school: str, cookies: CookieStore) -> Session:
77
+ if not cookies.get(school_host(school), "PHPSESSID"):
78
+ raise AuthError("login flow completed but no edookit session cookie was set")
79
+ return Session(school=school, cookies=cookies)
80
+
81
+
82
+ async def _finish_callback(
83
+ http: aiohttp.ClientSession, callback_url: str, cookies: CookieStore, school: str
84
+ ) -> Session:
85
+ """Follow the final OIDC callback redirect chain and build a Session from it.
86
+
87
+ Shared tail of login() and refresh(): both end with "GET the code=
88
+ callback, make sure we didn't land back on the login page, then read the
89
+ cookies that piled up along the way."
90
+
91
+ Confirmed against a real browser trace (HAR): after the callback GET
92
+ resolves (still 200 on /oidc/callback), the browser makes one more GET
93
+ to the portal root, which is what actually settles the session server
94
+ side - skipping it leaves the account looking like it has almost no
95
+ modules enabled (only the dashboard and badges), even though login
96
+ itself succeeded and the session cookies are valid.
97
+ """
98
+ result = await transport.request(http, "GET", callback_url, cookies, follow_redirects=True)
99
+ if is_login_url(result.url):
100
+ raise AuthError("edookit rejected the Plus4U authorization code")
101
+ settle = await transport.request(
102
+ http, "GET", school_base(school), cookies, follow_redirects=True
103
+ )
104
+ if is_login_url(settle.url):
105
+ raise AuthError("session did not settle after the OIDC callback")
106
+ return _session_from_cookies(school, cookies)
107
+
108
+
109
+ async def probe_client_id(school: str) -> str:
110
+ """Discover a school's Plus4U client_id without logging in.
111
+
112
+ Used by scripts/capture_fixtures.py to show what login()/refresh() will
113
+ use for this school before spending the user's password.
114
+ """
115
+ validate_school(school)
116
+ async with transport.http_session(None) as http:
117
+ return await _discover_client_id(http, school, CookieStore())
118
+
119
+
120
+ async def _authorize(
121
+ http: aiohttp.ClientSession, school: str, cookies: CookieStore, *, prompt_none: bool = False
122
+ ) -> tuple[str, str, Response]:
123
+ """Start the OIDC authorization: shared opening of login() and refresh()."""
124
+ client_id = await _discover_client_id(http, school, cookies)
125
+ state = secrets.token_urlsafe(16)
126
+ params = _auth_params(school, client_id, state, prompt_none=prompt_none)
127
+ return client_id, state, await transport.request(
128
+ http, "GET", _AUTH_URL, cookies, params=params
129
+ )
130
+
131
+
132
+ async def login(
133
+ school: str,
134
+ username: str,
135
+ password: str,
136
+ *,
137
+ http: aiohttp.ClientSession | None = None,
138
+ ) -> Session:
139
+ """Run the Plus4U OIDC password login and return a usable Session.
140
+
141
+ Raises AuthError if the credentials are rejected or the flow's shape has
142
+ changed (e.g. Edookit or Plus4U altered a redirect), and
143
+ EdookitConnectionError if either host can't be reached.
144
+
145
+ A passed-in `http` session must not keep its own cookies (use
146
+ `aiohttp.DummyCookieJar()`): cookies are tracked per host by hand here,
147
+ and a real jar would also collect and resend them.
148
+ """
149
+ validate_school(school)
150
+ cookies = CookieStore()
151
+ async with transport.http_session(http) as http:
152
+ client_id, state, step1 = await _authorize(http, school, cookies)
153
+ if not step1.is_redirect or not step1.location:
154
+ raise AuthError(f"expected a redirect from Plus4U auth endpoint, got {step1.status}")
155
+ login_url = URL(step1.location)
156
+ server_state = login_url.query.get("state", state)
157
+ server_client_id = login_url.query.get("clientId", client_id)
158
+
159
+ payload = {
160
+ "clientId": server_client_id,
161
+ "state": server_state,
162
+ "rememberMe": True,
163
+ "realmCode": _REALM_CODE,
164
+ "username": username,
165
+ "password": password,
166
+ }
167
+ step2 = await transport.request(http, "POST", _AUTH_PASSWORD_URL, cookies, json=payload)
168
+ if step2.status in (400, 401):
169
+ raise AuthError("Plus4U rejected the username or password")
170
+ continue_url = step2.location
171
+ if not continue_url and step2.text:
172
+ try:
173
+ body = _json.loads(step2.text)
174
+ except ValueError:
175
+ body = None
176
+ if isinstance(body, dict):
177
+ continue_url = body.get("redirectUrl")
178
+ if not continue_url:
179
+ raise AuthError("Plus4U password auth did not return a continuation URL")
180
+
181
+ step3 = await transport.request(http, "GET", continue_url, cookies)
182
+ if not step3.location or "code=" not in step3.location:
183
+ raise AuthError("Plus4U did not return an authorization code")
184
+
185
+ return await _finish_callback(http, step3.location, cookies, school)
186
+
187
+
188
+ async def refresh(session: Session, *, http: aiohttp.ClientSession | None = None) -> Session:
189
+ """Obtain a fresh edookit session using the stored Plus4U cookies.
190
+
191
+ No password is needed; this is what keeps a long-running Home Assistant
192
+ integration logged in across the ~30-60 minute edookit session lifetime.
193
+ Raises AuthError if the Plus4U cookies have themselves expired (roughly
194
+ every 19 days), which means a full `login()` with the password is needed.
195
+ `http` has the same cookie-jar requirement as in `login()`.
196
+ """
197
+ # Only the Plus4U cookies: the portal answers /user/login with a redirect
198
+ # to /oidc/logout when it sees the expired edookit session, so the new
199
+ # edookit session is built from scratch, exactly like in login().
200
+ cookies = session.cookies.for_host(PLUS4U_HOST)
201
+ async with transport.http_session(http) as http:
202
+ _, _, step1 = await _authorize(http, session.school, cookies, prompt_none=True)
203
+ if not step1.is_redirect or not step1.location or "code=" not in step1.location:
204
+ raise AuthError("Plus4U session expired; full login required")
205
+
206
+ return await _finish_callback(http, step1.location, cookies, session.school)
@@ -2,17 +2,21 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import json
5
+ import logging
6
+ from collections.abc import Callable
5
7
 
6
8
  import aiohttp
7
9
 
8
- from . import auth
9
- from .exceptions import EdookitConnectionError, SessionExpired
10
+ from . import auth, transport
11
+ from .exceptions import SessionExpired
10
12
  from .models import Lesson, Payments, SchoolEvent, Term
11
13
  from .parsers.events import parse_events
12
14
  from .parsers.payments import parse_payments
13
15
  from .parsers.timetable import parse_terms, parse_timetable
14
16
  from .session import Session
15
17
 
18
+ _LOGGER = logging.getLogger(__name__)
19
+
16
20
  _TIMETABLE_PATH = "/timetable/"
17
21
  _EVENTS_PATH = "/timetable/upcoming"
18
22
  _PAYMENTS_PATH = "/payments/"
@@ -28,10 +32,10 @@ CAPTURE_PAGES: dict[str, str] = {
28
32
  class EdookitClient:
29
33
  """Fetches parsed data from one school's Edookit parent portal.
30
34
 
31
- Holds a `Session` that the caller is responsible for persisting and
32
- refreshing (see `pyedookit.auth.refresh`); this class only detects when
33
- the current session has gone stale and raises `SessionExpired` so the
34
- caller can refresh and retry.
35
+ Renews an expired edookit session by itself from the Plus4U sign-in and
36
+ retries once; `on_session_update` receives each renewed `Session` so the
37
+ caller can persist it. `AuthError` means the Plus4U sign-in expired too
38
+ and a full `login()` is needed.
35
39
 
36
40
  Pass `http` to reuse an existing `aiohttp.ClientSession` across requests
37
41
  instead of opening a new connection per call; otherwise one is created
@@ -43,9 +47,17 @@ class EdookitClient:
43
47
  the caller's event loop.
44
48
  """
45
49
 
46
- def __init__(self, session: Session, http: aiohttp.ClientSession | None = None) -> None:
50
+ def __init__(
51
+ self,
52
+ session: Session,
53
+ http: aiohttp.ClientSession | None = None,
54
+ *,
55
+ on_session_update: Callable[[Session], None] | None = None,
56
+ ) -> None:
47
57
  self._session = session
48
58
  self._http = http
59
+ self._on_session_update = on_session_update
60
+ self._renew_lock = asyncio.Lock()
49
61
 
50
62
  @property
51
63
  def session(self) -> Session:
@@ -78,39 +90,45 @@ class EdookitClient:
78
90
  return await self._get(path)
79
91
 
80
92
  async def _get(self, path: str, params: dict[str, int] | None = None) -> str:
81
- url = f"{self._session.base_url}{path}"
82
- # allow_redirects=False: our Cookie/CSRF headers are set manually, and
83
- # older aiohttp forwards a manually-set Cookie header to a redirect's
84
- # target even when that target is a different host - so an expired
85
- # session's redirect to Plus4U must never be followed with them
86
- # attached. Any redirect at all means the session isn't good anymore.
93
+ session = self._session
87
94
  try:
88
- async with (
89
- auth.http_session(self._http) as http,
90
- http.get(
91
- url,
92
- params=params,
93
- headers=auth.build_headers(self._session),
94
- allow_redirects=False,
95
- timeout=auth.REQUEST_TIMEOUT,
96
- ) as resp,
97
- ):
98
- if resp.status in auth.REDIRECT_STATUSES:
99
- raise SessionExpired("edookit session has expired")
100
- body = await resp.text()
101
- except (aiohttp.ClientError, TimeoutError) as err:
102
- raise EdookitConnectionError(f"request to {url} failed: {err}") from err
103
- if _redirects_to_login(resp.headers.get("Content-Type", ""), body):
95
+ return await self._fetch(session, path, params)
96
+ except SessionExpired:
97
+ _LOGGER.debug("Edookit session expired, renewing it")
98
+ await self._renew(session)
99
+ return await self._fetch(self._session, path, params)
100
+
101
+ async def _renew(self, stale: Session) -> None:
102
+ async with self._renew_lock:
103
+ if self._session is not stale:
104
+ return
105
+ self._session = await auth.refresh(stale, http=self._http)
106
+ if self._on_session_update is not None:
107
+ self._on_session_update(self._session)
108
+
109
+ async def _fetch(self, session: Session, path: str, params: dict[str, int] | None) -> str:
110
+ async with transport.http_session(self._http) as http:
111
+ response = await transport.request(
112
+ http,
113
+ "GET",
114
+ f"{session.base_url}{path}",
115
+ session.cookies,
116
+ headers={"X-CSRF-Token": session.csrf_token, "X-Requested-With": "XMLHttpRequest"},
117
+ params=params,
118
+ )
119
+ if response.is_redirect or _is_ajax_redirect(response.content_type, response.text):
104
120
  raise SessionExpired("edookit session has expired")
105
- return body
121
+ return response.text
106
122
 
107
123
 
108
- def _redirects_to_login(content_type: str, body: str) -> bool:
109
- """Whether the portal answered with its AJAX-style login redirect.
124
+ def _is_ajax_redirect(content_type: str, body: str) -> bool:
125
+ """Whether the portal answered with an AJAX-style redirect.
110
126
 
111
- Requests carry X-Requested-With, so an expired session gets a 200 with
112
- {"redirect": "https://<school>.edookit.net/user/login?message=1"} rather
113
- than an HTTP redirect (confirmed against a real portal).
127
+ Requests carry X-Requested-With, so an expired session gets a 200 with a
128
+ JSON {"redirect": url} rather than an HTTP redirect. Both shapes seen on a
129
+ real portal are expired sessions: /user/login?message=1 and
130
+ /oidc/logout?performCallback=.... A redirect answer never carries the
131
+ page, so the target URL is deliberately not inspected.
114
132
  """
115
133
  if not content_type.startswith("application/json"):
116
134
  return False
@@ -118,5 +136,4 @@ def _redirects_to_login(content_type: str, body: str) -> bool:
118
136
  data = json.loads(body)
119
137
  except ValueError:
120
138
  return False
121
- redirect = data.get("redirect") if isinstance(data, dict) else None
122
- return isinstance(redirect, str) and auth.is_login_url(redirect)
139
+ return isinstance(data, dict) and isinstance(data.get("redirect"), str)
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime
8
+
9
+ from .exceptions import AuthError
10
+ from .transport import Cookie, CookieStore
11
+
12
+ PLUS4U_HOST = "uuidentity.plus4u.net"
13
+
14
+ # A school subdomain becomes part of every portal URL with no other encoding,
15
+ # so reject anything that isn't a bare hostname label - otherwise a value
16
+ # like "evil.com/" or "evil.com#" would send the session's cookies off-portal.
17
+ _SCHOOL_RE = re.compile(r"[a-z0-9][a-z0-9-]{0,62}")
18
+
19
+
20
+ def validate_school(school: str) -> None:
21
+ if not _SCHOOL_RE.fullmatch(school):
22
+ raise AuthError(f"{school!r} is not a valid Edookit school subdomain")
23
+
24
+
25
+ def school_host(school: str) -> str:
26
+ return f"{school}.edookit.net"
27
+
28
+
29
+ def school_base(school: str) -> str:
30
+ return f"https://{school_host(school)}"
31
+
32
+
33
+ @dataclass(slots=True)
34
+ class Session:
35
+ """Everything needed to talk to a school's portal without the password.
36
+
37
+ `cookies` holds the short-lived edookit session (30-60 minutes) and the
38
+ long-lived Plus4U sign-in that `refresh()` renews it from. Treat the
39
+ serialised form like a password.
40
+ """
41
+
42
+ school: str
43
+ cookies: CookieStore = field(repr=False)
44
+
45
+ def __post_init__(self) -> None:
46
+ validate_school(self.school)
47
+
48
+ @classmethod
49
+ def from_cookies(
50
+ cls, school: str, edookit: Mapping[str, str], plus4u: Mapping[str, str]
51
+ ) -> Session:
52
+ return cls(
53
+ school,
54
+ CookieStore(
55
+ {
56
+ school_host(school): {n: Cookie(v) for n, v in edookit.items()},
57
+ PLUS4U_HOST: {n: Cookie(v) for n, v in plus4u.items()},
58
+ }
59
+ ),
60
+ )
61
+
62
+ @property
63
+ def host(self) -> str:
64
+ return school_host(self.school)
65
+
66
+ @property
67
+ def base_url(self) -> str:
68
+ return school_base(self.school)
69
+
70
+ @property
71
+ def csrf_token(self) -> str:
72
+ return self.cookies.get(self.host, "uu.app.csrf") or ""
73
+
74
+ @property
75
+ def plus4u_expires_at(self) -> datetime | None:
76
+ return self.cookies.expires(PLUS4U_HOST, "uoid.ps")
77
+
78
+ def to_json(self) -> str:
79
+ return json.dumps({"school": self.school, "cookies": self.cookies.to_dict()})
80
+
81
+ @classmethod
82
+ def from_json(cls, raw: str) -> Session:
83
+ data = json.loads(raw)
84
+ if "cookies" in data:
85
+ return cls(data["school"], CookieStore.from_dict(data["cookies"]))
86
+ # Written by pyedookit <= 0.3.x: two plain dicts and no cookie expiry.
87
+ return cls.from_cookies(data["school"], data["edookit_cookies"], data["plus4u_cookies"])