parro-api-client 0.5.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.
- parro/__init__.py +17 -0
- parro/api_client.py +413 -0
- parro/archive.py +235 -0
- parro/attachments.py +516 -0
- parro/authenticator.py +292 -0
- parro/cache.py +177 -0
- parro/cli.py +502 -0
- parro/errors.py +79 -0
- parro/geon.py +131 -0
- parro/http.py +158 -0
- parro/icalendar.py +148 -0
- parro/login_methods.py +425 -0
- parro/mcp_server.py +600 -0
- parro/naming.py +26 -0
- parro/operations.py +786 -0
- parro/pkce.py +46 -0
- parro/rendering.py +921 -0
- parro/settings.py +221 -0
- parro/summaries.py +397 -0
- parro/token_store.py +223 -0
- parro_api_client-0.5.0.dist-info/METADATA +396 -0
- parro_api_client-0.5.0.dist-info/RECORD +27 -0
- parro_api_client-0.5.0.dist-info/WHEEL +5 -0
- parro_api_client-0.5.0.dist-info/entry_points.txt +3 -0
- parro_api_client-0.5.0.dist-info/licenses/LICENSE +395 -0
- parro_api_client-0.5.0.dist-info/licenses/LICENSE-CODE +21 -0
- parro_api_client-0.5.0.dist-info/top_level.txt +1 -0
parro/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Read-only client for the Parro REST API (talk.parro.com), shared by the CLI and the MCP server.
|
|
2
|
+
|
|
3
|
+
Everything that talks to Parro or to the ParnasSys login server lives in this package. The two entry
|
|
4
|
+
points only translate between their own interface (command line, MCP JSON-RPC) and this core.
|
|
5
|
+
|
|
6
|
+
This package must never:
|
|
7
|
+
* send anything other than GET to the Parro API (POST goes only to the IdP's token, revoke and
|
|
8
|
+
login-form endpoints);
|
|
9
|
+
* write tokens, passwords or full attachment URLs to a log;
|
|
10
|
+
* start a password login on its own after a password was rejected (see authenticator.py).
|
|
11
|
+
|
|
12
|
+
Inspired by fobatai/parro-mcp; independent implementation based on openapi/parro.openapi.json.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
# The one place the version lives. pyproject.toml repeats it; a test keeps the two equal, and the
|
|
16
|
+
# release workflow checks that the git tag matches.
|
|
17
|
+
__version__ = "0.5.0"
|
parro/api_client.py
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""ParroClient: read-only access to the Parro REST API (https://rest-v2.parro.com/rest/v2).
|
|
2
|
+
|
|
3
|
+
get() is the one low-level call; every endpoint in the spec has a small method on top of it
|
|
4
|
+
(get_account, list_groups, list_events, get_event, ...). They return the API's own JSON, untouched;
|
|
5
|
+
summaries.py shapes it for people.
|
|
6
|
+
|
|
7
|
+
Rules for this module:
|
|
8
|
+
* Only GET. There is no method that could change anything at Parro.
|
|
9
|
+
* Requests only go to the configured API host, so a token can never be sent elsewhere.
|
|
10
|
+
* A 401 leads to one refresh and one retry, never to a password login (PLAN-api-client §4, fix 3).
|
|
11
|
+
A second 401 raises TokenRejected, which explains the `dtype` pitfall of GET /event/{id}.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import urllib.parse
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any, Literal
|
|
18
|
+
|
|
19
|
+
from .authenticator import Authenticator
|
|
20
|
+
from .cache import Cache
|
|
21
|
+
from .errors import ApiError, ConfigurationError, TokenRejected
|
|
22
|
+
from .geon import (
|
|
23
|
+
build_range_header,
|
|
24
|
+
get_collection_items,
|
|
25
|
+
get_object_id,
|
|
26
|
+
parse_total_from_content_range,
|
|
27
|
+
)
|
|
28
|
+
from .http import HttpRequest, HttpResponse, HttpTransport, UrllibTransport, add_query_parameters
|
|
29
|
+
from .settings import Settings
|
|
30
|
+
from .token_store import TokenStore
|
|
31
|
+
|
|
32
|
+
log = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
# dtype values that select what an endpoint returns (spec: GET /event, GET /event/{id}, GET /group).
|
|
35
|
+
ANNOUNCEMENT_LIST_DTYPE = "event.RAnnouncementEventPrimer"
|
|
36
|
+
CALENDAR_LIST_DTYPE = "event.RCalendarItemEventPrimer"
|
|
37
|
+
# RAnnouncementEvent answers 401 (not 406) on GET /event/{id}; the Primer type works (fix 2).
|
|
38
|
+
ANNOUNCEMENT_DTYPE = "event.RAnnouncementEventPrimer"
|
|
39
|
+
CALENDAR_ITEM_DTYPE = "event.RCalendarItemEvent"
|
|
40
|
+
CURRENT_GROUPS_DTYPE = "identity.RHomeGroup"
|
|
41
|
+
PREVIOUS_GROUPS_SCOPE = "PREVIOUS_ACTIVE"
|
|
42
|
+
# The groups request of the official client asks for 50; enough for any family.
|
|
43
|
+
GROUPS_PAGE_SIZE = 50
|
|
44
|
+
PRIVACY_CHILD_DTYPE = "identity.RPrivacySettingChild"
|
|
45
|
+
|
|
46
|
+
EventKind = Literal["announcement", "calendar"]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class ApiResponse:
|
|
51
|
+
"""A parsed answer: the JSON payload and, for collections, the Content-Range header."""
|
|
52
|
+
|
|
53
|
+
payload: Any
|
|
54
|
+
content_range: str | None
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def items(self) -> list[Any]:
|
|
58
|
+
return get_collection_items(self.payload)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def total(self) -> int | None:
|
|
62
|
+
return parse_total_from_content_range(self.content_range)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class FeedEvent:
|
|
67
|
+
"""One event from a group's feed, with the name of the group it was listed under."""
|
|
68
|
+
|
|
69
|
+
event: dict[str, Any]
|
|
70
|
+
group_name: str | None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ParroClient:
|
|
74
|
+
"""Talks to the Parro API on behalf of one logged-in account."""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
settings: Settings,
|
|
79
|
+
transport: HttpTransport | None = None,
|
|
80
|
+
token_store: TokenStore | None = None,
|
|
81
|
+
authenticator: Authenticator | None = None,
|
|
82
|
+
) -> None:
|
|
83
|
+
self.settings = settings
|
|
84
|
+
self.transport = transport or UrllibTransport(settings.http_timeout_seconds)
|
|
85
|
+
self.token_store = token_store or TokenStore(
|
|
86
|
+
settings.token_file, bootstrap_access_token=settings.bootstrap_access_token
|
|
87
|
+
)
|
|
88
|
+
self.authenticator = authenticator or Authenticator(
|
|
89
|
+
settings, self.transport, self.token_store
|
|
90
|
+
)
|
|
91
|
+
# Attachments and calendar details only (PLAN-readable-output §3b); lists never.
|
|
92
|
+
self.cache = Cache(
|
|
93
|
+
settings.data_dir / "cache", settings.cache_max_bytes, enabled=settings.cache_enabled
|
|
94
|
+
)
|
|
95
|
+
self._account: dict[str, Any] | None = None
|
|
96
|
+
self._role_header: str | None = None
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def from_environment(cls) -> "ParroClient":
|
|
100
|
+
return cls(Settings.from_environment())
|
|
101
|
+
|
|
102
|
+
# ------------------------------------------------------------------------- low level
|
|
103
|
+
|
|
104
|
+
def get(
|
|
105
|
+
self,
|
|
106
|
+
path: str,
|
|
107
|
+
params: dict[str, Any] | None = None,
|
|
108
|
+
limit: int | None = None,
|
|
109
|
+
offset: int = 0,
|
|
110
|
+
count_total: bool = False,
|
|
111
|
+
send_role: bool = True,
|
|
112
|
+
allow_password_login: bool = True,
|
|
113
|
+
refresh_on_unauthorized: bool = True,
|
|
114
|
+
) -> ApiResponse:
|
|
115
|
+
"""GET an API path such as '/child' or '/event?dtype=...&group=1'.
|
|
116
|
+
|
|
117
|
+
`limit` sends a Range header (and `count_total` asks for an exact total). `send_role`
|
|
118
|
+
False leaves out parro-authorization-role, which GET /account/me must be called without.
|
|
119
|
+
`refresh_on_unauthorized` False reports a 401 at once (for probing a dtype).
|
|
120
|
+
"""
|
|
121
|
+
url = add_query_parameters(self.build_api_url(path), params or {})
|
|
122
|
+
label = f"GET {urllib.parse.urlsplit(url).path}"
|
|
123
|
+
headers = self._build_headers(limit, offset, count_total, send_role, allow_password_login)
|
|
124
|
+
response = self._send_with_one_refresh(url, headers, label, refresh_on_unauthorized)
|
|
125
|
+
if response.status not in (200, 206):
|
|
126
|
+
raise _api_error(label, response)
|
|
127
|
+
return ApiResponse(response.json(), response.header("Content-Range"))
|
|
128
|
+
|
|
129
|
+
def download_from_api_host(self, url: str) -> HttpResponse:
|
|
130
|
+
"""GET a file that lives on the API host itself (not the CDN), with the Parro headers."""
|
|
131
|
+
if not url.startswith(self.settings.api_base_uri + "/"):
|
|
132
|
+
raise ConfigurationError("Only URLs on the configured Parro API host get a token.")
|
|
133
|
+
headers = self._build_headers(None, 0, False, True, True)
|
|
134
|
+
headers["Accept"] = "*/*"
|
|
135
|
+
response = self._send_with_one_refresh(url, headers, "GET file on the API host", True)
|
|
136
|
+
if response.status not in (200, 206):
|
|
137
|
+
raise _api_error("GET file on the API host", response)
|
|
138
|
+
return response
|
|
139
|
+
|
|
140
|
+
def _send_with_one_refresh(
|
|
141
|
+
self, url: str, headers: dict[str, str], label: str, refresh_on_unauthorized: bool
|
|
142
|
+
) -> HttpResponse:
|
|
143
|
+
"""Send; on a 401 refresh once and resend once (fix 3). A 401 never leads to a login."""
|
|
144
|
+
response = self._send(url, headers, label)
|
|
145
|
+
if response.status == 401 and refresh_on_unauthorized:
|
|
146
|
+
new_access_token = self.authenticator.refresh_after_unauthorized()
|
|
147
|
+
if new_access_token is not None:
|
|
148
|
+
headers["Authorization"] = f"Bearer {new_access_token}"
|
|
149
|
+
response = self._send(url, headers, label)
|
|
150
|
+
if response.status == 401:
|
|
151
|
+
raise TokenRejected(
|
|
152
|
+
f"{label} answered 401 Unauthorized. Either the login is no longer valid (log "
|
|
153
|
+
"in again), or this endpoint refuses the requested `dtype` (GET /event/{id} "
|
|
154
|
+
"answers 401 for dtype=event.RAnnouncementEvent).",
|
|
155
|
+
status=401,
|
|
156
|
+
)
|
|
157
|
+
return response
|
|
158
|
+
|
|
159
|
+
def build_api_url(self, path: str) -> str:
|
|
160
|
+
"""Turn '/child' (or '/rest/v2/child') into a full URL on the configured API host."""
|
|
161
|
+
cleaned = path.strip()
|
|
162
|
+
if "://" in cleaned or cleaned.startswith("//"):
|
|
163
|
+
raise ConfigurationError(
|
|
164
|
+
"Give an API path such as /accountsettings, not a full URL: the token is only "
|
|
165
|
+
"ever sent to the configured Parro API host."
|
|
166
|
+
)
|
|
167
|
+
if not cleaned.startswith("/"):
|
|
168
|
+
cleaned = "/" + cleaned
|
|
169
|
+
if cleaned.startswith("/rest/"):
|
|
170
|
+
return self.settings.api_base_uri + cleaned
|
|
171
|
+
return self.settings.api_root + cleaned
|
|
172
|
+
|
|
173
|
+
def _build_headers(
|
|
174
|
+
self,
|
|
175
|
+
limit: int | None,
|
|
176
|
+
offset: int,
|
|
177
|
+
count_total: bool,
|
|
178
|
+
send_role: bool,
|
|
179
|
+
allow_password_login: bool,
|
|
180
|
+
) -> dict[str, str]:
|
|
181
|
+
access_token = self.authenticator.get_access_token(allow_password_login)
|
|
182
|
+
# The web client sends the geon media type as Accept *and* Content-Type, even on GET.
|
|
183
|
+
headers = {
|
|
184
|
+
"Authorization": f"Bearer {access_token}",
|
|
185
|
+
"Accept": self.settings.geon_media_type,
|
|
186
|
+
"Content-Type": self.settings.geon_media_type,
|
|
187
|
+
"parro-app-version": self.settings.app_version,
|
|
188
|
+
}
|
|
189
|
+
if send_role:
|
|
190
|
+
role = self.get_role_header(allow_password_login)
|
|
191
|
+
if role:
|
|
192
|
+
headers["parro-authorization-role"] = role
|
|
193
|
+
if limit is not None:
|
|
194
|
+
headers["Range"] = build_range_header(offset, limit)
|
|
195
|
+
if count_total:
|
|
196
|
+
headers["topicus-Range-Count"] = "Exact"
|
|
197
|
+
return headers
|
|
198
|
+
|
|
199
|
+
def _send(self, url: str, headers: dict[str, str], label: str) -> HttpResponse:
|
|
200
|
+
return self.transport.send(HttpRequest("GET", url, dict(headers), log_label=label))
|
|
201
|
+
|
|
202
|
+
# ---------------------------------------------------------------------- account and role
|
|
203
|
+
|
|
204
|
+
def get_account(self, allow_password_login: bool = True) -> dict[str, Any]:
|
|
205
|
+
"""GET /account/me (cached). Called without the role header, which is derived from it."""
|
|
206
|
+
if self._account is None:
|
|
207
|
+
response = self.get(
|
|
208
|
+
"/account/me", send_role=False, allow_password_login=allow_password_login
|
|
209
|
+
)
|
|
210
|
+
self._account = response.payload if isinstance(response.payload, dict) else {}
|
|
211
|
+
return self._account
|
|
212
|
+
|
|
213
|
+
def get_role_header(self, allow_password_login: bool = True) -> str | None:
|
|
214
|
+
"""'GUARDIAN:<id>' for parro-authorization-role: PARRO_ROLE, else derived and cached.
|
|
215
|
+
|
|
216
|
+
The id is that of the first guardian (or teacher, or child) identity of the account
|
|
217
|
+
(spec: Conventions → Client headers).
|
|
218
|
+
"""
|
|
219
|
+
if self.settings.role_override:
|
|
220
|
+
return self.settings.role_override
|
|
221
|
+
if self._role_header is None:
|
|
222
|
+
self._role_header = derive_role_header(self.get_account(allow_password_login))
|
|
223
|
+
return self._role_header
|
|
224
|
+
|
|
225
|
+
# -------------------------------------------------------------------- simple endpoints
|
|
226
|
+
|
|
227
|
+
def get_account_settings(self) -> ApiResponse:
|
|
228
|
+
return self.get("/accountsettings")
|
|
229
|
+
|
|
230
|
+
def get_organisation(self, organisation_id: int) -> ApiResponse:
|
|
231
|
+
return self.get(f"/organisation/{int(organisation_id)}")
|
|
232
|
+
|
|
233
|
+
def list_children(self, limit: int = 20) -> ApiResponse:
|
|
234
|
+
return self.get("/child", limit=limit, count_total=True)
|
|
235
|
+
|
|
236
|
+
def get_unread_counts(self) -> ApiResponse:
|
|
237
|
+
return self.get("/identity/unreadcounts")
|
|
238
|
+
|
|
239
|
+
def list_groups(
|
|
240
|
+
self, previous_year: bool = False, limit: int = GROUPS_PAGE_SIZE
|
|
241
|
+
) -> ApiResponse:
|
|
242
|
+
"""Current groups (dtype=identity.RHomeGroup) or last year's (scope=PREVIOUS_ACTIVE)."""
|
|
243
|
+
params = (
|
|
244
|
+
{"scope": PREVIOUS_GROUPS_SCOPE} if previous_year else {"dtype": CURRENT_GROUPS_DTYPE}
|
|
245
|
+
)
|
|
246
|
+
return self.get("/group", params, limit=limit, count_total=True)
|
|
247
|
+
|
|
248
|
+
def list_system_news(self, limit: int = 20) -> ApiResponse:
|
|
249
|
+
return self.get("/systemnews", limit=limit, count_total=True)
|
|
250
|
+
|
|
251
|
+
def get_calendar_link(self) -> str | None:
|
|
252
|
+
"""GET /calendar/sync: the personal iCalendar subscription link, or None if there is none.
|
|
253
|
+
|
|
254
|
+
The link is a secret (anyone holding it can read the calendar), so it is never logged.
|
|
255
|
+
"""
|
|
256
|
+
payload = self.get("/calendar/sync").payload
|
|
257
|
+
strings = payload.get("strings") if isinstance(payload, dict) else None
|
|
258
|
+
links = [
|
|
259
|
+
link for link in strings or [] if isinstance(link, str) and link.startswith("http")
|
|
260
|
+
]
|
|
261
|
+
return links[0] if links else None
|
|
262
|
+
|
|
263
|
+
def list_children_privacy(self) -> ApiResponse:
|
|
264
|
+
"""GET /child, privacy variant: per child, the state of the privacy choices (spec)."""
|
|
265
|
+
params = {"currentSchoolYear": "true", "dtype": PRIVACY_CHILD_DTYPE}
|
|
266
|
+
return self.get("/child", params, count_total=True)
|
|
267
|
+
|
|
268
|
+
def get_privacy_link(self, child_id: int) -> str | None:
|
|
269
|
+
"""GET /child/{id}/privacy: the signed link to the page where the choices are set.
|
|
270
|
+
|
|
271
|
+
Like the calendar link it is a secret (it opens the page without a login), so it is never
|
|
272
|
+
logged, cached or archived.
|
|
273
|
+
"""
|
|
274
|
+
payload = self.get(f"/child/{int(child_id)}/privacy").payload
|
|
275
|
+
link = payload.get("url") if isinstance(payload, dict) else None
|
|
276
|
+
return link if isinstance(link, str) and link.startswith("https://") else None
|
|
277
|
+
|
|
278
|
+
def get_absence_settings(self) -> ApiResponse:
|
|
279
|
+
return self.get("/absence/setting")
|
|
280
|
+
|
|
281
|
+
def list_chatrooms(self, limit: int = 25, offset: int = 0) -> ApiResponse:
|
|
282
|
+
return self.get("/chatroom", limit=limit, offset=offset, count_total=True)
|
|
283
|
+
|
|
284
|
+
def list_chat_messages(self, chatroom_id: int, limit: int = 30, offset: int = 0) -> ApiResponse:
|
|
285
|
+
"""Messages of one chat, newest first (spec)."""
|
|
286
|
+
return self.get(
|
|
287
|
+
f"/chatroom/{int(chatroom_id)}/chatmessage",
|
|
288
|
+
limit=limit,
|
|
289
|
+
offset=offset,
|
|
290
|
+
count_total=True,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
# ---------------------------------------------------------------------------- events
|
|
294
|
+
|
|
295
|
+
def list_events(
|
|
296
|
+
self,
|
|
297
|
+
group_id: int,
|
|
298
|
+
event_dtype: str,
|
|
299
|
+
limit: int = 20,
|
|
300
|
+
offset: int = 0,
|
|
301
|
+
count_total: bool = False,
|
|
302
|
+
) -> ApiResponse:
|
|
303
|
+
"""GET /event for one group. `event_dtype` picks announcements or calendar items.
|
|
304
|
+
|
|
305
|
+
Both come newest first. For calendar items that means the furthest future first: the
|
|
306
|
+
coming weeks can be several pages in (see operations.collect_calendar).
|
|
307
|
+
"""
|
|
308
|
+
return self.get(
|
|
309
|
+
"/event",
|
|
310
|
+
{"dtype": event_dtype, "group": int(group_id)},
|
|
311
|
+
limit=limit,
|
|
312
|
+
offset=offset,
|
|
313
|
+
count_total=count_total,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
def get_event(self, event_id: int, event_dtype: str) -> dict[str, Any]:
|
|
317
|
+
"""GET /event/{id}. The dtype is required: without it the API answers 406."""
|
|
318
|
+
payload = self.get(f"/event/{int(event_id)}", {"dtype": event_dtype}).payload
|
|
319
|
+
return payload if isinstance(payload, dict) else {}
|
|
320
|
+
|
|
321
|
+
def get_announcement(self, announcement_id: int) -> dict[str, Any]:
|
|
322
|
+
return self.get_event(announcement_id, ANNOUNCEMENT_DTYPE)
|
|
323
|
+
|
|
324
|
+
def get_calendar_item(self, event_id: int) -> dict[str, Any]:
|
|
325
|
+
return self.get_event(event_id, CALENDAR_ITEM_DTYPE)
|
|
326
|
+
|
|
327
|
+
def get_announcement_or_calendar_item(self, event_id: int) -> dict[str, Any]:
|
|
328
|
+
"""The event behind an id when the caller does not know its type (e.g. `attachment`).
|
|
329
|
+
|
|
330
|
+
Tries the announcement type first without the usual refresh-on-401, because the API may
|
|
331
|
+
answer a wrong dtype with 401 (spec) and that is not a reason to touch the tokens. Then
|
|
332
|
+
tries the calendar-item type the normal way.
|
|
333
|
+
"""
|
|
334
|
+
try:
|
|
335
|
+
payload = self.get(
|
|
336
|
+
f"/event/{int(event_id)}",
|
|
337
|
+
{"dtype": ANNOUNCEMENT_DTYPE},
|
|
338
|
+
refresh_on_unauthorized=False,
|
|
339
|
+
).payload
|
|
340
|
+
except ApiError as first_failure:
|
|
341
|
+
log.info(
|
|
342
|
+
"event is not an announcement (%s); trying calendar item", first_failure.status
|
|
343
|
+
)
|
|
344
|
+
return self.get_calendar_item(event_id)
|
|
345
|
+
return payload if isinstance(payload, dict) else {}
|
|
346
|
+
|
|
347
|
+
def list_current_group_names(self) -> dict[int, str | None]:
|
|
348
|
+
"""{group id: group name} for this school year's groups, in the API's order."""
|
|
349
|
+
names: dict[int, str | None] = {}
|
|
350
|
+
for group in self.get(
|
|
351
|
+
"/group", {"dtype": CURRENT_GROUPS_DTYPE}, limit=GROUPS_PAGE_SIZE
|
|
352
|
+
).items:
|
|
353
|
+
group_id = get_object_id(group)
|
|
354
|
+
if group_id is not None:
|
|
355
|
+
names[group_id] = group.get("name")
|
|
356
|
+
return names
|
|
357
|
+
|
|
358
|
+
def collect_feed(
|
|
359
|
+
self,
|
|
360
|
+
event_dtype: str,
|
|
361
|
+
group_id: int | None = None,
|
|
362
|
+
limit_per_group: int = 20,
|
|
363
|
+
groups: dict[int, str | None] | None = None,
|
|
364
|
+
) -> list[FeedEvent]:
|
|
365
|
+
"""Events of one group, or of every current group (the spec: query each group separately).
|
|
366
|
+
|
|
367
|
+
Events carry the group's name only when several groups were merged. Not sorted: the caller
|
|
368
|
+
decides the order. `groups` ({id: name}) saves the group request when the caller already
|
|
369
|
+
has them, e.g. the overview, which reads two feeds.
|
|
370
|
+
"""
|
|
371
|
+
if group_id is not None:
|
|
372
|
+
chosen: dict[int, str | None] = {int(group_id): None}
|
|
373
|
+
else:
|
|
374
|
+
chosen = groups if groups is not None else self.list_current_group_names()
|
|
375
|
+
collected: list[FeedEvent] = []
|
|
376
|
+
for one_group_id, group_name in chosen.items():
|
|
377
|
+
page = self.list_events(one_group_id, event_dtype, limit=limit_per_group)
|
|
378
|
+
collected.extend(FeedEvent(event, group_name) for event in page.items)
|
|
379
|
+
return collected
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def derive_role_header(account: dict[str, Any]) -> str | None:
|
|
383
|
+
"""'<ACCOUNTTYPE>:<id>' from GET /account/me, or None when the account has no identities."""
|
|
384
|
+
identity = account.get("identity") or {}
|
|
385
|
+
account_type = str(account.get("accountType") or identity.get("role") or "GUARDIAN").upper()
|
|
386
|
+
for identity_list in ("guardians", "teachers", "children"):
|
|
387
|
+
for linked_identity in identity.get(identity_list) or []:
|
|
388
|
+
identity_id = get_object_id(linked_identity)
|
|
389
|
+
if identity_id is not None:
|
|
390
|
+
return f"{account_type}:{identity_id}"
|
|
391
|
+
log.warning("could not derive the role header from /account/me")
|
|
392
|
+
return None
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _api_error(label: str, response: HttpResponse) -> ApiError:
|
|
396
|
+
"""An ApiError with the geon error details (applicationError, message) when there are any."""
|
|
397
|
+
try:
|
|
398
|
+
body = response.json()
|
|
399
|
+
except ValueError:
|
|
400
|
+
body = None
|
|
401
|
+
application_error = None
|
|
402
|
+
detail = ""
|
|
403
|
+
if isinstance(body, dict):
|
|
404
|
+
application_error = body.get("applicationError")
|
|
405
|
+
parts = [str(part) for part in (application_error, body.get("message")) if part]
|
|
406
|
+
detail = f": {' - '.join(parts)}" if parts else ""
|
|
407
|
+
elif response.body:
|
|
408
|
+
detail = f": {response.text()[:200]}"
|
|
409
|
+
return ApiError(
|
|
410
|
+
f"{label} answered HTTP {response.status}{detail}",
|
|
411
|
+
status=response.status,
|
|
412
|
+
application_error=application_error,
|
|
413
|
+
)
|
parro/archive.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""The local archive written by the `sync` command (PLAN-readable-output §3b, release 0.3.0).
|
|
2
|
+
|
|
3
|
+
<archive>/
|
|
4
|
+
announcements/2026-10-22-nieuwsbrief-nr-3-5000000101.md (+ .json)
|
|
5
|
+
announcements/attachments/5000000101-6000000101-Nieuwsbrief-3.pdf
|
|
6
|
+
calendar/2026-11-11-sint-maartenviering-7000000101.md (+ .json, + attachments/)
|
|
7
|
+
chats/rijden-naar-het-natuurmuseum-8000000001.md (+ .json)
|
|
8
|
+
index.md (everything, newest first)
|
|
9
|
+
.sync-state.json (what is archived, for the next run)
|
|
10
|
+
|
|
11
|
+
This module only writes files; operations.sync_archive() decides what to fetch.
|
|
12
|
+
|
|
13
|
+
Rules:
|
|
14
|
+
* Incremental: an item whose version (its `lastModifiedAt`) is unchanged is not rewritten, and
|
|
15
|
+
a saved attachment is never downloaded again.
|
|
16
|
+
* File names: date + slug of the title + id. When a title changes, the files are renamed, so
|
|
17
|
+
there is never a stale copy next to the new one.
|
|
18
|
+
* Nothing is ever deleted. An item that is no longer in Parro stays, and index.md marks it.
|
|
19
|
+
* Folders 0700, files 0600 (children's names, photos and school mail); no download links in
|
|
20
|
+
any file.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import re
|
|
25
|
+
import unicodedata
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from datetime import datetime
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from .cache import remove_urls, write_private_file
|
|
32
|
+
|
|
33
|
+
STATE_FILE = ".sync-state.json"
|
|
34
|
+
INDEX_FILE = "index.md"
|
|
35
|
+
SECTIONS = {"announcement": "announcements", "calendar": "calendar", "chat": "chats"}
|
|
36
|
+
SECTION_TITLES = {"announcement": "Announcements", "calendar": "Calendar", "chat": "Chats"}
|
|
37
|
+
MAX_SLUG_LENGTH = 50
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class SyncCounts:
|
|
42
|
+
"""What one run did, per kind of item."""
|
|
43
|
+
|
|
44
|
+
written: int = 0
|
|
45
|
+
unchanged: int = 0
|
|
46
|
+
renamed: int = 0
|
|
47
|
+
no_longer_in_parro: int = 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class ArchiveEntry:
|
|
52
|
+
"""One archived item, as remembered in the state file."""
|
|
53
|
+
|
|
54
|
+
kind: str
|
|
55
|
+
item_id: int
|
|
56
|
+
version: str
|
|
57
|
+
title: str
|
|
58
|
+
date: str
|
|
59
|
+
path: str # relative to the archive root, e.g. "announcements/2026-...-5000000101.md"
|
|
60
|
+
scope: str # "current", "previous" or "chats": which run is responsible for it
|
|
61
|
+
present: bool = True
|
|
62
|
+
groups: list[str] = field(default_factory=list)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Archive:
|
|
66
|
+
"""The archive folder: its files and its memory of what was written before."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, root: Path) -> None:
|
|
69
|
+
self.root = root
|
|
70
|
+
self.entries: dict[str, ArchiveEntry] = load_state(root / STATE_FILE)
|
|
71
|
+
self.counts: dict[str, SyncCounts] = {kind: SyncCounts() for kind in SECTIONS}
|
|
72
|
+
self._seen: set[str] = set()
|
|
73
|
+
|
|
74
|
+
# ------------------------------------------------------------------------ items
|
|
75
|
+
|
|
76
|
+
def is_current(self, kind: str, item_id: int, version: str) -> bool:
|
|
77
|
+
"""True when this version is already archived (and its file still exists)."""
|
|
78
|
+
entry = self.entries.get(entry_key(kind, item_id))
|
|
79
|
+
return entry is not None and entry.version == version and (self.root / entry.path).exists()
|
|
80
|
+
|
|
81
|
+
def keep(self, kind: str, item_id: int) -> None:
|
|
82
|
+
"""Note an unchanged item as still present in Parro."""
|
|
83
|
+
key = entry_key(kind, item_id)
|
|
84
|
+
self._seen.add(key)
|
|
85
|
+
self.entries[key].present = True
|
|
86
|
+
self.counts[kind].unchanged += 1
|
|
87
|
+
|
|
88
|
+
def save(self, entry: ArchiveEntry, markdown: str, summary: Any) -> None:
|
|
89
|
+
"""Write (or rewrite) one item: its .md with a header, and its .json without links."""
|
|
90
|
+
key = entry_key(entry.kind, entry.item_id)
|
|
91
|
+
self._seen.add(key)
|
|
92
|
+
previous = self.entries.get(key)
|
|
93
|
+
name_date = "" if entry.kind == "chat" else entry.date # chats: no date in the name
|
|
94
|
+
stem = file_stem(name_date, entry.title, entry.item_id)
|
|
95
|
+
entry.path = f"{SECTIONS[entry.kind]}/{stem}.md"
|
|
96
|
+
if previous is not None and previous.path != entry.path:
|
|
97
|
+
self._rename(previous.path, entry.path)
|
|
98
|
+
self.counts[entry.kind].renamed += 1
|
|
99
|
+
header = front_matter(entry)
|
|
100
|
+
self._write(entry.path, (header + markdown).encode("utf-8"))
|
|
101
|
+
self._write(json_path(entry.path), json_text(remove_urls(summary)).encode("utf-8"))
|
|
102
|
+
self.entries[key] = entry
|
|
103
|
+
self.counts[entry.kind].written += 1
|
|
104
|
+
|
|
105
|
+
# ------------------------------------------------------------------ attachments
|
|
106
|
+
|
|
107
|
+
def has_file(self, relative_path: str) -> bool:
|
|
108
|
+
"""True when the file is saved. Files are written in one step, so it is complete."""
|
|
109
|
+
return (self.root / relative_path).exists()
|
|
110
|
+
|
|
111
|
+
def save_file(self, relative_path: str, data: bytes) -> None:
|
|
112
|
+
self._write(relative_path, data)
|
|
113
|
+
|
|
114
|
+
# ----------------------------------------------------------------------- finish
|
|
115
|
+
|
|
116
|
+
def finish(self, scopes: set[str], synced_at: datetime) -> None:
|
|
117
|
+
"""Mark what the synced scopes no longer contain, then write index.md and the state."""
|
|
118
|
+
for key, entry in self.entries.items():
|
|
119
|
+
if entry.scope in scopes and key not in self._seen and entry.present:
|
|
120
|
+
entry.present = False
|
|
121
|
+
self.counts[entry.kind].no_longer_in_parro += 1
|
|
122
|
+
self._write(INDEX_FILE, build_index(list(self.entries.values()), synced_at).encode("utf-8"))
|
|
123
|
+
self._write(STATE_FILE, json_text(save_state(self.entries)).encode("utf-8"))
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------- helpers
|
|
126
|
+
|
|
127
|
+
def _write(self, relative_path: str, data: bytes) -> None:
|
|
128
|
+
write_private_file(self.root / relative_path, data)
|
|
129
|
+
|
|
130
|
+
def _rename(self, old: str, new: str) -> None:
|
|
131
|
+
for source, target in ((old, new), (json_path(old), json_path(new))):
|
|
132
|
+
old_file, new_file = self.root / source, self.root / target
|
|
133
|
+
if old_file.exists():
|
|
134
|
+
new_file.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
135
|
+
old_file.replace(new_file)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ----------------------------------------------------------------------- names and text
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def entry_key(kind: str, item_id: int) -> str:
|
|
142
|
+
return f"{kind}:{item_id}"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def slugify(title: str) -> str:
|
|
146
|
+
"""'Foto's herfstwandeling!' → 'fotos-herfstwandeling' (ASCII, lowercase, at most 50)."""
|
|
147
|
+
ascii_title = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode("ascii")
|
|
148
|
+
words = re.sub(r"[^a-z0-9]+", "-", ascii_title.lower().replace("'", "")).strip("-")
|
|
149
|
+
return words[:MAX_SLUG_LENGTH].rstrip("-") or "untitled"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def file_stem(date: str, title: str, item_id: int) -> str:
|
|
153
|
+
"""'2026-10-22-nieuwsbrief-nr-3-5000000101'; chats have no date prefix."""
|
|
154
|
+
prefix = f"{date[:10]}-" if re.match(r"^\d{4}-\d{2}-\d{2}", date or "") else ""
|
|
155
|
+
return f"{prefix}{slugify(title)}-{item_id}"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def attachment_path(kind: str, item_id: int, attachment_id: Any, filename: str | None) -> str:
|
|
159
|
+
"""'announcements/attachments/5000000101-6000000101-Nieuwsbrief-3.pdf' (a safe file name).
|
|
160
|
+
|
|
161
|
+
Attachments live next to their item's section, so the link from the item's .md file is
|
|
162
|
+
simply 'attachments/<name>'.
|
|
163
|
+
"""
|
|
164
|
+
name = Path(filename or "attachment").name
|
|
165
|
+
safe = re.sub(r"[^A-Za-z0-9._-]+", "-", name).strip("-.") or "attachment"
|
|
166
|
+
return f"{SECTIONS[kind]}/attachments/{item_id}-{attachment_id}-{safe}"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def json_path(markdown_path: str) -> str:
|
|
170
|
+
return markdown_path[: -len(".md")] + ".json"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def json_text(value: Any) -> str:
|
|
174
|
+
return json.dumps(value, ensure_ascii=False, indent=2, default=str) + "\n"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def front_matter(entry: ArchiveEntry) -> str:
|
|
178
|
+
"""A YAML header (strings written as JSON strings, which YAML reads as they are)."""
|
|
179
|
+
lines = [
|
|
180
|
+
"---",
|
|
181
|
+
f"id: {entry.item_id}",
|
|
182
|
+
f"type: {entry.kind}",
|
|
183
|
+
f"title: {json.dumps(entry.title, ensure_ascii=False)}",
|
|
184
|
+
f"date: {json.dumps(entry.date)}",
|
|
185
|
+
f"groups: {json.dumps(entry.groups, ensure_ascii=False)}",
|
|
186
|
+
f"version: {json.dumps(entry.version)}",
|
|
187
|
+
"source: Parro, archived with parro-api-client (sync)",
|
|
188
|
+
"---",
|
|
189
|
+
"",
|
|
190
|
+
]
|
|
191
|
+
return "\n".join(lines) + "\n"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def build_index(entries: list[ArchiveEntry], synced_at: datetime) -> str:
|
|
195
|
+
"""index.md: every archived item per kind, newest first, with a mark for removed ones."""
|
|
196
|
+
blocks = [
|
|
197
|
+
"# Parro archive",
|
|
198
|
+
f"Last sync: {synced_at.strftime('%Y-%m-%d %H:%M %Z').strip()}. Items no longer in Parro "
|
|
199
|
+
"stay here and are marked *(no longer in Parro)*.",
|
|
200
|
+
]
|
|
201
|
+
for kind, title in SECTION_TITLES.items():
|
|
202
|
+
items = sorted((e for e in entries if e.kind == kind), key=lambda e: e.date, reverse=True)
|
|
203
|
+
lines = [index_line(entry) for entry in items]
|
|
204
|
+
blocks.append(f"## {title} ({len(items)})\n" + ("\n".join(lines) if lines else "None yet."))
|
|
205
|
+
return "\n\n".join(blocks) + "\n"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def index_line(entry: ArchiveEntry) -> str:
|
|
209
|
+
when = f"{entry.date[:10]} " if entry.date[:4].isdigit() else ""
|
|
210
|
+
groups = f" · {', '.join(entry.groups)}" if entry.groups else ""
|
|
211
|
+
gone = " *(no longer in Parro)*" if not entry.present else ""
|
|
212
|
+
link_text = entry.title.replace("[", "(").replace("]", ")") or "(no title)"
|
|
213
|
+
return f"- {when}[{link_text}]({entry.path}){groups}{gone}"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ------------------------------------------------------------------------- state file
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def load_state(path: Path) -> dict[str, ArchiveEntry]:
|
|
220
|
+
"""The entries of the previous run; an unreadable state file starts a fresh archive."""
|
|
221
|
+
try:
|
|
222
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
223
|
+
except (OSError, ValueError):
|
|
224
|
+
return {}
|
|
225
|
+
entries = {}
|
|
226
|
+
for key, value in (raw.get("entries") or {}).items():
|
|
227
|
+
try:
|
|
228
|
+
entries[key] = ArchiveEntry(**value)
|
|
229
|
+
except TypeError:
|
|
230
|
+
continue
|
|
231
|
+
return entries
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def save_state(entries: dict[str, ArchiveEntry]) -> dict[str, Any]:
|
|
235
|
+
return {"format": 1, "entries": {key: vars(entry) for key, entry in sorted(entries.items())}}
|