Compare commits

...

10 commits

Author SHA1 Message Date
cea772371e Merge pull request 'All methods are in the right class' (#21) from refactor-code-and-comments into main
Reviewed-on: https://jouf.fr/gitea/stanislas/resa-padel/pulls/21
2024-03-24 13:37:39 +00:00
7f59443b64 All methods are in the right class 2024-03-23 21:54:17 +01:00
0d541e82a5 Merge pull request 'refactor-data-model' (#17) from refactor-data-model into main
Reviewed-on: https://jouf.fr/gitea/stanislas/resa-padel/pulls/17
2024-03-23 11:01:06 +00:00
a622ee69de Added a service that can get all current tournaments list 2024-03-23 11:58:07 +01:00
3d0bd47079 Added a service that can get all current tournaments list 2024-03-23 11:56:31 +01:00
42ce764654 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
2024-03-20 22:13:33 +00:00
e6023e0687 created a gestion sports services class that handles the connection while the connector is dedicated to the requests 2024-03-20 23:11:43 +01:00
bcd8dc0733 Added a lot of unit tests 2024-03-19 00:00:59 +01:00
16d4a0724c Added a lot of unit tests 2024-03-18 23:46:01 +01:00
0938fb98b7 Big refactoring.
- clubs, booking platforms and user are now defined in customization files -> there are less environment variables
- the responsibility of the session moved
- booking cancellation is available
2024-03-17 23:58:03 +01:00
62 changed files with 10326 additions and 1214 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

27
gd.json Normal file
View file

@ -0,0 +1,27 @@
[
{
"id": 3628098,
"chargeId": null,
"partners": [],
"dateResa": "28\/03\/2024",
"startTime": "13:30",
"endTime": "15:00",
"dayFr": "jeudi 28 mars 2024",
"codeLiveXperience": null,
"qrCodeSpartime": null,
"sport": "Padel",
"court": "court 11",
"creaPartie": 0,
"limitCreaPartie": "2024-03-28 11:30:00",
"cancel": true,
"bloquerRemplacementJoueur": 1,
"canRemovePartners": false,
"remainingPlaces": 3,
"isCaptain": true,
"dtStart": "2024-03-28T13:30:00+01:00",
"garantieCb": null,
"dureeValidCertif": null,
"playerStatus": 3,
"products": []
}
]

View file

@ -2,62 +2,91 @@ 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, Tournament, 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)
service = GestionSportsServices()
for user in users:
if not await service.has_user_available_slots(user, club):
return await service.book(club, user, booking_filter), user
async def get_user_without_booking(club: Club, users: list[User]) -> User | None:
async def cancel_booking(club: Club, user: User, booking_filter: BookingFilter) -> None:
"""
Return the first user who has no booking
Cancel the booking that matches the specified filter
:param club: the club where to book
:param users: the list of users
:return: any user who has no booking
:param club: the club in which the booking was made
:param user: the user who made the booking
:param booking_filter: the conditions to meet to cancel the booking
"""
async with GestionSportsPlatform(club) as platform:
for user in users:
if await platform.user_has_no_ongoing_booking(user):
return user
return None
service = GestionSportsServices()
await service.cancel_booking(user, club, booking_filter)
def main() -> int | None:
async def get_tournaments(club: Club, user: User) -> list[Tournament]:
"""
Get the list of all current tournaments, their price, date and availability
:param club: the club in which the tournaments are
:param user: a user of the club in order to retrieve the information
"""
service = GestionSportsServices()
return await service.get_all_tournaments(user, club)
def main() -> tuple[Court, User] | list[Tournament] | None:
"""
Main function used to book a court
:return: the id of the booked court, or None if no court was booked
"""
booking_filter = config.get_booking_filter()
club = config.get_club()
user = asyncio.run(get_user_without_booking(club, config.get_available_users()))
action = config.get_action()
LOGGER.info(
"Starting booking court of sport %s for user %s at club %s at %s",
booking_filter.sport_id,
user.login,
club.id,
booking_filter.date,
)
court_booked = asyncio.run(book(club, user, booking_filter))
if court_booked:
if action == Action.BOOK:
club = config.get_club()
users = config.get_users(club.id)
booking_filter = config.get_booking_filter()
LOGGER.info(
"Court %s booked successfully at %s", court_booked, booking_filter.date
f"Booking a court of {booking_filter.sport_name} at {booking_filter.date} "
f"at club {club.name}"
)
else:
LOGGER.info("Booking did not work")
return court_booked
court_booked, user = asyncio.run(book_court(club, users, booking_filter))
if court_booked:
LOGGER.info(
f"Court of {booking_filter.sport_name} {court_booked} was booked "
f"successfully at {booking_filter.date} at club {club.name} "
f"for user {user}"
)
return court_booked, user
else:
LOGGER.info(
f"No court of {booking_filter.sport_name} at {booking_filter.date} "
f"at club {club.name} was booked"
)
elif action == Action.CANCEL:
user = config.get_user()
club = config.get_club()
booking_filter = config.get_booking_filter()
asyncio.run(cancel_booking(club, user, booking_filter))
elif action == Action.TOURNAMENTS:
user = config.get_user()
club = config.get_club()
return asyncio.run(get_tournaments(club, user))

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)
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").lower())

View file

@ -1,2 +1,6 @@
class ArgumentMissing(Exception):
class WrongResponseStatus(Exception):
pass
class MissingProperty(Exception):
pass

View file

