Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: extract payments on club endpoint; feat: download attendance report; feat: update user's response to an event #107

Merged
merged 3 commits into from
May 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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.

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'
34 changes: 32 additions & 2 deletions manual_test_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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']}'"
Expand All @@ -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)
Expand Down
45 changes: 45 additions & 0 deletions spond/base.py
Original file line number Diff line number Diff line change
@@ -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)
58 changes: 58 additions & 0 deletions spond/club.py
Original file line number Diff line number Diff line change
@@ -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
Loading