From 0938fb98b7f900ae81fbfefd7eae0625a0d52d1d Mon Sep 17 00:00:00 2001 From: Stanislas Jouffroy Date: Sun, 17 Mar 2024 23:57:50 +0100 Subject: [PATCH 1/7] Big refactoring. - clubs, booking platforms and user are now defined in customization files -> there are less environment variables - the responsibility of the session moved - booking cancellation is available --- booking.md | 44 + resa_padel/booking.py | 102 +- resa_padel/booking_service.py | 42 + resa_padel/config.py | 142 +- resa_padel/connectors.py | 598 ++++++++ resa_padel/gestion_sports/__init__.py | 0 .../gestion_sports/gestion_sports_config.py | 9 - .../gestion_sports_connector.py | 199 --- .../gestion_sports_html_parser.py | 16 - .../gestion_sports/gestion_sports_platform.py | 116 -- resa_padel/gestion_sports/payload_builders.py | 128 -- resa_padel/models.py | 175 ++- resa_padel/payload_builders.py | 12 + resa_padel/resources/clubs.yaml | 160 ++ .../booking-cancellation-payload.txt | 1 + .../gestion-sports/booking-payload.txt | 2 +- .../gestion-sports/login-payload.txt | 2 +- ...bookings.txt => user-bookings-payload.txt} | 0 resa_padel/resources/platforms.yaml | 19 + resa_padel/resources/users.yaml | 13 + tests/data/user_bookings_html_response.html | 1363 +++++++++++++++++ tests/fixtures.py | 25 +- .../test_gestion_sports_payload_builder.py | 3 +- tests/test_booking.py | 109 +- tests/test_config.py | 45 +- tests/test_connectors.py | 411 +++++ tests/utils.py | 10 +- 27 files changed, 3050 insertions(+), 696 deletions(-) create mode 100644 booking.md create mode 100644 resa_padel/booking_service.py create mode 100644 resa_padel/connectors.py delete mode 100644 resa_padel/gestion_sports/__init__.py delete mode 100644 resa_padel/gestion_sports/gestion_sports_config.py delete mode 100644 resa_padel/gestion_sports/gestion_sports_connector.py delete mode 100644 resa_padel/gestion_sports/gestion_sports_html_parser.py delete mode 100644 resa_padel/gestion_sports/gestion_sports_platform.py delete mode 100644 resa_padel/gestion_sports/payload_builders.py create mode 100644 resa_padel/payload_builders.py create mode 100644 resa_padel/resources/clubs.yaml create mode 100644 resa_padel/resources/gestion-sports/booking-cancellation-payload.txt rename resa_padel/resources/gestion-sports/{users_bookings.txt => user-bookings-payload.txt} (100%) create mode 100644 resa_padel/resources/platforms.yaml create mode 100644 resa_padel/resources/users.yaml create mode 100644 tests/data/user_bookings_html_response.html create mode 100644 tests/test_connectors.py diff --git a/booking.md b/booking.md new file mode 100644 index 0000000..7b3050c --- /dev/null +++ b/booking.md @@ -0,0 +1,44 @@ +MAIN: + +- Book court C of sport S at club X at time T for users U1,U2 + +* X.has_ongoing_bookings(U1) +* X.book(C, S, T, U1) + +- Cancel booking B at club X for user U1 + +* X.cancel(B, U1) + +- Get tournaments of sport S at club X + +* X.get_tournaments(S) + +Club: + +- Book court C of sport S at time T for users U1,U2 + +* new ClubUserSession + +- Cancel booking B for user U1 +- Has user U1 ongoing booking + +ClubConnector: + +- land +- login user U +- book court C of sport S at time T +- has ongoing bookings +- cancel booking B +- get tournaments + +UserSession + ++ user ++ session ++ connector + +- book + +* connector.land +* connector.login +* connector.land diff --git a/resa_padel/booking.py b/resa_padel/booking.py index 2a0b7ec..7ac2576 100644 --- a/resa_padel/booking.py +++ b/resa_padel/booking.py @@ -2,62 +2,86 @@ import asyncio import logging import config -from gestion_sports.gestion_sports_platform import GestionSportsPlatform -from models import BookingFilter, Club, User +from connectors import Connector, GestionSportsConnector +from models import Action, BookingFilter, Club, Court, User LOGGER = logging.getLogger(__name__) -async def book(club: Club, user: User, booking_filter: BookingFilter) -> int | None: +def get_connector(club: Club) -> Connector: + if club.booking_platform.id == "gestion-sports": + return GestionSportsConnector(club) + + +async def book_court( + club: Club, users: list[User], booking_filter: BookingFilter +) -> tuple[Court, User]: """ - Book a court for a user to a club following a booking filter + Book any court that meets the condition from the filter. IThe user that will make + the booking is chosen among a list of users and should not have any ongoing bookings - :param club: the club where to book a court - :param user: the user information - :param booking_filter: the information related to the booking - :return: the id of the booked court, or None if no court was booked + :param club: the club in which the booking will be made + :param users: the list of users who have an account in the club + :param booking_filter: the conditions the court to book should meet + :return: a tuple containing the court that was booked and the user who made the + booking """ - async with GestionSportsPlatform(club) as platform: - return await platform.book(user, booking_filter) + connector = get_connector(club) + for user in users: + if not await connector.has_user_ongoing_booking(user): + return await connector.book(user, booking_filter), user -async def get_user_without_booking(club: Club, users: list[User]) -> User | None: +async def cancel_booking(club: Club, user: User, booking_filter: BookingFilter) -> None: """ - Return the first user who has no booking + Cancel the booking that matches the specified filter - :param club: the club where to book - :param users: the list of users - :return: any user who has no booking + :param club: the club in which the booking was made + :param user: the user who made the booking + :param booking_filter: the conditions to meet to cancel the booking """ - async with GestionSportsPlatform(club) as platform: - for user in users: - if await platform.user_has_no_ongoing_booking(user): - return user - return None + connector = get_connector(club) + await connector.cancel_booking(user, booking_filter) -def main() -> int | None: +async def cancel_booking_id(club: Club, user: User, booking_id: int) -> None: + """ + Cancel a booking that matches the booking id + + :param club: the club in which the booking was made + :param user: the user who made the booking + :param booking_id: the id of the booking to cancel + """ + connector = get_connector(club) + await connector.cancel_booking_id(user, booking_id) + + +def main() -> tuple[Court, User] | None: """ Main function used to book a court :return: the id of the booked court, or None if no court was booked """ - booking_filter = config.get_booking_filter() - club = config.get_club() - user = asyncio.run(get_user_without_booking(club, config.get_available_users())) + action = config.get_action() - LOGGER.info( - "Starting booking court of sport %s for user %s at club %s at %s", - booking_filter.sport_id, - user.login, - club.id, - booking_filter.date, - ) - court_booked = asyncio.run(book(club, user, booking_filter)) - if court_booked: - LOGGER.info( - "Court %s booked successfully at %s", court_booked, booking_filter.date - ) - else: - LOGGER.info("Booking did not work") - return court_booked + if action == Action.BOOK: + club = config.get_club() + users = config.get_users(club.id) + booking_filter = config.get_booking_filter() + court_booked, user = asyncio.run(book_court(club, users, booking_filter)) + if court_booked: + LOGGER.info( + "Court %s booked successfully at %s for user %s", + court_booked, + booking_filter.date, + user, + ) + return court_booked, user + else: + LOGGER.info("Booking did not work") + + elif action == Action.CANCEL: + user = config.get_user() + club = config.get_club() + booking_filter = config.get_booking_filter() + asyncio.run(cancel_booking(club, user, booking_filter)) diff --git a/resa_padel/booking_service.py b/resa_padel/booking_service.py new file mode 100644 index 0000000..1c9c437 --- /dev/null +++ b/resa_padel/booking_service.py @@ -0,0 +1,42 @@ +import logging + +from aiohttp import ClientSession +from connectors import Connector +from models import BookingFilter, Club, User + +LOGGER = logging.getLogger(__name__) + + +class BookingService: + def __init__(self, club: Club, connector: Connector): + LOGGER.info("Initializing booking service at for club", club.name) + self.club = club + self.connector = connector + self.session: ClientSession | None = None + + async def __aenter__(self): + self.session = ClientSession() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.session.close() + + async def book(self, user: User, booking_filter: BookingFilter) -> int | None: + """ + Book a court matching the booking filters for a user. + The steps to perform a booking are to go to the landing page, to log in, wait + and for the time when booking is open and then actually book the court + + :param user: the user that wants to book a court + :param booking_filter: the booking criteria + :return: the court number if the booking is successful, None otherwise + """ + if self.connector is None: + LOGGER.error("No connection to Gestion Sports is available") + return None + + if user is None or booking_filter is None: + LOGGER.error("Not enough information available to book a court") + return None + + self.connector.book(user, booking_filter) diff --git a/resa_padel/config.py b/resa_padel/config.py index 749ea5e..a0069ae 100644 --- a/resa_padel/config.py +++ b/resa_padel/config.py @@ -6,36 +6,11 @@ from pathlib import Path import pendulum import yaml from dotenv import load_dotenv -from models import BookingFilter, Club, User +from models import Action, BookingFilter, Club, User load_dotenv() - -def get_club() -> Club: - """ - Read the environment variables related to the current club - and build the Club object - - :return: the club - """ - club_url = os.environ.get("CLUB_URL") - court_ids_tmp = os.environ.get("COURT_IDS") or "" - court_ids = ( - [int(court_id) for court_id in court_ids_tmp.split(",")] - if court_ids_tmp - else [] - ) - club_id = os.environ.get("CLUB_ID") - booking_open_days_before = int(os.environ.get("BOOKING_OPEN_DAYS_BEFORE", "7")) - booking_opening_time_str = os.environ.get("BOOKING_OPENING_TIME", "00:00") - booking_opening_time = pendulum.parse(booking_opening_time_str) - return Club( - id=club_id, - url=club_url, - courts_ids=court_ids, - booking_open_days_before=booking_open_days_before, - booking_opening_time=booking_opening_time.time(), - ) +ROOT_DIR = Path(__file__).parent def get_booking_filter() -> BookingFilter: @@ -45,11 +20,10 @@ def get_booking_filter() -> BookingFilter: :return: the club """ - sport_id_tmp = os.environ.get("SPORT_ID") - sport_id = int(sport_id_tmp) if sport_id_tmp else None + sport_name = os.environ.get("SPORT_NAME") date_time_tmp = os.environ.get("DATE_TIME") date_time = pendulum.parse(date_time_tmp) if date_time_tmp else None - return BookingFilter(sport_id=sport_id, date=date_time) + return BookingFilter(sport_name=sport_name.lower(), date=date_time) def get_user() -> User: @@ -64,25 +38,6 @@ def get_user() -> User: return User(login=login, password=password) -def get_available_users() -> list[User]: - """ - Read the environment variables to get all the available users in order - to increase the chance of having a user with a free slot for a booking - - :return: the list of all users that can book a court - """ - available_users_credentials = os.environ.get("AVAILABLE_USERS_CREDENTIALS") - available_users = [ - credential for credential in available_users_credentials.split(",") - ] - users = [] - for user in available_users: - login, password = user.split(":") - users.append(User(login=login, password=password)) - - return users - - def get_post_headers(platform_id: str) -> dict: """ Get the headers for the POST endpoint related to a specific booking platform @@ -102,13 +57,90 @@ def init_log_config(): """ Read the logging.yaml file to initialize the logging configuration """ - root_dir = os.path.realpath(os.path.dirname(__file__)) - logging_file = root_dir + "/logging.yaml" + logging_file = ROOT_DIR / "logging.yaml" - with open(logging_file, "r") as f: + with logging_file.open(mode="r", encoding="utf-8") as f: logging_config = yaml.safe_load(f.read()) - logging.config.dictConfig(logging_config) + + logging.config.dictConfig(logging_config) -ROOT_PATH = Path(__file__).parent.resolve() -RESOURCES_DIR = Path(ROOT_PATH, "resources") +def _build_urls(platform_urls: dict) -> dict: + return {url["name"]: url for url in platform_urls} + + +def _read_clubs(platforms_data: dict, clubs_data: dict) -> dict[str, Club]: + platforms = {platform["id"]: platform for platform in platforms_data} + + for club in clubs_data["clubs"]: + club_platform = club["bookingPlatform"] + platform_id = club_platform["id"] + club_platform["urls"] = _build_urls(platforms[platform_id]["urls"]) + return {club["id"]: Club(**club) for club in clubs_data["clubs"]} + + +def get_clubs(): + platforms_file = ROOT_DIR / "resources" / "platforms.yaml" + with platforms_file.open(mode="r", encoding="utf-8") as fp: + platforms_data = yaml.safe_load(fp) + + clubs_file = ROOT_DIR / "resources" / "clubs.yaml" + with clubs_file.open(mode="r", encoding="utf-8") as fp: + clubs_data = yaml.safe_load(fp) + + return _read_clubs(platforms_data["platforms"], clubs_data) + + +def get_club() -> Club: + """ + Get the club from an environment variable + + :return: the club + """ + club_id = os.environ.get("CLUB_ID") + clubs = get_clubs() + return clubs[club_id] + + +def read_users(data: dict, club_id: str) -> list[User]: + """ + Deserialize users + + :param data: the dictionnary of users + :param club_id: the club id + :return: a list if users from the club + """ + for club in data.get("clubs"): + if club.get("id") == club_id: + return [User(**user) for user in club.get("users")] + + +def get_users(club_id: str) -> list[User]: + """ + Get a list of users from a club + + :param club_id: the club to which the users should have an account + :return: the list of all users for that club + """ + users_file = ROOT_DIR / "resources" / "users.yaml" + with users_file.open(mode="r", encoding="utf-8") as fp: + data = yaml.safe_load(fp) + + return read_users(data, club_id) + + +def get_resources_folder() -> Path: + """ + Compute the path to the resources used by the program + :return: the path to the resources folder + """ + default_resources_folder = Path(__file__).parent / "resources" + return Path(os.environ.get("RESOURCES_FOLDER", default_resources_folder)) + + +def get_action() -> Action: + """ + Get the action to perform from an environment variable + :return: the action to perform + """ + return Action(os.environ.get("ACTION")) diff --git a/resa_padel/connectors.py b/resa_padel/connectors.py new file mode 100644 index 0000000..c36149e --- /dev/null +++ b/resa_padel/connectors.py @@ -0,0 +1,598 @@ +import asyncio +import json +import logging +import time +from abc import ABC, abstractmethod +from pathlib import Path +from urllib.parse import urljoin + +import config +import pendulum +from aiohttp import ClientResponse, ClientSession +from bs4 import BeautifulSoup +from models import Booking, BookingFilter, Club, Court, Sport, User +from payload_builders import PayloadBuilder +from pendulum import DateTime + +LOGGER = logging.getLogger(__name__) + +POST_HEADERS = config.get_post_headers("gestion-sports") + + +class Connector(ABC): + """ + Abstract class that defines the method a connector + to a website for sport booking should have + """ + + @abstractmethod + async def book(self, user: User, booking_filter: BookingFilter) -> Court | None: + """ + Book a court matching the filter for a user + + :param user: the user who will have the booking + :param booking_filter: the conditions to book (date, time, court) + :return: the court booked + """ + pass + + @abstractmethod + async def has_user_ongoing_booking(self, user: User) -> bool: + """ + Test whether the user has ongoing bookings + + :param user: the user who will have the booking + :return: true if the user has at least one ongoing booking, false otherwise + """ + pass + + @abstractmethod + async def cancel_booking_id(self, user: User, booking_id: int) -> None: + """ + Cancel the booking for a given user + + :param user: the user who has the booking + :param booking_id: the id of the booking + """ + pass + + @abstractmethod + async def cancel_booking(self, user: User, booking_filter: BookingFilter) -> None: + """ + Cancel the booking that meet some conditions for a given user + + :param user: the user who has the booking + :param booking_filter: the booking conditions to meet to cancel the booking + """ + pass + + +class GestionSportsConnector(Connector): + """ + The connector for the Gestion Sports platform. + It handles all the requests to the website. + """ + + def __init__(self, club: Club): + if club is None: + raise ValueError("A connector cannot be instantiated without a club") + if club.booking_platform.id != "gestion-sports": + raise ValueError( + "Gestion Sports connector was instantiated with a club not handled" + " by gestions sports. Club id is {} instead of gestion-sports".format( + club.club_id + ) + ) + + self.club = club + + def _get_url_path(self, name: str) -> str: + """ + Get the URL path for the service with the given name + + :param name: the name of the service + :return: the URL path + """ + self._check_url_path_exists(name) + + return urljoin( + self.club.booking_platform.url, + self.club.booking_platform.urls.get(name).path, + ) + + def _get_payload_template(self, name: str) -> Path: + """ + Get the path to the template file for the service with the given name + + :param name: the name of the service + :return: the path to the template file + """ + self._check_payload_template_exists(name) + + return ( + config.get_resources_folder() + / self.club.booking_platform.urls.get(name).payload_template + ) + + def _check_url_path_exists(self, name: str) -> None: + """ + Check that the URL path for the given service is defined + + :param name: the name of the service + """ + if ( + self.club.booking_platform.urls is None + or self.club.booking_platform.urls.get(name) is None + or self.club.booking_platform.urls.get(name).path is None + ): + raise ValueError( + f"The booking platform internal URL path for page {name} of club " + f"{self.club.name} are not set" + ) + + def _check_payload_template_exists(self, name: str) -> None: + """ + Check that the payload template for the given service is defined + + :param name: the name of the service + """ + if ( + self.club.booking_platform.urls is None + or self.club.booking_platform.urls.get(name) is None + or self.club.booking_platform.urls.get(name).path is None + ): + raise ValueError( + f"The booking platform internal URL path for page {name} of club " + f"{self.club.name} are not set" + ) + + @property + def landing_url(self) -> str: + """ + Get the URL to the landing page of Gestion-Sports + + :return: the URL to the landing page + """ + return self._get_url_path("landing-page") + + @property + def login_url(self) -> str: + """ + Get the URL to the connection login of Gestion-Sports + + :return: the URL to the login page + """ + return self._get_url_path("login") + + @property + def login_template(self) -> Path: + """ + Get the payload template to send to log in the website + + :return: the payload template for logging in + """ + return self._get_payload_template("login") + + @property + def booking_url(self) -> str: + """ + Get the URL to the booking page of Gestion-Sports + + :return: the URL to the booking page + """ + return self._get_url_path("booking") + + @property + def booking_template(self) -> Path: + """ + Get the payload template to send to book a court + + :return: the payload template for booking a court + """ + return self._get_payload_template("booking") + + @property + def user_bookings_url(self) -> str: + """ + Get the URL where all the user's bookings are available + + :return: the URL to the user's bookings + """ + return self._get_url_path("user-bookings") + + @property + def user_bookings_template(self) -> Path: + """ + Get the payload template to send to get all the user's bookings that are + available + + :return: the payload template for the user's bookings + """ + return self._get_payload_template("user-bookings") + + @property + def booking_cancellation_url(self) -> str: + """ + Get the URL where all the user's bookings are available + + :return: the URL to the user's bookings + """ + return self._get_url_path("cancellation") + + @property + def booking_cancellation_template(self) -> Path: + """ + Get the payload template to send to get all the user's bookings that are + available + + :return: the payload template for the user's bookings + """ + return self._get_payload_template("cancellation") + + @property + def available_sports(self) -> dict[str, Sport]: + """ + Get a dictionary of all sports, the key is the sport name lowered case + :return: the dictionary of all sports + """ + return { + sport.name.lower(): sport for sport in self.club.booking_platform.sports + } + + async def land(self, session: ClientSession) -> ClientResponse: + """ + Perform the request to the landing page in order to get the cookie PHPSESSIONID + + :return: the response from the landing page + """ + LOGGER.info("Connecting to GestionSports API at %s", self.login_url) + async with session.get(self.landing_url) as response: + await response.text() + return response + + async def login(self, session: ClientSession, user: User) -> ClientResponse: + """ + Perform the request to the log in the user + + :return: the response from the login + """ + LOGGER.info("Logging in to GestionSports API at %s", self.login_url) + payload = PayloadBuilder.build(self.login_template, user=user, club=self.club) + + async with session.post( + self.login_url, data=payload, headers=POST_HEADERS + ) as response: + resp_text = await response.text() + LOGGER.debug("Connexion request response:\n%s", resp_text) + return response + + async def book(self, user: User, booking_filter: BookingFilter) -> Court | None: + """ + Perform a request for each court at the same time to increase the chances to get + a booking. + The gestion-sports backend does not allow several bookings at the same time + so there is no need to make each request one after the other + + :param user: the user that wants to book the court + :param booking_filter: the booking conditions to meet + :return: the booked court, or None if no court was booked + """ + LOGGER.info( + "Booking any available court from GestionSports API at %s", self.booking_url + ) + + sport = self.available_sports.get(booking_filter.sport_name) + + async with ClientSession() as session: + # use asyncio to request a booking on every court + # the gestion-sports backend is able to book only one court for a user + await self.land(session) + await self.login(session, user) + self.wait_until_booking_time(booking_filter) + bookings = await asyncio.gather( + *[ + self.send_booking_request( + session, booking_filter.date, court.id, sport.id + ) + for court in sport.courts + ], + return_exceptions=True, + ) + + LOGGER.debug("Booking results:\n'%s'", bookings) + return self.get_booked_court(bookings, sport.name) + + async def send_booking_request( + self, + session: ClientSession, + date: DateTime, + court_id: int, + sport_id: int, + ) -> tuple[ClientResponse, int, bool]: + """ + Book a single court that meets the conditions from the booking filter + + :param session: the HTTP session that contains the user information and cookies + :param date: the booking date + :param court_id: the id of the court to book + :param sport_id: the id of the sport + :return: a tuple containing the court id and the booking status + """ + LOGGER.debug("Booking court %s at %s", court_id, date.to_w3c_string()) + payload = PayloadBuilder.build( + self.booking_template, + date=date, + court_id=court_id, + sport_id=sport_id, + ) + + LOGGER.debug("Booking court %s request:\n'%s'", court_id, payload) + + async with session.post( + self.booking_url, data=payload, headers=POST_HEADERS + ) as response: + assert response.status == 200 + resp_json = await response.text() + LOGGER.debug("Response from booking request:\n'%s'", resp_json) + return response, court_id, self.is_booking_response_status_ok(resp_json) + + def get_booked_court( + self, bookings: list[tuple[ClientSession, int, bool]], sport_name: str + ) -> Court | None: + """ + Parse the booking list and return the court that was booked + + :param bookings: a list of bookings + :param sport_name: the sport name + :return: the id of the booked court if any, None otherwise + """ + for _, court_id, is_booked in bookings: + if is_booked: + LOGGER.debug("Court %d is booked", court_id) + court_booked = self.find_court(court_id, sport_name) + LOGGER.info("Court '%s' is booked", court_booked.name) + return court_booked + LOGGER.debug("No booked court found") + return None + + def find_court(self, court_id: int, sport_name: str) -> Court: + """ + Get all the court information based on the court id and the sport name + + :param court_id: the court id + :param sport_name: the sport name + :return: the court that has the given id and sport name + """ + sport = self.available_sports.get(sport_name.lower()) + for court in sport.courts: + if court.id == court_id: + return court + + @staticmethod + def is_booking_response_status_ok(response: str) -> bool: + """ + Check if the booking response is OK + + :param response: the response as a string + :return: true if the status is ok, false otherwise + """ + formatted_result = response.removeprefix('"').removesuffix('"') + result_json = json.loads(formatted_result) + return result_json["status"] == "ok" + + def build_booking_datetime(self, booking_filter: BookingFilter) -> DateTime: + """ + Build the date and time when the booking is open for a given match date. + The booking filter contains the date and time of the booking. + The club has the information about when the booking is open for that date. + + :param booking_filter: the booking information + :return: the date and time when the booking is open + """ + date_to_book = booking_filter.date + booking_opening = self.club.booking_platform.booking_opening + booking_date = date_to_book.subtract(days=booking_opening.days_before) + + opening_time = pendulum.parse(booking_opening.opening_time) + booking_hour = opening_time.hour + booking_minute = opening_time.minute + + return booking_date.at(booking_hour, booking_minute) + + def wait_until_booking_time(self, booking_filter: BookingFilter) -> None: + """ + Wait until the booking is open. + The booking filter contains the date and time of the booking. + The club has the information about when the booking is open for that date. + + :param booking_filter: the booking information + """ + LOGGER.info("Waiting for booking time") + booking_datetime = self.build_booking_datetime(booking_filter) + now = pendulum.now() + duration_until_booking = booking_datetime - now + LOGGER.debug(f"Current time: {now}, Datetime to book: {booking_datetime}") + LOGGER.debug( + f"Time to wait before booking: {duration_until_booking.hours:0>2}" + f":{duration_until_booking.minutes:0>2}" + f":{duration_until_booking.seconds:0>2}" + ) + + while now < booking_datetime: + time.sleep(1) + now = pendulum.now() + LOGGER.info("It's booking time!") + + async def has_user_ongoing_booking(self, user: User) -> bool: + """ + Check if the user currently has bookings in the future + :param user: the user to check the bookings + :return: true if the user has some bookings, false otherwise + """ + async with ClientSession() as session: + await self.land(session) + await self.login(session, user) + return bool(await self.get_ongoing_bookings(session)) + + async def get_ongoing_bookings(self, session: ClientSession) -> list[Booking]: + """ + Get the list of all ongoing bookings of a user. + The steps to perform this are to get the user's bookings page and get a hidden + property in the HTML to get a hash that will be used in the payload of the + POST request (sic) to get the user's bookings. + Gestion sports is really a mess!! + + :return: the list of all ongoing bookings of a user + """ + hash_value = await self.send_hash_request(session) + LOGGER.debug(f"Hash value: {hash_value}") + payload = PayloadBuilder.build(self.user_bookings_template, hash=hash_value) + LOGGER.debug(f"Payload to get ongoing bookings: {payload}") + return await self.send_user_bookings_request(session, payload) + + async def send_hash_request(self, session: ClientSession) -> str: + """ + Get the hash value used in the request to get the user's bookings + + :param session: the session in which the user logged in + :return: the value of the hash + """ + async with session.get(self.user_bookings_url) as response: + html = await response.text() + LOGGER.debug("Get bookings response: %s\n", html) + return self.get_hash_input(html) + + @staticmethod + def get_hash_input(html_doc: str) -> str: + """ + There is a secret hash generated by Gestion sports that is reused when trying to get + users bookings. This hash is stored in a hidden input with name "mesresas-hash" + + :param html_doc: the html document when getting the page mes-resas.html + :return: the value of the hash in the page + """ + soup = BeautifulSoup(html_doc, "html.parser") + inputs = soup.find_all("input") + for input_tag in inputs: + if input_tag.get("name") == "mesresas-hash": + return input_tag.get("value").strip() + + async def send_user_bookings_request( + self, session: ClientSession, payload: str + ) -> list[Booking]: + """ + Perform the HTTP request to get all bookings + + :param session: the session in which the user logged in + :param payload: the HTTP payload for the request + :return: a dictionary containing all the bookings + """ + async with session.post( + self.user_bookings_url, data=payload, headers=POST_HEADERS + ) as response: + resp = await response.text() + LOGGER.debug("ongoing bookings response: %s\n", resp) + return [Booking(**booking) for booking in json.loads(resp)] + + async def cancel_booking_id(self, user: User, booking_id: int) -> ClientResponse: + """ + Cancel a booking based on its id for a given user + + :param user: the user that has the booking + :param booking_id: the id of the booking to cancel + :return: the response from the client + """ + async with ClientSession() as session: + await self.land(session) + await self.login(session, user) + + return await self.send_cancellation_request(session, booking_id) + + async def send_cancellation_request( + self, session: ClientSession, booking_id: int + ) -> ClientResponse: + """ + Send the HTTP request to cancel the booking + + :param session: the HTTP session that contains the user information and cookies + :param booking_id: the id of the booking to cancel + :return: the response from the client + """ + hash_value = await self.send_hash_request(session) + payload = PayloadBuilder.build( + self.booking_cancellation_template, + booking_id=booking_id, + hash=hash_value, + ) + + async with session.post( + self.booking_cancellation_url, data=payload, headers=POST_HEADERS + ) as response: + await response.text() + return response + + async def cancel_booking(self, user: User, booking_filter: BookingFilter) -> None: + """ + Cancel the booking that meets some conditions + + :param user: the user who owns the booking + :param booking_filter: the conditions the booking to cancel should meet + """ + async with ClientSession() as session: + await self.land(session) + await self.login(session, user) + + bookings = await self.get_ongoing_bookings(session) + + for booking in bookings: + if self.is_booking_matching_filter(booking, booking_filter): + await self.send_cancellation_request(session, booking.id) + + def is_booking_matching_filter( + self, booking: Booking, booking_filter: BookingFilter + ) -> bool: + """ + Check if the booking matches the booking filter + + :param booking: the booking to be checked + :param booking_filter: the conditions the booking should meet + :return: true if the booking matches the conditions, false otherwise + """ + return ( + self._is_same_sport(booking, booking_filter) + and self._is_date_matching(booking, booking_filter) + and self._is_time_matching(booking, booking_filter) + ) + + @staticmethod + def _is_same_sport(booking: Booking, booking_filter: BookingFilter) -> bool: + """ + Check if the booking and the booking filter are about the same sport + + :param booking: the booking to be checked + :param booking_filter: the conditions the booking should meet + :return: true if the booking sport matches the filter sport, false otherwise + """ + return booking.sport == booking_filter.sport_name + + @staticmethod + def _is_date_matching(booking: Booking, booking_filter: BookingFilter) -> bool: + """ + Check if the booking and the booking filter are at the same date + + :param booking: the booking to be checked + :param booking_filter: the conditions the booking should meet + :return: true if the booking date matches the filter date, false otherwise + """ + return booking.booking_date.date() == booking_filter.date.date() + + @staticmethod + def _is_time_matching(booking: Booking, booking_filter: BookingFilter) -> bool: + """ + Check if the booking and the booking filter are at the same time + + :param booking: the booking to be checked + :param booking_filter: the conditions the booking should meet + :return: true if the booking time matches the filter time, false otherwise + """ + return booking.start_time.time() == booking_filter.date.time() diff --git a/resa_padel/gestion_sports/__init__.py b/resa_padel/gestion_sports/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/resa_padel/gestion_sports/gestion_sports_config.py b/resa_padel/gestion_sports/gestion_sports_config.py deleted file mode 100644 index ea5b852..0000000 --- a/resa_padel/gestion_sports/gestion_sports_config.py +++ /dev/null @@ -1,9 +0,0 @@ -from pathlib import Path - -import config - -RESOURCES_DIR = Path(config.RESOURCES_DIR, "gestion-sports") - -BOOKING_TEMPLATE = Path(RESOURCES_DIR, "booking-payload.txt") -LOGIN_TEMPLATE = Path(RESOURCES_DIR, "login-payload.txt") -USERS_BOOKINGS_TEMPLATE = Path(RESOURCES_DIR, "users_bookings.txt") diff --git a/resa_padel/gestion_sports/gestion_sports_connector.py b/resa_padel/gestion_sports/gestion_sports_connector.py deleted file mode 100644 index de5dca3..0000000 --- a/resa_padel/gestion_sports/gestion_sports_connector.py +++ /dev/null @@ -1,199 +0,0 @@ -import asyncio -import json -import logging -from urllib.parse import urljoin - -import config -from aiohttp import ClientResponse, ClientSession -from gestion_sports import gestion_sports_html_parser as html_parser -from gestion_sports.payload_builders import ( - GestionSportsBookingPayloadBuilder, - GestionSportsLoginPayloadBuilder, - GestionSportsUsersBookingsPayloadBuilder, -) -from models import BookingFilter, Club, User - -LOGGER = logging.getLogger(__name__) -POST_HEADERS = config.get_post_headers("gestion-sports") - - -class GestionSportsConnector: - """ - Handle the specific booking requests to Gestion-Sports - """ - - def __init__(self, session: ClientSession, url: str): - LOGGER.info("Initializing connection to GestionSports API") - self.url = url - self.session = session - - @property - def landing_url(self) -> str: - """ - Get the URL to the landing page of Gestion-Sports - - :return: the URL to the landing page - """ - return urljoin(self.url, "/connexion.php") - - @property - def login_url(self) -> str: - """ - Get the URL to the connection login of Gestion-Sports - - :return: the URL to the login page - """ - return urljoin(self.url, "/connexion.php") - - @property - def booking_url(self) -> str: - """ - Get the URL to the booking page of Gestion-Sports - - :return: the URL to the booking page - """ - return urljoin(self.url, "/membre/reservation.html") - - @property - def user_bookings_url(self) -> str: - """ - Get the URL where all the user's bookings are available - - :return: the URL to the user's bookings - """ - return urljoin(self.url, "/membre/mesresas.html") - - async def land(self) -> ClientResponse: - """ - Perform the request to the landing page in order to get the cookie PHPSESSIONID - - :return: the response from the landing page - """ - LOGGER.info("Connecting to GestionSports API at %s", self.login_url) - async with self.session.get(self.landing_url) as response: - await response.text() - return response - - async def login(self, user: User, club: Club) -> ClientResponse: - """ - Perform the request to the log in the user - - :return: the response from the login - """ - LOGGER.info("Logging in to GestionSports API at %s", self.login_url) - payload_builder = GestionSportsLoginPayloadBuilder() - payload = payload_builder.user(user).club(club).build() - - async with self.session.post( - self.login_url, data=payload, headers=POST_HEADERS - ) as response: - resp_text = await response.text() - LOGGER.debug("Connexion request response:\n%s", resp_text) - return response - - async def book(self, booking_filter: BookingFilter, club: Club) -> int | None: - """ - Perform a request for each court at the same time to increase the chances to get - a booking. - The gestion-sports backend does not allow several bookings at the same time - so there is no need to make each request one after the other - - :param booking_filter: the booking information - :param club: the club where to book the court - :return: the booked court, or None if no court was booked - """ - LOGGER.info( - "Booking any available court from GestionSports API at %s", self.booking_url - ) - # use asyncio to request a booking on every court - # the gestion-sports backend is able to book only one court for a user - bookings = await asyncio.gather( - *[ - self.book_one_court(booking_filter, court_id) - for court_id in club.courts_ids - ], - return_exceptions=True, - ) - - LOGGER.debug("Booking results:\n'%s'", bookings) - return self.get_booked_court(bookings) - - async def book_one_court( - self, booking_filter: BookingFilter, court_id: int - ) -> tuple[int, bool]: - """ - Book a single court according to the information provided in the booking filter - - :param booking_filter: the booking information - :param court_id: the id of the court to book - :return: a tuple containing the court id and the booking status - """ - LOGGER.debug( - "Booking court %s at %s", - court_id, - booking_filter.date.to_w3c_string(), - ) - payload_builder = GestionSportsBookingPayloadBuilder() - payload = ( - payload_builder.booking_filter(booking_filter).court_id(court_id).build() - ) - LOGGER.debug("Booking court %s request:\n'%s'", court_id, payload) - - async with self.session.post( - self.booking_url, data=payload, headers=POST_HEADERS - ) as response: - resp_json = await response.text() - LOGGER.debug("Response from booking request:\n'%s'", resp_json) - return court_id, self.is_booking_response_status_ok(resp_json) - - @staticmethod - def get_booked_court(bookings: list[tuple[int, bool]]) -> int | None: - """ - Parse the booking list and return the court that was booked - - :param bookings: a list of bookings - :return: the id of the booked court if any, None otherwise - """ - for court, is_booked in bookings: - if is_booked: - LOGGER.debug("Court %s is booked", court) - return court - LOGGER.debug("No booked court found") - return None - - @staticmethod - def is_booking_response_status_ok(response: str) -> bool: - """ - Check if the booking response is OK - - :param response: the response as a string - :return: true if the status is ok, false otherwise - """ - formatted_result = response.removeprefix('"').removesuffix('"') - result_json = json.loads(formatted_result) - return result_json["status"] == "ok" - - async def get_ongoing_bookings(self) -> dict: - """ - Get the list of all ongoing bookings of a user. - The steps to perform this are to get the user's bookings page and get a hidden - property in the HTML to get a hash that will be used in the payload of the - POST request (sic) to get the user's bookings. - Gestion sports is really a mess!! - - :return: the list of all ongoing bookings of a user - """ - async with self.session.get(self.user_bookings_url) as get_resp: - html = await get_resp.text() - hash_value = html_parser.get_hash_input(html) - - payload_builder = GestionSportsUsersBookingsPayloadBuilder() - payload_builder.hash(hash_value) - payload = payload_builder.build() - - async with self.session.post( - self.user_bookings_url, data=payload, headers=POST_HEADERS - ) as response: - resp = await response.text() - LOGGER.debug("ongoing bookings response: %s\n", resp) - return json.loads(resp) diff --git a/resa_padel/gestion_sports/gestion_sports_html_parser.py b/resa_padel/gestion_sports/gestion_sports_html_parser.py deleted file mode 100644 index ed7f12a..0000000 --- a/resa_padel/gestion_sports/gestion_sports_html_parser.py +++ /dev/null @@ -1,16 +0,0 @@ -from bs4 import BeautifulSoup - - -def get_hash_input(html_doc: str) -> str: - """ - There is a secret hash generated by Gestion sports that is reused when trying to get - users bookings. This hash is stored in a hidden input with name "mesresas-hash" - - :param html_doc: the html document when getting the page mes-resas.html - :return: the value of the hash in the page - """ - soup = BeautifulSoup(html_doc, "html.parser") - inputs = soup.find_all("input") - for input_tag in inputs: - if input_tag.get("name") == "mesresas-hash": - return input_tag.get("value").strip() diff --git a/resa_padel/gestion_sports/gestion_sports_platform.py b/resa_padel/gestion_sports/gestion_sports_platform.py deleted file mode 100644 index 35679ce..0000000 --- a/resa_padel/gestion_sports/gestion_sports_platform.py +++ /dev/null @@ -1,116 +0,0 @@ -import logging -import time - -import pendulum -from aiohttp import ClientSession -from gestion_sports.gestion_sports_connector import GestionSportsConnector -from models import BookingFilter, Club, User -from pendulum import DateTime - -LOGGER = logging.getLogger(__name__) - - -class GestionSportsPlatform: - def __init__(self, club: Club): - LOGGER.info("Initializing Gestion Sports platform at url %s", club.url) - self.connector: GestionSportsConnector | None = None - self.club: Club = club - self.session: ClientSession | None = None - - async def __aenter__(self): - self.session = ClientSession() - self.connector = GestionSportsConnector(self.session, self.club.url) - return self - - async def __aexit__(self, exc_type, exc_val, exc_tb): - await self.session.close() - - async def book(self, user: User, booking_filter: BookingFilter) -> int | None: - """ - Book a court matching the booking filters for a user. - The steps to perform a booking are to go to the landing page, to log in, wait - and for the time when booking is open and then actually book the court - - :param user: the user that wants to book a court - :param booking_filter: the booking criteria - :return: the court number if the booking is successful, None otherwise - """ - if self.connector is None: - LOGGER.error("No connection to Gestion Sports is available") - return None - - if user is None or booking_filter is None: - LOGGER.error("Not enough information available to book a court") - return None - - await self.connector.land() - await self.connector.login(user, self.club) - wait_until_booking_time(self.club, booking_filter) - return await self.connector.book(booking_filter, self.club) - - async def user_has_no_ongoing_booking(self, user: User) -> bool: - """ - Check if the user has any ongoing booking. - The steps to perform this task are to go to the landing page, to log in and - then retrieve user information and extract the ongoing bookings - - :param user: the user to check the bookings - :return: True if the user has ongoing bookings, false otherwise - """ - if self.connector is None: - LOGGER.error("No connection to Gestion Sports is available") - return False - - if user is None: - LOGGER.error("Not enough information available to book a court") - return False - - await self.connector.land() - await self.connector.login(user, self.club) - bookings = await self.connector.get_ongoing_bookings() - return bookings == [] - - -def wait_until_booking_time(club: Club, booking_filter: BookingFilter): - """ - Wait until the booking is open. - The booking filter contains the date and time of the booking. - The club has the information about when the booking is open for that date. - - :param club: the club where to book a court - :param booking_filter: the booking information - """ - LOGGER.info("Waiting for booking time") - booking_datetime = build_booking_datetime(booking_filter, club) - now = pendulum.now() - duration_until_booking = booking_datetime - now - LOGGER.debug( - "Time to wait before booking: %s:%s:%s", - "{:0>2}".format(duration_until_booking.hours), - "{:0>2}".format(duration_until_booking.minutes), - "{:0>2}".format(duration_until_booking.seconds), - ) - - while now < booking_datetime: - time.sleep(1) - now = pendulum.now() - LOGGER.info("It's booking time!") - - -def build_booking_datetime(booking_filter: BookingFilter, club: Club) -> DateTime: - """ - Build the date and time when the booking is open for a given match date. - The booking filter contains the date and time of the booking. - The club has the information about when the booking is open for that date. - - :param booking_filter: the booking information - :param club: the club where to book a court - :return: the date and time when the booking is open - """ - date_to_book = booking_filter.date - booking_date = date_to_book.subtract(days=club.booking_open_days_before) - - booking_hour = club.booking_opening_time.hour - booking_minute = club.booking_opening_time.minute - - return booking_date.at(booking_hour, booking_minute) diff --git a/resa_padel/gestion_sports/payload_builders.py b/resa_padel/gestion_sports/payload_builders.py deleted file mode 100644 index 1509a52..0000000 --- a/resa_padel/gestion_sports/payload_builders.py +++ /dev/null @@ -1,128 +0,0 @@ -from exceptions import ArgumentMissing -from gestion_sports.gestion_sports_config import ( - BOOKING_TEMPLATE, - LOGIN_TEMPLATE, - USERS_BOOKINGS_TEMPLATE, -) -from jinja2 import Environment, FileSystemLoader -from models import BookingFilter, Club, User - - -class GestionSportsLoginPayloadBuilder: - """ - Build the payload for the login page - """ - - def __init__(self): - self._user: User | None = None - self._club: Club | None = None - - def user(self, user: User): - """ - Set the user - - :param user: the user - :return: the class itself - """ - self._user = user - return self - - def club(self, club: Club): - """ - Set the club - - :param club: the club - :return: the class itself - """ - self._club = club - return self - - def build(self) -> str: - """ - Build the payload - - :return: the string representation of the payload - """ - if self._user is None: - raise ArgumentMissing("No user was provided") - if self._club is None: - raise ArgumentMissing("No club was provided") - - environment = Environment(loader=FileSystemLoader(LOGIN_TEMPLATE.parent)) - template = environment.get_template(LOGIN_TEMPLATE.name) - - return template.render(club=self._club, user=self._user) - - -class GestionSportsBookingPayloadBuilder: - def __init__(self): - self._booking_filter: BookingFilter | None = None - self._court_id: int | None = None - - def booking_filter(self, booking_filter: BookingFilter): - """ - Set the booking filter - - :param booking_filter: the booking filter - :return: the class itself - """ - self._booking_filter = booking_filter - return self - - def court_id(self, court_id: int): - """ - Set the court id - - :param court_id: the court id - :return: the class itself - """ - self._court_id = court_id - return self - - def build(self) -> str: - """ - Build the payload - - :return: the string representation of the payload - """ - if self._booking_filter is None: - raise ArgumentMissing("No booking filter was provided") - if self.court_id is None: - raise ArgumentMissing("No court id was provided") - - environment = Environment(loader=FileSystemLoader(BOOKING_TEMPLATE.parent)) - template = environment.get_template(BOOKING_TEMPLATE.name) - - return template.render( - court_id=self._court_id, booking_filter=self._booking_filter - ) - - -class GestionSportsUsersBookingsPayloadBuilder: - def __init__(self): - self._hash: str | None = None - - def hash(self, hash_value: str): - """ - Set the hash - - :param hash_value: the hash - :return: the class itself - """ - self._hash = hash_value - - def build(self) -> str: - """ - Build the payload - - :return: the string representation of the payload - """ - if self._hash is None: - raise ArgumentMissing("No hash was provided") - - environment = Environment( - loader=FileSystemLoader(USERS_BOOKINGS_TEMPLATE.parent) - ) - template = environment.get_template(USERS_BOOKINGS_TEMPLATE.name) - - return template.render(hash=self._hash) diff --git a/resa_padel/models.py b/resa_padel/models.py index 1857750..6045abd 100644 --- a/resa_padel/models.py +++ b/resa_padel/models.py @@ -1,22 +1,171 @@ -from pendulum import Time -from pydantic import BaseModel, ConfigDict, Field +from enum import Enum +from typing import Optional + +import pendulum +from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic_extra_types.pendulum_dt import DateTime -class Club(BaseModel): +class User(BaseModel): + login: str + password: str = Field(repr=False) + club_id: Optional[str] = Field(default=None) + + +class BookingOpening(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) - id: str = Field() - url: str = Field() - courts_ids: list[int] = Field(default_factory=list) - booking_open_days_before: int = Field(default=7) - booking_opening_time: Time = Field(default=Time(hour=0, minute=0)) + + days_before: Optional[int] = Field(default=7, alias="daysBefore") + opening_time: Optional[str] = Field(alias="time", default=None, repr=False) + time_after_booking: Optional[str] = Field( + alias="timeAfterBookingTime", default=None, repr=False + ) + + def __repr__(self): + base = super().__repr__() + time = f", time: {self.time})" if self.time else "" + time_after_booking = ( + f", time_after_booking_time: {self.time_after_booking_time})" + if self.time_after_booking_time + else "" + ) + + return base.removesuffix(")") + time + time_after_booking + + @property + def time(self): + return pendulum.parse(self.opening_time).time() + + @property + def time_after_booking_time(self): + return ( + pendulum.parse(self.time_after_booking).time() + if self.time_after_booking + else None + ) + + +class TotalBookings(BaseModel): + peak_hours: int | str = Field(alias="peakHours") + off_peak_hours: int | str = Field(alias="offPeakHours") + + +class Court(BaseModel): + id: int + name: str + number: int + is_indoor: Optional[bool] = Field(alias="isIndoor") + + +class Sport(BaseModel): + name: str + id: int + duration: int + price: int + players: int + courts: list[Court] + + +class Url(BaseModel): + name: str + path: str + payload_template: Optional[str] = Field(default=None, alias="payloadTemplate") + + +class BookingPlatform(BaseModel): + id: str + club_id: int = Field(alias="clubId") + url: str + hours_before_cancellation: int = Field(alias="hoursBeforeCancellation") + booking_opening: BookingOpening = Field(alias="bookingOpening") + total_bookings: TotalBookings = Field(alias="totalBookings") + sports: list[Sport] + urls: dict[str, Url] + + +class Club(BaseModel): + id: str + name: str + url: str + booking_platform: BookingPlatform = Field(alias="bookingPlatform") + + +class PlatformDefinition(BaseModel): + id: str + name: str + url: str + urls: list[Url] class BookingFilter(BaseModel): - sport_id: int = Field() - date: DateTime = Field() + date: DateTime + sport_name: str + + @field_validator("sport_name", mode="before") + @classmethod + def to_lower_case(cls, d: str) -> str: + return d.lower() -class User(BaseModel): - login: str = Field() - password: str = Field(repr=False) +class Booking(BaseModel): + id: int + booking_date: DateTime = Field(alias="dateResa") + start_time: DateTime = Field(alias="startTime") + sport: str + court: str + game_creation: Optional[int] = Field(default=None, alias="creaPartie") + game_creation_limit: Optional[DateTime] = Field( + default=None, alias="limitCreaPartie" + ) + cancel: Optional[bool] = Field(default=True) + block_player_replacement: Optional[int] = Field( + default=None, alias="bloquerRemplacementJoueur" + ) + can_remove_parteners: bool = Field(default=True, alias="canRemovePartners") + end_time: Optional[DateTime] = Field(default=None, alias="endTime") + day_fr: Optional[str] = Field(default=None, alias="dayFr") + live_xperience_code: Optional[str] = Field(default=None, alias="codeLiveXperience") + spartime_qr_code: Optional[str] = Field(default=None, alias="qrCodeSpartime") + remaining_places: int = Field(default=3, alias="remainingPlaces") + is_captain: bool = Field(default=True, alias="isCaptain") + dt_start: Optional[DateTime] = Field(default=None, alias="dtStart") + credit_card_guaranty: Optional[str] = Field(default=None, alias="garantieCb") + certificate_validity_duration: Optional[int] = Field( + alias="dureeValidCertif", default=None + ) + charge_id: Optional[str] = Field(default=None, alias="chargeId") + partners: Optional[list] = Field(default=[]) + player_status: Optional[int] = Field(default=None, alias="playerStatus") + products: Optional[list] = Field(default=[]) + + @field_validator("booking_date", mode="before") + @classmethod + def validate_date(cls, d: str) -> DateTime: + return pendulum.from_format( + d, "DD/MM/YYYY", tz=pendulum.timezone("Europe/Paris") + ) + + @field_validator("start_time", "end_time", mode="before") + @classmethod + def validate_time(cls, t: str) -> DateTime: + return pendulum.from_format(t, "HH:mm", tz=pendulum.timezone("Europe/Paris")) + + @field_validator("game_creation_limit", mode="before") + @classmethod + def validate_datetime_add_tz(cls, dt: str) -> DateTime: + return pendulum.parse(dt, tz=pendulum.timezone("Europe/Paris")) + + @field_validator("dt_start", mode="before") + @classmethod + def validate_datetime(cls, dt: str) -> DateTime: + return pendulum.parse(dt) + + @field_validator("sport", mode="before") + @classmethod + def to_lower_case(cls, d: str) -> str: + return d.lower() + + +class Action(Enum): + BOOK = "book" + CANCEL = "cancel" diff --git a/resa_padel/payload_builders.py b/resa_padel/payload_builders.py new file mode 100644 index 0000000..5c5c338 --- /dev/null +++ b/resa_padel/payload_builders.py @@ -0,0 +1,12 @@ +from pathlib import Path + +from jinja2 import Environment, FileSystemLoader + + +class PayloadBuilder: + @staticmethod + def build(template_path: Path, **kwargs) -> str: + environment = Environment(loader=FileSystemLoader(template_path.parent)) + template = environment.get_template(template_path.name) + + return template.render(**kwargs) diff --git a/resa_padel/resources/clubs.yaml b/resa_padel/resources/clubs.yaml new file mode 100644 index 0000000..0689fda --- /dev/null +++ b/resa_padel/resources/clubs.yaml @@ -0,0 +1,160 @@ +clubs: + - name: Toulouse Padel Club + url: https://www.toulousepadelclub.com + id: tpc + bookingPlatform: + id: gestion-sports + clubId: 88 + url: https://toulousepadelclub.gestion-sports.com + hoursBeforeCancellation: 4 + bookingOpening: + daysBefore: 7 + time: 00:00 + totalBookings: + peakHours: 1 + offPeakHours: unlimited + sports: + - name: Padel + id: 217 + duration: 90 + price: 48 + players: 4 + courts: + - name: Court 1 + number: 1 + id: 598 + isIndoor: True + - name: Court 2 + number: 2 + id: 599 + isIndoor: True + - name: Court 3 + number: 3 + id: 600 + isIndoor: True + - name: Court 4 + number: 4 + id: 601 + isIndoor: True + - name: Court 5 + number: 5 + id: 602 + isIndoor: True + - name: Court 6 + number: 6 + id: 603 + isIndoor: True + - name: Court 7 + number: 7 + id: 604 + isIndoor: True + - name: Court 8 + number: 8 + id: 605 + isIndoor: True + - name: Court 9 + number: 9 + id: 606 + isIndoor: True + - name: Court 10 + number: 10 + id: 607 + isIndoor: True + - name: Court 11 + number: 11 + id: 608 + isIndoor: True + - name: Court 12 + number: 12 + id: 609 + isIndoor: True + - name: Court 13 + number: 13 + id: 610 + isIndoor: True + - name: Court 14 + number: 14 + id: 611 + isIndoor: True + - name: Squash + id: 218 + duration: 45 + price: 18 + players: 2 + courts: + - name: Court 1 + id: 613 + number: 1 + isIndoor: True + - name: Court 2 + number: 2 + id: 614 + isIndoor: True + - name: Court 3 + number: 3 + id: 615 + isIndoor: True + - name: Court 4 + number: 4 + id: 616 + isIndoor: True + + - name: Padel Tolosa + url: https://www.padeltolosa.fr/ + id: padeltolosa + bookingPlatform: + id: gestion-sports + clubId: 89 + url: https://padeltolosa.gestion-sports.com/ + hoursBeforeCancellation: 24 + bookingOpening: + daysBefore: 7 + time: 00:00 + totalBookings: + peakHours: 4 + offPeakHours: unlimited + sports: + - name: Padel + id: 262 + duration: 90 + price: 48 + players: 4 + courts: + - name: Court 1 M.F IMMOBILLIER + number: 1 + id: 746 + isIndoor: True + - name: Court 2 PAQUITO + number: 2 + id: 747 + isIndoor: True + - name: Court 3 Seven Sisters PUB TOULOUSE + number: 3 + id: 748 + isIndoor: True + - name: Court MADRID + number: 4 + id: 749 + isIndoor: True + - name: Squash + id: 218 + duration: 45 + price: 18 + players: 2 + courts: + - name: Court 1 + id: 613 + isIndoor: True + number: 1 + - name: Court 2 + id: 614 + isIndoor: True + number: 2 + - name: Court 3 + id: 615 + isIndoor: True + number: 3 + - name: Court 4 + id: 616 + isIndoor: True + number: 4 diff --git a/resa_padel/resources/gestion-sports/booking-cancellation-payload.txt b/resa_padel/resources/gestion-sports/booking-cancellation-payload.txt new file mode 100644 index 0000000..2083999 --- /dev/null +++ b/resa_padel/resources/gestion-sports/booking-cancellation-payload.txt @@ -0,0 +1 @@ +ajax=removeResa&hash={{ hash }}&id={{ booking_id }} diff --git a/resa_padel/resources/gestion-sports/booking-payload.txt b/resa_padel/resources/gestion-sports/booking-payload.txt index b5a7cc5..c416a11 100644 --- a/resa_padel/resources/gestion-sports/booking-payload.txt +++ b/resa_padel/resources/gestion-sports/booking-payload.txt @@ -1 +1 @@ -ajax=addResa&date={{ booking_filter.date.date().strftime("%d/%m/%Y") }}&hour={{ booking_filter.date.time().strftime("%H:%M") }}&duration=90&partners=null|null|null&paiement=facultatif&idSport={{ booking_filter.sport_id }}&creaPartie=false&idCourt={{ court_id }}&pay=false&token=undefined&totalPrice=44&saveCard=0&foodNumber=0 +ajax=addResa&date={{ date.date().strftime("%d/%m/%Y") }}&hour={{ date.time().strftime("%H:%M") }}&duration=90&partners=null|null|null&paiement=facultatif&idSport={{ sport_id }}&creaPartie=false&idCourt={{ court_id }}&pay=false&token=undefined&totalPrice=48&saveCard=0&foodNumber=0 diff --git a/resa_padel/resources/gestion-sports/login-payload.txt b/resa_padel/resources/gestion-sports/login-payload.txt index b0da065..7692aed 100644 --- a/resa_padel/resources/gestion-sports/login-payload.txt +++ b/resa_padel/resources/gestion-sports/login-payload.txt @@ -1 +1 @@ -ajax=connexionUser&id_club={{ club.id }}&email={{ user.login }}&form_ajax=1&pass={{ user.password }}&compte=user&playeridonesignal=0&identifiant=identifiant&externCo=true +ajax=connexionUser&id_club={{ club.booking_platform.club_id }}&email={{ user.login }}&form_ajax=1&pass={{ user.password }}&compte=user&playeridonesignal=0&identifiant=identifiant&externCo=true diff --git a/resa_padel/resources/gestion-sports/users_bookings.txt b/resa_padel/resources/gestion-sports/user-bookings-payload.txt similarity index 100% rename from resa_padel/resources/gestion-sports/users_bookings.txt rename to resa_padel/resources/gestion-sports/user-bookings-payload.txt diff --git a/resa_padel/resources/platforms.yaml b/resa_padel/resources/platforms.yaml new file mode 100644 index 0000000..7f1525f --- /dev/null +++ b/resa_padel/resources/platforms.yaml @@ -0,0 +1,19 @@ +platforms: + - name: Gestion sports + url: https://gestion-sports.fr/ + id: gestion-sports + urls: + - name: landing-page + path: /connexion.php + - name: login + path: /connexion.php + payloadTemplate: gestion-sports/login-payload.txt + - name: booking + path: /membre/reservation.html + payloadTemplate: gestion-sports/booking-payload.txt + - name: user-bookings + path: /membre/mesresas.html + payloadTemplate: gestion-sports/user-bookings-payload.txt + - name: cancellation + path: /membre/mesresas.html + payloadTemplate: gestion-sports/booking-cancellation-payload.txt diff --git a/resa_padel/resources/users.yaml b/resa_padel/resources/users.yaml new file mode 100644 index 0000000..1d53d16 --- /dev/null +++ b/resa_padel/resources/users.yaml @@ -0,0 +1,13 @@ +clubs: + - id: tpc + users: + - login: padel.testing@jouf.fr + password: ridicule + - login: mateochal31@gmail.com + password: pleanyakis + - id: padeltolosa + users: + - login: padel.testing@jouf.fr + password: ridicule + - login: mateochal31@gmail.com + password: pleanyakis diff --git a/tests/data/user_bookings_html_response.html b/tests/data/user_bookings_html_response.html new file mode 100644 index 0000000..3af1e3b --- /dev/null +++ b/tests/data/user_bookings_html_response.html @@ -0,0 +1,1363 @@ + + + + + + Mes réservations + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ +
+
+ + + Retour + + + + + +
+ +
+ Mes réservations
+ +
+ + + +
+
+
+ + +
+
+
Mes événements
+
+
+
+
+
+ +
+
Mes réservations
+ +
+
+
+
+
Suivi de vos réservations par sport
+
+ + Heure pleines + Heure creuses +
+
+ Padel + +
+
0 /1
+
0 /1
+
+
+ +
+
illimitées
+
illimitées
+
+
+
+
+ Squash + +
+
0 /2
+
0 /2
+
+
+ +
+
illimitées
+
illimitées
+
+
+
+
+ Electrostimulation + +
+
illimitées
+
illimitées
+
+
+ +
+
illimitées
+
illimitées
+
+
+
+
+
+ + +
+ + + + + + + +
+ + +
+ + + + + + + +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + Accueil +
+
+ + +
+ + Actualités +
+
+ + +
+
+ + Réserver +
+
+ +
+ + +
+ + Compte +
+
+ + +
+ + Menu +
+
+ +
+ + + + + + + + + + + + + + + + + + +
+
+
+
+ image + Le Mas + à l'instant +
+ + + +
+
+
+