@ -0,0 +1,345 @@
import asyncio
import json
import logging
from pathlib import Path
import config
from aiohttp import ClientResponse, ClientSession
from exceptions import WrongResponseStatus
from models import BookingFilter, Club, Sport, User
from payload_builders import PayloadBuilder
from pendulum import DateTime
LOGGER = logging.getLogger(__name__)
POST_HEADERS = config.get_post_headers("gestion-sports")
class GestionSportsConnector:
"""
The connector for the Gestion Sports platform handles all the HTTP requests to the
Gestion sports website.
"""
def __init__(self, club: Club):
if club is None:
raise ValueError("A connector cannot be instantiated without a club")
if club.booking_platform.id != "gestion-sports":
raise ValueError(
"Gestion Sports connector was instantiated with a club not handled"
" by gestions sports. Club id is {} instead of gestion-sports".format(
club.id
)
)
self.club = club
@property
def landing_url(self) -> str:
"""
Get the URL to for landing to the website
:return: the URL to landing
"""
return self.club.landing_url
@property
def login_url(self) -> str:
"""
Get the URL to for logging in the website
:return: the URL for logging in
"""
return self.club.login_url
@property
def login_template(self) -> Path:
"""
Get the payload template for logging in the website
:return: the payload template for logging
"""
return self.club.login_template
@property
def booking_url(self) -> str:
"""
Get the URL used to book a court
:return: the URL to book a court
"""
return self.club.booking_url
@property
def booking_template(self) -> Path:
"""
Get the payload template for booking a court
:return: the payload template for booking a court
"""
return self.club.booking_template
@property
def user_bookings_url(self) -> str:
"""
Get the URL of the bookings related to a user that are not yet passed
:return: the URL to get the bookings related to a user
"""
return self.club.user_bookings_url
@property
def user_bookings_template(self) -> Path:
"""
Get the payload template to get the bookings related to a user that are not yet
passed
:return: the template for requesting the bookings related to a user
"""
return self.club.user_bookings_template
@property
def cancel_url(self) -> str:
"""
Get the URL used to cancel a booking
:return: the URL to cancel a booking
"""
return self.club.cancel_url
@property
def cancel_template(self) -> Path:
"""
Get the payload template for cancelling a booking
:return: the template for cancelling a booking
"""
return self.club.cancel_template
@property
def sessions_url(self) -> str:
"""
Get the URL of the session containing all the tournaments
:return: the URL to get the session
"""
return self.club.sessions_url
@property
def sessions_template(self) -> Path:
"""
Get the payload template for requesting the session containing all the
tournaments
:return: the template for requesting the session
"""
return self.club.sessions_template
@property
def tournaments_url(self) -> str:
"""
Get the URL of all the tournaments list
:return: the URL to get the tournaments list
"""
return self.club.tournaments_url
@property
def sports(self) -> dict[str, Sport]:
"""
Get a dictionary of all sports, the key is the sport name lowered case
:return: the dictionary of all sports
"""
return self.club.sports
@staticmethod
def check_response_status(response_status: int) -> None:
if response_status != 200:
raise WrongResponseStatus("GestionSports request failed")
async def land(self, session: ClientSession) -> ClientResponse:
"""
Perform the request to the landing page in order to get the cookie PHPSESSIONID
:param session: the client session shared among all connections
:return: the response from the landing page
"""
LOGGER.info("Connecting to GestionSports API at %s", self.login_url)
async with session.get(self.landing_url) as response:
self.check_response_status(response.status)
await response.text()
return response
async def login(self, session: ClientSession, user: User) -> ClientResponse:
"""
Perform the request to the log in the user
:param session: the client session shared among all connections
:param user: the user to log in
:return: the response from the login
"""
LOGGER.info("Logging in to GestionSports API at %s", self.login_url)
payload = PayloadBuilder.build(self.login_template, user=user, club=self.club)
async with session.post(
self.login_url, data=payload, headers=POST_HEADERS, allow_redirects=False
) as response:
self.check_response_status(response.status)
resp_text = await response.text()
LOGGER.debug("Connexion request response:\n%s", resp_text)
return response
async def send_all_booking_requests(
self, session: ClientSession, booking_filter: BookingFilter
) -> list[tuple[int, dict]]:
"""
Perform a request for each court at the same time to increase the chances to get
a booking.
The gestion-sports backend does not allow several bookings at the same time
so there is no need to make each request one after the other
:param session: the client session shared among all connections
:param booking_filter: the booking conditions to meet
:return: the booked court, or None if no court was booked
"""
LOGGER.info(
"Booking any available court from GestionSports API at %s", self.booking_url
)
sport = self.sports.get(booking_filter.sport_name)
bookings = await asyncio.gather(
*[
self.send_booking_request(
session, booking_filter.date, court.id, sport.id
)
for court in sport.courts
],
return_exceptions=True,
)
LOGGER.debug("Booking results:\n'%s'", bookings)
return bookings
async def send_booking_request(
self,
session: ClientSession,
date: DateTime,
court_id: int,
sport_id: int,
) -> tuple[int, dict]:
"""
Book a single court that meets the conditions from the booking filter
:param session: the client session shared among all connections
:param date: the booking date
:param court_id: the id of the court to book
:param sport_id: the id of the sport
:return: a tuple containing the court id and the response
"""
LOGGER.debug("Booking court %s at %s", court_id, date.to_w3c_string())
payload = PayloadBuilder.build(
self.booking_template,
date=date,
court_id=court_id,
sport_id=sport_id,
)
LOGGER.debug("Booking court %s request:\n'%s'", court_id, payload)
async with session.post(
self.booking_url, data=payload, headers=POST_HEADERS
) as response:
self.check_response_status(response.status)
resp_json = json.loads(await response.text())
LOGGER.debug("Response from booking request:\n'%s'", resp_json)
return court_id, resp_json
async def send_hash_request(self, session: ClientSession) -> ClientResponse:
"""
Get the hash value used in some other requests
:param session: the client session shared among all connections
:return: the value of the hash
"""
async with session.get(self.user_bookings_url) as response:
self.check_response_status(response.status)
html = await response.text()
LOGGER.debug("Get bookings response: %s\n", html)
return response
async def send_user_bookings_request(
self, session: ClientSession, hash_value: str
) -> ClientResponse:
"""
Send a request to the platform to get all bookings of a user
:param session: the client session shared among all connections
:param hash_value: the hash value to put in the payload
:return: a dictionary containing all the bookings
"""
payload = PayloadBuilder.build(self.user_bookings_template, hash=hash_value)
async with session.post(
self.user_bookings_url, data=payload, headers=POST_HEADERS
) as response:
self.check_response_status(response.status)
await response.text()
return response
async def send_cancellation_request(
self, session: ClientSession, booking_id: int, hash_value: str
) -> ClientResponse:
"""
Send the HTTP request to cancel the booking
:param session: the client session shared among all connections
:param booking_id: the id of the booking to cancel
:return: the response from the client
"""
payload = PayloadBuilder.build(
self.cancel_template,
booking_id=booking_id,
hash=hash_value,
)
async with session.post(
self.cancel_url, data=payload, headers=POST_HEADERS
) as response:
self.check_response_status(response.status)
await response.text()
return response
async def send_session_request(self, session: ClientSession) -> ClientResponse:
"""
Send a request to the platform to get the session id
:param session: the client session shared among all connections
:return: a client response containing HTML which has the session id
"""
payload = self.sessions_template.read_text()
async with session.post(
self.sessions_url, data=payload, headers=POST_HEADERS
) as response:
self.check_response_status(response.status)
LOGGER.debug("tournament sessions: \n%s", await response.text())
return response
async def send_tournaments_request(
self, session: ClientSession, session_id: str
) -> ClientResponse:
"""
Send a request to the platform to get the next tournaments
:param session: the client session shared among all connections
:param session_id: the tournaments are grouped in a session
:return: a client response containing the list of all the nex tournaments
"""
final_url = self.tournaments_url + session_id
LOGGER.debug("Getting tournaments list at %s", final_url)
async with session.get(final_url) as response:
self.check_response_status(response.status)
LOGGER.debug("tournaments: %s\n", await response.text())
return response

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,300 @@
import json
import logging
import time
import pendulum
from aiohttp import ClientSession
from bs4 import BeautifulSoup
from gestion_sport_connector import GestionSportsConnector
from models import (
Booking,
BookingFilter,
BookingOpening,
Club,
Court,
Sport,
Tournament,
User,
)
from pendulum import DateTime
LOGGER = logging.getLogger(__name__)
class GestionSportsServices:
async def book(
self, club: Club, user: User, booking_filter: BookingFilter
) -> Court | None:
"""
Perform a request for each court at the same time to increase the chances to get
a booking.
The gestion-sports backend does not allow several bookings at the same time
so there is no need to make each request one after the other
:param club: the club in which the booking will be made
:param user: the user that wants to book the court
:param booking_filter: the booking conditions to meet
:return: the booked court, or None if no court was booked
"""
connector = GestionSportsConnector(club)
LOGGER.info(
"Booking any available court from GestionSports API at %s",
connector.booking_url,
)
async with ClientSession() as session:
# use asyncio to request a booking on every court
# the gestion-sports backend is able to book only one court for a user
await connector.land(session)
await connector.login(session, user)
booking_opening = club.booking_platform.booking_opening
GestionSportsServices.wait_until_booking_time(
booking_filter, booking_opening
)
bookings = await connector.send_all_booking_requests(
session, booking_filter
)
LOGGER.debug("Booking results:\n'%s'", bookings)
sport = club.sports.get(booking_filter.sport_name)
return self.get_booked_court(bookings, sport)
def get_booked_court(
self, bookings: list[tuple[int, dict]], sport: Sport
) -> Court | None:
"""
Parse the booking list and return the court that was booked
:param bookings: a list of bookings
:param sport: the sport of the club and all the courts it has
:return: the id of the booked court if any, None otherwise
"""
for court_id, response in bookings:
if self.is_booking_response_status_ok(response):
LOGGER.debug("Court %d is booked", court_id)
court_booked = self.find_court(court_id, sport)
LOGGER.info("Court '%s' is booked", court_booked.name)
return court_booked
LOGGER.debug("No booked court found")
return None
@staticmethod
def is_booking_response_status_ok(response: dict) -> bool:
"""
Check if the booking response is OK
:param response: the response as a string
:return: true if the status is ok, false otherwise
"""
return response["status"] == "ok"
@staticmethod
def find_court(court_id: int, sport: Sport) -> Court:
"""
Get all the court information based on the court id and the sport name
:param court_id: the court id
:param sport: the sport
:return: the court that has the given id and sport name
"""
for court in sport.courts:
if court.id == court_id:
return court
async def has_user_available_slots(self, user: User, club: Club) -> bool:
"""
Checks if a user has available booking slot.
If a user already has an ongoing booking, it is considered as no slot is
available
:param user: The user to check the booking availability
:param club: The club of the user
:return: True if the user has no ongoing booking, False otherwise
"""
connector = GestionSportsConnector(club)
async with ClientSession() as session:
await connector.land(session)
await connector.login(session, user)
bookings = await self.get_ongoing_bookings(session, connector)
return bool(bookings)
@staticmethod
def wait_until_booking_time(
booking_filter: BookingFilter, booking_opening: BookingOpening
) -> None:
"""
Wait until the booking is open.
The booking filter contains the date and time of the booking.
The club has the information about when the booking is open for that date.
:param booking_opening:
:param booking_filter: the booking information
"""
LOGGER.info("Waiting for booking time")
booking_datetime = GestionSportsServices.build_booking_datetime(
booking_filter, booking_opening
)
now = pendulum.now()
duration_until_booking = booking_datetime - now
LOGGER.debug(f"Current time: {now}, Datetime to book: {booking_datetime}")
LOGGER.debug(
f"Time to wait before booking: {duration_until_booking.hours:0>2}"
f":{duration_until_booking.minutes:0>2}"
f":{duration_until_booking.seconds:0>2}"
)
while now < booking_datetime:
time.sleep(1)
now = pendulum.now()
LOGGER.info("It's booking time!")
@staticmethod
def build_booking_datetime(
booking_filter: BookingFilter, booking_opening: BookingOpening
) -> DateTime:
"""
Build the date and time when the booking is open for a given match date.
The booking filter contains the date and time of the booking.
The club has the information about when the booking is open for that date.
:param booking_opening:the booking opening conditions
:param booking_filter: the booking information
:return: the date and time when the booking is open
"""
date_to_book = booking_filter.date
booking_date = date_to_book.subtract(days=booking_opening.days_before)
opening_time = pendulum.parse(booking_opening.opening_time)
booking_hour = opening_time.hour
booking_minute = opening_time.minute
return booking_date.at(booking_hour, booking_minute)
async def cancel_booking(
self, user: User, club: Club, booking_filter: BookingFilter
):
connector = GestionSportsConnector(club)
async with ClientSession() as session:
await connector.land(session)
await connector.login(session, user)
bookings = await self.get_ongoing_bookings(session, connector)
for booking in bookings:
if booking.matches(booking_filter):
return await self.cancel_booking_id(session, connector, booking.id)
async def get_ongoing_bookings(
self, session: ClientSession, connector: GestionSportsConnector
) -> list[Booking]:
"""
Get the list of all ongoing bookings of a user.
The steps to perform this are to get the user's bookings page and get a hidden
property in the HTML to get a hash that will be used in the payload of the
POST request (sic) to get the user's bookings.
Gestion sports is really a mess!!
:param session: the client session shared among all connections
:param connector: the connector used to send the requests
:return: the list of all ongoing bookings of a user
"""
response = await connector.send_hash_request(session)
hash_value = self.get_hash_input(await response.text())
LOGGER.debug(f"Hash value: {hash_value}")
response = await connector.send_user_bookings_request(session, hash_value)
return [Booking(**booking) for booking in json.loads(await response.text())]
@staticmethod
def get_hash_input(html_doc: str) -> str:
"""
There is a secret hash generated by Gestion sports that is reused when trying to get
users bookings. This hash is stored in a hidden input with name "mesresas-hash"
:param html_doc: the html document when getting the page mes-resas.html
:return: the value of the hash in the page
"""
soup = BeautifulSoup(html_doc, "html.parser")
inputs = soup.find_all("input")
for input_tag in inputs:
if input_tag.get("name") == "mesresas-hash":
return input_tag.get("value").strip()
async def cancel_booking_id(
self, session: ClientSession, connector: GestionSportsConnector, booking_id: int
) -> None:
"""
Send the HTTP request to cancel the booking
:param session: the client session shared among all connections
:param connector: the connector used to send the requests
:param booking_id: the id of the booking to cancel
:return: the response from the client
"""
response = await connector.send_hash_request(session)
hash_value = self.get_hash_input(await response.text())
LOGGER.debug(f"Hash value: {hash_value}")
await connector.send_cancellation_request(session, booking_id, hash_value)
@staticmethod
async def get_all_tournaments(user: User, club: Club) -> list[Tournament]:
connector = GestionSportsConnector(club)
async with ClientSession() as session:
await connector.land(session)
await connector.login(session, user)
session_html = await connector.send_session_request(session)
tournaments_id = GestionSportsServices.retrieve_tournament_session(
await session_html.text()
)
tournaments = await connector.send_tournaments_request(
session, tournaments_id
)
return GestionSportsServices.retrieve_tournaments(await tournaments.text())
@staticmethod
def retrieve_tournament_session(sessions: str) -> str:
session_object = json.loads(sessions).get("Inscription tournois:school-outline")
return list(session_object.keys())[0]
@staticmethod
def retrieve_tournaments(html: str) -> list[Tournament]:
soup = BeautifulSoup(html, "html.parser")
tournaments = []
cards = soup.find_all("div", {"class": "card-body"})
for card in cards:
title = card.find("h5")
price = title.find("span").get_text().strip()
name = title.get_text().strip().removesuffix(price).strip()
elements = card.find("div", {"class": "row"}).find_all("li")
date = elements[0].get_text().strip()
start_time, end_time = (
elements[2].get_text().strip().replace("h", ":").split(" - ")
)
start_datetime = pendulum.from_format(
f"{date} {start_time}", "DD/MM/YYYY HH:mm"
)
end_datetime = pendulum.from_format(
f"{date} {end_time}", "DD/MM/YYYY HH:mm"
)
gender = elements[1].get_text().strip()
places_left = (
card.find("span", {"class": "nb_place_libre"}).get_text().strip()
)
tournament = Tournament(
name=name,
price=price,
start_date=start_datetime,
end_date=end_datetime,
gender=gender,
places_left=places_left,
)
tournaments.append(tournament)
return tournaments

