diff --git a/.dockerignore b/.dockerignore index 2e903ec..90d7459 100644 --- a/.dockerignore +++ b/.dockerignore @@ -164,3 +164,4 @@ data_*.txt test/ docs/ +database.db diff --git a/.github/workflows/build_image.yml b/.github/workflows/build_image.yml index 94714b2..b2c8192 100644 --- a/.github/workflows/build_image.yml +++ b/.github/workflows/build_image.yml @@ -20,22 +20,6 @@ jobs: uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - - name: Install requirements - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - - name: Run Black check - run: black --check . - - - name: Run Pytest - run: pytest - name: Login to Docker Hub uses: docker/login-action@v3 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..fe13457 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,36 @@ +name: test + +on: + push: + branches: + - "main" + paths-ignore: + - 'docs' + - '**.md' + pull_request: + branches: + - "main" + +jobs: + test_and_lint: + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install requirements + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run Black check + run: black --check . + + - name: Run Mypy + run: mypy . + + - name: Run Pytest + run: pytest diff --git a/.gitignore b/.gitignore index 7d71158..77d8cf8 100644 --- a/.gitignore +++ b/.gitignore @@ -161,3 +161,5 @@ cython_debug/ *.ics data_*.txt +database.db + diff --git a/Readme.md b/Readme.md index 9e6ac75..948f041 100644 --- a/Readme.md +++ b/Readme.md @@ -1,4 +1,4 @@ -[![Github Container Pulls](https://img.shields.io/badge/Github%20Container%20Pulls-960-blue +[![Github Container Pulls](https://img.shields.io/badge/Github%20Container%20Pulls-1K-blue )](https://github.com/tbmc/sporteasy-calendar-connector/pkgs/container/sporteasy-calendar-connector) [![Docker Pulls](https://img.shields.io/docker/pulls/tbmc/sporteasy-calendar-connector)](https://hub.docker.com/r/tbmc/sporteasy-calendar-connector) @@ -71,10 +71,15 @@ You can use mine, but at your own risk. :warning: Data in base64 are not ciphered. `` -https://sporteasy-calendar-connector.tbmc.ovh?data={base64Data} +https://sporteasy-calendar-connector.tbmc.ovh/api?data={base64Data} +`` + +You can add a parameter `disable_save_login` to disable saving of logins and password, but it deactivates links in event description to set present or absent. Without saving logins, it can not connect to SportEasy servers. + +`` +https://sporteasy-calendar-connector.tbmc.ovh/api?data={base64Data}&disable_save_login=True `` ## Info SportEasy block IPs from server providers, so you should have a domestic IP. - diff --git a/app.py b/app.py index b2c83c1..fb19c38 100644 --- a/app.py +++ b/app.py @@ -5,9 +5,15 @@ import flask from flask import send_from_directory +from werkzeug.exceptions import BadRequestKeyError + from calendar_connector.calendar_converter import CalendarConverter +from calendar_connector.consts import route_change_presence, PRESENCE from calendar_connector.data_decoder import decode_data from calendar_connector.sporteasy_connector import SporteasyConnector +from calendar_connector.database.user import generate_links_data +from calendar_connector.custom_exceptions import BadTokenException +from calendar_connector.presence_updater import set_presence_to_event logging.basicConfig( format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", @@ -33,8 +39,12 @@ def request_handler() -> flask.Response: ip = flask.request.remote_addr logging.info(f"New incoming request from {ip=} and {username=}") + url_root = flask.request.url_root + disable_save_login = flask.request.args.get("disable_save_login") is not None calendar_converter = CalendarConverter() - calendar_text = calendar_converter.get_calendar_text(username, password, team_id) + calendar_text = calendar_converter.get_calendar_text( + username, password, not disable_save_login, url_root, team_id + ) return flask.Response( calendar_text, @@ -43,6 +53,26 @@ def request_handler() -> flask.Response: ) +@app.route(route_change_presence) +def change_my_presence() -> flask.Response: + try: + team_id = flask.request.args["team_id"] + event_id = flask.request.args["event_id"] + user_id = flask.request.args["user_id"] + token = flask.request.args["token"] + presence = flask.request.args["presence"].lower() == PRESENCE.present + except BadRequestKeyError as e: + return flask.Response("Parameter missing", status=500) + + hash_token = generate_links_data(team_id, event_id, user_id, presence) + if token != hash_token: + raise BadTokenException() + + set_presence_to_event(int(team_id), int(event_id), int(user_id), presence) + + return flask.send_file("calendar_connector/html/auto_close.html") + + @app.route("/api/list-teams") def list_teams() -> flask.Response: if flask.request.method == "OPTIONS": diff --git a/calendar_connector/calendar_converter.py b/calendar_connector/calendar_converter.py index fd861b5..ba70e7c 100644 --- a/calendar_connector/calendar_converter.py +++ b/calendar_connector/calendar_converter.py @@ -1,10 +1,14 @@ +from typing import Optional, cast + from icalendar import Calendar, vText +from calendar_connector.database.user import save_user from calendar_connector.datetime_utils import ( - get_current_datetime, + get_formated_current_time, ) from calendar_connector.env import load_env_data from calendar_connector.event_convertor import event_to_calendar_event +from calendar_connector.event_utils.description import GenerateLinksData from calendar_connector.sporteasy_connector import SporteasyConnector @@ -13,9 +17,50 @@ def __init__(self) -> None: self.connector = SporteasyConnector() def get_calendar_text( - self, username: str, password: str, team_id: str | None = None + self, + username: str, + password: str, + save_login: bool, + url_root: str, + team_id: Optional[str] = None, ) -> str: + """ + Retrieves the calendar text from SportEasy for the specified team. + + Args: + username (str): The username for the SportEasy account. + password (str): The password for the SportEasy account. + save_login (bool): If True, save the login credentials. + url_root (str): The base URL for the SportEasy API. + team_id (Optional[str], optional): The ID of the team to retrieve events for. Defaults to None. + + Returns: + str: The calendar text in iCalendar format. + + Raises: + Exception: If an error occurs while retrieving the calendar text. + + Example: + ```python + calendar_converter = CalendarConverter() + calendar_content = calendar_converter.get_calendar_text( + "example_username", "example_password", False, "http://localhost:5000/", "12345" + ) + ``` + """ self.connector.login(username, password) + + links_data: Optional[GenerateLinksData] = None + if save_login: + user = save_user(username, password) + links_data = GenerateLinksData( + cast(int, user.id), + cast(str, username), + cast(str, password), + cast(str, user.salt), + url_root, + ) + teams = self.connector.list_teams() cal = Calendar() @@ -28,12 +73,14 @@ def get_calendar_text( cal.add("x-wr-calname", "SportEasy Calendar") cal.add("x-wr-timezone", "Europe/Paris") - formatted_time = get_current_datetime().strftime("%Y-%m-%d %H:%M:%S") - cal.add("x-wr-caldesc", f"SportEasy Calendar | Last sync: {formatted_time}") + cal.add( + "x-wr-caldesc", + f"SportEasy Calendar | Last sync: {get_formated_current_time()}", + ) cal.add("REFRESH-INTERVAL;VALUE=DURATION", "PT8H") cal.add("X-PUBLISHED-TTL", "PT8H") - for current_team_id, team_name in teams: + for current_team_id, team_name, team_url in teams: # Ignore other teams if ( team_id is not None @@ -43,7 +90,11 @@ def get_calendar_text( continue events = self.connector.list_events(current_team_id) for event in events: - cal.add_component(event_to_calendar_event(team_name, event)) + cal.add_component( + event_to_calendar_event( + current_team_id, team_name, event, links_data, team_url + ) + ) text_calendar: str = cal.to_ical().decode("utf-8").strip() @@ -54,7 +105,9 @@ def main() -> None: username, password, team_id = load_env_data() calendar_converter = CalendarConverter() - calendar_content = calendar_converter.get_calendar_text(username, password, team_id) + calendar_content = calendar_converter.get_calendar_text( + username, password, False, "http://localhost:5000/", team_id + ) with open("./test.ics", "w", encoding="utf-8") as f: f.write(calendar_content) diff --git a/calendar_connector/consts.py b/calendar_connector/consts.py index 45b40a1..6dcbceb 100644 --- a/calendar_connector/consts.py +++ b/calendar_connector/consts.py @@ -1,10 +1,21 @@ from typing import Any +from collections import namedtuple import pytz -url_authenticate = "https://api.sporteasy.net/v2.1/account/authenticate/" -url_list_teams = "https://api.sporteasy.net/v2.1/me/teams/" -url_list_seasons = "https://api.sporteasy.net/v2.1/teams/{team_id}/seasons/" -url_list_events = "https://api.sporteasy.net/v2.1/teams/{team_id}/events/" +url_base = "https://api.sporteasy.net/v2.1" +url_authenticate = f"{url_base}/account/authenticate/" +url_csrf = f"{url_base}/account/csrf/" +url_me = f"{url_base}/me/" +url_list_teams = f"{url_base}/me/teams/" + +url_team_id_base = url_base + "/teams/{team_id}" +url_list_seasons = f"{url_team_id_base}/seasons/" +url_list_events = f"{url_team_id_base}/events/" + +url_event_base = url_team_id_base + "/events/{event_id}" +url_put_event_presence = url_event_base + "/profiles/{profile_id}/" + +route_change_presence = "/api/change_my_presence" PLAYED_WORDS = "played", "present", "available" EVENT_TYPE = dict[str, int | str | Any | list[Any] | dict[str, Any]] @@ -29,3 +40,6 @@ "unavailable": "CANCELLED", "not_played": "CANCELLED", } + +_presence_type = namedtuple("_presence_type", ["present", "absent"]) +PRESENCE = _presence_type("yes", "no") diff --git a/calendar_connector/cryptography.py b/calendar_connector/cryptography.py new file mode 100644 index 0000000..c5ef3db --- /dev/null +++ b/calendar_connector/cryptography.py @@ -0,0 +1,31 @@ +import string +import random +import hashlib + +from calendar_connector.consts import PRESENCE + +_alphabet = string.printable + + +def generate_salt() -> str: + chars: list[str] = [] + for i in range(random.randint(30, 50)): + chars.append(random.choice(_alphabet)) + return "".join(chars) + + +def generate_hash( + team_id: int | str, + event_id: int | str, + user_id: int | str, + username: str, + password: str, + salt: str, + presence: bool, +) -> str: + presence_str = PRESENCE.present if presence else PRESENCE.absent + to_hash = ( + f"{team_id}:{event_id}:{user_id}:{username}:{password}:{salt}:{presence_str}" + ) + m = hashlib.sha3_256(to_hash.encode("utf-8")) + return m.hexdigest() diff --git a/calendar_connector/custom_exceptions.py b/calendar_connector/custom_exceptions.py new file mode 100644 index 0000000..16db2da --- /dev/null +++ b/calendar_connector/custom_exceptions.py @@ -0,0 +1,19 @@ +from typing import Optional + + +class AttributeNotFoundException(Exception): + def __init__(self, name: str) -> None: + super().__init__(f"{name} is not found") + + +class BadTokenException(Exception): + def __init__(self) -> None: + super().__init__(f"Your token is not valid") + + +class TooManyUsersException(Exception): + def __init__(self, mail: str, n: Optional[int] = None) -> None: + super().__init__( + f'There are too many users with the same mail "{mail}", this should not happen.' + + ("" if n is None else f"Number of users {n}.") + ) diff --git a/calendar_connector/database/__init__.py b/calendar_connector/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/calendar_connector/database/all_models.py b/calendar_connector/database/all_models.py new file mode 100644 index 0000000..720dc55 --- /dev/null +++ b/calendar_connector/database/all_models.py @@ -0,0 +1,3 @@ +from calendar_connector.database.user import User + +ALL_MODELS = [User] diff --git a/calendar_connector/database/base_models.py b/calendar_connector/database/base_models.py new file mode 100644 index 0000000..744a04a --- /dev/null +++ b/calendar_connector/database/base_models.py @@ -0,0 +1,10 @@ +from peewee import Model + +from calendar_connector.database.db_connector import get_db + +db = get_db() + + +class BaseModel(Model): + class Meta: + database = db diff --git a/calendar_connector/database/create_tables.py b/calendar_connector/database/create_tables.py new file mode 100644 index 0000000..d471f1d --- /dev/null +++ b/calendar_connector/database/create_tables.py @@ -0,0 +1,12 @@ +from peewee import SqliteDatabase + +from calendar_connector.database.db_connector import get_db +from calendar_connector.database.all_models import ALL_MODELS + + +def create_db(db: SqliteDatabase) -> None: + db.create_tables(ALL_MODELS) + + +if __name__ == "__main__": + create_db(get_db()) diff --git a/calendar_connector/database/db_connector.py b/calendar_connector/database/db_connector.py new file mode 100644 index 0000000..a51a470 --- /dev/null +++ b/calendar_connector/database/db_connector.py @@ -0,0 +1,29 @@ +from pathlib import Path +from typing import Optional + +from peewee import SqliteDatabase + +from calendar_connector.utils import is_under_unittest + +_default_db_path = Path(__file__).parent.parent.parent / "database.db" + +_database: Optional[SqliteDatabase] = None + + +def get_db() -> SqliteDatabase: + global _database + + if _database is None: + _database = _initiate_db() + + return _database + + +def _initiate_db() -> SqliteDatabase: + if is_under_unittest(): + db = SqliteDatabase(":memory:") + else: + db = SqliteDatabase(_default_db_path) + + db.connect(reuse_if_open=True) + return db diff --git a/calendar_connector/database/user.py b/calendar_connector/database/user.py new file mode 100644 index 0000000..96adb56 --- /dev/null +++ b/calendar_connector/database/user.py @@ -0,0 +1,47 @@ +from typing import cast + +from peewee import PrimaryKeyField, CharField + +from calendar_connector.cryptography import generate_salt, generate_hash +from calendar_connector.custom_exceptions import TooManyUsersException +from calendar_connector.database.base_models import BaseModel + + +class User(BaseModel): + id = PrimaryKeyField() + username = CharField(unique=True, max_length=256, null=False) + password = CharField(max_length=256, null=False) + salt = CharField(max_length=51, null=False) + + +def save_user(username: str, password: str) -> User: + already_existing_users = list(User.select().where(User.username == username)) + + if len(already_existing_users) > 1: + raise TooManyUsersException(username, len(already_existing_users)) + + if len(already_existing_users) == 0: + user = User(username=username, password=password, salt=generate_salt()) + user.save() + return user + + user = already_existing_users[0] + if user.password != password: + user.password = password # type: ignore + user.save() + + return cast(User, user) + + +def get_username_password(user_id: int) -> tuple[str, str]: + user = User.select().where(User.id == user_id).get() + return user.username, user.password + + +def generate_links_data( + team_id: str, event_id: str, user_id: str, presence: bool +) -> str: + user = User.select().where(User.id == user_id).get() + return generate_hash( + team_id, event_id, user.id, user.username, user.password, user.salt, presence + ) diff --git a/calendar_connector/datetime_utils.py b/calendar_connector/datetime_utils.py index f45ecff..1707d09 100644 --- a/calendar_connector/datetime_utils.py +++ b/calendar_connector/datetime_utils.py @@ -8,3 +8,7 @@ def get_current_timestamp() -> int: def get_current_datetime() -> datetime.datetime: return datetime.datetime.now() + + +def get_formated_current_time() -> str: + return get_current_datetime().strftime("%Y-%m-%d %H:%M:%S") diff --git a/calendar_connector/event_convertor.py b/calendar_connector/event_convertor.py index 2c2b396..71ec847 100644 --- a/calendar_connector/event_convertor.py +++ b/calendar_connector/event_convertor.py @@ -1,24 +1,33 @@ from datetime import datetime -from typing import Any, cast +from typing import Any, cast, Optional from icalendar import Event from calendar_connector.consts import EVENT_TYPE from calendar_connector.datetime_utils import get_current_timestamp from calendar_connector.event_utils.date import extract_event_dates -from calendar_connector.event_utils.description import extract_event_description +from calendar_connector.event_utils.description import ( + extract_event_description, + GenerateLinksData, +) from calendar_connector.event_utils.location import extract_event_location from calendar_connector.event_utils.summary import extract_event_summary from calendar_connector.normalize import normalize -def event_to_calendar_event(team_name: str, event_data: EVENT_TYPE) -> Event: +def event_to_calendar_event( + team_id: int, + team_name: str, + event_data: EVENT_TYPE, + links_data: Optional[GenerateLinksData], + team_web_url: str, +) -> Event: event = Event() event.add("uid", str(event_data["id"]) + f"@sporteasy.net") extract_event_location(event_data, event) extract_event_dates(event_data, event) extract_event_summary(event_data, event, team_name) - extract_event_description(event_data, event) + extract_event_description(team_id, event_data, event, links_data, team_web_url) event.add("class", "PUBLIC") current_timestamp = get_current_timestamp() diff --git a/calendar_connector/event_utils/__init__.py b/calendar_connector/event_utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/calendar_connector/event_utils/description.py b/calendar_connector/event_utils/description.py index eb550f8..9244d6a 100644 --- a/calendar_connector/event_utils/description.py +++ b/calendar_connector/event_utils/description.py @@ -1,9 +1,26 @@ -from typing import cast +import dataclasses +from typing import cast, Optional from icalendar import Event -from calendar_connector.consts import EVENT_TYPE, ORDER_PRESENT +from calendar_connector.consts import ( + EVENT_TYPE, + ORDER_PRESENT, + route_change_presence, + PRESENCE, +) +from calendar_connector.datetime_utils import get_formated_current_time from calendar_connector.event_utils.score import extract_scores +from calendar_connector.cryptography import generate_hash + + +@dataclasses.dataclass +class GenerateLinksData: + user_id: int + username: str + password: str + salt: str + url_root: str def _extract_attendee_description(event_data: EVENT_TYPE) -> str: @@ -34,12 +51,65 @@ def _extract_attendee_description(event_data: EVENT_TYPE) -> str: return attendee -def extract_event_description(event_data: EVENT_TYPE, event: Event) -> None: +def _generate_response_links( + team_id: int, event_id: int, data: GenerateLinksData +) -> str: + hashed_present = generate_hash( + team_id, + event_id, + data.user_id, + data.username, + data.password, + data.salt, + True, + ) + hashed_absent = generate_hash( + team_id, + event_id, + data.user_id, + data.username, + data.password, + data.salt, + False, + ) + url = ( + f"{data.url_root}{route_change_presence[1:]}" + f"?team_id={team_id}&event_id={event_id}&user_id={data.user_id}" + ) + present = f'Present' + absent = ( + f'Absent' + ) + + return f"{present} | {absent}" + + +def _generate_link_to_sporteasy(team_web_url: str, event_data: EVENT_TYPE) -> str: + id_ = event_data["id"] + return f'SportEasy event' + + +def extract_event_description( + team_id: int, + event_data: EVENT_TYPE, + event: Event, + links_data: Optional[GenerateLinksData], + team_web_url: str, +) -> None: description = "" score = extract_scores(event_data) if score is not None: description += f"{score}\n" attendee = _extract_attendee_description(event_data) - description += attendee + description += f"{attendee}\n" + + if links_data: + event_id = cast(int, event_data["id"]) + response_links = _generate_response_links(team_id, event_id, links_data) + description += f"{response_links}\n" + + description += f"\n{_generate_link_to_sporteasy(team_web_url, event_data)}\n" + + description += f"\nLast sync: {get_formated_current_time()}\n" event.add("description", description.strip()) diff --git a/calendar_connector/event_utils/summary.py b/calendar_connector/event_utils/summary.py index 66bd35a..4f0b758 100644 --- a/calendar_connector/event_utils/summary.py +++ b/calendar_connector/event_utils/summary.py @@ -3,7 +3,7 @@ from icalendar import Event from calendar_connector.consts import EVENT_TYPE, MY_PRESENCE -from calendar_connector.exceptions import AttributeNotFoundException +from calendar_connector.custom_exceptions import AttributeNotFoundException from calendar_connector.normalize import normalize diff --git a/calendar_connector/exceptions.py b/calendar_connector/exceptions.py deleted file mode 100644 index f73db06..0000000 --- a/calendar_connector/exceptions.py +++ /dev/null @@ -1,3 +0,0 @@ -class AttributeNotFoundException(Exception): - def __init__(self, name: str) -> None: - super().__init__(f"{name} is not found") diff --git a/calendar_connector/html/auto_close.html b/calendar_connector/html/auto_close.html new file mode 100644 index 0000000..ecf2419 --- /dev/null +++ b/calendar_connector/html/auto_close.html @@ -0,0 +1,10 @@ + + +
+ + + + + \ No newline at end of file diff --git a/calendar_connector/presence_updater.py b/calendar_connector/presence_updater.py new file mode 100644 index 0000000..63717d3 --- /dev/null +++ b/calendar_connector/presence_updater.py @@ -0,0 +1,12 @@ +from calendar_connector.sporteasy_connector import SporteasyConnector +from calendar_connector.database.user import get_username_password + + +def set_presence_to_event( + team_id: int, event_id: int, user_id: int, presence: bool +) -> None: + username, password = get_username_password(user_id) + connector = SporteasyConnector() + connector.login(username, password) + + connector.put_presence_status(team_id, event_id, presence) diff --git a/calendar_connector/sporteasy_connector.py b/calendar_connector/sporteasy_connector.py index 605481f..372dbba 100644 --- a/calendar_connector/sporteasy_connector.py +++ b/calendar_connector/sporteasy_connector.py @@ -1,3 +1,6 @@ +from typing import cast +import collections + import requests from calendar_connector.consts import ( @@ -5,9 +8,14 @@ url_list_teams, EVENT_TYPE, url_list_events, + url_put_event_presence, + url_me, + url_csrf, ) from calendar_connector.normalize import normalize +team_namedtuple = collections.namedtuple("team_namedtuple", ["id", "name", "web_url"]) + class SporteasyConnector: def __init__(self) -> None: @@ -27,10 +35,13 @@ def login(self, username: str, password: str) -> str: # csrf = authenticate_response.cookies.get(csrf_name) return token - def list_teams(self) -> list[tuple[int, str]]: + def list_teams(self) -> list[team_namedtuple]: response = self.session_requests.get(url_list_teams) data = response.json() - return [(d["id"], normalize(d["name"])) for d in data["results"]] + return [ + team_namedtuple(d["id"], normalize(d["name"]), d["web_url"]) + for d in data["results"] + ] def list_events(self, team_id: int) -> list[EVENT_TYPE]: response = self.session_requests.get( @@ -39,3 +50,33 @@ def list_events(self, team_id: int) -> list[EVENT_TYPE]: ) data: list[EVENT_TYPE] = response.json()["results"] return data + + def get_profile_id(self) -> int: + response = self.session_requests.get(url_me) + profile_id = cast(int, response.json()["id"]) + return profile_id + + def get_csrf_token(self, team_id: int) -> tuple[str, str]: + response = self.session_requests.get(url_csrf) + csrf = response.json()["csrf_token"] + + teams = self.session_requests.get(url_list_teams).json()["results"] + current_team = [t for t in teams if t["id"] == team_id][0] + web_url = current_team["web_url"] # type: ignore + + return csrf, web_url + + def put_presence_status( + self, team_id: int, event_id: int, is_present: bool + ) -> None: + profile_id = self.get_profile_id() + csrf, web_url = self.get_csrf_token(team_id) + formatted_url = url_put_event_presence.format( + team_id=team_id, event_id=event_id, profile_id=profile_id + ) + result = self.session_requests.put( + formatted_url, + data={"attendance_status": "present" if is_present else "absent"}, + headers={"X-Csrftoken": csrf, "Referer": web_url}, + ) + # print(result) diff --git a/calendar_connector/utils.py b/calendar_connector/utils.py new file mode 100644 index 0000000..be3c3c9 --- /dev/null +++ b/calendar_connector/utils.py @@ -0,0 +1,5 @@ +import sys + + +def is_under_unittest() -> bool: + return "unittest" in sys.modules.keys() diff --git a/list_teams.py b/list_teams.py index b90f611..1d8cc71 100644 --- a/list_teams.py +++ b/list_teams.py @@ -7,11 +7,7 @@ teams = connector.list_teams() -team_list = [ - ("TEAM NAME", "TEAM ID"), -] + teams - -str_template = "{:^8} | {}" -print(str_template.format("TEAM ID", "TEAM NAME")) -for team_id, team_name in teams: - print(str_template.format(team_id, team_name)) +str_template = "{:^8} | {:15} | {}" +print(str_template.format("TEAM ID", "TEAM NAME", "URL")) +for team_id, team_name, team_url in teams: + print(str_template.format(team_id, team_name, team_url)) diff --git a/requirements.txt b/requirements.txt index 2b1a6bf..18ac2cb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,6 @@ pytz==2023.3 types-pytz==2023.3.0.0 Flask==2.3.2 gunicorn==20.1.0 +peewee==3.17.5 +types-peewee==3.17.3.20240424 +mypy==1.10.0 diff --git a/test/data/expected_calendar_with_links.ics b/test/data/expected_calendar_with_links.ics new file mode 100644 index 0000000..0b32643 --- /dev/null +++ b/test/data/expected_calendar_with_links.ics @@ -0,0 +1,55 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Sporteasy Calendar Connector +CALSCALE:GREGORIAN +METHOD:PUBLISH +REFRESH-INTERVAL;VALUE=DURATION:PT8H +SUMMARY:SportEasyCalendar +VTIMEZONE:UTC +X-PUBLISHED-TTL:PT8H +X-WR-CALDESC:SportEasy Calendar | Last sync: 2024-12-25 10:45:00 +X-WR-CALNAME:SportEasy Calendar +X-WR-TIMEZONE:Europe/Paris +BEGIN:VEVENT +SUMMARY:Equipe 1 - Tournoi 1 - En attente +DTSTART:20240123T070000Z +DTEND:20240123T160000Z +DTSTAMP:20240123T070000Z +UID:1@sporteasy.net +SEQUENCE:173512350 +CLASS:PUBLIC +CREATED:20200101T010101Z +DESCRIPTION:Présents: 8\, En attente: 7\, Non retenu: 0\, Absents: 2\nPresent | Absent\n\n + SportEasy event\n\nLa + st sync: 2024-12-25 10:45:00 +LAST-MODIFIED:20200101T010101Z +LOCATION:rue de la Paix\, 75000 Paris\, France +STATUS:TENTATIVE +TRANSP:OPAQUE +END:VEVENT +BEGIN:VEVENT +SUMMARY:Equipe 1 - Entrainement 2 +DTSTART:20240126T180000Z +DTEND:20240126T200000Z +DTSTAMP:20240126T180000Z +UID:2@sporteasy.net +SEQUENCE:173512350 +CLASS:PUBLIC +CREATED:20200101T010101Z +DESCRIPTION:Present | Absent\n\nSportEasy + event\n\nLast sync: 2024-12-25 10:45:00 +LAST-MODIFIED:20200101T010101Z +LOCATION:rue de la Paix\, 75000 Paris\, France +TRANSP:OPAQUE +END:VEVENT +END:VCALENDAR \ No newline at end of file diff --git a/test/data/expected_calendar.ics b/test/data/expected_calendar_without_links.ics similarity index 81% rename from test/data/expected_calendar.ics rename to test/data/expected_calendar_without_links.ics index b9ef671..d4eabe9 100644 --- a/test/data/expected_calendar.ics +++ b/test/data/expected_calendar_without_links.ics @@ -19,7 +19,9 @@ UID:1@sporteasy.net SEQUENCE:173512350 CLASS:PUBLIC CREATED:20200101T010101Z -DESCRIPTION:Présents: 8\, En attente: 7\, Non retenu: 0\, Absents: 2 +DESCRIPTION:Présents: 8\, En attente: 7\, Non retenu: 0\, Absents: 2\n\n< + a href="https://equipe1.sporteasy.net/event/1/">SportEasy event\n\nLas + t sync: 2024-12-25 10:45:00 LAST-MODIFIED:20200101T010101Z LOCATION:rue de la Paix\, 75000 Paris\, France STATUS:TENTATIVE @@ -34,7 +36,8 @@ UID:2@sporteasy.net SEQUENCE:173512350 CLASS:PUBLIC CREATED:20200101T010101Z -DESCRIPTION: +DESCRIPTION:SportEasy eve + nt\n\nLast sync: 2024-12-25 10:45:00 LAST-MODIFIED:20200101T010101Z LOCATION:rue de la Paix\, 75000 Paris\, France TRANSP:OPAQUE diff --git a/test/data/list_events.json b/test/data/list_events.json index a17483f..6580761 100644 --- a/test/data/list_events.json +++ b/test/data/list_events.json @@ -93,6 +93,7 @@ "registration_parameters": { "hide_attendance": false }, + "url": "https://sport", "_links": {} }, { @@ -147,4 +148,4 @@ "_links": {} } ] -} \ No newline at end of file +} diff --git a/test/data/list_teams.json b/test/data/list_teams.json index d84c058..4d87a0c 100644 --- a/test/data/list_teams.json +++ b/test/data/list_teams.json @@ -90,7 +90,8 @@ }, "has_deal_advertising": true, "total_unread_comments": 0, - "onboarding_info": null + "onboarding_info": null, + "web_url": "https://equipe1.sporteasy.net/" }, { "id": 2, @@ -181,7 +182,8 @@ }, "has_deal_advertising": true, "total_unread_comments": 1, - "onboarding_info": null + "onboarding_info": null, + "web_url": "https://equipe2.sporteasy.net/" } ] -} \ No newline at end of file +} diff --git a/test/test_get_calendar_text_with_links.py b/test/test_get_calendar_text_with_links.py new file mode 100644 index 0000000..7580d1e --- /dev/null +++ b/test/test_get_calendar_text_with_links.py @@ -0,0 +1,58 @@ +import datetime +import importlib +from unittest.mock import patch, MagicMock + +import requests_mock + +from calendar_connector.consts import url_authenticate, url_list_teams, url_list_events +import calendar_connector.calendar_converter +import calendar_connector.event_convertor +from calendar_connector.database.user import User + +from test.test_utils import read_text_by_name, replace_unwanted_lines + + +@patch( + "calendar_connector.datetime_utils.get_current_timestamp", return_value=173512350 +) +@patch( + "calendar_connector.datetime_utils.get_current_datetime", + return_value=datetime.datetime(2024, 12, 25, 10, 45, 0), +) +@patch( + "calendar_connector.database.user.save_user", + return_value=User(1, "username", "password", "salt"), +) +def test_get_calendar_text_with_links( + timestamp_mock: MagicMock, + datetime_mock: MagicMock, + save_user_mock: MagicMock, +) -> None: + importlib.reload(calendar_connector.calendar_converter) + importlib.reload(calendar_connector.event_convertor) + + mocked_response_teams = read_text_by_name("list_teams.json") + mocked_response_events = read_text_by_name("list_events.json") + + expected_calendar = replace_unwanted_lines( + read_text_by_name("expected_calendar_with_links.ics") + ) + + with requests_mock.Mocker() as request_mocker: + request_mocker.post( + url_authenticate, + status_code=200, + cookies={"sporteasy": "token test calendar"}, + ) + request_mocker.get(url_list_teams, text=mocked_response_teams) + request_mocker.get( + url_list_events.format(team_id=1), text=mocked_response_events + ) + + converter = calendar_connector.calendar_converter.CalendarConverter() + calendar_text = converter.get_calendar_text( + "username", "password", True, "http://localhost:5000/", "1" + ) + + result = replace_unwanted_lines(calendar_text) + assert result == expected_calendar diff --git a/test/test_get_calendar_text.py b/test/test_get_calendar_text_without_links.py similarity index 82% rename from test/test_get_calendar_text.py rename to test/test_get_calendar_text_without_links.py index 6b6408f..f390585 100644 --- a/test/test_get_calendar_text.py +++ b/test/test_get_calendar_text_without_links.py @@ -4,9 +4,12 @@ import requests_mock +import calendar_connector.database.user import calendar_connector.event_convertor import calendar_connector.calendar_converter +import calendar_connector.cryptography from calendar_connector.consts import url_list_events, url_list_teams, url_authenticate + from .test_utils import ( read_text_by_name, replace_unwanted_lines, @@ -20,7 +23,7 @@ "calendar_connector.datetime_utils.get_current_datetime", return_value=datetime.datetime(2024, 12, 25, 10, 45, 0), ) -def test_get_calendar_text( +def test_get_calendar_text_without_links( timestamp_mock: MagicMock, datetime_mock: MagicMock, ) -> None: @@ -31,7 +34,7 @@ def test_get_calendar_text( mocked_response_events = read_text_by_name("list_events.json") expected_calendar = replace_unwanted_lines( - read_text_by_name("expected_calendar.ics") + read_text_by_name("expected_calendar_without_links.ics") ) with requests_mock.Mocker() as request_mocker: @@ -46,7 +49,9 @@ def test_get_calendar_text( ) converter = calendar_connector.calendar_converter.CalendarConverter() - calendar_text = converter.get_calendar_text("username", "password", "1") + calendar_text = converter.get_calendar_text( + "username", "password", False, "http://localhost:5000/", "1" + ) result = replace_unwanted_lines(calendar_text) assert result == expected_calendar diff --git a/test/test_list_teams.py b/test/test_list_teams.py index 2d8cb6a..b5c2d9b 100644 --- a/test/test_list_teams.py +++ b/test/test_list_teams.py @@ -1,6 +1,6 @@ import requests_mock -from calendar_connector.sporteasy_connector import SporteasyConnector +from calendar_connector.sporteasy_connector import SporteasyConnector, team_namedtuple from test.test_utils import read_text_by_name from calendar_connector.calendar_converter import CalendarConverter from calendar_connector.consts import url_list_teams @@ -15,6 +15,6 @@ def test_list_teams() -> None: result = connector.list_teams() assert result == [ - (1, "Equipe 1"), - (2, "Equipe 2"), + team_namedtuple(1, "Equipe 1", "https://equipe1.sporteasy.net/"), + team_namedtuple(2, "Equipe 2", "https://equipe2.sporteasy.net/"), ] diff --git a/web-app/src/app.html b/web-app/src/app.html index 9030fd4..9300f85 100644 --- a/web-app/src/app.html +++ b/web-app/src/app.html @@ -1,20 +1,24 @@ - - - - - - %sveltekit.head% - - - - -
+
+
+
{$t('generateUrl.warning.repository')} Github repo @@ -67,6 +81,7 @@
{$t('generateUrl.warning.credentialsRequired')}