From 6c79b9ac7676400228e69372eb6606b755669933 Mon Sep 17 00:00:00 2001 From: Julien Palard Date: Sat, 18 Mar 2023 00:47:31 +0100 Subject: [PATCH] Initial commit. --- README.md | 37 ++++++++ helloasso_to_discourse.py | 191 ++++++++++++++++++++++++++++++++++++++ pyproject.toml | 29 ++++++ 3 files changed, 257 insertions(+) create mode 100644 README.md create mode 100644 helloasso_to_discourse.py create mode 100644 pyproject.toml diff --git a/README.md b/README.md new file mode 100644 index 0000000..8992064 --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# Helloasso to Discourse + +This works in two distinct steps: first fetch HelloAsso data, then sync with Discourse. + + +## Installing + + pip install . + + +## Fetching HelloAsso data + +Use the `fetch` subcommand, I use it as is: + + $ helloasso-to-discourse fetch "$(pass helloasso-clientid)" "$(pass helloasso-clientsecret)" afpy + +this fetches the data of the given organization, here named `afpy`, it creates an `./afpy` file. + + +## Choosing what to sync + +The goal is to sync an HelloAsso event (they call it `forms`) to a Discourse Badge. + +First let's discover which events we can use: + + $ helloasso-to-discourse list-forms ./afpy + +this will list names that can be used for the next step. + + +## Syncing with Discourse + +This step actually assigns badges to Discourse users: + +As an example to assign badge number 114 to HelloAsso users having paid for the form named `adhesion-2023-a-l-afpy`: + + $ helloasso-to-discourse sync https://discuss.afpy.org "$(pass discuss.afpy.org-api-key)" ./afpy adhesion-2023-a-l-afpy 114 diff --git a/helloasso_to_discourse.py b/helloasso_to_discourse.py new file mode 100644 index 0000000..b1c98f2 --- /dev/null +++ b/helloasso_to_discourse.py @@ -0,0 +1,191 @@ +import argparse +import re +from itertools import count, groupby +import json +from pathlib import Path +from urllib.parse import urljoin +from time import sleep + + +from tabulate import tabulate +import requests +from helloasso_api import HaApiV5 + + +def parse_args(): + parser = argparse.ArgumentParser(description="Hello backup to Discourse badge") + subparsers = parser.add_subparsers(help="Choose a command") + + fetch_parser = subparsers.add_parser( + "fetch", help="Fetch HelloAsso data from the HelloAsso API to a file." + ) + fetch_parser.add_argument("client_id") + fetch_parser.add_argument("client_secret") + fetch_parser.add_argument("org") + fetch_parser.set_defaults(func=main_fetch) + + sync_parser = subparsers.add_parser( + "sync", help="Sync the backup file to a given Discourse instance" + ) + sync_parser.add_argument("discourse_url") + sync_parser.add_argument("discourse_api_key") + sync_parser.add_argument("helloasso_backup_file") + sync_parser.add_argument( + "form_slug", + help="See the `list-forms` subcommand to learn which one you can use.", + ) + sync_parser.add_argument("badge", type=int) + sync_parser.set_defaults(func=main_sync) + + list_form_parser = subparsers.add_parser( + "list-forms", help="List HelloAsso forms, to use with `sync`" + ) + list_form_parser.set_defaults(func=main_list_form) + list_form_parser.add_argument("helloasso_backup_file") + + return parser.parse_args() + + +class Discourse: + def __init__(self, url, api_key): + self.url = url + self.api_key = api_key + self.session = None + + def __enter__(self): + self.session = requests.Session() + self.session.headers.update({"Api-Key": self.api_key}) + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.session.close() + + def post(self, url, data=None, json=None, **kwargs): + response = self.session.post(urljoin(self.url, url), data, json, **kwargs) + response.raise_for_status() + return response.json() + + def get(self, url, **kwargs): + response = self.session.get(urljoin(self.url, url), **kwargs) + response.raise_for_status() + return response.json() + + def users(self, flag="active"): + all_users = [] + for page in count(1): + users = self.get( + f"/admin/users/list/{flag}.json", + params={"page": page, "show_emails": "true"}, + ) + if not users: + break + all_users.extend(users) + return all_users + + def user_by_email(self, email): + users = self.get( + "/admin/users/list/active.json", + params={"filter": email, "show_emails": "true"}, + ) + for user in users: + if user["email"] == email: + return user + # Maybe, like me, he has a tag in its email address... + users = self.get( + "/admin/users/list/active.json", + params={ + "show_emails": "true", + "filter": re.sub("@.*$", "", remove_email_tag(email)), + }, + ) + for user in users: + if remove_email_tag(user["email"]) == remove_email_tag(email): + return user + + def assign_badge(self, badge_id, username): + return self.post( + "/user_badges", data={"badge_id": badge_id, "username": username} + ) + + +def remove_email_tag(email): + return re.sub(r"\+.*@", "@", email) + + +def main(): + args = parse_args() + return args.func(args) + + +def main_list_form(args): + helloasso_backup = json.loads( + Path(args.helloasso_backup_file).read_text(encoding="UTF-8") + ) + forms = [ + (item["order"]["formType"], item["order"]["formSlug"]) + for item in helloasso_backup + if item["state"] == "Processed" and "payer" in item and "email" in item["payer"] + ] + table = [key + (len(list(group)),) for key, group in groupby(sorted(forms))] + print( + "Here are the available HelloAsso forms you can you with the `sync` command ", + "to link a form to a badge:\n", + sep="\n", + ) + print(tabulate(table, headers=("Type", "Name", "Members"))) + print() + if table: + print("Use the `name` for the `sync` command, like:") + print( + f'helloasso-to-discourse sync https://discuss.afpy.org "$(pass discuss.afpy.org-api-key)" ./afpy {table[0][1]} 114' + ) + + +def main_sync(args): + helloasso_backup = json.loads( + Path(args.helloasso_backup_file).read_text(encoding="UTF-8") + ) + discourse = Discourse(args.discourse_url, args.discourse_api_key) + with discourse: + from_helloasso = { + remove_email_tag(item["payer"]["email"]) + for item in helloasso_backup + if item["order"]["formSlug"] == args.form_slug + and item["state"] == "Processed" + and "payer" in item + and "email" in item["payer"] + } + discourse_users_by_email = { + remove_email_tag(user["email"]): user for user in discourse.users() + } + for email in set(discourse_users_by_email) & from_helloasso: + print( + discourse.assign_badge( + args.badge, discourse_users_by_email[email]["username"] + ) + ) + + +def main_fetch(args): + api = HaApiV5( + api_base="api.helloasso.com", + client_id=args.client_id, + client_secret=args.client_secret, + timeout=60, + ) + + backup = [] + endpoint = f"/v5/organizations/{args.org}/items" + params = {"pageSize": 100} + items = api.call(endpoint, params=params).json() + while items["data"]: + backup.extend(items["data"]) + params["continuationToken"] = items["pagination"]["continuationToken"] + items = api.call(endpoint, params=params).json() + Path(args.org).write_text( + json.dumps(backup, indent=4, ensure_ascii=False), encoding="UTF-8" + ) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e55b1f5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,29 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "helloasso-to-discourse" +description = "Assign Discourse Badges to participants of HelloAsso events" +version = "0.1" +readme = "README.md" +license = {text = "MIT License"} +authors = [ + {name = "Julien Palard", email = "julien@palard.fr"}, +] +dependencies = [ + "helloasso-apiv5", + "tabulate", + "requests", +] + +[project.scripts] +helloasso-to-discourse = "helloasso_to_discourse:main" + +[tool.setuptools] +py-modules = [ + "helloasso_to_discourse", +] +include-package-data = false + +[tool.black]