View file

@ -1,22 +1,490 @@
from pendulum import Time
from pydantic import BaseModel, ConfigDict, Field
from enum import Enum
from pathlib import Path
from typing import Optional
from urllib.parse import urljoin
import config
import pendulum
from exceptions import MissingProperty
from pendulum import Date, Time
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic_extra_types.pendulum_dt import DateTime
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
parameter: Optional[str] = Field(default=None)
payload_template: Optional[str] = Field(default=None, alias="payloadTemplate")
class BookingPlatform(BaseModel):
id: str
club_id: int = Field(alias="clubId")
url: str
hours_before_cancellation: int = Field(alias="hoursBeforeCancellation")
booking_opening: BookingOpening = Field(alias="bookingOpening")
total_bookings: TotalBookings = Field(alias="totalBookings")
sports: list[Sport]
urls: dict[str, Url]
def get_url_path(self, name: str) -> str:
"""
Get the URL path for the service with the given name
:param name: the name of the service
:return: the URL path
"""
self.check_url_path_exists(name)
return urljoin(self.url, self.urls.get(name).path)
def get_payload_template(self, name: str) -> Path:
"""
Get the path to the template file for the service with the given name
:param name: the name of the service
:return: the path to the template file
"""
self.check_payload_template_exists(name)
return config.get_resources_folder() / self.urls.get(name).payload_template
def get_url_parameter(self, name: str) -> str:
self.check_url_path_exists(name)
return self.urls.get(name).parameter
def check_url_path_exists(self, name: str) -> None:
"""
Check that the URL path for the given service is defined
:param name: the name of the service
"""
if (
self.urls is None
or self.urls.get(name) is None
or self.urls.get(name).path is None
):
raise MissingProperty(
f"The booking platform internal URL path for page {name} are not set"
)
def check_payload_template_exists(self, name: str) -> None:
"""
Check that the payload template for the given service is defined
:param name: the name of the service
"""
if (
self.urls is None
or self.urls.get(name) is None
or self.urls.get(name).path is None
):
raise ValueError(
f"The booking platform internal URL path for page {name} are not set"
)
@property
def landing_url(self) -> str:
"""
Get the URL to the landing page of Gestion-Sports
:return: the URL to the landing page
"""
return self.get_url_path("landing-page")
@property
def login_url(self) -> str:
"""
Get the URL to the connection login of Gestion-Sports
:return: the URL to the login page
"""
return self.get_url_path("login")
@property
def login_template(self) -> Path:
"""
Get the payload template to send to log in the website
:return: the payload template for logging in
"""
return self.get_payload_template("login")
@property
def booking_url(self) -> str:
"""
Get the URL to the booking page of Gestion-Sports
:return: the URL to the booking page
"""
return self.get_url_path("booking")
@property
def booking_template(self) -> Path:
"""
Get the payload template to send to book a court
:return: the payload template for booking a court
"""
return self.get_payload_template("booking")
@property
def user_bookings_url(self) -> str:
"""
Get the URL where all the user's bookings are available
:return: the URL to the user's bookings
"""
return self.get_url_path("user-bookings")
@property
def user_bookings_template(self) -> Path:
"""
Get the payload template to send to get all the user's bookings that are
available
:return: the payload template for the user's bookings
"""
return self.get_payload_template("user-bookings")
@property
def booking_cancellation_url(self) -> str:
"""
Get the URL where all the user's bookings are available
:return: the URL to the user's bookings
"""
return self.get_url_path("cancellation")
@property
def booking_cancel_template(self) -> Path:
"""
Get the payload template to send to get all the user's bookings that are
available
:return: the payload template for the user's bookings
"""
return self.get_payload_template("cancellation")
@property
def tournaments_sessions_url(self) -> str:
return self.get_url_path("tournament-sessions")
@property
def tournaments_sessions_template(self) -> Path:
return self.get_payload_template("tournament-sessions")
@property
def tournaments_list_url(self) -> str:
return self.get_url_path("tournaments-list")
@property
def available_sports(self) -> dict[str, Sport]:
"""
Get a dictionary of all sports, the key is the sport name lowered case
:return: the dictionary of all sports
"""
return {sport.name.lower(): sport for sport in self.sports}
class Club(BaseModel):
id: str
name: str
url: str
booking_platform: BookingPlatform = Field(alias="bookingPlatform")
@property
def landing_url(self) -> str:
"""
Get the URL to the landing page of Gestion-Sports
:return: the URL to the landing page
"""
return self.booking_platform.landing_url
@property
def login_url(self) -> str:
"""
Get the URL to the connection login of Gestion-Sports
:return: the URL to the login page
"""
return self.booking_platform.login_url
@property
def login_template(self) -> Path:
"""
Get the payload template to send to log in the website
:return: the payload template for logging in
"""
return self.booking_platform.login_template
@property
def booking_url(self) -> str:
"""
Get the URL to the booking page of Gestion-Sports
:return: the URL to the booking page
"""
return self.booking_platform.booking_url
@property
def booking_template(self) -> Path:
"""
Get the payload template to send to book a court
:return: the payload template for booking a court
"""
return self.booking_platform.booking_template
@property
def user_bookings_url(self) -> str:
"""
Get the URL where all the user's bookings are available
:return: the URL to the user's bookings
"""
return self.booking_platform.user_bookings_url
@property
def user_bookings_template(self) -> Path:
"""
Get the payload template to send to get all the user's bookings that are
available
:return: the payload template for the user's bookings
"""
return self.booking_platform.user_bookings_template
@property
def cancel_url(self) -> str:
"""
Get the URL where all the user's bookings are available
:return: the URL to the user's bookings
"""
return self.booking_platform.booking_cancellation_url
@property
def cancel_template(self) -> Path:
"""
Get the payload template to send to get all the user's bookings that are
available
:return: the payload template for the user's bookings
"""
return self.booking_platform.booking_cancel_template
@property
def sessions_url(self) -> str:
return self.booking_platform.tournaments_sessions_url
@property
def sessions_template(self) -> Path:
return self.booking_platform.tournaments_sessions_template
@property
def tournaments_url(self) -> str:
return self.booking_platform.tournaments_list_url
@property
def sports(self) -> dict[str, Sport]:
"""
Get a dictionary of all sports, the key is the sport name lowered case
:return: the dictionary of all sports
"""
return self.booking_platform.available_sports
class PlatformDefinition(BaseModel):
id: str
name: str
url: str
urls: list[Url]
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"
TOURNAMENTS = "tournaments"
class Tournament(BaseModel):
name: str
price: str
start_date: DateTime
end_date: DateTime
gender: str
places_left: str | int

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

