Merge pull request 'create gestion sport services' (#16) from refactor-data-model into main

Reviewed-on: https://jouf.fr/gitea/stanislas/resa-padel/pulls/16
This commit is contained in:
Stanislas Jouffroy 2024-03-20 22:13:33 +00:00
commit 42ce764654
57 changed files with 7316 additions and 1210 deletions

44
booking.md Normal file
View file

@ -0,0 +1,44 @@
MAIN:
- Book court C of sport S at club X at time T for users U1,U2
* X.has_ongoing_bookings(U1)
* X.book(C, S, T, U1)
- Cancel booking B at club X for user U1
* X.cancel(B, U1)
- Get tournaments of sport S at club X
* X.get_tournaments(S)
Club:
- Book court C of sport S at time T for users U1,U2
* new ClubUserSession
- Cancel booking B for user U1
- Has user U1 ongoing booking
ClubConnector:
- land
- login user U
- book court C of sport S at time T
- has ongoing bookings
- cancel booking B
- get tournaments
UserSession
+ user
+ session
+ connector
- book
* connector.land
* connector.login
* connector.land

View file

@ -2,62 +2,81 @@ import asyncio
import logging
import config
from gestion_sports.gestion_sports_platform import GestionSportsPlatform
from models import BookingFilter, Club, User
from gestion_sports_services import GestionSportsServices
from models import Action, BookingFilter, Club, Court, User
LOGGER = logging.getLogger(__name__)
async def book(club: Club, user: User, booking_filter: BookingFilter) -> int | None:
async def book_court(
club: Club, users: list[User], booking_filter: BookingFilter
) -> tuple[Court, User]:
"""
Book a court for a user to a club following a booking filter
Book any court that meets the condition from the filter. IThe user that will make
the booking is chosen among a list of users and should not have any ongoing bookings
:param club: the club where to book a court
:param user: the user information
:param booking_filter: the information related to the booking
:return: the id of the booked court, or None if no court was booked
:param club: the club in which the booking will be made
:param users: the list of users who have an account in the club
:param booking_filter: the conditions the court to book should meet
:return: a tuple containing the court that was booked and the user who made the
booking
"""
async with GestionSportsPlatform(club) as platform:
return await platform.book(user, booking_filter)
async def get_user_without_booking(club: Club, users: list[User]) -> User | None:
"""
Return the first user who has no booking
:param club: the club where to book
:param users: the list of users
:return: any user who has no booking
"""
async with GestionSportsPlatform(club) as platform:
service = GestionSportsServices()
for user in users:
if await platform.user_has_no_ongoing_booking(user):
return user
return None
if not await service.has_user_available_slots(user, club):
return await service.book(club, user, booking_filter), user
def main() -> int | None:
async def cancel_booking(club: Club, user: User, booking_filter: BookingFilter) -> None:
"""
Cancel the booking that matches the specified filter
: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
"""
service = GestionSportsServices()
await service.cancel_booking(user, club, booking_filter)
async def cancel_booking_id(club: Club, user: User, booking_id: int) -> None:
"""
Cancel a booking that matches the booking id
:param club: the club in which the booking was made
:param user: the user who made the booking
:param booking_id: the id of the booking to cancel
"""
service = GestionSportsServices()
await service.cancel_booking_id(user, club, booking_id)
def main() -> tuple[Court, User] | None:
"""
Main function used to book a court
:return: the id of the booked court, or None if no court was booked
"""
booking_filter = config.get_booking_filter()
club = config.get_club()
user = asyncio.run(get_user_without_booking(club, config.get_available_users()))
action = config.get_action()
LOGGER.info(
"Starting booking court of sport %s for user %s at club %s at %s",
booking_filter.sport_id,
user.login,
club.id,
booking_filter.date,
)
court_booked = asyncio.run(book(club, user, booking_filter))
if action == Action.BOOK:
club = config.get_club()
users = config.get_users(club.id)
booking_filter = config.get_booking_filter()
court_booked, user = asyncio.run(book_court(club, users, booking_filter))
if court_booked:
LOGGER.info(
"Court %s booked successfully at %s", court_booked, booking_filter.date
"Court %s booked successfully at %s for user %s",
court_booked,
booking_filter.date,
user,
)
return court_booked, user
else:
LOGGER.info("Booking did not work")
return court_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))

View file

@ -0,0 +1,42 @@
import logging
from aiohttp import ClientSession
from connectors import Connector
from models import BookingFilter, Club, User
LOGGER = logging.getLogger(__name__)
class BookingService:
def __init__(self, club: Club, connector: Connector):
LOGGER.info("Initializing booking service at for club", club.name)
self.club = club
self.connector = connector
self.session: ClientSession | None = None
async def __aenter__(self):
self.session = ClientSession()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.session.close()
async def book(self, user: User, booking_filter: BookingFilter) -> int | None:
"""
Book a court matching the booking filters for a user.
The steps to perform a booking are to go to the landing page, to log in, wait
and for the time when booking is open and then actually book the court
:param user: the user that wants to book a court
:param booking_filter: the booking criteria
:return: the court number if the booking is successful, None otherwise
"""
if self.connector is None:
LOGGER.error("No connection to Gestion Sports is available")
return None
if user is None or booking_filter is None:
LOGGER.error("Not enough information available to book a court")
return None
self.connector.book(user, booking_filter)

View file

@ -6,36 +6,11 @@ from pathlib import Path
import pendulum
import yaml
from dotenv import load_dotenv
from models import BookingFilter, Club, User
from models import Action, BookingFilter, Club, User
load_dotenv()
def get_club() -> Club:
"""
Read the environment variables related to the current club
and build the Club object
:return: the club
"""
club_url = os.environ.get("CLUB_URL")
court_ids_tmp = os.environ.get("COURT_IDS") or ""
court_ids = (
[int(court_id) for court_id in court_ids_tmp.split(",")]
if court_ids_tmp
else []
)
club_id = os.environ.get("CLUB_ID")
booking_open_days_before = int(os.environ.get("BOOKING_OPEN_DAYS_BEFORE", "7"))
booking_opening_time_str = os.environ.get("BOOKING_OPENING_TIME", "00:00")
booking_opening_time = pendulum.parse(booking_opening_time_str)
return Club(
id=club_id,
url=club_url,
courts_ids=court_ids,
booking_open_days_before=booking_open_days_before,
booking_opening_time=booking_opening_time.time(),
)
ROOT_DIR = Path(__file__).parent
def get_booking_filter() -> BookingFilter:
@ -45,11 +20,10 @@ def get_booking_filter() -> BookingFilter:
:return: the club
"""
sport_id_tmp = os.environ.get("SPORT_ID")
sport_id = int(sport_id_tmp) if sport_id_tmp else None
sport_name = os.environ.get("SPORT_NAME")
date_time_tmp = os.environ.get("DATE_TIME")
date_time = pendulum.parse(date_time_tmp) if date_time_tmp else None
return BookingFilter(sport_id=sport_id, date=date_time)
return BookingFilter(sport_name=sport_name.lower(), date=date_time)
def get_user() -> User:
@ -64,25 +38,6 @@ def get_user() -> User:
return User(login=login, password=password)
def get_available_users() -> list[User]:
"""
Read the environment variables to get all the available users in order
to increase the chance of having a user with a free slot for a booking
:return: the list of all users that can book a court
"""
available_users_credentials = os.environ.get("AVAILABLE_USERS_CREDENTIALS")
available_users = [
credential for credential in available_users_credentials.split(",")
]
users = []
for user in available_users:
login, password = user.split(":")
users.append(User(login=login, password=password))
return users
def get_post_headers(platform_id: str) -> dict:
"""
Get the headers for the POST endpoint related to a specific booking platform
@ -102,13 +57,90 @@ def init_log_config():
"""
Read the logging.yaml file to initialize the logging configuration
"""
root_dir = os.path.realpath(os.path.dirname(__file__))
logging_file = root_dir + "/logging.yaml"
logging_file = ROOT_DIR / "logging.yaml"
with open(logging_file, "r") as f:
with logging_file.open(mode="r", encoding="utf-8") as f:
logging_config = yaml.safe_load(f.read())
logging.config.dictConfig(logging_config)
ROOT_PATH = Path(__file__).parent.resolve()
RESOURCES_DIR = Path(ROOT_PATH, "resources")
def _build_urls(platform_urls: dict) -> dict:
return {url["name"]: url for url in platform_urls}
def _read_clubs(platforms_data: dict, clubs_data: dict) -> dict[str, Club]:
platforms = {platform["id"]: platform for platform in platforms_data}
for club in clubs_data["clubs"]:
club_platform = club["bookingPlatform"]
platform_id = club_platform["id"]
club_platform["urls"] = _build_urls(platforms[platform_id]["urls"])
return {club["id"]: Club(**club) for club in clubs_data["clubs"]}
def get_clubs():
platforms_file = ROOT_DIR / "resources" / "platforms.yaml"
with platforms_file.open(mode="r", encoding="utf-8") as fp:
platforms_data = yaml.safe_load(fp)
clubs_file = ROOT_DIR / "resources" / "clubs.yaml"
with clubs_file.open(mode="r", encoding="utf-8") as fp:
clubs_data = yaml.safe_load(fp)
return _read_clubs(platforms_data["platforms"], clubs_data)
def get_club() -> Club:
"""
Get the club from an environment variable
:return: the club
"""
club_id = os.environ.get("CLUB_ID")
clubs = get_clubs()
return clubs[club_id]
def read_users(data: dict, club_id: str) -> list[User]:
"""
Deserialize users
:param data: the dictionnary of users
:param club_id: the club id
:return: a list if users from the club
"""
for club in data.get("clubs"):
if club.get("id") == club_id:
return [User(**user) for user in club.get("users")]
def get_users(club_id: str) -> list[User]:
"""
Get a list of users from a club
:param club_id: the club to which the users should have an account
:return: the list of all users for that club
"""
users_file = ROOT_DIR / "resources" / "users.yaml"
with users_file.open(mode="r", encoding="utf-8") as fp:
data = yaml.safe_load(fp)
return read_users(data, club_id)
def get_resources_folder() -> Path:
"""
Compute the path to the resources used by the program
:return: the path to the resources folder
"""
default_resources_folder = Path(__file__).parent / "resources"
return Path(os.environ.get("RESOURCES_FOLDER", default_resources_folder))
def get_action() -> Action:
"""
Get the action to perform from an environment variable
:return: the action to perform
"""
return Action(os.environ.get("ACTION"))

423
resa_padel/connectors.py Normal file
View file