Titre de la notif

+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod + tempor incididunt ut labore et dolore magna aliqua. +
+
+
+
+
+ + + + + + + + + + + +
+
+ +
+ Votre inscription à la partie de Tennis a bien été enregistrée !
+ Rendez-vous le 14 Janvier de 16h30 à 17h30. +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures.py b/tests/fixtures.py index bef8442..c7d514f 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -3,19 +3,16 @@ from pathlib import Path import pendulum import pytest -from gestion_sports.payload_builders import GestionSportsBookingPayloadBuilder -from resa_padel.models import BookingFilter, Club, User +from resa_padel.models import BookingFilter, User user = User(login="padel.testing@jouf.fr", password="ridicule", club_id="123") -url = "https://tpc.padel.com" -club = Club(id="123", url=url, courts_ids=[606, 607, 608]) courts = [606, 607, 608] -sport_id = 217 +sport_name = "padel" tz_info = "Europe/Paris" booking_date = pendulum.now().add(days=6).set(hour=18, minute=0, second=0, tz=tz_info) -booking_filter = BookingFilter(sport_id=sport_id, date=booking_date) +booking_filter = BookingFilter(sport_name=sport_name, date=booking_date) booking_failure_response = json.dumps( { @@ -36,12 +33,6 @@ booking_success_response = json.dumps( date_format = "%d/%m/%Y" time_format = "%H:%M" -booking_payload = ( - GestionSportsBookingPayloadBuilder() - .booking_filter(booking_filter) - .court_id(courts[0]) - .build() -) html_file = Path(__file__).parent / "data" / "mes_resas.html" _mes_resas_html = html_file.read_text(encoding="utf-8") @@ -57,11 +48,6 @@ def a_booking_filter() -> BookingFilter: return booking_filter -@pytest.fixture -def a_club() -> Club: - return club - - @pytest.fixture def a_booking_success_response() -> str: return booking_success_response @@ -72,11 +58,6 @@ def a_booking_failure_response() -> str: return booking_failure_response -@pytest.fixture -def a_booking_payload() -> str: - return booking_payload - - @pytest.fixture def mes_resas_html() -> str: return _mes_resas_html diff --git a/tests/gestion_sports/test_gestion_sports_payload_builder.py b/tests/gestion_sports/test_gestion_sports_payload_builder.py index 4b7d23d..b494c55 100644 --- a/tests/gestion_sports/test_gestion_sports_payload_builder.py +++ b/tests/gestion_sports/test_gestion_sports_payload_builder.py @@ -1,8 +1,9 @@ -from resa_padel.gestion_sports.payload_builders import ( +from payload_builders import ( GestionSportsBookingPayloadBuilder, GestionSportsLoginPayloadBuilder, GestionSportsUsersBookingsPayloadBuilder, ) + from tests.fixtures import a_booking_filter, a_club, a_user diff --git a/tests/test_booking.py b/tests/test_booking.py index b60ae4d..4de9767 100644 --- a/tests/test_booking.py +++ b/tests/test_booking.py @@ -1,77 +1,70 @@ +import asyncio import os from unittest.mock import patch +import config import pendulum -from aioresponses import aioresponses -from models import BookingFilter, Club -from pendulum import Time +from models import BookingFilter, User from resa_padel import booking -from tests import fixtures, utils -from tests.fixtures import ( - a_booking_failure_response, - a_booking_success_response, - mes_resas_html, + + +@patch.dict( + os.environ, + {"CLUB_ID": "tpc"}, + clear=True, ) +def test_real_booking(): + club = config.get_club() + user = User(login="padel.testing@jouf.fr", password="ridicule") + booking_filter = BookingFilter( + sport_name="Padel", date=pendulum.parse("2024-03-21T13:30:00+01:00") + ) + booked_court, user_that_booked = asyncio.run( + booking.book_court(club, [user], booking_filter) + ) + assert booked_court is not None + assert user_that_booked == user -login = "user" -password = "password" -available_credentials = login + ":" + password + ",some_user:some_password" -club_id = "88" -court_id = "11" -paris_tz = "Europe/Paris" -datetime_to_book = ( - pendulum.now().add(days=6).set(hour=18, minute=0, second=0, tz=paris_tz) + +@patch.dict( + os.environ, + {"CLUB_ID": "tpc"}, + clear=True, ) +def test_real_cancellation(): + club = config.get_club() + user = User(login="padel.testing@jouf.fr", password="ridicule") + asyncio.run(booking.cancel_booking_id(club, user, 3605033)) -@patch("pendulum.now") @patch.dict( os.environ, { - "LOGIN": login, - "PASSWORD": password, - "CLUB_ID": club_id, - "CLUB_URL": fixtures.url, - "COURT_IDS": "7,8,10", - "SPORT_ID": "217", - "DATE_TIME": datetime_to_book.isoformat(), - "AVAILABLE_USERS_CREDENTIALS": available_credentials, + "CLUB_ID": "tpc", + "ACTION": "book", + "SPORT_NAME": "Padel", + "DATE_TIME": "2024-03-21T13:30:00+01:00", }, clear=True, ) -def test_main( - mock_now, - a_booking_success_response: str, - a_booking_failure_response: str, - mes_resas_html: str, -): - """ - Test the main function to book a court +def test_main_booking(): + court, user = booking.main() + assert court is not None + assert user.username == "padel.testing@jouf" - :param mock_now: the pendulum.now() mock - :param a_booking_success_response: the success json response - :param a_booking_failure_response: the failure json response - """ - booking_filter = BookingFilter(sport_id=666, date=datetime_to_book) - club = Club( - id="club", - url="some.url", - courts_ids=[7, 8, 10], - booking_open_days_before=7, - booking_opening_time=Time(hour=0, minute=0), - ) - booking_datetime = utils.retrieve_booking_datetime(booking_filter, club) - mock_now.side_effect = [booking_datetime] - with aioresponses() as aio_mock: - utils.mock_rest_api_from_connection_to_booking( - aio_mock, - fixtures.url, - a_booking_failure_response, - a_booking_success_response, - mes_resas_html, - ) - - court_booked = booking.main() - assert court_booked == 8 +@patch.dict( + os.environ, + { + "CLUB_ID": "tpc", + "ACTION": "cancel", + "SPORT_NAME": "Padel", + "DATE_TIME": "2024-03-21T13:30:00+01:00", + "LOGIN": "padel.testing@jouf.fr", + "PASSWORD": "ridicule", + }, + clear=True, +) +def test_main_cancellation(): + booking.main() diff --git a/tests/test_config.py b/tests/test_config.py index 0c1e25b..c9c76ef 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,40 +2,20 @@ import os from unittest.mock import patch import config -from pendulum import DateTime, Time, Timezone +from pendulum import DateTime, Timezone @patch.dict( os.environ, { - "CLUB_URL": "club.url", - "COURT_IDS": "7,8,10", - "CLUB_ID": "666", - "BOOKING_OPEN_DAYS_BEFORE": "5", - "BOOKING_OPENING_TIME": "18:37", - }, - clear=True, -) -def test_get_club(): - club = config.get_club() - assert club.url == "club.url" - assert club.courts_ids == [7, 8, 10] - assert club.id == "666" - assert club.booking_open_days_before == 5 - assert club.booking_opening_time == Time(hour=18, minute=37) - - -@patch.dict( - os.environ, - { - "SPORT_ID": "666", + "SPORT_NAME": "Padel", "DATE_TIME": "2024-02-03T22:38:45Z", }, clear=True, ) def test_get_booking_filter(): booking_filter = config.get_booking_filter() - assert booking_filter.sport_id == 666 + assert booking_filter.sport_id == "padel" assert booking_filter.date == DateTime( year=2024, month=2, @@ -61,14 +41,11 @@ def test_get_available_user(): assert user.password == "gloups" -@patch.dict( - os.environ, - {"AVAILABLE_USERS_CREDENTIALS": "login@user.tld:gloups,other@user.tld:patatras"}, - clear=True, -) -def test_user(): - users = config.get_available_users() - assert users[0].login == "login@user.tld" - assert users[0].password == "gloups" - assert users[1].login == "other@user.tld" - assert users[1].password == "patatras" +def test_read_clubs(): + clubs = config.get_clubs() + assert len(clubs) == 2 + + +def test_get_users(): + users = config.get_users("tpc") + assert len(users) == 2 diff --git a/tests/test_connectors.py b/tests/test_connectors.py new file mode 100644 index 0000000..9f503ed --- /dev/null +++ b/tests/test_connectors.py @@ -0,0 +1,411 @@ +import os +from pathlib import Path +from unittest.mock import patch + +import aiohttp +import config +import pendulum +import pytest +from aiohttp import ClientSession +from connectors import GestionSportsConnector +from models import Booking, BookingFilter, User +from yarl import URL + +from tests import utils + + +@patch.dict( + os.environ, + {"CLUB_ID": "tpc"}, + clear=True, +) +def test_urls(): + club = config.get_club() + connector = GestionSportsConnector(club) + assert ( + connector.landing_url + == "https://toulousepadelclub.gestion-sports.com/connexion.php" + ) + assert ( + connector.login_url + == "https://toulousepadelclub.gestion-sports.com/connexion.php" + ) + assert ( + connector.booking_url + == "https://toulousepadelclub.gestion-sports.com/membre/reservation.html" + ) + assert ( + connector.user_bookings_url + == "https://toulousepadelclub.gestion-sports.com/membre/mesresas.html" + ) + assert ( + connector.booking_cancellation_url + == "https://toulousepadelclub.gestion-sports.com/membre/mesresas.html" + ) + + +@patch.dict( + os.environ, + {"CLUB_ID": "tpc", "RESOURCES_FOLDER": "/some/path"}, + clear=True, +) +def test_urls_payload_templates(): + club = config.get_club() + connector = GestionSportsConnector(club) + resources_folder = Path("/some", "path", "gestion-sports") + assert connector.login_template == resources_folder / "login-payload.txt" + assert connector.booking_template == resources_folder / "booking-payload.txt" + assert ( + connector.user_bookings_template + == resources_folder / "user-bookings-payload.txt" + ) + assert ( + connector.booking_cancellation_template + == resources_folder / "booking-cancellation-payload.txt" + ) + + +@patch.dict( + os.environ, + {"CLUB_ID": "tpc"}, + clear=True, +) +@pytest.mark.asyncio +async def test_landing_page(): + club = config.get_club() + connector = GestionSportsConnector(club) + async with aiohttp.ClientSession() as session: + response = await connector.land(session) + + assert response.status == 200 + assert response.request_info.method == "GET" + assert response.content_type == "text/html" + assert response.request_info.url == URL(connector.landing_url) + assert response.charset == "UTF-8" + assert response.cookies.get("PHPSESSID") is not None + + +@patch.dict( + os.environ, + {"CLUB_ID": "tpc"}, + clear=True, +) +@pytest.mark.asyncio +async def test_login(): + club = config.get_club() + connector = GestionSportsConnector(club) + user = User(login="padel.testing@jouf.fr", password="ridicule") + + async with aiohttp.ClientSession() as session: + await connector.land(session) + + response = await connector.login(session, user) + + assert response.status == 200 + assert response.request_info.method == "POST" + assert response.content_type == "text/html" + assert response.request_info.url == URL(connector.landing_url) + assert response.charset == "UTF-8" + assert response.cookies.get("COOK_COMPTE") is not None + assert response.cookies.get("COOK_ID_CLUB").value == "88" + assert response.cookies.get("COOK_ID_USER").value == "232382" + + +@patch.dict( + os.environ, + {"CLUB_ID": "tpc"}, + clear=True, +) +def test_get_booked_court(): + club = config.get_club() + connector = GestionSportsConnector(club) + + session = ClientSession() + bookings = [ + (session, 601, False), + (session, 602, False), + (session, 603, False), + (session, 614, False), + (session, 605, False), + (session, 606, True), + (session, 607, False), + (session, 608, False), + ] + + court = connector.get_booked_court(bookings, "padel") + assert court.number == 9 + + +@patch.dict( + os.environ, + {"CLUB_ID": "tpc"}, + clear=True, +) +@pytest.mark.asyncio +async def test_book_one_court(): + club = config.get_club() + connector = GestionSportsConnector(club) + user = User(login="padel.testing@jouf.fr", password="ridicule") + + async with aiohttp.ClientSession() as session: + await connector.land(session) + await connector.login(session, user) + response, court_id, ok = await connector.send_booking_request( + session, pendulum.parse("2024-03-21T13:30:00Z"), 610, 217 + ) + + assert response.status == 200 + assert response.request_info.method == "POST" + assert response.content_type == "text/html" + assert response.request_info.url == URL(connector.booking_url) + assert response.charset == "UTF-8" + assert response.text is not None + assert court_id == 610 + assert ok is True + + +@patch.dict( + os.environ, + {"CLUB_ID": "tpc"}, + clear=True, +) +@pytest.mark.asyncio +async def test_book(): + club = config.get_club() + connector = GestionSportsConnector(club) + user = User(login="padel.testing@jouf.fr", password="ridicule") + booking_filter = BookingFilter( + sport_name="Padel", date=pendulum.parse("2024-03-21T13:30:00Z") + ) + + booked_court = await connector.book(user, booking_filter) + + assert booked_court is not None + + +@patch.dict( + os.environ, + {"CLUB_ID": "tpc"}, + clear=True, +) +def test_build_booking_datetime(): + club = config.get_club() + connector = GestionSportsConnector(club) + booking_filter = BookingFilter( + sport_name="Padel", date=pendulum.parse("2024-03-21T13:30:00Z") + ) + + opening_datetime = connector.build_booking_datetime(booking_filter) + assert opening_datetime.year == 2024 + assert opening_datetime.month == 3 + assert opening_datetime.day == 14 + assert opening_datetime.hour == 0 + assert opening_datetime.minute == 0 + + +@patch.dict( + os.environ, + {"CLUB_ID": "tpc"}, + clear=True, +) +@patch("pendulum.now") +def test_wait_until_booking_time(mock_now): + club = config.get_club() + connector = GestionSportsConnector(club) + booking_filter = BookingFilter( + sport_name="Padel", date=pendulum.parse("2024-03-21T13:30:00Z") + ) + + booking_datetime = utils.retrieve_booking_datetime(booking_filter, club) + + seconds = [ + booking_datetime.subtract(seconds=3), + booking_datetime.subtract(seconds=2), + booking_datetime.subtract(seconds=1), + booking_datetime, + booking_datetime.add(microseconds=1), + booking_datetime.add(microseconds=2), + ] + mock_now.side_effect = seconds + + connector.wait_until_booking_time(booking_filter) + + assert pendulum.now() == booking_datetime.add(microseconds=1) + + +@patch.dict( + os.environ, + {"CLUB_ID": "tpc"}, + clear=True, +) +@pytest.mark.asyncio +async def test_get_hash(): + club = config.get_club() + connector = GestionSportsConnector(club) + user = User(login="padel.testing@jouf.fr", password="ridicule") + + async with aiohttp.ClientSession() as session: + await connector.land(session) + await connector.login(session, user) + + hash_value = await connector.send_hash_request(session) + assert hash_value is not None + + +def test_get_hash_input(): + resources_folder = Path(__file__).parent / "data" + html_file = resources_folder / "user_bookings_html_response.html" + html = html_file.read_text(encoding="utf-8") + + hash_value = GestionSportsConnector.get_hash_input(html) + + assert hash_value == "63470fa38e300fd503de1ee21a71b3bdb6fb206b" + + +@patch.dict( + os.environ, + {"CLUB_ID": "tpc"}, + clear=True, +) +@pytest.mark.asyncio +async def test_get_bookings(): + club = config.get_club() + connector = GestionSportsConnector(club) + user = User(login="padel.testing@jouf.fr", password="ridicule") + + async with aiohttp.ClientSession() as session: + await connector.land(session) + await connector.login(session, user) + + hash_value = await connector.send_hash_request(session) + payload = f"ajax=loadResa&hash={hash_value}" + + bookings = await connector.send_user_bookings_request(session, payload) + print(bookings) + + +@patch.dict( + os.environ, + {"CLUB_ID": "tpc"}, + clear=True, +) +@pytest.mark.asyncio +async def test_get_ongoing_bookings(): + club = config.get_club() + connector = GestionSportsConnector(club) + user = User(login="padel.testing@jouf.fr", password="ridicule") + + async with aiohttp.ClientSession() as session: + await connector.land(session) + await connector.login(session, user) + + bookings = await connector.get_ongoing_bookings(session) + print(bookings) + + +@patch.dict( + os.environ, + {"CLUB_ID": "tpc"}, + clear=True, +) +@pytest.mark.asyncio +async def test_has_user_ongoing_bookings(): + club = config.get_club() + connector = GestionSportsConnector(club) + user = User(login="padel.testing@jouf.fr", password="ridicule") + + assert await connector.has_user_ongoing_booking(user) + + +@patch.dict( + os.environ, + {"CLUB_ID": "tpc"}, + clear=True, +) +@pytest.mark.asyncio +async def test_cancel_booking_id(): + club = config.get_club() + connector = GestionSportsConnector(club) + user = User(login="padel.testing@jouf.fr", password="ridicule") + + async with aiohttp.ClientSession() as session: + await connector.land(session) + await connector.login(session, user) + ongoing_bookings = await connector.get_ongoing_bookings(session) + booking_id = ongoing_bookings[0].id + + response = await connector.cancel_booking_id(user, booking_id) + + assert len(await connector.get_ongoing_bookings(session)) == 0 + + +@patch.dict( + os.environ, + {"CLUB_ID": "tpc"}, + clear=True, +) +def test_is_booking_matching_filter(): + club = config.get_club() + connector = GestionSportsConnector(club) + filter_date = pendulum.parse("2024-03-02T15:00:00+01:00") + booking = Booking( + id=1, + dateResa="02/03/2024", + startTime="15:00", + sport="Padel", + court="10", + ) + booking_filter = BookingFilter(date=filter_date, sport_name="Padel") + + assert connector.is_booking_matching_filter(booking, booking_filter) + + +@patch.dict( + os.environ, + {"CLUB_ID": "tpc"}, + clear=True, +) +def test_is_booking_not_matching_filter(): + club = config.get_club() + connector = GestionSportsConnector(club) + filter_date = pendulum.parse("2024-03-02T15:00:00+01:00") + booking = Booking( + id=1, + dateResa="02/03/2024", + startTime="16:00", + sport="Padel", + court="10", + ) + booking_filter = BookingFilter(date=filter_date, sport_name="Padel") + + assert not connector.is_booking_matching_filter(booking, booking_filter) + + +@patch.dict( + os.environ, + {"CLUB_ID": "tpc"}, + clear=True, +) +@pytest.mark.asyncio +def test_find_court(): + club = config.get_club() + connector = GestionSportsConnector(club) + + court = connector.find_court(603, "Padel") + + assert court.number == 6 + + +@patch.dict( + os.environ, + {"CLUB_ID": "tpc"}, + clear=True, +) +@pytest.mark.asyncio +async def test_cancel_booking(): + club = config.get_club() + connector = GestionSportsConnector(club) + user = User(login="padel.testing@jouf.fr", password="ridicule") + filter_date = pendulum.parse("2024-03-21T13:30:00+01:00") + booking_filter = BookingFilter(date=filter_date, sport_name="Padel") + await connector.cancel_booking(user, booking_filter) diff --git a/tests/utils.py b/tests/utils.py index c5383bb..c05ff6b 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,5 +1,6 @@ from urllib.parse import urljoin +import pendulum from models import BookingFilter, Club from pendulum import DateTime @@ -7,7 +8,6 @@ from tests.fixtures import ( a_booking_failure_response, a_booking_filter, a_booking_success_response, - a_club, mes_resas_html, ) @@ -65,11 +65,13 @@ def retrieve_booking_datetime( :param a_booking_filter: the booking filter that contains the date to book :param a_club: the club which has the number of days before the date and the booking time """ - booking_hour = a_club.booking_opening_time.hour - booking_minute = a_club.booking_opening_time.minute + booking_opening = a_club.booking_platform.booking_opening + opening_time = pendulum.parse(booking_opening.opening_time) + booking_hour = opening_time.hour + booking_minute = opening_time.minute date_to_book = a_booking_filter.date - return date_to_book.subtract(days=a_club.booking_open_days_before).at( + return date_to_book.subtract(days=booking_opening.days_before).at( booking_hour, booking_minute ) From 16d4a0724c2c162acd95693e6e384e6a92c8786f Mon Sep 17 00:00:00 2001 From: stanislas Date: Mon, 18 Mar 2024 23:46:01 +0100 Subject: [PATCH 2/7] Added a lot of unit tests --- resa_padel/connectors.py | 64 +- resa_padel/models.py | 41 + tests/data/configuration/clubs.yaml | 92 + .../booking-cancellation-payload.txt | 1 + .../gestion-sports/booking-payload.txt | 1 + .../gestion-sports/login-payload.txt | 1 + .../gestion-sports/post-headers.json | 12 + .../gestion-sports/user-bookings-payload.txt | 1 + tests/data/configuration/platforms.yaml | 19 + tests/data/configuration/users.yaml | 13 + tests/data/responses/booking_failure.json | 4 + tests/data/responses/booking_success.json | 5 + .../data/responses/cancellation_response.json | 4 + tests/data/responses/landing_response.html | 2033 +++++++++++++++++ tests/data/responses/login_failure.json | 5 + tests/data/responses/login_success.json | 9 + tests/data/responses/user_bookings_get.html | 1363 +++++++++++ tests/data/responses/user_bookings_post.json | 52 + .../test_gestion_sports_connector.py | 185 -- .../test_gestion_sports_html_parser.py | 8 - .../test_gestion_sports_payload_builder.py | 62 - .../test_gestion_sports_platform.py | 87 - .../__init__.py | 0 tests/{ => integration_tests}/test_booking.py | 4 +- .../test_connectors.py | 2 +- tests/unit_tests/__init__.py | 0 tests/unit_tests/conftest.py | 284 +++ tests/unit_tests/test_booking.py | 0 tests/unit_tests/test_cancellation.py | 0 tests/{ => unit_tests}/test_config.py | 0 .../test_gestion_sports_connector.py | 315 +++ tests/utils.py | 98 +- 32 files changed, 4268 insertions(+), 497 deletions(-) create mode 100644 tests/data/configuration/clubs.yaml create mode 100644 tests/data/configuration/gestion-sports/booking-cancellation-payload.txt create mode 100644 tests/data/configuration/gestion-sports/booking-payload.txt create mode 100644 tests/data/configuration/gestion-sports/login-payload.txt create mode 100644 tests/data/configuration/gestion-sports/post-headers.json create mode 100644 tests/data/configuration/gestion-sports/user-bookings-payload.txt create mode 100644 tests/data/configuration/platforms.yaml create mode 100644 tests/data/configuration/users.yaml create mode 100644 tests/data/responses/booking_failure.json create mode 100644 tests/data/responses/booking_success.json create mode 100644 tests/data/responses/cancellation_response.json create mode 100644 tests/data/responses/landing_response.html create mode 100644 tests/data/responses/login_failure.json create mode 100644 tests/data/responses/login_success.json create mode 100644 tests/data/responses/user_bookings_get.html create mode 100644 tests/data/responses/user_bookings_post.json delete mode 100644 tests/gestion_sports/test_gestion_sports_connector.py delete mode 100644 tests/gestion_sports/test_gestion_sports_html_parser.py delete mode 100644 tests/gestion_sports/test_gestion_sports_payload_builder.py delete mode 100644 tests/gestion_sports/test_gestion_sports_platform.py rename tests/{gestion_sports => integration_tests}/__init__.py (100%) rename tests/{ => integration_tests}/test_booking.py (96%) rename tests/{ => integration_tests}/test_connectors.py (99%) create mode 100644 tests/unit_tests/__init__.py create mode 100644 tests/unit_tests/conftest.py create mode 100644 tests/unit_tests/test_booking.py create mode 100644 tests/unit_tests/test_cancellation.py rename tests/{ => unit_tests}/test_config.py (100%) create mode 100644 tests/unit_tests/test_gestion_sports_connector.py diff --git a/resa_padel/connectors.py b/resa_padel/connectors.py index c36149e..25c6c90 100644 --- a/resa_padel/connectors.py +++ b/resa_padel/connectors.py @@ -80,7 +80,7 @@ class GestionSportsConnector(Connector): raise ValueError( "Gestion Sports connector was instantiated with a club not handled" " by gestions sports. Club id is {} instead of gestion-sports".format( - club.club_id + club.id ) ) @@ -220,7 +220,7 @@ class GestionSportsConnector(Connector): return self._get_url_path("cancellation") @property - def booking_cancellation_template(self) -> Path: + def booking_cancel_template(self) -> Path: """ Get the payload template to send to get all the user's bookings that are available @@ -519,8 +519,9 @@ class GestionSportsConnector(Connector): :return: the response from the client """ hash_value = await self.send_hash_request(session) + payload = PayloadBuilder.build( - self.booking_cancellation_template, + self.booking_cancel_template, booking_id=booking_id, hash=hash_value, ) @@ -531,7 +532,9 @@ class GestionSportsConnector(Connector): await response.text() return response - async def cancel_booking(self, user: User, booking_filter: BookingFilter) -> None: + async def cancel_booking( + self, user: User, booking_filter: BookingFilter + ) -> ClientResponse | None: """ Cancel the booking that meets some conditions @@ -545,54 +548,5 @@ class GestionSportsConnector(Connector): bookings = await self.get_ongoing_bookings(session) for booking in bookings: - if self.is_booking_matching_filter(booking, booking_filter): - await self.send_cancellation_request(session, booking.id) - - def is_booking_matching_filter( - self, booking: Booking, booking_filter: BookingFilter - ) -> bool: - """ - Check if the booking matches the booking filter - - :param booking: the booking to be checked - :param booking_filter: the conditions the booking should meet - :return: true if the booking matches the conditions, false otherwise - """ - return ( - self._is_same_sport(booking, booking_filter) - and self._is_date_matching(booking, booking_filter) - and self._is_time_matching(booking, booking_filter) - ) - - @staticmethod - def _is_same_sport(booking: Booking, booking_filter: BookingFilter) -> bool: - """ - Check if the booking and the booking filter are about the same sport - - :param booking: the booking to be checked - :param booking_filter: the conditions the booking should meet - :return: true if the booking sport matches the filter sport, false otherwise - """ - return booking.sport == booking_filter.sport_name - - @staticmethod - def _is_date_matching(booking: Booking, booking_filter: BookingFilter) -> bool: - """ - Check if the booking and the booking filter are at the same date - - :param booking: the booking to be checked - :param booking_filter: the conditions the booking should meet - :return: true if the booking date matches the filter date, false otherwise - """ - return booking.booking_date.date() == booking_filter.date.date() - - @staticmethod - def _is_time_matching(booking: Booking, booking_filter: BookingFilter) -> bool: - """ - Check if the booking and the booking filter are at the same time - - :param booking: the booking to be checked - :param booking_filter: the conditions the booking should meet - :return: true if the booking time matches the filter time, false otherwise - """ - return booking.start_time.time() == booking_filter.date.time() + if booking.matches(booking_filter): + return await self.send_cancellation_request(session, booking.id) diff --git a/resa_padel/models.py b/resa_padel/models.py index 6045abd..d13b375 100644 --- a/resa_padel/models.py +++ b/resa_padel/models.py @@ -2,6 +2,7 @@ from enum import Enum from typing import Optional import pendulum +from pendulum import Date, Time from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic_extra_types.pendulum_dt import DateTime @@ -165,6 +166,46 @@ class Booking(BaseModel): def to_lower_case(cls, d: str) -> str: return d.lower() + def matches(self, booking_filter: BookingFilter) -> bool: + """ + Check if the booking matches the booking filter + + :param booking_filter: the conditions the booking should meet + :return: true if the booking matches the conditions, false otherwise + """ + return ( + self.is_same_sport(booking_filter.sport_name) + and self.is_same_date(booking_filter.date.date()) + and self.is_same_time(booking_filter.date.time()) + ) + + def is_same_sport(self, sport: str) -> bool: + """ + Check if the booking and the booking filter are about the same sport + + :param sport: the sport to test + :return: true if the sport matches booking sport, false otherwise + """ + return self.sport == sport + + def is_same_date(self, date: Date) -> bool: + """ + Check if the booking filter has the same date as the booking + + :param date: the date to test + :return: true if the date matches the booking date, false otherwise + """ + return self.booking_date.date() == date + + def is_same_time(self, time: Time) -> bool: + """ + Check if the booking filter has the same time as the booking + + :param time: the time to test + :return: true if the time matches the booking time, false otherwise + """ + return self.start_time.time() == time + class Action(Enum): BOOK = "book" diff --git a/tests/data/configuration/clubs.yaml b/tests/data/configuration/clubs.yaml new file mode 100644 index 0000000..649f291 --- /dev/null +++ b/tests/data/configuration/clubs.yaml @@ -0,0 +1,92 @@ +clubs: + - name: Super Club + url: https://www.super-club.com + id: sc + bookingPlatform: + id: gestion-sports + clubId: 54 + url: https://superclub.flapi.fr + hoursBeforeCancellation: 10 + bookingOpening: + daysBefore: 10 + time: 22:37 + totalBookings: + peakHours: 3 + offPeakHours: unlimited + sports: + - name: Sport1 + id: 22 + duration: 55 + price: 78 + players: 4 + courts: + - name: Court 1 + number: 1 + id: 54 + isIndoor: True + - name: Court 2 + number: 2 + id: 67 + isIndoor: False + - name: Court 3 + number: 3 + id: 26 + isIndoor: True + - name: Sport2 + id: 25 + duration: 22 + price: 3 + players: 2 + courts: + - name: Court 1 + id: 99 + number: 1 + isIndoor: True + - name: Court 2 + number: 2 + id: 101 + isIndoor: False + + - name: Club Pourri + url: https://www.clubpourri.fr + id: cp + bookingPlatform: + id: gestion-sports + clubId: 1111 + url: https://clubpourri.flapi.fr + hoursBeforeCancellation: 24 + bookingOpening: + daysBefore: 2 + time: 02:54 + totalBookings: + peakHours: 4 + offPeakHours: unlimited + sports: + - name: Sport1 + id: 465 + duration: 44 + price: 98 + players: 4 + courts: + - name: Court 7 + number: 15 + id: 987 + isIndoor: True + - name: prout prout + number: 555 + id: 747 + isIndoor: False + - name: Sport3 + id: 321 + duration: 11 + price: 18 + players: 2 + courts: + - name: Court 1 + id: 613 + isIndoor: True + number: 1 + - name: Court 2 + id: 614 + isIndoor: True + number: 2 diff --git a/tests/data/configuration/gestion-sports/booking-cancellation-payload.txt b/tests/data/configuration/gestion-sports/booking-cancellation-payload.txt new file mode 100644 index 0000000..2083999 --- /dev/null +++ b/tests/data/configuration/gestion-sports/booking-cancellation-payload.txt @@ -0,0 +1 @@ +ajax=removeResa&hash={{ hash }}&id={{ booking_id }} diff --git a/tests/data/configuration/gestion-sports/booking-payload.txt b/tests/data/configuration/gestion-sports/booking-payload.txt new file mode 100644 index 0000000..c416a11 --- /dev/null +++ b/tests/data/configuration/gestion-sports/booking-payload.txt @@ -0,0 +1 @@ +ajax=addResa&date={{ date.date().strftime("%d/%m/%Y") }}&hour={{ date.time().strftime("%H:%M") }}&duration=90&partners=null|null|null&paiement=facultatif&idSport={{ sport_id }}&creaPartie=false&idCourt={{ court_id }}&pay=false&token=undefined&totalPrice=48&saveCard=0&foodNumber=0 diff --git a/tests/data/configuration/gestion-sports/login-payload.txt b/tests/data/configuration/gestion-sports/login-payload.txt new file mode 100644 index 0000000..7692aed --- /dev/null +++ b/tests/data/configuration/gestion-sports/login-payload.txt @@ -0,0 +1 @@ +ajax=connexionUser&id_club={{ club.booking_platform.club_id }}&email={{ user.login }}&form_ajax=1&pass={{ user.password }}&compte=user&playeridonesignal=0&identifiant=identifiant&externCo=true diff --git a/tests/data/configuration/gestion-sports/post-headers.json b/tests/data/configuration/gestion-sports/post-headers.json new file mode 100644 index 0000000..8adccc1 --- /dev/null +++ b/tests/data/configuration/gestion-sports/post-headers.json @@ -0,0 +1,12 @@ +{ + "Connection": "keep-alive", + "Accept-Language": "en-US,en;q=0.5", + "Accept-Encoding": "gzip, deflate, br", + "DNT": "1", + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + "Accept": "application/json, text/javascript, */*; q=0.01", + "X-Requested-With": "XMLHttpRequest", + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin" +} diff --git a/tests/data/configuration/gestion-sports/user-bookings-payload.txt b/tests/data/configuration/gestion-sports/user-bookings-payload.txt new file mode 100644 index 0000000..bc971e6 --- /dev/null +++ b/tests/data/configuration/gestion-sports/user-bookings-payload.txt @@ -0,0 +1 @@ +ajax=loadResa&hash={{ hash }} diff --git a/tests/data/configuration/platforms.yaml b/tests/data/configuration/platforms.yaml new file mode 100644 index 0000000..f3cdc8e --- /dev/null +++ b/tests/data/configuration/platforms.yaml @@ -0,0 +1,19 @@ +platforms: + - name: flapi + url: https://flapi.fr/ + id: gestion-sports + urls: + - name: landing-page + path: /landing.html + - name: login + path: /login.html + payloadTemplate: gestion-sports/login-payload.txt + - name: booking + path: /booking.html + payloadTemplate: gestion-sports/booking-payload.txt + - name: user-bookings + path: /user_bookings.html + payloadTemplate: gestion-sports/user-bookings-payload.txt + - name: cancellation + path: /cancel.html + payloadTemplate: gestion-sports/booking-cancellation-payload.txt diff --git a/tests/data/configuration/users.yaml b/tests/data/configuration/users.yaml new file mode 100644 index 0000000..1d53d16 --- /dev/null +++ b/tests/data/configuration/users.yaml @@ -0,0 +1,13 @@ +clubs: + - id: tpc + users: + - login: padel.testing@jouf.fr + password: ridicule + - login: mateochal31@gmail.com + password: pleanyakis + - id: padeltolosa + users: + - login: padel.testing@jouf.fr + password: ridicule + - login: mateochal31@gmail.com + password: pleanyakis diff --git a/tests/data/responses/booking_failure.json b/tests/data/responses/booking_failure.json new file mode 100644 index 0000000..406f970 --- /dev/null +++ b/tests/data/responses/booking_failure.json @@ -0,0 +1,4 @@ +{ + "status": "error", + "message": "D\u00e9sol\u00e9 mais vous avez 1 r\u00e9servation en cours au Padel en heures pleines et le r\u00e9glement n'autorise qu'une r\u00e9servation en heures pleines \u00e0 la fois au Padel!" +} diff --git a/tests/data/responses/booking_success.json b/tests/data/responses/booking_success.json new file mode 100644 index 0000000..267ebee --- /dev/null +++ b/tests/data/responses/booking_success.json @@ -0,0 +1,5 @@ +{ + "status": "ok", + "message": "Merci, votre r\u00e9servation s'est bien effectu\u00e9e, vous allez recevoir un email avec le r\u00e9capitulatif de votre r\u00e9servation, pensez \u00e0 le conserver.", + "id_resa": 3609529 +} diff --git a/tests/data/responses/cancellation_response.json b/tests/data/responses/cancellation_response.json new file mode 100644 index 0000000..401ac46 --- /dev/null +++ b/tests/data/responses/cancellation_response.json @@ -0,0 +1,4 @@ +{ + "status": "ok", + "message": "La r\u00e9servation a bien \u00e9t\u00e9 annul\u00e9e !" +} diff --git a/tests/data/responses/landing_response.html b/tests/data/responses/landing_response.html new file mode 100644 index 0000000..71dab94 --- /dev/null +++ b/tests/data/responses/landing_response.html @@ -0,0 +1,2033 @@ + + + + Gestion-sports + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+ + + +
+ +
+ + + + + + + + + + +

Retour

+
+ + + +
+
+ +
+ + + +

Retour

+

Votre compte est déjà connu de nos services. + Grâce à la technologie GS Connect, vous serez automatiquement enregistré auprès de Toulouse Padel Club + avec les mêmes identifiants de connexion que vous utilisez dans notre réseau

+ +
+ + + + + +
+ Mot de passe oublié ? +
+ +
+ + + + + +
+ +
+

Sur quel espace souhaitez-vous vous connecter ?

+
+ + + +
+ + +
+ + + + +
+ +

Retour

+ +

Renseignez votre email ci-dessous et vous recevrez un mail avec un nouveau mot de passe.

+ +
+ + + +
+ +
+ + +
+
+ +
+

Retour

+ + +
+ + + +
+ +
+ + + +
+
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + + + +
+ +
+ + + +
+ +
+ + + +
+ +

Votre mot de passe doit + contenir minimum 6 caractères.

+
+ + + + + +
+ + +
+ + + +
+ +

Champs obligatoires.

+ +
+ + +
+ +
+ + +
+
+ +
+ +
+
+
+
+ + + +
+ + +
+ + + +
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/data/responses/login_failure.json b/tests/data/responses/login_failure.json new file mode 100644 index 0000000..731ed87 --- /dev/null +++ b/tests/data/responses/login_failure.json @@ -0,0 +1,5 @@ +{ + "status": "ko", + "msg": "L'email ou le mot de passe saisi est incorrect.", + "data": false +} diff --git a/tests/data/responses/login_success.json b/tests/data/responses/login_success.json new file mode 100644 index 0000000..3a07b66 --- /dev/null +++ b/tests/data/responses/login_success.json @@ -0,0 +1,9 @@ +{ + "status": "ok", + "msg": "", + "data": { + "needChoice": false, + "redirectUrl": "\/membre", + "id_club": 88 + } +} diff --git a/tests/data/responses/user_bookings_get.html b/tests/data/responses/user_bookings_get.html new file mode 100644 index 0000000..fc473f0 --- /dev/null +++ b/tests/data/responses/user_bookings_get.html @@ -0,0 +1,1363 @@ + + + + + + Mes réservations + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ +
+
+ + + Retour + + + + + +
+ +
+ Mes réservations
+ +
+ + + +
+
+
+ + +
+
+
Mes événements
+
+
+
+
+
+ +
+
Mes réservations
+ +
+
+
+
+
Suivi de vos réservations par sport
+
+ + Heure pleines + Heure creuses +
+
+ Padel + +
+
1 /1
+
1 /1
+
+
+ +
+
illimitées
+
illimitées
+
+
+
+
+ Squash + +
+
0 /2
+
0 /2
+
+
+ +
+
illimitées
+
illimitées
+
+
+
+
+ Electrostimulation + +
+
illimitées
+
illimitées
+
+
+ +
+
illimitées
+
illimitées
+
+
+
+
+
+ + +
+ + + + + + + +
+ + +
+ + + + + + + +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ image + Le Mas + à l'instant +
+ + + +
+
+
+

Titre de la notif

+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod + tempor incididunt ut labore et dolore magna aliqua. +
+
+
+
+
+ + + + + + + + + + + +
+
+ +
+ Votre inscription à la partie de Tennis a bien été enregistrée !
+ Rendez-vous le 14 Janvier de 16h30 à 17h30. +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/data/responses/user_bookings_post.json b/tests/data/responses/user_bookings_post.json new file mode 100644 index 0000000..e6c5218 --- /dev/null +++ b/tests/data/responses/user_bookings_post.json @@ -0,0 +1,52 @@ +[ + { + "id": 111, + "chargeId": null, + "partners": [], + "dateResa": "21\/03\/2024", + "startTime": "13:30", + "endTime": "15:00", + "dayFr": "jeudi 21 mars 2024", + "codeLiveXperience": null, + "qrCodeSpartime": null, + "sport": "Sport1", + "court": "court 13", + "creaPartie": 0, + "limitCreaPartie": "2024-03-21 11:30:00", + "cancel": true, + "bloquerRemplacementJoueur": 1, + "canRemovePartners": false, + "remainingPlaces": 3, + "isCaptain": true, + "dtStart": "2024-03-21T13:30:00+01:00", + "garantieCb": null, + "dureeValidCertif": null, + "playerStatus": 3, + "products": [] + }, + { + "id": 360, + "chargeId": null, + "partners": [], + "dateResa": "18\/11\/2025", + "startTime": "09:00", + "endTime": "10:30", + "dayFr": "vendredi 18 novembre 2025", + "codeLiveXperience": null, + "qrCodeSpartime": null, + "sport": "Sport1", + "court": "court 13", + "creaPartie": 0, + "limitCreaPartie": "2025-11-18 07:00:00", + "cancel": true, + "bloquerRemplacementJoueur": 1, + "canRemovePartners": false, + "remainingPlaces": 3, + "isCaptain": true, + "dtStart": "2025-11-18T07:00:00+01:00", + "garantieCb": null, + "dureeValidCertif": null, + "playerStatus": 3, + "products": [] + } +] diff --git a/tests/gestion_sports/test_gestion_sports_connector.py b/tests/gestion_sports/test_gestion_sports_connector.py deleted file mode 100644 index c279263..0000000 --- a/tests/gestion_sports/test_gestion_sports_connector.py +++ /dev/null @@ -1,185 +0,0 @@ -import pytest -from aiohttp import ClientSession -from models import BookingFilter, Club, User -from yarl import URL - -from resa_padel.gestion_sports.gestion_sports_connector import GestionSportsConnector -from tests.fixtures import ( - a_booking_failure_response, - a_booking_filter, - a_booking_success_response, - a_club, - a_user, -) - -tpc_url = "https://toulousepadelclub.gestion-sports.com" -TPC_COURTS = [ - None, - 596, - 597, - 598, - 599, - 600, - 601, - 602, - 603, - 604, - 605, - 606, - 607, - 608, - 609, - 610, - 611, -] - - -@pytest.mark.asyncio -async def test_should_reach_landing_page_to_gestion_sports_website() -> None: - """ - Test that landing page is reached - """ - async with ClientSession() as session: - cookies = session.cookie_jar.filter_cookies(URL(tpc_url)) - assert cookies.get("PHPSESSID") is None - gs_connector = GestionSportsConnector(session, tpc_url) - - response = await gs_connector.land() - - assert response.status == 200 - assert response.request_info.method == "GET" - assert response.content_type == "text/html" - assert response.request_info.url == URL(tpc_url + "/connexion.php") - assert response.charset == "UTF-8" - - cookies = session.cookie_jar.filter_cookies(URL(tpc_url)) - assert cookies.get("PHPSESSID") is not None - - -@pytest.mark.asyncio -async def test_should_login_to_gestion_sports_website( - a_user: User, a_club: Club -) -> None: - """ - Test that a user can log in after reaching the landing page - - :param a_user: the user that wants to book a court - :param a_club: the club information - """ - async with ClientSession() as session: - gs_connector = GestionSportsConnector(session, tpc_url) - await gs_connector.land() - - response = await gs_connector.login(a_user, a_club) - - assert response.status == 200 - assert response.request_info.url == URL(tpc_url + "/connexion.php") - assert response.request_info.method == "POST" - - cookies = session.cookie_jar.filter_cookies(URL(tpc_url)) - assert cookies.get("COOK_ID_CLUB").value is not None - assert cookies.get("COOK_ID_USER").value is not None - assert cookies.get("PHPSESSID") is not None - - -@pytest.mark.asyncio -@pytest.mark.slow -async def test_booking_url_should_be_reachable( - a_user: User, a_booking_filter: BookingFilter, a_club: Club -) -> None: - """ - Test that a user can log in the booking platform and book a court - - :param a_user: the user that wants to book a court - :param a_booking_filter: the booking information - :param a_club: the club information - """ - async with ClientSession() as session: - gs_connector = GestionSportsConnector(session, tpc_url) - await gs_connector.land() - await gs_connector.login(a_user, a_club) - - court_booked = await gs_connector.book(a_booking_filter, a_club) - # At 18:00 no chance to get a booking, any day of the week - assert court_booked in TPC_COURTS - - -@pytest.mark.asyncio -async def test_should_book_a_court_from_gestion_sports( - aioresponses, - a_booking_filter: BookingFilter, - a_club: Club, - a_booking_success_response: str, - a_booking_failure_response: str, -) -> None: - """ - Test that user can reach the landing page, then log in to the platform - and eventually book a court - - :param aioresponses: the http response mock - :param a_booking_filter: the booking information - :param a_club: the club information - :param a_booking_success_response: the success response mock - :param a_booking_failure_response: the failure response mock - """ - booking_url = URL(tpc_url + "/membre/reservation.html?") - - # first booking request will fail - aioresponses.post(booking_url, status=200, body=a_booking_failure_response) - # first booking request will succeed - aioresponses.post(booking_url, status=200, body=a_booking_success_response) - # first booking request will fail - aioresponses.post(booking_url, status=200, body=a_booking_failure_response) - - async with ClientSession() as session: - gs_connector = GestionSportsConnector(session, tpc_url) - court_booked = await gs_connector.book(a_booking_filter, a_club) - - # the second element of the list is the booked court - assert court_booked == a_club.courts_ids[1] - - -def test_response_status_should_be_ok(a_booking_success_response: str) -> None: - """ - Test internal method to verify that the success response received by booking - a gestion-sports court is still a JSON with a field 'status' set to 'ok' - - :param a_booking_success_response: the success response mock - """ - is_booked = GestionSportsConnector.is_booking_response_status_ok( - a_booking_success_response - ) - assert is_booked - - -def test_response_status_should_be_not_ok(a_booking_failure_response: str) -> None: - """ - Test internal method to verify that the failure response received by booking - a gestion-sports court is still a JSON with a field 'status' set to 'error' - - :param a_booking_failure_response: the failure response mock - """ - is_booked = GestionSportsConnector.is_booking_response_status_ok( - a_booking_failure_response - ) - assert not is_booked - - -@pytest.mark.asyncio -@pytest.mark.slow -async def test_get_user_ongoing_bookings(a_user: User, a_club: Club) -> None: - """ - Test that the user has 2 ongoing bookings - - :param a_user: - :param a_club: - :return: - """ - async with ClientSession() as session: - gs_connector = GestionSportsConnector(session, tpc_url) - await gs_connector.land() - await gs_connector.login(a_user, a_club) - - bookings = await gs_connector.get_ongoing_bookings() - - assert len(bookings) == 0 diff --git a/tests/gestion_sports/test_gestion_sports_html_parser.py b/tests/gestion_sports/test_gestion_sports_html_parser.py deleted file mode 100644 index 314e3e3..0000000 --- a/tests/gestion_sports/test_gestion_sports_html_parser.py +++ /dev/null @@ -1,8 +0,0 @@ -from gestion_sports import gestion_sports_html_parser as parser - -from tests.fixtures import mes_resas_html - - -def test_html_parser(mes_resas_html): - hash_value = parser.get_hash_input(mes_resas_html) - assert hash_value == "ef4403f4c44fa91060a92476aae011a2184323ec" diff --git a/tests/gestion_sports/test_gestion_sports_payload_builder.py b/tests/gestion_sports/test_gestion_sports_payload_builder.py deleted file mode 100644 index b494c55..0000000 --- a/tests/gestion_sports/test_gestion_sports_payload_builder.py +++ /dev/null @@ -1,62 +0,0 @@ -from payload_builders import ( - GestionSportsBookingPayloadBuilder, - GestionSportsLoginPayloadBuilder, - GestionSportsUsersBookingsPayloadBuilder, -) - -from tests.fixtures import a_booking_filter, a_club, a_user - - -def test_login_payload_should_be_built(a_user, a_club): - """ - Test that the login payload is filled with the right template - and filled accordingly - - :param a_user: the user information fixture - :param a_club: the club information fixture - """ - payload_builder = GestionSportsLoginPayloadBuilder() - login_payload = payload_builder.user(a_user).club(a_club).build() - - expected_payload = ( - f"ajax=connexionUser&id_club={a_club.id}&email={a_user.login}&form_ajax=1" - f"&pass={a_user.password}&compte=user&playeridonesignal=0" - f"&identifiant=identifiant&externCo=true" - ) - - assert login_payload == expected_payload - - -def test_booking_payload_should_be_built(a_booking_filter): - """ - Test that the booking payload is filled with the right template - and filled accordingly - - :param a_booking_filter: the booking information fixture - """ - booking_builder = GestionSportsBookingPayloadBuilder() - booking_payload = ( - booking_builder.booking_filter(a_booking_filter).court_id(4).build() - ) - - expected_date = a_booking_filter.date.date().strftime("%d/%m/%Y") - expected_time = a_booking_filter.date.time().strftime("%H:%M") - expected_payload = ( - f"ajax=addResa&date={expected_date}" - f"&hour={expected_time}&duration=90&partners=null|null|null" - f"&paiement=facultatif&idSport={a_booking_filter.sport_id}" - f"&creaPartie=false&idCourt=4&pay=false&token=undefined&totalPrice=44" - f"&saveCard=0&foodNumber=0" - ) - - assert booking_payload == expected_payload - - -def test_users_bookings_payload_should_be_built(): - builder = GestionSportsUsersBookingsPayloadBuilder() - builder.hash("super_hash") - expected_payload = "ajax=loadResa&hash=super_hash" - - actual_payload = builder.build() - - assert actual_payload == expected_payload diff --git a/tests/gestion_sports/test_gestion_sports_platform.py b/tests/gestion_sports/test_gestion_sports_platform.py deleted file mode 100644 index 7332bc5..0000000 --- a/tests/gestion_sports/test_gestion_sports_platform.py +++ /dev/null @@ -1,87 +0,0 @@ -from unittest.mock import patch - -import pendulum -import pytest -from aioresponses import aioresponses -from gestion_sports.gestion_sports_platform import ( - GestionSportsPlatform, - wait_until_booking_time, -) -from models import BookingFilter, Club, User - -from tests import fixtures, utils -from tests.fixtures import ( - a_booking_failure_response, - a_booking_filter, - a_booking_success_response, - a_club, - a_user, - mes_resas_html, -) - - -@pytest.mark.asyncio -@patch("pendulum.now") -async def test_booking( - mock_now, - a_booking_success_response: str, - a_booking_failure_response: str, - a_user: User, - a_club: Club, - a_booking_filter: BookingFilter, - mes_resas_html: str, -): - """ - Test a single court booking without reading the conf from environment variables - - :param mock_now: the pendulum.now() mock - :param a_booking_success_response: the success json response - :param a_booking_failure_response: the failure json response - :param a_user: a test user - :param a_club:a test club - :param a_booking_filter: a test booking filter - """ - booking_datetime = utils.retrieve_booking_datetime(a_booking_filter, a_club) - mock_now.side_effect = [booking_datetime] - - # mock connection to the booking platform - with aioresponses() as aio_mock: - utils.mock_rest_api_from_connection_to_booking( - aio_mock, - fixtures.url, - a_booking_failure_response, - a_booking_success_response, - mes_resas_html, - ) - - async with GestionSportsPlatform(a_club) as gs_operations: - court_booked = await gs_operations.book(a_user, a_booking_filter) - assert court_booked == a_club.courts_ids[1] - - -@patch("pendulum.now") -def test_wait_until_booking_time( - mock_now, a_club: Club, a_booking_filter: BookingFilter -): - """ - Test the function that waits until the booking can be performed - - :param mock_now: the pendulum.now() mock - :param a_club: a club - :param a_booking_filter: a booking filter - """ - booking_datetime = utils.retrieve_booking_datetime(a_booking_filter, a_club) - - seconds = [ - booking_datetime.subtract(seconds=3), - booking_datetime.subtract(seconds=2), - booking_datetime.subtract(seconds=1), - booking_datetime, - booking_datetime.add(microseconds=1), - booking_datetime.add(microseconds=2), - ] - mock_now.side_effect = seconds - - wait_until_booking_time(a_club, a_booking_filter) - - assert pendulum.now() == booking_datetime.add(microseconds=1) diff --git a/tests/gestion_sports/__init__.py b/tests/integration_tests/__init__.py similarity index 100% rename from tests/gestion_sports/__init__.py rename to tests/integration_tests/__init__.py diff --git a/tests/test_booking.py b/tests/integration_tests/test_booking.py similarity index 96% rename from tests/test_booking.py rename to tests/integration_tests/test_booking.py index 4de9767..7d738ed 100644 --- a/tests/test_booking.py +++ b/tests/integration_tests/test_booking.py @@ -14,7 +14,7 @@ from resa_padel import booking {"CLUB_ID": "tpc"}, clear=True, ) -def test_real_booking(): +def test_booking(): club = config.get_club() user = User(login="padel.testing@jouf.fr", password="ridicule") booking_filter = BookingFilter( @@ -32,7 +32,7 @@ def test_real_booking(): {"CLUB_ID": "tpc"}, clear=True, ) -def test_real_cancellation(): +def test_cancellation(): club = config.get_club() user = User(login="padel.testing@jouf.fr", password="ridicule") asyncio.run(booking.cancel_booking_id(club, user, 3605033)) diff --git a/tests/test_connectors.py b/tests/integration_tests/test_connectors.py similarity index 99% rename from tests/test_connectors.py rename to tests/integration_tests/test_connectors.py index 9f503ed..6c262bc 100644 --- a/tests/test_connectors.py +++ b/tests/integration_tests/test_connectors.py @@ -60,7 +60,7 @@ def test_urls_payload_templates(): == resources_folder / "user-bookings-payload.txt" ) assert ( - connector.booking_cancellation_template + connector.booking_cancel_template == resources_folder / "booking-cancellation-payload.txt" ) diff --git a/tests/unit_tests/__init__.py b/tests/unit_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py new file mode 100644 index 0000000..e301c8a --- /dev/null +++ b/tests/unit_tests/conftest.py @@ -0,0 +1,284 @@ +import json +from pathlib import Path + +import pendulum +import pytest +from connectors import GestionSportsConnector +from models import ( + BookingFilter, + BookingOpening, + BookingPlatform, + Club, + Court, + Sport, + TotalBookings, + Url, + User, +) + +TEST_FOLDER = Path(__file__).parent.parent +DATA_FOLDER = TEST_FOLDER / "data" +RESPONSES_FOLDER = DATA_FOLDER / "responses" + +court11 = Court(id="1", name="Court 1", number=1, isIndoor=True) +court12 = Court(id="2", name="Court 2", number=2, isIndoor=False) +court13 = Court(id="3", name="Court 3", number=3, isIndoor=True) +court14 = Court(id="4", name="Court 4", number=4, isIndoor=True) + +sport1 = Sport( + name="Sport1", + id=8, + duration=99, + price=54, + players=3, + courts=[court11, court12, court13, court14], +) + +court21 = Court(id="1", name="Court 1", number=1, isIndoor=False) +court22 = Court(id="2", name="Court 2", number=2, isIndoor=True) +court23 = Court(id="3", name="Court 3", number=3, isIndoor=True) +court24 = Court(id="4", name="Court 4", number=4, isIndoor=True) + +sport2 = Sport( + name="Sport 2", + id=10, + duration=44, + price=23, + players=1, + courts=[court21, court22, court23, court24], +) + +landing_url = Url( + name="landing-page", + path="landing.html", +) + +login_url = Url( + name="login", + path="login.html", + payloadTemplate="gestion-sports/login-payload.txt", +) + +booking_url = Url( + name="booking", + path="booking.html", + payloadTemplate="gestion-sports/booking-payload.txt", +) + +user_bookings_url = Url( + name="user-bookings", + path="user_bookings.html", + payloadTemplate="gestion-sports/user-bookings-payload.txt", +) + +cancellation_url = Url( + name="cancellation", + path="cancel.html", + payloadTemplate="gestion-sports/booking-cancellation-payload.txt", +) + +booking_opening = BookingOpening(daysBefore=10, time="03:27") + +total_bookings = TotalBookings(peakHours=3, offPeakHours="unlimited") + +booking_platform = BookingPlatform( + id="gestion-sports", + clubId=21, + url="https://ptf1.com", + hoursBeforeCancellation=7, + bookingOpening=booking_opening, + totalBookings=total_bookings, + sports=[sport1, sport2], + urls={ + "landing-page": landing_url, + "login": login_url, + "booking": booking_url, + "user-bookings": user_bookings_url, + "cancellation": cancellation_url, + }, +) + +club = Club( + id="super_club", + name="Super Club", + url="https://superclub.com", + bookingPlatform=booking_platform, +) + + +@pytest.fixture +def a_club() -> Club: + return club + + +@pytest.fixture +def connector() -> GestionSportsConnector: + return GestionSportsConnector(club) + + +@pytest.fixture +def user() -> User: + return User(login="padel.testing@jouf.fr", password="ridicule") + + +@pytest.fixture +def booking_filter() -> BookingFilter: + return BookingFilter( + sport_name="Sport1", date=pendulum.parse("2024-03-21T13:30:00Z") + ) + + +@pytest.fixture +def landing_response() -> str: + landing_response_file = RESPONSES_FOLDER / "landing_response.html" + return landing_response_file.read_text(encoding="utf-8") + + +@pytest.fixture +def login_success_response() -> dict: + login_success_file = RESPONSES_FOLDER / "login_success.json" + return json.loads(login_success_file.read_text(encoding="utf-8")) + + +@pytest.fixture +def login_failure_response() -> dict: + login_failure_file = RESPONSES_FOLDER / "login_failure.json" + return json.loads(login_failure_file.read_text(encoding="utf-8")) + + +@pytest.fixture +def booking_success_response() -> dict: + booking_success_file = RESPONSES_FOLDER / "booking_success.json" + return json.loads(booking_success_file.read_text(encoding="utf-8")) + + +@pytest.fixture +def booking_failure_response() -> dict: + booking_failure_file = RESPONSES_FOLDER / "booking_failure.json" + return json.loads(booking_failure_file.read_text(encoding="utf-8")) + + +@pytest.fixture +def booking_success_from_start( + landing_response, + login_success_response, + booking_success_response, + booking_failure_response, +): + return [ + landing_response, + login_success_response, + booking_failure_response, + booking_success_response, + booking_failure_response, + booking_failure_response, + ] + + +@pytest.fixture +def booking_failure_from_start( + landing_response, + login_success_response, + booking_success_response, + booking_failure_response, +): + return [ + landing_response, + login_success_response, + booking_failure_response, + booking_failure_response, + booking_failure_response, + booking_failure_response, + ] + + +@pytest.fixture +def user_bookings_get_response() -> str: + user_bookings_file = RESPONSES_FOLDER / "user_bookings_get.html" + return user_bookings_file.read_text(encoding="utf-8") + + +@pytest.fixture +def user_bookings_list() -> list: + user_bookings_file = RESPONSES_FOLDER / "user_bookings_post.json" + return json.loads(user_bookings_file.read_text(encoding="utf-8")) + + +@pytest.fixture +def user_has_ongoing_bookings_from_start( + landing_response, + login_success_response, + user_bookings_get_response, + user_bookings_list, +) -> list: + return [ + landing_response, + login_success_response, + user_bookings_get_response, + user_bookings_list, + ] + + +@pytest.fixture +def user_bookings_empty_list() -> list: + return [] + + +@pytest.fixture +def user_has_no_ongoing_bookings_from_start( + landing_response, + login_success_response, + user_bookings_get_response, + user_bookings_empty_list, +) -> list: + return [ + landing_response, + login_success_response, + user_bookings_get_response, + user_bookings_empty_list, + ] + + +@pytest.fixture +def cancellation_response() -> list: + cancellation_response_file = RESPONSES_FOLDER / "cancellation_response.json" + return json.loads(cancellation_response_file.read_text(encoding="utf-8")) + + +@pytest.fixture +def cancellation_by_id_from_start( + landing_response, + login_success_response, + user_bookings_get_response, + cancellation_response, +): + return [ + landing_response, + login_success_response, + user_bookings_get_response, + cancellation_response, + ] + + +@pytest.fixture +def cancellation_success_from_start( + landing_response, + login_success_response, + user_bookings_get_response, + user_bookings_list, + cancellation_response, +): + return [ + landing_response, + login_success_response, + user_bookings_get_response, + user_bookings_list, + cancellation_response, + ] + + +@pytest.fixture +def cancellation_success_booking_filter() -> BookingFilter: + return BookingFilter( + sport_name="Sport1", date=pendulum.parse("2024-03-21T13:30:00Z") + ) diff --git a/tests/unit_tests/test_booking.py b/tests/unit_tests/test_booking.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit_tests/test_cancellation.py b/tests/unit_tests/test_cancellation.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_config.py b/tests/unit_tests/test_config.py similarity index 100% rename from tests/test_config.py rename to tests/unit_tests/test_config.py diff --git a/tests/unit_tests/test_gestion_sports_connector.py b/tests/unit_tests/test_gestion_sports_connector.py new file mode 100644 index 0000000..7ba5b52 --- /dev/null +++ b/tests/unit_tests/test_gestion_sports_connector.py @@ -0,0 +1,315 @@ +from pathlib import Path +from unittest.mock import patch + +import pytest +from aiohttp import ClientSession +from connectors import GestionSportsConnector + + +def make_landing_request_success(aioresponses, connector, landing_response): + aioresponses.get( + connector.landing_url, + status=200, + headers={"Set-Cookie": "PHPSESSID=987512"}, + body=landing_response, + ) + + +def make_login_request_fail(aioresponses, connector, login_failure_response): + aioresponses.post( + connector.login_url, + status=200, + payload=login_failure_response, + ) + + +def make_login_request_success(aioresponses, connector, login_success_response): + aioresponses.post( + connector.login_url, + status=200, + headers={"Set-Cookie": "COOK_COMPTE=e2be1;" "COOK_ID_CLUB=22;COOK_ID_USER=666"}, + payload=login_success_response, + ) + + +def set_booking_request(aioresponses, connector, booking_response): + aioresponses.post(connector.booking_url, status=200, payload=booking_response) + + +def set_full_booking_requests_responses(aioresponses, connector, responses_list): + make_landing_request_success(aioresponses, connector, responses_list[0]) + make_login_request_success(aioresponses, connector, responses_list[1]) + for response in responses_list[2:]: + set_booking_request(aioresponses, connector, response) + + +def set_ongoing_bookings_response( + aioresponses, connector, user_bookings_get_response, user_bookings_post_response +): + set_hash_response(aioresponses, connector, user_bookings_get_response) + set_bookings_response(aioresponses, connector, user_bookings_post_response) + + +def set_hash_response(aioresponses, connector, user_bookings_get_response): + aioresponses.get( + connector.user_bookings_url, status=200, body=user_bookings_get_response + ) + + +def set_bookings_response(aioresponses, connector, user_bookings_post_response): + aioresponses.post( + connector.user_bookings_url, status=200, payload=user_bookings_post_response + ) + + +def set_full_user_bookings_responses(aioresponses, connector, responses): + make_landing_request_success(aioresponses, connector, responses[0]) + make_login_request_success(aioresponses, connector, responses[1]) + set_ongoing_bookings_response(aioresponses, connector, *responses[2:]) + + +def set_cancellation_response(aioresponses, connector, response): + aioresponses.post(connector.booking_cancellation_url, status=200, payload=response) + + +def set_full_cancellation_by_id_responses(aioresponses, connector, responses): + make_landing_request_success(aioresponses, connector, responses[0]) + make_login_request_success(aioresponses, connector, responses[1]) + set_hash_response(aioresponses, connector, responses[2]) + set_cancellation_response(aioresponses, connector, responses[3]) + + +def set_full_cancellation_responses(aioresponses, connector, responses): + make_landing_request_success(aioresponses, connector, responses[0]) + make_login_request_success(aioresponses, connector, responses[1]) + + # the request to get the hash is made twice + set_hash_response(aioresponses, connector, responses[2]) + set_hash_response(aioresponses, connector, responses[2]) + + set_bookings_response(aioresponses, connector, responses[3]) + set_cancellation_response(aioresponses, connector, responses[4]) + + +def test_urls(a_club): + connector = GestionSportsConnector(a_club) + base_url = a_club.booking_platform.url + relative_urls = a_club.booking_platform.urls + + relative_landing_url = relative_urls.get("landing-page").path + assert connector.landing_url == f"{base_url}/{relative_landing_url}" + + relative_login_url = relative_urls.get("login").path + assert connector.login_url == f"{base_url}/{relative_login_url}" + + relative_booking_url = relative_urls.get("booking").path + assert connector.booking_url == f"{base_url}/{relative_booking_url}" + + relative_user_bookings_url = relative_urls.get("user-bookings").path + assert connector.user_bookings_url == f"{base_url}/{relative_user_bookings_url}" + + relative_cancel_url = relative_urls.get("cancellation").path + assert connector.booking_cancellation_url == f"{base_url}/{relative_cancel_url}" + + +@patch("config.get_resources_folder") +def test_urls_payload_templates(mock_resources, a_club): + path_to_resources = Path("some/path/to/resource") + mock_resources.return_value = path_to_resources + + connector = GestionSportsConnector(a_club) + relative_urls = a_club.booking_platform.urls + + login_payload = relative_urls.get("login").payload_template + assert connector.login_template == path_to_resources / login_payload + + booking_payload = relative_urls.get("booking").payload_template + assert connector.booking_template == path_to_resources / booking_payload + + user_bookings_payload = relative_urls.get("user-bookings").payload_template + assert connector.user_bookings_template == path_to_resources / user_bookings_payload + + cancel_payload = relative_urls.get("cancellation").payload_template + assert connector.booking_cancel_template == path_to_resources / cancel_payload + + +@pytest.mark.asyncio +async def test_landing_page(aioresponses, connector, landing_response): + make_landing_request_success(aioresponses, connector, landing_response) + + async with ClientSession() as session: + response = await connector.land(session) + + assert response.status == 200 + assert response.cookies.get("PHPSESSID").value == "987512" + assert await response.text() == landing_response + + +@pytest.mark.asyncio +async def test_login_success(aioresponses, connector, user, login_success_response): + make_login_request_success(aioresponses, connector, login_success_response) + + async with ClientSession() as session: + response = await connector.login(session, user) + + assert response.status == 200 + assert response.cookies.get("COOK_COMPTE").value == "e2be1" + assert response.cookies.get("COOK_ID_CLUB").value == "22" + assert response.cookies.get("COOK_ID_USER").value == "666" + assert await response.json() == login_success_response + + +@pytest.mark.asyncio +async def test_login_failure(aioresponses, connector, user, login_failure_response): + make_login_request_fail(aioresponses, connector, login_failure_response) + + async with ClientSession() as session: + response = await connector.login(session, user) + + assert response.status == 200 + assert len(response.cookies) == 0 + assert await response.json() == login_failure_response + + +@pytest.mark.asyncio +async def test_booking_success( + aioresponses, + connector, + user, + booking_filter, + booking_success_from_start, +): + set_full_booking_requests_responses( + aioresponses, connector, booking_success_from_start + ) + + court_booked = await connector.book(user, booking_filter) + + assert court_booked.id == 2 + + +@pytest.mark.asyncio +async def test_booking_failure( + aioresponses, + connector, + user, + booking_filter, + booking_failure_from_start, +): + set_full_booking_requests_responses( + aioresponses, connector, booking_failure_from_start + ) + + court_booked = await connector.book(user, booking_filter) + + assert court_booked is None + + +@pytest.mark.asyncio +async def test_get_ongoing_bookings( + aioresponses, + connector, + user, + user_bookings_get_response, + user_bookings_list, +): + set_ongoing_bookings_response( + aioresponses, connector, user_bookings_get_response, user_bookings_list + ) + + async with ClientSession() as session: + bookings = await connector.get_ongoing_bookings(session) + + assert len(bookings) == 2 + + +@pytest.mark.asyncio +async def test_get_ongoing_bookings( + aioresponses, + connector, + user, + user_bookings_get_response, + user_bookings_list, +): + set_ongoing_bookings_response( + aioresponses, connector, user_bookings_get_response, user_bookings_list + ) + + async with ClientSession() as session: + bookings = await connector.get_ongoing_bookings(session) + + assert len(bookings) == 2 + + +@pytest.mark.asyncio +async def test_has_user_ongoing_bookings( + aioresponses, + connector, + user, + user_has_ongoing_bookings_from_start, +): + set_full_user_bookings_responses( + aioresponses, connector, user_has_ongoing_bookings_from_start + ) + + has_bookings = await connector.has_user_ongoing_booking(user) + + assert has_bookings + + +@pytest.mark.asyncio +async def test_has_user_ongoing_bookings( + aioresponses, + connector, + user, + user_has_no_ongoing_bookings_from_start, +): + set_full_user_bookings_responses( + aioresponses, connector, user_has_no_ongoing_bookings_from_start + ) + has_bookings = await connector.has_user_ongoing_booking(user) + + assert not has_bookings + + +@pytest.mark.asyncio +async def test_cancellation_request( + aioresponses, connector, user_bookings_get_response, cancellation_response +): + set_hash_response(aioresponses, connector, user_bookings_get_response) + set_cancellation_response(aioresponses, connector, cancellation_response) + + async with ClientSession() as session: + response = await connector.send_cancellation_request(session, 123) + + assert await response.json() == cancellation_response + + +@pytest.mark.asyncio +async def test_cancel_booking_id( + aioresponses, connector, user, cancellation_by_id_from_start +): + set_full_cancellation_by_id_responses( + aioresponses, connector, cancellation_by_id_from_start + ) + + response = await connector.cancel_booking_id(user, 132) + + assert await response.json() == cancellation_by_id_from_start[3] + + +@pytest.mark.asyncio +async def test_cancel_booking_success( + aioresponses, + connector, + user, + cancellation_success_booking_filter, + cancellation_success_from_start, +): + set_full_cancellation_responses( + aioresponses, connector, cancellation_success_from_start + ) + + response = await connector.cancel_booking(user, cancellation_success_booking_filter) + + assert await response.json() == cancellation_success_from_start[4] diff --git a/tests/utils.py b/tests/utils.py index c05ff6b..7d769c0 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,59 +1,8 @@ -from urllib.parse import urljoin - import pendulum from models import BookingFilter, Club from pendulum import DateTime -from tests.fixtures import ( - a_booking_failure_response, - a_booking_filter, - a_booking_success_response, - mes_resas_html, -) - - -def mock_successful_connection(aio_mock, url): - """ - Mock a call to the connection endpoint - - :param aio_mock: the aioresponses mock object - :param url: the URL of the connection endpoint - """ - aio_mock.get( - url, - status=200, - headers={"Set-Cookie": f"connection_called=True; Domain={url}"}, - ) - - -def mock_successful_login(aio_mock, url): - """ - Mock a call to the login endpoint - - :param aio_mock: the aioresponses mock object - :param url: the URL of the login endpoint - """ - aio_mock.post( - url, - status=200, - headers={"Set-Cookie": f"login_called=True; Domain={url}"}, - ) - - -def mock_booking(aio_mock, url, response): - """ - Mock a call to the booking endpoint - - :param aio_mock: the aioresponses mock object - :param url: the URL of the booking endpoint - :param response: the response from the booking endpoint - """ - aio_mock.post( - url, - status=200, - headers={"Set-Cookie": f"booking_called=True; Domain={url}"}, - body=response, - ) +from tests.fixtures import a_booking_filter def retrieve_booking_datetime( @@ -74,48 +23,3 @@ def retrieve_booking_datetime( return date_to_book.subtract(days=booking_opening.days_before).at( booking_hour, booking_minute ) - - -def mock_get_users_booking(aio_mock, url: str, booking_response: str): - return aio_mock.get(url, body=booking_response) - - -def mock_post_users_booking(aio_mock, url: str): - return aio_mock.post(url, payload=[]) - - -def mock_rest_api_from_connection_to_booking( - aio_mock, - url: str, - a_booking_failure_response: str, - a_booking_success_response: str, - mes_resas_html: str, -): - """ - Mock a REST API from a club. - It mocks the calls to the connexion to the website, a call to log in the user - and 2 calls to the booking endpoint - - :param aio_mock: the pendulum.now() mock - :param url: the API root URL - :param a_booking_success_response: the success json response - :param a_booking_failure_response: the failure json response - :param mes_resas_html: the html response for getting the bookings - :return: - """ - connexion_url = urljoin(url, "/connexion.php?") - mock_successful_connection(aio_mock, connexion_url) - mock_successful_connection(aio_mock, connexion_url) - - login_url = urljoin(url, "/connexion.php?") - mock_successful_login(aio_mock, login_url) - mock_successful_login(aio_mock, login_url) - - users_bookings_url = urljoin(url, "/membre/mesresas.html") - mock_get_users_booking(aio_mock, users_bookings_url, mes_resas_html) - mock_post_users_booking(aio_mock, users_bookings_url) - - booking_url = urljoin(url, "/membre/reservation.html?") - mock_booking(aio_mock, booking_url, a_booking_failure_response) - mock_booking(aio_mock, booking_url, a_booking_success_response) - mock_booking(aio_mock, booking_url, a_booking_failure_response) From bcd8dc0733f4b52936c10a6517480d025dee1227 Mon Sep 17 00:00:00 2001 From: stanislas Date: Tue, 19 Mar 2024 00:00:59 +0100 Subject: [PATCH 3/7] Added a lot of unit tests --- tests/integration_tests/test_connectors.py | 25 +- tests/unit_tests/conftest.py | 240 +++++++++++------- tests/unit_tests/test_cancellation.py | 0 .../test_gestion_sports_connector.py | 13 +- tests/utils.py | 25 -- 5 files changed, 182 insertions(+), 121 deletions(-) delete mode 100644 tests/unit_tests/test_cancellation.py delete mode 100644 tests/utils.py diff --git a/tests/integration_tests/test_connectors.py b/tests/integration_tests/test_connectors.py index 6c262bc..b9e8d4c 100644 --- a/tests/integration_tests/test_connectors.py +++ b/tests/integration_tests/test_connectors.py @@ -8,10 +8,29 @@ import pendulum import pytest from aiohttp import ClientSession from connectors import GestionSportsConnector -from models import Booking, BookingFilter, User +from models import Booking, BookingFilter, Club, User +from pendulum import DateTime from yarl import URL -from tests import utils + +def retrieve_booking_datetime( + a_booking_filter: BookingFilter, a_club: Club +) -> DateTime: + """ + Utility to retrieve the booking datetime from the booking filter and the club + + :param a_booking_filter: the booking filter that contains the date to book + :param a_club: the club which has the number of days before the date and the booking time + """ + booking_opening = a_club.booking_platform.booking_opening + opening_time = pendulum.parse(booking_opening.opening_time) + booking_hour = opening_time.hour + booking_minute = opening_time.minute + + date_to_book = a_booking_filter.date + return date_to_book.subtract(days=booking_opening.days_before).at( + booking_hour, booking_minute + ) @patch.dict( @@ -216,7 +235,7 @@ def test_wait_until_booking_time(mock_now): sport_name="Padel", date=pendulum.parse("2024-03-21T13:30:00Z") ) - booking_datetime = utils.retrieve_booking_datetime(booking_filter, club) + booking_datetime = retrieve_booking_datetime(booking_filter, club) seconds = [ booking_datetime.subtract(seconds=3), diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index e301c8a..a02afff 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -20,99 +20,167 @@ TEST_FOLDER = Path(__file__).parent.parent DATA_FOLDER = TEST_FOLDER / "data" RESPONSES_FOLDER = DATA_FOLDER / "responses" -court11 = Court(id="1", name="Court 1", number=1, isIndoor=True) -court12 = Court(id="2", name="Court 2", number=2, isIndoor=False) -court13 = Court(id="3", name="Court 3", number=3, isIndoor=True) -court14 = Court(id="4", name="Court 4", number=4, isIndoor=True) -sport1 = Sport( - name="Sport1", - id=8, - duration=99, - price=54, - players=3, - courts=[court11, court12, court13, court14], -) - -court21 = Court(id="1", name="Court 1", number=1, isIndoor=False) -court22 = Court(id="2", name="Court 2", number=2, isIndoor=True) -court23 = Court(id="3", name="Court 3", number=3, isIndoor=True) -court24 = Court(id="4", name="Court 4", number=4, isIndoor=True) - -sport2 = Sport( - name="Sport 2", - id=10, - duration=44, - price=23, - players=1, - courts=[court21, court22, court23, court24], -) - -landing_url = Url( - name="landing-page", - path="landing.html", -) - -login_url = Url( - name="login", - path="login.html", - payloadTemplate="gestion-sports/login-payload.txt", -) - -booking_url = Url( - name="booking", - path="booking.html", - payloadTemplate="gestion-sports/booking-payload.txt", -) - -user_bookings_url = Url( - name="user-bookings", - path="user_bookings.html", - payloadTemplate="gestion-sports/user-bookings-payload.txt", -) - -cancellation_url = Url( - name="cancellation", - path="cancel.html", - payloadTemplate="gestion-sports/booking-cancellation-payload.txt", -) - -booking_opening = BookingOpening(daysBefore=10, time="03:27") - -total_bookings = TotalBookings(peakHours=3, offPeakHours="unlimited") - -booking_platform = BookingPlatform( - id="gestion-sports", - clubId=21, - url="https://ptf1.com", - hoursBeforeCancellation=7, - bookingOpening=booking_opening, - totalBookings=total_bookings, - sports=[sport1, sport2], - urls={ - "landing-page": landing_url, - "login": login_url, - "booking": booking_url, - "user-bookings": user_bookings_url, - "cancellation": cancellation_url, - }, -) - -club = Club( - id="super_club", - name="Super Club", - url="https://superclub.com", - bookingPlatform=booking_platform, -) +@pytest.fixture +def court11() -> Court: + return Court(id="1", name="Court 1", number=1, isIndoor=True) @pytest.fixture -def a_club() -> Club: - return club +def court12() -> Court: + return Court(id="2", name="Court 2", number=2, isIndoor=False) @pytest.fixture -def connector() -> GestionSportsConnector: +def court13() -> Court: + return Court(id="3", name="Court 3", number=3, isIndoor=True) + + +@pytest.fixture +def court14() -> Court: + return Court(id="4", name="Court 4", number=4, isIndoor=True) + + +@pytest.fixture +def sport1(court11, court12, court13, court14) -> Sport: + return Sport( + name="Sport1", + id=8, + duration=99, + price=54, + players=3, + courts=[court11, court12, court13, court14], + ) + + +@pytest.fixture +def court21() -> Court: + return Court(id="1", name="Court 1", number=1, isIndoor=False) + + +@pytest.fixture +def court22() -> Court: + return Court(id="2", name="Court 2", number=2, isIndoor=True) + + +@pytest.fixture +def court23() -> Court: + return Court(id="3", name="Court 3", number=3, isIndoor=True) + + +@pytest.fixture +def court24() -> Court: + return Court(id="4", name="Court 4", number=4, isIndoor=True) + + +@pytest.fixture +def sport2(court21, court22, court23, court24) -> Sport: + return Sport( + name="Sport 2", + id=10, + duration=44, + price=23, + players=1, + courts=[court21, court22, court23, court24], + ) + + +@pytest.fixture +def landing_url() -> Url: + return Url( + name="landing-page", + path="landing.html", + ) + + +@pytest.fixture +def login_url() -> Url: + return Url( + name="login", + path="login.html", + payloadTemplate="gestion-sports/login-payload.txt", + ) + + +@pytest.fixture +def booking_url() -> Url: + return Url( + name="booking", + path="booking.html", + payloadTemplate="gestion-sports/booking-payload.txt", + ) + + +@pytest.fixture +def user_bookings_url() -> Url: + return Url( + name="user-bookings", + path="user_bookings.html", + payloadTemplate="gestion-sports/user-bookings-payload.txt", + ) + + +@pytest.fixture +def cancellation_url() -> Url: + return Url( + name="cancellation", + path="cancel.html", + payloadTemplate="gestion-sports/booking-cancellation-payload.txt", + ) + + +@pytest.fixture +def booking_opening() -> BookingOpening: + return BookingOpening(daysBefore=10, time="03:27") + + +@pytest.fixture +def total_bookings() -> TotalBookings: + return TotalBookings(peakHours=3, offPeakHours="unlimited") + + +@pytest.fixture +def booking_platform( + booking_opening, + total_bookings, + sport1, + sport2, + landing_url, + login_url, + booking_url, + user_bookings_url, + cancellation_url, +) -> BookingPlatform: + return BookingPlatform( + id="gestion-sports", + clubId=21, + url="https://ptf1.com", + hoursBeforeCancellation=7, + bookingOpening=booking_opening, + totalBookings=total_bookings, + sports=[sport1, sport2], + urls={ + "landing-page": landing_url, + "login": login_url, + "booking": booking_url, + "user-bookings": user_bookings_url, + "cancellation": cancellation_url, + }, + ) + + +@pytest.fixture +def club(booking_platform) -> Club: + return Club( + id="super_club", + name="Super Club", + url="https://superclub.com", + bookingPlatform=booking_platform, + ) + + +@pytest.fixture +def connector(club) -> GestionSportsConnector: return GestionSportsConnector(club) diff --git a/tests/unit_tests/test_cancellation.py b/tests/unit_tests/test_cancellation.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/unit_tests/test_gestion_sports_connector.py b/tests/unit_tests/test_gestion_sports_connector.py index 7ba5b52..e7c3972 100644 --- a/tests/unit_tests/test_gestion_sports_connector.py +++ b/tests/unit_tests/test_gestion_sports_connector.py @@ -91,10 +91,9 @@ def set_full_cancellation_responses(aioresponses, connector, responses): set_cancellation_response(aioresponses, connector, responses[4]) -def test_urls(a_club): - connector = GestionSportsConnector(a_club) - base_url = a_club.booking_platform.url - relative_urls = a_club.booking_platform.urls +def test_urls(connector, club): + base_url = club.booking_platform.url + relative_urls = club.booking_platform.urls relative_landing_url = relative_urls.get("landing-page").path assert connector.landing_url == f"{base_url}/{relative_landing_url}" @@ -113,12 +112,12 @@ def test_urls(a_club): @patch("config.get_resources_folder") -def test_urls_payload_templates(mock_resources, a_club): +def test_urls_payload_templates(mock_resources, club): path_to_resources = Path("some/path/to/resource") mock_resources.return_value = path_to_resources - connector = GestionSportsConnector(a_club) - relative_urls = a_club.booking_platform.urls + connector = GestionSportsConnector(club) + relative_urls = club.booking_platform.urls login_payload = relative_urls.get("login").payload_template assert connector.login_template == path_to_resources / login_payload diff --git a/tests/utils.py b/tests/utils.py deleted file mode 100644 index 7d769c0..0000000 --- a/tests/utils.py +++ /dev/null @@ -1,25 +0,0 @@ -import pendulum -from models import BookingFilter, Club -from pendulum import DateTime - -from tests.fixtures import a_booking_filter - - -def retrieve_booking_datetime( - a_booking_filter: BookingFilter, a_club: Club -) -> DateTime: - """ - Utility to retrieve the booking datetime from the booking filter and the club - - :param a_booking_filter: the booking filter that contains the date to book - :param a_club: the club which has the number of days before the date and the booking time - """ - booking_opening = a_club.booking_platform.booking_opening - opening_time = pendulum.parse(booking_opening.opening_time) - booking_hour = opening_time.hour - booking_minute = opening_time.minute - - date_to_book = a_booking_filter.date - return date_to_book.subtract(days=booking_opening.days_before).at( - booking_hour, booking_minute - ) From e6023e06874bb604cb8b5273f8bf421cb95a54a8 Mon Sep 17 00:00:00 2001 From: stanislas Date: Wed, 20 Mar 2024 23:11:43 +0100 Subject: [PATCH 4/7] created a gestion sports services class that handles the connection while the connector is dedicated to the requests --- resa_padel/booking.py | 21 +- resa_padel/connectors.py | 191 +++---------- resa_padel/gestion_sports_services.py | 127 +++++++++ tests/integration_tests/conftest.py | 46 ++++ tests/integration_tests/test_booking.py | 21 +- tests/integration_tests/test_connectors.py | 253 +++--------------- .../test_gestion_sports_services.py | 20 ++ tests/unit_tests/conftest.py | 27 ++ tests/unit_tests/responses.py | 83 ++++++ tests/unit_tests/test_config.py | 2 +- .../test_gestion_sports_connector.py | 205 ++------------ .../test_gestion_sports_services.py | 110 ++++++++ 12 files changed, 513 insertions(+), 593 deletions(-) create mode 100644 resa_padel/gestion_sports_services.py create mode 100644 tests/integration_tests/conftest.py create mode 100644 tests/integration_tests/test_gestion_sports_services.py create mode 100644 tests/unit_tests/responses.py create mode 100644 tests/unit_tests/test_gestion_sports_services.py diff --git a/resa_padel/booking.py b/resa_padel/booking.py index 7ac2576..c3271f4 100644 --- a/resa_padel/booking.py +++ b/resa_padel/booking.py @@ -2,17 +2,12 @@ import asyncio import logging import config -from connectors import Connector, GestionSportsConnector +from gestion_sports_services import GestionSportsServices from models import Action, BookingFilter, Club, Court, User LOGGER = logging.getLogger(__name__) -def get_connector(club: Club) -> Connector: - if club.booking_platform.id == "gestion-sports": - return GestionSportsConnector(club) - - async def book_court( club: Club, users: list[User], booking_filter: BookingFilter ) -> tuple[Court, User]: @@ -26,10 +21,10 @@ async def book_court( :return: a tuple containing the court that was booked and the user who made the booking """ - connector = get_connector(club) + service = GestionSportsServices() for user in users: - if not await connector.has_user_ongoing_booking(user): - return await connector.book(user, booking_filter), user + if not await service.has_user_available_slots(user, club): + return await service.book(club, user, booking_filter), user async def cancel_booking(club: Club, user: User, booking_filter: BookingFilter) -> None: @@ -40,8 +35,8 @@ async def cancel_booking(club: Club, user: User, booking_filter: BookingFilter) :param user: the user who made the booking :param booking_filter: the conditions to meet to cancel the booking """ - connector = get_connector(club) - await connector.cancel_booking(user, booking_filter) + service = GestionSportsServices() + await service.cancel_booking(user, club, booking_filter) async def cancel_booking_id(club: Club, user: User, booking_id: int) -> None: @@ -52,8 +47,8 @@ async def cancel_booking_id(club: Club, user: User, booking_id: int) -> None: :param user: the user who made the booking :param booking_id: the id of the booking to cancel """ - connector = get_connector(club) - await connector.cancel_booking_id(user, booking_id) + service = GestionSportsServices() + await service.cancel_booking_id(user, club, booking_id) def main() -> tuple[Court, User] | None: diff --git a/resa_padel/connectors.py b/resa_padel/connectors.py index 25c6c90..5b9469e 100644 --- a/resa_padel/connectors.py +++ b/resa_padel/connectors.py @@ -1,13 +1,10 @@ import asyncio import json import logging -import time -from abc import ABC, abstractmethod from pathlib import Path from urllib.parse import urljoin import config -import pendulum from aiohttp import ClientResponse, ClientSession from bs4 import BeautifulSoup from models import Booking, BookingFilter, Club, Court, Sport, User @@ -19,55 +16,7 @@ LOGGER = logging.getLogger(__name__) POST_HEADERS = config.get_post_headers("gestion-sports") -class Connector(ABC): - """ - Abstract class that defines the method a connector - to a website for sport booking should have - """ - - @abstractmethod - async def book(self, user: User, booking_filter: BookingFilter) -> Court | None: - """ - Book a court matching the filter for a user - - :param user: the user who will have the booking - :param booking_filter: the conditions to book (date, time, court) - :return: the court booked - """ - pass - - @abstractmethod - async def has_user_ongoing_booking(self, user: User) -> bool: - """ - Test whether the user has ongoing bookings - - :param user: the user who will have the booking - :return: true if the user has at least one ongoing booking, false otherwise - """ - pass - - @abstractmethod - async def cancel_booking_id(self, user: User, booking_id: int) -> None: - """ - Cancel the booking for a given user - - :param user: the user who has the booking - :param booking_id: the id of the booking - """ - pass - - @abstractmethod - async def cancel_booking(self, user: User, booking_filter: BookingFilter) -> None: - """ - Cancel the booking that meet some conditions for a given user - - :param user: the user who has the booking - :param booking_filter: the booking conditions to meet to cancel the booking - """ - pass - - -class GestionSportsConnector(Connector): +class GestionSportsConnector: """ The connector for the Gestion Sports platform. It handles all the requests to the website. @@ -266,14 +215,16 @@ class GestionSportsConnector(Connector): LOGGER.debug("Connexion request response:\n%s", resp_text) return response - async def book(self, user: User, booking_filter: BookingFilter) -> Court | None: + async def book_any_court( + self, session: ClientSession, booking_filter: BookingFilter + ) -> list[tuple[int, dict]]: """ Perform a request for each court at the same time to increase the chances to get a booking. The gestion-sports backend does not allow several bookings at the same time so there is no need to make each request one after the other - :param user: the user that wants to book the court + :param session: the session to use :param booking_filter: the booking conditions to meet :return: the booked court, or None if no court was booked """ @@ -283,24 +234,18 @@ class GestionSportsConnector(Connector): sport = self.available_sports.get(booking_filter.sport_name) - async with ClientSession() as session: - # use asyncio to request a booking on every court - # the gestion-sports backend is able to book only one court for a user - await self.land(session) - await self.login(session, user) - self.wait_until_booking_time(booking_filter) - bookings = await asyncio.gather( - *[ - self.send_booking_request( - session, booking_filter.date, court.id, sport.id - ) - for court in sport.courts - ], - return_exceptions=True, - ) + bookings = await asyncio.gather( + *[ + self.send_booking_request( + session, booking_filter.date, court.id, sport.id + ) + for court in sport.courts + ], + return_exceptions=True, + ) LOGGER.debug("Booking results:\n'%s'", bookings) - return self.get_booked_court(bookings, sport.name) + return bookings async def send_booking_request( self, @@ -308,7 +253,7 @@ class GestionSportsConnector(Connector): date: DateTime, court_id: int, sport_id: int, - ) -> tuple[ClientResponse, int, bool]: + ) -> tuple[int, dict]: """ Book a single court that meets the conditions from the booking filter @@ -316,7 +261,7 @@ class GestionSportsConnector(Connector): :param date: the booking date :param court_id: the id of the court to book :param sport_id: the id of the sport - :return: a tuple containing the court id and the booking status + :return: a tuple containing the court id and the response """ LOGGER.debug("Booking court %s at %s", court_id, date.to_w3c_string()) payload = PayloadBuilder.build( @@ -332,12 +277,12 @@ class GestionSportsConnector(Connector): self.booking_url, data=payload, headers=POST_HEADERS ) as response: assert response.status == 200 - resp_json = await response.text() + resp_json = json.loads(await response.text()) LOGGER.debug("Response from booking request:\n'%s'", resp_json) - return response, court_id, self.is_booking_response_status_ok(resp_json) + return court_id, resp_json def get_booked_court( - self, bookings: list[tuple[ClientSession, int, bool]], sport_name: str + self, bookings: list[tuple[int, dict]], sport_name: str ) -> Court | None: """ Parse the booking list and return the court that was booked @@ -346,8 +291,8 @@ class GestionSportsConnector(Connector): :param sport_name: the sport name :return: the id of the booked court if any, None otherwise """ - for _, court_id, is_booked in bookings: - if is_booked: + for court_id, response in bookings: + if self.is_booking_response_status_ok(response): LOGGER.debug("Court %d is booked", court_id) court_booked = self.find_court(court_id, sport_name) LOGGER.info("Court '%s' is booked", court_booked.name) @@ -369,70 +314,14 @@ class GestionSportsConnector(Connector): return court @staticmethod - def is_booking_response_status_ok(response: str) -> bool: + def is_booking_response_status_ok(response: dict) -> bool: """ Check if the booking response is OK :param response: the response as a string :return: true if the status is ok, false otherwise """ - formatted_result = response.removeprefix('"').removesuffix('"') - result_json = json.loads(formatted_result) - return result_json["status"] == "ok" - - def build_booking_datetime(self, booking_filter: BookingFilter) -> DateTime: - """ - Build the date and time when the booking is open for a given match date. - The booking filter contains the date and time of the booking. - The club has the information about when the booking is open for that date. - - :param booking_filter: the booking information - :return: the date and time when the booking is open - """ - date_to_book = booking_filter.date - booking_opening = self.club.booking_platform.booking_opening - booking_date = date_to_book.subtract(days=booking_opening.days_before) - - opening_time = pendulum.parse(booking_opening.opening_time) - booking_hour = opening_time.hour - booking_minute = opening_time.minute - - return booking_date.at(booking_hour, booking_minute) - - def wait_until_booking_time(self, booking_filter: BookingFilter) -> None: - """ - Wait until the booking is open. - The booking filter contains the date and time of the booking. - The club has the information about when the booking is open for that date. - - :param booking_filter: the booking information - """ - LOGGER.info("Waiting for booking time") - booking_datetime = self.build_booking_datetime(booking_filter) - now = pendulum.now() - duration_until_booking = booking_datetime - now - LOGGER.debug(f"Current time: {now}, Datetime to book: {booking_datetime}") - LOGGER.debug( - f"Time to wait before booking: {duration_until_booking.hours:0>2}" - f":{duration_until_booking.minutes:0>2}" - f":{duration_until_booking.seconds:0>2}" - ) - - while now < booking_datetime: - time.sleep(1) - now = pendulum.now() - LOGGER.info("It's booking time!") - - async def has_user_ongoing_booking(self, user: User) -> bool: - """ - Check if the user currently has bookings in the future - :param user: the user to check the bookings - :return: true if the user has some bookings, false otherwise - """ - async with ClientSession() as session: - await self.land(session) - await self.login(session, user) - return bool(await self.get_ongoing_bookings(session)) + return response["status"] == "ok" async def get_ongoing_bookings(self, session: ClientSession) -> list[Booking]: """ @@ -494,21 +383,7 @@ class GestionSportsConnector(Connector): LOGGER.debug("ongoing bookings response: %s\n", resp) return [Booking(**booking) for booking in json.loads(resp)] - async def cancel_booking_id(self, user: User, booking_id: int) -> ClientResponse: - """ - Cancel a booking based on its id for a given user - - :param user: the user that has the booking - :param booking_id: the id of the booking to cancel - :return: the response from the client - """ - async with ClientSession() as session: - await self.land(session) - await self.login(session, user) - - return await self.send_cancellation_request(session, booking_id) - - async def send_cancellation_request( + async def cancel_booking_id( self, session: ClientSession, booking_id: int ) -> ClientResponse: """ @@ -533,20 +408,16 @@ class GestionSportsConnector(Connector): return response async def cancel_booking( - self, user: User, booking_filter: BookingFilter + self, session: ClientSession, booking_filter: BookingFilter ) -> ClientResponse | None: """ Cancel the booking that meets some conditions - :param user: the user who owns the booking + :param session: the session :param booking_filter: the conditions the booking to cancel should meet """ - async with ClientSession() as session: - await self.land(session) - await self.login(session, user) + bookings = await self.get_ongoing_bookings(session) - bookings = await self.get_ongoing_bookings(session) - - for booking in bookings: - if booking.matches(booking_filter): - return await self.send_cancellation_request(session, booking.id) + for booking in bookings: + if booking.matches(booking_filter): + return await self.cancel_booking_id(session, booking.id) diff --git a/resa_padel/gestion_sports_services.py b/resa_padel/gestion_sports_services.py new file mode 100644 index 0000000..d196eba --- /dev/null +++ b/resa_padel/gestion_sports_services.py @@ -0,0 +1,127 @@ +import logging +import time + +import pendulum +from aiohttp import ClientSession +from connectors import GestionSportsConnector +from models import BookingFilter, BookingOpening, Club, Court, User +from pendulum import DateTime + +LOGGER = logging.getLogger(__name__) + + +class GestionSportsServices: + @staticmethod + async def book( + club: Club, user: User, booking_filter: BookingFilter + ) -> Court | None: + """ + Perform a request for each court at the same time to increase the chances to get + a booking. + The gestion-sports backend does not allow several bookings at the same time + so there is no need to make each request one after the other + + :param club: the club in which the booking will be made + :param user: the user that wants to book the court + :param booking_filter: the booking conditions to meet + :return: the booked court, or None if no court was booked + """ + connector = GestionSportsConnector(club) + LOGGER.info( + "Booking any available court from GestionSports API at %s", + connector.booking_url, + ) + + async with ClientSession() as session: + # use asyncio to request a booking on every court + # the gestion-sports backend is able to book only one court for a user + await connector.land(session) + await connector.login(session, user) + + booking_opening = club.booking_platform.booking_opening + GestionSportsServices.wait_until_booking_time( + booking_filter, booking_opening + ) + + bookings = await connector.book_any_court(session, booking_filter) + + LOGGER.debug("Booking results:\n'%s'", bookings) + return connector.get_booked_court(bookings, booking_filter.sport_name) + + @staticmethod + async def has_user_available_slots(user: User, club: Club) -> bool: + connector = GestionSportsConnector(club) + async with ClientSession() as session: + await connector.land(session) + await connector.login(session, user) + bookings = await connector.get_ongoing_bookings(session) + + return bool(bookings) + + @staticmethod + async def cancel_booking(user: User, club: Club, booking_filter: BookingFilter): + connector = GestionSportsConnector(club) + async with ClientSession() as session: + await connector.land(session) + await connector.login(session, user) + await connector.cancel_booking(session, booking_filter) + + @staticmethod + async def cancel_booking_id(user: User, club: Club, booking_id: int): + connector = GestionSportsConnector(club) + async with ClientSession() as session: + await connector.land(session) + await connector.login(session, user) + await connector.cancel_booking_id(session, booking_id) + + @staticmethod + def wait_until_booking_time( + booking_filter: BookingFilter, booking_opening: BookingOpening + ) -> None: + """ + Wait until the booking is open. + The booking filter contains the date and time of the booking. + The club has the information about when the booking is open for that date. + + :param booking_opening: + :param booking_filter: the booking information + """ + LOGGER.info("Waiting for booking time") + booking_datetime = GestionSportsServices.build_booking_datetime( + booking_filter, booking_opening + ) + now = pendulum.now() + duration_until_booking = booking_datetime - now + LOGGER.debug(f"Current time: {now}, Datetime to book: {booking_datetime}") + LOGGER.debug( + f"Time to wait before booking: {duration_until_booking.hours:0>2}" + f":{duration_until_booking.minutes:0>2}" + f":{duration_until_booking.seconds:0>2}" + ) + + while now < booking_datetime: + time.sleep(1) + now = pendulum.now() + LOGGER.info("It's booking time!") + + @staticmethod + def build_booking_datetime( + booking_filter: BookingFilter, booking_opening: BookingOpening + ) -> DateTime: + """ + Build the date and time when the booking is open for a given match date. + The booking filter contains the date and time of the booking. + The club has the information about when the booking is open for that date. + + :param booking_opening:the booking opening conditions + :param booking_filter: the booking information + :return: the date and time when the booking is open + """ + date_to_book = booking_filter.date + booking_date = date_to_book.subtract(days=booking_opening.days_before) + + opening_time = pendulum.parse(booking_opening.opening_time) + booking_hour = opening_time.hour + booking_minute = opening_time.minute + + return booking_date.at(booking_hour, booking_minute) diff --git a/tests/integration_tests/conftest.py b/tests/integration_tests/conftest.py new file mode 100644 index 0000000..0e10773 --- /dev/null +++ b/tests/integration_tests/conftest.py @@ -0,0 +1,46 @@ +import json +from pathlib import Path + +import config +import pendulum +import pytest +from connectors import GestionSportsConnector +from models import BookingFilter, Club, User + +TEST_FOLDER = Path(__file__).parent.parent +DATA_FOLDER = TEST_FOLDER / "data" +RESPONSES_FOLDER = DATA_FOLDER / "responses" + + +@pytest.fixture +def club() -> Club: + return config.get_clubs()["tpc"] + + +@pytest.fixture +def connector(club) -> GestionSportsConnector: + return GestionSportsConnector(club) + + +@pytest.fixture +def user() -> User: + return User(login="padel.testing@jouf.fr", password="ridicule") + + +@pytest.fixture +def booking_filter() -> BookingFilter: + return BookingFilter( + sport_name="Padel", date=pendulum.parse("2024-03-21T13:30:00+01:00") + ) + + +@pytest.fixture +def booking_success_response() -> dict: + booking_success_file = RESPONSES_FOLDER / "booking_success.json" + return json.loads(booking_success_file.read_text(encoding="utf-8")) + + +@pytest.fixture +def booking_failure_response() -> dict: + booking_failure_file = RESPONSES_FOLDER / "booking_failure.json" + return json.loads(booking_failure_file.read_text(encoding="utf-8")) diff --git a/tests/integration_tests/test_booking.py b/tests/integration_tests/test_booking.py index 7d738ed..22e9613 100644 --- a/tests/integration_tests/test_booking.py +++ b/tests/integration_tests/test_booking.py @@ -2,10 +2,6 @@ import asyncio import os from unittest.mock import patch -import config -import pendulum -from models import BookingFilter, User - from resa_padel import booking @@ -14,12 +10,7 @@ from resa_padel import booking {"CLUB_ID": "tpc"}, clear=True, ) -def test_booking(): - club = config.get_club() - user = User(login="padel.testing@jouf.fr", password="ridicule") - booking_filter = BookingFilter( - sport_name="Padel", date=pendulum.parse("2024-03-21T13:30:00+01:00") - ) +def test_booking(club, user, booking_filter): booked_court, user_that_booked = asyncio.run( booking.book_court(club, [user], booking_filter) ) @@ -32,10 +23,8 @@ def test_booking(): {"CLUB_ID": "tpc"}, clear=True, ) -def test_cancellation(): - club = config.get_club() - user = User(login="padel.testing@jouf.fr", password="ridicule") - asyncio.run(booking.cancel_booking_id(club, user, 3605033)) +def test_cancellation(club, user, booking_filter): + asyncio.run(booking.cancel_booking(club, user, booking_filter)) @patch.dict( @@ -49,9 +38,9 @@ def test_cancellation(): clear=True, ) def test_main_booking(): - court, user = booking.main() + court, player = booking.main() assert court is not None - assert user.username == "padel.testing@jouf" + assert player.login == "padel.testing@jouf.fr" @patch.dict( diff --git a/tests/integration_tests/test_connectors.py b/tests/integration_tests/test_connectors.py index b9e8d4c..a5f3f88 100644 --- a/tests/integration_tests/test_connectors.py +++ b/tests/integration_tests/test_connectors.py @@ -3,12 +3,10 @@ from pathlib import Path from unittest.mock import patch import aiohttp -import config import pendulum import pytest -from aiohttp import ClientSession from connectors import GestionSportsConnector -from models import Booking, BookingFilter, Club, User +from models import BookingFilter, Club from pendulum import DateTime from yarl import URL @@ -38,9 +36,7 @@ def retrieve_booking_datetime( {"CLUB_ID": "tpc"}, clear=True, ) -def test_urls(): - club = config.get_club() - connector = GestionSportsConnector(club) +def test_urls(connector): assert ( connector.landing_url == "https://toulousepadelclub.gestion-sports.com/connexion.php" @@ -65,12 +61,10 @@ def test_urls(): @patch.dict( os.environ, - {"CLUB_ID": "tpc", "RESOURCES_FOLDER": "/some/path"}, + {"RESOURCES_FOLDER": "/some/path"}, clear=True, ) -def test_urls_payload_templates(): - club = config.get_club() - connector = GestionSportsConnector(club) +def test_urls_payload_templates(connector): resources_folder = Path("/some", "path", "gestion-sports") assert connector.login_template == resources_folder / "login-payload.txt" assert connector.booking_template == resources_folder / "booking-payload.txt" @@ -84,15 +78,8 @@ def test_urls_payload_templates(): ) -@patch.dict( - os.environ, - {"CLUB_ID": "tpc"}, - clear=True, -) @pytest.mark.asyncio -async def test_landing_page(): - club = config.get_club() - connector = GestionSportsConnector(club) +async def test_landing_page(connector): async with aiohttp.ClientSession() as session: response = await connector.land(session) @@ -104,17 +91,8 @@ async def test_landing_page(): assert response.cookies.get("PHPSESSID") is not None -@patch.dict( - os.environ, - {"CLUB_ID": "tpc"}, - clear=True, -) @pytest.mark.asyncio -async def test_login(): - club = config.get_club() - connector = GestionSportsConnector(club) - user = User(login="padel.testing@jouf.fr", password="ridicule") - +async def test_login(connector, user): async with aiohttp.ClientSession() as session: await connector.land(session) @@ -130,90 +108,46 @@ async def test_login(): assert response.cookies.get("COOK_ID_USER").value == "232382" -@patch.dict( - os.environ, - {"CLUB_ID": "tpc"}, - clear=True, -) -def test_get_booked_court(): - club = config.get_club() - connector = GestionSportsConnector(club) - - session = ClientSession() +def test_get_booked_court( + connector, booking_success_response, booking_failure_response +): bookings = [ - (session, 601, False), - (session, 602, False), - (session, 603, False), - (session, 614, False), - (session, 605, False), - (session, 606, True), - (session, 607, False), - (session, 608, False), + (601, booking_failure_response), + (602, booking_failure_response), + (603, booking_failure_response), + (614, booking_failure_response), + (605, booking_failure_response), + (606, booking_success_response), + (607, booking_failure_response), + (608, booking_failure_response), ] court = connector.get_booked_court(bookings, "padel") assert court.number == 9 -@patch.dict( - os.environ, - {"CLUB_ID": "tpc"}, - clear=True, -) @pytest.mark.asyncio -async def test_book_one_court(): - club = config.get_club() - connector = GestionSportsConnector(club) - user = User(login="padel.testing@jouf.fr", password="ridicule") - +async def test_book_one_court(connector, user, booking_filter): async with aiohttp.ClientSession() as session: await connector.land(session) await connector.login(session, user) - response, court_id, ok = await connector.send_booking_request( - session, pendulum.parse("2024-03-21T13:30:00Z"), 610, 217 + + court_id, response = await connector.send_booking_request( + session, pendulum.parse("2024-03-21T13:30:00+01:00"), 610, 217 ) - assert response.status == 200 - assert response.request_info.method == "POST" - assert response.content_type == "text/html" - assert response.request_info.url == URL(connector.booking_url) - assert response.charset == "UTF-8" - assert response.text is not None - assert court_id == 610 - assert ok is True + assert court_id == 610 + assert response.get("status") == "ok" -@patch.dict( - os.environ, - {"CLUB_ID": "tpc"}, - clear=True, -) @pytest.mark.asyncio -async def test_book(): - club = config.get_club() - connector = GestionSportsConnector(club) - user = User(login="padel.testing@jouf.fr", password="ridicule") - booking_filter = BookingFilter( - sport_name="Padel", date=pendulum.parse("2024-03-21T13:30:00Z") - ) - +async def test_book(connector, user, booking_filter): booked_court = await connector.book(user, booking_filter) assert booked_court is not None -@patch.dict( - os.environ, - {"CLUB_ID": "tpc"}, - clear=True, -) -def test_build_booking_datetime(): - club = config.get_club() - connector = GestionSportsConnector(club) - booking_filter = BookingFilter( - sport_name="Padel", date=pendulum.parse("2024-03-21T13:30:00Z") - ) - +def test_build_booking_datetime(connector, booking_filter): opening_datetime = connector.build_booking_datetime(booking_filter) assert opening_datetime.year == 2024 assert opening_datetime.month == 3 @@ -222,19 +156,8 @@ def test_build_booking_datetime(): assert opening_datetime.minute == 0 -@patch.dict( - os.environ, - {"CLUB_ID": "tpc"}, - clear=True, -) @patch("pendulum.now") -def test_wait_until_booking_time(mock_now): - club = config.get_club() - connector = GestionSportsConnector(club) - booking_filter = BookingFilter( - sport_name="Padel", date=pendulum.parse("2024-03-21T13:30:00Z") - ) - +def test_wait_until_booking_time(mock_now, connector, booking_filter, club): booking_datetime = retrieve_booking_datetime(booking_filter, club) seconds = [ @@ -252,17 +175,8 @@ def test_wait_until_booking_time(mock_now): assert pendulum.now() == booking_datetime.add(microseconds=1) -@patch.dict( - os.environ, - {"CLUB_ID": "tpc"}, - clear=True, -) @pytest.mark.asyncio -async def test_get_hash(): - club = config.get_club() - connector = GestionSportsConnector(club) - user = User(login="padel.testing@jouf.fr", password="ridicule") - +async def test_get_hash(connector, user): async with aiohttp.ClientSession() as session: await connector.land(session) await connector.login(session, user) @@ -281,17 +195,8 @@ def test_get_hash_input(): assert hash_value == "63470fa38e300fd503de1ee21a71b3bdb6fb206b" -@patch.dict( - os.environ, - {"CLUB_ID": "tpc"}, - clear=True, -) @pytest.mark.asyncio -async def test_get_bookings(): - club = config.get_club() - connector = GestionSportsConnector(club) - user = User(login="padel.testing@jouf.fr", password="ridicule") - +async def test_get_bookings(connector, user): async with aiohttp.ClientSession() as session: await connector.land(session) await connector.login(session, user) @@ -303,17 +208,8 @@ async def test_get_bookings(): print(bookings) -@patch.dict( - os.environ, - {"CLUB_ID": "tpc"}, - clear=True, -) @pytest.mark.asyncio -async def test_get_ongoing_bookings(): - club = config.get_club() - connector = GestionSportsConnector(club) - user = User(login="padel.testing@jouf.fr", password="ridicule") - +async def test_get_ongoing_bookings(connector, user): async with aiohttp.ClientSession() as session: await connector.land(session) await connector.login(session, user) @@ -322,31 +218,13 @@ async def test_get_ongoing_bookings(): print(bookings) -@patch.dict( - os.environ, - {"CLUB_ID": "tpc"}, - clear=True, -) @pytest.mark.asyncio -async def test_has_user_ongoing_bookings(): - club = config.get_club() - connector = GestionSportsConnector(club) - user = User(login="padel.testing@jouf.fr", password="ridicule") - +async def test_has_user_ongoing_bookings(connector, user): assert await connector.has_user_ongoing_booking(user) -@patch.dict( - os.environ, - {"CLUB_ID": "tpc"}, - clear=True, -) @pytest.mark.asyncio -async def test_cancel_booking_id(): - club = config.get_club() - connector = GestionSportsConnector(club) - user = User(login="padel.testing@jouf.fr", password="ridicule") - +async def test_cancel_booking_id(connector, user): async with aiohttp.ClientSession() as session: await connector.land(session) await connector.login(session, user) @@ -358,73 +236,16 @@ async def test_cancel_booking_id(): assert len(await connector.get_ongoing_bookings(session)) == 0 -@patch.dict( - os.environ, - {"CLUB_ID": "tpc"}, - clear=True, -) -def test_is_booking_matching_filter(): - club = config.get_club() - connector = GestionSportsConnector(club) - filter_date = pendulum.parse("2024-03-02T15:00:00+01:00") - booking = Booking( - id=1, - dateResa="02/03/2024", - startTime="15:00", - sport="Padel", - court="10", - ) - booking_filter = BookingFilter(date=filter_date, sport_name="Padel") - - assert connector.is_booking_matching_filter(booking, booking_filter) - - -@patch.dict( - os.environ, - {"CLUB_ID": "tpc"}, - clear=True, -) -def test_is_booking_not_matching_filter(): - club = config.get_club() - connector = GestionSportsConnector(club) - filter_date = pendulum.parse("2024-03-02T15:00:00+01:00") - booking = Booking( - id=1, - dateResa="02/03/2024", - startTime="16:00", - sport="Padel", - court="10", - ) - booking_filter = BookingFilter(date=filter_date, sport_name="Padel") - - assert not connector.is_booking_matching_filter(booking, booking_filter) - - -@patch.dict( - os.environ, - {"CLUB_ID": "tpc"}, - clear=True, -) @pytest.mark.asyncio -def test_find_court(): - club = config.get_club() - connector = GestionSportsConnector(club) - +def test_find_court(connector): court = connector.find_court(603, "Padel") assert court.number == 6 -@patch.dict( - os.environ, - {"CLUB_ID": "tpc"}, - clear=True, -) @pytest.mark.asyncio -async def test_cancel_booking(): - club = config.get_club() - connector = GestionSportsConnector(club) - user = User(login="padel.testing@jouf.fr", password="ridicule") - filter_date = pendulum.parse("2024-03-21T13:30:00+01:00") - booking_filter = BookingFilter(date=filter_date, sport_name="Padel") - await connector.cancel_booking(user, booking_filter) +async def test_cancel_booking(connector, user, booking_filter): + async with aiohttp.ClientSession() as session: + await connector.land(session) + await connector.login(session, user) + await connector.cancel_booking(session, booking_filter) diff --git a/tests/integration_tests/test_gestion_sports_services.py b/tests/integration_tests/test_gestion_sports_services.py new file mode 100644 index 0000000..c1ccc69 --- /dev/null +++ b/tests/integration_tests/test_gestion_sports_services.py @@ -0,0 +1,20 @@ +import pytest +from gestion_sports_services import GestionSportsServices + + +@pytest.mark.asyncio +async def test_booking_success(club, user, booking_filter): + court_booked = await GestionSportsServices.book(club, user, booking_filter) + + assert court_booked.id is not None + + +@pytest.mark.asyncio +async def test_user_has_available_slots(club, user): + has_slots = await GestionSportsServices.has_user_available_slots(user, club) + assert has_slots + + +@pytest.mark.asyncio +async def test_cancel_booking(club, user, booking_filter): + await GestionSportsServices.cancel_booking(user, club, booking_filter) diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index a02afff..8ab4b06 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -4,6 +4,7 @@ from pathlib import Path import pendulum import pytest from connectors import GestionSportsConnector +from gestion_sports_services import GestionSportsServices from models import ( BookingFilter, BookingOpening, @@ -184,6 +185,11 @@ def connector(club) -> GestionSportsConnector: return GestionSportsConnector(club) +@pytest.fixture +def gs_services() -> GestionSportsServices: + return GestionSportsServices() + + @pytest.fixture def user() -> User: return User(login="padel.testing@jouf.fr", password="ridicule") @@ -226,6 +232,22 @@ def booking_failure_response() -> dict: return json.loads(booking_failure_file.read_text(encoding="utf-8")) +@pytest.fixture +def booked_courts_response( + court11, + court12, + court13, + court14, + booking_success_response, + booking_failure_response, +) -> list[tuple[int, dict]]: + court1_resp = court11.id, booking_failure_response + court2_resp = court12.id, booking_failure_response + court3_resp = court13.id, booking_success_response + court4_resp = court14.id, booking_failure_response + return [court1_resp, court2_resp, court3_resp, court4_resp] + + @pytest.fixture def booking_success_from_start( landing_response, @@ -350,3 +372,8 @@ def cancellation_success_booking_filter() -> BookingFilter: return BookingFilter( sport_name="Sport1", date=pendulum.parse("2024-03-21T13:30:00Z") ) + + +@pytest.fixture +def service() -> GestionSportsServices: + return GestionSportsServices() diff --git a/tests/unit_tests/responses.py b/tests/unit_tests/responses.py new file mode 100644 index 0000000..35982f9 --- /dev/null +++ b/tests/unit_tests/responses.py @@ -0,0 +1,83 @@ +def make_landing_request_success(aioresponses, connector, landing_response): + aioresponses.get( + connector.landing_url, + status=200, + headers={"Set-Cookie": "PHPSESSID=987512"}, + body=landing_response, + ) + + +def make_login_request_fail(aioresponses, connector, login_failure_response): + aioresponses.post( + connector.login_url, + status=200, + payload=login_failure_response, + ) + + +def make_login_request_success(aioresponses, connector, login_success_response): + aioresponses.post( + connector.login_url, + status=200, + headers={"Set-Cookie": "COOK_COMPTE=e2be1;" "COOK_ID_CLUB=22;COOK_ID_USER=666"}, + payload=login_success_response, + ) + + +def set_booking_request(aioresponses, connector, booking_response): + aioresponses.post(connector.booking_url, status=200, payload=booking_response) + + +def set_full_booking_requests_responses(aioresponses, connector, responses_list): + make_landing_request_success(aioresponses, connector, responses_list[0]) + make_login_request_success(aioresponses, connector, responses_list[1]) + for response in responses_list[2:]: + set_booking_request(aioresponses, connector, response) + + +def set_ongoing_bookings_response( + aioresponses, connector, user_bookings_get_response, user_bookings_post_response +): + set_hash_response(aioresponses, connector, user_bookings_get_response) + set_bookings_response(aioresponses, connector, user_bookings_post_response) + + +def set_hash_response(aioresponses, connector, user_bookings_get_response): + aioresponses.get( + connector.user_bookings_url, status=200, body=user_bookings_get_response + ) + + +def set_bookings_response(aioresponses, connector, user_bookings_post_response): + aioresponses.post( + connector.user_bookings_url, status=200, payload=user_bookings_post_response + ) + + +def set_full_user_bookings_responses(aioresponses, connector, responses): + make_landing_request_success(aioresponses, connector, responses[0]) + make_login_request_success(aioresponses, connector, responses[1]) + set_ongoing_bookings_response(aioresponses, connector, *responses[2:]) + + +def set_cancellation_response(aioresponses, connector, response): + aioresponses.post(connector.booking_cancellation_url, status=200, payload=response) + + +def set_full_cancellation_by_id_responses(aioresponses, connector, responses): + make_landing_request_success(aioresponses, connector, responses[0]) + make_login_request_success(aioresponses, connector, responses[1]) + set_hash_response(aioresponses, connector, responses[2]) + set_cancellation_response(aioresponses, connector, responses[3]) + + +def set_full_cancellation_responses(aioresponses, connector, responses): + make_landing_request_success(aioresponses, connector, responses[0]) + make_login_request_success(aioresponses, connector, responses[1]) + + # the request to get the hash is made twice + set_hash_response(aioresponses, connector, responses[2]) + set_hash_response(aioresponses, connector, responses[2]) + + set_bookings_response(aioresponses, connector, responses[3]) + set_cancellation_response(aioresponses, connector, responses[4]) diff --git a/tests/unit_tests/test_config.py b/tests/unit_tests/test_config.py index c9c76ef..64bbd3f 100644 --- a/tests/unit_tests/test_config.py +++ b/tests/unit_tests/test_config.py @@ -15,7 +15,7 @@ from pendulum import DateTime, Timezone ) def test_get_booking_filter(): booking_filter = config.get_booking_filter() - assert booking_filter.sport_id == "padel" + assert booking_filter.sport_name == "padel" assert booking_filter.date == DateTime( year=2024, month=2, diff --git a/tests/unit_tests/test_gestion_sports_connector.py b/tests/unit_tests/test_gestion_sports_connector.py index e7c3972..003bab5 100644 --- a/tests/unit_tests/test_gestion_sports_connector.py +++ b/tests/unit_tests/test_gestion_sports_connector.py @@ -5,90 +5,7 @@ import pytest from aiohttp import ClientSession from connectors import GestionSportsConnector - -def make_landing_request_success(aioresponses, connector, landing_response): - aioresponses.get( - connector.landing_url, - status=200, - headers={"Set-Cookie": "PHPSESSID=987512"}, - body=landing_response, - ) - - -def make_login_request_fail(aioresponses, connector, login_failure_response): - aioresponses.post( - connector.login_url, - status=200, - payload=login_failure_response, - ) - - -def make_login_request_success(aioresponses, connector, login_success_response): - aioresponses.post( - connector.login_url, - status=200, - headers={"Set-Cookie": "COOK_COMPTE=e2be1;" "COOK_ID_CLUB=22;COOK_ID_USER=666"}, - payload=login_success_response, - ) - - -def set_booking_request(aioresponses, connector, booking_response): - aioresponses.post(connector.booking_url, status=200, payload=booking_response) - - -def set_full_booking_requests_responses(aioresponses, connector, responses_list): - make_landing_request_success(aioresponses, connector, responses_list[0]) - make_login_request_success(aioresponses, connector, responses_list[1]) - for response in responses_list[2:]: - set_booking_request(aioresponses, connector, response) - - -def set_ongoing_bookings_response( - aioresponses, connector, user_bookings_get_response, user_bookings_post_response -): - set_hash_response(aioresponses, connector, user_bookings_get_response) - set_bookings_response(aioresponses, connector, user_bookings_post_response) - - -def set_hash_response(aioresponses, connector, user_bookings_get_response): - aioresponses.get( - connector.user_bookings_url, status=200, body=user_bookings_get_response - ) - - -def set_bookings_response(aioresponses, connector, user_bookings_post_response): - aioresponses.post( - connector.user_bookings_url, status=200, payload=user_bookings_post_response - ) - - -def set_full_user_bookings_responses(aioresponses, connector, responses): - make_landing_request_success(aioresponses, connector, responses[0]) - make_login_request_success(aioresponses, connector, responses[1]) - set_ongoing_bookings_response(aioresponses, connector, *responses[2:]) - - -def set_cancellation_response(aioresponses, connector, response): - aioresponses.post(connector.booking_cancellation_url, status=200, payload=response) - - -def set_full_cancellation_by_id_responses(aioresponses, connector, responses): - make_landing_request_success(aioresponses, connector, responses[0]) - make_login_request_success(aioresponses, connector, responses[1]) - set_hash_response(aioresponses, connector, responses[2]) - set_cancellation_response(aioresponses, connector, responses[3]) - - -def set_full_cancellation_responses(aioresponses, connector, responses): - make_landing_request_success(aioresponses, connector, responses[0]) - make_login_request_success(aioresponses, connector, responses[1]) - - # the request to get the hash is made twice - set_hash_response(aioresponses, connector, responses[2]) - set_hash_response(aioresponses, connector, responses[2]) - - set_bookings_response(aioresponses, connector, responses[3]) - set_cancellation_response(aioresponses, connector, responses[4]) +from tests.unit_tests import responses def test_urls(connector, club): @@ -134,7 +51,7 @@ def test_urls_payload_templates(mock_resources, club): @pytest.mark.asyncio async def test_landing_page(aioresponses, connector, landing_response): - make_landing_request_success(aioresponses, connector, landing_response) + responses.make_landing_request_success(aioresponses, connector, landing_response) async with ClientSession() as session: response = await connector.land(session) @@ -146,7 +63,9 @@ async def test_landing_page(aioresponses, connector, landing_response): @pytest.mark.asyncio async def test_login_success(aioresponses, connector, user, login_success_response): - make_login_request_success(aioresponses, connector, login_success_response) + responses.make_login_request_success( + aioresponses, connector, login_success_response + ) async with ClientSession() as session: response = await connector.login(session, user) @@ -160,7 +79,7 @@ async def test_login_success(aioresponses, connector, user, login_success_respon @pytest.mark.asyncio async def test_login_failure(aioresponses, connector, user, login_failure_response): - make_login_request_fail(aioresponses, connector, login_failure_response) + responses.make_login_request_fail(aioresponses, connector, login_failure_response) async with ClientSession() as session: response = await connector.login(session, user) @@ -170,38 +89,9 @@ async def test_login_failure(aioresponses, connector, user, login_failure_respon assert await response.json() == login_failure_response -@pytest.mark.asyncio -async def test_booking_success( - aioresponses, - connector, - user, - booking_filter, - booking_success_from_start, -): - set_full_booking_requests_responses( - aioresponses, connector, booking_success_from_start - ) - - court_booked = await connector.book(user, booking_filter) - - assert court_booked.id == 2 - - -@pytest.mark.asyncio -async def test_booking_failure( - aioresponses, - connector, - user, - booking_filter, - booking_failure_from_start, -): - set_full_booking_requests_responses( - aioresponses, connector, booking_failure_from_start - ) - - court_booked = await connector.book(user, booking_filter) - - assert court_booked is None +def test_get_booked_court(connector, booked_courts_response): + booked_court = connector.get_booked_court(booked_courts_response, "Sport1") + assert booked_court.number == 3 @pytest.mark.asyncio @@ -212,7 +102,7 @@ async def test_get_ongoing_bookings( user_bookings_get_response, user_bookings_list, ): - set_ongoing_bookings_response( + responses.set_ongoing_bookings_response( aioresponses, connector, user_bookings_get_response, user_bookings_list ) @@ -222,81 +112,19 @@ async def test_get_ongoing_bookings( assert len(bookings) == 2 -@pytest.mark.asyncio -async def test_get_ongoing_bookings( - aioresponses, - connector, - user, - user_bookings_get_response, - user_bookings_list, -): - set_ongoing_bookings_response( - aioresponses, connector, user_bookings_get_response, user_bookings_list - ) - - async with ClientSession() as session: - bookings = await connector.get_ongoing_bookings(session) - - assert len(bookings) == 2 - - -@pytest.mark.asyncio -async def test_has_user_ongoing_bookings( - aioresponses, - connector, - user, - user_has_ongoing_bookings_from_start, -): - set_full_user_bookings_responses( - aioresponses, connector, user_has_ongoing_bookings_from_start - ) - - has_bookings = await connector.has_user_ongoing_booking(user) - - assert has_bookings - - -@pytest.mark.asyncio -async def test_has_user_ongoing_bookings( - aioresponses, - connector, - user, - user_has_no_ongoing_bookings_from_start, -): - set_full_user_bookings_responses( - aioresponses, connector, user_has_no_ongoing_bookings_from_start - ) - has_bookings = await connector.has_user_ongoing_booking(user) - - assert not has_bookings - - @pytest.mark.asyncio async def test_cancellation_request( aioresponses, connector, user_bookings_get_response, cancellation_response ): - set_hash_response(aioresponses, connector, user_bookings_get_response) - set_cancellation_response(aioresponses, connector, cancellation_response) + responses.set_hash_response(aioresponses, connector, user_bookings_get_response) + responses.set_cancellation_response(aioresponses, connector, cancellation_response) async with ClientSession() as session: - response = await connector.send_cancellation_request(session, 123) + response = await connector.cancel_booking_id(session, 123) assert await response.json() == cancellation_response -@pytest.mark.asyncio -async def test_cancel_booking_id( - aioresponses, connector, user, cancellation_by_id_from_start -): - set_full_cancellation_by_id_responses( - aioresponses, connector, cancellation_by_id_from_start - ) - - response = await connector.cancel_booking_id(user, 132) - - assert await response.json() == cancellation_by_id_from_start[3] - - @pytest.mark.asyncio async def test_cancel_booking_success( aioresponses, @@ -305,10 +133,13 @@ async def test_cancel_booking_success( cancellation_success_booking_filter, cancellation_success_from_start, ): - set_full_cancellation_responses( + responses.set_full_cancellation_responses( aioresponses, connector, cancellation_success_from_start ) - response = await connector.cancel_booking(user, cancellation_success_booking_filter) + async with ClientSession() as session: + response = await connector.cancel_booking( + session, cancellation_success_booking_filter + ) assert await response.json() == cancellation_success_from_start[4] diff --git a/tests/unit_tests/test_gestion_sports_services.py b/tests/unit_tests/test_gestion_sports_services.py new file mode 100644 index 0000000..fea8185 --- /dev/null +++ b/tests/unit_tests/test_gestion_sports_services.py @@ -0,0 +1,110 @@ +import pytest +from gestion_sports_services import GestionSportsServices + +from tests.unit_tests import responses + + +@pytest.mark.asyncio +async def test_booking_success( + aioresponses, + connector, + club, + user, + booking_filter, + booking_success_from_start, +): + responses.set_full_booking_requests_responses( + aioresponses, connector, booking_success_from_start + ) + + court_booked = await GestionSportsServices.book(club, user, booking_filter) + + assert court_booked.id == 2 + + +@pytest.mark.asyncio +async def test_booking_failure( + aioresponses, + gs_services, + connector, + club, + user, + booking_filter, + booking_failure_from_start, +): + responses.set_full_booking_requests_responses( + aioresponses, connector, booking_failure_from_start + ) + + court_booked = await gs_services.book(club, user, booking_filter) + + assert court_booked is None + + +@pytest.mark.asyncio +async def test_user_has_available_booking_slots( + aioresponses, + gs_services, + connector, + user, + club, + user_has_ongoing_bookings_from_start, +): + responses.set_full_user_bookings_responses( + aioresponses, connector, user_has_ongoing_bookings_from_start + ) + + has_user_available_slots = await gs_services.has_user_available_slots(user, club) + + assert has_user_available_slots + + +@pytest.mark.asyncio +async def test_user_has_no_available_booking_slots( + aioresponses, + gs_services, + connector, + user, + club, + user_has_no_ongoing_bookings_from_start, +): + responses.set_full_user_bookings_responses( + aioresponses, connector, user_has_no_ongoing_bookings_from_start + ) + + has_user_available_slots = await gs_services.has_user_available_slots(user, club) + + assert not has_user_available_slots + + +@pytest.mark.asyncio +async def test_cancel_booking( + aioresponses, + gs_services, + connector, + user, + club, + booking_filter, + cancellation_success_from_start, +): + responses.set_full_cancellation_responses( + aioresponses, connector, cancellation_success_from_start + ) + + await gs_services.cancel_booking(user, club, booking_filter) + + +@pytest.mark.asyncio +async def test_cancel_booking_id( + aioresponses, + gs_services, + connector, + user, + club, + cancellation_success_from_start, +): + responses.set_full_cancellation_responses( + aioresponses, connector, cancellation_success_from_start + ) + + await gs_services.cancel_booking_id(user, club, 65464) From 3d0bd47079f20c614f8a756a36c23f9cf16f40b6 Mon Sep 17 00:00:00 2001 From: stanislas Date: Sat, 23 Mar 2024 11:56:31 +0100 Subject: [PATCH 5/7] Added a service that can get all current tournaments list --- resa_padel/booking.py | 20 +- resa_padel/booking_service.py | 42 - resa_padel/config.py | 2 +- ...nnectors.py => gestion_sport_connector.py} | 39 +- resa_padel/gestion_sports_services.py | 65 +- resa_padel/models.py | 11 + .../gestion-sports/post-headers.json | 14 +- .../tournament-sessions-payload.txt | 1 + resa_padel/resources/platforms.yaml | 5 + ...king_failure.json => booking-failure.json} | 0 ...king_success.json => booking-success.json} | 0 ...sponse.json => cancellation-response.json} | 0 ...ng_response.html => landing-response.html} | 0 ...{login_failure.json => login-failure.json} | 0 ...{login_success.json => login-success.json} | 0 tests/data/responses/tournament-sessions.html | 1380 +++++++++ tests/data/responses/tournament-sessions.json | 21 + tests/data/responses/tournaments.html | 2459 +++++++++++++++++ ...okings_get.html => user-bookings-get.html} | 0 ...ings_post.json => user-bookings-post.json} | 0 tests/integration_tests/conftest.py | 14 +- ...ors.py => test_gestion_sport_connector.py} | 103 +- .../test_gestion_sports_services.py | 6 + tests/unit_tests/conftest.py | 170 +- .../test_gestion_sports_connector.py | 91 +- .../test_gestion_sports_services.py | 66 + 26 files changed, 4305 insertions(+), 204 deletions(-) delete mode 100644 resa_padel/booking_service.py rename resa_padel/{connectors.py => gestion_sport_connector.py} (90%) create mode 100644 resa_padel/resources/gestion-sports/tournament-sessions-payload.txt rename tests/data/responses/{booking_failure.json => booking-failure.json} (100%) rename tests/data/responses/{booking_success.json => booking-success.json} (100%) rename tests/data/responses/{cancellation_response.json => cancellation-response.json} (100%) rename tests/data/responses/{landing_response.html => landing-response.html} (100%) rename tests/data/responses/{login_failure.json => login-failure.json} (100%) rename tests/data/responses/{login_success.json => login-success.json} (100%) create mode 100644 tests/data/responses/tournament-sessions.html create mode 100644 tests/data/responses/tournament-sessions.json create mode 100644 tests/data/responses/tournaments.html rename tests/data/responses/{user_bookings_get.html => user-bookings-get.html} (100%) rename tests/data/responses/{user_bookings_post.json => user-bookings-post.json} (100%) rename tests/integration_tests/{test_connectors.py => test_gestion_sport_connector.py} (74%) diff --git a/resa_padel/booking.py b/resa_padel/booking.py index c3271f4..b5a58f1 100644 --- a/resa_padel/booking.py +++ b/resa_padel/booking.py @@ -3,7 +3,7 @@ import logging import config from gestion_sports_services import GestionSportsServices -from models import Action, BookingFilter, Club, Court, User +from models import Action, BookingFilter, Club, Court, Tournament, User LOGGER = logging.getLogger(__name__) @@ -51,7 +51,18 @@ async def cancel_booking_id(club: Club, user: User, booking_id: int) -> None: await service.cancel_booking_id(user, club, booking_id) -def main() -> tuple[Court, User] | None: +async def get_tournaments(club: Club, user: User) -> list[Tournament]: + """ + Cancel a booking that matches the booking id + + :param club: the club in which the booking was made + :param user: the user who made the booking + """ + service = GestionSportsServices() + return await service.get_all_tournaments(user, club) + + +def main() -> tuple[Court, User] | list[Tournament] | None: """ Main function used to book a court @@ -80,3 +91,8 @@ def main() -> tuple[Court, User] | None: club = config.get_club() booking_filter = config.get_booking_filter() asyncio.run(cancel_booking(club, user, booking_filter)) + + elif action == Action.TOURNAMENTS: + user = config.get_user() + club = config.get_club() + return asyncio.run(get_tournaments(club, user)) diff --git a/resa_padel/booking_service.py b/resa_padel/booking_service.py deleted file mode 100644 index 1c9c437..0000000 --- a/resa_padel/booking_service.py +++ /dev/null @@ -1,42 +0,0 @@ -import logging - -from aiohttp import ClientSession -from connectors import Connector -from models import BookingFilter, Club, User - -LOGGER = logging.getLogger(__name__) - - -class BookingService: - def __init__(self, club: Club, connector: Connector): - LOGGER.info("Initializing booking service at for club", club.name) - self.club = club - self.connector = connector - self.session: ClientSession | None = None - - async def __aenter__(self): - self.session = ClientSession() - return self - - async def __aexit__(self, exc_type, exc_val, exc_tb): - await self.session.close() - - async def book(self, user: User, booking_filter: BookingFilter) -> int | None: - """ - Book a court matching the booking filters for a user. - The steps to perform a booking are to go to the landing page, to log in, wait - and for the time when booking is open and then actually book the court - - :param user: the user that wants to book a court - :param booking_filter: the booking criteria - :return: the court number if the booking is successful, None otherwise - """ - if self.connector is None: - LOGGER.error("No connection to Gestion Sports is available") - return None - - if user is None or booking_filter is None: - LOGGER.error("Not enough information available to book a court") - return None - - self.connector.book(user, booking_filter) diff --git a/resa_padel/config.py b/resa_padel/config.py index a0069ae..70132b5 100644 --- a/resa_padel/config.py +++ b/resa_padel/config.py @@ -143,4 +143,4 @@ def get_action() -> Action: Get the action to perform from an environment variable :return: the action to perform """ - return Action(os.environ.get("ACTION")) + return Action(os.environ.get("ACTION").lower()) diff --git a/resa_padel/connectors.py b/resa_padel/gestion_sport_connector.py similarity index 90% rename from resa_padel/connectors.py rename to resa_padel/gestion_sport_connector.py index 5b9469e..55abcea 100644 --- a/resa_padel/connectors.py +++ b/resa_padel/gestion_sport_connector.py @@ -49,6 +49,11 @@ class GestionSportsConnector: self.club.booking_platform.urls.get(name).path, ) + def _get_url_parameter(self, name: str) -> str: + self._check_url_path_exists(name) + + return self.club.booking_platform.urls.get(name).parameter + def _get_payload_template(self, name: str) -> Path: """ Get the path to the template file for the service with the given name @@ -178,6 +183,18 @@ class GestionSportsConnector: """ return self._get_payload_template("cancellation") + @property + def tournaments_sessions_url(self) -> str: + return self._get_url_path("tournament-sessions") + + @property + def tournaments_sessions_template(self) -> Path: + return self._get_payload_template("tournament-sessions") + + @property + def tournaments_list_url(self) -> str: + return self._get_url_path("tournaments-list") + @property def available_sports(self) -> dict[str, Sport]: """ @@ -209,7 +226,7 @@ class GestionSportsConnector: payload = PayloadBuilder.build(self.login_template, user=user, club=self.club) async with session.post( - self.login_url, data=payload, headers=POST_HEADERS + self.login_url, data=payload, headers=POST_HEADERS, allow_redirects=False ) as response: resp_text = await response.text() LOGGER.debug("Connexion request response:\n%s", resp_text) @@ -421,3 +438,23 @@ class GestionSportsConnector: for booking in bookings: if booking.matches(booking_filter): return await self.cancel_booking_id(session, booking.id) + + async def send_tournaments_sessions_request( + self, session: ClientSession + ) -> ClientResponse: + payload = self.tournaments_sessions_template.read_text() + + async with session.post( + self.tournaments_sessions_url, data=payload, headers=POST_HEADERS + ) as response: + LOGGER.debug("tournament sessions: \n%s", await response.text()) + return response + + async def send_tournaments_request( + self, session: ClientSession, tournement_session_id: str + ) -> ClientResponse: + final_url = self.tournaments_list_url + tournement_session_id + LOGGER.debug("Getting tournaments list at %s", final_url) + async with session.get(final_url) as response: + LOGGER.debug("tournaments: %s\n", await response.text()) + return response diff --git a/resa_padel/gestion_sports_services.py b/resa_padel/gestion_sports_services.py index d196eba..d616484 100644 --- a/resa_padel/gestion_sports_services.py +++ b/resa_padel/gestion_sports_services.py @@ -1,10 +1,12 @@ +import json import logging import time import pendulum from aiohttp import ClientSession -from connectors import GestionSportsConnector -from models import BookingFilter, BookingOpening, Club, Court, User +from bs4 import BeautifulSoup +from gestion_sport_connector import GestionSportsConnector +from models import BookingFilter, BookingOpening, Club, Court, Tournament, User from pendulum import DateTime LOGGER = logging.getLogger(__name__) @@ -125,3 +127,62 @@ class GestionSportsServices: booking_minute = opening_time.minute return booking_date.at(booking_hour, booking_minute) + + @staticmethod + async def get_all_tournaments(user: User, club: Club) -> list[Tournament]: + connector = GestionSportsConnector(club) + async with ClientSession() as session: + await connector.land(session) + await connector.login(session, user) + + session_html = await connector.send_tournaments_sessions_request(session) + tournaments_id = GestionSportsServices.retrieve_tournament_session( + await session_html.text() + ) + + tournaments = await connector.send_tournaments_request( + session, tournaments_id + ) + return GestionSportsServices.retrieve_tournaments(await tournaments.text()) + + @staticmethod + def retrieve_tournament_session(sessions: str) -> str: + session_object = json.loads(sessions).get("Inscription tournois:school-outline") + return list(session_object.keys())[0] + + @staticmethod + def retrieve_tournaments(html: str) -> list[Tournament]: + soup = BeautifulSoup(html, "html.parser") + tournaments = [] + + cards = soup.find_all("div", {"class": "card-body"}) + for card in cards: + title = card.find("h5") + price = title.find("span").get_text().strip() + name = title.get_text().strip().removesuffix(price).strip() + elements = card.find("div", {"class": "row"}).find_all("li") + date = elements[0].get_text().strip() + start_time, end_time = ( + elements[2].get_text().strip().replace("h", ":").split(" - ") + ) + start_datetime = pendulum.from_format( + f"{date} {start_time}", "DD/MM/YYYY HH:mm" + ) + end_datetime = pendulum.from_format( + f"{date} {end_time}", "DD/MM/YYYY HH:mm" + ) + gender = elements[1].get_text().strip() + places_left = ( + card.find("span", {"class": "nb_place_libre"}).get_text().strip() + ) + tournament = Tournament( + name=name, + price=price, + start_date=start_datetime, + end_date=end_datetime, + gender=gender, + places_left=places_left, + ) + tournaments.append(tournament) + + return tournaments diff --git a/resa_padel/models.py b/resa_padel/models.py index d13b375..1fbb39c 100644 --- a/resa_padel/models.py +++ b/resa_padel/models.py @@ -70,6 +70,7 @@ class Sport(BaseModel): class Url(BaseModel): name: str path: str + parameter: Optional[str] = Field(default=None) payload_template: Optional[str] = Field(default=None, alias="payloadTemplate") @@ -210,3 +211,13 @@ class Booking(BaseModel): class Action(Enum): BOOK = "book" CANCEL = "cancel" + TOURNAMENTS = "tournaments" + + +class Tournament(BaseModel): + name: str + price: str + start_date: DateTime + end_date: DateTime + gender: str + places_left: str | int diff --git a/resa_padel/resources/gestion-sports/post-headers.json b/resa_padel/resources/gestion-sports/post-headers.json index 8adccc1..0071be4 100644 --- a/resa_padel/resources/gestion-sports/post-headers.json +++ b/resa_padel/resources/gestion-sports/post-headers.json @@ -1,12 +1,16 @@ { - "Connection": "keep-alive", + "Accept": "application/json, text/javascript, */*; q=0.01", "Accept-Language": "en-US,en;q=0.5", "Accept-Encoding": "gzip, deflate, br", - "DNT": "1", + "Cache-Control": "no-cache", "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", - "Accept": "application/json, text/javascript, */*; q=0.01", - "X-Requested-With": "XMLHttpRequest", + "Connection": "keep-alive", + "DNT": "1", + "Origin": "https://toulousepadelclub.gestion-sports.com", + "Pragma": "no-cache", "Sec-Fetch-Dest": "empty", "Sec-Fetch-Mode": "cors", - "Sec-Fetch-Site": "same-origin" + "Sec-Fetch-Site": "same-origin", + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:123.0) Gecko/20100101 Firefox/123.0", + "X-Requested-With": "XMLHttpRequest" } diff --git a/resa_padel/resources/gestion-sports/tournament-sessions-payload.txt b/resa_padel/resources/gestion-sports/tournament-sessions-payload.txt new file mode 100644 index 0000000..179ea03 --- /dev/null +++ b/resa_padel/resources/gestion-sports/tournament-sessions-payload.txt @@ -0,0 +1 @@ +ajax=loadSessionForSpecDay&date=all diff --git a/resa_padel/resources/platforms.yaml b/resa_padel/resources/platforms.yaml index 7f1525f..3a5a37e 100644 --- a/resa_padel/resources/platforms.yaml +++ b/resa_padel/resources/platforms.yaml @@ -17,3 +17,8 @@ platforms: - name: cancellation path: /membre/mesresas.html payloadTemplate: gestion-sports/booking-cancellation-payload.txt + - name: tournament-sessions + path: /membre/index.php + payloadTemplate: gestion-sports/tournament-sessions-payload.txt + - name: tournaments-list + path: /membre/events/event.html?event= diff --git a/tests/data/responses/booking_failure.json b/tests/data/responses/booking-failure.json similarity index 100% rename from tests/data/responses/booking_failure.json rename to tests/data/responses/booking-failure.json diff --git a/tests/data/responses/booking_success.json b/tests/data/responses/booking-success.json similarity index 100% rename from tests/data/responses/booking_success.json rename to tests/data/responses/booking-success.json diff --git a/tests/data/responses/cancellation_response.json b/tests/data/responses/cancellation-response.json similarity index 100% rename from tests/data/responses/cancellation_response.json rename to tests/data/responses/cancellation-response.json diff --git a/tests/data/responses/landing_response.html b/tests/data/responses/landing-response.html similarity index 100% rename from tests/data/responses/landing_response.html rename to tests/data/responses/landing-response.html diff --git a/tests/data/responses/login_failure.json b/tests/data/responses/login-failure.json similarity index 100% rename from tests/data/responses/login_failure.json rename to tests/data/responses/login-failure.json diff --git a/tests/data/responses/login_success.json b/tests/data/responses/login-success.json similarity index 100% rename from tests/data/responses/login_success.json rename to tests/data/responses/login-success.json diff --git a/tests/data/responses/tournament-sessions.html b/tests/data/responses/tournament-sessions.html new file mode 100644 index 0000000..ff61262 --- /dev/null +++ b/tests/data/responses/tournament-sessions.html @@ -0,0 +1,1380 @@ + + + + + + Home + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ image + Le Mas + à l'instant +
+ + + +
+
+
+

Titre de la notif

+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod + tempor incididunt ut labore et dolore magna aliqua. +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + +
+
+
+
+ +
+
+ + + + +
+ +
+ Accueil
+ +
+ + + + +
+
+
imageimageimageimageimageimage
imageimageimageimage
+
+ +
+
+
imageimageimageimage
imageimageimageimage
imageimage
+
+ + + + +
+ +
+

Bonjour Audiard,

+
+ +
+ + + +
+ Padel / Squash / Electrostimulation +
+
+ +
+ +
+ + + + + + + + + + +
+
+
+
+
+ +
+ Évènements +
+ +
+ +
+
+
+
+ +
+ +
+
    +
+
+ +
+
+
+
+
+ + +
+
+
+ +
Toulouse Padel Club
+

+ 11 rue Marie Louise Dissard
+ 31300
+ Toulouse
+

+ +
+
+
+ +
+ + +
+
+
+
+
+
+
+
+ + + + +
+ + +
+ + + + + + + +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ Votre inscription à la partie de Tennis a bien été enregistrée !
+ Rendez-vous le 14 Janvier de 16h30 à 17h30. +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/data/responses/tournament-sessions.json b/tests/data/responses/tournament-sessions.json new file mode 100644 index 0000000..c1137c4 --- /dev/null +++ b/tests/data/responses/tournament-sessions.json @@ -0,0 +1,21 @@ +{ + "Inscription tournois:school-outline": { + "1174": { + "id": 1174, + "sport": "padel", + "clubName": "toulouse padel club", + "nom": "Tournoi", + "dateNextSession": "25\/03\/2024", + "dateDebut": "01\/08\/2022", + "dateFin": "01\/10\/2024", + "logo": "TCP_Ligue_Arcanthe2-01-min.png", + "nbSession": 14, + "icon": "school-outline", + "playerCanSeeThisEvent": null, + "type": "tournoi", + "isJp": false, + "isCiup": false, + "sqlDate": "2024-03-25 13:30:00" + } + } +} diff --git a/tests/data/responses/tournaments.html b/tests/data/responses/tournaments.html new file mode 100644 index 0000000..5f4a137 --- /dev/null +++ b/tests/data/responses/tournaments.html @@ -0,0 +1,2459 @@ + + + + + Evenements + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ +
+
+ + + Retour + + + + + +
+ +
+ Evenements
+ +
+ + + + +
+
+ + + + + + + + +
+
+ +

Padel

+ +
+
+ + + +
+ +
+ +
+
+ + +
+ Tournoi P250 aprem + + +
+
+ 50,00 € +
+
+
+
+
    +
  • +
    +
    + +
    +
    +
    25/03/2024
    +
    +
    +
  • + +
  • +
    +
    + +
    +
    +
    Homme / Double
    +
    +
    +
  • +
+
    +
  • +
    +
    + +
    +
    +
    13h30 - 19h00
    +
    +
    +
  • +
+
+
+ + +
+ Complet +
+
+ +
+
+
+ +
+ +
+
+ + +
+ Tournoi P25 matin + + +
+
+ 50,00 € +
+
+
+
+
    +
  • +
    +
    + +
    +
    +
    28/03/2024
    +
    +
    +
  • + +
  • +
    +
    + +
    +
    +
    Homme / Double
    +
    +
    +
  • +
+
    +
  • +
    +
    + +
    +
    +
    08h00 - 12h00
    +
    +
    +
  • +
+
+
+ + + +
+ 1 place(s) restante(s) +
+
+ +
+
+
+ +
+ +
+
+ + +
+ Tournoi P25 matin + + +
+
+ 50,00 € +
+
+ +
+
+
+
+
    +
  • +
    +
    + +
    +
    +
    30/03/2024
    +
    +
    +
  • + +
  • +
    +
    + +
    +
    +
    Open / Double
    +
    +
    +
  • +
+
    +
  • +
    +
    + +
    +
    +
    08h30 - 12h00
    +
    +
    +
  • +
+
+
+ + +
+ Complet +
+
+ +
+
+
+ +
+ +
+
+ + +
+ Tournoi P100 Femmes + + +
+
+ 50,00 € +
+
+
+
+
    +
  • +
    +
    + +
    +
    +
    30/03/2024
    +
    +
    +
  • + +
  • +
    +
    + +
    +
    +
    Femme / Double
    +
    +
    +
  • +
+
    +
  • +
    +
    + +
    +
    +
    08h30 - 12h00
    +
    +
    +
  • +
+
+
+ + +
+ Exclusivement pour les femmes. +
+
+ Complet +
+
+ +
+
+
+ +
+ +
+
+ + +
+ Tournoi P250 après-midi + + +
+
+ 50,00 € +
+
+ +
+
+
+
+
    +
  • +
    +
    + +
    +
    +
    30/03/2024
    +
    +
    +
  • + +
  • +
    +
    + +
    +
    +
    Open / Double
    +
    +
    +
  • +
+
    +
  • +
    +
    + +
    +
    +
    13h30 - 18h00
    +
    +
    +
  • +
+
+
+ + +
+ Complet +
+
+ +
+
+
+ +
+ +
+
+ + +
+ Tournoi P100 matin + + +
+
+ 50,00 € +
+
+
+
+
    +
  • +
    +
    + +
    +
    +
    04/04/2024
    +
    +
    +
  • + +
  • +
    +
    + +
    +
    +
    Homme / Double
    +
    +
    +
  • +
+
    +
  • +
    +
    + +
    +
    +
    08h00 - 12h00
    +
    +
    +
  • +
+
+
+ + +
+ Complet +
+
+ +
+
+
+ +
+ +
+
+ + +
+ Tournoi P100 aprem + + +
+
+ 50,00 € +
+
+
+
+
    +
  • +
    +
    + +
    +
    +
    08/04/2024
    +
    +
    +
  • + +
  • +
    +
    + +
    +
    +
    Homme / Double
    +
    +
    +
  • +
+
    +
  • +
    +
    + +
    +
    +
    13h30 - 19h00
    +
    +
    +
  • +
+
+
+ + +
+ Complet +
+
+ +
+
+
+ +
+ +
+
+ + +
+ Tournoi P25 matin + + +
+
+ 50,00 € +
+
+
+
+
    +
  • +
    +
    + +
    +
    +
    11/04/2024
    +
    +
    +
  • + +
  • +
    +
    + +
    +
    +
    Homme / Double
    +
    +
    +
  • +
+
    +
  • +
    +
    + +
    +
    +
    08h00 - 12h00
    +
    +
    +
  • +
+
+
+ + +
+ Complet +
+
+ +
+
+
+ +
+ +
+
+ + +
+ Tournoi P100 mixte + + +
+
+ 50,00 € +
+
+ +
+
+
+
+
    +
  • +
    +
    + +
    +
    +
    20/04/2024
    +
    +
    +
  • + +
  • +
    +
    + +
    +
    +
    Mixte / Double
    +
    +
    +
  • +
+
    +
  • +
    +
    + +
    +
    +
    08h30 - 13h00
    +
    +
    +
  • +
+
+
+ + +
+ Complet +
+
+ +
+
+
+ +
+ +
+
+ + +
+ Tournoi P100 + + +
+
+ 50,00 € +
+
+
+
+
    +
  • +
    +
    + +
    +
    +
    20/04/2024
    +
    +
    +
  • + +
  • +
    +
    + +
    +
    +
    Homme / Double
    +
    +
    +
  • +
+
    +
  • +
    +
    + +
    +
    +
    08h30 - 13h30
    +
    +
    +
  • +
+
+
+ + +
+ Complet +
+
+ +
+
+
+ +
+ +
+
+ + +
+ Tournoi P250 hommes + + +
+
+ 50,00 € +
+
+
+
+
    +
  • +
    +
    + +
    +
    +
    20/04/2024
    +
    +
    +
  • + +
  • +
    +
    + +
    +
    +
    Homme / Double
    +
    +
    +
  • +
+
    +
  • +
    +
    + +
    +
    +
    13h30 - 19h00
    +
    +
    +
  • +
+
+
+ + +
+ Complet +
+
+ +
+
+
+ +
+ +
+
+ + +
+ Tournoi P25 femmes + + +
+
+ 50,00 € +
+
+
+
+
    +
  • +
    +
    + +
    +
    +
    20/04/2024
    +
    +
    +
  • + +
  • +
    +
    + +
    +
    +
    Femme / Double
    +
    +
    +
  • +
+
    +
  • +
    +
    + +
    +
    +
    14h00 - 18h30
    +
    +
    +
  • +
+
+
+ + +
+ Exclusivement pour les femmes. +
+
+ 8 place(s) restante(s) +
+
+ +
+
+
+ +
+ +
+
+ + +
+ Tournoi P250 aprem + + +
+
+ 50,00 € +
+
+
+
+
    +
  • +
    +
    + +
    +
    +
    22/04/2024
    +
    +
    +
  • + +
  • +
    +
    + +
    +
    +
    Homme / Double
    +
    +
    +
  • +
+
    +
  • +
    +
    + +
    +
    +
    13h30 - 19h00
    +
    +
    +
  • +
+
+
+ + +
+ Complet +
+
+ +
+
+
+ +
+ +
+
+ + +
+ Tournoi P25 matin + + +
+
+ 50,00 € +
+
+
+
+
    +
  • +
    +
    + +
    +
    +
    25/04/2024
    +
    +
    +
  • + +
  • +
    +
    + +
    +
    +
    Homme / Double
    +
    +
    +
  • +
+
    +
  • +
    +
    + +
    +
    +
    08h00 - 12h00
    +
    +
    +
  • +
+
+
+ + +
+ Complet +
+
+ +
+
+
+
+ + + + + + + + + + + + + +
+ + +
+ + + + + + + +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ image + Le Mas + à l'instant +
+ + + +
+
+
+

Titre de la notif

+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod + tempor incididunt ut labore et dolore magna aliqua. +
+
+
+
+
+ + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/data/responses/user_bookings_get.html b/tests/data/responses/user-bookings-get.html similarity index 100% rename from tests/data/responses/user_bookings_get.html rename to tests/data/responses/user-bookings-get.html diff --git a/tests/data/responses/user_bookings_post.json b/tests/data/responses/user-bookings-post.json similarity index 100% rename from tests/data/responses/user_bookings_post.json rename to tests/data/responses/user-bookings-post.json diff --git a/tests/integration_tests/conftest.py b/tests/integration_tests/conftest.py index 0e10773..666fd90 100644 --- a/tests/integration_tests/conftest.py +++ b/tests/integration_tests/conftest.py @@ -4,7 +4,7 @@ from pathlib import Path import config import pendulum import pytest -from connectors import GestionSportsConnector +from gestion_sport_connector import GestionSportsConnector from models import BookingFilter, Club, User TEST_FOLDER = Path(__file__).parent.parent @@ -36,11 +36,17 @@ def booking_filter() -> BookingFilter: @pytest.fixture def booking_success_response() -> dict: - booking_success_file = RESPONSES_FOLDER / "booking_success.json" + booking_success_file = RESPONSES_FOLDER / "booking-success.json" return json.loads(booking_success_file.read_text(encoding="utf-8")) @pytest.fixture def booking_failure_response() -> dict: - booking_failure_file = RESPONSES_FOLDER / "booking_failure.json" - return json.loads(booking_failure_file.read_text(encoding="utf-8")) + file = RESPONSES_FOLDER / "booking-failure.json" + return json.loads(file.read_text(encoding="utf-8")) + + +@pytest.fixture +def tournament_sessions_json() -> str: + file = RESPONSES_FOLDER / "tournament-sessions.json" + return file.read_text(encoding="utf-8") diff --git a/tests/integration_tests/test_connectors.py b/tests/integration_tests/test_gestion_sport_connector.py similarity index 74% rename from tests/integration_tests/test_connectors.py rename to tests/integration_tests/test_gestion_sport_connector.py index a5f3f88..0489110 100644 --- a/tests/integration_tests/test_connectors.py +++ b/tests/integration_tests/test_gestion_sport_connector.py @@ -1,36 +1,15 @@ +import json import os from pathlib import Path from unittest.mock import patch -import aiohttp import pendulum import pytest -from connectors import GestionSportsConnector -from models import BookingFilter, Club -from pendulum import DateTime +from aiohttp import ClientSession +from gestion_sport_connector import GestionSportsConnector from yarl import URL -def retrieve_booking_datetime( - a_booking_filter: BookingFilter, a_club: Club -) -> DateTime: - """ - Utility to retrieve the booking datetime from the booking filter and the club - - :param a_booking_filter: the booking filter that contains the date to book - :param a_club: the club which has the number of days before the date and the booking time - """ - booking_opening = a_club.booking_platform.booking_opening - opening_time = pendulum.parse(booking_opening.opening_time) - booking_hour = opening_time.hour - booking_minute = opening_time.minute - - date_to_book = a_booking_filter.date - return date_to_book.subtract(days=booking_opening.days_before).at( - booking_hour, booking_minute - ) - - @patch.dict( os.environ, {"CLUB_ID": "tpc"}, @@ -57,6 +36,10 @@ def test_urls(connector): connector.booking_cancellation_url == "https://toulousepadelclub.gestion-sports.com/membre/mesresas.html" ) + assert ( + connector.tournaments_sessions_url + == "https://toulousepadelclub.gestion-sports.com/membre/index.php" + ) @patch.dict( @@ -76,11 +59,15 @@ def test_urls_payload_templates(connector): connector.booking_cancel_template == resources_folder / "booking-cancellation-payload.txt" ) + assert ( + connector.tournaments_sessions_template + == resources_folder / "tournament-sessions-payload.txt" + ) @pytest.mark.asyncio async def test_landing_page(connector): - async with aiohttp.ClientSession() as session: + async with ClientSession() as session: response = await connector.land(session) assert response.status == 200 @@ -93,7 +80,7 @@ async def test_landing_page(connector): @pytest.mark.asyncio async def test_login(connector, user): - async with aiohttp.ClientSession() as session: + async with ClientSession() as session: await connector.land(session) response = await connector.login(session, user) @@ -128,7 +115,7 @@ def test_get_booked_court( @pytest.mark.asyncio async def test_book_one_court(connector, user, booking_filter): - async with aiohttp.ClientSession() as session: + async with ClientSession() as session: await connector.land(session) await connector.login(session, user) @@ -156,28 +143,9 @@ def test_build_booking_datetime(connector, booking_filter): assert opening_datetime.minute == 0 -@patch("pendulum.now") -def test_wait_until_booking_time(mock_now, connector, booking_filter, club): - booking_datetime = retrieve_booking_datetime(booking_filter, club) - - seconds = [ - booking_datetime.subtract(seconds=3), - booking_datetime.subtract(seconds=2), - booking_datetime.subtract(seconds=1), - booking_datetime, - booking_datetime.add(microseconds=1), - booking_datetime.add(microseconds=2), - ] - mock_now.side_effect = seconds - - connector.wait_until_booking_time(booking_filter) - - assert pendulum.now() == booking_datetime.add(microseconds=1) - - @pytest.mark.asyncio async def test_get_hash(connector, user): - async with aiohttp.ClientSession() as session: + async with ClientSession() as session: await connector.land(session) await connector.login(session, user) @@ -197,7 +165,7 @@ def test_get_hash_input(): @pytest.mark.asyncio async def test_get_bookings(connector, user): - async with aiohttp.ClientSession() as session: + async with ClientSession() as session: await connector.land(session) await connector.login(session, user) @@ -210,7 +178,7 @@ async def test_get_bookings(connector, user): @pytest.mark.asyncio async def test_get_ongoing_bookings(connector, user): - async with aiohttp.ClientSession() as session: + async with ClientSession() as session: await connector.land(session) await connector.login(session, user) @@ -218,20 +186,15 @@ async def test_get_ongoing_bookings(connector, user): print(bookings) -@pytest.mark.asyncio -async def test_has_user_ongoing_bookings(connector, user): - assert await connector.has_user_ongoing_booking(user) - - @pytest.mark.asyncio async def test_cancel_booking_id(connector, user): - async with aiohttp.ClientSession() as session: + async with ClientSession() as session: await connector.land(session) await connector.login(session, user) ongoing_bookings = await connector.get_ongoing_bookings(session) booking_id = ongoing_bookings[0].id - response = await connector.cancel_booking_id(user, booking_id) + response = await connector.cancel_booking_id(session, 666) assert len(await connector.get_ongoing_bookings(session)) == 0 @@ -245,7 +208,33 @@ def test_find_court(connector): @pytest.mark.asyncio async def test_cancel_booking(connector, user, booking_filter): - async with aiohttp.ClientSession() as session: + async with ClientSession() as session: await connector.land(session) await connector.login(session, user) await connector.cancel_booking(session, booking_filter) + + +@pytest.mark.asyncio +async def test_tournament_sessions(connector, user): + async with ClientSession() as session: + await connector.land(session) + await connector.login(session, user) + response = await connector.send_tournaments_sessions_request(session) + + assert response.status == 200 + + all_sessions = json.loads(await response.text()) + sessions = all_sessions.get("Inscription tournois:school-outline") + assert len(sessions) == 1 + + +@pytest.mark.asyncio +async def test_send_tournaments_request(connector, user): + async with ClientSession() as session: + await connector.land(session) + await connector.login(session, user) + tournament_session_id = "1174" + response = await connector.send_tournaments_request( + session, tournament_session_id + ) + assert "Complet" in await response.text() diff --git a/tests/integration_tests/test_gestion_sports_services.py b/tests/integration_tests/test_gestion_sports_services.py index c1ccc69..323e5e0 100644 --- a/tests/integration_tests/test_gestion_sports_services.py +++ b/tests/integration_tests/test_gestion_sports_services.py @@ -18,3 +18,9 @@ async def test_user_has_available_slots(club, user): @pytest.mark.asyncio async def test_cancel_booking(club, user, booking_filter): await GestionSportsServices.cancel_booking(user, club, booking_filter) + + +@pytest.mark.asyncio +async def test_get_all_tournaments(user, club): + tournaments = await GestionSportsServices.get_all_tournaments(user, club) + assert len(tournaments) == 14 diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 8ab4b06..bde7bae 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -3,7 +3,7 @@ from pathlib import Path import pendulum import pytest -from connectors import GestionSportsConnector +from gestion_sport_connector import GestionSportsConnector from gestion_sports_services import GestionSportsServices from models import ( BookingFilter, @@ -43,7 +43,7 @@ def court14() -> Court: @pytest.fixture -def sport1(court11, court12, court13, court14) -> Sport: +def sport1(court11: Court, court12: Court, court13: Court, court14: Court) -> Sport: return Sport( name="Sport1", id=8, @@ -75,7 +75,7 @@ def court24() -> Court: @pytest.fixture -def sport2(court21, court22, court23, court24) -> Sport: +def sport2(court21: Court, court22: Court, court23: Court, court24: Court) -> Sport: return Sport( name="Sport 2", id=10, @@ -130,9 +130,26 @@ def cancellation_url() -> Url: ) +@pytest.fixture +def tournament_sessions_url() -> Url: + return Url( + name="tournament-sessions", + path="/tournaments_sessions.php", + payloadTemplate="gestion-sports/tournament-sessions-payload.txt", + ) + + +@pytest.fixture +def tournaments_list_url() -> Url: + return Url( + name="tournaments-list", + path="/tournaments_list.html?event=", + ) + + @pytest.fixture def booking_opening() -> BookingOpening: - return BookingOpening(daysBefore=10, time="03:27") + return BookingOpening(daysBefore=7, time="00:00") @pytest.fixture @@ -142,15 +159,17 @@ def total_bookings() -> TotalBookings: @pytest.fixture def booking_platform( - booking_opening, - total_bookings, - sport1, - sport2, - landing_url, - login_url, - booking_url, - user_bookings_url, - cancellation_url, + booking_opening: BookingOpening, + total_bookings: TotalBookings, + sport1: Sport, + sport2: Sport, + landing_url: str, + login_url: str, + booking_url: str, + user_bookings_url: str, + cancellation_url: str, + tournament_sessions_url: str, + tournaments_list_url: str, ) -> BookingPlatform: return BookingPlatform( id="gestion-sports", @@ -166,12 +185,14 @@ def booking_platform( "booking": booking_url, "user-bookings": user_bookings_url, "cancellation": cancellation_url, + "tournament-sessions": tournament_sessions_url, + "tournaments-list": tournaments_list_url, }, ) @pytest.fixture -def club(booking_platform) -> Club: +def club(booking_platform: BookingPlatform) -> Club: return Club( id="super_club", name="Super Club", @@ -204,42 +225,42 @@ def booking_filter() -> BookingFilter: @pytest.fixture def landing_response() -> str: - landing_response_file = RESPONSES_FOLDER / "landing_response.html" - return landing_response_file.read_text(encoding="utf-8") + file = RESPONSES_FOLDER / "landing-response.html" + return file.read_text(encoding="utf-8") @pytest.fixture def login_success_response() -> dict: - login_success_file = RESPONSES_FOLDER / "login_success.json" + login_success_file = RESPONSES_FOLDER / "login-success.json" return json.loads(login_success_file.read_text(encoding="utf-8")) @pytest.fixture def login_failure_response() -> dict: - login_failure_file = RESPONSES_FOLDER / "login_failure.json" - return json.loads(login_failure_file.read_text(encoding="utf-8")) + file = RESPONSES_FOLDER / "login-failure.json" + return json.loads(file.read_text(encoding="utf-8")) @pytest.fixture def booking_success_response() -> dict: - booking_success_file = RESPONSES_FOLDER / "booking_success.json" - return json.loads(booking_success_file.read_text(encoding="utf-8")) + file = RESPONSES_FOLDER / "booking-success.json" + return json.loads(file.read_text(encoding="utf-8")) @pytest.fixture def booking_failure_response() -> dict: - booking_failure_file = RESPONSES_FOLDER / "booking_failure.json" - return json.loads(booking_failure_file.read_text(encoding="utf-8")) + file = RESPONSES_FOLDER / "booking-failure.json" + return json.loads(file.read_text(encoding="utf-8")) @pytest.fixture def booked_courts_response( - court11, - court12, - court13, - court14, - booking_success_response, - booking_failure_response, + court11: int, + court12: int, + court13: int, + court14: int, + booking_success_response: dict, + booking_failure_response: dict, ) -> list[tuple[int, dict]]: court1_resp = court11.id, booking_failure_response court2_resp = court12.id, booking_failure_response @@ -250,10 +271,10 @@ def booked_courts_response( @pytest.fixture def booking_success_from_start( - landing_response, - login_success_response, - booking_success_response, - booking_failure_response, + landing_response: str, + login_success_response: str, + booking_success_response: str, + booking_failure_response: str, ): return [ landing_response, @@ -267,10 +288,10 @@ def booking_success_from_start( @pytest.fixture def booking_failure_from_start( - landing_response, - login_success_response, - booking_success_response, - booking_failure_response, + landing_response: str, + login_success_response: str, + booking_success_response: str, + booking_failure_response: str, ): return [ landing_response, @@ -284,22 +305,22 @@ def booking_failure_from_start( @pytest.fixture def user_bookings_get_response() -> str: - user_bookings_file = RESPONSES_FOLDER / "user_bookings_get.html" - return user_bookings_file.read_text(encoding="utf-8") + file = RESPONSES_FOLDER / "user-bookings-get.html" + return file.read_text(encoding="utf-8") @pytest.fixture -def user_bookings_list() -> list: - user_bookings_file = RESPONSES_FOLDER / "user_bookings_post.json" - return json.loads(user_bookings_file.read_text(encoding="utf-8")) +def user_bookings_list() -> str: + file = RESPONSES_FOLDER / "user-bookings-post.json" + return json.loads(file.read_text(encoding="utf-8")) @pytest.fixture def user_has_ongoing_bookings_from_start( - landing_response, - login_success_response, - user_bookings_get_response, - user_bookings_list, + landing_response: str, + login_success_response: str, + user_bookings_get_response: str, + user_bookings_list: str, ) -> list: return [ landing_response, @@ -316,10 +337,10 @@ def user_bookings_empty_list() -> list: @pytest.fixture def user_has_no_ongoing_bookings_from_start( - landing_response, - login_success_response, - user_bookings_get_response, - user_bookings_empty_list, + landing_response: str, + login_success_response: str, + user_bookings_get_response: str, + user_bookings_empty_list: str, ) -> list: return [ landing_response, @@ -331,16 +352,16 @@ def user_has_no_ongoing_bookings_from_start( @pytest.fixture def cancellation_response() -> list: - cancellation_response_file = RESPONSES_FOLDER / "cancellation_response.json" - return json.loads(cancellation_response_file.read_text(encoding="utf-8")) + file = RESPONSES_FOLDER / "cancellation-response.json" + return json.loads(file.read_text(encoding="utf-8")) @pytest.fixture def cancellation_by_id_from_start( - landing_response, - login_success_response, - user_bookings_get_response, - cancellation_response, + landing_response: str, + login_success_response: str, + user_bookings_get_response: str, + cancellation_response: str, ): return [ landing_response, @@ -352,11 +373,11 @@ def cancellation_by_id_from_start( @pytest.fixture def cancellation_success_from_start( - landing_response, - login_success_response, - user_bookings_get_response, - user_bookings_list, - cancellation_response, + landing_response: str, + login_success_response: str, + user_bookings_get_response: str, + user_bookings_list: str, + cancellation_response: str, ): return [ landing_response, @@ -377,3 +398,30 @@ def cancellation_success_booking_filter() -> BookingFilter: @pytest.fixture def service() -> GestionSportsServices: return GestionSportsServices() + + +@pytest.fixture +def tournament_sessions_json() -> str: + file = RESPONSES_FOLDER / "tournament-sessions.json" + return file.read_text(encoding="utf-8") + + +@pytest.fixture +def tournaments_html() -> str: + file = RESPONSES_FOLDER / "tournaments.html" + return file.read_text(encoding="utf-8") + + +@pytest.fixture +def full_tournaments_responses( + landing_response: str, + login_success_response: str, + tournament_sessions_json: str, + tournaments_html: str, +) -> list[str]: + return [ + landing_response, + login_success_response, + tournament_sessions_json, + tournaments_html, + ] diff --git a/tests/unit_tests/test_gestion_sports_connector.py b/tests/unit_tests/test_gestion_sports_connector.py index 003bab5..82be8ee 100644 --- a/tests/unit_tests/test_gestion_sports_connector.py +++ b/tests/unit_tests/test_gestion_sports_connector.py @@ -1,31 +1,24 @@ +import json from pathlib import Path from unittest.mock import patch import pytest from aiohttp import ClientSession -from connectors import GestionSportsConnector +from gestion_sport_connector import GestionSportsConnector from tests.unit_tests import responses def test_urls(connector, club): - base_url = club.booking_platform.url - relative_urls = club.booking_platform.urls + base_url = "https://ptf1.com" - relative_landing_url = relative_urls.get("landing-page").path - assert connector.landing_url == f"{base_url}/{relative_landing_url}" - - relative_login_url = relative_urls.get("login").path - assert connector.login_url == f"{base_url}/{relative_login_url}" - - relative_booking_url = relative_urls.get("booking").path - assert connector.booking_url == f"{base_url}/{relative_booking_url}" - - relative_user_bookings_url = relative_urls.get("user-bookings").path - assert connector.user_bookings_url == f"{base_url}/{relative_user_bookings_url}" - - relative_cancel_url = relative_urls.get("cancellation").path - assert connector.booking_cancellation_url == f"{base_url}/{relative_cancel_url}" + assert connector.landing_url == f"{base_url}/landing.html" + assert connector.login_url == f"{base_url}/login.html" + assert connector.booking_url == f"{base_url}/booking.html" + assert connector.user_bookings_url == f"{base_url}/user_bookings.html" + assert connector.booking_cancellation_url == f"{base_url}/cancel.html" + assert connector.tournaments_sessions_url == f"{base_url}/tournaments_sessions.php" + assert connector.tournaments_list_url == f"{base_url}/tournaments_list.html?event=" @patch("config.get_resources_folder") @@ -34,19 +27,27 @@ def test_urls_payload_templates(mock_resources, club): mock_resources.return_value = path_to_resources connector = GestionSportsConnector(club) - relative_urls = club.booking_platform.urls - login_payload = relative_urls.get("login").payload_template - assert connector.login_template == path_to_resources / login_payload - - booking_payload = relative_urls.get("booking").payload_template - assert connector.booking_template == path_to_resources / booking_payload - - user_bookings_payload = relative_urls.get("user-bookings").payload_template - assert connector.user_bookings_template == path_to_resources / user_bookings_payload - - cancel_payload = relative_urls.get("cancellation").payload_template - assert connector.booking_cancel_template == path_to_resources / cancel_payload + assert ( + connector.login_template + == path_to_resources / "gestion-sports/login-payload.txt" + ) + assert ( + connector.booking_template + == path_to_resources / "gestion-sports/booking-payload.txt" + ) + assert ( + connector.user_bookings_template + == path_to_resources / "gestion-sports/user-bookings-payload.txt" + ) + assert ( + connector.booking_cancel_template + == path_to_resources / "gestion-sports/booking-cancellation-payload.txt" + ) + assert ( + connector.tournaments_sessions_template + == path_to_resources / "gestion-sports/tournament-sessions-payload.txt" + ) @pytest.mark.asyncio @@ -143,3 +144,35 @@ async def test_cancel_booking_success( ) assert await response.json() == cancellation_success_from_start[4] + + +@pytest.mark.asyncio +async def test_tournament_sessions( + aioresponses, connector, user, tournament_sessions_json +): + responses.set_tournaments_sessions_response( + aioresponses, connector, tournament_sessions_json + ) + async with ClientSession() as session: + response = await connector.send_tournaments_sessions_request(session) + + assert response.status == 200 + + all_sessions = json.loads(await response.text()) + sessions = all_sessions.get("Inscription tournois:school-outline") + assert len(sessions) == 1 + + +@pytest.mark.asyncio +async def test_send_tournaments_request( + aioresponses, connector, user, tournaments_html +): + tournament_session_id = "255" + responses.set_tournaments_list_response( + aioresponses, connector, tournament_session_id, tournaments_html + ) + async with ClientSession() as session: + response = await connector.send_tournaments_request( + session, tournament_session_id + ) + assert "Complet" in await response.text() diff --git a/tests/unit_tests/test_gestion_sports_services.py b/tests/unit_tests/test_gestion_sports_services.py index fea8185..6cc8351 100644 --- a/tests/unit_tests/test_gestion_sports_services.py +++ b/tests/unit_tests/test_gestion_sports_services.py @@ -1,5 +1,9 @@ +from unittest.mock import patch + +import pendulum import pytest from gestion_sports_services import GestionSportsServices +from models import BookingFilter, BookingOpening from tests.unit_tests import responses @@ -108,3 +112,65 @@ async def test_cancel_booking_id( ) await gs_services.cancel_booking_id(user, club, 65464) + + +@patch("pendulum.now") +def test_wait_until_booking_time(mock_now, club, user): + booking_filter = BookingFilter( + sport_name="Sport1", date=pendulum.parse("2024-03-21T13:30:00+01:00") + ) + + booking_datetime = pendulum.parse("2024-03-14T00:00:00+01:00") + + seconds = [ + booking_datetime.subtract(seconds=3), + booking_datetime.subtract(seconds=2), + booking_datetime.subtract(seconds=1), + booking_datetime, + booking_datetime.add(microseconds=1), + booking_datetime.add(microseconds=2), + ] + mock_now.side_effect = seconds + + booking_opening = club.booking_platform.booking_opening + + GestionSportsServices.wait_until_booking_time(booking_filter, booking_opening) + + assert pendulum.now() == booking_datetime.add(microseconds=1) + + +def test_build_booking_time(): + booking_filter = BookingFilter( + sport_name="Sport1", date=pendulum.parse("2024-03-21T13:30:00+01:00") + ) + booking_opening = BookingOpening(daysBefore=7, time="00:00") + + booking_time = GestionSportsServices.build_booking_datetime( + booking_filter, booking_opening + ) + + assert booking_time == pendulum.parse("2024-03-13T23:00:00Z") + + +def test_retrieve_tournament_id(tournament_sessions_json): + session_id = GestionSportsServices.retrieve_tournament_session( + tournament_sessions_json + ) + + assert session_id == "1174" + + +def test_retrieve_tournaments(tournaments_html): + tournaments = GestionSportsServices.retrieve_tournaments(tournaments_html) + assert len(tournaments) == 14 + + +@pytest.mark.asyncio +async def test_get_all_tournaments( + aioresponses, user, connector, club, full_tournaments_responses +): + responses.set_full_tournaments_requests( + aioresponses, connector, full_tournaments_responses, 1174 + ) + tournaments = await GestionSportsServices.get_all_tournaments(user, club) + assert len(tournaments) == 14 From a622ee69de7a866cadcbca547e433476167ebc8d Mon Sep 17 00:00:00 2001 From: stanislas Date: Sat, 23 Mar 2024 11:58:07 +0100 Subject: [PATCH 6/7] Added a service that can get all current tournaments list --- tests/data/configuration/platforms.yaml | 7 ++++- tests/integration_tests/test_booking.py | 15 +++++++++ .../test_gestion_sport_connector.py | 4 +-- tests/unit_tests/responses.py | 31 +++++++++++++++++++ 4 files changed, 53 insertions(+), 4 deletions(-) diff --git a/tests/data/configuration/platforms.yaml b/tests/data/configuration/platforms.yaml index f3cdc8e..4d82911 100644 --- a/tests/data/configuration/platforms.yaml +++ b/tests/data/configuration/platforms.yaml @@ -16,4 +16,9 @@ platforms: payloadTemplate: gestion-sports/user-bookings-payload.txt - name: cancellation path: /cancel.html - payloadTemplate: gestion-sports/booking-cancellation-payload.txt + payloadTemplate: sports/booking-cancellation-payload.txt + - name: tournament-sessions + path: /membre/index.php + payloadTemplate: sports/tournament-sessions-payload.txt + - name: tournament-list + path: /membre/events/event.html?event= diff --git a/tests/integration_tests/test_booking.py b/tests/integration_tests/test_booking.py index 22e9613..db6c6e1 100644 --- a/tests/integration_tests/test_booking.py +++ b/tests/integration_tests/test_booking.py @@ -57,3 +57,18 @@ def test_main_booking(): ) def test_main_cancellation(): booking.main() + + +@patch.dict( + os.environ, + { + "CLUB_ID": "tpc", + "ACTION": "tournaments", + "LOGIN": "padel.testing@jouf.fr", + "PASSWORD": "ridicule", + }, + clear=True, +) +def test_main_tournaments(): + tournaments = booking.main() + assert len(tournaments) != 0 diff --git a/tests/integration_tests/test_gestion_sport_connector.py b/tests/integration_tests/test_gestion_sport_connector.py index 0489110..4e5bb44 100644 --- a/tests/integration_tests/test_gestion_sport_connector.py +++ b/tests/integration_tests/test_gestion_sport_connector.py @@ -191,10 +191,8 @@ async def test_cancel_booking_id(connector, user): async with ClientSession() as session: await connector.land(session) await connector.login(session, user) - ongoing_bookings = await connector.get_ongoing_bookings(session) - booking_id = ongoing_bookings[0].id - response = await connector.cancel_booking_id(session, 666) + await connector.cancel_booking_id(session, 666) assert len(await connector.get_ongoing_bookings(session)) == 0 diff --git a/tests/unit_tests/responses.py b/tests/unit_tests/responses.py index 35982f9..8ee5c7e 100644 --- a/tests/unit_tests/responses.py +++ b/tests/unit_tests/responses.py @@ -1,3 +1,6 @@ +from gestion_sport_connector import GestionSportsConnector + + def make_landing_request_success(aioresponses, connector, landing_response): aioresponses.get( connector.landing_url, @@ -54,6 +57,26 @@ def set_bookings_response(aioresponses, connector, user_bookings_post_response): ) +def set_tournaments_sessions_response( + aioresponses, connector: GestionSportsConnector, tournaments_sessions_response +): + aioresponses.post( + connector.tournaments_sessions_url, + status=200, + body=tournaments_sessions_response, + ) + + +def set_tournaments_list_response( + aioresponses, + connector: GestionSportsConnector, + tournament_id, + tournaments_list_response, +): + url = f"{connector.tournaments_list_url}{tournament_id}" + aioresponses.get(url, status=200, body=tournaments_list_response) + + def set_full_user_bookings_responses(aioresponses, connector, responses): make_landing_request_success(aioresponses, connector, responses[0]) make_login_request_success(aioresponses, connector, responses[1]) @@ -81,3 +104,11 @@ def set_full_cancellation_responses(aioresponses, connector, responses): set_bookings_response(aioresponses, connector, responses[3]) set_cancellation_response(aioresponses, connector, responses[4]) + + +def set_full_tournaments_requests(aioresponses, connector, responses, tournament_id): + make_landing_request_success(aioresponses, connector, responses[0]) + make_login_request_success(aioresponses, connector, responses[1]) + + set_tournaments_sessions_response(aioresponses, connector, responses[2]) + set_tournaments_list_response(aioresponses, connector, tournament_id, responses[3]) From 7f59443b643b1846ef7cb4c0d1b09aaa98c0f233 Mon Sep 17 00:00:00 2001 From: stanislas Date: Sat, 23 Mar 2024 21:54:17 +0100 Subject: [PATCH 7/7] All methods are in the right class --- gd.json | 27 + resa_padel/booking.py | 34 +- resa_padel/exceptions.py | 6 +- resa_padel/gestion_sport_connector.py | 337 ++-- resa_padel/gestion_sports_services.py | 160 +- resa_padel/models.py | 267 ++++ tests/data/responses/tournament-sessions.html | 1380 ----------------- tests/integration_tests/test_booking.py | 4 +- .../test_gestion_sport_connector.py | 12 +- tests/unit_tests/responses.py | 6 +- .../test_gestion_sports_connector.py | 57 +- .../test_gestion_sports_services.py | 24 +- 12 files changed, 585 insertions(+), 1729 deletions(-) create mode 100644 gd.json delete mode 100644 tests/data/responses/tournament-sessions.html diff --git a/gd.json b/gd.json new file mode 100644 index 0000000..5d6eb6b --- /dev/null +++ b/gd.json @@ -0,0 +1,27 @@ +[ + { + "id": 3628098, + "chargeId": null, + "partners": [], + "dateResa": "28\/03\/2024", + "startTime": "13:30", + "endTime": "15:00", + "dayFr": "jeudi 28 mars 2024", + "codeLiveXperience": null, + "qrCodeSpartime": null, + "sport": "Padel", + "court": "court 11", + "creaPartie": 0, + "limitCreaPartie": "2024-03-28 11:30:00", + "cancel": true, + "bloquerRemplacementJoueur": 1, + "canRemovePartners": false, + "remainingPlaces": 3, + "isCaptain": true, + "dtStart": "2024-03-28T13:30:00+01:00", + "garantieCb": null, + "dureeValidCertif": null, + "playerStatus": 3, + "products": [] + } +] diff --git a/resa_padel/booking.py b/resa_padel/booking.py index b5a58f1..64b1983 100644 --- a/resa_padel/booking.py +++ b/resa_padel/booking.py @@ -39,24 +39,12 @@ async def cancel_booking(club: Club, user: User, booking_filter: BookingFilter) await service.cancel_booking(user, club, booking_filter) -async def cancel_booking_id(club: Club, user: User, booking_id: int) -> None: - """ - Cancel a booking that matches the booking id - - :param club: the club in which the booking was made - :param user: the user who made the booking - :param booking_id: the id of the booking to cancel - """ - service = GestionSportsServices() - await service.cancel_booking_id(user, club, booking_id) - - async def get_tournaments(club: Club, user: User) -> list[Tournament]: """ - Cancel a booking that matches the booking id + Get the list of all current tournaments, their price, date and availability - :param club: the club in which the booking was made - :param user: the user who made the booking + :param club: the club in which the tournaments are + :param user: a user of the club in order to retrieve the information """ service = GestionSportsServices() return await service.get_all_tournaments(user, club) @@ -74,17 +62,23 @@ def main() -> tuple[Court, User] | list[Tournament] | None: club = config.get_club() users = config.get_users(club.id) booking_filter = config.get_booking_filter() + LOGGER.info( + f"Booking a court of {booking_filter.sport_name} at {booking_filter.date} " + f"at club {club.name}" + ) court_booked, user = asyncio.run(book_court(club, users, booking_filter)) if court_booked: LOGGER.info( - "Court %s booked successfully at %s for user %s", - court_booked, - booking_filter.date, - user, + f"Court of {booking_filter.sport_name} {court_booked} was booked " + f"successfully at {booking_filter.date} at club {club.name} " + f"for user {user}" ) return court_booked, user else: - LOGGER.info("Booking did not work") + LOGGER.info( + f"No court of {booking_filter.sport_name} at {booking_filter.date} " + f"at club {club.name} was booked" + ) elif action == Action.CANCEL: user = config.get_user() diff --git a/resa_padel/exceptions.py b/resa_padel/exceptions.py index 6a17281..b777616 100644 --- a/resa_padel/exceptions.py +++ b/resa_padel/exceptions.py @@ -1,2 +1,6 @@ -class ArgumentMissing(Exception): +class WrongResponseStatus(Exception): + pass + + +class MissingProperty(Exception): pass diff --git a/resa_padel/gestion_sport_connector.py b/resa_padel/gestion_sport_connector.py index 55abcea..f7d1942 100644 --- a/resa_padel/gestion_sport_connector.py +++ b/resa_padel/gestion_sport_connector.py @@ -2,12 +2,11 @@ import asyncio import json import logging from pathlib import Path -from urllib.parse import urljoin import config from aiohttp import ClientResponse, ClientSession -from bs4 import BeautifulSoup -from models import Booking, BookingFilter, Club, Court, Sport, User +from exceptions import WrongResponseStatus +from models import BookingFilter, Club, Sport, User from payload_builders import PayloadBuilder from pendulum import DateTime @@ -18,8 +17,8 @@ POST_HEADERS = config.get_post_headers("gestion-sports") class GestionSportsConnector: """ - The connector for the Gestion Sports platform. - It handles all the requests to the website. + The connector for the Gestion Sports platform handles all the HTTP requests to the + Gestion sports website. """ def __init__(self, club: Club): @@ -35,184 +34,139 @@ class GestionSportsConnector: self.club = club - def _get_url_path(self, name: str) -> str: - """ - Get the URL path for the service with the given name - - :param name: the name of the service - :return: the URL path - """ - self._check_url_path_exists(name) - - return urljoin( - self.club.booking_platform.url, - self.club.booking_platform.urls.get(name).path, - ) - - def _get_url_parameter(self, name: str) -> str: - self._check_url_path_exists(name) - - return self.club.booking_platform.urls.get(name).parameter - - def _get_payload_template(self, name: str) -> Path: - """ - Get the path to the template file for the service with the given name - - :param name: the name of the service - :return: the path to the template file - """ - self._check_payload_template_exists(name) - - return ( - config.get_resources_folder() - / self.club.booking_platform.urls.get(name).payload_template - ) - - def _check_url_path_exists(self, name: str) -> None: - """ - Check that the URL path for the given service is defined - - :param name: the name of the service - """ - if ( - self.club.booking_platform.urls is None - or self.club.booking_platform.urls.get(name) is None - or self.club.booking_platform.urls.get(name).path is None - ): - raise ValueError( - f"The booking platform internal URL path for page {name} of club " - f"{self.club.name} are not set" - ) - - def _check_payload_template_exists(self, name: str) -> None: - """ - Check that the payload template for the given service is defined - - :param name: the name of the service - """ - if ( - self.club.booking_platform.urls is None - or self.club.booking_platform.urls.get(name) is None - or self.club.booking_platform.urls.get(name).path is None - ): - raise ValueError( - f"The booking platform internal URL path for page {name} of club " - f"{self.club.name} are not set" - ) - @property def landing_url(self) -> str: """ - Get the URL to the landing page of Gestion-Sports + Get the URL to for landing to the website - :return: the URL to the landing page + :return: the URL to landing """ - return self._get_url_path("landing-page") + return self.club.landing_url @property def login_url(self) -> str: """ - Get the URL to the connection login of Gestion-Sports + Get the URL to for logging in the website - :return: the URL to the login page + :return: the URL for logging in """ - return self._get_url_path("login") + return self.club.login_url @property def login_template(self) -> Path: """ - Get the payload template to send to log in the website + Get the payload template for logging in the website - :return: the payload template for logging in + :return: the payload template for logging """ - return self._get_payload_template("login") + return self.club.login_template @property def booking_url(self) -> str: """ - Get the URL to the booking page of Gestion-Sports + Get the URL used to book a court - :return: the URL to the booking page + :return: the URL to book a court """ - return self._get_url_path("booking") + return self.club.booking_url @property def booking_template(self) -> Path: """ - Get the payload template to send to book a court + Get the payload template for booking a court :return: the payload template for booking a court """ - return self._get_payload_template("booking") + return self.club.booking_template @property def user_bookings_url(self) -> str: """ - Get the URL where all the user's bookings are available + Get the URL of the bookings related to a user that are not yet passed - :return: the URL to the user's bookings + :return: the URL to get the bookings related to a user """ - return self._get_url_path("user-bookings") + return self.club.user_bookings_url @property def user_bookings_template(self) -> Path: """ - Get the payload template to send to get all the user's bookings that are - available + Get the payload template to get the bookings related to a user that are not yet + passed - :return: the payload template for the user's bookings + :return: the template for requesting the bookings related to a user """ - return self._get_payload_template("user-bookings") + return self.club.user_bookings_template @property - def booking_cancellation_url(self) -> str: + def cancel_url(self) -> str: """ - Get the URL where all the user's bookings are available + Get the URL used to cancel a booking - :return: the URL to the user's bookings + :return: the URL to cancel a booking """ - return self._get_url_path("cancellation") + return self.club.cancel_url @property - def booking_cancel_template(self) -> Path: + def cancel_template(self) -> Path: """ - Get the payload template to send to get all the user's bookings that are - available + Get the payload template for cancelling a booking - :return: the payload template for the user's bookings + :return: the template for cancelling a booking """ - return self._get_payload_template("cancellation") + return self.club.cancel_template @property - def tournaments_sessions_url(self) -> str: - return self._get_url_path("tournament-sessions") + def sessions_url(self) -> str: + """ + Get the URL of the session containing all the tournaments + + :return: the URL to get the session + """ + return self.club.sessions_url @property - def tournaments_sessions_template(self) -> Path: - return self._get_payload_template("tournament-sessions") + def sessions_template(self) -> Path: + """ + Get the payload template for requesting the session containing all the + tournaments + + :return: the template for requesting the session + """ + return self.club.sessions_template @property - def tournaments_list_url(self) -> str: - return self._get_url_path("tournaments-list") + def tournaments_url(self) -> str: + """ + Get the URL of all the tournaments list + + :return: the URL to get the tournaments list + """ + return self.club.tournaments_url @property - def available_sports(self) -> dict[str, Sport]: + def sports(self) -> dict[str, Sport]: """ Get a dictionary of all sports, the key is the sport name lowered case :return: the dictionary of all sports """ - return { - sport.name.lower(): sport for sport in self.club.booking_platform.sports - } + return self.club.sports + + @staticmethod + def check_response_status(response_status: int) -> None: + if response_status != 200: + raise WrongResponseStatus("GestionSports request failed") async def land(self, session: ClientSession) -> ClientResponse: """ Perform the request to the landing page in order to get the cookie PHPSESSIONID + :param session: the client session shared among all connections :return: the response from the landing page """ LOGGER.info("Connecting to GestionSports API at %s", self.login_url) async with session.get(self.landing_url) as response: + self.check_response_status(response.status) await response.text() return response @@ -220,6 +174,8 @@ class GestionSportsConnector: """ Perform the request to the log in the user + :param session: the client session shared among all connections + :param user: the user to log in :return: the response from the login """ LOGGER.info("Logging in to GestionSports API at %s", self.login_url) @@ -228,11 +184,12 @@ class GestionSportsConnector: async with session.post( self.login_url, data=payload, headers=POST_HEADERS, allow_redirects=False ) as response: + self.check_response_status(response.status) resp_text = await response.text() LOGGER.debug("Connexion request response:\n%s", resp_text) return response - async def book_any_court( + async def send_all_booking_requests( self, session: ClientSession, booking_filter: BookingFilter ) -> list[tuple[int, dict]]: """ @@ -241,7 +198,7 @@ class GestionSportsConnector: The gestion-sports backend does not allow several bookings at the same time so there is no need to make each request one after the other - :param session: the session to use + :param session: the client session shared among all connections :param booking_filter: the booking conditions to meet :return: the booked court, or None if no court was booked """ @@ -249,7 +206,7 @@ class GestionSportsConnector: "Booking any available court from GestionSports API at %s", self.booking_url ) - sport = self.available_sports.get(booking_filter.sport_name) + sport = self.sports.get(booking_filter.sport_name) bookings = await asyncio.gather( *[ @@ -274,7 +231,7 @@ class GestionSportsConnector: """ Book a single court that meets the conditions from the booking filter - :param session: the HTTP session that contains the user information and cookies + :param session: the client session shared among all connections :param date: the booking date :param court_id: the id of the court to book :param sport_id: the id of the sport @@ -293,168 +250,96 @@ class GestionSportsConnector: async with session.post( self.booking_url, data=payload, headers=POST_HEADERS ) as response: - assert response.status == 200 + self.check_response_status(response.status) resp_json = json.loads(await response.text()) + LOGGER.debug("Response from booking request:\n'%s'", resp_json) return court_id, resp_json - def get_booked_court( - self, bookings: list[tuple[int, dict]], sport_name: str - ) -> Court | None: + async def send_hash_request(self, session: ClientSession) -> ClientResponse: """ - Parse the booking list and return the court that was booked + Get the hash value used in some other requests - :param bookings: a list of bookings - :param sport_name: the sport name - :return: the id of the booked court if any, None otherwise - """ - for court_id, response in bookings: - if self.is_booking_response_status_ok(response): - LOGGER.debug("Court %d is booked", court_id) - court_booked = self.find_court(court_id, sport_name) - LOGGER.info("Court '%s' is booked", court_booked.name) - return court_booked - LOGGER.debug("No booked court found") - return None - - def find_court(self, court_id: int, sport_name: str) -> Court: - """ - Get all the court information based on the court id and the sport name - - :param court_id: the court id - :param sport_name: the sport name - :return: the court that has the given id and sport name - """ - sport = self.available_sports.get(sport_name.lower()) - for court in sport.courts: - if court.id == court_id: - return court - - @staticmethod - def is_booking_response_status_ok(response: dict) -> bool: - """ - Check if the booking response is OK - - :param response: the response as a string - :return: true if the status is ok, false otherwise - """ - return response["status"] == "ok" - - async def get_ongoing_bookings(self, session: ClientSession) -> list[Booking]: - """ - Get the list of all ongoing bookings of a user. - The steps to perform this are to get the user's bookings page and get a hidden - property in the HTML to get a hash that will be used in the payload of the - POST request (sic) to get the user's bookings. - Gestion sports is really a mess!! - - :return: the list of all ongoing bookings of a user - """ - hash_value = await self.send_hash_request(session) - LOGGER.debug(f"Hash value: {hash_value}") - payload = PayloadBuilder.build(self.user_bookings_template, hash=hash_value) - LOGGER.debug(f"Payload to get ongoing bookings: {payload}") - return await self.send_user_bookings_request(session, payload) - - async def send_hash_request(self, session: ClientSession) -> str: - """ - Get the hash value used in the request to get the user's bookings - - :param session: the session in which the user logged in + :param session: the client session shared among all connections :return: the value of the hash """ async with session.get(self.user_bookings_url) as response: + self.check_response_status(response.status) html = await response.text() LOGGER.debug("Get bookings response: %s\n", html) - return self.get_hash_input(html) - - @staticmethod - def get_hash_input(html_doc: str) -> str: - """ - There is a secret hash generated by Gestion sports that is reused when trying to get - users bookings. This hash is stored in a hidden input with name "mesresas-hash" - - :param html_doc: the html document when getting the page mes-resas.html - :return: the value of the hash in the page - """ - soup = BeautifulSoup(html_doc, "html.parser") - inputs = soup.find_all("input") - for input_tag in inputs: - if input_tag.get("name") == "mesresas-hash": - return input_tag.get("value").strip() + return response async def send_user_bookings_request( - self, session: ClientSession, payload: str - ) -> list[Booking]: + self, session: ClientSession, hash_value: str + ) -> ClientResponse: """ - Perform the HTTP request to get all bookings + Send a request to the platform to get all bookings of a user - :param session: the session in which the user logged in - :param payload: the HTTP payload for the request + :param session: the client session shared among all connections + :param hash_value: the hash value to put in the payload :return: a dictionary containing all the bookings """ + + payload = PayloadBuilder.build(self.user_bookings_template, hash=hash_value) async with session.post( self.user_bookings_url, data=payload, headers=POST_HEADERS ) as response: - resp = await response.text() - LOGGER.debug("ongoing bookings response: %s\n", resp) - return [Booking(**booking) for booking in json.loads(resp)] + self.check_response_status(response.status) + await response.text() + return response - async def cancel_booking_id( - self, session: ClientSession, booking_id: int + async def send_cancellation_request( + self, session: ClientSession, booking_id: int, hash_value: str ) -> ClientResponse: """ Send the HTTP request to cancel the booking - :param session: the HTTP session that contains the user information and cookies + :param session: the client session shared among all connections :param booking_id: the id of the booking to cancel :return: the response from the client """ - hash_value = await self.send_hash_request(session) - payload = PayloadBuilder.build( - self.booking_cancel_template, + self.cancel_template, booking_id=booking_id, hash=hash_value, ) async with session.post( - self.booking_cancellation_url, data=payload, headers=POST_HEADERS + self.cancel_url, data=payload, headers=POST_HEADERS ) as response: + self.check_response_status(response.status) await response.text() return response - async def cancel_booking( - self, session: ClientSession, booking_filter: BookingFilter - ) -> ClientResponse | None: + async def send_session_request(self, session: ClientSession) -> ClientResponse: """ - Cancel the booking that meets some conditions + Send a request to the platform to get the session id - :param session: the session - :param booking_filter: the conditions the booking to cancel should meet + :param session: the client session shared among all connections + :return: a client response containing HTML which has the session id """ - bookings = await self.get_ongoing_bookings(session) - - for booking in bookings: - if booking.matches(booking_filter): - return await self.cancel_booking_id(session, booking.id) - - async def send_tournaments_sessions_request( - self, session: ClientSession - ) -> ClientResponse: - payload = self.tournaments_sessions_template.read_text() + payload = self.sessions_template.read_text() async with session.post( - self.tournaments_sessions_url, data=payload, headers=POST_HEADERS + self.sessions_url, data=payload, headers=POST_HEADERS ) as response: + self.check_response_status(response.status) LOGGER.debug("tournament sessions: \n%s", await response.text()) return response async def send_tournaments_request( - self, session: ClientSession, tournement_session_id: str + self, session: ClientSession, session_id: str ) -> ClientResponse: - final_url = self.tournaments_list_url + tournement_session_id + """ + Send a request to the platform to get the next tournaments + + :param session: the client session shared among all connections + :param session_id: the tournaments are grouped in a session + :return: a client response containing the list of all the nex tournaments + """ + final_url = self.tournaments_url + session_id LOGGER.debug("Getting tournaments list at %s", final_url) async with session.get(final_url) as response: + self.check_response_status(response.status) LOGGER.debug("tournaments: %s\n", await response.text()) return response diff --git a/resa_padel/gestion_sports_services.py b/resa_padel/gestion_sports_services.py index d616484..6aa8f19 100644 --- a/resa_padel/gestion_sports_services.py +++ b/resa_padel/gestion_sports_services.py @@ -6,16 +6,24 @@ import pendulum from aiohttp import ClientSession from bs4 import BeautifulSoup from gestion_sport_connector import GestionSportsConnector -from models import BookingFilter, BookingOpening, Club, Court, Tournament, User +from models import ( + Booking, + BookingFilter, + BookingOpening, + Club, + Court, + Sport, + Tournament, + User, +) from pendulum import DateTime LOGGER = logging.getLogger(__name__) class GestionSportsServices: - @staticmethod async def book( - club: Club, user: User, booking_filter: BookingFilter + self, club: Club, user: User, booking_filter: BookingFilter ) -> Court | None: """ Perform a request for each court at the same time to increase the chances to get @@ -45,37 +53,76 @@ class GestionSportsServices: booking_filter, booking_opening ) - bookings = await connector.book_any_court(session, booking_filter) + bookings = await connector.send_all_booking_requests( + session, booking_filter + ) LOGGER.debug("Booking results:\n'%s'", bookings) - return connector.get_booked_court(bookings, booking_filter.sport_name) + + sport = club.sports.get(booking_filter.sport_name) + + return self.get_booked_court(bookings, sport) + + def get_booked_court( + self, bookings: list[tuple[int, dict]], sport: Sport + ) -> Court | None: + """ + Parse the booking list and return the court that was booked + + :param bookings: a list of bookings + :param sport: the sport of the club and all the courts it has + :return: the id of the booked court if any, None otherwise + """ + for court_id, response in bookings: + if self.is_booking_response_status_ok(response): + LOGGER.debug("Court %d is booked", court_id) + court_booked = self.find_court(court_id, sport) + LOGGER.info("Court '%s' is booked", court_booked.name) + return court_booked + LOGGER.debug("No booked court found") + return None @staticmethod - async def has_user_available_slots(user: User, club: Club) -> bool: + def is_booking_response_status_ok(response: dict) -> bool: + """ + Check if the booking response is OK + + :param response: the response as a string + :return: true if the status is ok, false otherwise + """ + return response["status"] == "ok" + + @staticmethod + def find_court(court_id: int, sport: Sport) -> Court: + """ + Get all the court information based on the court id and the sport name + + :param court_id: the court id + :param sport: the sport + :return: the court that has the given id and sport name + """ + for court in sport.courts: + if court.id == court_id: + return court + + async def has_user_available_slots(self, user: User, club: Club) -> bool: + """ + Checks if a user has available booking slot. + If a user already has an ongoing booking, it is considered as no slot is + available + + :param user: The user to check the booking availability + :param club: The club of the user + :return: True if the user has no ongoing booking, False otherwise + """ connector = GestionSportsConnector(club) async with ClientSession() as session: await connector.land(session) await connector.login(session, user) - bookings = await connector.get_ongoing_bookings(session) + bookings = await self.get_ongoing_bookings(session, connector) return bool(bookings) - @staticmethod - async def cancel_booking(user: User, club: Club, booking_filter: BookingFilter): - connector = GestionSportsConnector(club) - async with ClientSession() as session: - await connector.land(session) - await connector.login(session, user) - await connector.cancel_booking(session, booking_filter) - - @staticmethod - async def cancel_booking_id(user: User, club: Club, booking_id: int): - connector = GestionSportsConnector(club) - async with ClientSession() as session: - await connector.land(session) - await connector.login(session, user) - await connector.cancel_booking_id(session, booking_id) - @staticmethod def wait_until_booking_time( booking_filter: BookingFilter, booking_opening: BookingOpening @@ -128,6 +175,71 @@ class GestionSportsServices: return booking_date.at(booking_hour, booking_minute) + async def cancel_booking( + self, user: User, club: Club, booking_filter: BookingFilter + ): + connector = GestionSportsConnector(club) + async with ClientSession() as session: + await connector.land(session) + await connector.login(session, user) + + bookings = await self.get_ongoing_bookings(session, connector) + for booking in bookings: + if booking.matches(booking_filter): + return await self.cancel_booking_id(session, connector, booking.id) + + async def get_ongoing_bookings( + self, session: ClientSession, connector: GestionSportsConnector + ) -> list[Booking]: + """ + Get the list of all ongoing bookings of a user. + The steps to perform this are to get the user's bookings page and get a hidden + property in the HTML to get a hash that will be used in the payload of the + POST request (sic) to get the user's bookings. + Gestion sports is really a mess!! + + :param session: the client session shared among all connections + :param connector: the connector used to send the requests + :return: the list of all ongoing bookings of a user + """ + response = await connector.send_hash_request(session) + hash_value = self.get_hash_input(await response.text()) + LOGGER.debug(f"Hash value: {hash_value}") + response = await connector.send_user_bookings_request(session, hash_value) + return [Booking(**booking) for booking in json.loads(await response.text())] + + @staticmethod + def get_hash_input(html_doc: str) -> str: + """ + There is a secret hash generated by Gestion sports that is reused when trying to get + users bookings. This hash is stored in a hidden input with name "mesresas-hash" + + :param html_doc: the html document when getting the page mes-resas.html + :return: the value of the hash in the page + """ + soup = BeautifulSoup(html_doc, "html.parser") + inputs = soup.find_all("input") + for input_tag in inputs: + if input_tag.get("name") == "mesresas-hash": + return input_tag.get("value").strip() + + async def cancel_booking_id( + self, session: ClientSession, connector: GestionSportsConnector, booking_id: int + ) -> None: + """ + Send the HTTP request to cancel the booking + + :param session: the client session shared among all connections + :param connector: the connector used to send the requests + :param booking_id: the id of the booking to cancel + :return: the response from the client + """ + response = await connector.send_hash_request(session) + hash_value = self.get_hash_input(await response.text()) + LOGGER.debug(f"Hash value: {hash_value}") + + await connector.send_cancellation_request(session, booking_id, hash_value) + @staticmethod async def get_all_tournaments(user: User, club: Club) -> list[Tournament]: connector = GestionSportsConnector(club) @@ -135,7 +247,7 @@ class GestionSportsServices: await connector.land(session) await connector.login(session, user) - session_html = await connector.send_tournaments_sessions_request(session) + session_html = await connector.send_session_request(session) tournaments_id = GestionSportsServices.retrieve_tournament_session( await session_html.text() ) diff --git a/resa_padel/models.py b/resa_padel/models.py index 1fbb39c..4180fb4 100644 --- a/resa_padel/models.py +++ b/resa_padel/models.py @@ -1,7 +1,11 @@ from enum import Enum +from pathlib import Path from typing import Optional +from urllib.parse import urljoin +import config import pendulum +from exceptions import MissingProperty from pendulum import Date, Time from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic_extra_types.pendulum_dt import DateTime @@ -84,6 +88,166 @@ class BookingPlatform(BaseModel): sports: list[Sport] urls: dict[str, Url] + def get_url_path(self, name: str) -> str: + """ + Get the URL path for the service with the given name + + :param name: the name of the service + :return: the URL path + """ + self.check_url_path_exists(name) + + return urljoin(self.url, self.urls.get(name).path) + + def get_payload_template(self, name: str) -> Path: + """ + Get the path to the template file for the service with the given name + + :param name: the name of the service + :return: the path to the template file + """ + self.check_payload_template_exists(name) + + return config.get_resources_folder() / self.urls.get(name).payload_template + + def get_url_parameter(self, name: str) -> str: + self.check_url_path_exists(name) + + return self.urls.get(name).parameter + + def check_url_path_exists(self, name: str) -> None: + """ + Check that the URL path for the given service is defined + + :param name: the name of the service + """ + if ( + self.urls is None + or self.urls.get(name) is None + or self.urls.get(name).path is None + ): + raise MissingProperty( + f"The booking platform internal URL path for page {name} are not set" + ) + + def check_payload_template_exists(self, name: str) -> None: + """ + Check that the payload template for the given service is defined + + :param name: the name of the service + """ + if ( + self.urls is None + or self.urls.get(name) is None + or self.urls.get(name).path is None + ): + raise ValueError( + f"The booking platform internal URL path for page {name} are not set" + ) + + @property + def landing_url(self) -> str: + """ + Get the URL to the landing page of Gestion-Sports + + :return: the URL to the landing page + """ + return self.get_url_path("landing-page") + + @property + def login_url(self) -> str: + """ + Get the URL to the connection login of Gestion-Sports + + :return: the URL to the login page + """ + return self.get_url_path("login") + + @property + def login_template(self) -> Path: + """ + Get the payload template to send to log in the website + + :return: the payload template for logging in + """ + return self.get_payload_template("login") + + @property + def booking_url(self) -> str: + """ + Get the URL to the booking page of Gestion-Sports + + :return: the URL to the booking page + """ + return self.get_url_path("booking") + + @property + def booking_template(self) -> Path: + """ + Get the payload template to send to book a court + + :return: the payload template for booking a court + """ + return self.get_payload_template("booking") + + @property + def user_bookings_url(self) -> str: + """ + Get the URL where all the user's bookings are available + + :return: the URL to the user's bookings + """ + return self.get_url_path("user-bookings") + + @property + def user_bookings_template(self) -> Path: + """ + Get the payload template to send to get all the user's bookings that are + available + + :return: the payload template for the user's bookings + """ + return self.get_payload_template("user-bookings") + + @property + def booking_cancellation_url(self) -> str: + """ + Get the URL where all the user's bookings are available + + :return: the URL to the user's bookings + """ + return self.get_url_path("cancellation") + + @property + def booking_cancel_template(self) -> Path: + """ + Get the payload template to send to get all the user's bookings that are + available + + :return: the payload template for the user's bookings + """ + return self.get_payload_template("cancellation") + + @property + def tournaments_sessions_url(self) -> str: + return self.get_url_path("tournament-sessions") + + @property + def tournaments_sessions_template(self) -> Path: + return self.get_payload_template("tournament-sessions") + + @property + def tournaments_list_url(self) -> str: + return self.get_url_path("tournaments-list") + + @property + def available_sports(self) -> dict[str, Sport]: + """ + Get a dictionary of all sports, the key is the sport name lowered case + :return: the dictionary of all sports + """ + return {sport.name.lower(): sport for sport in self.sports} + class Club(BaseModel): id: str @@ -91,6 +255,109 @@ class Club(BaseModel): url: str booking_platform: BookingPlatform = Field(alias="bookingPlatform") + @property + def landing_url(self) -> str: + """ + Get the URL to the landing page of Gestion-Sports + + :return: the URL to the landing page + """ + return self.booking_platform.landing_url + + @property + def login_url(self) -> str: + """ + Get the URL to the connection login of Gestion-Sports + + :return: the URL to the login page + """ + return self.booking_platform.login_url + + @property + def login_template(self) -> Path: + """ + Get the payload template to send to log in the website + + :return: the payload template for logging in + """ + return self.booking_platform.login_template + + @property + def booking_url(self) -> str: + """ + Get the URL to the booking page of Gestion-Sports + + :return: the URL to the booking page + """ + return self.booking_platform.booking_url + + @property + def booking_template(self) -> Path: + """ + Get the payload template to send to book a court + + :return: the payload template for booking a court + """ + return self.booking_platform.booking_template + + @property + def user_bookings_url(self) -> str: + """ + Get the URL where all the user's bookings are available + + :return: the URL to the user's bookings + """ + return self.booking_platform.user_bookings_url + + @property + def user_bookings_template(self) -> Path: + """ + Get the payload template to send to get all the user's bookings that are + available + + :return: the payload template for the user's bookings + """ + return self.booking_platform.user_bookings_template + + @property + def cancel_url(self) -> str: + """ + Get the URL where all the user's bookings are available + + :return: the URL to the user's bookings + """ + return self.booking_platform.booking_cancellation_url + + @property + def cancel_template(self) -> Path: + """ + Get the payload template to send to get all the user's bookings that are + available + + :return: the payload template for the user's bookings + """ + return self.booking_platform.booking_cancel_template + + @property + def sessions_url(self) -> str: + return self.booking_platform.tournaments_sessions_url + + @property + def sessions_template(self) -> Path: + return self.booking_platform.tournaments_sessions_template + + @property + def tournaments_url(self) -> str: + return self.booking_platform.tournaments_list_url + + @property + def sports(self) -> dict[str, Sport]: + """ + Get a dictionary of all sports, the key is the sport name lowered case + :return: the dictionary of all sports + """ + return self.booking_platform.available_sports + class PlatformDefinition(BaseModel): id: str diff --git a/tests/data/responses/tournament-sessions.html b/tests/data/responses/tournament-sessions.html deleted file mode 100644 index ff61262..0000000 --- a/tests/data/responses/tournament-sessions.html +++ /dev/null @@ -1,1380 +0,0 @@ - - - - - - Home - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
- image - Le Mas - à l'instant -
- - - -
-
-
-

Titre de la notif

-
- Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod - tempor incididunt ut labore et dolore magna aliqua. -
-
-
-
-
- - - - - - - - - - - - - - - - - - - -
-
-
-
- -
-
- - - - -
- -
- Accueil
- -
- - - - -
-
-
imageimageimageimageimageimage
imageimageimageimage
-
- -
-
-
imageimageimageimage
imageimageimageimage
imageimage
-
- - - - -
- -
-

Bonjour Audiard,

-
- -
- - - -
- Padel / Squash / Electrostimulation -
-
- -
- -
- - - - - - - - - - -
-
-
-
-
- -
- Évènements -
- -
- -
-
-
-
- -
- -
-
    -
-
- -
-
-
-
-
- - -
-
-
- -
Toulouse Padel Club
-

- 11 rue Marie Louise Dissard
- 31300
- Toulouse
-

- -
-
-
- -
- - -
-
-
-
-
-
-
-
- - - - -
- - -
- - - - - - - -
- - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- -
- Votre inscription à la partie de Tennis a bien été enregistrée !
- Rendez-vous le 14 Janvier de 16h30 à 17h30. -
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/tests/integration_tests/test_booking.py b/tests/integration_tests/test_booking.py index db6c6e1..2e093cd 100644 --- a/tests/integration_tests/test_booking.py +++ b/tests/integration_tests/test_booking.py @@ -33,7 +33,7 @@ def test_cancellation(club, user, booking_filter): "CLUB_ID": "tpc", "ACTION": "book", "SPORT_NAME": "Padel", - "DATE_TIME": "2024-03-21T13:30:00+01:00", + "DATE_TIME": "2024-03-28T13:30:00+01:00", }, clear=True, ) @@ -49,7 +49,7 @@ def test_main_booking(): "CLUB_ID": "tpc", "ACTION": "cancel", "SPORT_NAME": "Padel", - "DATE_TIME": "2024-03-21T13:30:00+01:00", + "DATE_TIME": "2024-03-28T13:30:00+01:00", "LOGIN": "padel.testing@jouf.fr", "PASSWORD": "ridicule", }, diff --git a/tests/integration_tests/test_gestion_sport_connector.py b/tests/integration_tests/test_gestion_sport_connector.py index 4e5bb44..5078e6a 100644 --- a/tests/integration_tests/test_gestion_sport_connector.py +++ b/tests/integration_tests/test_gestion_sport_connector.py @@ -33,11 +33,11 @@ def test_urls(connector): == "https://toulousepadelclub.gestion-sports.com/membre/mesresas.html" ) assert ( - connector.booking_cancellation_url + connector.cancel_url == "https://toulousepadelclub.gestion-sports.com/membre/mesresas.html" ) assert ( - connector.tournaments_sessions_url + connector.sessions_url == "https://toulousepadelclub.gestion-sports.com/membre/index.php" ) @@ -56,11 +56,11 @@ def test_urls_payload_templates(connector): == resources_folder / "user-bookings-payload.txt" ) assert ( - connector.booking_cancel_template + connector.cancel_template == resources_folder / "booking-cancellation-payload.txt" ) assert ( - connector.tournaments_sessions_template + connector.sessions_template == resources_folder / "tournament-sessions-payload.txt" ) @@ -192,7 +192,7 @@ async def test_cancel_booking_id(connector, user): await connector.land(session) await connector.login(session, user) - await connector.cancel_booking_id(session, 666) + await connector.send_cancellation_request(session, 666) assert len(await connector.get_ongoing_bookings(session)) == 0 @@ -217,7 +217,7 @@ async def test_tournament_sessions(connector, user): async with ClientSession() as session: await connector.land(session) await connector.login(session, user) - response = await connector.send_tournaments_sessions_request(session) + response = await connector.send_session_request(session) assert response.status == 200 diff --git a/tests/unit_tests/responses.py b/tests/unit_tests/responses.py index 8ee5c7e..57e8fee 100644 --- a/tests/unit_tests/responses.py +++ b/tests/unit_tests/responses.py @@ -61,7 +61,7 @@ def set_tournaments_sessions_response( aioresponses, connector: GestionSportsConnector, tournaments_sessions_response ): aioresponses.post( - connector.tournaments_sessions_url, + connector.sessions_url, status=200, body=tournaments_sessions_response, ) @@ -73,7 +73,7 @@ def set_tournaments_list_response( tournament_id, tournaments_list_response, ): - url = f"{connector.tournaments_list_url}{tournament_id}" + url = f"{connector.tournaments_url}{tournament_id}" aioresponses.get(url, status=200, body=tournaments_list_response) @@ -84,7 +84,7 @@ def set_full_user_bookings_responses(aioresponses, connector, responses): def set_cancellation_response(aioresponses, connector, response): - aioresponses.post(connector.booking_cancellation_url, status=200, payload=response) + aioresponses.post(connector.cancel_url, status=200, payload=response) def set_full_cancellation_by_id_responses(aioresponses, connector, responses): diff --git a/tests/unit_tests/test_gestion_sports_connector.py b/tests/unit_tests/test_gestion_sports_connector.py index 82be8ee..b1b9bad 100644 --- a/tests/unit_tests/test_gestion_sports_connector.py +++ b/tests/unit_tests/test_gestion_sports_connector.py @@ -16,9 +16,9 @@ def test_urls(connector, club): assert connector.login_url == f"{base_url}/login.html" assert connector.booking_url == f"{base_url}/booking.html" assert connector.user_bookings_url == f"{base_url}/user_bookings.html" - assert connector.booking_cancellation_url == f"{base_url}/cancel.html" - assert connector.tournaments_sessions_url == f"{base_url}/tournaments_sessions.php" - assert connector.tournaments_list_url == f"{base_url}/tournaments_list.html?event=" + assert connector.cancel_url == f"{base_url}/cancel.html" + assert connector.sessions_url == f"{base_url}/tournaments_sessions.php" + assert connector.tournaments_url == f"{base_url}/tournaments_list.html?event=" @patch("config.get_resources_folder") @@ -41,11 +41,11 @@ def test_urls_payload_templates(mock_resources, club): == path_to_resources / "gestion-sports/user-bookings-payload.txt" ) assert ( - connector.booking_cancel_template + connector.cancel_template == path_to_resources / "gestion-sports/booking-cancellation-payload.txt" ) assert ( - connector.tournaments_sessions_template + connector.sessions_template == path_to_resources / "gestion-sports/tournament-sessions-payload.txt" ) @@ -90,29 +90,6 @@ async def test_login_failure(aioresponses, connector, user, login_failure_respon assert await response.json() == login_failure_response -def test_get_booked_court(connector, booked_courts_response): - booked_court = connector.get_booked_court(booked_courts_response, "Sport1") - assert booked_court.number == 3 - - -@pytest.mark.asyncio -async def test_get_ongoing_bookings( - aioresponses, - connector, - user, - user_bookings_get_response, - user_bookings_list, -): - responses.set_ongoing_bookings_response( - aioresponses, connector, user_bookings_get_response, user_bookings_list - ) - - async with ClientSession() as session: - bookings = await connector.get_ongoing_bookings(session) - - assert len(bookings) == 2 - - @pytest.mark.asyncio async def test_cancellation_request( aioresponses, connector, user_bookings_get_response, cancellation_response @@ -121,31 +98,11 @@ async def test_cancellation_request( responses.set_cancellation_response(aioresponses, connector, cancellation_response) async with ClientSession() as session: - response = await connector.cancel_booking_id(session, 123) + response = await connector.send_cancellation_request(session, 123, "hash") assert await response.json() == cancellation_response -@pytest.mark.asyncio -async def test_cancel_booking_success( - aioresponses, - connector, - user, - cancellation_success_booking_filter, - cancellation_success_from_start, -): - responses.set_full_cancellation_responses( - aioresponses, connector, cancellation_success_from_start - ) - - async with ClientSession() as session: - response = await connector.cancel_booking( - session, cancellation_success_booking_filter - ) - - assert await response.json() == cancellation_success_from_start[4] - - @pytest.mark.asyncio async def test_tournament_sessions( aioresponses, connector, user, tournament_sessions_json @@ -154,7 +111,7 @@ async def test_tournament_sessions( aioresponses, connector, tournament_sessions_json ) async with ClientSession() as session: - response = await connector.send_tournaments_sessions_request(session) + response = await connector.send_session_request(session) assert response.status == 200 diff --git a/tests/unit_tests/test_gestion_sports_services.py b/tests/unit_tests/test_gestion_sports_services.py index 6cc8351..5ac584f 100644 --- a/tests/unit_tests/test_gestion_sports_services.py +++ b/tests/unit_tests/test_gestion_sports_services.py @@ -11,6 +11,7 @@ from tests.unit_tests import responses @pytest.mark.asyncio async def test_booking_success( aioresponses, + gs_services, connector, club, user, @@ -21,7 +22,7 @@ async def test_booking_success( aioresponses, connector, booking_success_from_start ) - court_booked = await GestionSportsServices.book(club, user, booking_filter) + court_booked = await gs_services.book(club, user, booking_filter) assert court_booked.id == 2 @@ -45,6 +46,11 @@ async def test_booking_failure( assert court_booked is None +def test_get_booked_court(gs_services, booked_courts_response, sport1): + booked_court = gs_services.get_booked_court(booked_courts_response, sport1) + assert booked_court.number == 3 + + @pytest.mark.asyncio async def test_user_has_available_booking_slots( aioresponses, @@ -98,22 +104,6 @@ async def test_cancel_booking( await gs_services.cancel_booking(user, club, booking_filter) -@pytest.mark.asyncio -async def test_cancel_booking_id( - aioresponses, - gs_services, - connector, - user, - club, - cancellation_success_from_start, -): - responses.set_full_cancellation_responses( - aioresponses, connector, cancellation_success_from_start - ) - - await gs_services.cancel_booking_id(user, club, 65464) - - @patch("pendulum.now") def test_wait_until_booking_time(mock_now, club, user): booking_filter = BookingFilter(