Merge pull request 'create gestion sport services' (#16) from refactor-data-model into main
Reviewed-on: https://jouf.fr/gitea/stanislas/resa-padel/pulls/16
This commit is contained in:
commit
42ce764654
57 changed files with 7316 additions and 1210 deletions
44
booking.md
Normal file
44
booking.md
Normal 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
|
|
@ -2,62 +2,81 @@ import asyncio
|
|||
import logging
|
||||
|
||||
import config
|
||||
from gestion_sports.gestion_sports_platform import GestionSportsPlatform
|
||||
from models import BookingFilter, Club, User
|
||||
from gestion_sports_services import GestionSportsServices
|
||||
from models import Action, BookingFilter, Club, Court, User
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def book(club: Club, user: User, booking_filter: BookingFilter) -> int | None:
|
||||
async def book_court(
|
||||
club: Club, users: list[User], booking_filter: BookingFilter
|
||||
) -> tuple[Court, User]:
|
||||
"""
|
||||
Book a court for a user to a club following a booking filter
|
||||
Book any court that meets the condition from the filter. IThe user that will make
|
||||
the booking is chosen among a list of users and should not have any ongoing bookings
|
||||
|
||||
:param club: the club where to book a court
|
||||
:param user: the user information
|
||||
:param booking_filter: the information related to the booking
|
||||
:return: the id of the booked court, or None if no court was booked
|
||||
:param club: the club in which the booking will be made
|
||||
:param users: the list of users who have an account in the club
|
||||
:param booking_filter: the conditions the court to book should meet
|
||||
:return: a tuple containing the court that was booked and the user who made the
|
||||
booking
|
||||
"""
|
||||
async with GestionSportsPlatform(club) as platform:
|
||||
return await platform.book(user, booking_filter)
|
||||
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 cancel_booking_id(club: Club, user: User, booking_id: int) -> None:
|
||||
"""
|
||||
Cancel a booking that matches the booking id
|
||||
|
||||
:param club: the club in which the booking was made
|
||||
:param user: the user who made the booking
|
||||
:param booking_id: the id of the booking to cancel
|
||||
"""
|
||||
service = GestionSportsServices()
|
||||
await service.cancel_booking_id(user, club, booking_id)
|
||||
|
||||
|
||||
def main() -> tuple[Court, User] | None:
|
||||
"""
|
||||
Main function used to book a court
|
||||
|
||||
:return: the id of the booked court, or None if no court was booked
|
||||
"""
|
||||
booking_filter = config.get_booking_filter()
|
||||
club = config.get_club()
|
||||
user = asyncio.run(get_user_without_booking(club, config.get_available_users()))
|
||||
action = config.get_action()
|
||||
|
||||
LOGGER.info(
|
||||
"Starting booking court of sport %s for user %s at club %s at %s",
|
||||
booking_filter.sport_id,
|
||||
user.login,
|
||||
club.id,
|
||||
booking_filter.date,
|
||||
)
|
||||
court_booked = asyncio.run(book(club, user, booking_filter))
|
||||
if court_booked:
|
||||
LOGGER.info(
|
||||
"Court %s booked successfully at %s", court_booked, booking_filter.date
|
||||
)
|
||||
else:
|
||||
LOGGER.info("Booking did not work")
|
||||
return court_booked
|
||||
if action == Action.BOOK:
|
||||
club = config.get_club()
|
||||
users = config.get_users(club.id)
|
||||
booking_filter = config.get_booking_filter()
|
||||
court_booked, user = asyncio.run(book_court(club, users, booking_filter))
|
||||
if court_booked:
|
||||
LOGGER.info(
|
||||
"Court %s booked successfully at %s for user %s",
|
||||
court_booked,
|
||||
booking_filter.date,
|
||||
user,
|
||||
)
|
||||
return court_booked, user
|
||||
else:
|
||||
LOGGER.info("Booking did not work")
|
||||
|
||||
elif action == Action.CANCEL:
|
||||
user = config.get_user()
|
||||
club = config.get_club()
|
||||
booking_filter = config.get_booking_filter()
|
||||
asyncio.run(cancel_booking(club, user, booking_filter))
|
||||
|
|
42
resa_padel/booking_service.py
Normal file
42
resa_padel/booking_service.py
Normal file
|
@ -0,0 +1,42 @@
|
|||
import logging
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from connectors import Connector
|
||||
from models import BookingFilter, Club, User
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BookingService:
|
||||
def __init__(self, club: Club, connector: Connector):
|
||||
LOGGER.info("Initializing booking service at for club", club.name)
|
||||
self.club = club
|
||||
self.connector = connector
|
||||
self.session: ClientSession | None = None
|
||||
|
||||
async def __aenter__(self):
|
||||
self.session = ClientSession()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
await self.session.close()
|
||||
|
||||
async def book(self, user: User, booking_filter: BookingFilter) -> int | None:
|
||||
"""
|
||||
Book a court matching the booking filters for a user.
|
||||
The steps to perform a booking are to go to the landing page, to log in, wait
|
||||
and for the time when booking is open and then actually book the court
|
||||
|
||||
:param user: the user that wants to book a court
|
||||
:param booking_filter: the booking criteria
|
||||
:return: the court number if the booking is successful, None otherwise
|
||||
"""
|
||||
if self.connector is None:
|
||||
LOGGER.error("No connection to Gestion Sports is available")
|
||||
return None
|
||||
|
||||
if user is None or booking_filter is None:
|
||||
LOGGER.error("Not enough information available to book a court")
|
||||
return None
|
||||
|
||||
self.connector.book(user, booking_filter)
|
|
@ -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"))
|
||||
|
|
423
resa_padel/connectors.py
Normal file
423
resa_padel/connectors.py
Normal file
|
@ -0,0 +1,423 @@
|
|||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import config
|
||||
from aiohttp import ClientResponse, ClientSession
|
||||
from bs4 import BeautifulSoup
|
||||
from models import Booking, BookingFilter, Club, Court, Sport, User
|
||||
from payload_builders import PayloadBuilder
|
||||
from pendulum import DateTime
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
POST_HEADERS = config.get_post_headers("gestion-sports")
|
||||
|
||||
|
||||
class GestionSportsConnector:
|
||||
"""
|
||||
The connector for the Gestion Sports platform.
|
||||
It handles all the requests to the website.
|
||||
"""
|
||||
|
||||
def __init__(self, club: Club):
|
||||
if club is None:
|
||||
raise ValueError("A connector cannot be instantiated without a club")
|
||||
if club.booking_platform.id != "gestion-sports":
|
||||
raise ValueError(
|
||||
"Gestion Sports connector was instantiated with a club not handled"
|
||||
" by gestions sports. Club id is {} instead of gestion-sports".format(
|
||||
club.id
|
||||
)
|
||||
)
|
||||
|
||||
self.club = club
|
||||
|
||||
def _get_url_path(self, name: str) -> str:
|
||||
"""
|
||||
Get the URL path for the service with the given name
|
||||
|
||||
:param name: the name of the service
|
||||
:return: the URL path
|
||||
"""
|
||||
self._check_url_path_exists(name)
|
||||
|
||||
return urljoin(
|
||||
self.club.booking_platform.url,
|
||||
self.club.booking_platform.urls.get(name).path,
|
||||
)
|
||||
|
||||
def _get_payload_template(self, name: str) -> Path:
|
||||
"""
|
||||
Get the path to the template file for the service with the given name
|
||||
|
||||
:param name: the name of the service
|
||||
:return: the path to the template file
|
||||
"""
|
||||
self._check_payload_template_exists(name)
|
||||
|
||||
return (
|
||||
config.get_resources_folder()
|
||||
/ self.club.booking_platform.urls.get(name).payload_template
|
||||
)
|
||||
|
||||
def _check_url_path_exists(self, name: str) -> None:
|
||||
"""
|
||||
Check that the URL path for the given service is defined
|
||||
|
||||
:param name: the name of the service
|
||||
"""
|
||||
if (
|
||||
self.club.booking_platform.urls is None
|
||||
or self.club.booking_platform.urls.get(name) is None
|
||||
or self.club.booking_platform.urls.get(name).path is None
|
||||
):
|
||||
raise ValueError(
|
||||
f"The booking platform internal URL path for page {name} of club "
|
||||
f"{self.club.name} are not set"
|
||||
)
|
||||
|
||||
def _check_payload_template_exists(self, name: str) -> None:
|
||||
"""
|
||||
Check that the payload template for the given service is defined
|
||||
|
||||
:param name: the name of the service
|
||||
"""
|
||||
if (
|
||||
self.club.booking_platform.urls is None
|
||||
or self.club.booking_platform.urls.get(name) is None
|
||||
or self.club.booking_platform.urls.get(name).path is None
|
||||
):
|
||||
raise ValueError(
|
||||
f"The booking platform internal URL path for page {name} of club "
|
||||
f"{self.club.name} are not set"
|
||||
)
|
||||
|
||||
@property
|
||||
def landing_url(self) -> str:
|
||||
"""
|
||||
Get the URL to the landing page of Gestion-Sports
|
||||
|
||||
:return: the URL to the landing page
|
||||
"""
|
||||
return self._get_url_path("landing-page")
|
||||
|
||||
@property
|
||||
def login_url(self) -> str:
|
||||
"""
|
||||
Get the URL to the connection login of Gestion-Sports
|
||||
|
||||
:return: the URL to the login page
|
||||
"""
|
||||
return self._get_url_path("login")
|
||||
|
||||
@property
|
||||
def login_template(self) -> Path:
|
||||
"""
|
||||
Get the payload template to send to log in the website
|
||||
|
||||
:return: the payload template for logging in
|
||||
"""
|
||||
return self._get_payload_template("login")
|
||||
|
||||
@property
|
||||
def booking_url(self) -> str:
|
||||
"""
|
||||
Get the URL to the booking page of Gestion-Sports
|
||||
|
||||
:return: the URL to the booking page
|
||||
"""
|
||||
return self._get_url_path("booking")
|
||||
|
||||
@property
|
||||
def booking_template(self) -> Path:
|
||||
"""
|
||||
Get the payload template to send to book a court
|
||||
|
||||
:return: the payload template for booking a court
|
||||
"""
|
||||
return self._get_payload_template("booking")
|
||||
|
||||
@property
|
||||
def user_bookings_url(self) -> str:
|
||||
"""
|
||||
Get the URL where all the user's bookings are available
|
||||
|
||||
:return: the URL to the user's bookings
|
||||
"""
|
||||
return self._get_url_path("user-bookings")
|
||||
|
||||
@property
|
||||
def user_bookings_template(self) -> Path:
|
||||
"""
|
||||
Get the payload template to send to get all the user's bookings that are
|
||||
available
|
||||
|
||||
:return: the payload template for the user's bookings
|
||||
"""
|
||||
return self._get_payload_template("user-bookings")
|
||||
|
||||
@property
|
||||
def booking_cancellation_url(self) -> str:
|
||||
"""
|
||||
Get the URL where all the user's bookings are available
|
||||
|
||||
:return: the URL to the user's bookings
|
||||
"""
|
||||
return self._get_url_path("cancellation")
|
||||
|
||||
@property
|
||||
def booking_cancel_template(self) -> Path:
|
||||
"""
|
||||
Get the payload template to send to get all the user's bookings that are
|
||||
available
|
||||
|
||||
:return: the payload template for the user's bookings
|
||||
"""
|
||||
return self._get_payload_template("cancellation")
|
||||
|
||||
@property
|
||||
def available_sports(self) -> dict[str, Sport]:
|
||||
"""
|
||||
Get a dictionary of all sports, the key is the sport name lowered case
|
||||
:return: the dictionary of all sports
|
||||
"""
|
||||
return {
|
||||
sport.name.lower(): sport for sport in self.club.booking_platform.sports
|
||||
}
|
||||
|
||||
async def land(self, session: ClientSession) -> ClientResponse:
|
||||
"""
|
||||
Perform the request to the landing page in order to get the cookie PHPSESSIONID
|
||||
|
||||
:return: the response from the landing page
|
||||
"""
|
||||
LOGGER.info("Connecting to GestionSports API at %s", self.login_url)
|
||||
async with session.get(self.landing_url) as response:
|
||||
await response.text()
|
||||
return response
|
||||
|
||||
async def login(self, session: ClientSession, user: User) -> ClientResponse:
|
||||
"""
|
||||
Perform the request to the log in the user
|
||||
|
||||
:return: the response from the login
|
||||
"""
|
||||
LOGGER.info("Logging in to GestionSports API at %s", self.login_url)
|
||||
payload = PayloadBuilder.build(self.login_template, user=user, club=self.club)
|
||||
|
||||
async with session.post(
|
||||
self.login_url, data=payload, headers=POST_HEADERS
|
||||
) as response:
|
||||
resp_text = await response.text()
|
||||
LOGGER.debug("Connexion request response:\n%s", resp_text)
|
||||
return response
|
||||
|
||||
async def book_any_court(
|
||||
self, session: ClientSession, booking_filter: BookingFilter
|
||||
) -> list[tuple[int, dict]]:
|
||||
"""
|
||||
Perform a request for each court at the same time to increase the chances to get
|
||||
a booking.
|
||||
The gestion-sports backend does not allow several bookings at the same time
|
||||
so there is no need to make each request one after the other
|
||||
|
||||
:param session: the session to use
|
||||
:param booking_filter: the booking conditions to meet
|
||||
:return: the booked court, or None if no court was booked
|
||||
"""
|
||||
LOGGER.info(
|
||||
"Booking any available court from GestionSports API at %s", self.booking_url
|
||||
)
|
||||
|
||||
sport = self.available_sports.get(booking_filter.sport_name)
|
||||
|
||||
bookings = await asyncio.gather(
|
||||
*[
|
||||
self.send_booking_request(
|
||||
session, booking_filter.date, court.id, sport.id
|
||||
)
|
||||
for court in sport.courts
|
||||
],
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
LOGGER.debug("Booking results:\n'%s'", bookings)
|
||||
return bookings
|
||||
|
||||
async def send_booking_request(
|
||||
self,
|
||||
session: ClientSession,
|
||||
date: DateTime,
|
||||
court_id: int,
|
||||
sport_id: int,
|
||||
) -> tuple[int, dict]:
|
||||
"""
|
||||
Book a single court that meets the conditions from the booking filter
|
||||
|
||||
:param session: the HTTP session that contains the user information and cookies
|
||||
:param date: the booking date
|
||||
:param court_id: the id of the court to book
|
||||
:param sport_id: the id of the sport
|
||||
:return: a tuple containing the court id and the response
|
||||
"""
|
||||
LOGGER.debug("Booking court %s at %s", court_id, date.to_w3c_string())
|
||||
payload = PayloadBuilder.build(
|
||||
self.booking_template,
|
||||
date=date,
|
||||
court_id=court_id,
|
||||
sport_id=sport_id,
|
||||
)
|
||||
|
||||
LOGGER.debug("Booking court %s request:\n'%s'", court_id, payload)
|
||||
|
||||
async with session.post(
|
||||
self.booking_url, data=payload, headers=POST_HEADERS
|
||||
) as response:
|
||||
assert response.status == 200
|
||||
resp_json = json.loads(await response.text())
|
||||
LOGGER.debug("Response from booking request:\n'%s'", resp_json)
|
||||
return court_id, resp_json
|
||||
|
||||
def get_booked_court(
|
||||
self, bookings: list[tuple[int, dict]], sport_name: str
|
||||
) -> Court | None:
|
||||
"""
|
||||
Parse the booking list and return the court that was booked
|
||||
|
||||
:param bookings: a list of bookings
|
||||
:param sport_name: the sport name
|
||||
:return: the id of the booked court if any, None otherwise
|
||||
"""
|
||||
for court_id, response in bookings:
|
||||
if self.is_booking_response_status_ok(response):
|
||||
LOGGER.debug("Court %d is booked", court_id)
|
||||
court_booked = self.find_court(court_id, sport_name)
|
||||
LOGGER.info("Court '%s' is booked", court_booked.name)
|
||||
return court_booked
|
||||
LOGGER.debug("No booked court found")
|
||||
return None
|
||||
|
||||
def find_court(self, court_id: int, sport_name: str) -> Court:
|
||||
"""
|
||||
Get all the court information based on the court id and the sport name
|
||||
|
||||
:param court_id: the court id
|
||||
:param sport_name: the sport name
|
||||
:return: the court that has the given id and sport name
|
||||
"""
|
||||
sport = self.available_sports.get(sport_name.lower())
|
||||
for court in sport.courts:
|
||||
if court.id == court_id:
|
||||
return court
|
||||
|
||||
@staticmethod
|
||||
def is_booking_response_status_ok(response: dict) -> bool:
|
||||
"""
|
||||
Check if the booking response is OK
|
||||
|
||||
:param response: the response as a string
|
||||
:return: true if the status is ok, false otherwise
|
||||
"""
|
||||
return response["status"] == "ok"
|
||||
|
||||
async def get_ongoing_bookings(self, session: ClientSession) -> list[Booking]:
|
||||
"""
|
||||
Get the list of all ongoing bookings of a user.
|
||||
The steps to perform this are to get the user's bookings page and get a hidden
|
||||
property in the HTML to get a hash that will be used in the payload of the
|
||||
POST request (sic) to get the user's bookings.
|
||||
Gestion sports is really a mess!!
|
||||
|
||||
:return: the list of all ongoing bookings of a user
|
||||
"""
|
||||
hash_value = await self.send_hash_request(session)
|
||||
LOGGER.debug(f"Hash value: {hash_value}")
|
||||
payload = PayloadBuilder.build(self.user_bookings_template, hash=hash_value)
|
||||
LOGGER.debug(f"Payload to get ongoing bookings: {payload}")
|
||||
return await self.send_user_bookings_request(session, payload)
|
||||
|
||||
async def send_hash_request(self, session: ClientSession) -> str:
|
||||
"""
|
||||
Get the hash value used in the request to get the user's bookings
|
||||
|
||||
:param session: the session in which the user logged in
|
||||
:return: the value of the hash
|
||||
"""
|
||||
async with session.get(self.user_bookings_url) as response:
|
||||
html = await response.text()
|
||||
LOGGER.debug("Get bookings response: %s\n", html)
|
||||
return self.get_hash_input(html)
|
||||
|
||||
@staticmethod
|
||||
def get_hash_input(html_doc: str) -> str:
|
||||
"""
|
||||
There is a secret hash generated by Gestion sports that is reused when trying to get
|
||||
users bookings. This hash is stored in a hidden input with name "mesresas-hash"
|
||||
|
||||
:param html_doc: the html document when getting the page mes-resas.html
|
||||
:return: the value of the hash in the page
|
||||
"""
|
||||
soup = BeautifulSoup(html_doc, "html.parser")
|
||||
inputs = soup.find_all("input")
|
||||
for input_tag in inputs:
|
||||
if input_tag.get("name") == "mesresas-hash":
|
||||
return input_tag.get("value").strip()
|
||||
|
||||
async def send_user_bookings_request(
|
||||
self, session: ClientSession, payload: str
|
||||
) -> list[Booking]:
|
||||
"""
|
||||
Perform the HTTP request to get all bookings
|
||||
|
||||
:param session: the session in which the user logged in
|
||||
:param payload: the HTTP payload for the request
|
||||
:return: a dictionary containing all the bookings
|
||||
"""
|
||||
async with session.post(
|
||||
self.user_bookings_url, data=payload, headers=POST_HEADERS
|
||||
) as response:
|
||||
resp = await response.text()
|
||||
LOGGER.debug("ongoing bookings response: %s\n", resp)
|
||||
return [Booking(**booking) for booking in json.loads(resp)]
|
||||
|
||||
async def cancel_booking_id(
|
||||
self, session: ClientSession, booking_id: int
|
||||
) -> ClientResponse:
|
||||
"""
|
||||
Send the HTTP request to cancel the booking
|
||||
|
||||
:param session: the HTTP session that contains the user information and cookies
|
||||
:param booking_id: the id of the booking to cancel
|
||||
:return: the response from the client
|
||||
"""
|
||||
hash_value = await self.send_hash_request(session)
|
||||
|
||||
payload = PayloadBuilder.build(
|
||||
self.booking_cancel_template,
|
||||
booking_id=booking_id,
|
||||
hash=hash_value,
|
||||
)
|
||||
|
||||
async with session.post(
|
||||
self.booking_cancellation_url, data=payload, headers=POST_HEADERS
|
||||
) as response:
|
||||
await response.text()
|
||||
return response
|
||||
|
||||
async def cancel_booking(
|
||||
self, session: ClientSession, booking_filter: BookingFilter
|
||||
) -> ClientResponse | None:
|
||||
"""
|
||||
Cancel the booking that meets some conditions
|
||||
|
||||
:param session: the session
|
||||
:param booking_filter: the conditions the booking to cancel should meet
|
||||
"""
|
||||
bookings = await self.get_ongoing_bookings(session)
|
||||
|
||||
for booking in bookings:
|
||||
if booking.matches(booking_filter):
|
||||
return await self.cancel_booking_id(session, booking.id)
|
|
@ -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")
|
|
@ -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)
|
|
@ -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()
|
|
@ -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)
|
|
@ -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)
|
127
resa_padel/gestion_sports_services.py
Normal file
127
resa_padel/gestion_sports_services.py
Normal file
|
@ -0,0 +1,127 @@
|
|||
import logging
|
||||
import time
|
||||
|
||||
import pendulum
|
||||
from aiohttp import ClientSession
|
||||
from connectors import GestionSportsConnector
|
||||
from models import BookingFilter, BookingOpening, Club, Court, User
|
||||
from pendulum import DateTime
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GestionSportsServices:
|
||||
@staticmethod
|
||||
async def book(
|
||||
club: Club, user: User, booking_filter: BookingFilter
|
||||
) -> Court | None:
|
||||
"""
|
||||
Perform a request for each court at the same time to increase the chances to get
|
||||
a booking.
|
||||
The gestion-sports backend does not allow several bookings at the same time
|
||||
so there is no need to make each request one after the other
|
||||
|
||||
:param club: the club in which the booking will be made
|
||||
:param user: the user that wants to book the court
|
||||
:param booking_filter: the booking conditions to meet
|
||||
:return: the booked court, or None if no court was booked
|
||||
"""
|
||||
connector = GestionSportsConnector(club)
|
||||
LOGGER.info(
|
||||
"Booking any available court from GestionSports API at %s",
|
||||
connector.booking_url,
|
||||
)
|
||||
|
||||
async with ClientSession() as session:
|
||||
# use asyncio to request a booking on every court
|
||||
# the gestion-sports backend is able to book only one court for a user
|
||||
await connector.land(session)
|
||||
await connector.login(session, user)
|
||||
|
||||
booking_opening = club.booking_platform.booking_opening
|
||||
GestionSportsServices.wait_until_booking_time(
|
||||
booking_filter, booking_opening
|
||||
)
|
||||
|
||||
bookings = await connector.book_any_court(session, booking_filter)
|
||||
|
||||
LOGGER.debug("Booking results:\n'%s'", bookings)
|
||||
return connector.get_booked_court(bookings, booking_filter.sport_name)
|
||||
|
||||
@staticmethod
|
||||
async def has_user_available_slots(user: User, club: Club) -> bool:
|
||||
connector = GestionSportsConnector(club)
|
||||
async with ClientSession() as session:
|
||||
await connector.land(session)
|
||||
await connector.login(session, user)
|
||||
bookings = await connector.get_ongoing_bookings(session)
|
||||
|
||||
return bool(bookings)
|
||||
|
||||
@staticmethod
|
||||
async def cancel_booking(user: User, club: Club, booking_filter: BookingFilter):
|
||||
connector = GestionSportsConnector(club)
|
||||
async with ClientSession() as session:
|
||||
await connector.land(session)
|
||||
await connector.login(session, user)
|
||||
await connector.cancel_booking(session, booking_filter)
|
||||
|
||||
@staticmethod
|
||||
async def cancel_booking_id(user: User, club: Club, booking_id: int):
|
||||
connector = GestionSportsConnector(club)
|
||||
async with ClientSession() as session:
|
||||
await connector.land(session)
|
||||
await connector.login(session, user)
|
||||
await connector.cancel_booking_id(session, booking_id)
|
||||
|
||||
@staticmethod
|
||||
def wait_until_booking_time(
|
||||
booking_filter: BookingFilter, booking_opening: BookingOpening
|
||||
) -> None:
|
||||
"""
|
||||
Wait until the booking is open.
|
||||
The booking filter contains the date and time of the booking.
|
||||
The club has the information about when the booking is open for that date.
|
||||
|
||||
:param booking_opening:
|
||||
:param booking_filter: the booking information
|
||||
"""
|
||||
LOGGER.info("Waiting for booking time")
|
||||
booking_datetime = GestionSportsServices.build_booking_datetime(
|
||||
booking_filter, booking_opening
|
||||
)
|
||||
now = pendulum.now()
|
||||
duration_until_booking = booking_datetime - now
|
||||
LOGGER.debug(f"Current time: {now}, Datetime to book: {booking_datetime}")
|
||||
LOGGER.debug(
|
||||
f"Time to wait before booking: {duration_until_booking.hours:0>2}"
|
||||
f":{duration_until_booking.minutes:0>2}"
|
||||
f":{duration_until_booking.seconds:0>2}"
|
||||
)
|
||||
|
||||
while now < booking_datetime:
|
||||
time.sleep(1)
|
||||
now = pendulum.now()
|
||||
LOGGER.info("It's booking time!")
|
||||
|
||||
@staticmethod
|
||||
def build_booking_datetime(
|
||||
booking_filter: BookingFilter, booking_opening: BookingOpening
|
||||
) -> DateTime:
|
||||
"""
|
||||
Build the date and time when the booking is open for a given match date.
|
||||
The booking filter contains the date and time of the booking.
|
||||
The club has the information about when the booking is open for that date.
|
||||
|
||||
:param booking_opening:the booking opening conditions
|
||||
:param booking_filter: the booking information
|
||||
:return: the date and time when the booking is open
|
||||
"""
|
||||
date_to_book = booking_filter.date
|
||||
booking_date = date_to_book.subtract(days=booking_opening.days_before)
|
||||
|
||||
opening_time = pendulum.parse(booking_opening.opening_time)
|
||||
booking_hour = opening_time.hour
|
||||
booking_minute = opening_time.minute
|
||||
|
||||
return booking_date.at(booking_hour, booking_minute)
|
|
@ -1,22 +1,212 @@
|
|||
from pendulum import Time
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
import pendulum
|
||||
from pendulum import Date, Time
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from pydantic_extra_types.pendulum_dt import DateTime
|
||||
|
||||
|
||||
class Club(BaseModel):
|
||||
class User(BaseModel):
|
||||
login: str
|
||||
password: str = Field(repr=False)
|
||||
club_id: Optional[str] = Field(default=None)
|
||||
|
||||
|
||||
class BookingOpening(BaseModel):
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
id: str = Field()
|
||||
url: str = Field()
|
||||
courts_ids: list[int] = Field(default_factory=list)
|
||||
booking_open_days_before: int = Field(default=7)
|
||||
booking_opening_time: Time = Field(default=Time(hour=0, minute=0))
|
||||
|
||||
days_before: Optional[int] = Field(default=7, alias="daysBefore")
|
||||
opening_time: Optional[str] = Field(alias="time", default=None, repr=False)
|
||||
time_after_booking: Optional[str] = Field(
|
||||
alias="timeAfterBookingTime", default=None, repr=False
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
base = super().__repr__()
|
||||
time = f", time: {self.time})" if self.time else ""
|
||||
time_after_booking = (
|
||||
f", time_after_booking_time: {self.time_after_booking_time})"
|
||||
if self.time_after_booking_time
|
||||
else ""
|
||||
)
|
||||
|
||||
return base.removesuffix(")") + time + time_after_booking
|
||||
|
||||
@property
|
||||
def time(self):
|
||||
return pendulum.parse(self.opening_time).time()
|
||||
|
||||
@property
|
||||
def time_after_booking_time(self):
|
||||
return (
|
||||
pendulum.parse(self.time_after_booking).time()
|
||||
if self.time_after_booking
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
class TotalBookings(BaseModel):
|
||||
peak_hours: int | str = Field(alias="peakHours")
|
||||
off_peak_hours: int | str = Field(alias="offPeakHours")
|
||||
|
||||
|
||||
class Court(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
number: int
|
||||
is_indoor: Optional[bool] = Field(alias="isIndoor")
|
||||
|
||||
|
||||
class Sport(BaseModel):
|
||||
name: str
|
||||
id: int
|
||||
duration: int
|
||||
price: int
|
||||
players: int
|
||||
courts: list[Court]
|
||||
|
||||
|
||||
class Url(BaseModel):
|
||||
name: str
|
||||
path: str
|
||||
payload_template: Optional[str] = Field(default=None, alias="payloadTemplate")
|
||||
|
||||
|
||||
class BookingPlatform(BaseModel):
|
||||
id: str
|
||||
club_id: int = Field(alias="clubId")
|
||||
url: str
|
||||
hours_before_cancellation: int = Field(alias="hoursBeforeCancellation")
|
||||
booking_opening: BookingOpening = Field(alias="bookingOpening")
|
||||
total_bookings: TotalBookings = Field(alias="totalBookings")
|
||||
sports: list[Sport]
|
||||
urls: dict[str, Url]
|
||||
|
||||
|
||||
class Club(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
url: str
|
||||
booking_platform: BookingPlatform = Field(alias="bookingPlatform")
|
||||
|
||||
|
||||
class PlatformDefinition(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
url: str
|
||||
urls: list[Url]
|
||||
|
||||
|
||||
class BookingFilter(BaseModel):
|
||||
sport_id: int = Field()
|
||||
date: DateTime = Field()
|
||||
date: DateTime
|
||||
sport_name: str
|
||||
|
||||
@field_validator("sport_name", mode="before")
|
||||
@classmethod
|
||||
def to_lower_case(cls, d: str) -> str:
|
||||
return d.lower()
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
login: str = Field()
|
||||
password: str = Field(repr=False)
|
||||
class Booking(BaseModel):
|
||||
id: int
|
||||
booking_date: DateTime = Field(alias="dateResa")
|
||||
start_time: DateTime = Field(alias="startTime")
|
||||
sport: str
|
||||
court: str
|
||||
game_creation: Optional[int] = Field(default=None, alias="creaPartie")
|
||||
game_creation_limit: Optional[DateTime] = Field(
|
||||
default=None, alias="limitCreaPartie"
|
||||
)
|
||||
cancel: Optional[bool] = Field(default=True)
|
||||
block_player_replacement: Optional[int] = Field(
|
||||
default=None, alias="bloquerRemplacementJoueur"
|
||||
)
|
||||
can_remove_parteners: bool = Field(default=True, alias="canRemovePartners")
|
||||
end_time: Optional[DateTime] = Field(default=None, alias="endTime")
|
||||
day_fr: Optional[str] = Field(default=None, alias="dayFr")
|
||||
live_xperience_code: Optional[str] = Field(default=None, alias="codeLiveXperience")
|
||||
spartime_qr_code: Optional[str] = Field(default=None, alias="qrCodeSpartime")
|
||||
remaining_places: int = Field(default=3, alias="remainingPlaces")
|
||||
is_captain: bool = Field(default=True, alias="isCaptain")
|
||||
dt_start: Optional[DateTime] = Field(default=None, alias="dtStart")
|
||||
credit_card_guaranty: Optional[str] = Field(default=None, alias="garantieCb")
|
||||
certificate_validity_duration: Optional[int] = Field(
|
||||
alias="dureeValidCertif", default=None
|
||||
)
|
||||
charge_id: Optional[str] = Field(default=None, alias="chargeId")
|
||||
partners: Optional[list] = Field(default=[])
|
||||
player_status: Optional[int] = Field(default=None, alias="playerStatus")
|
||||
products: Optional[list] = Field(default=[])
|
||||
|
||||
@field_validator("booking_date", mode="before")
|
||||
@classmethod
|
||||
def validate_date(cls, d: str) -> DateTime:
|
||||
return pendulum.from_format(
|
||||
d, "DD/MM/YYYY", tz=pendulum.timezone("Europe/Paris")
|
||||
)
|
||||
|
||||
@field_validator("start_time", "end_time", mode="before")
|
||||
@classmethod
|
||||
def validate_time(cls, t: str) -> DateTime:
|
||||
return pendulum.from_format(t, "HH:mm", tz=pendulum.timezone("Europe/Paris"))
|
||||
|
||||
@field_validator("game_creation_limit", mode="before")
|
||||
@classmethod
|
||||
def validate_datetime_add_tz(cls, dt: str) -> DateTime:
|
||||
return pendulum.parse(dt, tz=pendulum.timezone("Europe/Paris"))
|
||||
|
||||
@field_validator("dt_start", mode="before")
|
||||
@classmethod
|
||||
def validate_datetime(cls, dt: str) -> DateTime:
|
||||
return pendulum.parse(dt)
|
||||
|
||||
@field_validator("sport", mode="before")
|
||||
@classmethod
|
||||
def to_lower_case(cls, d: str) -> str:
|
||||
return d.lower()
|
||||
|
||||
def matches(self, booking_filter: BookingFilter) -> bool:
|
||||
"""
|
||||
Check if the booking matches the booking filter
|
||||
|
||||
:param booking_filter: the conditions the booking should meet
|
||||
:return: true if the booking matches the conditions, false otherwise
|
||||
"""
|
||||
return (
|
||||
self.is_same_sport(booking_filter.sport_name)
|
||||
and self.is_same_date(booking_filter.date.date())
|
||||
and self.is_same_time(booking_filter.date.time())
|
||||
)
|
||||
|
||||
def is_same_sport(self, sport: str) -> bool:
|
||||
"""
|
||||
Check if the booking and the booking filter are about the same sport
|
||||
|
||||
:param sport: the sport to test
|
||||
:return: true if the sport matches booking sport, false otherwise
|
||||
"""
|
||||
return self.sport == sport
|
||||
|
||||
def is_same_date(self, date: Date) -> bool:
|
||||
"""
|
||||
Check if the booking filter has the same date as the booking
|
||||
|
||||
:param date: the date to test
|
||||
:return: true if the date matches the booking date, false otherwise
|
||||
"""
|
||||
return self.booking_date.date() == date
|
||||
|
||||
def is_same_time(self, time: Time) -> bool:
|
||||
"""
|
||||
Check if the booking filter has the same time as the booking
|
||||
|
||||
:param time: the time to test
|
||||
:return: true if the time matches the booking time, false otherwise
|
||||
"""
|
||||
return self.start_time.time() == time
|
||||
|
||||
|
||||
class Action(Enum):
|
||||
BOOK = "book"
|
||||
CANCEL = "cancel"
|
||||
|
|
12
resa_padel/payload_builders.py
Normal file
12
resa_padel/payload_builders.py
Normal 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)
|
160
resa_padel/resources/clubs.yaml
Normal file
160
resa_padel/resources/clubs.yaml
Normal 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
|
|
@ -0,0 +1 @@
|
|||
ajax=removeResa&hash={{ hash }}&id={{ booking_id }}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
19
resa_padel/resources/platforms.yaml
Normal file
19
resa_padel/resources/platforms.yaml
Normal file
|
@ -0,0 +1,19 @@
|
|||
platforms:
|
||||
- name: Gestion sports
|
||||
url: https://gestion-sports.fr/
|
||||
id: gestion-sports
|
||||
urls:
|
||||
- name: landing-page
|
||||
path: /connexion.php
|
||||
- name: login
|
||||
path: /connexion.php
|
||||
payloadTemplate: gestion-sports/login-payload.txt
|
||||
- name: booking
|
||||
path: /membre/reservation.html
|
||||
payloadTemplate: gestion-sports/booking-payload.txt
|
||||
- name: user-bookings
|
||||
path: /membre/mesresas.html
|
||||
payloadTemplate: gestion-sports/user-bookings-payload.txt
|
||||
- name: cancellation
|
||||
path: /membre/mesresas.html
|
||||
payloadTemplate: gestion-sports/booking-cancellation-payload.txt
|
13
resa_padel/resources/users.yaml
Normal file
13
resa_padel/resources/users.yaml
Normal 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
|
92
tests/data/configuration/clubs.yaml
Normal file
92
tests/data/configuration/clubs.yaml
Normal 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
|
|
@ -0,0 +1 @@
|
|||
ajax=removeResa&hash={{ hash }}&id={{ booking_id }}
|
|
@ -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
|
|
@ -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
|
12
tests/data/configuration/gestion-sports/post-headers.json
Normal file
12
tests/data/configuration/gestion-sports/post-headers.json
Normal 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"
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
ajax=loadResa&hash={{ hash }}
|
19
tests/data/configuration/platforms.yaml
Normal file
19
tests/data/configuration/platforms.yaml
Normal file
|
@ -0,0 +1,19 @@
|
|||
platforms:
|
||||
- name: flapi
|
||||
url: https://flapi.fr/
|
||||
id: gestion-sports
|
||||
urls:
|
||||
- name: landing-page
|
||||
path: /landing.html
|
||||
- name: login
|
||||
path: /login.html
|
||||
payloadTemplate: gestion-sports/login-payload.txt
|
||||
- name: booking
|
||||
path: /booking.html
|
||||
payloadTemplate: gestion-sports/booking-payload.txt
|
||||
- name: user-bookings
|
||||
path: /user_bookings.html
|
||||
payloadTemplate: gestion-sports/user-bookings-payload.txt
|
||||
- name: cancellation
|
||||
path: /cancel.html
|
||||
payloadTemplate: gestion-sports/booking-cancellation-payload.txt
|
13
tests/data/configuration/users.yaml
Normal file
13
tests/data/configuration/users.yaml
Normal 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
|
4
tests/data/responses/booking_failure.json
Normal file
4
tests/data/responses/booking_failure.json
Normal 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!"
|
||||
}
|
5
tests/data/responses/booking_success.json
Normal file
5
tests/data/responses/booking_success.json
Normal 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
|
||||
}
|
4
tests/data/responses/cancellation_response.json
Normal file
4
tests/data/responses/cancellation_response.json
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"status": "ok",
|
||||
"message": "La r\u00e9servation a bien \u00e9t\u00e9 annul\u00e9e !"
|
||||
}
|
2033
tests/data/responses/landing_response.html
Normal file
2033
tests/data/responses/landing_response.html
Normal file
File diff suppressed because it is too large
Load diff
5
tests/data/responses/login_failure.json
Normal file
5
tests/data/responses/login_failure.json
Normal file
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"status": "ko",
|
||||
"msg": "L'email ou le mot de passe saisi est incorrect.",
|
||||
"data": false
|
||||
}
|
9
tests/data/responses/login_success.json
Normal file
9
tests/data/responses/login_success.json
Normal file
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"status": "ok",
|
||||
"msg": "",
|
||||
"data": {
|
||||
"needChoice": false,
|
||||
"redirectUrl": "\/membre",
|
||||
"id_club": 88
|
||||
}
|
||||
}
|
1363
tests/data/responses/user_bookings_get.html
Normal file
1363
tests/data/responses/user_bookings_get.html
Normal file
File diff suppressed because one or more lines are too long
52
tests/data/responses/user_bookings_post.json
Normal file
52
tests/data/responses/user_bookings_post.json
Normal 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": []
|
||||
}
|
||||
]
|
1363
tests/data/user_bookings_html_response.html
Normal file
1363
tests/data/user_bookings_html_response.html
Normal file
File diff suppressed because one or more lines are too long
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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"
|
|
@ -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
|
|
@ -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)
|
46
tests/integration_tests/conftest.py
Normal file
46
tests/integration_tests/conftest.py
Normal file
|
@ -0,0 +1,46 @@
|
|||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import config
|
||||
import pendulum
|
||||
import pytest
|
||||
from connectors import GestionSportsConnector
|
||||
from models import BookingFilter, Club, User
|
||||
|
||||
TEST_FOLDER = Path(__file__).parent.parent
|
||||
DATA_FOLDER = TEST_FOLDER / "data"
|
||||
RESPONSES_FOLDER = DATA_FOLDER / "responses"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def club() -> Club:
|
||||
return config.get_clubs()["tpc"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def connector(club) -> GestionSportsConnector:
|
||||
return GestionSportsConnector(club)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user() -> User:
|
||||
return User(login="padel.testing@jouf.fr", password="ridicule")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def booking_filter() -> BookingFilter:
|
||||
return BookingFilter(
|
||||
sport_name="Padel", date=pendulum.parse("2024-03-21T13:30:00+01:00")
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def booking_success_response() -> dict:
|
||||
booking_success_file = RESPONSES_FOLDER / "booking_success.json"
|
||||
return json.loads(booking_success_file.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def booking_failure_response() -> dict:
|
||||
booking_failure_file = RESPONSES_FOLDER / "booking_failure.json"
|
||||
return json.loads(booking_failure_file.read_text(encoding="utf-8"))
|
59
tests/integration_tests/test_booking.py
Normal file
59
tests/integration_tests/test_booking.py
Normal file
|
@ -0,0 +1,59 @@
|
|||
import asyncio
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
from resa_padel import booking
|
||||
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{"CLUB_ID": "tpc"},
|
||||
clear=True,
|
||||
)
|
||||
def test_booking(club, user, booking_filter):
|
||||
booked_court, user_that_booked = asyncio.run(
|
||||
booking.book_court(club, [user], booking_filter)
|
||||
)
|
||||
assert booked_court is not None
|
||||
assert user_that_booked == user
|
||||
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{"CLUB_ID": "tpc"},
|
||||
clear=True,
|
||||
)
|
||||
def test_cancellation(club, user, booking_filter):
|
||||
asyncio.run(booking.cancel_booking(club, user, booking_filter))
|
||||
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"CLUB_ID": "tpc",
|
||||
"ACTION": "book",
|
||||
"SPORT_NAME": "Padel",
|
||||
"DATE_TIME": "2024-03-21T13:30:00+01:00",
|
||||
},
|
||||
clear=True,
|
||||
)
|
||||
def test_main_booking():
|
||||
court, player = booking.main()
|
||||
assert court is not None
|
||||
assert player.login == "padel.testing@jouf.fr"
|
||||
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"CLUB_ID": "tpc",
|
||||
"ACTION": "cancel",
|
||||
"SPORT_NAME": "Padel",
|
||||
"DATE_TIME": "2024-03-21T13:30:00+01:00",
|
||||
"LOGIN": "padel.testing@jouf.fr",
|
||||
"PASSWORD": "ridicule",
|
||||
},
|
||||
clear=True,
|
||||
)
|
||||
def test_main_cancellation():
|
||||
booking.main()
|
251
tests/integration_tests/test_connectors.py
Normal file
251
tests/integration_tests/test_connectors.py
Normal file
|
@ -0,0 +1,251 @@
|
|||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import aiohttp
|
||||
import pendulum
|
||||
import pytest
|
||||
from connectors import GestionSportsConnector
|
||||
from models import BookingFilter, Club
|
||||
from pendulum import DateTime
|
||||
from yarl import URL
|
||||
|
||||
|
||||
def retrieve_booking_datetime(
|
||||
a_booking_filter: BookingFilter, a_club: Club
|
||||
) -> DateTime:
|
||||
"""
|
||||
Utility to retrieve the booking datetime from the booking filter and the club
|
||||
|
||||
:param a_booking_filter: the booking filter that contains the date to book
|
||||
:param a_club: the club which has the number of days before the date and the booking time
|
||||
"""
|
||||
booking_opening = a_club.booking_platform.booking_opening
|
||||
opening_time = pendulum.parse(booking_opening.opening_time)
|
||||
booking_hour = opening_time.hour
|
||||
booking_minute = opening_time.minute
|
||||
|
||||
date_to_book = a_booking_filter.date
|
||||
return date_to_book.subtract(days=booking_opening.days_before).at(
|
||||
booking_hour, booking_minute
|
||||
)
|
||||
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{"CLUB_ID": "tpc"},
|
||||
clear=True,
|
||||
)
|
||||
def test_urls(connector):
|
||||
assert (
|
||||
connector.landing_url
|
||||
== "https://toulousepadelclub.gestion-sports.com/connexion.php"
|
||||
)
|
||||
assert (
|
||||
connector.login_url
|
||||
== "https://toulousepadelclub.gestion-sports.com/connexion.php"
|
||||
)
|
||||
assert (
|
||||
connector.booking_url
|
||||
== "https://toulousepadelclub.gestion-sports.com/membre/reservation.html"
|
||||
)
|
||||
assert (
|
||||
connector.user_bookings_url
|
||||
== "https://toulousepadelclub.gestion-sports.com/membre/mesresas.html"
|
||||
)
|
||||
assert (
|
||||
connector.booking_cancellation_url
|
||||
== "https://toulousepadelclub.gestion-sports.com/membre/mesresas.html"
|
||||
)
|
||||
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{"RESOURCES_FOLDER": "/some/path"},
|
||||
clear=True,
|
||||
)
|
||||
def test_urls_payload_templates(connector):
|
||||
resources_folder = Path("/some", "path", "gestion-sports")
|
||||
assert connector.login_template == resources_folder / "login-payload.txt"
|
||||
assert connector.booking_template == resources_folder / "booking-payload.txt"
|
||||
assert (
|
||||
connector.user_bookings_template
|
||||
== resources_folder / "user-bookings-payload.txt"
|
||||
)
|
||||
assert (
|
||||
connector.booking_cancel_template
|
||||
== resources_folder / "booking-cancellation-payload.txt"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_landing_page(connector):
|
||||
async with aiohttp.ClientSession() as session:
|
||||
response = await connector.land(session)
|
||||
|
||||
assert response.status == 200
|
||||
assert response.request_info.method == "GET"
|
||||
assert response.content_type == "text/html"
|
||||
assert response.request_info.url == URL(connector.landing_url)
|
||||
assert response.charset == "UTF-8"
|
||||
assert response.cookies.get("PHPSESSID") is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login(connector, user):
|
||||
async with aiohttp.ClientSession() as session:
|
||||
await connector.land(session)
|
||||
|
||||
response = await connector.login(session, user)
|
||||
|
||||
assert response.status == 200
|
||||
assert response.request_info.method == "POST"
|
||||
assert response.content_type == "text/html"
|
||||
assert response.request_info.url == URL(connector.landing_url)
|
||||
assert response.charset == "UTF-8"
|
||||
assert response.cookies.get("COOK_COMPTE") is not None
|
||||
assert response.cookies.get("COOK_ID_CLUB").value == "88"
|
||||
assert response.cookies.get("COOK_ID_USER").value == "232382"
|
||||
|
||||
|
||||
def test_get_booked_court(
|
||||
connector, booking_success_response, booking_failure_response
|
||||
):
|
||||
bookings = [
|
||||
(601, booking_failure_response),
|
||||
(602, booking_failure_response),
|
||||
(603, booking_failure_response),
|
||||
(614, booking_failure_response),
|
||||
(605, booking_failure_response),
|
||||
(606, booking_success_response),
|
||||
(607, booking_failure_response),
|
||||
(608, booking_failure_response),
|
||||
]
|
||||
|
||||
court = connector.get_booked_court(bookings, "padel")
|
||||
assert court.number == 9
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_book_one_court(connector, user, booking_filter):
|
||||
async with aiohttp.ClientSession() as session:
|
||||
await connector.land(session)
|
||||
await connector.login(session, user)
|
||||
|
||||
court_id, response = await connector.send_booking_request(
|
||||
session, pendulum.parse("2024-03-21T13:30:00+01:00"), 610, 217
|
||||
)
|
||||
|
||||
assert court_id == 610
|
||||
assert response.get("status") == "ok"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_book(connector, user, booking_filter):
|
||||
booked_court = await connector.book(user, booking_filter)
|
||||
|
||||
assert booked_court is not None
|
||||
|
||||
|
||||
def test_build_booking_datetime(connector, booking_filter):
|
||||
opening_datetime = connector.build_booking_datetime(booking_filter)
|
||||
assert opening_datetime.year == 2024
|
||||
assert opening_datetime.month == 3
|
||||
assert opening_datetime.day == 14
|
||||
assert opening_datetime.hour == 0
|
||||
assert opening_datetime.minute == 0
|
||||
|
||||
|
||||
@patch("pendulum.now")
|
||||
def test_wait_until_booking_time(mock_now, connector, booking_filter, club):
|
||||
booking_datetime = retrieve_booking_datetime(booking_filter, club)
|
||||
|
||||
seconds = [
|
||||
booking_datetime.subtract(seconds=3),
|
||||
booking_datetime.subtract(seconds=2),
|
||||
booking_datetime.subtract(seconds=1),
|
||||
booking_datetime,
|
||||
booking_datetime.add(microseconds=1),
|
||||
booking_datetime.add(microseconds=2),
|
||||
]
|
||||
mock_now.side_effect = seconds
|
||||
|
||||
connector.wait_until_booking_time(booking_filter)
|
||||
|
||||
assert pendulum.now() == booking_datetime.add(microseconds=1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_hash(connector, user):
|
||||
async with aiohttp.ClientSession() as session:
|
||||
await connector.land(session)
|
||||
await connector.login(session, user)
|
||||
|
||||
hash_value = await connector.send_hash_request(session)
|
||||
assert hash_value is not None
|
||||
|
||||
|
||||
def test_get_hash_input():
|
||||
resources_folder = Path(__file__).parent / "data"
|
||||
html_file = resources_folder / "user_bookings_html_response.html"
|
||||
html = html_file.read_text(encoding="utf-8")
|
||||
|
||||
hash_value = GestionSportsConnector.get_hash_input(html)
|
||||
|
||||
assert hash_value == "63470fa38e300fd503de1ee21a71b3bdb6fb206b"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_bookings(connector, user):
|
||||
async with aiohttp.ClientSession() as session:
|
||||
await connector.land(session)
|
||||
await connector.login(session, user)
|
||||
|
||||
hash_value = await connector.send_hash_request(session)
|
||||
payload = f"ajax=loadResa&hash={hash_value}"
|
||||
|
||||
bookings = await connector.send_user_bookings_request(session, payload)
|
||||
print(bookings)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_ongoing_bookings(connector, user):
|
||||
async with aiohttp.ClientSession() as session:
|
||||
await connector.land(session)
|
||||
await connector.login(session, user)
|
||||
|
||||
bookings = await connector.get_ongoing_bookings(session)
|
||||
print(bookings)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_has_user_ongoing_bookings(connector, user):
|
||||
assert await connector.has_user_ongoing_booking(user)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_booking_id(connector, user):
|
||||
async with aiohttp.ClientSession() as session:
|
||||
await connector.land(session)
|
||||
await connector.login(session, user)
|
||||
ongoing_bookings = await connector.get_ongoing_bookings(session)
|
||||
booking_id = ongoing_bookings[0].id
|
||||
|
||||
response = await connector.cancel_booking_id(user, booking_id)
|
||||
|
||||
assert len(await connector.get_ongoing_bookings(session)) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
def test_find_court(connector):
|
||||
court = connector.find_court(603, "Padel")
|
||||
|
||||
assert court.number == 6
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_booking(connector, user, booking_filter):
|
||||
async with aiohttp.ClientSession() as session:
|
||||
await connector.land(session)
|
||||
await connector.login(session, user)
|
||||
await connector.cancel_booking(session, booking_filter)
|
20
tests/integration_tests/test_gestion_sports_services.py
Normal file
20
tests/integration_tests/test_gestion_sports_services.py
Normal file
|
@ -0,0 +1,20 @@
|
|||
import pytest
|
||||
from gestion_sports_services import GestionSportsServices
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_booking_success(club, user, booking_filter):
|
||||
court_booked = await GestionSportsServices.book(club, user, booking_filter)
|
||||
|
||||
assert court_booked.id is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_has_available_slots(club, user):
|
||||
has_slots = await GestionSportsServices.has_user_available_slots(user, club)
|
||||
assert has_slots
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_booking(club, user, booking_filter):
|
||||
await GestionSportsServices.cancel_booking(user, club, booking_filter)
|
|
@ -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
|
|
@ -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"
|
379
tests/unit_tests/conftest.py
Normal file
379
tests/unit_tests/conftest.py
Normal file
|
@ -0,0 +1,379 @@
|
|||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pendulum
|
||||
import pytest
|
||||
from connectors import GestionSportsConnector
|
||||
from gestion_sports_services import GestionSportsServices
|
||||
from models import (
|
||||
BookingFilter,
|
||||
BookingOpening,
|
||||
BookingPlatform,
|
||||
Club,
|
||||
Court,
|
||||
Sport,
|
||||
TotalBookings,
|
||||
Url,
|
||||
User,
|
||||
)
|
||||
|
||||
TEST_FOLDER = Path(__file__).parent.parent
|
||||
DATA_FOLDER = TEST_FOLDER / "data"
|
||||
RESPONSES_FOLDER = DATA_FOLDER / "responses"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def court11() -> Court:
|
||||
return Court(id="1", name="Court 1", number=1, isIndoor=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def court12() -> Court:
|
||||
return Court(id="2", name="Court 2", number=2, isIndoor=False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def court13() -> Court:
|
||||
return Court(id="3", name="Court 3", number=3, isIndoor=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def court14() -> Court:
|
||||
return Court(id="4", name="Court 4", number=4, isIndoor=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sport1(court11, court12, court13, court14) -> Sport:
|
||||
return Sport(
|
||||
name="Sport1",
|
||||
id=8,
|
||||
duration=99,
|
||||
price=54,
|
||||
players=3,
|
||||
courts=[court11, court12, court13, court14],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def court21() -> Court:
|
||||
return Court(id="1", name="Court 1", number=1, isIndoor=False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def court22() -> Court:
|
||||
return Court(id="2", name="Court 2", number=2, isIndoor=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def court23() -> Court:
|
||||
return Court(id="3", name="Court 3", number=3, isIndoor=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def court24() -> Court:
|
||||
return Court(id="4", name="Court 4", number=4, isIndoor=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sport2(court21, court22, court23, court24) -> Sport:
|
||||
return Sport(
|
||||
name="Sport 2",
|
||||
id=10,
|
||||
duration=44,
|
||||
price=23,
|
||||
players=1,
|
||||
courts=[court21, court22, court23, court24],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def landing_url() -> Url:
|
||||
return Url(
|
||||
name="landing-page",
|
||||
path="landing.html",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def login_url() -> Url:
|
||||
return Url(
|
||||
name="login",
|
||||
path="login.html",
|
||||
payloadTemplate="gestion-sports/login-payload.txt",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def booking_url() -> Url:
|
||||
return Url(
|
||||
name="booking",
|
||||
path="booking.html",
|
||||
payloadTemplate="gestion-sports/booking-payload.txt",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_bookings_url() -> Url:
|
||||
return Url(
|
||||
name="user-bookings",
|
||||
path="user_bookings.html",
|
||||
payloadTemplate="gestion-sports/user-bookings-payload.txt",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cancellation_url() -> Url:
|
||||
return Url(
|
||||
name="cancellation",
|
||||
path="cancel.html",
|
||||
payloadTemplate="gestion-sports/booking-cancellation-payload.txt",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def booking_opening() -> BookingOpening:
|
||||
return BookingOpening(daysBefore=10, time="03:27")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def total_bookings() -> TotalBookings:
|
||||
return TotalBookings(peakHours=3, offPeakHours="unlimited")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def booking_platform(
|
||||
booking_opening,
|
||||
total_bookings,
|
||||
sport1,
|
||||
sport2,
|
||||
landing_url,
|
||||
login_url,
|
||||
booking_url,
|
||||
user_bookings_url,
|
||||
cancellation_url,
|
||||
) -> BookingPlatform:
|
||||
return BookingPlatform(
|
||||
id="gestion-sports",
|
||||
clubId=21,
|
||||
url="https://ptf1.com",
|
||||
hoursBeforeCancellation=7,
|
||||
bookingOpening=booking_opening,
|
||||
totalBookings=total_bookings,
|
||||
sports=[sport1, sport2],
|
||||
urls={
|
||||
"landing-page": landing_url,
|
||||
"login": login_url,
|
||||
"booking": booking_url,
|
||||
"user-bookings": user_bookings_url,
|
||||
"cancellation": cancellation_url,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def club(booking_platform) -> Club:
|
||||
return Club(
|
||||
id="super_club",
|
||||
name="Super Club",
|
||||
url="https://superclub.com",
|
||||
bookingPlatform=booking_platform,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def connector(club) -> GestionSportsConnector:
|
||||
return GestionSportsConnector(club)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gs_services() -> GestionSportsServices:
|
||||
return GestionSportsServices()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user() -> User:
|
||||
return User(login="padel.testing@jouf.fr", password="ridicule")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def booking_filter() -> BookingFilter:
|
||||
return BookingFilter(
|
||||
sport_name="Sport1", date=pendulum.parse("2024-03-21T13:30:00Z")
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def landing_response() -> str:
|
||||
landing_response_file = RESPONSES_FOLDER / "landing_response.html"
|
||||
return landing_response_file.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def login_success_response() -> dict:
|
||||
login_success_file = RESPONSES_FOLDER / "login_success.json"
|
||||
return json.loads(login_success_file.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def login_failure_response() -> dict:
|
||||
login_failure_file = RESPONSES_FOLDER / "login_failure.json"
|
||||
return json.loads(login_failure_file.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def booking_success_response() -> dict:
|
||||
booking_success_file = RESPONSES_FOLDER / "booking_success.json"
|
||||
return json.loads(booking_success_file.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def booking_failure_response() -> dict:
|
||||
booking_failure_file = RESPONSES_FOLDER / "booking_failure.json"
|
||||
return json.loads(booking_failure_file.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def booked_courts_response(
|
||||
court11,
|
||||
court12,
|
||||
court13,
|
||||
court14,
|
||||
booking_success_response,
|
||||
booking_failure_response,
|
||||
) -> list[tuple[int, dict]]:
|
||||
court1_resp = court11.id, booking_failure_response
|
||||
court2_resp = court12.id, booking_failure_response
|
||||
court3_resp = court13.id, booking_success_response
|
||||
court4_resp = court14.id, booking_failure_response
|
||||
return [court1_resp, court2_resp, court3_resp, court4_resp]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def booking_success_from_start(
|
||||
landing_response,
|
||||
login_success_response,
|
||||
booking_success_response,
|
||||
booking_failure_response,
|
||||
):
|
||||
return [
|
||||
landing_response,
|
||||
login_success_response,
|
||||
booking_failure_response,
|
||||
booking_success_response,
|
||||
booking_failure_response,
|
||||
booking_failure_response,
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def booking_failure_from_start(
|
||||
landing_response,
|
||||
login_success_response,
|
||||
booking_success_response,
|
||||
booking_failure_response,
|
||||
):
|
||||
return [
|
||||
landing_response,
|
||||
login_success_response,
|
||||
booking_failure_response,
|
||||
booking_failure_response,
|
||||
booking_failure_response,
|
||||
booking_failure_response,
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_bookings_get_response() -> str:
|
||||
user_bookings_file = RESPONSES_FOLDER / "user_bookings_get.html"
|
||||
return user_bookings_file.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_bookings_list() -> list:
|
||||
user_bookings_file = RESPONSES_FOLDER / "user_bookings_post.json"
|
||||
return json.loads(user_bookings_file.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_has_ongoing_bookings_from_start(
|
||||
landing_response,
|
||||
login_success_response,
|
||||
user_bookings_get_response,
|
||||
user_bookings_list,
|
||||
) -> list:
|
||||
return [
|
||||
landing_response,
|
||||
login_success_response,
|
||||
user_bookings_get_response,
|
||||
user_bookings_list,
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_bookings_empty_list() -> list:
|
||||
return []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_has_no_ongoing_bookings_from_start(
|
||||
landing_response,
|
||||
login_success_response,
|
||||
user_bookings_get_response,
|
||||
user_bookings_empty_list,
|
||||
) -> list:
|
||||
return [
|
||||
landing_response,
|
||||
login_success_response,
|
||||
user_bookings_get_response,
|
||||
user_bookings_empty_list,
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cancellation_response() -> list:
|
||||
cancellation_response_file = RESPONSES_FOLDER / "cancellation_response.json"
|
||||
return json.loads(cancellation_response_file.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cancellation_by_id_from_start(
|
||||
landing_response,
|
||||
login_success_response,
|
||||
user_bookings_get_response,
|
||||
cancellation_response,
|
||||
):
|
||||
return [
|
||||
landing_response,
|
||||
login_success_response,
|
||||
user_bookings_get_response,
|
||||
cancellation_response,
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cancellation_success_from_start(
|
||||
landing_response,
|
||||
login_success_response,
|
||||
user_bookings_get_response,
|
||||
user_bookings_list,
|
||||
cancellation_response,
|
||||
):
|
||||
return [
|
||||
landing_response,
|
||||
login_success_response,
|
||||
user_bookings_get_response,
|
||||
user_bookings_list,
|
||||
cancellation_response,
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cancellation_success_booking_filter() -> BookingFilter:
|
||||
return BookingFilter(
|
||||
sport_name="Sport1", date=pendulum.parse("2024-03-21T13:30:00Z")
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service() -> GestionSportsServices:
|
||||
return GestionSportsServices()
|
83
tests/unit_tests/responses.py
Normal file
83
tests/unit_tests/responses.py
Normal file
|
@ -0,0 +1,83 @@
|
|||
def make_landing_request_success(aioresponses, connector, landing_response):
|
||||
aioresponses.get(
|
||||
connector.landing_url,
|
||||
status=200,
|
||||
headers={"Set-Cookie": "PHPSESSID=987512"},
|
||||
body=landing_response,
|
||||
)
|
||||
|
||||
|
||||
def make_login_request_fail(aioresponses, connector, login_failure_response):
|
||||
aioresponses.post(
|
||||
connector.login_url,
|
||||
status=200,
|
||||
payload=login_failure_response,
|
||||
)
|
||||
|
||||
|
||||
def make_login_request_success(aioresponses, connector, login_success_response):
|
||||
aioresponses.post(
|
||||
connector.login_url,
|
||||
status=200,
|
||||
headers={"Set-Cookie": "COOK_COMPTE=e2be1;" "COOK_ID_CLUB=22;COOK_ID_USER=666"},
|
||||
payload=login_success_response,
|
||||
)
|
||||
|
||||
|
||||
def set_booking_request(aioresponses, connector, booking_response):
|
||||
aioresponses.post(connector.booking_url, status=200, payload=booking_response)
|
||||
|
||||
|
||||
def set_full_booking_requests_responses(aioresponses, connector, responses_list):
|
||||
make_landing_request_success(aioresponses, connector, responses_list[0])
|
||||
make_login_request_success(aioresponses, connector, responses_list[1])
|
||||
for response in responses_list[2:]:
|
||||
set_booking_request(aioresponses, connector, response)
|
||||
|
||||
|
||||
def set_ongoing_bookings_response(
|
||||
aioresponses, connector, user_bookings_get_response, user_bookings_post_response
|
||||
):
|
||||
set_hash_response(aioresponses, connector, user_bookings_get_response)
|
||||
set_bookings_response(aioresponses, connector, user_bookings_post_response)
|
||||
|
||||
|
||||
def set_hash_response(aioresponses, connector, user_bookings_get_response):
|
||||
aioresponses.get(
|
||||
connector.user_bookings_url, status=200, body=user_bookings_get_response
|
||||
)
|
||||
|
||||
|
||||
def set_bookings_response(aioresponses, connector, user_bookings_post_response):
|
||||
aioresponses.post(
|
||||
connector.user_bookings_url, status=200, payload=user_bookings_post_response
|
||||
)
|
||||
|
||||
|
||||
def set_full_user_bookings_responses(aioresponses, connector, responses):
|
||||
make_landing_request_success(aioresponses, connector, responses[0])
|
||||
make_login_request_success(aioresponses, connector, responses[1])
|
||||
set_ongoing_bookings_response(aioresponses, connector, *responses[2:])
|
||||
|
||||
|
||||
def set_cancellation_response(aioresponses, connector, response):
|
||||
aioresponses.post(connector.booking_cancellation_url, status=200, payload=response)
|
||||
|
||||
|
||||
def set_full_cancellation_by_id_responses(aioresponses, connector, responses):
|
||||
make_landing_request_success(aioresponses, connector, responses[0])
|
||||
make_login_request_success(aioresponses, connector, responses[1])
|
||||
set_hash_response(aioresponses, connector, responses[2])
|
||||
set_cancellation_response(aioresponses, connector, responses[3])
|
||||
|
||||
|
||||
def set_full_cancellation_responses(aioresponses, connector, responses):
|
||||
make_landing_request_success(aioresponses, connector, responses[0])
|
||||
make_login_request_success(aioresponses, connector, responses[1])
|
||||
|
||||
# the request to get the hash is made twice
|
||||
set_hash_response(aioresponses, connector, responses[2])
|
||||
set_hash_response(aioresponses, connector, responses[2])
|
||||
|
||||
set_bookings_response(aioresponses, connector, responses[3])
|
||||
set_cancellation_response(aioresponses, connector, responses[4])
|
0
tests/unit_tests/test_booking.py
Normal file
0
tests/unit_tests/test_booking.py
Normal file
51
tests/unit_tests/test_config.py
Normal file
51
tests/unit_tests/test_config.py
Normal 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
|
145
tests/unit_tests/test_gestion_sports_connector.py
Normal file
145
tests/unit_tests/test_gestion_sports_connector.py
Normal file
|
@ -0,0 +1,145 @@
|
|||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from aiohttp import ClientSession
|
||||
from connectors import GestionSportsConnector
|
||||
|
||||
from tests.unit_tests import responses
|
||||
|
||||
|
||||
def test_urls(connector, club):
|
||||
base_url = club.booking_platform.url
|
||||
relative_urls = club.booking_platform.urls
|
||||
|
||||
relative_landing_url = relative_urls.get("landing-page").path
|
||||
assert connector.landing_url == f"{base_url}/{relative_landing_url}"
|
||||
|
||||
relative_login_url = relative_urls.get("login").path
|
||||
assert connector.login_url == f"{base_url}/{relative_login_url}"
|
||||
|
||||
relative_booking_url = relative_urls.get("booking").path
|
||||
assert connector.booking_url == f"{base_url}/{relative_booking_url}"
|
||||
|
||||
relative_user_bookings_url = relative_urls.get("user-bookings").path
|
||||
assert connector.user_bookings_url == f"{base_url}/{relative_user_bookings_url}"
|
||||
|
||||
relative_cancel_url = relative_urls.get("cancellation").path
|
||||
assert connector.booking_cancellation_url == f"{base_url}/{relative_cancel_url}"
|
||||
|
||||
|
||||
@patch("config.get_resources_folder")
|
||||
def test_urls_payload_templates(mock_resources, club):
|
||||
path_to_resources = Path("some/path/to/resource")
|
||||
mock_resources.return_value = path_to_resources
|
||||
|
||||
connector = GestionSportsConnector(club)
|
||||
relative_urls = club.booking_platform.urls
|
||||
|
||||
login_payload = relative_urls.get("login").payload_template
|
||||
assert connector.login_template == path_to_resources / login_payload
|
||||
|
||||
booking_payload = relative_urls.get("booking").payload_template
|
||||
assert connector.booking_template == path_to_resources / booking_payload
|
||||
|
||||
user_bookings_payload = relative_urls.get("user-bookings").payload_template
|
||||
assert connector.user_bookings_template == path_to_resources / user_bookings_payload
|
||||
|
||||
cancel_payload = relative_urls.get("cancellation").payload_template
|
||||
assert connector.booking_cancel_template == path_to_resources / cancel_payload
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_landing_page(aioresponses, connector, landing_response):
|
||||
responses.make_landing_request_success(aioresponses, connector, landing_response)
|
||||
|
||||
async with ClientSession() as session:
|
||||
response = await connector.land(session)
|
||||
|
||||
assert response.status == 200
|
||||
assert response.cookies.get("PHPSESSID").value == "987512"
|
||||
assert await response.text() == landing_response
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_success(aioresponses, connector, user, login_success_response):
|
||||
responses.make_login_request_success(
|
||||
aioresponses, connector, login_success_response
|
||||
)
|
||||
|
||||
async with ClientSession() as session:
|
||||
response = await connector.login(session, user)
|
||||
|
||||
assert response.status == 200
|
||||
assert response.cookies.get("COOK_COMPTE").value == "e2be1"
|
||||
assert response.cookies.get("COOK_ID_CLUB").value == "22"
|
||||
assert response.cookies.get("COOK_ID_USER").value == "666"
|
||||
assert await response.json() == login_success_response
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_failure(aioresponses, connector, user, login_failure_response):
|
||||
responses.make_login_request_fail(aioresponses, connector, login_failure_response)
|
||||
|
||||
async with ClientSession() as session:
|
||||
response = await connector.login(session, user)
|
||||
|
||||
assert response.status == 200
|
||||
assert len(response.cookies) == 0
|
||||
assert await response.json() == login_failure_response
|
||||
|
||||
|
||||
def test_get_booked_court(connector, booked_courts_response):
|
||||
booked_court = connector.get_booked_court(booked_courts_response, "Sport1")
|
||||
assert booked_court.number == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_ongoing_bookings(
|
||||
aioresponses,
|
||||
connector,
|
||||
user,
|
||||
user_bookings_get_response,
|
||||
user_bookings_list,
|
||||
):
|
||||
responses.set_ongoing_bookings_response(
|
||||
aioresponses, connector, user_bookings_get_response, user_bookings_list
|
||||
)
|
||||
|
||||
async with ClientSession() as session:
|
||||
bookings = await connector.get_ongoing_bookings(session)
|
||||
|
||||
assert len(bookings) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancellation_request(
|
||||
aioresponses, connector, user_bookings_get_response, cancellation_response
|
||||
):
|
||||
responses.set_hash_response(aioresponses, connector, user_bookings_get_response)
|
||||
responses.set_cancellation_response(aioresponses, connector, cancellation_response)
|
||||
|
||||
async with ClientSession() as session:
|
||||
response = await connector.cancel_booking_id(session, 123)
|
||||
|
||||
assert await response.json() == cancellation_response
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_booking_success(
|
||||
aioresponses,
|
||||
connector,
|
||||
user,
|
||||
cancellation_success_booking_filter,
|
||||
cancellation_success_from_start,
|
||||
):
|
||||
responses.set_full_cancellation_responses(
|
||||
aioresponses, connector, cancellation_success_from_start
|
||||
)
|
||||
|
||||
async with ClientSession() as session:
|
||||
response = await connector.cancel_booking(
|
||||
session, cancellation_success_booking_filter
|
||||
)
|
||||
|
||||
assert await response.json() == cancellation_success_from_start[4]
|
110
tests/unit_tests/test_gestion_sports_services.py
Normal file
110
tests/unit_tests/test_gestion_sports_services.py
Normal file
|
@ -0,0 +1,110 @@
|
|||
import pytest
|
||||
from gestion_sports_services import GestionSportsServices
|
||||
|
||||
from tests.unit_tests import responses
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_booking_success(
|
||||
aioresponses,
|
||||
connector,
|
||||
club,
|
||||
user,
|
||||
booking_filter,
|
||||
booking_success_from_start,
|
||||
):
|
||||
responses.set_full_booking_requests_responses(
|
||||
aioresponses, connector, booking_success_from_start
|
||||
)
|
||||
|
||||
court_booked = await GestionSportsServices.book(club, user, booking_filter)
|
||||
|
||||
assert court_booked.id == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_booking_failure(
|
||||
aioresponses,
|
||||
gs_services,
|
||||
connector,
|
||||
club,
|
||||
user,
|
||||
booking_filter,
|
||||
booking_failure_from_start,
|
||||
):
|
||||
responses.set_full_booking_requests_responses(
|
||||
aioresponses, connector, booking_failure_from_start
|
||||
)
|
||||
|
||||
court_booked = await gs_services.book(club, user, booking_filter)
|
||||
|
||||
assert court_booked is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_has_available_booking_slots(
|
||||
aioresponses,
|
||||
gs_services,
|
||||
connector,
|
||||
user,
|
||||
club,
|
||||
user_has_ongoing_bookings_from_start,
|
||||
):
|
||||
responses.set_full_user_bookings_responses(
|
||||
aioresponses, connector, user_has_ongoing_bookings_from_start
|
||||
)
|
||||
|
||||
has_user_available_slots = await gs_services.has_user_available_slots(user, club)
|
||||
|
||||
assert has_user_available_slots
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_has_no_available_booking_slots(
|
||||
aioresponses,
|
||||
gs_services,
|
||||
connector,
|
||||
user,
|
||||
club,
|
||||
user_has_no_ongoing_bookings_from_start,
|
||||
):
|
||||
responses.set_full_user_bookings_responses(
|
||||
aioresponses, connector, user_has_no_ongoing_bookings_from_start
|
||||
)
|
||||
|
||||
has_user_available_slots = await gs_services.has_user_available_slots(user, club)
|
||||
|
||||
assert not has_user_available_slots
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_booking(
|
||||
aioresponses,
|
||||
gs_services,
|
||||
connector,
|
||||
user,
|
||||
club,
|
||||
booking_filter,
|
||||
cancellation_success_from_start,
|
||||
):
|
||||
responses.set_full_cancellation_responses(
|
||||
aioresponses, connector, cancellation_success_from_start
|
||||
)
|
||||
|
||||
await gs_services.cancel_booking(user, club, booking_filter)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_booking_id(
|
||||
aioresponses,
|
||||
gs_services,
|
||||
connector,
|
||||
user,
|
||||
club,
|
||||
cancellation_success_from_start,
|
||||
):
|
||||
responses.set_full_cancellation_responses(
|
||||
aioresponses, connector, cancellation_success_from_start
|
||||
)
|
||||
|
||||
await gs_services.cancel_booking_id(user, club, 65464)
|
119
tests/utils.py
119
tests/utils.py
|
@ -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)
|
Loading…
Add table
Add a link
Reference in a new issue