Choose a user with booking availability among many

This commit is contained in:
Stanislas Jouffroy 2024-03-05 00:24:28 +01:00
parent a8322d6be0
commit 559c3b6d69
18 changed files with 1810 additions and 147 deletions

View file

@ -21,15 +21,30 @@ async def book(club: Club, user: User, booking_filter: BookingFilter) -> int | N
return await platform.book(user, booking_filter)
async def get_user_without_booking(club: Club, users: list[User]) -> User | None:
"""
Return the first user who has no booking
:param club: the club where to book
:param users: the list of users
:return: any user who has no booking
"""
async with GestionSportsPlatform(club) as platform:
for user in users:
if await platform.user_has_no_ongoing_booking(user):
return user
return None
def main() -> int | None:
"""
Main function used to book a court
:return: the id of the booked court, or None if no court was booked
"""
user = config.get_user()
booking_filter = config.get_booking_filter()
club = config.get_club()
user = asyncio.run(get_user_without_booking(club, config.get_available_users()))
LOGGER.info(
"Starting booking court of sport %s for user %s at club %s at %s",

View file

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

View file

@ -5,9 +5,11 @@ 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
@ -32,7 +34,7 @@ class GestionSportsConnector:
:return: the URL to the landing page
"""
return urljoin(self.url, "/connexion.php?")
return urljoin(self.url, "/connexion.php")
@property
def login_url(self) -> str:
@ -41,7 +43,7 @@ class GestionSportsConnector:
:return: the URL to the login page
"""
return urljoin(self.url, "/connexion.php?")
return urljoin(self.url, "/connexion.php")
@property
def booking_url(self) -> str:
@ -50,7 +52,16 @@ class GestionSportsConnector:
:return: the URL to the booking page
"""
return urljoin(self.url, "/membre/reservation.html?")
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:
"""
@ -133,10 +144,16 @@ class GestionSportsConnector:
) as response:
resp_json = await response.text()
LOGGER.debug("Response from booking request:\n'%s'", resp_json)
return court_id, self.is_response_status_ok(resp_json)
return court_id, self.is_booking_response_status_ok(resp_json)
@staticmethod
def get_booked_court(bookings):
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)
@ -145,10 +162,38 @@ class GestionSportsConnector:
return None
@staticmethod
def is_response_status_ok(response: str) -> bool:
def is_booking_response_status_ok(response: str) -> bool:
"""
Check if the booking response is OK
:param response: the response as a string
:return: true if the status is ok, false otherwise
"""
formatted_result = response.removeprefix('"').removesuffix('"')
result_json = json.loads(formatted_result)
return result_json["status"] == "ok"
def get_user_information(self):
pass
async def get_ongoing_bookings(self) -> dict:
"""
Get the list of all ongoing bookings of a user.
The steps to perform this are to get the user's bookings page and get a hidden
property in the HTML to get a hash that will be used in the payload of the
POST request (sic) to get the user's bookings.
Gestion sports is really a mess!!
:return: the list of all ongoing bookings of a user
"""
async with self.session.get(self.user_bookings_url) as get_resp:
html = await get_resp.text()
hash_value = html_parser.get_hash_input(html)
payload_builder = GestionSportsUsersBookingsPayloadBuilder()
payload_builder.hash(hash_value)
payload = payload_builder.build()
async with self.session.post(
self.user_bookings_url, data=payload, headers=POST_HEADERS
) as response:
resp = await response.text()
LOGGER.debug("ongoing bookings response: %s\n", resp)
return json.loads(resp)

View file

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

View file

@ -26,23 +26,49 @@ class GestionSportsPlatform:
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
return None
if user is None or booking_filter is None:
LOGGER.error("Not enough information available to book a court")
return
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_can_book(self, user: User, club: 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)
await self.connector.get_user_information()
bookings = await self.connector.get_ongoing_bookings()
return bookings == []
def wait_until_booking_time(club: Club, booking_filter: BookingFilter):

View file

@ -1,23 +1,48 @@
from exceptions import ArgumentMissing
from gestion_sports.gestion_sports_config import BOOKING_TEMPLATE, LOGIN_TEMPLATE
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):
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:
@ -35,14 +60,31 @@ class GestionSportsBookingPayloadBuilder:
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):
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:
@ -54,3 +96,33 @@ class GestionSportsBookingPayloadBuilder:
return template.render(
court_id=self._court_id, booking_filter=self._booking_filter
)
class GestionSportsUsersBookingsPayloadBuilder:
def __init__(self):
self._hash: str | None = None
def hash(self, hash_value: str):
"""
Set the hash
:param hash_value: the hash
:return: the class itself
"""
self._hash = hash_value
def build(self) -> str:
"""
Build the payload
:return: the string representation of the payload
"""
if self._hash is None:
raise ArgumentMissing("No hash was provided")
environment = Environment(
loader=FileSystemLoader(USERS_BOOKINGS_TEMPLATE.parent)
)
template = environment.get_template(USERS_BOOKINGS_TEMPLATE.name)
return template.render(hash=self._hash)

View file

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