@ -1,12 +1,16 @@
{
"Connection": "keep-alive",
"Accept": "application/json, text/javascript, */*; q=0.01",
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate, br",
"DNT": "1",
"Cache-Control": "no-cache",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"Accept": "application/json, text/javascript, */*; q=0.01",
"X-Requested-With": "XMLHttpRequest",
"Connection": "keep-alive",
"DNT": "1",
"Origin": "https://toulousepadelclub.gestion-sports.com",
"Pragma": "no-cache",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin"
"Sec-Fetch-Site": "same-origin",
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:123.0) Gecko/20100101 Firefox/123.0",
"X-Requested-With": "XMLHttpRequest"
}

View file

@ -0,0 +1 @@
ajax=loadSessionForSpecDay&date=all

View file

@ -0,0 +1,24 @@
platforms:
- name: Gestion sports
url: https://gestion-sports.fr/
id: gestion-sports
urls:
- name: landing-page
path: /connexion.php
- name: login
path: /connexion.php
payloadTemplate: gestion-sports/login-payload.txt
- name: booking
path: /membre/reservation.html
payloadTemplate: gestion-sports/booking-payload.txt
- name: user-bookings
path: /membre/mesresas.html
payloadTemplate: gestion-sports/user-bookings-payload.txt
- name: cancellation
path: /membre/mesresas.html
payloadTemplate: gestion-sports/booking-cancellation-payload.txt
- name: tournament-sessions
path: /membre/index.php
payloadTemplate: gestion-sports/tournament-sessions-payload.txt
- name: tournaments-list
path: /membre/events/event.html?event=

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,24 @@
platforms:
- name: flapi
url: https://flapi.fr/
id: gestion-sports
urls:
- name: landing-page
path: /landing.html
- name: login
path: /login.html
payloadTemplate: gestion-sports/login-payload.txt
- name: booking
path: /booking.html
payloadTemplate: gestion-sports/booking-payload.txt
- name: user-bookings
path: /user_bookings.html
payloadTemplate: gestion-sports/user-bookings-payload.txt
- name: cancellation
path: /cancel.html
payloadTemplate: sports/booking-cancellation-payload.txt
- name: tournament-sessions
path: /membre/index.php
payloadTemplate: sports/tournament-sessions-payload.txt
- name: tournament-list
path: /membre/events/event.html?event=

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
}
}

