diff --git a/.gitignore b/.gitignore index e9e52a7..2935f83 100644 --- a/.gitignore +++ b/.gitignore @@ -161,6 +161,8 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. .idea/ +# VSC +.vscode/ ######################################################################################## # Project specific diff --git a/README.md b/README.md index 43342d3..3b5308a 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,12 @@ Get all your messages. Send a message with content `text`. Either specify an existing `chat_id`, or both `user` and `group_uid` for a new chat. +### get_event_attendance_xlsx() +Get Excel attendance report for a single event, available via the web client. + +### change_response() +Change a member's response for an event (e.g. accept/decline) + ## Example scripts The following scripts are included as examples. Some of the scripts might require additional packages to be installed (csv, ical etc). @@ -72,6 +78,9 @@ Generates a json-file for each group you are a member of. ### attendance.py <-f from_date> <-t to_date> [-a] Generates a csv-file for each event between `from_date` and `to_date` with attendance status of all organizers. The optional parameter `-a` also includes all members that has been invited. +### transactions.py +Generates a csv-file for transactions / payments appeared in [Spond Club](https://www.spond.com/spond-club-overview/) > Finance > Payments. + ## AsyncIO [Asyncio](https://docs.python.org/3/library/asyncio.html) might seem intimidating in the beginning, but for basic stuff, it is quite easy to follow the examples above, and just remeber to prefix functions that use the API with `async def ...` and to `await` all API-calls and all calls to said functions. diff --git a/config.py.sample b/config.py.sample index 2ed0501..733f9e5 100644 --- a/config.py.sample +++ b/config.py.sample @@ -1,2 +1,3 @@ username = 'user@name.invalid' password = 'Pa55w0rd' +club_id = '1234567890' diff --git a/manual_test_functions.py b/manual_test_functions.py index 4285d72..c76dfc6 100644 --- a/manual_test_functions.py +++ b/manual_test_functions.py @@ -6,9 +6,10 @@ Doesn't yet use `get_person(user)` or any `send_`, `update_` methods.""" import asyncio +import tempfile -from config import password, username -from spond import spond +from config import club_id, password, username +from spond import club, spond DUMMY_ID = "DUMMY_ID" @@ -40,8 +41,28 @@ async def main() -> None: for i, message in enumerate(messages): print(f"[{i}] {_message_summary(message)}") + # ATTENDANCE EXPORT + + print("\nGetting attendance report for the first event...") + e = events[0] + data = await s.get_event_attendance_xlsx(e["id"]) + with tempfile.NamedTemporaryFile( + mode="wb", suffix=".xlsx", delete=False + ) as temp_file: + temp_file.write(data) + print(f"Check out {temp_file.name}") + await s.clientsession.close() + # SPOND CLUB + sc = club.SpondClub(username=username, password=password) + print("\nGetting up to 10 transactions...") + transactions = await sc.get_transactions(club_id=club_id, max_items=10) + print(f"{len(transactions)} transactions:") + for i, t in enumerate(transactions): + print(f"[{i}] {_transaction_summary(t)}") + await sc.clientsession.close() + def _group_summary(group) -> str: return f"id='{group['id']}', " f"name='{group['name']}'" @@ -63,6 +84,15 @@ def _message_summary(message) -> str: ) +def _transaction_summary(transaction) -> str: + return ( + f"id='{transaction['id']}', " + f"timestamp='{transaction['paidAt']}', " + f"payment_name='{transaction['paymentName']}', " + f"name={transaction['paidByName']}" + ) + + def _abbreviate(text, length) -> str: """Abbreviate long text, normalising line endings to escape characters.""" escaped_text = repr(text) diff --git a/spond/base.py b/spond/base.py new file mode 100644 index 0000000..020b1f8 --- /dev/null +++ b/spond/base.py @@ -0,0 +1,45 @@ +from abc import ABC + +import aiohttp + + +class AuthenticationError(Exception): + pass + + +class _SpondBase(ABC): + def __init__(self, username, password, api_url): + self.username = username + self.password = password + self.api_url = api_url + self.clientsession = aiohttp.ClientSession(cookie_jar=aiohttp.CookieJar()) + self.token = None + + @property + def auth_headers(self): + return { + "content-type": "application/json", + "Authorization": f"Bearer {self.token}", + } + + def require_authentication(func: callable): + async def wrapper(self, *args, **kwargs): + if not self.token: + try: + await self.login() + except AuthenticationError as e: + await self.clientsession.close() + raise e + return await func(self, *args, **kwargs) + + return wrapper + + async def login(self): + login_url = f"{self.api_url}login" + data = {"email": self.username, "password": self.password} + async with self.clientsession.post(login_url, json=data) as r: + login_result = await r.json() + self.token = login_result.get("loginToken") + if self.token is None: + err_msg = f"Login failed. Response received: {login_result}" + raise AuthenticationError(err_msg) diff --git a/spond/club.py b/spond/club.py new file mode 100644 index 0000000..6e516e6 --- /dev/null +++ b/spond/club.py @@ -0,0 +1,58 @@ +from typing import Optional + +from .base import _SpondBase + + +class SpondClub(_SpondBase): + def __init__(self, username, password): + super().__init__(username, password, "https://api.spond.com/club/v1/") + self.transactions = None + + @_SpondBase.require_authentication + async def get_transactions( + self, club_id: str, skip: Optional[int] = None, max_items: int = 100 + ): + """ + Retrieves a list of transactions/payments for a specified club. + + Parameters + ---------- + club_id : str + Identifier for the club. Note that this is different from the Group ID used + in the core API. + max_items : int, optional + The maximum number of transactions to retrieve. Defaults to 100. + skip : int, optional + This endpoint only returns 25 transactions at a time (page scrolling). + Therefore, we need to increment this `skip` param to grab the next + 25 etc. Defaults to None. It's better to keep `skip` at None + and specify `max_items` instead. This param is only here for the + recursion implementation + + Returns + ------- + list of dict + A list of transactions, each represented as a dictionary. + """ + if self.transactions is None: + self.transactions = [] + + url = f"{self.api_url}transactions" + params = None if skip is None else {"skip": skip} + headers = {**self.auth_headers, "X-Spond-Clubid": club_id} + + async with self.clientsession.get(url, headers=headers, params=params) as r: + if r.status == 200: + t = await r.json() + if len(t) == 0: + return self.transactions + + self.transactions.extend(t) + if len(self.transactions) < max_items: + return await self.get_transactions( + club_id=club_id, + skip=len(t) if skip is None else skip + len(t), + max_items=max_items, + ) + + return self.transactions diff --git a/spond/spond.py b/spond/spond.py index 7c493c4..dba9202 100644 --- a/spond/spond.py +++ b/spond/spond.py @@ -1,74 +1,32 @@ #!/usr/bin/env python3 - from datetime import datetime from typing import TYPE_CHECKING, List, Optional -import aiohttp +from .base import _SpondBase if TYPE_CHECKING: from datetime import datetime -class AuthenticationError(Exception): - pass - - -class Spond: +class Spond(_SpondBase): - API_BASE_URL = "https://api.spond.com/core/v1/" DT_FORMAT = "%Y-%m-%dT00:00:00.000Z" def __init__(self, username, password): - self.username = username - self.password = password - self.clientsession = aiohttp.ClientSession(cookie_jar=aiohttp.CookieJar()) + super().__init__(username, password, "https://api.spond.com/core/v1/") self.chat_url = None self.auth = None - self.token = None self.groups = None self.events = None - @property - def auth_headers(self): - return { - "content-type": "application/json", - "Authorization": f"Bearer {self.token}", - "auth": f"{self.auth}", - } - - async def login(self): - login_url = f"{self.API_BASE_URL}login" - data = {"email": self.username, "password": self.password} - async with self.clientsession.post(login_url, json=data) as r: - login_result = await r.json() - self.token = login_result.get("loginToken", None) - if self.token is None: - err_msg = f"Login failed. Response received: {login_result}" - raise AuthenticationError(err_msg) - - api_chat_url = f"{self.API_BASE_URL}chat" - headers = { - "content-type": "application/json", - "Authorization": f"Bearer {self.token}", - } - r = await self.clientsession.post(api_chat_url, headers=headers) + async def login_chat(self): + api_chat_url = f"{self.api_url}chat" + r = await self.clientsession.post(api_chat_url, headers=self.auth_headers) result = await r.json() self.chat_url = result["url"] self.auth = result["auth"] - def require_authentication(func: callable): - async def wrapper(self, *args, **kwargs): - if not self.token: - try: - await self.login() - except AuthenticationError as e: - await self.clientsession.close() - raise e - return await func(self, *args, **kwargs) - - return wrapper - - @require_authentication + @_SpondBase.require_authentication async def get_groups(self): """ Get all groups. @@ -79,12 +37,12 @@ async def get_groups(self): list of dict Groups; each group is a dict. """ - url = f"{self.API_BASE_URL}groups/" + url = f"{self.api_url}groups/" async with self.clientsession.get(url, headers=self.auth_headers) as r: self.groups = await r.json() return self.groups - @require_authentication + @_SpondBase.require_authentication async def get_group(self, uid) -> dict: """ Get a group by unique ID. @@ -113,7 +71,7 @@ async def get_group(self, uid) -> dict: errmsg = f"No group with id='{uid}'" raise IndexError(errmsg) - @require_authentication + @_SpondBase.require_authentication async def get_person(self, user) -> dict: """ Get a member or guardian by matching various identifiers. @@ -155,14 +113,15 @@ async def get_person(self, user) -> dict: return guardian raise IndexError - @require_authentication + @_SpondBase.require_authentication async def get_messages(self): + if not self.auth: + await self.login_chat() url = f"{self.chat_url}/chats/?max=10" - headers = {"auth": self.auth} - async with self.clientsession.get(url, headers=headers) as r: + async with self.clientsession.get(url, headers={"auth": self.auth}) as r: return await r.json() - @require_authentication + @_SpondBase.require_authentication async def _continue_chat(self, chat_id, text): """ Send a given text in an existing given chat. @@ -181,13 +140,14 @@ async def _continue_chat(self, chat_id, text): dict Result of the sending. """ + if not self.auth: + await self.login_chat() url = f"{self.chat_url}/messages" data = {"chatId": chat_id, "text": text, "type": "TEXT"} - headers = {"auth": self.auth} - r = await self.clientsession.post(url, json=data, headers=headers) + r = await self.clientsession.post(url, json=data, headers={"auth": self.auth}) return await r.json() - @require_authentication + @_SpondBase.require_authentication async def send_message(self, text, user=None, group_uid=None, chat_id=None): """ Start a new chat or continue an existing one. @@ -212,6 +172,8 @@ async def send_message(self, text, user=None, group_uid=None, chat_id=None): dict Result of the sending. """ + if self.auth is None: + await self.login_chat() if chat_id is not None: return self._continue_chat(chat_id, text) @@ -232,11 +194,10 @@ async def send_message(self, text, user=None, group_uid=None, chat_id=None): "recipient": user_uid, "groupId": group_uid, } - headers = {"auth": self.auth} - r = await self.clientsession.post(url, json=data, headers=headers) + r = await self.clientsession.post(url, json=data, headers={"auth": self.auth}) return await r.json() - @require_authentication + @_SpondBase.require_authentication async def get_events( self, group_id: Optional[str] = None, @@ -289,7 +250,7 @@ async def get_events( list of dict Events; each event is a dict. """ - url = f"{self.API_BASE_URL}sponds/" + url = f"{self.api_url}sponds/" params = { "max": str(max_events), "scheduled": str(include_scheduled), @@ -313,7 +274,7 @@ async def get_events( self.events = await r.json() return self.events - @require_authentication + @_SpondBase.require_authentication async def get_event(self, uid) -> dict: """ Get an event by unique ID. @@ -341,7 +302,7 @@ async def get_event(self, uid) -> dict: errmsg = f"No event with id='{uid}'" raise IndexError(errmsg) - @require_authentication + @_SpondBase.require_authentication async def update_event(self, uid, updates: dict): """ Updates an existing event. @@ -364,7 +325,7 @@ async def update_event(self, uid, updates: dict): if event["id"] == uid: break - url = f"{self.API_BASE_URL}sponds/{uid}" + url = f"{self.api_url}sponds/{uid}" base_event = { "heading": None, @@ -417,3 +378,46 @@ async def update_event(self, uid, updates: dict): ) as r: self.events_update = await r.json() return self.events + + @_SpondBase.require_authentication + async def get_event_attendance_xlsx(self, uid: str) -> bytes: + """get Excel attendance report for a single event. + Available via the web client. + + Parameters + ---------- + uid : str + UID of the event. + + Returns: + bytes: XLSX binary data + """ + url = f"{self.api_url}sponds/{uid}/export" + async with self.clientsession.get(url, headers=self.auth_headers) as r: + output_data = await r.read() + return output_data + + @_SpondBase.require_authentication + async def change_response(self, uid: str, user: str, payload: dict) -> dict: + """change a user's response for an event + + Parameters + ---------- + uid : str + UID of the event. + + user : str + UID of the user + + payload : dict + user response to event, e.g. {"accepted": "true"} + + Returns + ---------- + json: event["responses"] with updated info + """ + url = f"{self.api_url}sponds/{uid}/responses/{user}" + async with self.clientsession.put( + url, headers=self.auth_headers, json=payload + ) as r: + return await r.json() diff --git a/tests/test_spond.py b/tests/test_spond.py index 1b606a4..0ffdaee 100644 --- a/tests/test_spond.py +++ b/tests/test_spond.py @@ -1,11 +1,15 @@ """Test suite for Spond class.""" +from unittest.mock import AsyncMock, patch + import pytest +from spond.base import _SpondBase from spond.spond import Spond MOCK_USERNAME, MOCK_PASSWORD = "MOCK_USERNAME", "MOCK_PASSWORD" MOCK_TOKEN = "MOCK_TOKEN" +MOCK_PAYLOAD = {"accepted": "false", "declineMessage": "sick cannot make it"} # Mock the `require_authentication` decorator to bypass authentication @@ -16,7 +20,7 @@ async def wrapper(*args, **kwargs): return wrapper -Spond.require_authentication = mock_require_authentication(Spond.get_event) +_SpondBase.require_authentication = mock_require_authentication(Spond.get_event) @pytest.fixture @@ -54,6 +58,11 @@ def mock_token(): return MOCK_TOKEN +@pytest.fixture +def mock_payload(): + return MOCK_PAYLOAD + + @pytest.mark.asyncio async def test_get_event__happy_path(mock_events, mock_token): """Test that a valid `id` returns the matching event.""" @@ -130,3 +139,61 @@ async def test_get_group__blank_id_raises_exception(mock_groups, mock_token): with pytest.raises(IndexError): await s.get_group("") + + +@pytest.mark.asyncio +@patch("aiohttp.ClientSession.get") +async def test_get_export(mock_get, mock_token): + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + + mock_binary = b"\x68\x65\x6c\x6c\x6f\x77\x6f\x72\x6c\x64" # helloworld + mock_get.return_value.__aenter__.return_value.status = 200 + mock_get.return_value.__aenter__.return_value.read = AsyncMock( + return_value=mock_binary + ) + + data = await s.get_event_attendance_xlsx(uid="ID1") + + mock_url = "https://api.spond.com/core/v1/sponds/ID1/export" + mock_get.assert_called_once_with( + mock_url, + headers={ + "content-type": "application/json", + "Authorization": f"Bearer {mock_token}", + }, + ) + assert data == mock_binary + + +@pytest.mark.asyncio +@patch("aiohttp.ClientSession.put") +async def test_change_response(mock_put, mock_payload, mock_token): + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + + mock_response_data = { + "acceptedIds": ["PID1", "PID2"], + "declinedIds": ["PID3"], + "unansweredIds": [], + "waitinglistIds": [], + "unconfirmedIds": [], + "declineMessages": {"PID3": "sick cannot make it"}, + } + mock_put.return_value.__aenter__.return_value.status = 200 + mock_put.return_value.__aenter__.return_value.json = AsyncMock( + return_value=mock_response_data + ) + + response = await s.change_response(uid="ID1", user="PID3", payload=mock_payload) + + mock_url = "https://api.spond.com/core/v1/sponds/ID1/responses/PID3" + mock_put.assert_called_once_with( + mock_url, + headers={ + "content-type": "application/json", + "Authorization": f"Bearer {mock_token}", + }, + json=mock_payload, + ) + assert response == mock_response_data diff --git a/transactions.py b/transactions.py new file mode 100644 index 0000000..0c63781 --- /dev/null +++ b/transactions.py @@ -0,0 +1,48 @@ +import argparse +import asyncio +import csv +from pathlib import Path + +from config import club_id, password, username +from spond.club import SpondClub + +parser = argparse.ArgumentParser( + description="Creates an transactions.csv to keep track of payments accessible on Spond Club" +) +parser.add_argument( + "-m", + "--max", + help="The max number of transactions to query for", + type=int, + dest="max", + default=1000, +) + +args = parser.parse_args() + + +async def main(): + output_path = Path("./exports/transactions.csv") + + s = SpondClub(username=username, password=password) + transactions = await s.get_transactions(club_id=club_id, max_items=args.max) + if not transactions: + print("No transactions found.") + await s.clientsession.close() + return + + header = transactions[0].keys() + + with open(output_path, "w", newline="") as file: + writer = csv.DictWriter(file, fieldnames=header) + writer.writeheader() + for t in transactions: + writer.writerow(t) + + print(f"Collected {len(transactions)} transactions. Written to {output_path}") + await s.clientsession.close() + + +loop = asyncio.new_event_loop() +asyncio.set_event_loop(loop) +asyncio.run(main())