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..c7bba73 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,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..ef0b4f8 100644 --- a/manual_test_functions.py +++ b/manual_test_functions.py @@ -7,8 +7,8 @@ import asyncio -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" @@ -42,6 +42,15 @@ async def main() -> None: 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 +72,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..7be586b --- /dev/null +++ b/spond/base.py @@ -0,0 +1,43 @@ +import aiohttp + + +class AuthenticationError(Exception): + pass + + +class SpondBase: + 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..c61d619 --- /dev/null +++ b/spond/club.py @@ -0,0 +1,59 @@ +import aiohttp + +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: 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 = list() + + 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 aiohttp.ClientSession() as session: + async with session.get(url, headers=headers, params=params) as response: + if response.status == 200: + t = await response.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..c5e5c0d 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(SpondBase): -class Spond: - - 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, diff --git a/tests/test_spond.py b/tests/test_spond.py index 1b606a4..0f3b7ee 100644 --- a/tests/test_spond.py +++ b/tests/test_spond.py @@ -2,6 +2,7 @@ import pytest +from spond.base import SpondBase from spond.spond import Spond MOCK_USERNAME, MOCK_PASSWORD = "MOCK_USERNAME", "MOCK_PASSWORD" @@ -16,7 +17,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 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())