@ -0,0 +1,423 @@
import asyncio
import json
import logging
from pathlib import Path
from urllib.parse import urljoin
import config
from aiohttp import ClientResponse, ClientSession
from bs4 import BeautifulSoup
from models import Booking, BookingFilter, Club, Court, Sport, User
from 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.
It handles all the requests to the website.
"""
def __init__(self, club: Club):
if club is None:
raise ValueError("A connector cannot be instantiated without a club")
if club.booking_platform.id != "gestion-sports":
raise ValueError(
"Gestion Sports connector was instantiated with a club not handled"
" by gestions sports. Club id is {} instead of gestion-sports".format(
club.id
)
)
self.club = club
def _get_url_path(self, name: str) -> str:
"""
Get the URL path for the service with the given name
:param name: the name of the service
:return: the URL path
"""
self._check_url_path_exists(name)
return urljoin(
self.club.booking_platform.url,
self.club.booking_platform.urls.get(name).path,
)
def _get_payload_template(self, name: str) -> Path:
"""
Get the path to the template file for the service with the given name
:param name: the name of the service
:return: the path to the template file
"""
self._check_payload_template_exists(name)
return (
config.get_resources_folder()
/ self.club.booking_platform.urls.get(name).payload_template
)
def _check_url_path_exists(self, name: str) -> None:
"""
Check that the URL path for the given service is defined
:param name: the name of the service
"""
if (
self.club.booking_platform.urls is None
or self.club.booking_platform.urls.get(name) is None
or self.club.booking_platform.urls.get(name).path is None
):
raise ValueError(
f"The booking platform internal URL path for page {name} of club "
f"{self.club.name} are not set"
)
def _check_payload_template_exists(self, name: str) -> None:
"""
Check that the payload template for the given service is defined
:param name: the name of the service
"""
if (
self.club.booking_platform.urls is None
or self.club.booking_platform.urls.get(name) is None
or self.club.booking_platform.urls.get(name).path is None
):
raise ValueError(
f"The booking platform internal URL path for page {name} of club "
f"{self.club.name} are not set"
)
@property
def landing_url(self) -> str:
"""
Get the URL to the landing page of Gestion-Sports
:return: the URL to the landing page
"""
return self._get_url_path("landing-page")
@property
def login_url(self) -> str:
"""
Get the URL to the connection login of Gestion-Sports
:return: the URL to the login page
"""
return self._get_url_path("login")
@property
def login_template(self) -> Path:
"""
Get the payload template to send to log in the website
:return: the payload template for logging in
"""
return self._get_payload_template("login")
@property
def booking_url(self) -> str:
"""
Get the URL to the booking page of Gestion-Sports
:return: the URL to the booking page
"""
return self._get_url_path("booking")
@property
def booking_template(self) -> Path:
"""
Get the payload template to send to book a court
:return: the payload template for booking a court
"""
return self._get_payload_template("booking")
@property
def user_bookings_url(self) -> str:
"""
Get the URL where all the user's bookings are available
:return: the URL to the user's bookings
"""
return self._get_url_path("user-bookings")
@property
def user_bookings_template(self) -> Path:
"""
Get the payload template to send to get all the user's bookings that are
available
:return: the payload template for the user's bookings
"""
return self._get_payload_template("user-bookings")
@property
def booking_cancellation_url(self) -> str:
"""
Get the URL where all the user's bookings are available
:return: the URL to the user's bookings
"""
return self._get_url_path("cancellation")
@property
def booking_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 available_sports(self) -> dict[str, Sport]:
"""
Get a dictionary of all sports, the key is the sport name lowered case
:return: the dictionary of all sports
"""
return {
sport.name.lower(): sport for sport in self.club.booking_platform.sports
}
async def land(self, session: ClientSession) -> ClientResponse:
"""
Perform the request to the landing page in order to get the cookie PHPSESSIONID
:return: the response from the landing page
"""
LOGGER.info("Connecting to GestionSports API at %s", self.login_url)
async with session.get(self.landing_url) as response:
await response.text()
return response
async def login(self, session: ClientSession, user: User) -> ClientResponse:
"""
Perform the request to the log in the user
:return: the response from the login
"""
LOGGER.info("Logging in to GestionSports API at %s", self.login_url)
payload = PayloadBuilder.build(self.login_template, user=user, club=self.club)
async with session.post(
self.login_url, data=payload, headers=POST_HEADERS
) as response:
resp_text = await response.text()
LOGGER.debug("Connexion request response:\n%s", resp_text)
return response
async def book_any_court(
self, session: ClientSession, booking_filter: BookingFilter
) -> list[tuple[int, dict]]:
"""
Perform a request for each court at the same time to increase the chances to get
a booking.
The gestion-sports backend does not allow several bookings at the same time
so there is no need to make each request one after the other
:param session: the session to use
:param booking_filter: the booking conditions to meet
:return: the booked court, or None if no court was booked
"""
LOGGER.info(
"Booking any available court from GestionSports API at %s", self.booking_url
)
sport = self.available_sports.get(booking_filter.sport_name)
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 HTTP session that contains the user information and cookies
:param date: the booking date
:param court_id: the id of the court to book
:param sport_id: the id of the sport
:return: a tuple containing the court id and the 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:
assert response.status == 200
resp_json = json.loads(await response.text())
LOGGER.debug("Response from booking request:\n'%s'", resp_json)
return court_id, resp_json
def get_booked_court(
self, bookings: list[tuple[int, dict]], sport_name: str
) -> Court | None:
"""
Parse the booking list and return the court that was booked
:param bookings: a list of bookings
:param sport_name: the sport name
:return: the id of the booked court if any, None otherwise
"""
for court_id, response in bookings:
if self.is_booking_response_status_ok(response):
LOGGER.debug("Court %d is booked", court_id)
court_booked = self.find_court(court_id, sport_name)
LOGGER.info("Court '%s' is booked", court_booked.name)
return court_booked
LOGGER.debug("No booked court found")
return None
def find_court(self, court_id: int, sport_name: str) -> Court:
"""
Get all the court information based on the court id and the sport name
:param court_id: the court id
:param sport_name: the sport name
:return: the court that has the given id and sport name
"""
sport = self.available_sports.get(sport_name.lower())
for court in sport.courts:
if court.id == court_id:
return court
@staticmethod
def is_booking_response_status_ok(response: dict) -> bool:
"""
Check if the booking response is OK
:param response: the response as a string
:return: true if the status is ok, false otherwise
"""
return response["status"] == "ok"
async def get_ongoing_bookings(self, session: ClientSession) -> list[Booking]:
"""
Get the list of all ongoing bookings of a user.
The steps to perform this are to get the user's bookings page and get a hidden
property in the HTML to get a hash that will be used in the payload of the
POST request (sic) to get the user's bookings.
Gestion sports is really a mess!!
:return: the list of all ongoing bookings of a user
"""
hash_value = await self.send_hash_request(session)
LOGGER.debug(f"Hash value: {hash_value}")
payload = PayloadBuilder.build(self.user_bookings_template, hash=hash_value)
LOGGER.debug(f"Payload to get ongoing bookings: {payload}")
return await self.send_user_bookings_request(session, payload)
async def send_hash_request(self, session: ClientSession) -> str:
"""
Get the hash value used in the request to get the user's bookings
:param session: the session in which the user logged in
:return: the value of the hash
"""
async with session.get(self.user_bookings_url) as response:
html = await response.text()
LOGGER.debug("Get bookings response: %s\n", html)
return self.get_hash_input(html)
@staticmethod
def get_hash_input(html_doc: str) -> str:
"""
There is a secret hash generated by Gestion sports that is reused when trying to get
users bookings. This hash is stored in a hidden input with name "mesresas-hash"
:param html_doc: the html document when getting the page mes-resas.html
:return: the value of the hash in the page
"""
soup = BeautifulSoup(html_doc, "html.parser")
inputs = soup.find_all("input")
for input_tag in inputs:
if input_tag.get("name") == "mesresas-hash":
return input_tag.get("value").strip()
async def send_user_bookings_request(
self, session: ClientSession, payload: str
) -> list[Booking]:
"""
Perform the HTTP request to get all bookings
:param session: the session in which the user logged in
:param payload: the HTTP payload for the request
:return: a dictionary containing all the bookings
"""
async with session.post(
self.user_bookings_url, data=payload, headers=POST_HEADERS
) as response:
resp = await response.text()
LOGGER.debug("ongoing bookings response: %s\n", resp)
return [Booking(**booking) for booking in json.loads(resp)]
async def cancel_booking_id(
self, session: ClientSession, booking_id: int
) -> ClientResponse:
"""
Send the HTTP request to cancel the booking
:param session: the HTTP session that contains the user information and cookies
:param booking_id: the id of the booking to cancel
:return: the response from the client
"""
hash_value = await self.send_hash_request(session)
payload = PayloadBuilder.build(
self.booking_cancel_template,
booking_id=booking_id,
hash=hash_value,
)
async with session.post(
self.booking_cancellation_url, data=payload, headers=POST_HEADERS
) as response:
await response.text()
return response
async def cancel_booking(
self, session: ClientSession, booking_filter: BookingFilter
) -> ClientResponse | None:
"""
Cancel the booking that meets some conditions
:param session: the session
:param booking_filter: the conditions the booking to cancel should meet
"""
bookings = await self.get_ongoing_bookings(session)
for booking in bookings:
if booking.matches(booking_filter):
return await self.cancel_booking_id(session, booking.id)

View file

@ -1,9 +0,0 @@
from pathlib import Path
import config
RESOURCES_DIR = Path(config.RESOURCES_DIR, "gestion-sports")
BOOKING_TEMPLATE = Path(RESOURCES_DIR, "booking-payload.txt")
LOGIN_TEMPLATE = Path(RESOURCES_DIR, "login-payload.txt")
USERS_BOOKINGS_TEMPLATE = Path(RESOURCES_DIR, "users_bookings.txt")

View file

@ -1,199 +0,0 @@
import asyncio
import json
import logging
from urllib.parse import urljoin
import config
from aiohttp import ClientResponse, ClientSession
from gestion_sports import gestion_sports_html_parser as html_parser
from gestion_sports.payload_builders import (
GestionSportsBookingPayloadBuilder,
GestionSportsLoginPayloadBuilder,
GestionSportsUsersBookingsPayloadBuilder,
)
from models import BookingFilter, Club, User
LOGGER = logging.getLogger(__name__)
POST_HEADERS = config.get_post_headers("gestion-sports")
class GestionSportsConnector:
"""
Handle the specific booking requests to Gestion-Sports
"""
def __init__(self, session: ClientSession, url: str):
LOGGER.info("Initializing connection to GestionSports API")
self.url = url
self.session = session
@property
def landing_url(self) -> str:
"""
Get the URL to the landing page of Gestion-Sports
:return: the URL to the landing page
"""
return urljoin(self.url, "/connexion.php")
@property
def login_url(self) -> str:
"""
Get the URL to the connection login of Gestion-Sports
:return: the URL to the login page
"""
return urljoin(self.url, "/connexion.php")
@property
def booking_url(self) -> str:
"""
Get the URL to the booking page of Gestion-Sports
:return: the URL to the booking page
"""
return urljoin(self.url, "/membre/reservation.html")
@property
def user_bookings_url(self) -> str:
"""
Get the URL where all the user's bookings are available
:return: the URL to the user's bookings
"""
return urljoin(self.url, "/membre/mesresas.html")
async def land(self) -> ClientResponse:
"""
Perform the request to the landing page in order to get the cookie PHPSESSIONID
:return: the response from the landing page
"""
LOGGER.info("Connecting to GestionSports API at %s", self.login_url)
async with self.session.get(self.landing_url) as response:
await response.text()
return response
async def login(self, user: User, club: Club) -> ClientResponse:
"""
Perform the request to the log in the user
:return: the response from the login
"""
LOGGER.info("Logging in to GestionSports API at %s", self.login_url)
payload_builder = GestionSportsLoginPayloadBuilder()
payload = payload_builder.user(user).club(club).build()
async with self.session.post(
self.login_url, data=payload, headers=POST_HEADERS
) as response:
resp_text = await response.text()
LOGGER.debug("Connexion request response:\n%s", resp_text)
return response
async def book(self, booking_filter: BookingFilter, club: Club) -> int | None:
"""
Perform a request for each court at the same time to increase the chances to get
a booking.
The gestion-sports backend does not allow several bookings at the same time
so there is no need to make each request one after the other
:param booking_filter: the booking information
:param club: the club where to book the court
:return: the booked court, or None if no court was booked
"""
LOGGER.info(
"Booking any available court from GestionSports API at %s", self.booking_url
)
# use asyncio to request a booking on every court
# the gestion-sports backend is able to book only one court for a user
bookings = await asyncio.gather(
*[
self.book_one_court(booking_filter, court_id)
for court_id in club.courts_ids
],
return_exceptions=True,
)
LOGGER.debug("Booking results:\n'%s'", bookings)
return self.get_booked_court(bookings)
async def book_one_court(
self, booking_filter: BookingFilter, court_id: int
) -> tuple[int, bool]:
"""
Book a single court according to the information provided in the booking filter
:param booking_filter: the booking information
:param court_id: the id of the court to book
:return: a tuple containing the court id and the booking status
"""
LOGGER.debug(
"Booking court %s at %s",
court_id,
booking_filter.date.to_w3c_string(),
)
payload_builder = GestionSportsBookingPayloadBuilder()
payload = (
payload_builder.booking_filter(booking_filter).court_id(court_id).build()
)
LOGGER.debug("Booking court %s request:\n'%s'", court_id, payload)
async with self.session.post(
self.booking_url, data=payload, headers=POST_HEADERS
) as response:
resp_json = await response.text()
LOGGER.debug("Response from booking request:\n'%s'", resp_json)
return court_id, self.is_booking_response_status_ok(resp_json)
@staticmethod
def get_booked_court(bookings: list[tuple[int, bool]]) -> int | None:
"""
Parse the booking list and return the court that was booked
:param bookings: a list of bookings
:return: the id of the booked court if any, None otherwise
"""
for court, is_booked in bookings:
if is_booked:
LOGGER.debug("Court %s is booked", court)
return court
LOGGER.debug("No booked court found")
return None
@staticmethod
def is_booking_response_status_ok(response: str) -> bool:
"""
Check if the booking response is OK
:param response: the response as a string
:return: true if the status is ok, false otherwise
"""
formatted_result = response.removeprefix('"').removesuffix('"')
result_json = json.loads(formatted_result)
return result_json["status"] == "ok"
async def get_ongoing_bookings(self) -> dict:
"""
Get the list of all ongoing bookings of a user.
The steps to perform this are to get the user's bookings page and get a hidden
property in the HTML to get a hash that will be used in the payload of the
POST request (sic) to get the user's bookings.
Gestion sports is really a mess!!
:return: the list of all ongoing bookings of a user
"""
async with self.session.get(self.user_bookings_url) as get_resp:
html = await get_resp.text()
hash_value = html_parser.get_hash_input(html)
payload_builder = GestionSportsUsersBookingsPayloadBuilder()
payload_builder.hash(hash_value)
payload = payload_builder.build()
async with self.session.post(
self.user_bookings_url, data=payload, headers=POST_HEADERS
) as response:
resp = await response.text()
LOGGER.debug("ongoing bookings response: %s\n", resp)
return json.loads(resp)

View file

@ -1,16 +0,0 @@
from bs4 import BeautifulSoup
def get_hash_input(html_doc: str) -> str:
"""
There is a secret hash generated by Gestion sports that is reused when trying to get
users bookings. This hash is stored in a hidden input with name "mesresas-hash"
:param html_doc: the html document when getting the page mes-resas.html
:return: the value of the hash in the page
"""
soup = BeautifulSoup(html_doc, "html.parser")
inputs = soup.find_all("input")
for input_tag in inputs:
if input_tag.get("name") == "mesresas-hash":
return input_tag.get("value").strip()

View file

@ -1,116 +0,0 @@
import logging
import time
import pendulum
from aiohttp import ClientSession
from gestion_sports.gestion_sports_connector import GestionSportsConnector
from models import BookingFilter, Club, User
from pendulum import DateTime
LOGGER = logging.getLogger(__name__)
class GestionSportsPlatform:
def __init__(self, club: Club):
LOGGER.info("Initializing Gestion Sports platform at url %s", club.url)
self.connector: GestionSportsConnector | None = None
self.club: Club = club
self.session: ClientSession | None = None
async def __aenter__(self):
self.session = ClientSession()
self.connector = GestionSportsConnector(self.session, self.club.url)
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.session.close()
async def book(self, user: User, booking_filter: BookingFilter) -> int | None:
"""
Book a court matching the booking filters for a user.
The steps to perform a booking are to go to the landing page, to log in, wait
and for the time when booking is open and then actually book the court
:param user: the user that wants to book a court
:param booking_filter: the booking criteria
:return: the court number if the booking is successful, None otherwise
"""
if self.connector is None:
LOGGER.error("No connection to Gestion Sports is available")
return None
if user is None or booking_filter is None:
LOGGER.error("Not enough information available to book a court")
return None
await self.connector.land()
await self.connector.login(user, self.club)
wait_until_booking_time(self.club, booking_filter)
return await self.connector.book(booking_filter, self.club)
async def user_has_no_ongoing_booking(self, user: User) -> bool:
"""
Check if the user has any ongoing booking.
The steps to perform this task are to go to the landing page, to log in and
then retrieve user information and extract the ongoing bookings
:param user: the user to check the bookings
:return: True if the user has ongoing bookings, false otherwise
"""
if self.connector is None:
LOGGER.error("No connection to Gestion Sports is available")
return False
if user is None:
LOGGER.error("Not enough information available to book a court")
return False
await self.connector.land()
await self.connector.login(user, self.club)
bookings = await self.connector.get_ongoing_bookings()
return bookings == []
def wait_until_booking_time(club: Club, booking_filter: BookingFilter):
"""
Wait until the booking is open.
The booking filter contains the date and time of the booking.
The club has the information about when the booking is open for that date.
:param club: the club where to book a court
:param booking_filter: the booking information
"""
LOGGER.info("Waiting for booking time")
booking_datetime = build_booking_datetime(booking_filter, club)
now = pendulum.now()
duration_until_booking = booking_datetime - now
LOGGER.debug(
"Time to wait before booking: %s:%s:%s",
"{:0>2}".format(duration_until_booking.hours),
"{:0>2}".format(duration_until_booking.minutes),
"{:0>2}".format(duration_until_booking.seconds),
)
while now < booking_datetime:
time.sleep(1)
now = pendulum.now()
LOGGER.info("It's booking time!")
def build_booking_datetime(booking_filter: BookingFilter, club: Club) -> DateTime:
"""
Build the date and time when the booking is open for a given match date.
The booking filter contains the date and time of the booking.
The club has the information about when the booking is open for that date.
:param booking_filter: the booking information
:param club: the club where to book a court
:return: the date and time when the booking is open
"""
date_to_book = booking_filter.date
booking_date = date_to_book.subtract(days=club.booking_open_days_before)
booking_hour = club.booking_opening_time.hour
booking_minute = club.booking_opening_time.minute
return booking_date.at(booking_hour, booking_minute)

View file

@ -1,128 +0,0 @@
from exceptions import ArgumentMissing
from gestion_sports.gestion_sports_config import (
BOOKING_TEMPLATE,
LOGIN_TEMPLATE,
USERS_BOOKINGS_TEMPLATE,
)
from jinja2 import Environment, FileSystemLoader
from models import BookingFilter, Club, User
class GestionSportsLoginPayloadBuilder:
"""
Build the payload for the login page
"""
def __init__(self):
self._user: User | None = None
self._club: Club | None = None
def user(self, user: User):
"""
Set the user
:param user: the user
:return: the class itself
"""
self._user = user
return self
def club(self, club: Club):
"""
Set the club
:param club: the club
:return: the class itself
"""
self._club = club
return self
def build(self) -> str:
"""
Build the payload
:return: the string representation of the payload
"""
if self._user is None:
raise ArgumentMissing("No user was provided")
if self._club is None:
raise ArgumentMissing("No club was provided")
environment = Environment(loader=FileSystemLoader(LOGIN_TEMPLATE.parent))
template = environment.get_template(LOGIN_TEMPLATE.name)
return template.render(club=self._club, user=self._user)
class GestionSportsBookingPayloadBuilder:
def __init__(self):
self._booking_filter: BookingFilter | None = None
self._court_id: int | None = None
def booking_filter(self, booking_filter: BookingFilter):
"""
Set the booking filter
:param booking_filter: the booking filter
:return: the class itself
"""
self._booking_filter = booking_filter
return self
def court_id(self, court_id: int):
"""
Set the court id
:param court_id: the court id
:return: the class itself
"""
self._court_id = court_id
return self
def build(self) -> str:
"""
Build the payload
:return: the string representation of the payload
"""
if self._booking_filter is None:
raise ArgumentMissing("No booking filter was provided")
if self.court_id is None:
raise ArgumentMissing("No court id was provided")
environment = Environment(loader=FileSystemLoader(BOOKING_TEMPLATE.parent))
template = environment.get_template(BOOKING_TEMPLATE.name)
return template.render(
court_id=self._court_id, booking_filter=self._booking_filter
)
class GestionSportsUsersBookingsPayloadBuilder:
def __init__(self):
self._hash: str | None = None
def hash(self, hash_value: str):
"""
Set the hash
:param hash_value: the hash
:return: the class itself
"""
self._hash = hash_value
def build(self) -> str:
"""
Build the payload
:return: the string representation of the payload
"""
if self._hash is None:
raise ArgumentMissing("No hash was provided")
environment = Environment(
loader=FileSystemLoader(USERS_BOOKINGS_TEMPLATE.parent)
)
template = environment.get_template(USERS_BOOKINGS_TEMPLATE.name)
return template.render(hash=self._hash)

View file

@ -0,0 +1,127 @@
import logging
import time
import pendulum
from aiohttp import ClientSession
from connectors import GestionSportsConnector
from models import BookingFilter, BookingOpening, Club, Court, User
from pendulum import DateTime
LOGGER = logging.getLogger(__name__)
class GestionSportsServices:
@staticmethod
async def book(
club: Club, user: User, booking_filter: BookingFilter
) -> Court | None:
"""
Perform a request for each court at the same time to increase the chances to get
a booking.
The gestion-sports backend does not allow several bookings at the same time
so there is no need to make each request one after the other
:param club: the club in which the booking will be made
:param user: the user that wants to book the court
:param booking_filter: the booking conditions to meet
:return: the booked court, or None if no court was booked
"""
connector = GestionSportsConnector(club)
LOGGER.info(
"Booking any available court from GestionSports API at %s",
connector.booking_url,
)
async with ClientSession() as session:
# use asyncio to request a booking on every court
# the gestion-sports backend is able to book only one court for a user
await connector.land(session)
await connector.login(session, user)
booking_opening = club.booking_platform.booking_opening
GestionSportsServices.wait_until_booking_time(
booking_filter, booking_opening
)
bookings = await connector.book_any_court(session, booking_filter)
LOGGER.debug("Booking results:\n'%s'", bookings)
return connector.get_booked_court(bookings, booking_filter.sport_name)
@staticmethod
async def has_user_available_slots(user: User, club: Club) -> bool:
connector = GestionSportsConnector(club)
async with ClientSession() as session:
await connector.land(session)
await connector.login(session, user)
bookings = await connector.get_ongoing_bookings(session)
return bool(bookings)
@staticmethod
async def cancel_booking(user: User, club: Club, booking_filter: BookingFilter):
connector = GestionSportsConnector(club)
async with ClientSession() as session:
await connector.land(session)
await connector.login(session, user)
await connector.cancel_booking(session, booking_filter)
@staticmethod
async def cancel_booking_id(user: User, club: Club, booking_id: int):
connector = GestionSportsConnector(club)
async with ClientSession() as session:
await connector.land(session)
await connector.login(session, user)
await connector.cancel_booking_id(session, booking_id)
@staticmethod
def wait_until_booking_time(
booking_filter: BookingFilter, booking_opening: BookingOpening
) -> None:
"""
Wait until the booking is open.
The booking filter contains the date and time of the booking.
The club has the information about when the booking is open for that date.
:param booking_opening:
:param booking_filter: the booking information
"""
LOGGER.info("Waiting for booking time")
booking_datetime = GestionSportsServices.build_booking_datetime(
booking_filter, booking_opening
)
now = pendulum.now()
duration_until_booking = booking_datetime - now
LOGGER.debug(f"Current time: {now}, Datetime to book: {booking_datetime}")
LOGGER.debug(
f"Time to wait before booking: {duration_until_booking.hours:0>2}"
f":{duration_until_booking.minutes:0>2}"
f":{duration_until_booking.seconds:0>2}"
)
while now < booking_datetime:
time.sleep(1)
now = pendulum.now()
LOGGER.info("It's booking time!")
@staticmethod
def build_booking_datetime(
booking_filter: BookingFilter, booking_opening: BookingOpening
) -> DateTime:
"""
Build the date and time when the booking is open for a given match date.
The booking filter contains the date and time of the booking.
The club has the information about when the booking is open for that date.
:param booking_opening:the booking opening conditions
:param booking_filter: the booking information
:return: the date and time when the booking is open
"""
date_to_book = booking_filter.date
booking_date = date_to_book.subtract(days=booking_opening.days_before)
opening_time = pendulum.parse(booking_opening.opening_time)
booking_hour = opening_time.hour
booking_minute = opening_time.minute
return booking_date.at(booking_hour, booking_minute)

View file

@ -1,22 +1,212 @@
from pendulum import Time
from pydantic import BaseModel, ConfigDict, Field
from enum import Enum
from typing import Optional
import pendulum
from pendulum import Date, Time
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic_extra_types.pendulum_dt import DateTime
class Club(BaseModel):
class User(BaseModel):
login: str
password: str = Field(repr=False)
club_id: Optional[str] = Field(default=None)
class BookingOpening(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
id: str = Field()
url: str = Field()
courts_ids: list[int] = Field(default_factory=list)
booking_open_days_before: int = Field(default=7)
booking_opening_time: Time = Field(default=Time(hour=0, minute=0))
days_before: Optional[int] = Field(default=7, alias="daysBefore")
opening_time: Optional[str] = Field(alias="time", default=None, repr=False)
time_after_booking: Optional[str] = Field(
alias="timeAfterBookingTime", default=None, repr=False
)
def __repr__(self):
base = super().__repr__()
time = f", time: {self.time})" if self.time else ""
time_after_booking = (
f", time_after_booking_time: {self.time_after_booking_time})"
if self.time_after_booking_time
else ""
)
return base.removesuffix(")") + time + time_after_booking
@property
def time(self):
return pendulum.parse(self.opening_time).time()
@property
def time_after_booking_time(self):
return (
pendulum.parse(self.time_after_booking).time()
if self.time_after_booking
else None
)
class TotalBookings(BaseModel):
peak_hours: int | str = Field(alias="peakHours")
off_peak_hours: int | str = Field(alias="offPeakHours")
class Court(BaseModel):
id: int
name: str
number: int
is_indoor: Optional[bool] = Field(alias="isIndoor")
class Sport(BaseModel):
name: str
id: int
duration: int
price: int
players: int
courts: list[Court]
class Url(BaseModel):
name: str
path: str
payload_template: Optional[str] = Field(default=None, alias="payloadTemplate")
class BookingPlatform(BaseModel):
id: str
club_id: int = Field(alias="clubId")
url: str
hours_before_cancellation: int = Field(alias="hoursBeforeCancellation")
booking_opening: BookingOpening = Field(alias="bookingOpening")
total_bookings: TotalBookings = Field(alias="totalBookings")
sports: list[Sport]
urls: dict[str, Url]
class Club(BaseModel):
id: str
name: str
url: str
booking_platform: BookingPlatform = Field(alias="bookingPlatform")
class PlatformDefinition(BaseModel):
id: str
name: str
url: str
urls: list[Url]
class BookingFilter(BaseModel):
sport_id: int = Field()
date: DateTime = Field()
date: DateTime
sport_name: str
@field_validator("sport_name", mode="before")
@classmethod
def to_lower_case(cls, d: str) -> str:
return d.lower()
class User(BaseModel):
login: str = Field()
password: str = Field(repr=False)
class Booking(BaseModel):
id: int
booking_date: DateTime = Field(alias="dateResa")
start_time: DateTime = Field(alias="startTime")
sport: str
court: str
game_creation: Optional[int] = Field(default=None, alias="creaPartie")
game_creation_limit: Optional[DateTime] = Field(
default=None, alias="limitCreaPartie"
)
cancel: Optional[bool] = Field(default=True)
block_player_replacement: Optional[int] = Field(
default=None, alias="bloquerRemplacementJoueur"
)
can_remove_parteners: bool = Field(default=True, alias="canRemovePartners")
end_time: Optional[DateTime] = Field(default=None, alias="endTime")
day_fr: Optional[str] = Field(default=None, alias="dayFr")
live_xperience_code: Optional[str] = Field(default=None, alias="codeLiveXperience")
spartime_qr_code: Optional[str] = Field(default=None, alias="qrCodeSpartime")
remaining_places: int = Field(default=3, alias="remainingPlaces")
is_captain: bool = Field(default=True, alias="isCaptain")
dt_start: Optional[DateTime] = Field(default=None, alias="dtStart")
credit_card_guaranty: Optional[str] = Field(default=None, alias="garantieCb")
certificate_validity_duration: Optional[int] = Field(
alias="dureeValidCertif", default=None
)
charge_id: Optional[str] = Field(default=None, alias="chargeId")
partners: Optional[list] = Field(default=[])
player_status: Optional[int] = Field(default=None, alias="playerStatus")
products: Optional[list] = Field(default=[])
@field_validator("booking_date", mode="before")
@classmethod
def validate_date(cls, d: str) -> DateTime:
return pendulum.from_format(
d, "DD/MM/YYYY", tz=pendulum.timezone("Europe/Paris")
)
@field_validator("start_time", "end_time", mode="before")
@classmethod
def validate_time(cls, t: str) -> DateTime:
return pendulum.from_format(t, "HH:mm", tz=pendulum.timezone("Europe/Paris"))
@field_validator("game_creation_limit", mode="before")
@classmethod
def validate_datetime_add_tz(cls, dt: str) -> DateTime:
return pendulum.parse(dt, tz=pendulum.timezone("Europe/Paris"))
@field_validator("dt_start", mode="before")
@classmethod
def validate_datetime(cls, dt: str) -> DateTime:
return pendulum.parse(dt)
@field_validator("sport", mode="before")
@classmethod
def to_lower_case(cls, d: str) -> str:
return d.lower()
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"

View file

@ -0,0 +1,12 @@
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
class PayloadBuilder:
@staticmethod
def build(template_path: Path, **kwargs) -> str:
environment = Environment(loader=FileSystemLoader(template_path.parent))
template = environment.get_template(template_path.name)
return template.render(**kwargs)

View file

@ -0,0 +1,160 @@
clubs:
- name: Toulouse Padel Club
url: https://www.toulousepadelclub.com
id: tpc
bookingPlatform:
id: gestion-sports
clubId: 88
url: https://toulousepadelclub.gestion-sports.com
hoursBeforeCancellation: 4
bookingOpening:
daysBefore: 7
time: 00:00
totalBookings:
peakHours: 1
offPeakHours: unlimited
sports:
- name: Padel
id: 217
duration: 90
price: 48
players: 4
courts:
- name: Court 1
number: 1
id: 598
isIndoor: True
- name: Court 2
number: 2
id: 599
isIndoor: True
- name: Court 3
number: 3
id: 600
isIndoor: True
- name: Court 4
number: 4
id: 601
isIndoor: True
- name: Court 5
number: 5
id: 602
isIndoor: True
- name: Court 6
number: 6
id: 603
isIndoor: True
- name: Court 7
number: 7
id: 604
isIndoor: True
- name: Court 8
number: 8
id: 605
isIndoor: True
- name: Court 9
number: 9
id: 606
isIndoor: True
- name: Court 10
number: 10
id: 607
isIndoor: True
- name: Court 11
number: 11
id: 608
isIndoor: True
- name: Court 12
number: 12
id: 609
isIndoor: True
- name: Court 13
number: 13
id: 610
isIndoor: True
- name: Court 14
number: 14
id: 611
isIndoor: True
- name: Squash
id: 218
duration: 45
price: 18
players: 2
courts:
- name: Court 1
id: 613
number: 1
isIndoor: True
- name: Court 2
number: 2
id: 614
isIndoor: True
- name: Court 3
number: 3
id: 615
isIndoor: True
- name: Court 4
number: 4
id: 616
isIndoor: True
- name: Padel Tolosa
url: https://www.padeltolosa.fr/
id: padeltolosa
bookingPlatform:
id: gestion-sports
clubId: 89
url: https://padeltolosa.gestion-sports.com/
hoursBeforeCancellation: 24
bookingOpening:
daysBefore: 7
time: 00:00
totalBookings:
peakHours: 4
offPeakHours: unlimited
sports:
- name: Padel
id: 262
duration: 90
price: 48
players: 4
courts:
- name: Court 1 M.F IMMOBILLIER
number: 1
id: 746
isIndoor: True
- name: Court 2 PAQUITO
number: 2
id: 747
isIndoor: True
- name: Court 3 Seven Sisters PUB TOULOUSE
number: 3
id: 748
isIndoor: True
- name: Court MADRID
number: 4
id: 749
isIndoor: True
- name: Squash
id: 218
duration: 45
price: 18
players: 2
courts:
- name: Court 1
id: 613
isIndoor: True
number: 1
- name: Court 2
id: 614
isIndoor: True
number: 2
- name: Court 3
id: 615
isIndoor: True
number: 3
- name: Court 4
id: 616
isIndoor: True
number: 4

View file

@ -0,0 +1 @@
ajax=removeResa&hash={{ hash }}&id={{ booking_id }}

View file

@ -1 +1 @@
ajax=addResa&date={{ booking_filter.date.date().strftime("%d/%m/%Y") }}&hour={{ booking_filter.date.time().strftime("%H:%M") }}&duration=90&partners=null|null|null&paiement=facultatif&idSport={{ booking_filter.sport_id }}&creaPartie=false&idCourt={{ court_id }}&pay=false&token=undefined&totalPrice=44&saveCard=0&foodNumber=0
ajax=addResa&date={{ date.date().strftime("%d/%m/%Y") }}&hour={{ date.time().strftime("%H:%M") }}&duration=90&partners=null|null|null&paiement=facultatif&idSport={{ sport_id }}&creaPartie=false&idCourt={{ court_id }}&pay=false&token=undefined&totalPrice=48&saveCard=0&foodNumber=0

View file

@ -1 +1 @@
ajax=connexionUser&id_club={{ club.id }}&email={{ user.login }}&form_ajax=1&pass={{ user.password }}&compte=user&playeridonesignal=0&identifiant=identifiant&externCo=true
ajax=connexionUser&id_club={{ club.booking_platform.club_id }}&email={{ user.login }}&form_ajax=1&pass={{ user.password }}&compte=user&playeridonesignal=0&identifiant=identifiant&externCo=true

View file

@ -0,0 +1,19 @@
platforms:
- name: Gestion sports
url: https://gestion-sports.fr/
id: gestion-sports
urls:
- name: landing-page
path: /connexion.php
- name: login
path: /connexion.php
payloadTemplate: gestion-sports/login-payload.txt
- name: booking
path: /membre/reservation.html
payloadTemplate: gestion-sports/booking-payload.txt
- name: user-bookings
path: /membre/mesresas.html
payloadTemplate: gestion-sports/user-bookings-payload.txt
- name: cancellation
path: /membre/mesresas.html
payloadTemplate: gestion-sports/booking-cancellation-payload.txt

View file

@ -0,0 +1,13 @@
clubs:
- id: tpc
users:
- login: padel.testing@jouf.fr
password: ridicule
- login: mateochal31@gmail.com
password: pleanyakis
- id: padeltolosa
users:
- login: padel.testing@jouf.fr
password: ridicule
- login: mateochal31@gmail.com
password: pleanyakis

View file

@ -0,0 +1,92 @@
clubs:
- name: Super Club
url: https://www.super-club.com
id: sc
bookingPlatform:
id: gestion-sports
clubId: 54
url: https://superclub.flapi.fr
hoursBeforeCancellation: 10
bookingOpening:
daysBefore: 10
time: 22:37
totalBookings:
peakHours: 3
offPeakHours: unlimited
sports:
- name: Sport1
id: 22
duration: 55
price: 78
players: 4
courts:
- name: Court 1
number: 1
id: 54
isIndoor: True
- name: Court 2
number: 2
id: 67
isIndoor: False
- name: Court 3
number: 3
id: 26
isIndoor: True
- name: Sport2
id: 25
duration: 22
price: 3
players: 2
courts:
- name: Court 1
id: 99
number: 1
isIndoor: True
- name: Court 2
number: 2
id: 101
isIndoor: False
- name: Club Pourri
url: https://www.clubpourri.fr
id: cp
bookingPlatform:
id: gestion-sports
clubId: 1111
url: https://clubpourri.flapi.fr
hoursBeforeCancellation: 24
bookingOpening:
daysBefore: 2
time: 02:54
totalBookings:
peakHours: 4
offPeakHours: unlimited
sports:
- name: Sport1
id: 465
duration: 44
price: 98
players: 4
courts:
- name: Court 7
number: 15
id: 987
isIndoor: True
- name: prout prout
number: 555
id: 747
isIndoor: False
- name: Sport3
id: 321
duration: 11
price: 18
players: 2
courts:
- name: Court 1
id: 613
isIndoor: True
number: 1
- name: Court 2
id: 614
isIndoor: True
number: 2

View file

@ -0,0 +1 @@
ajax=removeResa&hash={{ hash }}&id={{ booking_id }}

View file

@ -0,0 +1 @@
ajax=addResa&date={{ date.date().strftime("%d/%m/%Y") }}&hour={{ date.time().strftime("%H:%M") }}&duration=90&partners=null|null|null&paiement=facultatif&idSport={{ sport_id }}&creaPartie=false&idCourt={{ court_id }}&pay=false&token=undefined&totalPrice=48&saveCard=0&foodNumber=0

View file

@ -0,0 +1 @@
ajax=connexionUser&id_club={{ club.booking_platform.club_id }}&email={{ user.login }}&form_ajax=1&pass={{ user.password }}&compte=user&playeridonesignal=0&identifiant=identifiant&externCo=true

View file

@ -0,0 +1,12 @@
{
"Connection": "keep-alive",
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate, br",
"DNT": "1",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"Accept": "application/json, text/javascript, */*; q=0.01",
"X-Requested-With": "XMLHttpRequest",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin"
}

View file

@ -0,0 +1 @@
ajax=loadResa&hash={{ hash }}

View file

@ -0,0 +1,19 @@
platforms:
- name: flapi
url: https://flapi.fr/
id: gestion-sports
urls:
- name: landing-page
path: /landing.html
- name: login
path: /login.html
payloadTemplate: gestion-sports/login-payload.txt
- name: booking
path: /booking.html
payloadTemplate: gestion-sports/booking-payload.txt
- name: user-bookings
path: /user_bookings.html
payloadTemplate: gestion-sports/user-bookings-payload.txt
- name: cancellation
path: /cancel.html
payloadTemplate: gestion-sports/booking-cancellation-payload.txt

View file

@ -0,0 +1,13 @@
clubs:
- id: tpc
users:
- login: padel.testing@jouf.fr
password: ridicule
- login: mateochal31@gmail.com
password: pleanyakis
- id: padeltolosa
users:
- login: padel.testing@jouf.fr
password: ridicule
- login: mateochal31@gmail.com
password: pleanyakis

View file

@ -0,0 +1,4 @@
{
"status": "error",
"message": "D\u00e9sol\u00e9 mais vous avez 1 r\u00e9servation en cours au Padel en heures pleines et le r\u00e9glement n'autorise qu'une r\u00e9servation en heures pleines \u00e0 la fois au Padel!"
}

View file

@ -0,0 +1,5 @@
{
"status": "ok",
"message": "Merci, votre r\u00e9servation s'est bien effectu\u00e9e, vous allez recevoir un email avec le r\u00e9capitulatif de votre r\u00e9servation, pensez \u00e0 le conserver.",
"id_resa": 3609529
}

View file

@ -0,0 +1,4 @@
{
"status": "ok",
"message": "La r\u00e9servation a bien \u00e9t\u00e9 annul\u00e9e !"
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,5 @@
{
"status": "ko",
"msg": "L'email ou le mot de passe saisi est incorrect.",
"data": false
}

View file

@ -0,0 +1,9 @@
{
"status": "ok",
"msg": "",
"data": {
"needChoice": false,
"redirectUrl": "\/membre",
"id_club": 88
}
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,52 @@
[
{
"id": 111,
"chargeId": null,
"partners": [],
"dateResa": "21\/03\/2024",
"startTime": "13:30",
"endTime": "15:00",
"dayFr": "jeudi 21 mars 2024",
"codeLiveXperience": null,
"qrCodeSpartime": null,
"sport": "Sport1",
"court": "court 13",
"creaPartie": 0,
"limitCreaPartie": "2024-03-21 11:30:00",
"cancel": true,
"bloquerRemplacementJoueur": 1,
"canRemovePartners": false,
"remainingPlaces": 3,
"isCaptain": true,
"dtStart": "2024-03-21T13:30:00+01:00",
"garantieCb": null,
"dureeValidCertif": null,
"playerStatus": 3,
"products": []
},
{
"id": 360,
"chargeId": null,
"partners": [],
"dateResa": "18\/11\/2025",
"startTime": "09:00",
"endTime": "10:30",
"dayFr": "vendredi 18 novembre 2025",
"codeLiveXperience": null,
"qrCodeSpartime": null,
"sport": "Sport1",
"court": "court 13",
"creaPartie": 0,
"limitCreaPartie": "2025-11-18 07:00:00",
"cancel": true,
"bloquerRemplacementJoueur": 1,
"canRemovePartners": false,
"remainingPlaces": 3,
"isCaptain": true,
"dtStart": "2025-11-18T07:00:00+01:00",
"garantieCb": null,
"dureeValidCertif": null,
"playerStatus": 3,
"products": []
}
]

File diff suppressed because one or more lines are too long

View file

@ -3,19 +3,16 @@ from pathlib import Path
import pendulum
import pytest
from gestion_sports.payload_builders import GestionSportsBookingPayloadBuilder
from resa_padel.models import BookingFilter, Club, User
from resa_padel.models import BookingFilter, User
user = User(login="padel.testing@jouf.fr", password="ridicule", club_id="123")
url = "https://tpc.padel.com"
club = Club(id="123", url=url, courts_ids=[606, 607, 608])
courts = [606, 607, 608]
sport_id = 217
sport_name = "padel"
tz_info = "Europe/Paris"
booking_date = pendulum.now().add(days=6).set(hour=18, minute=0, second=0, tz=tz_info)
booking_filter = BookingFilter(sport_id=sport_id, date=booking_date)
booking_filter = BookingFilter(sport_name=sport_name, date=booking_date)
booking_failure_response = json.dumps(
{
@ -36,12 +33,6 @@ booking_success_response = json.dumps(
date_format = "%d/%m/%Y"
time_format = "%H:%M"
booking_payload = (
GestionSportsBookingPayloadBuilder()
.booking_filter(booking_filter)
.court_id(courts[0])
.build()
)
html_file = Path(__file__).parent / "data" / "mes_resas.html"
_mes_resas_html = html_file.read_text(encoding="utf-8")
@ -57,11 +48,6 @@ def a_booking_filter() -> BookingFilter:
return booking_filter
@pytest.fixture
def a_club() -> Club:
return club
@pytest.fixture
def a_booking_success_response() -> str:
return booking_success_response
@ -72,11 +58,6 @@ def a_booking_failure_response() -> str:
return booking_failure_response
@pytest.fixture
def a_booking_payload() -> str:
return booking_payload
@pytest.fixture
def mes_resas_html() -> str:
return _mes_resas_html

View file

@ -1,185 +0,0 @@
import pytest
from aiohttp import ClientSession
from models import BookingFilter, Club, User
from yarl import URL
from resa_padel.gestion_sports.gestion_sports_connector import GestionSportsConnector
from tests.fixtures import (
a_booking_failure_response,
a_booking_filter,
a_booking_success_response,
a_club,
a_user,
)
tpc_url = "https://toulousepadelclub.gestion-sports.com"
TPC_COURTS = [
None,
596,
597,
598,
599,
600,
601,
602,
603,
604,
605,
606,
607,
608,
609,
610,
611,
]
@pytest.mark.asyncio
async def test_should_reach_landing_page_to_gestion_sports_website() -> None:
"""
Test that landing page is reached
"""
async with ClientSession() as session:
cookies = session.cookie_jar.filter_cookies(URL(tpc_url))
assert cookies.get("PHPSESSID") is None
gs_connector = GestionSportsConnector(session, tpc_url)
response = await gs_connector.land()
assert response.status == 200
assert response.request_info.method == "GET"
assert response.content_type == "text/html"
assert response.request_info.url == URL(tpc_url + "/connexion.php")
assert response.charset == "UTF-8"
cookies = session.cookie_jar.filter_cookies(URL(tpc_url))
assert cookies.get("PHPSESSID") is not None
@pytest.mark.asyncio
async def test_should_login_to_gestion_sports_website(
a_user: User, a_club: Club
) -> None:
"""
Test that a user can log in after reaching the landing page
:param a_user: the user that wants to book a court
:param a_club: the club information
"""
async with ClientSession() as session:
gs_connector = GestionSportsConnector(session, tpc_url)
await gs_connector.land()
response = await gs_connector.login(a_user, a_club)
assert response.status == 200
assert response.request_info.url == URL(tpc_url + "/connexion.php")
assert response.request_info.method == "POST"
cookies = session.cookie_jar.filter_cookies(URL(tpc_url))
assert cookies.get("COOK_ID_CLUB").value is not None
assert cookies.get("COOK_ID_USER").value is not None
assert cookies.get("PHPSESSID") is not None
@pytest.mark.asyncio
@pytest.mark.slow
async def test_booking_url_should_be_reachable(
a_user: User, a_booking_filter: BookingFilter, a_club: Club
) -> None:
"""
Test that a user can log in the booking platform and book a court
:param a_user: the user that wants to book a court
:param a_booking_filter: the booking information
:param a_club: the club information
"""
async with ClientSession() as session:
gs_connector = GestionSportsConnector(session, tpc_url)
await gs_connector.land()
await gs_connector.login(a_user, a_club)
court_booked = await gs_connector.book(a_booking_filter, a_club)
# At 18:00 no chance to get a booking, any day of the week
assert court_booked in TPC_COURTS
@pytest.mark.asyncio
async def test_should_book_a_court_from_gestion_sports(
aioresponses,
a_booking_filter: BookingFilter,
a_club: Club,
a_booking_success_response: str,
a_booking_failure_response: str,
) -> None:
"""
Test that user can reach the landing page, then log in to the platform
and eventually book a court
:param aioresponses: the http response mock
:param a_booking_filter: the booking information
:param a_club: the club information
:param a_booking_success_response: the success response mock
:param a_booking_failure_response: the failure response mock
"""
booking_url = URL(tpc_url + "/membre/reservation.html?")
# first booking request will fail
aioresponses.post(booking_url, status=200, body=a_booking_failure_response)
# first booking request will succeed
aioresponses.post(booking_url, status=200, body=a_booking_success_response)
# first booking request will fail
aioresponses.post(booking_url, status=200, body=a_booking_failure_response)
async with ClientSession() as session:
gs_connector = GestionSportsConnector(session, tpc_url)
court_booked = await gs_connector.book(a_booking_filter, a_club)
# the second element of the list is the booked court
assert court_booked == a_club.courts_ids[1]
def test_response_status_should_be_ok(a_booking_success_response: str) -> None:
"""
Test internal method to verify that the success response received by booking
a gestion-sports court is still a JSON with a field 'status' set to 'ok'
:param a_booking_success_response: the success response mock
"""
is_booked = GestionSportsConnector.is_booking_response_status_ok(
a_booking_success_response
)
assert is_booked
def test_response_status_should_be_not_ok(a_booking_failure_response: str) -> None:
"""
Test internal method to verify that the failure response received by booking
a gestion-sports court is still a JSON with a field 'status' set to 'error'
:param a_booking_failure_response: the failure response mock
"""
is_booked = GestionSportsConnector.is_booking_response_status_ok(
a_booking_failure_response
)
assert not is_booked
@pytest.mark.asyncio
@pytest.mark.slow
async def test_get_user_ongoing_bookings(a_user: User, a_club: Club) -> None:
"""
Test that the user has 2 ongoing bookings
:param a_user:
:param a_club:
:return:
"""
async with ClientSession() as session:
gs_connector = GestionSportsConnector(session, tpc_url)
await gs_connector.land()
await gs_connector.login(a_user, a_club)
bookings = await gs_connector.get_ongoing_bookings()
assert len(bookings) == 0

View file

@ -1,8 +0,0 @@
from gestion_sports import gestion_sports_html_parser as parser
from tests.fixtures import mes_resas_html
def test_html_parser(mes_resas_html):
hash_value = parser.get_hash_input(mes_resas_html)
assert hash_value == "ef4403f4c44fa91060a92476aae011a2184323ec"

View file

@ -1,61 +0,0 @@
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

View file

@ -1,87 +0,0 @@
from unittest.mock import patch
import pendulum
import pytest
from aioresponses import aioresponses
from gestion_sports.gestion_sports_platform import (
GestionSportsPlatform,
wait_until_booking_time,
)
from models import BookingFilter, Club, User
from tests import fixtures, utils
from tests.fixtures import (
a_booking_failure_response,
a_booking_filter,
a_booking_success_response,
a_club,
a_user,
mes_resas_html,
)
@pytest.mark.asyncio
@patch("pendulum.now")
async def test_booking(
mock_now,
a_booking_success_response: str,
a_booking_failure_response: str,
a_user: User,
a_club: Club,
a_booking_filter: BookingFilter,
mes_resas_html: str,
):
"""
Test a single court booking without reading the conf from environment variables
:param mock_now: the pendulum.now() mock
:param a_booking_success_response: the success json response
:param a_booking_failure_response: the failure json response
:param a_user: a test user
:param a_club:a test club
:param a_booking_filter: a test booking filter
"""
booking_datetime = utils.retrieve_booking_datetime(a_booking_filter, a_club)
mock_now.side_effect = [booking_datetime]
# mock connection to the booking platform
with aioresponses() as aio_mock:
utils.mock_rest_api_from_connection_to_booking(
aio_mock,
fixtures.url,
a_booking_failure_response,
a_booking_success_response,
mes_resas_html,
)
async with GestionSportsPlatform(a_club) as gs_operations:
court_booked = await gs_operations.book(a_user, a_booking_filter)
assert court_booked == a_club.courts_ids[1]
@patch("pendulum.now")
def test_wait_until_booking_time(
mock_now, a_club: Club, a_booking_filter: BookingFilter
):
"""
Test the function that waits until the booking can be performed
:param mock_now: the pendulum.now() mock
:param a_club: a club
:param a_booking_filter: a booking filter
"""
booking_datetime = utils.retrieve_booking_datetime(a_booking_filter, a_club)
seconds = [
booking_datetime.subtract(seconds=3),
booking_datetime.subtract(seconds=2),
booking_datetime.subtract(seconds=1),
booking_datetime,
booking_datetime.add(microseconds=1),
booking_datetime.add(microseconds=2),
]
mock_now.side_effect = seconds
wait_until_booking_time(a_club, a_booking_filter)
assert pendulum.now() == booking_datetime.add(microseconds=1)

View file

@ -0,0 +1,46 @@
import json
from pathlib import Path
import config
import pendulum
import pytest
from connectors import GestionSportsConnector
from models import BookingFilter, Club, User
TEST_FOLDER = Path(__file__).parent.parent
DATA_FOLDER = TEST_FOLDER / "data"
RESPONSES_FOLDER = DATA_FOLDER / "responses"
@pytest.fixture
def club() -> Club:
return config.get_clubs()["tpc"]
@pytest.fixture
def connector(club) -> GestionSportsConnector:
return GestionSportsConnector(club)
@pytest.fixture
def user() -> User:
return User(login="padel.testing@jouf.fr", password="ridicule")
@pytest.fixture
def booking_filter() -> BookingFilter:
return BookingFilter(
sport_name="Padel", date=pendulum.parse("2024-03-21T13:30:00+01:00")
)
@pytest.fixture
def booking_success_response() -> dict:
booking_success_file = RESPONSES_FOLDER / "booking_success.json"
return json.loads(booking_success_file.read_text(encoding="utf-8"))
@pytest.fixture
def booking_failure_response() -> dict:
booking_failure_file = RESPONSES_FOLDER / "booking_failure.json"
return json.loads(booking_failure_file.read_text(encoding="utf-8"))

View file

@ -0,0 +1,59 @@
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-21T13: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-21T13:30:00+01:00",
"LOGIN": "padel.testing@jouf.fr",
"PASSWORD": "ridicule",
},
clear=True,
)
def test_main_cancellation():
booking.main()

View file

@ -0,0 +1,251 @@
import os
from pathlib import Path
from unittest.mock import patch
import aiohttp
import pendulum
import pytest
from connectors import GestionSportsConnector
from models import BookingFilter, Club
from pendulum import DateTime
from yarl import URL
def retrieve_booking_datetime(
a_booking_filter: BookingFilter, a_club: Club
) -> DateTime:
"""
Utility to retrieve the booking datetime from the booking filter and the club
:param a_booking_filter: the booking filter that contains the date to book
:param a_club: the club which has the number of days before the date and the booking time
"""
booking_opening = a_club.booking_platform.booking_opening
opening_time = pendulum.parse(booking_opening.opening_time)
booking_hour = opening_time.hour
booking_minute = opening_time.minute
date_to_book = a_booking_filter.date
return date_to_book.subtract(days=booking_opening.days_before).at(
booking_hour, booking_minute
)
@patch.dict(
os.environ,
{"CLUB_ID": "tpc"},
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.booking_cancellation_url
== "https://toulousepadelclub.gestion-sports.com/membre/mesresas.html"
)
@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.booking_cancel_template
== resources_folder / "booking-cancellation-payload.txt"
)
@pytest.mark.asyncio
async def test_landing_page(connector):
async with aiohttp.ClientSession() as session:
response = await connector.land(session)
assert response.status == 200
assert response.request_info.method == "GET"
assert response.content_type == "text/html"
assert response.request_info.url == URL(connector.landing_url)
assert response.charset == "UTF-8"
assert response.cookies.get("PHPSESSID") is not None
@pytest.mark.asyncio
async def test_login(connector, user):
async with aiohttp.ClientSession() as session:
await connector.land(session)
response = await connector.login(session, user)
assert response.status == 200
assert response.request_info.method == "POST"
assert response.content_type == "text/html"
assert response.request_info.url == URL(connector.landing_url)
assert response.charset == "UTF-8"
assert response.cookies.get("COOK_COMPTE") is not None
assert response.cookies.get("COOK_ID_CLUB").value == "88"
assert response.cookies.get("COOK_ID_USER").value == "232382"
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 aiohttp.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
@patch("pendulum.now")
def test_wait_until_booking_time(mock_now, connector, booking_filter, club):
booking_datetime = retrieve_booking_datetime(booking_filter, club)
seconds = [
booking_datetime.subtract(seconds=3),
booking_datetime.subtract(seconds=2),
booking_datetime.subtract(seconds=1),
booking_datetime,
booking_datetime.add(microseconds=1),
booking_datetime.add(microseconds=2),
]
mock_now.side_effect = seconds
connector.wait_until_booking_time(booking_filter)
assert pendulum.now() == booking_datetime.add(microseconds=1)
@pytest.mark.asyncio
async def test_get_hash(connector, user):
async with aiohttp.ClientSession() as session:
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 aiohttp.ClientSession() as session:
await connector.land(session)
await connector.login(session, user)
hash_value = await connector.send_hash_request(session)
payload = f"ajax=loadResa&hash={hash_value}"
bookings = await connector.send_user_bookings_request(session, payload)
print(bookings)
@pytest.mark.asyncio
async def test_get_ongoing_bookings(connector, user):
async with aiohttp.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_has_user_ongoing_bookings(connector, user):
assert await connector.has_user_ongoing_booking(user)
@pytest.mark.asyncio
async def test_cancel_booking_id(connector, user):
async with aiohttp.ClientSession() as session:
await connector.land(session)
await connector.login(session, user)
ongoing_bookings = await connector.get_ongoing_bookings(session)
booking_id = ongoing_bookings[0].id
response = await connector.cancel_booking_id(user, booking_id)
assert len(await connector.get_ongoing_bookings(session)) == 0
@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 aiohttp.ClientSession() as session:
await connector.land(session)
await connector.login(session, user)
await connector.cancel_booking(session, booking_filter)

View file

@ -0,0 +1,20 @@
import pytest
from gestion_sports_services import GestionSportsServices
@pytest.mark.asyncio
async def test_booking_success(club, user, booking_filter):
court_booked = await GestionSportsServices.book(club, user, booking_filter)
assert court_booked.id is not None
@pytest.mark.asyncio
async def test_user_has_available_slots(club, user):
has_slots = await GestionSportsServices.has_user_available_slots(user, club)
assert has_slots
@pytest.mark.asyncio
async def test_cancel_booking(club, user, booking_filter):
await GestionSportsServices.cancel_booking(user, club, booking_filter)

View file

@ -1,77 +0,0 @@
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

View file

@ -1,74 +0,0 @@
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"

View file

@ -0,0 +1,379 @@
import json
from pathlib import Path
import pendulum
import pytest
from connectors 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, court12, court13, court14) -> Sport:
return Sport(
name="Sport1",
id=8,
duration=99,
price=54,
players=3,
courts=[court11, court12, court13, court14],
)
@pytest.fixture
def court21() -> Court:
return Court(id="1", name="Court 1", number=1, isIndoor=False)
@pytest.fixture
def court22() -> Court:
return Court(id="2", name="Court 2", number=2, isIndoor=True)
@pytest.fixture
def court23() -> Court:
return Court(id="3", name="Court 3", number=3, isIndoor=True)
@pytest.fixture
def court24() -> Court:
return Court(id="4", name="Court 4", number=4, isIndoor=True)
@pytest.fixture
def sport2(court21, court22, court23, court24) -> Sport:
return Sport(
name="Sport 2",
id=10,
duration=44,
price=23,
players=1,
courts=[court21, court22, court23, court24],
)
@pytest.fixture
def landing_url() -> Url:
return Url(
name="landing-page",
path="landing.html",
)
@pytest.fixture
def login_url() -> Url:
return Url(
name="login",
path="login.html",
payloadTemplate="gestion-sports/login-payload.txt",
)
@pytest.fixture
def booking_url() -> Url:
return Url(
name="booking",
path="booking.html",
payloadTemplate="gestion-sports/booking-payload.txt",
)
@pytest.fixture
def user_bookings_url() -> Url:
return Url(
name="user-bookings",
path="user_bookings.html",
payloadTemplate="gestion-sports/user-bookings-payload.txt",
)
@pytest.fixture
def cancellation_url() -> Url:
return Url(
name="cancellation",
path="cancel.html",
payloadTemplate="gestion-sports/booking-cancellation-payload.txt",
)
@pytest.fixture
def booking_opening() -> BookingOpening:
return BookingOpening(daysBefore=10, time="03:27")
@pytest.fixture
def total_bookings() -> TotalBookings:
return TotalBookings(peakHours=3, offPeakHours="unlimited")
@pytest.fixture
def booking_platform(
booking_opening,
total_bookings,
sport1,
sport2,
landing_url,
login_url,
booking_url,
user_bookings_url,
cancellation_url,
) -> BookingPlatform:
return BookingPlatform(
id="gestion-sports",
clubId=21,
url="https://ptf1.com",
hoursBeforeCancellation=7,
bookingOpening=booking_opening,
totalBookings=total_bookings,
sports=[sport1, sport2],
urls={
"landing-page": landing_url,
"login": login_url,
"booking": booking_url,
"user-bookings": user_bookings_url,
"cancellation": cancellation_url,
},
)
@pytest.fixture
def club(booking_platform) -> Club:
return Club(
id="super_club",
name="Super Club",
url="https://superclub.com",
bookingPlatform=booking_platform,
)
@pytest.fixture
def connector(club) -> GestionSportsConnector:
return GestionSportsConnector(club)
@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:
landing_response_file = RESPONSES_FOLDER / "landing_response.html"
return landing_response_file.read_text(encoding="utf-8")
@pytest.fixture
def login_success_response() -> dict:
login_success_file = RESPONSES_FOLDER / "login_success.json"
return json.loads(login_success_file.read_text(encoding="utf-8"))
@pytest.fixture
def login_failure_response() -> dict:
login_failure_file = RESPONSES_FOLDER / "login_failure.json"
return json.loads(login_failure_file.read_text(encoding="utf-8"))
@pytest.fixture
def booking_success_response() -> dict:
booking_success_file = RESPONSES_FOLDER / "booking_success.json"
return json.loads(booking_success_file.read_text(encoding="utf-8"))
@pytest.fixture
def booking_failure_response() -> dict:
booking_failure_file = RESPONSES_FOLDER / "booking_failure.json"
return json.loads(booking_failure_file.read_text(encoding="utf-8"))
@pytest.fixture
def booked_courts_response(
court11,
court12,
court13,
court14,
booking_success_response,
booking_failure_response,
) -> list[tuple[int, dict]]:
court1_resp = court11.id, booking_failure_response
court2_resp = court12.id, booking_failure_response
court3_resp = court13.id, booking_success_response
court4_resp = court14.id, booking_failure_response
return [court1_resp, court2_resp, court3_resp, court4_resp]
@pytest.fixture
def booking_success_from_start(
landing_response,
login_success_response,
booking_success_response,
booking_failure_response,
):
return [
landing_response,
login_success_response,
booking_failure_response,
booking_success_response,
booking_failure_response,
booking_failure_response,
]
@pytest.fixture
def booking_failure_from_start(
landing_response,
login_success_response,
booking_success_response,
booking_failure_response,
):
return [
landing_response,
login_success_response,
booking_failure_response,
booking_failure_response,
booking_failure_response,
booking_failure_response,
]
@pytest.fixture
def user_bookings_get_response() -> str:
user_bookings_file = RESPONSES_FOLDER / "user_bookings_get.html"
return user_bookings_file.read_text(encoding="utf-8")
@pytest.fixture
def user_bookings_list() -> list:
user_bookings_file = RESPONSES_FOLDER / "user_bookings_post.json"
return json.loads(user_bookings_file.read_text(encoding="utf-8"))
@pytest.fixture
def user_has_ongoing_bookings_from_start(
landing_response,
login_success_response,
user_bookings_get_response,
user_bookings_list,
) -> list:
return [
landing_response,
login_success_response,
user_bookings_get_response,
user_bookings_list,
]
@pytest.fixture
def user_bookings_empty_list() -> list:
return []
@pytest.fixture
def user_has_no_ongoing_bookings_from_start(
landing_response,
login_success_response,
user_bookings_get_response,
user_bookings_empty_list,
) -> list:
return [
landing_response,
login_success_response,
user_bookings_get_response,
user_bookings_empty_list,
]
@pytest.fixture
def cancellation_response() -> list:
cancellation_response_file = RESPONSES_FOLDER / "cancellation_response.json"
return json.loads(cancellation_response_file.read_text(encoding="utf-8"))
@pytest.fixture
def cancellation_by_id_from_start(
landing_response,
login_success_response,
user_bookings_get_response,
cancellation_response,
):
return [
landing_response,
login_success_response,
user_bookings_get_response,
cancellation_response,
]
@pytest.fixture
def cancellation_success_from_start(
landing_response,
login_success_response,
user_bookings_get_response,
user_bookings_list,
cancellation_response,
):
return [
landing_response,
login_success_response,
user_bookings_get_response,
user_bookings_list,
cancellation_response,
]
@pytest.fixture
def cancellation_success_booking_filter() -> BookingFilter:
return BookingFilter(
sport_name="Sport1", date=pendulum.parse("2024-03-21T13:30:00Z")
)
@pytest.fixture
def service() -> GestionSportsServices:
return GestionSportsServices()

View file

@ -0,0 +1,83 @@
def make_landing_request_success(aioresponses, connector, landing_response):
aioresponses.get(
connector.landing_url,
status=200,
headers={"Set-Cookie": "PHPSESSID=987512"},
body=landing_response,
)
def make_login_request_fail(aioresponses, connector, login_failure_response):
aioresponses.post(
connector.login_url,
status=200,
payload=login_failure_response,
)
def make_login_request_success(aioresponses, connector, login_success_response):
aioresponses.post(
connector.login_url,
status=200,
headers={"Set-Cookie": "COOK_COMPTE=e2be1;" "COOK_ID_CLUB=22;COOK_ID_USER=666"},
payload=login_success_response,
)
def set_booking_request(aioresponses, connector, booking_response):
aioresponses.post(connector.booking_url, status=200, payload=booking_response)
def set_full_booking_requests_responses(aioresponses, connector, responses_list):
make_landing_request_success(aioresponses, connector, responses_list[0])
make_login_request_success(aioresponses, connector, responses_list[1])
for response in responses_list[2:]:
set_booking_request(aioresponses, connector, response)
def set_ongoing_bookings_response(
aioresponses, connector, user_bookings_get_response, user_bookings_post_response
):
set_hash_response(aioresponses, connector, user_bookings_get_response)
set_bookings_response(aioresponses, connector, user_bookings_post_response)
def set_hash_response(aioresponses, connector, user_bookings_get_response):
aioresponses.get(
connector.user_bookings_url, status=200, body=user_bookings_get_response
)
def set_bookings_response(aioresponses, connector, user_bookings_post_response):
aioresponses.post(
connector.user_bookings_url, status=200, payload=user_bookings_post_response
)
def set_full_user_bookings_responses(aioresponses, connector, responses):
make_landing_request_success(aioresponses, connector, responses[0])
make_login_request_success(aioresponses, connector, responses[1])
set_ongoing_bookings_response(aioresponses, connector, *responses[2:])
def set_cancellation_response(aioresponses, connector, response):
aioresponses.post(connector.booking_cancellation_url, status=200, payload=response)
def set_full_cancellation_by_id_responses(aioresponses, connector, responses):
make_landing_request_success(aioresponses, connector, responses[0])
make_login_request_success(aioresponses, connector, responses[1])
set_hash_response(aioresponses, connector, responses[2])
set_cancellation_response(aioresponses, connector, responses[3])
def set_full_cancellation_responses(aioresponses, connector, responses):
make_landing_request_success(aioresponses, connector, responses[0])
make_login_request_success(aioresponses, connector, responses[1])
# the request to get the hash is made twice
set_hash_response(aioresponses, connector, responses[2])
set_hash_response(aioresponses, connector, responses[2])
set_bookings_response(aioresponses, connector, responses[3])
set_cancellation_response(aioresponses, connector, responses[4])

View file

View file

@ -0,0 +1,51 @@
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

View file

@ -0,0 +1,145 @@
from pathlib import Path
from unittest.mock import patch
import pytest
from aiohttp import ClientSession
from connectors import GestionSportsConnector
from tests.unit_tests import responses
def test_urls(connector, club):
base_url = club.booking_platform.url
relative_urls = club.booking_platform.urls
relative_landing_url = relative_urls.get("landing-page").path
assert connector.landing_url == f"{base_url}/{relative_landing_url}"
relative_login_url = relative_urls.get("login").path
assert connector.login_url == f"{base_url}/{relative_login_url}"
relative_booking_url = relative_urls.get("booking").path
assert connector.booking_url == f"{base_url}/{relative_booking_url}"
relative_user_bookings_url = relative_urls.get("user-bookings").path
assert connector.user_bookings_url == f"{base_url}/{relative_user_bookings_url}"
relative_cancel_url = relative_urls.get("cancellation").path
assert connector.booking_cancellation_url == f"{base_url}/{relative_cancel_url}"
@patch("config.get_resources_folder")
def test_urls_payload_templates(mock_resources, club):
path_to_resources = Path("some/path/to/resource")
mock_resources.return_value = path_to_resources
connector = GestionSportsConnector(club)
relative_urls = club.booking_platform.urls
login_payload = relative_urls.get("login").payload_template
assert connector.login_template == path_to_resources / login_payload
booking_payload = relative_urls.get("booking").payload_template
assert connector.booking_template == path_to_resources / booking_payload
user_bookings_payload = relative_urls.get("user-bookings").payload_template
assert connector.user_bookings_template == path_to_resources / user_bookings_payload
cancel_payload = relative_urls.get("cancellation").payload_template
assert connector.booking_cancel_template == path_to_resources / cancel_payload
@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
def test_get_booked_court(connector, booked_courts_response):
booked_court = connector.get_booked_court(booked_courts_response, "Sport1")
assert booked_court.number == 3
@pytest.mark.asyncio
async def test_get_ongoing_bookings(
aioresponses,
connector,
user,
user_bookings_get_response,
user_bookings_list,
):
responses.set_ongoing_bookings_response(
aioresponses, connector, user_bookings_get_response, user_bookings_list
)
async with ClientSession() as session:
bookings = await connector.get_ongoing_bookings(session)
assert len(bookings) == 2
@pytest.mark.asyncio
async def test_cancellation_request(
aioresponses, connector, user_bookings_get_response, cancellation_response
):
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.cancel_booking_id(session, 123)
assert await response.json() == cancellation_response
@pytest.mark.asyncio
async def test_cancel_booking_success(
aioresponses,
connector,
user,
cancellation_success_booking_filter,
cancellation_success_from_start,
):
responses.set_full_cancellation_responses(
aioresponses, connector, cancellation_success_from_start
)
async with ClientSession() as session:
response = await connector.cancel_booking(
session, cancellation_success_booking_filter
)
assert await response.json() == cancellation_success_from_start[4]

View file

@ -0,0 +1,110 @@
import pytest
from gestion_sports_services import GestionSportsServices
from tests.unit_tests import responses
@pytest.mark.asyncio
async def test_booking_success(
aioresponses,
connector,
club,
user,
booking_filter,
booking_success_from_start,
):
responses.set_full_booking_requests_responses(
aioresponses, connector, booking_success_from_start
)
court_booked = await GestionSportsServices.book(club, user, booking_filter)
assert court_booked.id == 2
@pytest.mark.asyncio
async def test_booking_failure(
aioresponses,
gs_services,
connector,
club,
user,
booking_filter,
booking_failure_from_start,
):
responses.set_full_booking_requests_responses(
aioresponses, connector, booking_failure_from_start
)
court_booked = await gs_services.book(club, user, booking_filter)
assert court_booked is None
@pytest.mark.asyncio
async def test_user_has_available_booking_slots(
aioresponses,
gs_services,
connector,
user,
club,
user_has_ongoing_bookings_from_start,
):
responses.set_full_user_bookings_responses(
aioresponses, connector, user_has_ongoing_bookings_from_start
)
has_user_available_slots = await gs_services.has_user_available_slots(user, club)
assert has_user_available_slots
@pytest.mark.asyncio
async def test_user_has_no_available_booking_slots(
aioresponses,
gs_services,
connector,
user,
club,
user_has_no_ongoing_bookings_from_start,
):
responses.set_full_user_bookings_responses(
aioresponses, connector, user_has_no_ongoing_bookings_from_start
)
has_user_available_slots = await gs_services.has_user_available_slots(user, club)
assert not has_user_available_slots
@pytest.mark.asyncio
async def test_cancel_booking(
aioresponses,
gs_services,
connector,
user,
club,
booking_filter,
cancellation_success_from_start,
):
responses.set_full_cancellation_responses(
aioresponses, connector, cancellation_success_from_start
)
await gs_services.cancel_booking(user, club, booking_filter)
@pytest.mark.asyncio
async def test_cancel_booking_id(
aioresponses,
gs_services,
connector,
user,
club,
cancellation_success_from_start,
):
responses.set_full_cancellation_responses(
aioresponses, connector, cancellation_success_from_start
)
await gs_services.cancel_booking_id(user, club, 65464)

View file

@ -1,119 +0,0 @@
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)