Skip to content

Commit

Permalink
feat: extract payments on club endpoint
Browse files Browse the repository at this point in the history
This feature is needed for our club. However, this is a new
major addition to the module as a whole (new API endpoint club).

a couple of major changes:

- base class to avoid redundant code
- manual tests added
- example usage script added

Related to #70.
  • Loading branch information
Mai Minh Pham authored and maiminhp committed May 23, 2024
1 parent 6b4a9fc commit 6d894bf
Show file tree
Hide file tree
Showing 9 changed files with 205 additions and 69 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions config.py.sample
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
username = '[email protected]'
password = 'Pa55w0rd'
club_id = '1234567890'
22 changes: 20 additions & 2 deletions manual_test_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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']}'"
Expand All @@ -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)
Expand Down
43 changes: 43 additions & 0 deletions spond/base.py
Original file line number Diff line number Diff line change
@@ -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)
59 changes: 59 additions & 0 deletions spond/club.py
Original file line number Diff line number Diff line change
@@ -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
93 changes: 27 additions & 66 deletions spond/spond.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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),
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion tests/test_spond.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import pytest

from spond.base import SpondBase
from spond.spond import Spond

MOCK_USERNAME, MOCK_PASSWORD = "MOCK_USERNAME", "MOCK_PASSWORD"
Expand All @@ -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
Expand Down
Loading

0 comments on commit 6d894bf

Please sign in to comment.