View file

@ -0,0 +1,21 @@
{
"Inscription tournois:school-outline": {
"1174": {
"id": 1174,
"sport": "padel",
"clubName": "toulouse padel club",
"nom": "Tournoi",
"dateNextSession": "25\/03\/2024",
"dateDebut": "01\/08\/2022",
"dateFin": "01\/10\/2024",
"logo": "TCP_Ligue_Arcanthe2-01-min.png",
"nbSession": 14,
"icon": "school-outline",
"playerCanSeeThisEvent": null,
"type": "tournoi",
"isJp": false,
"isCiup": false,
"sqlDate": "2024-03-25 13:30:00"
}
}
}

File diff suppressed because it is too large Load diff

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,52 @@
import json
from pathlib import Path
import config
import pendulum
import pytest
from gestion_sport_connector import GestionSportsConnector
from models import BookingFilter, Club, User
TEST_FOLDER = Path(__file__).parent.parent
DATA_FOLDER = TEST_FOLDER / "data"
RESPONSES_FOLDER = DATA_FOLDER / "responses"
@pytest.fixture
def club() -> Club:
return config.get_clubs()["tpc"]
@pytest.fixture
def connector(club) -> GestionSportsConnector:
return GestionSportsConnector(club)
@pytest.fixture
def user() -> User:
return User(login="padel.testing@jouf.fr", password="ridicule")
@pytest.fixture
def booking_filter() -> BookingFilter:
return BookingFilter(
sport_name="Padel", date=pendulum.parse("2024-03-21T13:30:00+01:00")
)
@pytest.fixture
def booking_success_response() -> dict:
booking_success_file = RESPONSES_FOLDER / "booking-success.json"
return json.loads(booking_success_file.read_text(encoding="utf-8"))
@pytest.fixture
def booking_failure_response() -> dict:
file = RESPONSES_FOLDER / "booking-failure.json"
return json.loads(file.read_text(encoding="utf-8"))
@pytest.fixture
def tournament_sessions_json() -> str:
file = RESPONSES_FOLDER / "tournament-sessions.json"
return file.read_text(encoding="utf-8")

View file

@ -0,0 +1,74 @@
import asyncio
import os
from unittest.mock import patch
from resa_padel import booking
@patch.dict(
os.environ,
{"CLUB_ID": "tpc"},
clear=True,
)
def test_booking(club, user, booking_filter):
booked_court, user_that_booked = asyncio.run(
booking.book_court(club, [user], booking_filter)
)
assert booked_court is not None
assert user_that_booked == user
@patch.dict(
os.environ,
{"CLUB_ID": "tpc"},
clear=True,
)
def test_cancellation(club, user, booking_filter):
asyncio.run(booking.cancel_booking(club, user, booking_filter))
@patch.dict(
os.environ,
{
"CLUB_ID": "tpc",
"ACTION": "book",
"SPORT_NAME": "Padel",
"DATE_TIME": "2024-03-28T13:30:00+01:00",
},
clear=True,
)
def test_main_booking():
court, player = booking.main()
assert court is not None
assert player.login == "padel.testing@jouf.fr"
@patch.dict(
os.environ,
{
"CLUB_ID": "tpc",
"ACTION": "cancel",
"SPORT_NAME": "Padel",
"DATE_TIME": "2024-03-28T13:30:00+01:00",
"LOGIN": "padel.testing@jouf.fr",
"PASSWORD": "ridicule",
},
clear=True,
)
def test_main_cancellation():
booking.main()
@patch.dict(
os.environ,
{
"CLUB_ID": "tpc",
"ACTION": "tournaments",
"LOGIN": "padel.testing@jouf.fr",
"PASSWORD": "ridicule",
},
clear=True,
)
def test_main_tournaments():
tournaments = booking.main()
assert len(tournaments) != 0

View file

