spond.spond
Core Spond API client.
This module contains the Spond class, the main entrypoint to the
Spond consumer API: account profile, groups, members,
events, posts, and chats. For the separate Spond Club finance API, see
spond.club.
1#!/usr/bin/env python3 2"""Core Spond API client. 3 4This module contains the `Spond` class, the main entrypoint to the 5[Spond](https://spond.com/) consumer API: account profile, groups, members, 6events, posts, and chats. For the separate Spond Club finance API, see 7`spond.club`. 8""" 9 10from __future__ import annotations 11 12from typing import TYPE_CHECKING, ClassVar 13 14from . import JSONDict 15from ._event_template import _EVENT_TEMPLATE 16from .base import _SpondBase 17 18if TYPE_CHECKING: 19 from datetime import datetime 20 21 22class Spond(_SpondBase): 23 """Async client for the Spond consumer API. 24 25 Authentication happens lazily on the first API call (via the 26 `require_authentication` decorator inherited from `spond.base._SpondBase`); 27 you do not need to call `login()` explicitly. 28 29 Several `get_*` methods cache their last response on the instance 30 (`self.groups`, `self.events`, `self.posts`, `self.messages`, 31 `self.profile`). This lets lookup helpers like `get_group(uid)` and 32 `get_person(user)` avoid re-fetching when called repeatedly. To force a 33 refresh, set the relevant attribute to `None` and call the `get_*` method 34 again, or call the underlying `get_*s()` method directly. 35 36 Remember to close the underlying aiohttp session when finished: 37 38 ```python 39 s = Spond(username="...", password="...") 40 try: 41 groups = await s.get_groups() 42 ... 43 finally: 44 await s.clientsession.close() 45 ``` 46 47 Example 48 ------- 49 ```python 50 import asyncio 51 from spond import spond 52 53 async def main(): 54 s = spond.Spond(username="me@example.invalid", password="secret") 55 groups = await s.get_groups() or [] 56 for g in groups: 57 print(g["name"]) 58 await s.clientsession.close() 59 60 asyncio.run(main()) 61 ``` 62 """ 63 64 _API_BASE_URL: ClassVar = "https://api.spond.com/core/v1/" 65 _DT_FORMAT: ClassVar = "%Y-%m-%dT00:00:00.000Z" 66 _EVENT_TEMPLATE: ClassVar = _EVENT_TEMPLATE 67 _EVENT: ClassVar = "event" 68 _GROUP: ClassVar = "group" 69 70 def __init__(self, username: str, password: str) -> None: 71 """Construct a Spond client. 72 73 The credentials are stored on the instance and used to obtain an access 74 token on the first authenticated call. An aiohttp `ClientSession` is 75 opened immediately; close it via `await s.clientsession.close()` 76 (where `s` is the constructed instance) when finished, to avoid 77 `Unclosed client session` warnings. 78 79 Parameters 80 ---------- 81 username : str 82 Spond account email address. 83 password : str 84 Spond account password. For accounts with 2FA enabled, login will 85 currently fail — Spond's TOTP flow is not yet supported. 86 """ 87 super().__init__(username, password, self._API_BASE_URL) 88 self._chat_url = None 89 self._auth = None 90 self.groups: list[JSONDict] | None = None 91 self.events: list[JSONDict] | None = None 92 self.posts: list[JSONDict] | None = None 93 self.messages: list[JSONDict] | None = None 94 self.profile: JSONDict | None = None 95 96 async def _login_chat(self) -> None: 97 """Perform the secondary handshake with Spond's chat server. 98 99 The chat API lives on a separate host and uses its own short-lived 100 token (`self._auth`) rather than the regular Bearer token used by the 101 core API. This method is called lazily by `get_messages`, 102 `send_message`, and `_continue_chat` on their first use; the resulting 103 `self._chat_url` and `self._auth` are cached for the lifetime of the 104 client. 105 """ 106 api_chat_url = f"{self.api_url}chat" 107 r = await self.clientsession.post(api_chat_url, headers=self.auth_headers) 108 result = await r.json() 109 self._chat_url = result["url"] 110 self._auth = result["auth"] 111 112 @_SpondBase.require_authentication 113 async def get_profile(self) -> JSONDict: 114 """Retrieve the authenticated user's profile. 115 116 The profile dict includes at least the user's `id`, `firstName`, and 117 `lastName`, plus contact details and account preferences. The full 118 response is cached on `self.profile`. 119 120 Returns 121 ------- 122 JSONDict 123 The profile object as returned by the Spond API. 124 """ 125 url = f"{self._API_BASE_URL}profile" 126 async with self.clientsession.get(url, headers=self.auth_headers) as r: 127 self.profile = await r.json() 128 return self.profile 129 130 @_SpondBase.require_authentication 131 async def get_groups(self) -> list[JSONDict] | None: 132 """Retrieve every group the authenticated user is a member of. 133 134 Each group dict includes a `members` list, with each member dict 135 containing `id`, `firstName`, `lastName`, and (for child profiles 136 managed by another account) a nested `guardians` list of the same 137 shape. The full response is cached on `self.groups` and reused by 138 `get_group(uid)` and `get_person(user)`. 139 140 Returns 141 ------- 142 list[JSONDict] or None 143 A list of groups, each represented as a dictionary. `None` if the 144 account has no groups at all. 145 """ 146 url = f"{self.api_url}groups/" 147 async with self.clientsession.get(url, headers=self.auth_headers) as r: 148 self.groups = await r.json() 149 return self.groups 150 151 async def get_group(self, uid: str) -> JSONDict: 152 """Look up a single group by its unique id. 153 154 Searches the cached `self.groups` (populated by `get_groups()` on 155 first call). To force a refresh, set `self.groups = None` first. 156 157 Parameters 158 ---------- 159 uid : str 160 UID of the group. 161 162 Returns 163 ------- 164 JSONDict 165 The group's details, with the same shape as elements returned by 166 `get_groups()`. 167 168 Raises 169 ------ 170 KeyError 171 If no group with the given id is found (or the user has no groups). 172 """ 173 return await self._get_entity(self._GROUP, uid) 174 175 @_SpondBase.require_authentication 176 async def get_person(self, user: str) -> JSONDict: 177 """Look up a member or guardian by any of several identifiers. 178 179 Searches every member of every cached group (and each member's 180 `guardians` list). The first match wins. The cache `self.groups` is 181 populated by `get_groups()` if empty. 182 183 Parameters 184 ---------- 185 user : str 186 Identifier to match against. Accepted forms: 187 188 - the member's `id` 189 - the member's email (exact match) 190 - first and last name joined by a single space 191 (e.g. `"Ola Thoresen"`) 192 - the member's `profile.id` (different from `id` for child profiles) 193 194 Returns 195 ------- 196 JSONDict 197 The first matching member or guardian dict. Shape matches the 198 entries in a group's `members` list from `get_groups()`. 199 200 Raises 201 ------ 202 KeyError 203 If no match is found across any group or guardian. 204 """ 205 if not self.groups: 206 await self.get_groups() 207 for group in self.groups: 208 for member in group["members"]: 209 if self._match_person(member, user): 210 return member 211 if "guardians" in member: 212 for guardian in member["guardians"]: 213 if self._match_person(guardian, user): 214 return guardian 215 errmsg = f"No person matched with identifier '{user}'." 216 raise KeyError(errmsg) 217 218 @staticmethod 219 def _match_person(person: JSONDict, match_str: str) -> bool: 220 """Return True if `match_str` matches any of the person's identifiers. 221 222 Used internally by `get_person` to scan group members and guardians. 223 See `get_person` for the list of accepted identifier forms. 224 225 Parameters 226 ---------- 227 person : JSONDict 228 A member or guardian dict from a group's `members` list. 229 match_str : str 230 The identifier to test against. 231 232 Returns 233 ------- 234 bool 235 True on first matching identifier; False otherwise. 236 """ 237 return ( 238 person["id"] == match_str 239 or ("email" in person and person["email"]) == match_str 240 or person["firstName"] + " " + person["lastName"] == match_str 241 or ("profile" in person and person["profile"]["id"] == match_str) 242 ) 243 244 @_SpondBase.require_authentication 245 async def get_posts( 246 self, 247 group_id: str | None = None, 248 max_posts: int = 20, 249 include_comments: bool = True, 250 ) -> list[JSONDict] | None: 251 """ 252 Retrieve posts from group walls. 253 254 Posts are announcements/messages posted to group walls, as opposed to 255 chat messages or events. 256 257 Parameters 258 ---------- 259 group_id : str, optional 260 Filter by group. Uses `groupId` API parameter. 261 max_posts : int, optional 262 Set a limit on the number of posts returned. 263 For performance reasons, defaults to 20. 264 Uses `max` API parameter. 265 include_comments : bool, optional 266 Include comments on posts. 267 Defaults to True. 268 Uses `includeComments` API parameter. 269 270 Returns 271 ------- 272 list[JSONDict] or None 273 A list of posts, each represented as a dictionary, or None if no 274 posts are available. 275 276 Raises 277 ------ 278 ValueError 279 Raised when the request to the API fails. 280 """ 281 url = f"{self.api_url}posts/" 282 params: dict[str, str] = { 283 "type": "PLAIN", 284 "max": str(max_posts), 285 "includeComments": str(include_comments).lower(), 286 } 287 if group_id: 288 params["groupId"] = group_id 289 290 async with self.clientsession.get( 291 url, headers=self.auth_headers, params=params 292 ) as r: 293 if not r.ok: 294 error_details = await r.text() 295 raise ValueError( 296 f"Request failed with status {r.status}: {error_details}" 297 ) 298 self.posts = await r.json() 299 return self.posts 300 301 @_SpondBase.require_authentication 302 async def get_messages(self, max_chats: int = 100) -> list[JSONDict] | None: 303 """Retrieve recent chats (one-to-one and group conversations). 304 305 "Chats" here refers to the in-app direct/group messaging feature, not 306 comments on events or posts. Uses Spond's separate chat-server host 307 and chat token (handled internally by `_login_chat`). 308 309 The full response is cached on `self.messages`. 310 311 Parameters 312 ---------- 313 max_chats : int, optional 314 Maximum number of chats to return. Defaults to 100 for performance. 315 Uses the `max` API parameter. 316 317 Returns 318 ------- 319 list[JSONDict] or None 320 A list of chat objects ordered by most recent activity. `None` if 321 the account has no chats. 322 """ 323 if not self._auth: 324 await self._login_chat() 325 url = f"{self._chat_url}/chats/" 326 async with self.clientsession.get( 327 url, 328 headers={"auth": self._auth}, 329 params={"max": str(max_chats)}, 330 ) as r: 331 self.messages = await r.json() 332 return self.messages 333 334 @_SpondBase.require_authentication 335 async def _continue_chat(self, chat_id: str, text: str) -> JSONDict: 336 """Append a text message to an existing chat thread. 337 338 Internal helper used by `send_message` when called with `chat_id`. 339 Performs the lazy chat-server login (`_login_chat`) on first use. 340 341 Parameters 342 ---------- 343 chat_id : str 344 Identifier of the existing chat to continue. 345 text : str 346 Message body to send. 347 348 Returns 349 ------- 350 JSONDict 351 The Spond API response for the send operation. 352 """ 353 if not self._auth: 354 await self._login_chat() 355 url = f"{self._chat_url}/messages" 356 data = {"chatId": chat_id, "text": text, "type": "TEXT"} 357 r = await self.clientsession.post(url, json=data, headers={"auth": self._auth}) 358 return await r.json() 359 360 @_SpondBase.require_authentication 361 async def send_message( 362 self, 363 text: str, 364 user: str | None = None, 365 group_uid: str | None = None, 366 chat_id: str | None = None, 367 ) -> JSONDict: 368 """Send a chat message, either continuing an existing thread or 369 starting a new one. 370 371 Two calling patterns: 372 373 - **Continue an existing chat**: pass `chat_id` (the recipient and 374 group context are inferred from the existing thread). `user` and 375 `group_uid` are ignored. 376 - **Start a new chat**: pass both `user` (the recipient) and 377 `group_uid` (the group context the chat belongs to). The user is 378 resolved via `get_person()` to find the underlying profile id. 379 380 Parameters 381 ---------- 382 text : str 383 Message body to send. 384 user : str, optional 385 Recipient identifier when starting a new chat. Accepts the same 386 forms as `get_person()`: member id, email, full name, or 387 profile id. Required when `chat_id` is not given. 388 group_uid : str, optional 389 UID of the group that scopes the new chat. Required when `chat_id` 390 is not given. 391 chat_id : str, optional 392 Identifier of an existing chat to continue. When provided, 393 `user` and `group_uid` are not consulted. 394 395 Returns 396 ------- 397 JSONDict 398 The Spond API response for the send operation. 399 400 Raises 401 ------ 402 ValueError 403 Neither `chat_id` nor both of `user`/`group_uid` were supplied — 404 the call has no way to identify the target chat. 405 KeyError 406 `user` was given but doesn't match any member or guardian in any 407 of the authenticated user's groups (propagated from 408 `get_person`). 409 """ 410 if self._auth is None: 411 await self._login_chat() 412 413 if chat_id is not None: 414 return await self._continue_chat(chat_id, text) 415 if group_uid is None or user is None: 416 raise ValueError( 417 "send_message requires either chat_id (to continue an existing " 418 "chat) or both user and group_uid (to start a new one)." 419 ) 420 421 user_obj = await self.get_person(user) 422 user_uid = user_obj["profile"]["id"] 423 url = f"{self._chat_url}/messages" 424 data = { 425 "text": text, 426 "type": "TEXT", 427 "recipient": user_uid, 428 "groupId": group_uid, 429 } 430 r = await self.clientsession.post(url, json=data, headers={"auth": self._auth}) 431 return await r.json() 432 433 @_SpondBase.require_authentication 434 async def get_events( 435 self, 436 group_id: str | None = None, 437 subgroup_id: str | None = None, 438 include_scheduled: bool = False, 439 include_hidden: bool = False, 440 max_end: datetime | None = None, 441 min_end: datetime | None = None, 442 max_start: datetime | None = None, 443 min_start: datetime | None = None, 444 max_events: int = 100, 445 ) -> list[JSONDict] | None: 446 """Retrieve events visible to the authenticated user. 447 448 Filters can narrow by group/subgroup, by start/end timestamp window, 449 and by visibility (scheduled, hidden). The full response is cached on 450 `self.events`. 451 452 Note: `get_event(uid)` looks up events via this method's cache, so 453 it inherits these defaults — an event that doesn't appear in the 454 first `max_events` results or is excluded by `include_scheduled=False` 455 is unreachable through `get_event()`. If you need broader visibility, 456 call this method directly with appropriate filters. 457 458 Parameters 459 ---------- 460 group_id : str, optional 461 Restrict to events belonging to this group. Uses `groupId` API 462 parameter. 463 subgroup_id : str, optional 464 Restrict to events within this subgroup. Uses `subGroupId` API 465 parameter. 466 include_scheduled : bool, optional 467 Include scheduled events (events whose invitations are queued to be 468 sent in the future). 469 Defaults to False for performance reasons. 470 Uses `scheduled` API parameter. 471 include_hidden : bool, optional 472 Include hidden events. 473 Uses `includeHidden` API parameter. 474 'includeHidden' filter is only available inside a group. 475 max_end : datetime, optional 476 Only include events which end before or at this datetime. 477 Uses `maxEndTimestamp` API parameter; relates to `endTimestamp` event 478 attribute. 479 min_end : datetime, optional 480 Only include events which end after or at this datetime. 481 Uses `minEndTimestamp` API parameter; relates to `endTimestamp` event 482 attribute. 483 max_start : datetime, optional 484 Only include events which start before or at this datetime. 485 Uses `maxStartTimestamp` API parameter; relates to `startTimestamp` event 486 attribute. 487 min_start : datetime, optional 488 Only include events which start after or at this datetime. 489 Uses `minStartTimestamp` API parameter; relates to `startTimestamp` event 490 attribute. 491 max_events : int, optional 492 Set a limit on the number of events returned. 493 For performance reasons, defaults to 100. 494 Uses `max` API parameter. 495 496 Returns 497 ------- 498 list[JSONDict] or None 499 A list of events, each represented as a dictionary, or None if no events 500 are available. 501 502 Raises 503 ------ 504 ValueError 505 Raised when the request to the API fails. This occurs if the response 506 status code indicates an error (e.g., 4xx or 5xx). The error message 507 includes the HTTP status code and the response body for debugging purposes. 508 """ 509 url = f"{self.api_url}sponds/" 510 params = { 511 "max": str(max_events), 512 "scheduled": str(include_scheduled), 513 } 514 if max_end: 515 params["maxEndTimestamp"] = max_end.strftime(self._DT_FORMAT) 516 if max_start: 517 params["maxStartTimestamp"] = max_start.strftime(self._DT_FORMAT) 518 if min_end: 519 params["minEndTimestamp"] = min_end.strftime(self._DT_FORMAT) 520 if min_start: 521 params["minStartTimestamp"] = min_start.strftime(self._DT_FORMAT) 522 if group_id: 523 params["groupId"] = group_id 524 if subgroup_id: 525 params["subGroupId"] = subgroup_id 526 if include_hidden: 527 params["includeHidden"] = "true" 528 529 async with self.clientsession.get( 530 url, headers=self.auth_headers, params=params 531 ) as r: 532 if not r.ok: 533 error_details = await r.text() 534 raise ValueError( 535 f"Request failed with status {r.status}: {error_details}" 536 ) 537 self.events = await r.json() 538 return self.events 539 540 async def get_event(self, uid: str) -> JSONDict: 541 """Look up a single event by its unique id. 542 543 Routes through the cached events list (populated by `get_events()`), 544 which means events outside the `max_events=100` default or those 545 excluded by `include_scheduled=False` may not be findable. To reach 546 those events, call `get_events()` directly with appropriate filters 547 first to populate the cache, then call this method. 548 549 Parameters 550 ---------- 551 uid : str 552 UID of the event. 553 554 Returns 555 ------- 556 JSONDict 557 The event's details, with the same shape as elements returned by 558 `get_events()`. 559 560 Raises 561 ------ 562 KeyError 563 If no event with the given id is found in the cache. 564 """ 565 return await self._get_entity(self._EVENT, uid) 566 567 @_SpondBase.require_authentication 568 async def update_event(self, uid: str, updates: JSONDict) -> JSONDict: 569 """Update an existing event by merging changes into the current state. 570 571 The implementation fetches the event via `_get_entity()`, copies the 572 fields present in `_EVENT_TEMPLATE` from the existing event as the 573 base, then overlays any keys provided in `updates`. The merged event 574 is POSTed back to `sponds/{uid}`. 575 576 Parameters 577 ---------- 578 uid : str 579 UID of the event to update. 580 updates : JSONDict 581 Mapping of keys to new values. Only keys present in 582 `_EVENT_TEMPLATE` are honoured. Example: 583 584 ```python 585 await s.update_event(uid, {"description": "New description"}) 586 ``` 587 588 Returns 589 ------- 590 JSONDict 591 The Spond API response from the POST — the updated event as 592 persisted server-side. 593 """ 594 event = await self._get_entity(self._EVENT, uid) 595 url = f"{self.api_url}sponds/{uid}" 596 597 base_event = self._EVENT_TEMPLATE.copy() 598 for key in base_event: 599 if event.get(key) is not None and not updates.get(key): 600 base_event[key] = event[key] 601 elif updates.get(key) is not None: 602 base_event[key] = updates[key] 603 604 async with self.clientsession.post( 605 url, json=base_event, headers=self.auth_headers 606 ) as r: 607 return await r.json() 608 609 @_SpondBase.require_authentication 610 async def get_event_attendance_xlsx(self, uid: str) -> bytes: 611 """Download the attendance report for an event as XLSX bytes. 612 613 Thin wrapper around Spond's own "Export attendance history" feature 614 in the web UI. The columns and format are determined by Spond, not by 615 this library — for example, the export does not include member ids. 616 For a customisable CSV alternative built from `get_event()` data, 617 see `examples/attendance.py`. 618 619 Parameters 620 ---------- 621 uid : str 622 UID of the event whose attendance report to fetch. 623 624 Returns 625 ------- 626 bytes 627 Raw XLSX file contents. Typically written directly to disk: 628 629 ```python 630 import pathlib 631 632 data = await s.get_event_attendance_xlsx(uid) 633 pathlib.Path(f"{uid}.xlsx").write_bytes(data) 634 ``` 635 """ 636 url = f"{self.api_url}sponds/{uid}/export" 637 async with self.clientsession.get(url, headers=self.auth_headers) as r: 638 return await r.read() 639 640 @_SpondBase.require_authentication 641 async def change_response(self, uid: str, user: str, payload: JSONDict) -> JSONDict: 642 """Update a single member's response (accept/decline) for an event. 643 644 Useful for managing attendance on someone else's behalf (e.g. a coach 645 accepting on behalf of a player who can't reach the app). The caller 646 must have permission on the event. 647 648 Parameters 649 ---------- 650 uid : str 651 UID of the event. 652 user : str 653 UID of the member whose response to change. Note: this is the 654 *member's* id (as seen in `group["members"][i]["id"]`), not the 655 authenticated user's id. 656 payload : JSONDict 657 The response body. Common shapes: 658 659 - `{"accepted": "true"}` — accept the invitation 660 - `{"accepted": "false"}` — decline (Spond may also accept a 661 `"declineMessage"` field with a reason) 662 663 Returns 664 ------- 665 JSONDict 666 The event's `responses` object with the updated id lists 667 (`acceptedIds`, `declinedIds`, `unansweredIds`, etc.). 668 """ 669 url = f"{self.api_url}sponds/{uid}/responses/{user}" 670 async with self.clientsession.put( 671 url, headers=self.auth_headers, json=payload 672 ) as r: 673 return await r.json() 674 675 @_SpondBase.require_authentication 676 async def _get_entity(self, entity_type: str, uid: str) -> JSONDict: 677 """Internal lookup helper shared by `get_event` and `get_group`. 678 679 Routes to the relevant cache (`self.events` or `self.groups`), 680 triggers a fetch via `get_events()` / `get_groups()` if the cache is 681 empty, then linearly scans for a matching `id`. Raises `KeyError` 682 cleanly (rather than `TypeError`) when the cache remains empty after 683 the fetch attempt — the underlying `get_*s()` method may legitimately 684 return `None` if the account has no events/groups available. 685 686 Parameters 687 ---------- 688 entity_type : str 689 One of `self._EVENT` (`"event"`) or `self._GROUP` (`"group"`). 690 uid : str 691 UID of the entity to find. 692 693 Returns 694 ------- 695 JSONDict 696 The matching entity dict. 697 698 Raises 699 ------ 700 KeyError 701 No entity with that id was found (either because the relevant 702 cache is empty or because the id doesn't appear in it). 703 NotImplementedError 704 `entity_type` is something other than `"event"` or `"group"`. 705 """ 706 if entity_type == self._EVENT: 707 if not self.events: 708 await self.get_events() 709 entities = self.events 710 elif entity_type == self._GROUP: 711 if not self.groups: 712 await self.get_groups() 713 entities = self.groups 714 else: 715 errmsg = f"Entity type '{entity_type}' is not supported." 716 raise NotImplementedError(errmsg) 717 718 errmsg = f"No {entity_type} with id='{uid}'." 719 if not entities: 720 raise KeyError(errmsg) 721 722 for entity in entities: 723 if entity["id"] == uid: 724 return entity 725 raise KeyError(errmsg)
23class Spond(_SpondBase): 24 """Async client for the Spond consumer API. 25 26 Authentication happens lazily on the first API call (via the 27 `require_authentication` decorator inherited from `spond.base._SpondBase`); 28 you do not need to call `login()` explicitly. 29 30 Several `get_*` methods cache their last response on the instance 31 (`self.groups`, `self.events`, `self.posts`, `self.messages`, 32 `self.profile`). This lets lookup helpers like `get_group(uid)` and 33 `get_person(user)` avoid re-fetching when called repeatedly. To force a 34 refresh, set the relevant attribute to `None` and call the `get_*` method 35 again, or call the underlying `get_*s()` method directly. 36 37 Remember to close the underlying aiohttp session when finished: 38 39 ```python 40 s = Spond(username="...", password="...") 41 try: 42 groups = await s.get_groups() 43 ... 44 finally: 45 await s.clientsession.close() 46 ``` 47 48 Example 49 ------- 50 ```python 51 import asyncio 52 from spond import spond 53 54 async def main(): 55 s = spond.Spond(username="me@example.invalid", password="secret") 56 groups = await s.get_groups() or [] 57 for g in groups: 58 print(g["name"]) 59 await s.clientsession.close() 60 61 asyncio.run(main()) 62 ``` 63 """ 64 65 _API_BASE_URL: ClassVar = "https://api.spond.com/core/v1/" 66 _DT_FORMAT: ClassVar = "%Y-%m-%dT00:00:00.000Z" 67 _EVENT_TEMPLATE: ClassVar = _EVENT_TEMPLATE 68 _EVENT: ClassVar = "event" 69 _GROUP: ClassVar = "group" 70 71 def __init__(self, username: str, password: str) -> None: 72 """Construct a Spond client. 73 74 The credentials are stored on the instance and used to obtain an access 75 token on the first authenticated call. An aiohttp `ClientSession` is 76 opened immediately; close it via `await s.clientsession.close()` 77 (where `s` is the constructed instance) when finished, to avoid 78 `Unclosed client session` warnings. 79 80 Parameters 81 ---------- 82 username : str 83 Spond account email address. 84 password : str 85 Spond account password. For accounts with 2FA enabled, login will 86 currently fail — Spond's TOTP flow is not yet supported. 87 """ 88 super().__init__(username, password, self._API_BASE_URL) 89 self._chat_url = None 90 self._auth = None 91 self.groups: list[JSONDict] | None = None 92 self.events: list[JSONDict] | None = None 93 self.posts: list[JSONDict] | None = None 94 self.messages: list[JSONDict] | None = None 95 self.profile: JSONDict | None = None 96 97 async def _login_chat(self) -> None: 98 """Perform the secondary handshake with Spond's chat server. 99 100 The chat API lives on a separate host and uses its own short-lived 101 token (`self._auth`) rather than the regular Bearer token used by the 102 core API. This method is called lazily by `get_messages`, 103 `send_message`, and `_continue_chat` on their first use; the resulting 104 `self._chat_url` and `self._auth` are cached for the lifetime of the 105 client. 106 """ 107 api_chat_url = f"{self.api_url}chat" 108 r = await self.clientsession.post(api_chat_url, headers=self.auth_headers) 109 result = await r.json() 110 self._chat_url = result["url"] 111 self._auth = result["auth"] 112 113 @_SpondBase.require_authentication 114 async def get_profile(self) -> JSONDict: 115 """Retrieve the authenticated user's profile. 116 117 The profile dict includes at least the user's `id`, `firstName`, and 118 `lastName`, plus contact details and account preferences. The full 119 response is cached on `self.profile`. 120 121 Returns 122 ------- 123 JSONDict 124 The profile object as returned by the Spond API. 125 """ 126 url = f"{self._API_BASE_URL}profile" 127 async with self.clientsession.get(url, headers=self.auth_headers) as r: 128 self.profile = await r.json() 129 return self.profile 130 131 @_SpondBase.require_authentication 132 async def get_groups(self) -> list[JSONDict] | None: 133 """Retrieve every group the authenticated user is a member of. 134 135 Each group dict includes a `members` list, with each member dict 136 containing `id`, `firstName`, `lastName`, and (for child profiles 137 managed by another account) a nested `guardians` list of the same 138 shape. The full response is cached on `self.groups` and reused by 139 `get_group(uid)` and `get_person(user)`. 140 141 Returns 142 ------- 143 list[JSONDict] or None 144 A list of groups, each represented as a dictionary. `None` if the 145 account has no groups at all. 146 """ 147 url = f"{self.api_url}groups/" 148 async with self.clientsession.get(url, headers=self.auth_headers) as r: 149 self.groups = await r.json() 150 return self.groups 151 152 async def get_group(self, uid: str) -> JSONDict: 153 """Look up a single group by its unique id. 154 155 Searches the cached `self.groups` (populated by `get_groups()` on 156 first call). To force a refresh, set `self.groups = None` first. 157 158 Parameters 159 ---------- 160 uid : str 161 UID of the group. 162 163 Returns 164 ------- 165 JSONDict 166 The group's details, with the same shape as elements returned by 167 `get_groups()`. 168 169 Raises 170 ------ 171 KeyError 172 If no group with the given id is found (or the user has no groups). 173 """ 174 return await self._get_entity(self._GROUP, uid) 175 176 @_SpondBase.require_authentication 177 async def get_person(self, user: str) -> JSONDict: 178 """Look up a member or guardian by any of several identifiers. 179 180 Searches every member of every cached group (and each member's 181 `guardians` list). The first match wins. The cache `self.groups` is 182 populated by `get_groups()` if empty. 183 184 Parameters 185 ---------- 186 user : str 187 Identifier to match against. Accepted forms: 188 189 - the member's `id` 190 - the member's email (exact match) 191 - first and last name joined by a single space 192 (e.g. `"Ola Thoresen"`) 193 - the member's `profile.id` (different from `id` for child profiles) 194 195 Returns 196 ------- 197 JSONDict 198 The first matching member or guardian dict. Shape matches the 199 entries in a group's `members` list from `get_groups()`. 200 201 Raises 202 ------ 203 KeyError 204 If no match is found across any group or guardian. 205 """ 206 if not self.groups: 207 await self.get_groups() 208 for group in self.groups: 209 for member in group["members"]: 210 if self._match_person(member, user): 211 return member 212 if "guardians" in member: 213 for guardian in member["guardians"]: 214 if self._match_person(guardian, user): 215 return guardian 216 errmsg = f"No person matched with identifier '{user}'." 217 raise KeyError(errmsg) 218 219 @staticmethod 220 def _match_person(person: JSONDict, match_str: str) -> bool: 221 """Return True if `match_str` matches any of the person's identifiers. 222 223 Used internally by `get_person` to scan group members and guardians. 224 See `get_person` for the list of accepted identifier forms. 225 226 Parameters 227 ---------- 228 person : JSONDict 229 A member or guardian dict from a group's `members` list. 230 match_str : str 231 The identifier to test against. 232 233 Returns 234 ------- 235 bool 236 True on first matching identifier; False otherwise. 237 """ 238 return ( 239 person["id"] == match_str 240 or ("email" in person and person["email"]) == match_str 241 or person["firstName"] + " " + person["lastName"] == match_str 242 or ("profile" in person and person["profile"]["id"] == match_str) 243 ) 244 245 @_SpondBase.require_authentication 246 async def get_posts( 247 self, 248 group_id: str | None = None, 249 max_posts: int = 20, 250 include_comments: bool = True, 251 ) -> list[JSONDict] | None: 252 """ 253 Retrieve posts from group walls. 254 255 Posts are announcements/messages posted to group walls, as opposed to 256 chat messages or events. 257 258 Parameters 259 ---------- 260 group_id : str, optional 261 Filter by group. Uses `groupId` API parameter. 262 max_posts : int, optional 263 Set a limit on the number of posts returned. 264 For performance reasons, defaults to 20. 265 Uses `max` API parameter. 266 include_comments : bool, optional 267 Include comments on posts. 268 Defaults to True. 269 Uses `includeComments` API parameter. 270 271 Returns 272 ------- 273 list[JSONDict] or None 274 A list of posts, each represented as a dictionary, or None if no 275 posts are available. 276 277 Raises 278 ------ 279 ValueError 280 Raised when the request to the API fails. 281 """ 282 url = f"{self.api_url}posts/" 283 params: dict[str, str] = { 284 "type": "PLAIN", 285 "max": str(max_posts), 286 "includeComments": str(include_comments).lower(), 287 } 288 if group_id: 289 params["groupId"] = group_id 290 291 async with self.clientsession.get( 292 url, headers=self.auth_headers, params=params 293 ) as r: 294 if not r.ok: 295 error_details = await r.text() 296 raise ValueError( 297 f"Request failed with status {r.status}: {error_details}" 298 ) 299 self.posts = await r.json() 300 return self.posts 301 302 @_SpondBase.require_authentication 303 async def get_messages(self, max_chats: int = 100) -> list[JSONDict] | None: 304 """Retrieve recent chats (one-to-one and group conversations). 305 306 "Chats" here refers to the in-app direct/group messaging feature, not 307 comments on events or posts. Uses Spond's separate chat-server host 308 and chat token (handled internally by `_login_chat`). 309 310 The full response is cached on `self.messages`. 311 312 Parameters 313 ---------- 314 max_chats : int, optional 315 Maximum number of chats to return. Defaults to 100 for performance. 316 Uses the `max` API parameter. 317 318 Returns 319 ------- 320 list[JSONDict] or None 321 A list of chat objects ordered by most recent activity. `None` if 322 the account has no chats. 323 """ 324 if not self._auth: 325 await self._login_chat() 326 url = f"{self._chat_url}/chats/" 327 async with self.clientsession.get( 328 url, 329 headers={"auth": self._auth}, 330 params={"max": str(max_chats)}, 331 ) as r: 332 self.messages = await r.json() 333 return self.messages 334 335 @_SpondBase.require_authentication 336 async def _continue_chat(self, chat_id: str, text: str) -> JSONDict: 337 """Append a text message to an existing chat thread. 338 339 Internal helper used by `send_message` when called with `chat_id`. 340 Performs the lazy chat-server login (`_login_chat`) on first use. 341 342 Parameters 343 ---------- 344 chat_id : str 345 Identifier of the existing chat to continue. 346 text : str 347 Message body to send. 348 349 Returns 350 ------- 351 JSONDict 352 The Spond API response for the send operation. 353 """ 354 if not self._auth: 355 await self._login_chat() 356 url = f"{self._chat_url}/messages" 357 data = {"chatId": chat_id, "text": text, "type": "TEXT"} 358 r = await self.clientsession.post(url, json=data, headers={"auth": self._auth}) 359 return await r.json() 360 361 @_SpondBase.require_authentication 362 async def send_message( 363 self, 364 text: str, 365 user: str | None = None, 366 group_uid: str | None = None, 367 chat_id: str | None = None, 368 ) -> JSONDict: 369 """Send a chat message, either continuing an existing thread or 370 starting a new one. 371 372 Two calling patterns: 373 374 - **Continue an existing chat**: pass `chat_id` (the recipient and 375 group context are inferred from the existing thread). `user` and 376 `group_uid` are ignored. 377 - **Start a new chat**: pass both `user` (the recipient) and 378 `group_uid` (the group context the chat belongs to). The user is 379 resolved via `get_person()` to find the underlying profile id. 380 381 Parameters 382 ---------- 383 text : str 384 Message body to send. 385 user : str, optional 386 Recipient identifier when starting a new chat. Accepts the same 387 forms as `get_person()`: member id, email, full name, or 388 profile id. Required when `chat_id` is not given. 389 group_uid : str, optional 390 UID of the group that scopes the new chat. Required when `chat_id` 391 is not given. 392 chat_id : str, optional 393 Identifier of an existing chat to continue. When provided, 394 `user` and `group_uid` are not consulted. 395 396 Returns 397 ------- 398 JSONDict 399 The Spond API response for the send operation. 400 401 Raises 402 ------ 403 ValueError 404 Neither `chat_id` nor both of `user`/`group_uid` were supplied — 405 the call has no way to identify the target chat. 406 KeyError 407 `user` was given but doesn't match any member or guardian in any 408 of the authenticated user's groups (propagated from 409 `get_person`). 410 """ 411 if self._auth is None: 412 await self._login_chat() 413 414 if chat_id is not None: 415 return await self._continue_chat(chat_id, text) 416 if group_uid is None or user is None: 417 raise ValueError( 418 "send_message requires either chat_id (to continue an existing " 419 "chat) or both user and group_uid (to start a new one)." 420 ) 421 422 user_obj = await self.get_person(user) 423 user_uid = user_obj["profile"]["id"] 424 url = f"{self._chat_url}/messages" 425 data = { 426 "text": text, 427 "type": "TEXT", 428 "recipient": user_uid, 429 "groupId": group_uid, 430 } 431 r = await self.clientsession.post(url, json=data, headers={"auth": self._auth}) 432 return await r.json() 433 434 @_SpondBase.require_authentication 435 async def get_events( 436 self, 437 group_id: str | None = None, 438 subgroup_id: str | None = None, 439 include_scheduled: bool = False, 440 include_hidden: bool = False, 441 max_end: datetime | None = None, 442 min_end: datetime | None = None, 443 max_start: datetime | None = None, 444 min_start: datetime | None = None, 445 max_events: int = 100, 446 ) -> list[JSONDict] | None: 447 """Retrieve events visible to the authenticated user. 448 449 Filters can narrow by group/subgroup, by start/end timestamp window, 450 and by visibility (scheduled, hidden). The full response is cached on 451 `self.events`. 452 453 Note: `get_event(uid)` looks up events via this method's cache, so 454 it inherits these defaults — an event that doesn't appear in the 455 first `max_events` results or is excluded by `include_scheduled=False` 456 is unreachable through `get_event()`. If you need broader visibility, 457 call this method directly with appropriate filters. 458 459 Parameters 460 ---------- 461 group_id : str, optional 462 Restrict to events belonging to this group. Uses `groupId` API 463 parameter. 464 subgroup_id : str, optional 465 Restrict to events within this subgroup. Uses `subGroupId` API 466 parameter. 467 include_scheduled : bool, optional 468 Include scheduled events (events whose invitations are queued to be 469 sent in the future). 470 Defaults to False for performance reasons. 471 Uses `scheduled` API parameter. 472 include_hidden : bool, optional 473 Include hidden events. 474 Uses `includeHidden` API parameter. 475 'includeHidden' filter is only available inside a group. 476 max_end : datetime, optional 477 Only include events which end before or at this datetime. 478 Uses `maxEndTimestamp` API parameter; relates to `endTimestamp` event 479 attribute. 480 min_end : datetime, optional 481 Only include events which end after or at this datetime. 482 Uses `minEndTimestamp` API parameter; relates to `endTimestamp` event 483 attribute. 484 max_start : datetime, optional 485 Only include events which start before or at this datetime. 486 Uses `maxStartTimestamp` API parameter; relates to `startTimestamp` event 487 attribute. 488 min_start : datetime, optional 489 Only include events which start after or at this datetime. 490 Uses `minStartTimestamp` API parameter; relates to `startTimestamp` event 491 attribute. 492 max_events : int, optional 493 Set a limit on the number of events returned. 494 For performance reasons, defaults to 100. 495 Uses `max` API parameter. 496 497 Returns 498 ------- 499 list[JSONDict] or None 500 A list of events, each represented as a dictionary, or None if no events 501 are available. 502 503 Raises 504 ------ 505 ValueError 506 Raised when the request to the API fails. This occurs if the response 507 status code indicates an error (e.g., 4xx or 5xx). The error message 508 includes the HTTP status code and the response body for debugging purposes. 509 """ 510 url = f"{self.api_url}sponds/" 511 params = { 512 "max": str(max_events), 513 "scheduled": str(include_scheduled), 514 } 515 if max_end: 516 params["maxEndTimestamp"] = max_end.strftime(self._DT_FORMAT) 517 if max_start: 518 params["maxStartTimestamp"] = max_start.strftime(self._DT_FORMAT) 519 if min_end: 520 params["minEndTimestamp"] = min_end.strftime(self._DT_FORMAT) 521 if min_start: 522 params["minStartTimestamp"] = min_start.strftime(self._DT_FORMAT) 523 if group_id: 524 params["groupId"] = group_id 525 if subgroup_id: 526 params["subGroupId"] = subgroup_id 527 if include_hidden: 528 params["includeHidden"] = "true" 529 530 async with self.clientsession.get( 531 url, headers=self.auth_headers, params=params 532 ) as r: 533 if not r.ok: 534 error_details = await r.text() 535 raise ValueError( 536 f"Request failed with status {r.status}: {error_details}" 537 ) 538 self.events = await r.json() 539 return self.events 540 541 async def get_event(self, uid: str) -> JSONDict: 542 """Look up a single event by its unique id. 543 544 Routes through the cached events list (populated by `get_events()`), 545 which means events outside the `max_events=100` default or those 546 excluded by `include_scheduled=False` may not be findable. To reach 547 those events, call `get_events()` directly with appropriate filters 548 first to populate the cache, then call this method. 549 550 Parameters 551 ---------- 552 uid : str 553 UID of the event. 554 555 Returns 556 ------- 557 JSONDict 558 The event's details, with the same shape as elements returned by 559 `get_events()`. 560 561 Raises 562 ------ 563 KeyError 564 If no event with the given id is found in the cache. 565 """ 566 return await self._get_entity(self._EVENT, uid) 567 568 @_SpondBase.require_authentication 569 async def update_event(self, uid: str, updates: JSONDict) -> JSONDict: 570 """Update an existing event by merging changes into the current state. 571 572 The implementation fetches the event via `_get_entity()`, copies the 573 fields present in `_EVENT_TEMPLATE` from the existing event as the 574 base, then overlays any keys provided in `updates`. The merged event 575 is POSTed back to `sponds/{uid}`. 576 577 Parameters 578 ---------- 579 uid : str 580 UID of the event to update. 581 updates : JSONDict 582 Mapping of keys to new values. Only keys present in 583 `_EVENT_TEMPLATE` are honoured. Example: 584 585 ```python 586 await s.update_event(uid, {"description": "New description"}) 587 ``` 588 589 Returns 590 ------- 591 JSONDict 592 The Spond API response from the POST — the updated event as 593 persisted server-side. 594 """ 595 event = await self._get_entity(self._EVENT, uid) 596 url = f"{self.api_url}sponds/{uid}" 597 598 base_event = self._EVENT_TEMPLATE.copy() 599 for key in base_event: 600 if event.get(key) is not None and not updates.get(key): 601 base_event[key] = event[key] 602 elif updates.get(key) is not None: 603 base_event[key] = updates[key] 604 605 async with self.clientsession.post( 606 url, json=base_event, headers=self.auth_headers 607 ) as r: 608 return await r.json() 609 610 @_SpondBase.require_authentication 611 async def get_event_attendance_xlsx(self, uid: str) -> bytes: 612 """Download the attendance report for an event as XLSX bytes. 613 614 Thin wrapper around Spond's own "Export attendance history" feature 615 in the web UI. The columns and format are determined by Spond, not by 616 this library — for example, the export does not include member ids. 617 For a customisable CSV alternative built from `get_event()` data, 618 see `examples/attendance.py`. 619 620 Parameters 621 ---------- 622 uid : str 623 UID of the event whose attendance report to fetch. 624 625 Returns 626 ------- 627 bytes 628 Raw XLSX file contents. Typically written directly to disk: 629 630 ```python 631 import pathlib 632 633 data = await s.get_event_attendance_xlsx(uid) 634 pathlib.Path(f"{uid}.xlsx").write_bytes(data) 635 ``` 636 """ 637 url = f"{self.api_url}sponds/{uid}/export" 638 async with self.clientsession.get(url, headers=self.auth_headers) as r: 639 return await r.read() 640 641 @_SpondBase.require_authentication 642 async def change_response(self, uid: str, user: str, payload: JSONDict) -> JSONDict: 643 """Update a single member's response (accept/decline) for an event. 644 645 Useful for managing attendance on someone else's behalf (e.g. a coach 646 accepting on behalf of a player who can't reach the app). The caller 647 must have permission on the event. 648 649 Parameters 650 ---------- 651 uid : str 652 UID of the event. 653 user : str 654 UID of the member whose response to change. Note: this is the 655 *member's* id (as seen in `group["members"][i]["id"]`), not the 656 authenticated user's id. 657 payload : JSONDict 658 The response body. Common shapes: 659 660 - `{"accepted": "true"}` — accept the invitation 661 - `{"accepted": "false"}` — decline (Spond may also accept a 662 `"declineMessage"` field with a reason) 663 664 Returns 665 ------- 666 JSONDict 667 The event's `responses` object with the updated id lists 668 (`acceptedIds`, `declinedIds`, `unansweredIds`, etc.). 669 """ 670 url = f"{self.api_url}sponds/{uid}/responses/{user}" 671 async with self.clientsession.put( 672 url, headers=self.auth_headers, json=payload 673 ) as r: 674 return await r.json() 675 676 @_SpondBase.require_authentication 677 async def _get_entity(self, entity_type: str, uid: str) -> JSONDict: 678 """Internal lookup helper shared by `get_event` and `get_group`. 679 680 Routes to the relevant cache (`self.events` or `self.groups`), 681 triggers a fetch via `get_events()` / `get_groups()` if the cache is 682 empty, then linearly scans for a matching `id`. Raises `KeyError` 683 cleanly (rather than `TypeError`) when the cache remains empty after 684 the fetch attempt — the underlying `get_*s()` method may legitimately 685 return `None` if the account has no events/groups available. 686 687 Parameters 688 ---------- 689 entity_type : str 690 One of `self._EVENT` (`"event"`) or `self._GROUP` (`"group"`). 691 uid : str 692 UID of the entity to find. 693 694 Returns 695 ------- 696 JSONDict 697 The matching entity dict. 698 699 Raises 700 ------ 701 KeyError 702 No entity with that id was found (either because the relevant 703 cache is empty or because the id doesn't appear in it). 704 NotImplementedError 705 `entity_type` is something other than `"event"` or `"group"`. 706 """ 707 if entity_type == self._EVENT: 708 if not self.events: 709 await self.get_events() 710 entities = self.events 711 elif entity_type == self._GROUP: 712 if not self.groups: 713 await self.get_groups() 714 entities = self.groups 715 else: 716 errmsg = f"Entity type '{entity_type}' is not supported." 717 raise NotImplementedError(errmsg) 718 719 errmsg = f"No {entity_type} with id='{uid}'." 720 if not entities: 721 raise KeyError(errmsg) 722 723 for entity in entities: 724 if entity["id"] == uid: 725 return entity 726 raise KeyError(errmsg)
Async client for the Spond consumer API.
Authentication happens lazily on the first API call (via the
require_authentication decorator inherited from spond.base._SpondBase);
you do not need to call login() explicitly.
Several get_* methods cache their last response on the instance
(self.groups, self.events, self.posts, self.messages,
self.profile). This lets lookup helpers like get_group(uid) and
get_person(user) avoid re-fetching when called repeatedly. To force a
refresh, set the relevant attribute to None and call the get_* method
again, or call the underlying get_*s() method directly.
Remember to close the underlying aiohttp session when finished:
s = Spond(username="...", password="...")
try:
groups = await s.get_groups()
...
finally:
await s.clientsession.close()
Example
import asyncio
from spond import spond
async def main():
s = spond.Spond(username="me@example.invalid", password="secret")
groups = await s.get_groups() or []
for g in groups:
print(g["name"])
await s.clientsession.close()
asyncio.run(main())
71 def __init__(self, username: str, password: str) -> None: 72 """Construct a Spond client. 73 74 The credentials are stored on the instance and used to obtain an access 75 token on the first authenticated call. An aiohttp `ClientSession` is 76 opened immediately; close it via `await s.clientsession.close()` 77 (where `s` is the constructed instance) when finished, to avoid 78 `Unclosed client session` warnings. 79 80 Parameters 81 ---------- 82 username : str 83 Spond account email address. 84 password : str 85 Spond account password. For accounts with 2FA enabled, login will 86 currently fail — Spond's TOTP flow is not yet supported. 87 """ 88 super().__init__(username, password, self._API_BASE_URL) 89 self._chat_url = None 90 self._auth = None 91 self.groups: list[JSONDict] | None = None 92 self.events: list[JSONDict] | None = None 93 self.posts: list[JSONDict] | None = None 94 self.messages: list[JSONDict] | None = None 95 self.profile: JSONDict | None = None
Construct a Spond client.
The credentials are stored on the instance and used to obtain an access
token on the first authenticated call. An aiohttp ClientSession is
opened immediately; close it via await s.clientsession.close()
(where s is the constructed instance) when finished, to avoid
Unclosed client session warnings.
Parameters
- username (str): Spond account email address.
- password (str): Spond account password. For accounts with 2FA enabled, login will currently fail — Spond's TOTP flow is not yet supported.
113 @_SpondBase.require_authentication 114 async def get_profile(self) -> JSONDict: 115 """Retrieve the authenticated user's profile. 116 117 The profile dict includes at least the user's `id`, `firstName`, and 118 `lastName`, plus contact details and account preferences. The full 119 response is cached on `self.profile`. 120 121 Returns 122 ------- 123 JSONDict 124 The profile object as returned by the Spond API. 125 """ 126 url = f"{self._API_BASE_URL}profile" 127 async with self.clientsession.get(url, headers=self.auth_headers) as r: 128 self.profile = await r.json() 129 return self.profile
Retrieve the authenticated user's profile.
The profile dict includes at least the user's id, firstName, and
lastName, plus contact details and account preferences. The full
response is cached on self.profile.
Returns
- JSONDict: The profile object as returned by the Spond API.
131 @_SpondBase.require_authentication 132 async def get_groups(self) -> list[JSONDict] | None: 133 """Retrieve every group the authenticated user is a member of. 134 135 Each group dict includes a `members` list, with each member dict 136 containing `id`, `firstName`, `lastName`, and (for child profiles 137 managed by another account) a nested `guardians` list of the same 138 shape. The full response is cached on `self.groups` and reused by 139 `get_group(uid)` and `get_person(user)`. 140 141 Returns 142 ------- 143 list[JSONDict] or None 144 A list of groups, each represented as a dictionary. `None` if the 145 account has no groups at all. 146 """ 147 url = f"{self.api_url}groups/" 148 async with self.clientsession.get(url, headers=self.auth_headers) as r: 149 self.groups = await r.json() 150 return self.groups
Retrieve every group the authenticated user is a member of.
Each group dict includes a members list, with each member dict
containing id, firstName, lastName, and (for child profiles
managed by another account) a nested guardians list of the same
shape. The full response is cached on self.groups and reused by
get_group(uid) and get_person(user).
Returns
- list[JSONDict] or None: A list of groups, each represented as a dictionary.
Noneif the account has no groups at all.
152 async def get_group(self, uid: str) -> JSONDict: 153 """Look up a single group by its unique id. 154 155 Searches the cached `self.groups` (populated by `get_groups()` on 156 first call). To force a refresh, set `self.groups = None` first. 157 158 Parameters 159 ---------- 160 uid : str 161 UID of the group. 162 163 Returns 164 ------- 165 JSONDict 166 The group's details, with the same shape as elements returned by 167 `get_groups()`. 168 169 Raises 170 ------ 171 KeyError 172 If no group with the given id is found (or the user has no groups). 173 """ 174 return await self._get_entity(self._GROUP, uid)
Look up a single group by its unique id.
Searches the cached self.groups (populated by get_groups() on
first call). To force a refresh, set self.groups = None first.
Parameters
- uid (str): UID of the group.
Returns
- JSONDict: The group's details, with the same shape as elements returned by
get_groups().
Raises
- KeyError: If no group with the given id is found (or the user has no groups).
176 @_SpondBase.require_authentication 177 async def get_person(self, user: str) -> JSONDict: 178 """Look up a member or guardian by any of several identifiers. 179 180 Searches every member of every cached group (and each member's 181 `guardians` list). The first match wins. The cache `self.groups` is 182 populated by `get_groups()` if empty. 183 184 Parameters 185 ---------- 186 user : str 187 Identifier to match against. Accepted forms: 188 189 - the member's `id` 190 - the member's email (exact match) 191 - first and last name joined by a single space 192 (e.g. `"Ola Thoresen"`) 193 - the member's `profile.id` (different from `id` for child profiles) 194 195 Returns 196 ------- 197 JSONDict 198 The first matching member or guardian dict. Shape matches the 199 entries in a group's `members` list from `get_groups()`. 200 201 Raises 202 ------ 203 KeyError 204 If no match is found across any group or guardian. 205 """ 206 if not self.groups: 207 await self.get_groups() 208 for group in self.groups: 209 for member in group["members"]: 210 if self._match_person(member, user): 211 return member 212 if "guardians" in member: 213 for guardian in member["guardians"]: 214 if self._match_person(guardian, user): 215 return guardian 216 errmsg = f"No person matched with identifier '{user}'." 217 raise KeyError(errmsg)
Look up a member or guardian by any of several identifiers.
Searches every member of every cached group (and each member's
guardians list). The first match wins. The cache self.groups is
populated by get_groups() if empty.
Parameters
user (str): Identifier to match against. Accepted forms:
- the member's
id - the member's email (exact match)
- first and last name joined by a single space
(e.g.
"Ola Thoresen") - the member's
profile.id(different fromidfor child profiles)
- the member's
Returns
- JSONDict: The first matching member or guardian dict. Shape matches the
entries in a group's
memberslist fromget_groups().
Raises
- KeyError: If no match is found across any group or guardian.
245 @_SpondBase.require_authentication 246 async def get_posts( 247 self, 248 group_id: str | None = None, 249 max_posts: int = 20, 250 include_comments: bool = True, 251 ) -> list[JSONDict] | None: 252 """ 253 Retrieve posts from group walls. 254 255 Posts are announcements/messages posted to group walls, as opposed to 256 chat messages or events. 257 258 Parameters 259 ---------- 260 group_id : str, optional 261 Filter by group. Uses `groupId` API parameter. 262 max_posts : int, optional 263 Set a limit on the number of posts returned. 264 For performance reasons, defaults to 20. 265 Uses `max` API parameter. 266 include_comments : bool, optional 267 Include comments on posts. 268 Defaults to True. 269 Uses `includeComments` API parameter. 270 271 Returns 272 ------- 273 list[JSONDict] or None 274 A list of posts, each represented as a dictionary, or None if no 275 posts are available. 276 277 Raises 278 ------ 279 ValueError 280 Raised when the request to the API fails. 281 """ 282 url = f"{self.api_url}posts/" 283 params: dict[str, str] = { 284 "type": "PLAIN", 285 "max": str(max_posts), 286 "includeComments": str(include_comments).lower(), 287 } 288 if group_id: 289 params["groupId"] = group_id 290 291 async with self.clientsession.get( 292 url, headers=self.auth_headers, params=params 293 ) as r: 294 if not r.ok: 295 error_details = await r.text() 296 raise ValueError( 297 f"Request failed with status {r.status}: {error_details}" 298 ) 299 self.posts = await r.json() 300 return self.posts
Retrieve posts from group walls.
Posts are announcements/messages posted to group walls, as opposed to chat messages or events.
Parameters
- group_id (str, optional):
Filter by group. Uses
groupIdAPI parameter. - max_posts (int, optional):
Set a limit on the number of posts returned.
For performance reasons, defaults to 20.
Uses
maxAPI parameter. - include_comments (bool, optional):
Include comments on posts.
Defaults to True.
Uses
includeCommentsAPI parameter.
Returns
- list[JSONDict] or None: A list of posts, each represented as a dictionary, or None if no posts are available.
Raises
- ValueError: Raised when the request to the API fails.
302 @_SpondBase.require_authentication 303 async def get_messages(self, max_chats: int = 100) -> list[JSONDict] | None: 304 """Retrieve recent chats (one-to-one and group conversations). 305 306 "Chats" here refers to the in-app direct/group messaging feature, not 307 comments on events or posts. Uses Spond's separate chat-server host 308 and chat token (handled internally by `_login_chat`). 309 310 The full response is cached on `self.messages`. 311 312 Parameters 313 ---------- 314 max_chats : int, optional 315 Maximum number of chats to return. Defaults to 100 for performance. 316 Uses the `max` API parameter. 317 318 Returns 319 ------- 320 list[JSONDict] or None 321 A list of chat objects ordered by most recent activity. `None` if 322 the account has no chats. 323 """ 324 if not self._auth: 325 await self._login_chat() 326 url = f"{self._chat_url}/chats/" 327 async with self.clientsession.get( 328 url, 329 headers={"auth": self._auth}, 330 params={"max": str(max_chats)}, 331 ) as r: 332 self.messages = await r.json() 333 return self.messages
Retrieve recent chats (one-to-one and group conversations).
"Chats" here refers to the in-app direct/group messaging feature, not
comments on events or posts. Uses Spond's separate chat-server host
and chat token (handled internally by _login_chat).
The full response is cached on self.messages.
Parameters
- max_chats (int, optional):
Maximum number of chats to return. Defaults to 100 for performance.
Uses the
maxAPI parameter.
Returns
- list[JSONDict] or None: A list of chat objects ordered by most recent activity.
Noneif the account has no chats.
361 @_SpondBase.require_authentication 362 async def send_message( 363 self, 364 text: str, 365 user: str | None = None, 366 group_uid: str | None = None, 367 chat_id: str | None = None, 368 ) -> JSONDict: 369 """Send a chat message, either continuing an existing thread or 370 starting a new one. 371 372 Two calling patterns: 373 374 - **Continue an existing chat**: pass `chat_id` (the recipient and 375 group context are inferred from the existing thread). `user` and 376 `group_uid` are ignored. 377 - **Start a new chat**: pass both `user` (the recipient) and 378 `group_uid` (the group context the chat belongs to). The user is 379 resolved via `get_person()` to find the underlying profile id. 380 381 Parameters 382 ---------- 383 text : str 384 Message body to send. 385 user : str, optional 386 Recipient identifier when starting a new chat. Accepts the same 387 forms as `get_person()`: member id, email, full name, or 388 profile id. Required when `chat_id` is not given. 389 group_uid : str, optional 390 UID of the group that scopes the new chat. Required when `chat_id` 391 is not given. 392 chat_id : str, optional 393 Identifier of an existing chat to continue. When provided, 394 `user` and `group_uid` are not consulted. 395 396 Returns 397 ------- 398 JSONDict 399 The Spond API response for the send operation. 400 401 Raises 402 ------ 403 ValueError 404 Neither `chat_id` nor both of `user`/`group_uid` were supplied — 405 the call has no way to identify the target chat. 406 KeyError 407 `user` was given but doesn't match any member or guardian in any 408 of the authenticated user's groups (propagated from 409 `get_person`). 410 """ 411 if self._auth is None: 412 await self._login_chat() 413 414 if chat_id is not None: 415 return await self._continue_chat(chat_id, text) 416 if group_uid is None or user is None: 417 raise ValueError( 418 "send_message requires either chat_id (to continue an existing " 419 "chat) or both user and group_uid (to start a new one)." 420 ) 421 422 user_obj = await self.get_person(user) 423 user_uid = user_obj["profile"]["id"] 424 url = f"{self._chat_url}/messages" 425 data = { 426 "text": text, 427 "type": "TEXT", 428 "recipient": user_uid, 429 "groupId": group_uid, 430 } 431 r = await self.clientsession.post(url, json=data, headers={"auth": self._auth}) 432 return await r.json()
Send a chat message, either continuing an existing thread or starting a new one.
Two calling patterns:
- Continue an existing chat: pass
chat_id(the recipient and group context are inferred from the existing thread).userandgroup_uidare ignored. - Start a new chat: pass both
user(the recipient) andgroup_uid(the group context the chat belongs to). The user is resolved viaget_person()to find the underlying profile id.
Parameters
- text (str): Message body to send.
- user (str, optional):
Recipient identifier when starting a new chat. Accepts the same
forms as
get_person(): member id, email, full name, or profile id. Required whenchat_idis not given. - group_uid (str, optional):
UID of the group that scopes the new chat. Required when
chat_idis not given. - chat_id (str, optional):
Identifier of an existing chat to continue. When provided,
userandgroup_uidare not consulted.
Returns
- JSONDict: The Spond API response for the send operation.
Raises
- ValueError: Neither
chat_idnor both ofuser/group_uidwere supplied — the call has no way to identify the target chat. - KeyError:
userwas given but doesn't match any member or guardian in any of the authenticated user's groups (propagated fromget_person).
434 @_SpondBase.require_authentication 435 async def get_events( 436 self, 437 group_id: str | None = None, 438 subgroup_id: str | None = None, 439 include_scheduled: bool = False, 440 include_hidden: bool = False, 441 max_end: datetime | None = None, 442 min_end: datetime | None = None, 443 max_start: datetime | None = None, 444 min_start: datetime | None = None, 445 max_events: int = 100, 446 ) -> list[JSONDict] | None: 447 """Retrieve events visible to the authenticated user. 448 449 Filters can narrow by group/subgroup, by start/end timestamp window, 450 and by visibility (scheduled, hidden). The full response is cached on 451 `self.events`. 452 453 Note: `get_event(uid)` looks up events via this method's cache, so 454 it inherits these defaults — an event that doesn't appear in the 455 first `max_events` results or is excluded by `include_scheduled=False` 456 is unreachable through `get_event()`. If you need broader visibility, 457 call this method directly with appropriate filters. 458 459 Parameters 460 ---------- 461 group_id : str, optional 462 Restrict to events belonging to this group. Uses `groupId` API 463 parameter. 464 subgroup_id : str, optional 465 Restrict to events within this subgroup. Uses `subGroupId` API 466 parameter. 467 include_scheduled : bool, optional 468 Include scheduled events (events whose invitations are queued to be 469 sent in the future). 470 Defaults to False for performance reasons. 471 Uses `scheduled` API parameter. 472 include_hidden : bool, optional 473 Include hidden events. 474 Uses `includeHidden` API parameter. 475 'includeHidden' filter is only available inside a group. 476 max_end : datetime, optional 477 Only include events which end before or at this datetime. 478 Uses `maxEndTimestamp` API parameter; relates to `endTimestamp` event 479 attribute. 480 min_end : datetime, optional 481 Only include events which end after or at this datetime. 482 Uses `minEndTimestamp` API parameter; relates to `endTimestamp` event 483 attribute. 484 max_start : datetime, optional 485 Only include events which start before or at this datetime. 486 Uses `maxStartTimestamp` API parameter; relates to `startTimestamp` event 487 attribute. 488 min_start : datetime, optional 489 Only include events which start after or at this datetime. 490 Uses `minStartTimestamp` API parameter; relates to `startTimestamp` event 491 attribute. 492 max_events : int, optional 493 Set a limit on the number of events returned. 494 For performance reasons, defaults to 100. 495 Uses `max` API parameter. 496 497 Returns 498 ------- 499 list[JSONDict] or None 500 A list of events, each represented as a dictionary, or None if no events 501 are available. 502 503 Raises 504 ------ 505 ValueError 506 Raised when the request to the API fails. This occurs if the response 507 status code indicates an error (e.g., 4xx or 5xx). The error message 508 includes the HTTP status code and the response body for debugging purposes. 509 """ 510 url = f"{self.api_url}sponds/" 511 params = { 512 "max": str(max_events), 513 "scheduled": str(include_scheduled), 514 } 515 if max_end: 516 params["maxEndTimestamp"] = max_end.strftime(self._DT_FORMAT) 517 if max_start: 518 params["maxStartTimestamp"] = max_start.strftime(self._DT_FORMAT) 519 if min_end: 520 params["minEndTimestamp"] = min_end.strftime(self._DT_FORMAT) 521 if min_start: 522 params["minStartTimestamp"] = min_start.strftime(self._DT_FORMAT) 523 if group_id: 524 params["groupId"] = group_id 525 if subgroup_id: 526 params["subGroupId"] = subgroup_id 527 if include_hidden: 528 params["includeHidden"] = "true" 529 530 async with self.clientsession.get( 531 url, headers=self.auth_headers, params=params 532 ) as r: 533 if not r.ok: 534 error_details = await r.text() 535 raise ValueError( 536 f"Request failed with status {r.status}: {error_details}" 537 ) 538 self.events = await r.json() 539 return self.events
Retrieve events visible to the authenticated user.
Filters can narrow by group/subgroup, by start/end timestamp window,
and by visibility (scheduled, hidden). The full response is cached on
self.events.
Note: get_event(uid) looks up events via this method's cache, so
it inherits these defaults — an event that doesn't appear in the
first max_events results or is excluded by include_scheduled=False
is unreachable through get_event(). If you need broader visibility,
call this method directly with appropriate filters.
Parameters
- group_id (str, optional):
Restrict to events belonging to this group. Uses
groupIdAPI parameter. - subgroup_id (str, optional):
Restrict to events within this subgroup. Uses
subGroupIdAPI parameter. - include_scheduled (bool, optional):
Include scheduled events (events whose invitations are queued to be
sent in the future).
Defaults to False for performance reasons.
Uses
scheduledAPI parameter. - include_hidden (bool, optional):
Include hidden events.
Uses
includeHiddenAPI parameter. 'includeHidden' filter is only available inside a group. - max_end (datetime, optional):
Only include events which end before or at this datetime.
Uses
maxEndTimestampAPI parameter; relates toendTimestampevent attribute. - min_end (datetime, optional):
Only include events which end after or at this datetime.
Uses
minEndTimestampAPI parameter; relates toendTimestampevent attribute. - max_start (datetime, optional):
Only include events which start before or at this datetime.
Uses
maxStartTimestampAPI parameter; relates tostartTimestampevent attribute. - min_start (datetime, optional):
Only include events which start after or at this datetime.
Uses
minStartTimestampAPI parameter; relates tostartTimestampevent attribute. - max_events (int, optional):
Set a limit on the number of events returned.
For performance reasons, defaults to 100.
Uses
maxAPI parameter.
Returns
- list[JSONDict] or None: A list of events, each represented as a dictionary, or None if no events are available.
Raises
- ValueError: Raised when the request to the API fails. This occurs if the response status code indicates an error (e.g., 4xx or 5xx). The error message includes the HTTP status code and the response body for debugging purposes.
541 async def get_event(self, uid: str) -> JSONDict: 542 """Look up a single event by its unique id. 543 544 Routes through the cached events list (populated by `get_events()`), 545 which means events outside the `max_events=100` default or those 546 excluded by `include_scheduled=False` may not be findable. To reach 547 those events, call `get_events()` directly with appropriate filters 548 first to populate the cache, then call this method. 549 550 Parameters 551 ---------- 552 uid : str 553 UID of the event. 554 555 Returns 556 ------- 557 JSONDict 558 The event's details, with the same shape as elements returned by 559 `get_events()`. 560 561 Raises 562 ------ 563 KeyError 564 If no event with the given id is found in the cache. 565 """ 566 return await self._get_entity(self._EVENT, uid)
Look up a single event by its unique id.
Routes through the cached events list (populated by get_events()),
which means events outside the max_events=100 default or those
excluded by include_scheduled=False may not be findable. To reach
those events, call get_events() directly with appropriate filters
first to populate the cache, then call this method.
Parameters
- uid (str): UID of the event.
Returns
- JSONDict: The event's details, with the same shape as elements returned by
get_events().
Raises
- KeyError: If no event with the given id is found in the cache.
568 @_SpondBase.require_authentication 569 async def update_event(self, uid: str, updates: JSONDict) -> JSONDict: 570 """Update an existing event by merging changes into the current state. 571 572 The implementation fetches the event via `_get_entity()`, copies the 573 fields present in `_EVENT_TEMPLATE` from the existing event as the 574 base, then overlays any keys provided in `updates`. The merged event 575 is POSTed back to `sponds/{uid}`. 576 577 Parameters 578 ---------- 579 uid : str 580 UID of the event to update. 581 updates : JSONDict 582 Mapping of keys to new values. Only keys present in 583 `_EVENT_TEMPLATE` are honoured. Example: 584 585 ```python 586 await s.update_event(uid, {"description": "New description"}) 587 ``` 588 589 Returns 590 ------- 591 JSONDict 592 The Spond API response from the POST — the updated event as 593 persisted server-side. 594 """ 595 event = await self._get_entity(self._EVENT, uid) 596 url = f"{self.api_url}sponds/{uid}" 597 598 base_event = self._EVENT_TEMPLATE.copy() 599 for key in base_event: 600 if event.get(key) is not None and not updates.get(key): 601 base_event[key] = event[key] 602 elif updates.get(key) is not None: 603 base_event[key] = updates[key] 604 605 async with self.clientsession.post( 606 url, json=base_event, headers=self.auth_headers 607 ) as r: 608 return await r.json()
Update an existing event by merging changes into the current state.
The implementation fetches the event via _get_entity(), copies the
fields present in _EVENT_TEMPLATE from the existing event as the
base, then overlays any keys provided in updates. The merged event
is POSTed back to sponds/{uid}.
Parameters
- uid (str): UID of the event to update.
updates (JSONDict): Mapping of keys to new values. Only keys present in
_EVENT_TEMPLATEare honoured. Example:await s.update_event(uid, {"description": "New description"})
Returns
- JSONDict: The Spond API response from the POST — the updated event as persisted server-side.
610 @_SpondBase.require_authentication 611 async def get_event_attendance_xlsx(self, uid: str) -> bytes: 612 """Download the attendance report for an event as XLSX bytes. 613 614 Thin wrapper around Spond's own "Export attendance history" feature 615 in the web UI. The columns and format are determined by Spond, not by 616 this library — for example, the export does not include member ids. 617 For a customisable CSV alternative built from `get_event()` data, 618 see `examples/attendance.py`. 619 620 Parameters 621 ---------- 622 uid : str 623 UID of the event whose attendance report to fetch. 624 625 Returns 626 ------- 627 bytes 628 Raw XLSX file contents. Typically written directly to disk: 629 630 ```python 631 import pathlib 632 633 data = await s.get_event_attendance_xlsx(uid) 634 pathlib.Path(f"{uid}.xlsx").write_bytes(data) 635 ``` 636 """ 637 url = f"{self.api_url}sponds/{uid}/export" 638 async with self.clientsession.get(url, headers=self.auth_headers) as r: 639 return await r.read()
Download the attendance report for an event as XLSX bytes.
Thin wrapper around Spond's own "Export attendance history" feature
in the web UI. The columns and format are determined by Spond, not by
this library — for example, the export does not include member ids.
For a customisable CSV alternative built from get_event() data,
see examples/attendance.py.
Parameters
- uid (str): UID of the event whose attendance report to fetch.
Returns
- bytes: Raw XLSX file contents. Typically written directly to disk:
import pathlib
data = await s.get_event_attendance_xlsx(uid)
pathlib.Path(f"{uid}.xlsx").write_bytes(data)
641 @_SpondBase.require_authentication 642 async def change_response(self, uid: str, user: str, payload: JSONDict) -> JSONDict: 643 """Update a single member's response (accept/decline) for an event. 644 645 Useful for managing attendance on someone else's behalf (e.g. a coach 646 accepting on behalf of a player who can't reach the app). The caller 647 must have permission on the event. 648 649 Parameters 650 ---------- 651 uid : str 652 UID of the event. 653 user : str 654 UID of the member whose response to change. Note: this is the 655 *member's* id (as seen in `group["members"][i]["id"]`), not the 656 authenticated user's id. 657 payload : JSONDict 658 The response body. Common shapes: 659 660 - `{"accepted": "true"}` — accept the invitation 661 - `{"accepted": "false"}` — decline (Spond may also accept a 662 `"declineMessage"` field with a reason) 663 664 Returns 665 ------- 666 JSONDict 667 The event's `responses` object with the updated id lists 668 (`acceptedIds`, `declinedIds`, `unansweredIds`, etc.). 669 """ 670 url = f"{self.api_url}sponds/{uid}/responses/{user}" 671 async with self.clientsession.put( 672 url, headers=self.auth_headers, json=payload 673 ) as r: 674 return await r.json()
Update a single member's response (accept/decline) for an event.
Useful for managing attendance on someone else's behalf (e.g. a coach accepting on behalf of a player who can't reach the app). The caller must have permission on the event.
Parameters
- uid (str): UID of the event.
- user (str):
UID of the member whose response to change. Note: this is the
member's id (as seen in
group["members"][i]["id"]), not the authenticated user's id. payload (JSONDict): The response body. Common shapes:
{"accepted": "true"}— accept the invitation{"accepted": "false"}— decline (Spond may also accept a"declineMessage"field with a reason)
Returns
- JSONDict: The event's
responsesobject with the updated id lists (acceptedIds,declinedIds,unansweredIds, etc.).