diff --git a/booking.md b/booking.md deleted file mode 100644 index 7b3050c..0000000 --- a/booking.md +++ /dev/null @@ -1,44 +0,0 @@ -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/gd.json b/gd.json deleted file mode 100644 index 5d6eb6b..0000000 --- a/gd.json +++ /dev/null @@ -1,27 +0,0 @@ -[ - { - "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 64b1983..2a0b7ec 100644 --- a/resa_padel/booking.py +++ b/resa_padel/booking.py @@ -2,91 +2,62 @@ import asyncio import logging import config -from gestion_sports_services import GestionSportsServices -from models import Action, BookingFilter, Club, Court, Tournament, User +from gestion_sports.gestion_sports_platform import GestionSportsPlatform +from models import BookingFilter, Club, User LOGGER = logging.getLogger(__name__) -async def book_court( - club: Club, users: list[User], booking_filter: BookingFilter -) -> tuple[Court, User]: +async def book(club: Club, user: User, booking_filter: BookingFilter) -> int | None: """ - 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 + Book a court for a user to a club following a booking filter - :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 + :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 """ - service = GestionSportsServices() - for user in users: - if not await service.has_user_available_slots(user, club): - return await service.book(club, user, booking_filter), user + async with GestionSportsPlatform(club) as platform: + return await platform.book(user, booking_filter) -async def cancel_booking(club: Club, user: User, booking_filter: BookingFilter) -> None: +async def get_user_without_booking(club: Club, users: list[User]) -> User | None: """ - Cancel the booking that matches the specified filter + Return the first 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 + :param club: the club where to book + :param users: the list of users + :return: any user who has no booking """ - service = GestionSportsServices() - await service.cancel_booking(user, club, booking_filter) + async with GestionSportsPlatform(club) as platform: + for user in users: + if await platform.user_has_no_ongoing_booking(user): + return user + return None -async def get_tournaments(club: Club, user: User) -> list[Tournament]: - """ - Get the list of all current tournaments, their price, date and availability - - :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) - - -def main() -> tuple[Court, User] | list[Tournament] | None: +def main() -> int | None: """ Main function used to book a court :return: the id of the booked court, or None if no court was booked """ - action = config.get_action() + booking_filter = config.get_booking_filter() + club = config.get_club() + user = asyncio.run(get_user_without_booking(club, config.get_available_users())) - if action == Action.BOOK: - club = config.get_club() - users = config.get_users(club.id) - booking_filter = config.get_booking_filter() + 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( - f"Booking a court of {booking_filter.sport_name} at {booking_filter.date} " - f"at club {club.name}" + "Court %s booked successfully at %s", court_booked, booking_filter.date ) - court_booked, user = asyncio.run(book_court(club, users, booking_filter)) - if court_booked: - LOGGER.info( - 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( - 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() - 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)) + else: + LOGGER.info("Booking did not work") + return court_booked diff --git a/resa_padel/config.py b/resa_padel/config.py index 70132b5..749ea5e 100644 --- a/resa_padel/config.py +++ b/resa_padel/config.py @@ -6,11 +6,36 @@ from pathlib import Path import pendulum import yaml from dotenv import load_dotenv -from models import Action, BookingFilter, Club, User +from models import BookingFilter, Club, User load_dotenv() -ROOT_DIR = Path(__file__).parent + +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(), + ) def get_booking_filter() -> BookingFilter: @@ -20,10 +45,11 @@ def get_booking_filter() -> BookingFilter: :return: the club """ - sport_name = os.environ.get("SPORT_NAME") + sport_id_tmp = os.environ.get("SPORT_ID") + sport_id = int(sport_id_tmp) if sport_id_tmp else None date_time_tmp = os.environ.get("DATE_TIME") date_time = pendulum.parse(date_time_tmp) if date_time_tmp else None - return BookingFilter(sport_name=sport_name.lower(), date=date_time) + return BookingFilter(sport_id=sport_id, date=date_time) def get_user() -> User: @@ -38,6 +64,25 @@ 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 @@ -57,90 +102,13 @@ def init_log_config(): """ Read the logging.yaml file to initialize the logging configuration """ - logging_file = ROOT_DIR / "logging.yaml" + root_dir = os.path.realpath(os.path.dirname(__file__)) + logging_file = root_dir + "/logging.yaml" - with logging_file.open(mode="r", encoding="utf-8") as f: + with open(logging_file, "r") as f: logging_config = yaml.safe_load(f.read()) - - logging.config.dictConfig(logging_config) + logging.config.dictConfig(logging_config) -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").lower()) +ROOT_PATH = Path(__file__).parent.resolve() +RESOURCES_DIR = Path(ROOT_PATH, "resources") diff --git a/resa_padel/exceptions.py b/resa_padel/exceptions.py index b777616..6a17281 100644 --- a/resa_padel/exceptions.py +++ b/resa_padel/exceptions.py @@ -1,6 +1,2 @@ -class WrongResponseStatus(Exception): - pass - - -class MissingProperty(Exception): +class ArgumentMissing(Exception): pass diff --git a/resa_padel/gestion_sport_connector.py b/resa_padel/gestion_sport_connector.py deleted file mode 100644 index f7d1942..0000000 --- a/resa_padel/gestion_sport_connector.py +++ /dev/null @@ -1,345 +0,0 @@ -import asyncio -import json -import logging -from pathlib import Path - -import config -from aiohttp import ClientResponse, ClientSession -from exceptions import WrongResponseStatus -from models import BookingFilter, Club, Sport, User -from payload_builders import PayloadBuilder -from pendulum import DateTime - -LOGGER = logging.getLogger(__name__) - -POST_HEADERS = config.get_post_headers("gestion-sports") - - -class GestionSportsConnector: - """ - The connector for the Gestion Sports platform handles all the HTTP requests to the - Gestion sports 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.id - ) - ) - - self.club = club - - @property - def landing_url(self) -> str: - """ - Get the URL to for landing to the website - - :return: the URL to landing - """ - return self.club.landing_url - - @property - def login_url(self) -> str: - """ - Get the URL to for logging in the website - - :return: the URL for logging in - """ - return self.club.login_url - - @property - def login_template(self) -> Path: - """ - Get the payload template for logging in the website - - :return: the payload template for logging - """ - return self.club.login_template - - @property - def booking_url(self) -> str: - """ - Get the URL used to book a court - - :return: the URL to book a court - """ - return self.club.booking_url - - @property - def booking_template(self) -> Path: - """ - Get the payload template for booking a court - - :return: the payload template for booking a court - """ - return self.club.booking_template - - @property - def user_bookings_url(self) -> str: - """ - Get the URL of the bookings related to a user that are not yet passed - - :return: the URL to get the bookings related to a user - """ - return self.club.user_bookings_url - - @property - def user_bookings_template(self) -> Path: - """ - Get the payload template to get the bookings related to a user that are not yet - passed - - :return: the template for requesting the bookings related to a user - """ - return self.club.user_bookings_template - - @property - def cancel_url(self) -> str: - """ - Get the URL used to cancel a booking - - :return: the URL to cancel a booking - """ - return self.club.cancel_url - - @property - def cancel_template(self) -> Path: - """ - Get the payload template for cancelling a booking - - :return: the template for cancelling a booking - """ - return self.club.cancel_template - - @property - 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 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_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 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.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 - - async def login(self, session: ClientSession, user: User) -> ClientResponse: - """ - 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) - payload = PayloadBuilder.build(self.login_template, user=user, club=self.club) - - 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 send_all_booking_requests( - 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 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 - """ - LOGGER.info( - "Booking any available court from GestionSports API at %s", self.booking_url - ) - - sport = self.sports.get(booking_filter.sport_name) - - 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 bookings - - async def send_booking_request( - self, - session: ClientSession, - date: DateTime, - court_id: int, - sport_id: int, - ) -> tuple[int, dict]: - """ - Book a single court that meets the conditions from the booking filter - - :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 - :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( - 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: - 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 - - async def send_hash_request(self, session: ClientSession) -> ClientResponse: - """ - Get the hash value used in some other requests - - :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 response - - async def send_user_bookings_request( - self, session: ClientSession, hash_value: str - ) -> ClientResponse: - """ - Send a request to the platform to get all bookings of a user - - :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: - self.check_response_status(response.status) - await response.text() - return response - - 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 client session shared among all connections - :param booking_id: the id of the booking to cancel - :return: the response from the client - """ - payload = PayloadBuilder.build( - self.cancel_template, - booking_id=booking_id, - hash=hash_value, - ) - - async with session.post( - self.cancel_url, data=payload, headers=POST_HEADERS - ) as response: - self.check_response_status(response.status) - await response.text() - return response - - async def send_session_request(self, session: ClientSession) -> ClientResponse: - """ - Send a request to the platform to get the session id - - :param session: the client session shared among all connections - :return: a client response containing HTML which has the session id - """ - payload = self.sessions_template.read_text() - - async with session.post( - 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, session_id: str - ) -> ClientResponse: - """ - 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/tests/integration_tests/__init__.py b/resa_padel/gestion_sports/__init__.py similarity index 100% rename from tests/integration_tests/__init__.py rename to resa_padel/gestion_sports/__init__.py diff --git a/resa_padel/gestion_sports/gestion_sports_config.py b/resa_padel/gestion_sports/gestion_sports_config.py new file mode 100644 index 0000000..ea5b852 --- /dev/null +++ b/resa_padel/gestion_sports/gestion_sports_config.py @@ -0,0 +1,9 @@ +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 new file mode 100644 index 0000000..de5dca3 --- /dev/null +++ b/resa_padel/gestion_sports/gestion_sports_connector.py @@ -0,0 +1,199 @@ +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 new file mode 100644 index 0000000..ed7f12a --- /dev/null +++ b/resa_padel/gestion_sports/gestion_sports_html_parser.py @@ -0,0 +1,16 @@ +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 new file mode 100644 index 0000000..35679ce --- /dev/null +++ b/resa_padel/gestion_sports/gestion_sports_platform.py @@ -0,0 +1,116 @@ +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 new file mode 100644 index 0000000..1509a52 --- /dev/null +++ b/resa_padel/gestion_sports/payload_builders.py @@ -0,0 +1,128 @@ +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/gestion_sports_services.py b/resa_padel/gestion_sports_services.py deleted file mode 100644 index 6aa8f19..0000000 --- a/resa_padel/gestion_sports_services.py +++ /dev/null @@ -1,300 +0,0 @@ -import json -import logging -import time - -import pendulum -from aiohttp import ClientSession -from bs4 import BeautifulSoup -from gestion_sport_connector import GestionSportsConnector -from models import ( - Booking, - BookingFilter, - BookingOpening, - Club, - Court, - Sport, - Tournament, - User, -) -from pendulum import DateTime - -LOGGER = logging.getLogger(__name__) - - -class GestionSportsServices: - async def book( - 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 - 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.send_all_booking_requests( - session, booking_filter - ) - - LOGGER.debug("Booking results:\n'%s'", bookings) - - 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 - 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 self.get_ongoing_bookings(session, connector) - - return bool(bookings) - - @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) - - 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) - async with ClientSession() as session: - await connector.land(session) - await connector.login(session, user) - - session_html = await connector.send_session_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 4180fb4..1857750 100644 --- a/resa_padel/models.py +++ b/resa_padel/models.py @@ -1,490 +1,22 @@ -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 pendulum import Time +from pydantic import BaseModel, ConfigDict, Field from pydantic_extra_types.pendulum_dt import DateTime -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) - - 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 - parameter: Optional[str] = Field(default=None) - 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] - - 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 - name: str - 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 - name: str - url: str - urls: list[Url] + 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)) class BookingFilter(BaseModel): - date: DateTime - sport_name: str - - @field_validator("sport_name", mode="before") - @classmethod - def to_lower_case(cls, d: str) -> str: - return d.lower() + sport_id: int = Field() + date: DateTime = Field() -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() - - 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" - CANCEL = "cancel" - TOURNAMENTS = "tournaments" - - -class Tournament(BaseModel): - name: str - price: str - start_date: DateTime - end_date: DateTime - gender: str - places_left: str | int +class User(BaseModel): + login: str = Field() + password: str = Field(repr=False) diff --git a/resa_padel/payload_builders.py b/resa_padel/payload_builders.py deleted file mode 100644 index 5c5c338..0000000 --- a/resa_padel/payload_builders.py +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 0689fda..0000000 --- a/resa_padel/resources/clubs.yaml +++ /dev/null @@ -1,160 +0,0 @@ -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 deleted file mode 100644 index 2083999..0000000 --- a/resa_padel/resources/gestion-sports/booking-cancellation-payload.txt +++ /dev/null @@ -1 +0,0 @@ -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 c416a11..b5a7cc5 100644 --- a/resa_padel/resources/gestion-sports/booking-payload.txt +++ b/resa_padel/resources/gestion-sports/booking-payload.txt @@ -1 +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 +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 diff --git a/resa_padel/resources/gestion-sports/login-payload.txt b/resa_padel/resources/gestion-sports/login-payload.txt index 7692aed..b0da065 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.booking_platform.club_id }}&email={{ user.login }}&form_ajax=1&pass={{ user.password }}&compte=user&playeridonesignal=0&identifiant=identifiant&externCo=true +ajax=connexionUser&id_club={{ 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/post-headers.json b/resa_padel/resources/gestion-sports/post-headers.json index 0071be4..8adccc1 100644 --- a/resa_padel/resources/gestion-sports/post-headers.json +++ b/resa_padel/resources/gestion-sports/post-headers.json @@ -1,16 +1,12 @@ { - "Accept": "application/json, text/javascript, */*; q=0.01", + "Connection": "keep-alive", "Accept-Language": "en-US,en;q=0.5", "Accept-Encoding": "gzip, deflate, br", - "Cache-Control": "no-cache", - "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", - "Connection": "keep-alive", "DNT": "1", - "Origin": "https://toulousepadelclub.gestion-sports.com", - "Pragma": "no-cache", + "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", - "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:123.0) Gecko/20100101 Firefox/123.0", - "X-Requested-With": "XMLHttpRequest" + "Sec-Fetch-Site": "same-origin" } diff --git a/resa_padel/resources/gestion-sports/tournament-sessions-payload.txt b/resa_padel/resources/gestion-sports/tournament-sessions-payload.txt deleted file mode 100644 index 179ea03..0000000 --- a/resa_padel/resources/gestion-sports/tournament-sessions-payload.txt +++ /dev/null @@ -1 +0,0 @@ -ajax=loadSessionForSpecDay&date=all diff --git a/resa_padel/resources/gestion-sports/user-bookings-payload.txt b/resa_padel/resources/gestion-sports/users_bookings.txt similarity index 100% rename from resa_padel/resources/gestion-sports/user-bookings-payload.txt rename to resa_padel/resources/gestion-sports/users_bookings.txt diff --git a/resa_padel/resources/platforms.yaml b/resa_padel/resources/platforms.yaml deleted file mode 100644 index 3a5a37e..0000000 --- a/resa_padel/resources/platforms.yaml +++ /dev/null @@ -1,24 +0,0 @@ -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 - - 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/resa_padel/resources/users.yaml b/resa_padel/resources/users.yaml deleted file mode 100644 index 1d53d16..0000000 --- a/resa_padel/resources/users.yaml +++ /dev/null @@ -1,13 +0,0 @@ -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/configuration/clubs.yaml b/tests/data/configuration/clubs.yaml deleted file mode 100644 index 649f291..0000000 --- a/tests/data/configuration/clubs.yaml +++ /dev/null @@ -1,92 +0,0 @@ -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 deleted file mode 100644 index 2083999..0000000 --- a/tests/data/configuration/gestion-sports/booking-cancellation-payload.txt +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index c416a11..0000000 --- a/tests/data/configuration/gestion-sports/booking-payload.txt +++ /dev/null @@ -1 +0,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/tests/data/configuration/gestion-sports/login-payload.txt b/tests/data/configuration/gestion-sports/login-payload.txt deleted file mode 100644 index 7692aed..0000000 --- a/tests/data/configuration/gestion-sports/login-payload.txt +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 8adccc1..0000000 --- a/tests/data/configuration/gestion-sports/post-headers.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "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 deleted file mode 100644 index bc971e6..0000000 --- a/tests/data/configuration/gestion-sports/user-bookings-payload.txt +++ /dev/null @@ -1 +0,0 @@ -ajax=loadResa&hash={{ hash }} diff --git a/tests/data/configuration/platforms.yaml b/tests/data/configuration/platforms.yaml deleted file mode 100644 index 4d82911..0000000 --- a/tests/data/configuration/platforms.yaml +++ /dev/null @@ -1,24 +0,0 @@ -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: 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/data/configuration/users.yaml b/tests/data/configuration/users.yaml deleted file mode 100644 index 1d53d16..0000000 --- a/tests/data/configuration/users.yaml +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index 406f970..0000000 --- a/tests/data/responses/booking-failure.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "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 deleted file mode 100644 index 267ebee..0000000 --- a/tests/data/responses/booking-success.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "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 deleted file mode 100644 index 401ac46..0000000 --- a/tests/data/responses/cancellation-response.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "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 deleted file mode 100644 index 71dab94..0000000 --- a/tests/data/responses/landing-response.html +++ /dev/null @@ -1,2033 +0,0 @@ - - - - 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 deleted file mode 100644 index 731ed87..0000000 --- a/tests/data/responses/login-failure.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "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 deleted file mode 100644 index 3a07b66..0000000 --- a/tests/data/responses/login-success.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "status": "ok", - "msg": "", - "data": { - "needChoice": false, - "redirectUrl": "\/membre", - "id_club": 88 - } -} diff --git a/tests/data/responses/tournament-sessions.json b/tests/data/responses/tournament-sessions.json deleted file mode 100644 index c1137c4..0000000 --- a/tests/data/responses/tournament-sessions.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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 deleted file mode 100644 index 5f4a137..0000000 --- a/tests/data/responses/tournaments.html +++ /dev/null @@ -1,2459 +0,0 @@ - - - - - Evenements - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
- -
-
- - - Retour - - - - - -
- -
- Evenements
-
- - - 37 - -
-
- - - - -
-
- - - - - - - - -
-
- -

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. -
-
-
-
-
- - - - - - - - - - -
- - - - - - - - - - - - -
- -
- - Accueil -
-
- - -
- - Actualités -
-
- - -
-
- - Réserver -
-
- -
- - -
- - Compte -
-
- - -
- - Menu -
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/tests/data/responses/user-bookings-get.html b/tests/data/responses/user-bookings-get.html deleted file mode 100644 index fc473f0..0000000 --- a/tests/data/responses/user-bookings-get.html +++ /dev/null @@ -1,1363 +0,0 @@ - - - - - - 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 deleted file mode 100644 index e6c5218..0000000 --- a/tests/data/responses/user-bookings-post.json +++ /dev/null @@ -1,52 +0,0 @@ -[ - { - "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/data/user_bookings_html_response.html b/tests/data/user_bookings_html_response.html deleted file mode 100644 index 3af1e3b..0000000 --- a/tests/data/user_bookings_html_response.html +++ /dev/null @@ -1,1363 +0,0 @@ - - - - - - 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
-
-
-
-
-
- - -
- - - - - - - -
- - -
- - - - - - - -
- - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
- 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 c7d514f..bef8442 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -3,16 +3,19 @@ from pathlib import Path import pendulum import pytest +from gestion_sports.payload_builders import GestionSportsBookingPayloadBuilder -from resa_padel.models import BookingFilter, User +from resa_padel.models import BookingFilter, Club, 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_name = "padel" +sport_id = 217 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_name=sport_name, date=booking_date) +booking_filter = BookingFilter(sport_id=sport_id, date=booking_date) booking_failure_response = json.dumps( { @@ -33,6 +36,12 @@ 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") @@ -48,6 +57,11 @@ 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 @@ -58,6 +72,11 @@ 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/unit_tests/__init__.py b/tests/gestion_sports/__init__.py similarity index 100% rename from tests/unit_tests/__init__.py rename to tests/gestion_sports/__init__.py diff --git a/tests/gestion_sports/test_gestion_sports_connector.py b/tests/gestion_sports/test_gestion_sports_connector.py new file mode 100644 index 0000000..c279263 --- /dev/null +++ b/tests/gestion_sports/test_gestion_sports_connector.py @@ -0,0 +1,185 @@ +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 new file mode 100644 index 0000000..314e3e3 --- /dev/null +++ b/tests/gestion_sports/test_gestion_sports_html_parser.py @@ -0,0 +1,8 @@ +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 new file mode 100644 index 0000000..4b7d23d --- /dev/null +++ b/tests/gestion_sports/test_gestion_sports_payload_builder.py @@ -0,0 +1,61 @@ +from resa_padel.gestion_sports.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 new file mode 100644 index 0000000..7332bc5 --- /dev/null +++ b/tests/gestion_sports/test_gestion_sports_platform.py @@ -0,0 +1,87 @@ +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/integration_tests/conftest.py b/tests/integration_tests/conftest.py deleted file mode 100644 index 666fd90..0000000 --- a/tests/integration_tests/conftest.py +++ /dev/null @@ -1,52 +0,0 @@ -import json -from pathlib import Path - -import config -import pendulum -import pytest -from gestion_sport_connector 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: - 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_booking.py b/tests/integration_tests/test_booking.py deleted file mode 100644 index 2e093cd..0000000 --- a/tests/integration_tests/test_booking.py +++ /dev/null @@ -1,74 +0,0 @@ -import asyncio -import os -from unittest.mock import patch - -from resa_padel import booking - - -@patch.dict( - os.environ, - {"CLUB_ID": "tpc"}, - clear=True, -) -def test_booking(club, user, booking_filter): - 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 - - -@patch.dict( - os.environ, - {"CLUB_ID": "tpc"}, - clear=True, -) -def test_cancellation(club, user, booking_filter): - asyncio.run(booking.cancel_booking(club, user, booking_filter)) - - -@patch.dict( - os.environ, - { - "CLUB_ID": "tpc", - "ACTION": "book", - "SPORT_NAME": "Padel", - "DATE_TIME": "2024-03-28T13:30:00+01:00", - }, - clear=True, -) -def test_main_booking(): - court, player = booking.main() - assert court is not None - assert player.login == "padel.testing@jouf.fr" - - -@patch.dict( - os.environ, - { - "CLUB_ID": "tpc", - "ACTION": "cancel", - "SPORT_NAME": "Padel", - "DATE_TIME": "2024-03-28T13:30:00+01:00", - "LOGIN": "padel.testing@jouf.fr", - "PASSWORD": "ridicule", - }, - clear=True, -) -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 deleted file mode 100644 index 5078e6a..0000000 --- a/tests/integration_tests/test_gestion_sport_connector.py +++ /dev/null @@ -1,238 +0,0 @@ -import json -import os -from pathlib import Path -from unittest.mock import patch - -import pendulum -import pytest -from aiohttp import ClientSession -from gestion_sport_connector import GestionSportsConnector -from yarl import URL - - -@patch.dict( - os.environ, - {"CLUB_ID": "tpc"}, - clear=True, -) -def test_urls(connector): - 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.cancel_url - == "https://toulousepadelclub.gestion-sports.com/membre/mesresas.html" - ) - assert ( - connector.sessions_url - == "https://toulousepadelclub.gestion-sports.com/membre/index.php" - ) - - -@patch.dict( - os.environ, - {"RESOURCES_FOLDER": "/some/path"}, - clear=True, -) -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" - assert ( - connector.user_bookings_template - == resources_folder / "user-bookings-payload.txt" - ) - assert ( - connector.cancel_template - == resources_folder / "booking-cancellation-payload.txt" - ) - assert ( - connector.sessions_template - == resources_folder / "tournament-sessions-payload.txt" - ) - - -@pytest.mark.asyncio -async def test_landing_page(connector): - async with 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 - - -@pytest.mark.asyncio -async def test_login(connector, user): - async with 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" - - -def test_get_booked_court( - connector, booking_success_response, booking_failure_response -): - bookings = [ - (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 - - -@pytest.mark.asyncio -async def test_book_one_court(connector, user, booking_filter): - async with ClientSession() as session: - await connector.land(session) - await connector.login(session, user) - - court_id, response = await connector.send_booking_request( - session, pendulum.parse("2024-03-21T13:30:00+01:00"), 610, 217 - ) - - assert court_id == 610 - assert response.get("status") == "ok" - - -@pytest.mark.asyncio -async def test_book(connector, user, booking_filter): - booked_court = await connector.book(user, booking_filter) - - assert booked_court is not None - - -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 - assert opening_datetime.day == 14 - assert opening_datetime.hour == 0 - assert opening_datetime.minute == 0 - - -@pytest.mark.asyncio -async def test_get_hash(connector, user): - async with 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" - - -@pytest.mark.asyncio -async def test_get_bookings(connector, user): - async with 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) - - -@pytest.mark.asyncio -async def test_get_ongoing_bookings(connector, user): - async with ClientSession() as session: - await connector.land(session) - await connector.login(session, user) - - bookings = await connector.get_ongoing_bookings(session) - print(bookings) - - -@pytest.mark.asyncio -async def test_cancel_booking_id(connector, user): - async with ClientSession() as session: - await connector.land(session) - await connector.login(session, user) - - await connector.send_cancellation_request(session, 666) - - assert len(await connector.get_ongoing_bookings(session)) == 0 - - -@pytest.mark.asyncio -def test_find_court(connector): - court = connector.find_court(603, "Padel") - - assert court.number == 6 - - -@pytest.mark.asyncio -async def test_cancel_booking(connector, user, booking_filter): - 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_session_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 deleted file mode 100644 index 323e5e0..0000000 --- a/tests/integration_tests/test_gestion_sports_services.py +++ /dev/null @@ -1,26 +0,0 @@ -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) - - -@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/test_booking.py b/tests/test_booking.py new file mode 100644 index 0000000..b60ae4d --- /dev/null +++ b/tests/test_booking.py @@ -0,0 +1,77 @@ +import os +from unittest.mock import patch + +import pendulum +from aioresponses import aioresponses +from models import BookingFilter, Club +from pendulum import Time + +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, +) + +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("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, + }, + 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 + + :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 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..0c1e25b --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,74 @@ +import os +from unittest.mock import patch + +import config +from pendulum import DateTime, Time, 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", + "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.date == DateTime( + year=2024, + month=2, + day=3, + hour=23, + minute=38, + second=45, + tzinfo=Timezone("Europe/Paris"), + ) + + +@patch.dict( + os.environ, + { + "LOGIN": "login@user.tld", + "PASSWORD": "gloups", + }, + clear=True, +) +def test_get_available_user(): + user = config.get_user() + assert user.login == "login@user.tld" + 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" diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py deleted file mode 100644 index bde7bae..0000000 --- a/tests/unit_tests/conftest.py +++ /dev/null @@ -1,427 +0,0 @@ -import json -from pathlib import Path - -import pendulum -import pytest -from gestion_sport_connector import GestionSportsConnector -from gestion_sports_services import GestionSportsServices -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" - - -@pytest.fixture -def court11() -> Court: - return Court(id="1", name="Court 1", number=1, isIndoor=True) - - -@pytest.fixture -def court12() -> Court: - return Court(id="2", name="Court 2", number=2, isIndoor=False) - - -@pytest.fixture -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: Court, court12: Court, court13: Court, court14: Court) -> 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: Court, court22: Court, court23: Court, court24: Court) -> 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 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=7, time="00:00") - - -@pytest.fixture -def total_bookings() -> TotalBookings: - return TotalBookings(peakHours=3, offPeakHours="unlimited") - - -@pytest.fixture -def booking_platform( - 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", - 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, - "tournament-sessions": tournament_sessions_url, - "tournaments-list": tournaments_list_url, - }, - ) - - -@pytest.fixture -def club(booking_platform: BookingPlatform) -> 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) - - -@pytest.fixture -def gs_services() -> GestionSportsServices: - return GestionSportsServices() - - -@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: - 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" - return json.loads(login_success_file.read_text(encoding="utf-8")) - - -@pytest.fixture -def login_failure_response() -> dict: - file = RESPONSES_FOLDER / "login-failure.json" - return json.loads(file.read_text(encoding="utf-8")) - - -@pytest.fixture -def booking_success_response() -> dict: - file = RESPONSES_FOLDER / "booking-success.json" - return json.loads(file.read_text(encoding="utf-8")) - - -@pytest.fixture -def booking_failure_response() -> dict: - file = RESPONSES_FOLDER / "booking-failure.json" - return json.loads(file.read_text(encoding="utf-8")) - - -@pytest.fixture -def booked_courts_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 - 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: str, - login_success_response: str, - booking_success_response: str, - booking_failure_response: str, -): - 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: str, - login_success_response: str, - booking_success_response: str, - booking_failure_response: str, -): - 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: - file = RESPONSES_FOLDER / "user-bookings-get.html" - return file.read_text(encoding="utf-8") - - -@pytest.fixture -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: str, - login_success_response: str, - user_bookings_get_response: str, - user_bookings_list: str, -) -> 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: str, - login_success_response: str, - user_bookings_get_response: str, - user_bookings_empty_list: str, -) -> list: - return [ - landing_response, - login_success_response, - user_bookings_get_response, - user_bookings_empty_list, - ] - - -@pytest.fixture -def cancellation_response() -> list: - 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: str, - login_success_response: str, - user_bookings_get_response: str, - cancellation_response: str, -): - return [ - landing_response, - login_success_response, - user_bookings_get_response, - cancellation_response, - ] - - -@pytest.fixture -def cancellation_success_from_start( - landing_response: str, - login_success_response: str, - user_bookings_get_response: str, - user_bookings_list: str, - cancellation_response: str, -): - 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") - ) - - -@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/responses.py b/tests/unit_tests/responses.py deleted file mode 100644 index 57e8fee..0000000 --- a/tests/unit_tests/responses.py +++ /dev/null @@ -1,114 +0,0 @@ -from gestion_sport_connector 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_tournaments_sessions_response( - aioresponses, connector: GestionSportsConnector, tournaments_sessions_response -): - aioresponses.post( - connector.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_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]) - set_ongoing_bookings_response(aioresponses, connector, *responses[2:]) - - -def set_cancellation_response(aioresponses, connector, response): - aioresponses.post(connector.cancel_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 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]) diff --git a/tests/unit_tests/test_booking.py b/tests/unit_tests/test_booking.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/unit_tests/test_config.py b/tests/unit_tests/test_config.py deleted file mode 100644 index 64bbd3f..0000000 --- a/tests/unit_tests/test_config.py +++ /dev/null @@ -1,51 +0,0 @@ -import os -from unittest.mock import patch - -import config -from pendulum import DateTime, Timezone - - -@patch.dict( - os.environ, - { - "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_name == "padel" - assert booking_filter.date == DateTime( - year=2024, - month=2, - day=3, - hour=23, - minute=38, - second=45, - tzinfo=Timezone("Europe/Paris"), - ) - - -@patch.dict( - os.environ, - { - "LOGIN": "login@user.tld", - "PASSWORD": "gloups", - }, - clear=True, -) -def test_get_available_user(): - user = config.get_user() - assert user.login == "login@user.tld" - assert user.password == "gloups" - - -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/unit_tests/test_gestion_sports_connector.py b/tests/unit_tests/test_gestion_sports_connector.py deleted file mode 100644 index b1b9bad..0000000 --- a/tests/unit_tests/test_gestion_sports_connector.py +++ /dev/null @@ -1,135 +0,0 @@ -import json -from pathlib import Path -from unittest.mock import patch - -import pytest -from aiohttp import ClientSession -from gestion_sport_connector import GestionSportsConnector - -from tests.unit_tests import responses - - -def test_urls(connector, club): - base_url = "https://ptf1.com" - - 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.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") -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(club) - - 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.cancel_template - == path_to_resources / "gestion-sports/booking-cancellation-payload.txt" - ) - assert ( - connector.sessions_template - == path_to_resources / "gestion-sports/tournament-sessions-payload.txt" - ) - - -@pytest.mark.asyncio -async def test_landing_page(aioresponses, connector, landing_response): - responses.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): - responses.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): - responses.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_cancellation_request( - aioresponses, connector, user_bookings_get_response, 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, "hash") - - assert await response.json() == cancellation_response - - -@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_session_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 deleted file mode 100644 index 5ac584f..0000000 --- a/tests/unit_tests/test_gestion_sports_services.py +++ /dev/null @@ -1,166 +0,0 @@ -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 - - -@pytest.mark.asyncio -async def test_booking_success( - aioresponses, - gs_services, - connector, - club, - user, - booking_filter, - booking_success_from_start, -): - responses.set_full_booking_requests_responses( - aioresponses, connector, booking_success_from_start - ) - - court_booked = await gs_services.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 - - -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, - 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) - - -@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 diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..c5383bb --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,119 @@ +from urllib.parse import urljoin + +from models import BookingFilter, Club +from pendulum import DateTime + +from tests.fixtures import ( + a_booking_failure_response, + a_booking_filter, + a_booking_success_response, + a_club, + 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, + ) + + +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_hour = a_club.booking_opening_time.hour + booking_minute = a_club.booking_opening_time.minute + + date_to_book = a_booking_filter.date + return date_to_book.subtract(days=a_club.booking_open_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)