@ -0,0 +1,238 @@
import json
import os
from pathlib import Path
from unittest.mock import patch
import pendulum
import pytest
from aiohttp import ClientSession
from gestion_sport_connector import GestionSportsConnector
from yarl import URL
@patch.dict(
os.environ,
{"CLUB_ID": "tpc"},
clear=True,
)
def test_urls(connector):
assert (
connector.landing_url
== "https://toulousepadelclub.gestion-sports.com/connexion.php"
)
assert (
connector.login_url
== "https://toulousepadelclub.gestion-sports.com/connexion.php"
)
assert (
connector.booking_url
== "https://toulousepadelclub.gestion-sports.com/membre/reservation.html"
)
assert (
connector.user_bookings_url
== "https://toulousepadelclub.gestion-sports.com/membre/mesresas.html"
)
assert (
connector.cancel_url
== "https://toulousepadelclub.gestion-sports.com/membre/mesresas.html"
)
assert (
connector.sessions_url
== "https://toulousepadelclub.gestion-sports.com/membre/index.php"
)
@patch.dict(
os.environ,
{"RESOURCES_FOLDER": "/some/path"},
clear=True,
)
def test_urls_payload_templates(connector):
resources_folder = Path("/some", "path", "gestion-sports")
assert connector.login_template == resources_folder / "login-payload.txt"
assert connector.booking_template == resources_folder / "booking-payload.txt"
assert (
connector.user_bookings_template
== resources_folder / "user-bookings-payload.txt"
)
assert (
connector.cancel_template
== resources_folder / "booking-cancellation-payload.txt"
)
assert (
connector.sessions_template
== resources_folder / "tournament-sessions-payload.txt"
)
@pytest.mark.asyncio
async def test_landing_page(connector):
async with ClientSession() as session:
response = await connector.land(session)
assert response.status == 200
assert response.request_info.method == "GET"
assert response.content_type == "text/html"
assert response.request_info.url == URL(connector.landing_url)
assert response.charset == "UTF-8"
assert response.cookies.get("PHPSESSID") is not None
@pytest.mark.asyncio
async def test_login(connector, user):
async with ClientSession() as session:
await connector.land(session)
response = await connector.login(session, user)
assert response.status == 200
assert response.request_info.method == "POST"
assert response.content_type == "text/html"
assert response.request_info.url == URL(connector.landing_url)
assert response.charset == "UTF-8"
assert response.cookies.get("COOK_COMPTE") is not None
assert response.cookies.get("COOK_ID_CLUB").value == "88"
assert response.cookies.get("COOK_ID_USER").value == "232382"
def test_get_booked_court(
connector, booking_success_response, booking_failure_response
):
bookings = [
(601, booking_failure_response),
(602, booking_failure_response),
(603, booking_failure_response),
(614, booking_failure_response),
(605, booking_failure_response),
(606, booking_success_response),
(607, booking_failure_response),
(608, booking_failure_response),
]
court = connector.get_booked_court(bookings, "padel")
assert court.number == 9
@pytest.mark.asyncio
async def test_book_one_court(connector, user, booking_filter):
async with ClientSession() as session:
await connector.land(session)
await connector.login(session, user)
court_id, response = await connector.send_booking_request(
session, pendulum.parse("2024-03-21T13:30:00+01:00"), 610, 217
)
assert court_id == 610
assert response.get("status") == "ok"
@pytest.mark.asyncio
async def test_book(connector, user, booking_filter):
booked_court = await connector.book(user, booking_filter)
assert booked_court is not None
def test_build_booking_datetime(connector, booking_filter):
opening_datetime = connector.build_booking_datetime(booking_filter)
assert opening_datetime.year == 2024
assert opening_datetime.month == 3
assert opening_datetime.day == 14
assert opening_datetime.hour == 0
assert opening_datetime.minute == 0
@pytest.mark.asyncio
async def test_get_hash(connector, user):
async with ClientSession() as session:
await connector.land(session)
await connector.login(session, user)
hash_value = await connector.send_hash_request(session)
assert hash_value is not None
def test_get_hash_input():
resources_folder = Path(__file__).parent / "data"
html_file = resources_folder / "user_bookings_html_response.html"
html = html_file.read_text(encoding="utf-8")
hash_value = GestionSportsConnector.get_hash_input(html)
assert hash_value == "63470fa38e300fd503de1ee21a71b3bdb6fb206b"
@pytest.mark.asyncio
async def test_get_bookings(connector, user):
async with ClientSession() as session:
await connector.land(session)
await connector.login(session, user)
hash_value = await connector.send_hash_request(session)
payload = f"ajax=loadResa&hash={hash_value}"
bookings = await connector.send_user_bookings_request(session, payload)
print(bookings)
@pytest.mark.asyncio
async def test_get_ongoing_bookings(connector, user):
async with ClientSession() as session:
await connector.land(session)
await connector.login(session, user)
bookings = await connector.get_ongoing_bookings(session)
print(bookings)
@pytest.mark.asyncio
async def test_cancel_booking_id(connector, user):
async with ClientSession() as session:
await connector.land(session)
await connector.login(session, user)
await connector.send_cancellation_request(session, 666)
assert len(await connector.get_ongoing_bookings(session)) == 0
@pytest.mark.asyncio
def test_find_court(connector):
court = connector.find_court(603, "Padel")
assert court.number == 6
@pytest.mark.asyncio
async def test_cancel_booking(connector, user, booking_filter):
async with ClientSession() as session:
await connector.land(session)
await connector.login(session, user)
await connector.cancel_booking(session, booking_filter)
@pytest.mark.asyncio
async def test_tournament_sessions(connector, user):
async with ClientSession() as session:
await connector.land(session)
await connector.login(session, user)
response = await connector.send_session_request(session)
assert response.status == 200
all_sessions = json.loads(await response.text())
sessions = all_sessions.get("Inscription tournois:school-outline")
assert len(sessions) == 1
@pytest.mark.asyncio
async def test_send_tournaments_request(connector, user):
async with ClientSession() as session:
await connector.land(session)
await connector.login(session, user)
tournament_session_id = "1174"
response = await connector.send_tournaments_request(
session, tournament_session_id
)
assert "<span class='nb_place_libre'>Complet</span>" in await response.text()

