Choose a user with booking availability among many
This commit is contained in:
parent
a8322d6be0
commit
559c3b6d69
18 changed files with 1810 additions and 147 deletions
|
@ -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",
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
||||
|
|
16
resa_padel/gestion_sports/gestion_sports_html_parser.py
Normal file
16
resa_padel/gestion_sports/gestion_sports_html_parser.py
Normal 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()
|
|
@ -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):
|
||||
|
|
|
@ -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)
|
||||
|
|
1
resa_padel/resources/gestion-sports/users_bookings.txt
Normal file
1
resa_padel/resources/gestion-sports/users_bookings.txt
Normal file
|
@ -0,0 +1 @@
|
|||
ajax=loadResa&hash={{ hash }}
|
Loading…
Add table
Add a link
Reference in a new issue