View file

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

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,427 @@
import json
from pathlib import Path
import pendulum
import pytest
from gestion_sport_connector import GestionSportsConnector
from gestion_sports_services import GestionSportsServices
from models import (
BookingFilter,
BookingOpening,
BookingPlatform,
Club,
Court,
Sport,
TotalBookings,
Url,
User,
)
TEST_FOLDER = Path(__file__).parent.parent
DATA_FOLDER = TEST_FOLDER / "data"
RESPONSES_FOLDER = DATA_FOLDER / "responses"
@pytest.fixture
def court11() -> Court:
return Court(id="1", name="Court 1", number=1, isIndoor=True)
@pytest.fixture
def court12() -> Court:
return Court(id="2", name="Court 2", number=2, isIndoor=False)
@pytest.fixture
def court13() -> Court:
return Court(id="3", name="Court 3", number=3, isIndoor=True)
@pytest.fixture
def court14() -> Court:
return Court(id="4", name="Court 4", number=4, isIndoor=True)
@pytest.fixture
def sport1(court11: Court, court12: Court, court13: Court, court14: Court) -> Sport:
return Sport(
name="Sport1",
id=8,
duration=99,
price=54,
players=3,
courts=[court11, court12, court13, court14],
)
@pytest.fixture
def court21() -> Court:
return Court(id="1", name="Court 1", number=1, isIndoor=False)
@pytest.fixture
def court22() -> Court:
return Court(id="2", name="Court 2", number=2, isIndoor=True)
@pytest.fixture
def court23() -> Court:
return Court(id="3", name="Court 3", number=3, isIndoor=True)
@pytest.fixture
def court24() -> Court:
return Court(id="4", name="Court 4", number=4, isIndoor=True)
@pytest.fixture
def sport2(court21: Court, court22: Court, court23: Court, court24: Court) -> Sport:
return Sport(
name="Sport 2",
id=10,
duration=44,
price=23,
players=1,
courts=[court21, court22, court23, court24],
)
@pytest.fixture
def landing_url() -> Url:
return Url(
name="landing-page",
path="landing.html",
)
@pytest.fixture
def login_url() -> Url:
return Url(
name="login",
path="login.html",
payloadTemplate="gestion-sports/login-payload.txt",
)
@pytest.fixture
def booking_url() -> Url:
return Url(
name="booking",
path="booking.html",
payloadTemplate="gestion-sports/booking-payload.txt",
)
@pytest.fixture
def user_bookings_url() -> Url:
return Url(
name="user-bookings",
path="user_bookings.html",
payloadTemplate="gestion-sports/user-bookings-payload.txt",
)
@pytest.fixture
def cancellation_url() -> Url:
return Url(
name="cancellation",
path="cancel.html",
payloadTemplate="gestion-sports/booking-cancellation-payload.txt",
)
@pytest.fixture
def tournament_sessions_url() -> Url:
return Url(
name="tournament-sessions",
path="/tournaments_sessions.php",
payloadTemplate="gestion-sports/tournament-sessions-payload.txt",
)
@pytest.fixture
def tournaments_list_url() -> Url:
return Url(
name="tournaments-list",
path="/tournaments_list.html?event=",
)
@pytest.fixture
def booking_opening() -> BookingOpening:
return BookingOpening(daysBefore=7, time="00:00")
@pytest.fixture
def total_bookings() -> TotalBookings:
return TotalBookings(peakHours=3, offPeakHours="unlimited")
@pytest.fixture
def booking_platform(
booking_opening: BookingOpening,
total_bookings: TotalBookings,
sport1: Sport,
sport2: Sport,
landing_url: str,
login_url: str,
booking_url: str,
user_bookings_url: str,
cancellation_url: str,
tournament_sessions_url: str,
tournaments_list_url: str,
) -> BookingPlatform:
return BookingPlatform(
id="gestion-sports",
clubId=21,
url="https://ptf1.com",
hoursBeforeCancellation=7,
bookingOpening=booking_opening,
totalBookings=total_bookings,
sports=[sport1, sport2],
urls={
"landing-page": landing_url,
"login": login_url,
"booking": booking_url,
"user-bookings": user_bookings_url,
"cancellation": cancellation_url,
"tournament-sessions": tournament_sessions_url,
"tournaments-list": tournaments_list_url,
},
)
@pytest.fixture
def club(booking_platform: BookingPlatform) -> Club:
return Club(
id="super_club",
name="Super Club",
url="https://superclub.com",
bookingPlatform=booking_platform,
)
@pytest.fixture
def connector(club) -> GestionSportsConnector:
return GestionSportsConnector(club)
@pytest.fixture
def gs_services() -> GestionSportsServices:
return GestionSportsServices()
@pytest.fixture
def user() -> User:
return User(login="padel.testing@jouf.fr", password="ridicule")
@pytest.fixture
def booking_filter() -> BookingFilter:
return BookingFilter(
sport_name="Sport1", date=pendulum.parse("2024-03-21T13:30:00Z")
)
@pytest.fixture
def landing_response() -> str:
file = RESPONSES_FOLDER / "landing-response.html"
return file.read_text(encoding="utf-8")
@pytest.fixture
def login_success_response() -> dict:
login_success_file = RESPONSES_FOLDER / "login-success.json"
return json.loads(login_success_file.read_text(encoding="utf-8"))
@pytest.fixture
def login_failure_response() -> dict:
file = RESPONSES_FOLDER / "login-failure.json"
return json.loads(file.read_text(encoding="utf-8"))
@pytest.fixture
def booking_success_response() -> dict:
file = RESPONSES_FOLDER / "booking-success.json"
return json.loads(file.read_text(encoding="utf-8"))
@pytest.fixture
def booking_failure_response() -> dict:
file = RESPONSES_FOLDER / "booking-failure.json"
return json.loads(file.read_text(encoding="utf-8"))
@pytest.fixture
def booked_courts_response(
court11: int,
court12: int,
court13: int,
court14: int,
booking_success_response: dict,
booking_failure_response: dict,
) -> list[tuple[int, dict]]:
court1_resp = court11.id, booking_failure_response
court2_resp = court12.id, booking_failure_response
court3_resp = court13.id, booking_success_response
court4_resp = court14.id, booking_failure_response
return [court1_resp, court2_resp, court3_resp, court4_resp]
@pytest.fixture
def booking_success_from_start(
landing_response: str,
login_success_response: str,
booking_success_response: str,
booking_failure_response: str,
):
return [
landing_response,
login_success_response,
booking_failure_response,
booking_success_response,
booking_failure_response,
booking_failure_response,
]
@pytest.fixture
def booking_failure_from_start(
landing_response: str,
login_success_response: str,
booking_success_response: str,
booking_failure_response: str,
):
return [
landing_response,
login_success_response,
booking_failure_response,
booking_failure_response,
booking_failure_response,
booking_failure_response,
]
@pytest.fixture
def user_bookings_get_response() -> str:
file = RESPONSES_FOLDER / "user-bookings-get.html"
return file.read_text(encoding="utf-8")
@pytest.fixture
def user_bookings_list() -> str:
file = RESPONSES_FOLDER / "user-bookings-post.json"
return json.loads(file.read_text(encoding="utf-8"))
@pytest.fixture
def user_has_ongoing_bookings_from_start(
landing_response: str,
login_success_response: str,
user_bookings_get_response: str,
user_bookings_list: str,
) -> list:
return [
landing_response,
login_success_response,
user_bookings_get_response,
user_bookings_list,
]
@pytest.fixture
def user_bookings_empty_list() -> list:
return []
@pytest.fixture
def user_has_no_ongoing_bookings_from_start(
landing_response: str,
login_success_response: str,
user_bookings_get_response: str,
user_bookings_empty_list: str,
) -> list:
return [
landing_response,
login_success_response,
user_bookings_get_response,
user_bookings_empty_list,
]
@pytest.fixture
def cancellation_response() -> list:
file = RESPONSES_FOLDER / "cancellation-response.json"
return json.loads(file.read_text(encoding="utf-8"))
@pytest.fixture
def cancellation_by_id_from_start(
landing_response: str,
login_success_response: str,
user_bookings_get_response: str,
cancellation_response: str,
):
return [
landing_response,
login_success_response,
user_bookings_get_response,
cancellation_response,
]
@pytest.fixture
def cancellation_success_from_start(
landing_response: str,
login_success_response: str,
user_bookings_get_response: str,
user_bookings_list: str,
cancellation_response: str,
):
return [
landing_response,
login_success_response,
user_bookings_get_response,
user_bookings_list,
cancellation_response,
]
@pytest.fixture
def cancellation_success_booking_filter() -> BookingFilter:
return BookingFilter(
sport_name="Sport1", date=pendulum.parse("2024-03-21T13:30:00Z")
)
@pytest.fixture
def service() -> GestionSportsServices:
return GestionSportsServices()
@pytest.fixture
def tournament_sessions_json() -> str:
file = RESPONSES_FOLDER / "tournament-sessions.json"
return file.read_text(encoding="utf-8")
@pytest.fixture
def tournaments_html() -> str:
file = RESPONSES_FOLDER / "tournaments.html"
return file.read_text(encoding="utf-8")
@pytest.fixture
def full_tournaments_responses(
landing_response: str,
login_success_response: str,
tournament_sessions_json: str,
tournaments_html: str,
) -> list[str]:
return [
landing_response,
login_success_response,
tournament_sessions_json,
tournaments_html,
]

View file

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

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,135 @@
import json
from pathlib import Path
from unittest.mock import patch
import pytest
from aiohttp import ClientSession
from gestion_sport_connector import GestionSportsConnector
from tests.unit_tests import responses
def test_urls(connector, club):
base_url = "https://ptf1.com"
assert connector.landing_url == f"{base_url}/landing.html"
assert connector.login_url == f"{base_url}/login.html"
assert connector.booking_url == f"{base_url}/booking.html"
assert connector.user_bookings_url == f"{base_url}/user_bookings.html"
assert connector.cancel_url == f"{base_url}/cancel.html"
assert connector.sessions_url == f"{base_url}/tournaments_sessions.php"
assert connector.tournaments_url == f"{base_url}/tournaments_list.html?event="
@patch("config.get_resources_folder")
def test_urls_payload_templates(mock_resources, club):
path_to_resources = Path("some/path/to/resource")
mock_resources.return_value = path_to_resources
connector = GestionSportsConnector(club)
assert (
connector.login_template
== path_to_resources / "gestion-sports/login-payload.txt"
)
assert (
connector.booking_template
== path_to_resources / "gestion-sports/booking-payload.txt"
)
assert (
connector.user_bookings_template
== path_to_resources / "gestion-sports/user-bookings-payload.txt"
)
assert (
connector.cancel_template
== path_to_resources / "gestion-sports/booking-cancellation-payload.txt"
)
assert (
connector.sessions_template
== path_to_resources / "gestion-sports/tournament-sessions-payload.txt"
)
@pytest.mark.asyncio
async def test_landing_page(aioresponses, connector, landing_response):
responses.make_landing_request_success(aioresponses, connector, landing_response)
async with ClientSession() as session:
response = await connector.land(session)
assert response.status == 200
assert response.cookies.get("PHPSESSID").value == "987512"
assert await response.text() == landing_response
@pytest.mark.asyncio
async def test_login_success(aioresponses, connector, user, login_success_response):
responses.make_login_request_success(
aioresponses, connector, login_success_response
)
async with ClientSession() as session:
response = await connector.login(session, user)
assert response.status == 200
assert response.cookies.get("COOK_COMPTE").value == "e2be1"
assert response.cookies.get("COOK_ID_CLUB").value == "22"
assert response.cookies.get("COOK_ID_USER").value == "666"
assert await response.json() == login_success_response
@pytest.mark.asyncio
async def test_login_failure(aioresponses, connector, user, login_failure_response):
responses.make_login_request_fail(aioresponses, connector, login_failure_response)
async with ClientSession() as session:
response = await connector.login(session, user)
assert response.status == 200
assert len(response.cookies) == 0
assert await response.json() == login_failure_response
@pytest.mark.asyncio
async def test_cancellation_request(
aioresponses, connector, user_bookings_get_response, cancellation_response
):
responses.set_hash_response(aioresponses, connector, user_bookings_get_response)
responses.set_cancellation_response(aioresponses, connector, cancellation_response)
async with ClientSession() as session:
response = await connector.send_cancellation_request(session, 123, "hash")
assert await response.json() == cancellation_response
@pytest.mark.asyncio
async def test_tournament_sessions(
aioresponses, connector, user, tournament_sessions_json
):
responses.set_tournaments_sessions_response(
aioresponses, connector, tournament_sessions_json
)
async with ClientSession() as session:
response = await connector.send_session_request(session)
assert response.status == 200
all_sessions = json.loads(await response.text())
sessions = all_sessions.get("Inscription tournois:school-outline")
assert len(sessions) == 1
@pytest.mark.asyncio
async def test_send_tournaments_request(
aioresponses, connector, user, tournaments_html
):
tournament_session_id = "255"
responses.set_tournaments_list_response(
aioresponses, connector, tournament_session_id, tournaments_html
)
async with ClientSession() as session:
response = await connector.send_tournaments_request(
session, tournament_session_id
)
assert "<span class='nb_place_libre'>Complet</span>" in await response.text()

View file

@ -0,0 +1,166 @@
from unittest.mock import patch
import pendulum
import pytest
from gestion_sports_services import GestionSportsServices
from models import BookingFilter, BookingOpening
from tests.unit_tests import responses
@pytest.mark.asyncio
async def test_booking_success(
aioresponses,
gs_services,
connector,
club,
user,
booking_filter,
booking_success_from_start,
):
responses.set_full_booking_requests_responses(
aioresponses, connector, booking_success_from_start
)
court_booked = await gs_services.book(club, user, booking_filter)
assert court_booked.id == 2
@pytest.mark.asyncio
async def test_booking_failure(
aioresponses,
gs_services,
connector,
club,
user,
booking_filter,
booking_failure_from_start,
):
responses.set_full_booking_requests_responses(
aioresponses, connector, booking_failure_from_start
)
court_booked = await gs_services.book(club, user, booking_filter)
assert court_booked is None
def test_get_booked_court(gs_services, booked_courts_response, sport1):
booked_court = gs_services.get_booked_court(booked_courts_response, sport1)
assert booked_court.number == 3
@pytest.mark.asyncio
async def test_user_has_available_booking_slots(
aioresponses,
gs_services,
connector,
user,
club,
user_has_ongoing_bookings_from_start,
):
responses.set_full_user_bookings_responses(
aioresponses, connector, user_has_ongoing_bookings_from_start
)
has_user_available_slots = await gs_services.has_user_available_slots(user, club)
assert has_user_available_slots
@pytest.mark.asyncio
async def test_user_has_no_available_booking_slots(
aioresponses,
gs_services,
connector,
user,
club,
user_has_no_ongoing_bookings_from_start,
):
responses.set_full_user_bookings_responses(
aioresponses, connector, user_has_no_ongoing_bookings_from_start
)
has_user_available_slots = await gs_services.has_user_available_slots(user, club)
assert not has_user_available_slots
@pytest.mark.asyncio
async def test_cancel_booking(
aioresponses,
gs_services,
connector,
user,
club,
booking_filter,
cancellation_success_from_start,
):
responses.set_full_cancellation_responses(
aioresponses, connector, cancellation_success_from_start
)
await gs_services.cancel_booking(user, club, booking_filter)
@patch("pendulum.now")
def test_wait_until_booking_time(mock_now, club, user):
booking_filter = BookingFilter(
sport_name="Sport1", date=pendulum.parse("2024-03-21T13:30:00+01:00")
)
booking_datetime = pendulum.parse("2024-03-14T00:00:00+01:00")
seconds = [
booking_datetime.subtract(seconds=3),
booking_datetime.subtract(seconds=2),
booking_datetime.subtract(seconds=1),
booking_datetime,
booking_datetime.add(microseconds=1),
booking_datetime.add(microseconds=2),
]
mock_now.side_effect = seconds
booking_opening = club.booking_platform.booking_opening
GestionSportsServices.wait_until_booking_time(booking_filter, booking_opening)
assert pendulum.now() == booking_datetime.add(microseconds=1)
def test_build_booking_time():
booking_filter = BookingFilter(
sport_name="Sport1", date=pendulum.parse("2024-03-21T13:30:00+01:00")
)
booking_opening = BookingOpening(daysBefore=7, time="00:00")
booking_time = GestionSportsServices.build_booking_datetime(
booking_filter, booking_opening
)
assert booking_time == pendulum.parse("2024-03-13T23:00:00Z")
def test_retrieve_tournament_id(tournament_sessions_json):
session_id = GestionSportsServices.retrieve_tournament_session(
tournament_sessions_json
)
assert session_id == "1174"
def test_retrieve_tournaments(tournaments_html):
tournaments = GestionSportsServices.retrieve_tournaments(tournaments_html)
assert len(tournaments) == 14
@pytest.mark.asyncio
async def test_get_all_tournaments(
aioresponses, user, connector, club, full_tournaments_responses
):
responses.set_full_tournaments_requests(
aioresponses, connector, full_tournaments_responses, 1174
)
tournaments = await GestionSportsServices.get_all_tournaments(user, club)
assert len(tournaments) == 14

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)