pysportbot 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pysportbot/__init__.py +116 -0
- pysportbot/activities.py +122 -0
- pysportbot/authenticator.py +118 -0
- pysportbot/bookings.py +87 -0
- pysportbot/centres.py +108 -0
- pysportbot/endpoints.py +40 -0
- pysportbot/service/__init__.py +0 -0
- pysportbot/service/__main__.py +32 -0
- pysportbot/service/booking.py +147 -0
- pysportbot/service/config_loader.py +7 -0
- pysportbot/service/config_validator.py +54 -0
- pysportbot/service/scheduling.py +61 -0
- pysportbot/service/service.py +47 -0
- pysportbot/session.py +47 -0
- pysportbot/utils/__init__.py +0 -0
- pysportbot/utils/errors.py +140 -0
- pysportbot/utils/logger.py +78 -0
- pysportbot/utils/time.py +39 -0
- pysportbot-0.0.1.dist-info/LICENSE +21 -0
- pysportbot-0.0.1.dist-info/METADATA +148 -0
- pysportbot-0.0.1.dist-info/RECORD +22 -0
- pysportbot-0.0.1.dist-info/WHEEL +4 -0
pysportbot/__init__.py
ADDED
@@ -0,0 +1,116 @@
|
|
1
|
+
import logging
|
2
|
+
from typing import Optional
|
3
|
+
|
4
|
+
from pandas import DataFrame
|
5
|
+
|
6
|
+
from .activities import Activities
|
7
|
+
from .authenticator import Authenticator
|
8
|
+
from .bookings import Bookings
|
9
|
+
from .centres import Centres
|
10
|
+
from .session import Session
|
11
|
+
from .utils.errors import ErrorMessages
|
12
|
+
from .utils.logger import set_log_level, setup_logger
|
13
|
+
|
14
|
+
|
15
|
+
class SportBot:
|
16
|
+
"""Unified interface for interacting with the booking system."""
|
17
|
+
|
18
|
+
def __init__(self, log_level: str = "INFO", print_centres: bool = False) -> None:
|
19
|
+
setup_logger(log_level)
|
20
|
+
self._logger = logging.getLogger("SportBot")
|
21
|
+
self._logger.info("Initializing SportBot...")
|
22
|
+
self._centres = Centres(print_centres)
|
23
|
+
self._session: Session = Session()
|
24
|
+
self._auth: Optional[Authenticator] = None
|
25
|
+
self._activities: Activities = Activities(self._session)
|
26
|
+
self._bookings: Bookings = Bookings(self._session)
|
27
|
+
self._df_activities: DataFrame | None = None
|
28
|
+
self._is_logged_in: bool = False # State variable for login status
|
29
|
+
|
30
|
+
def set_log_level(self, log_level: str) -> None:
|
31
|
+
set_log_level(log_level)
|
32
|
+
self._logger.info(f"Log level changed to {log_level}.")
|
33
|
+
|
34
|
+
def login(self, email: str, password: str, centre: str) -> None:
|
35
|
+
|
36
|
+
# Check if the selected centre is valid
|
37
|
+
self._centres.check_centre(centre)
|
38
|
+
self._logger.info(f"Selected centre: {centre}")
|
39
|
+
|
40
|
+
# Initialize the Authenticator
|
41
|
+
self._auth = Authenticator(self._session, centre)
|
42
|
+
|
43
|
+
self._logger.info("Attempting to log in...")
|
44
|
+
try:
|
45
|
+
self._auth.login(email, password)
|
46
|
+
self._df_activities = self._activities.fetch()
|
47
|
+
self._is_logged_in = True
|
48
|
+
self._logger.info("Login successful!")
|
49
|
+
except Exception:
|
50
|
+
self._is_logged_in = False # Ensure state is False on failure
|
51
|
+
self._logger.exception(ErrorMessages.login_failed())
|
52
|
+
raise
|
53
|
+
|
54
|
+
def is_logged_in(self) -> bool:
|
55
|
+
"""Returns the login status."""
|
56
|
+
return self._is_logged_in
|
57
|
+
|
58
|
+
def activities(self, limit: int | None = None) -> DataFrame:
|
59
|
+
if not self._is_logged_in:
|
60
|
+
self._logger.error(ErrorMessages.not_logged_in())
|
61
|
+
raise PermissionError(ErrorMessages.not_logged_in())
|
62
|
+
|
63
|
+
if self._df_activities is None:
|
64
|
+
self._logger.error(ErrorMessages.no_activities_loaded())
|
65
|
+
raise ValueError(ErrorMessages.no_activities_loaded())
|
66
|
+
|
67
|
+
df = self._df_activities[["name_activity", "id_activity"]]
|
68
|
+
return df.head(limit) if limit else df
|
69
|
+
|
70
|
+
def daily_slots(self, activity: str, day: str, limit: int | None = None) -> DataFrame:
|
71
|
+
if not self._is_logged_in:
|
72
|
+
self._logger.error(ErrorMessages.not_logged_in())
|
73
|
+
raise PermissionError(ErrorMessages.not_logged_in())
|
74
|
+
|
75
|
+
if self._df_activities is None:
|
76
|
+
self._logger.error(ErrorMessages.no_activities_loaded())
|
77
|
+
raise ValueError(ErrorMessages.no_activities_loaded())
|
78
|
+
|
79
|
+
df = self._activities.daily_slots(self._df_activities, activity, day)
|
80
|
+
return df.head(limit) if limit else df
|
81
|
+
|
82
|
+
def book(self, activity: str, start_time: str) -> None:
|
83
|
+
if not self._is_logged_in:
|
84
|
+
self._logger.error(ErrorMessages.not_logged_in())
|
85
|
+
raise PermissionError(ErrorMessages.not_logged_in())
|
86
|
+
|
87
|
+
if self._df_activities is None:
|
88
|
+
self._logger.error(ErrorMessages.no_activities_loaded())
|
89
|
+
raise ValueError(ErrorMessages.no_activities_loaded())
|
90
|
+
|
91
|
+
slots = self.daily_slots(activity, start_time.split(" ")[0])
|
92
|
+
matching_slot = slots[slots["start_timestamp"] == start_time]
|
93
|
+
if matching_slot.empty:
|
94
|
+
error_msg = ErrorMessages.slot_not_found(activity, start_time)
|
95
|
+
self._logger.error(error_msg)
|
96
|
+
raise IndexError(error_msg)
|
97
|
+
|
98
|
+
self._bookings.book(matching_slot.iloc[0]["id_activity_calendar"])
|
99
|
+
|
100
|
+
def cancel(self, activity: str, start_time: str) -> None:
|
101
|
+
if not self._is_logged_in:
|
102
|
+
self._logger.error(ErrorMessages.not_logged_in())
|
103
|
+
raise PermissionError(ErrorMessages.not_logged_in())
|
104
|
+
|
105
|
+
if self._df_activities is None:
|
106
|
+
self._logger.error(ErrorMessages.no_activities_loaded())
|
107
|
+
raise ValueError(ErrorMessages.no_activities_loaded())
|
108
|
+
|
109
|
+
slots = self.daily_slots(activity, start_time.split(" ")[0])
|
110
|
+
matching_slot = slots[slots["start_timestamp"] == start_time]
|
111
|
+
if matching_slot.empty:
|
112
|
+
error_msg = ErrorMessages.slot_not_found(activity, start_time)
|
113
|
+
self._logger.error(error_msg)
|
114
|
+
raise IndexError(error_msg)
|
115
|
+
|
116
|
+
self._bookings.cancel(matching_slot.iloc[0]["id_activity_calendar"])
|
pysportbot/activities.py
ADDED
@@ -0,0 +1,122 @@
|
|
1
|
+
import json
|
2
|
+
|
3
|
+
import pandas as pd
|
4
|
+
from pandas import DataFrame
|
5
|
+
|
6
|
+
from .endpoints import Endpoints
|
7
|
+
from .session import Session
|
8
|
+
from .utils.errors import ErrorMessages
|
9
|
+
from .utils.logger import get_logger
|
10
|
+
from .utils.time import get_unix_day_bounds
|
11
|
+
|
12
|
+
logger = get_logger(__name__)
|
13
|
+
|
14
|
+
|
15
|
+
class Activities:
|
16
|
+
"""Handles activity fetching and slot management."""
|
17
|
+
|
18
|
+
def __init__(self, session: Session) -> None:
|
19
|
+
"""Initialize the Activities class."""
|
20
|
+
self.session = session.session
|
21
|
+
self.headers = session.headers
|
22
|
+
|
23
|
+
def fetch(self) -> DataFrame:
|
24
|
+
"""
|
25
|
+
Fetch all available activities.
|
26
|
+
|
27
|
+
Returns:
|
28
|
+
DataFrame: A DataFrame containing activity details.
|
29
|
+
|
30
|
+
Raises:
|
31
|
+
RuntimeError: If the request fails.
|
32
|
+
"""
|
33
|
+
logger.info("Fetching activities...")
|
34
|
+
response = self.session.post(Endpoints.ACTIVITIES, headers=self.headers)
|
35
|
+
if response.status_code != 200:
|
36
|
+
error_msg = ErrorMessages.failed_fetch("activities")
|
37
|
+
logger.error(error_msg)
|
38
|
+
raise RuntimeError(error_msg)
|
39
|
+
logger.info("Activities fetched successfully.")
|
40
|
+
activities = json.loads(response.content.decode("utf-8"))["activities"]
|
41
|
+
return pd.DataFrame.from_dict(activities, orient="index")
|
42
|
+
|
43
|
+
def daily_slots(self, df_activities: DataFrame, activity_name: str, day: str) -> DataFrame:
|
44
|
+
"""
|
45
|
+
Fetch available slots for a specific activity on a given day.
|
46
|
+
|
47
|
+
Args:
|
48
|
+
df_activities (DataFrame): The DataFrame of activities.
|
49
|
+
activity_name (str): The name of the activity.
|
50
|
+
day (str): The day in 'YYYY-MM-DD' format.
|
51
|
+
|
52
|
+
Returns:
|
53
|
+
DataFrame: A DataFrame containing available slots.
|
54
|
+
|
55
|
+
Raises:
|
56
|
+
ValueError: If the specified activity is not found.
|
57
|
+
RuntimeError: If slots cannot be fetched.
|
58
|
+
"""
|
59
|
+
logger.info(f"Fetching daily slots for '{activity_name}' on {day}...")
|
60
|
+
|
61
|
+
# Check if the activity exists
|
62
|
+
activity_match = df_activities[df_activities["name_activity"] == activity_name]
|
63
|
+
if activity_match.empty:
|
64
|
+
error_msg = ErrorMessages.activity_not_found(
|
65
|
+
activity_name, df_activities["name_activity"].unique().tolist()
|
66
|
+
)
|
67
|
+
logger.error(error_msg)
|
68
|
+
raise ValueError(error_msg)
|
69
|
+
|
70
|
+
# Extract activity ID
|
71
|
+
activity_id = str(activity_match.id_activity.iloc[0])
|
72
|
+
id_category_activity = df_activities.loc[activity_id].activityCategoryId
|
73
|
+
|
74
|
+
# Get Unix timestamp bounds for the day
|
75
|
+
unix_day_bounds = get_unix_day_bounds(day)
|
76
|
+
|
77
|
+
# Fetch slots
|
78
|
+
params = {
|
79
|
+
"id_category_activity": id_category_activity,
|
80
|
+
"start": unix_day_bounds[0],
|
81
|
+
"end": unix_day_bounds[1],
|
82
|
+
}
|
83
|
+
response = self.session.get(Endpoints.SLOTS, headers=self.headers, params=params)
|
84
|
+
if response.status_code != 200:
|
85
|
+
error_msg = ErrorMessages.failed_fetch("slots")
|
86
|
+
logger.error(error_msg)
|
87
|
+
raise RuntimeError(error_msg)
|
88
|
+
|
89
|
+
slots = json.loads(response.content.decode("utf-8"))
|
90
|
+
if not slots:
|
91
|
+
warning_msg = ErrorMessages.no_slots(activity_name, day)
|
92
|
+
logger.warning(warning_msg)
|
93
|
+
return DataFrame()
|
94
|
+
|
95
|
+
logger.info(f"Daily slots fetched for '{activity_name}' on {day}.")
|
96
|
+
|
97
|
+
# Filter desired columns
|
98
|
+
columns = [
|
99
|
+
"name_activity",
|
100
|
+
"id_activity_calendar",
|
101
|
+
"id_activity",
|
102
|
+
"id_category_activity",
|
103
|
+
"start_timestamp",
|
104
|
+
"end_timestamp",
|
105
|
+
"n_inscribed",
|
106
|
+
"capacity",
|
107
|
+
"n_waiting_list",
|
108
|
+
"cancelled",
|
109
|
+
"can_join",
|
110
|
+
"trainer",
|
111
|
+
]
|
112
|
+
df_slots = pd.DataFrame(slots)
|
113
|
+
df_slots = df_slots[df_slots.columns.intersection(columns)] # Ensure no KeyError
|
114
|
+
|
115
|
+
# Only select rows of the specified activity
|
116
|
+
df_slots = df_slots[df_slots.id_activity == activity_id]
|
117
|
+
if df_slots.empty:
|
118
|
+
warning_msg = ErrorMessages.no_matching_slots(activity_name, day)
|
119
|
+
logger.warning(warning_msg)
|
120
|
+
return DataFrame()
|
121
|
+
|
122
|
+
return df_slots
|
@@ -0,0 +1,118 @@
|
|
1
|
+
import ast
|
2
|
+
import json
|
3
|
+
from urllib.parse import parse_qs
|
4
|
+
|
5
|
+
from bs4 import BeautifulSoup
|
6
|
+
|
7
|
+
from .endpoints import Endpoints
|
8
|
+
from .session import Session
|
9
|
+
from .utils.errors import ErrorMessages
|
10
|
+
from .utils.logger import get_logger
|
11
|
+
|
12
|
+
logger = get_logger(__name__)
|
13
|
+
|
14
|
+
|
15
|
+
class Authenticator:
|
16
|
+
"""Handles user authentication and Nubapp login functionality."""
|
17
|
+
|
18
|
+
def __init__(self, session: Session, centre: str) -> None:
|
19
|
+
"""
|
20
|
+
Initialize the Authenticator.
|
21
|
+
|
22
|
+
Args:
|
23
|
+
session (Session): An instance of the Session class.
|
24
|
+
"""
|
25
|
+
self.session = session.session
|
26
|
+
self.headers = session.headers
|
27
|
+
# Has the user successfully authenticated?
|
28
|
+
self.authenticated = False
|
29
|
+
# User ID for the authenticated user
|
30
|
+
self.user_id = None
|
31
|
+
# Centre selected by the user
|
32
|
+
self.centre = centre
|
33
|
+
|
34
|
+
def login(self, email: str, password: str) -> None:
|
35
|
+
"""
|
36
|
+
Authenticate the user with email and password and log in to Nubapp.
|
37
|
+
|
38
|
+
Args:
|
39
|
+
email (str): The user's email address.
|
40
|
+
password (str): The user's password.
|
41
|
+
|
42
|
+
Raises:
|
43
|
+
RuntimeError: If the login process fails at any stage.
|
44
|
+
"""
|
45
|
+
logger.info("Starting login process...")
|
46
|
+
|
47
|
+
# Step 1: Fetch CSRF token
|
48
|
+
logger.debug(f"GET {Endpoints.USER_LOGIN} | Headers: {json.dumps(self.headers, indent=2)}")
|
49
|
+
response = self.session.get(Endpoints.USER_LOGIN, headers=self.headers)
|
50
|
+
if response.status_code != 200:
|
51
|
+
logger.error(f"Failed to fetch login popup: {response.status_code}")
|
52
|
+
raise RuntimeError(ErrorMessages.failed_fetch("login popup"))
|
53
|
+
logger.debug("Login popup fetched successfully.")
|
54
|
+
csrf_token = BeautifulSoup(response.text, "html.parser").find("input", {"name": "_csrf_token"})["value"]
|
55
|
+
|
56
|
+
# Step 2: Perform login
|
57
|
+
payload = {
|
58
|
+
"_username": email,
|
59
|
+
"_password": password,
|
60
|
+
"_csrf_token": csrf_token,
|
61
|
+
"_submit": "",
|
62
|
+
"_force": "true",
|
63
|
+
}
|
64
|
+
self.headers.update({"Content-Type": "application/x-www-form-urlencoded"})
|
65
|
+
logger.debug(
|
66
|
+
f"POST {Endpoints.LOGIN_CHECK} | Headers: {json.dumps(self.headers, indent=2)} | "
|
67
|
+
f"Payload: {json.dumps(payload, indent=2)}"
|
68
|
+
)
|
69
|
+
response = self.session.post(Endpoints.LOGIN_CHECK, data=payload, headers=self.headers)
|
70
|
+
if response.status_code != 200:
|
71
|
+
logger.error(f"Login failed: {response.status_code}, {response.text}")
|
72
|
+
raise ValueError(ErrorMessages.failed_login())
|
73
|
+
logger.info("Login successful!")
|
74
|
+
|
75
|
+
# Step 3: Retrieve credentials for Nubapp
|
76
|
+
cred_endpoint = Endpoints.get_cred_endpoint(self.centre)
|
77
|
+
logger.debug(f"GET {cred_endpoint} | Headers: {json.dumps(self.headers, indent=2)}")
|
78
|
+
response = self.session.get(cred_endpoint, headers=self.headers)
|
79
|
+
if response.status_code != 200:
|
80
|
+
logger.error(f"Failed to retrieve Nubapp credentials: {response.status_code}")
|
81
|
+
raise RuntimeError(ErrorMessages.failed_fetch("credentials"))
|
82
|
+
nubapp_creds = ast.literal_eval(response.content.decode("utf-8"))["payload"]
|
83
|
+
nubapp_creds = {k: v[0] for k, v in parse_qs(nubapp_creds).items()}
|
84
|
+
nubapp_creds["platform"] = "resasocial"
|
85
|
+
nubapp_creds["network"] = "resasports"
|
86
|
+
logger.debug(f"Nubapp credentials retrieved: {json.dumps(nubapp_creds, indent=2)}")
|
87
|
+
|
88
|
+
# Step 4: Log in to Nubapp
|
89
|
+
logger.debug(
|
90
|
+
f"GET {Endpoints.NUBAP_LOGIN} | Headers: {json.dumps(self.headers, indent=2)} | "
|
91
|
+
f"Params: {json.dumps(nubapp_creds, indent=2)}"
|
92
|
+
)
|
93
|
+
response = self.session.get(Endpoints.NUBAP_LOGIN, headers=self.headers, params=nubapp_creds)
|
94
|
+
if response.status_code != 200:
|
95
|
+
logger.error(f"Login to Nubapp failed: {response.status_code}, {response.text}")
|
96
|
+
raise ValueError(ErrorMessages.failed_login_nubapp())
|
97
|
+
logger.info("Login to Nubapp successful!")
|
98
|
+
|
99
|
+
# Step 5: Get user information
|
100
|
+
response = self.session.post(Endpoints.USER, headers=self.headers, allow_redirects=True)
|
101
|
+
|
102
|
+
if response.status_code == 200:
|
103
|
+
response_dict = json.loads(response.content.decode("utf-8"))
|
104
|
+
|
105
|
+
if response_dict["user"]:
|
106
|
+
self.user_id = response_dict.get("user", {}).get("id_user")
|
107
|
+
if self.user_id:
|
108
|
+
self.authenticated = True
|
109
|
+
logger.info(f"Authentication successful. User ID: {self.user_id}")
|
110
|
+
else:
|
111
|
+
self.authenticated = False
|
112
|
+
raise ValueError()
|
113
|
+
else:
|
114
|
+
self.authenticated = False
|
115
|
+
raise ValueError(ErrorMessages.failed_login())
|
116
|
+
else:
|
117
|
+
logger.error(f"Failed to retrieve user information: {response.status_code}, {response.text}")
|
118
|
+
raise ValueError(ErrorMessages.failed_login())
|
pysportbot/bookings.py
ADDED
@@ -0,0 +1,87 @@
|
|
1
|
+
import json
|
2
|
+
|
3
|
+
from .endpoints import Endpoints
|
4
|
+
from .session import Session
|
5
|
+
from .utils.errors import ErrorMessages
|
6
|
+
from .utils.logger import get_logger
|
7
|
+
|
8
|
+
logger = get_logger(__name__)
|
9
|
+
|
10
|
+
|
11
|
+
class Bookings:
|
12
|
+
"""Handles booking and cancellation of activity slots."""
|
13
|
+
|
14
|
+
def __init__(self, session: Session) -> None:
|
15
|
+
"""Initialize the Bookings class."""
|
16
|
+
self.session = session.session
|
17
|
+
self.headers = session.headers
|
18
|
+
|
19
|
+
def book(self, slot_id: str) -> None:
|
20
|
+
"""
|
21
|
+
Book a specific slot by its ID.
|
22
|
+
|
23
|
+
Args:
|
24
|
+
slot_id (str): The unique ID of the activity slot.
|
25
|
+
|
26
|
+
Raises:
|
27
|
+
ValueError: If the slot is already booked or unavailable.
|
28
|
+
RuntimeError: If an unknown error occurs during booking.
|
29
|
+
"""
|
30
|
+
logger.info(f"Attempting to book slot {slot_id}...")
|
31
|
+
|
32
|
+
# Payload for booking
|
33
|
+
payload = {
|
34
|
+
"items[activities][0][id_activity_calendar]": slot_id,
|
35
|
+
"items[activities][0][unit_price]": "0",
|
36
|
+
"items[activities][0][n_guests]": "0",
|
37
|
+
"items[activities][0][id_resource]": "false",
|
38
|
+
"discount_code": "false",
|
39
|
+
"form": "",
|
40
|
+
"formIntoNotes": "",
|
41
|
+
}
|
42
|
+
|
43
|
+
# Send booking request
|
44
|
+
response = self.session.post(Endpoints.BOOKING, data=payload, headers=self.headers)
|
45
|
+
response_json = json.loads(response.content.decode("utf-8"))
|
46
|
+
|
47
|
+
# Handle response
|
48
|
+
if response_json["error"] == 0:
|
49
|
+
logger.info(f"Successfully booked slot {slot_id}.")
|
50
|
+
elif response_json["error"] == 5:
|
51
|
+
logger.warning(f"Slot {slot_id} is already booked.")
|
52
|
+
raise ValueError(ErrorMessages.slot_already_booked())
|
53
|
+
elif response_json["error"] == 6:
|
54
|
+
logger.warning(f"Slot {slot_id} is not available.")
|
55
|
+
raise ValueError(ErrorMessages.slot_unavailable())
|
56
|
+
elif response_json["error"] == 28:
|
57
|
+
logger.warning(f"Slot {slot_id} is not bookable yet.")
|
58
|
+
raise ValueError(ErrorMessages.slot_not_bookable_yet())
|
59
|
+
else:
|
60
|
+
logger.error(f"Booking failed with error code: {response_json['error']}")
|
61
|
+
raise RuntimeError(ErrorMessages.unknown_error("booking"))
|
62
|
+
|
63
|
+
def cancel(self, slot_id: str) -> None:
|
64
|
+
"""
|
65
|
+
Cancel a specific slot by its ID.
|
66
|
+
|
67
|
+
Args:
|
68
|
+
slot_id (str): The unique ID of the activity slot.
|
69
|
+
|
70
|
+
Raises:
|
71
|
+
ValueError: If the cancellation fails.
|
72
|
+
"""
|
73
|
+
logger.info(f"Attempting to cancel slot {slot_id}...")
|
74
|
+
|
75
|
+
# Payload for cancellation
|
76
|
+
payload = {"id_activity_calendar": slot_id}
|
77
|
+
|
78
|
+
# Send cancellation request
|
79
|
+
response = self.session.post(Endpoints.CANCELLATION, data=payload, headers=self.headers)
|
80
|
+
response_json = json.loads(response.content.decode("utf-8"))
|
81
|
+
|
82
|
+
# Handle response
|
83
|
+
if response_json["success"]:
|
84
|
+
logger.info(f"Successfully cancelled slot {slot_id}.")
|
85
|
+
else:
|
86
|
+
logger.warning(f"Slot {slot_id} was not booked.")
|
87
|
+
raise ValueError(ErrorMessages.cancellation_failed())
|
pysportbot/centres.py
ADDED
@@ -0,0 +1,108 @@
|
|
1
|
+
# centres.py
|
2
|
+
|
3
|
+
import json
|
4
|
+
import logging
|
5
|
+
|
6
|
+
import pandas as pd
|
7
|
+
import requests
|
8
|
+
from pandas import DataFrame
|
9
|
+
|
10
|
+
from pysportbot.utils.logger import get_logger
|
11
|
+
|
12
|
+
from .endpoints import Endpoints
|
13
|
+
from .utils.errors import ErrorMessages
|
14
|
+
|
15
|
+
logger = get_logger(__name__)
|
16
|
+
|
17
|
+
|
18
|
+
class Centres:
|
19
|
+
"""
|
20
|
+
Manages fetching and storing the list of available centres
|
21
|
+
from the Resasports service.
|
22
|
+
"""
|
23
|
+
|
24
|
+
def __init__(self, print_centres: bool = False) -> None:
|
25
|
+
# Coordinates for the bounding box of the world
|
26
|
+
# Set to the entire world by default
|
27
|
+
self.bounds: dict = {
|
28
|
+
"bounds": {
|
29
|
+
"south": -90,
|
30
|
+
"west": -180,
|
31
|
+
"north": 90,
|
32
|
+
"east": 180,
|
33
|
+
}
|
34
|
+
}
|
35
|
+
self._df_centres = self.fetch_centres()
|
36
|
+
if print_centres:
|
37
|
+
self.print_centres()
|
38
|
+
|
39
|
+
# list of centre (slugs)
|
40
|
+
self.centre_list = self._df_centres["slug"].tolist()
|
41
|
+
|
42
|
+
def check_centre(self, centre: str) -> None:
|
43
|
+
"""
|
44
|
+
Set the user selected centre.
|
45
|
+
"""
|
46
|
+
if centre not in self.centre_list:
|
47
|
+
logger.error(ErrorMessages.centre_not_found(centre))
|
48
|
+
self.print_centres()
|
49
|
+
raise ValueError(ErrorMessages.centre_not_found(centre))
|
50
|
+
|
51
|
+
def fetch_centres(self) -> DataFrame:
|
52
|
+
"""
|
53
|
+
Fetches the info of available centres from Resasports and returns a DataFrame.
|
54
|
+
"""
|
55
|
+
try:
|
56
|
+
response = requests.post(
|
57
|
+
Endpoints.CENTRE,
|
58
|
+
json=self.bounds,
|
59
|
+
timeout=10,
|
60
|
+
)
|
61
|
+
response.raise_for_status()
|
62
|
+
|
63
|
+
# Parse the JSON content
|
64
|
+
response_json = json.loads(response.content.decode("utf-8"))
|
65
|
+
# Flatten and extract the desired columns
|
66
|
+
df = pd.json_normalize(response_json["applications"])
|
67
|
+
df = df[["slug", "name", "address.town", "address.country", "address.street_line"]]
|
68
|
+
df.columns = ["slug", "name", "town", "country", "address"]
|
69
|
+
|
70
|
+
if df is None or df.empty:
|
71
|
+
logging.error("Failed to fetch centres.")
|
72
|
+
self._raise_fetch_error() # Fix for TRY301: abstract raise
|
73
|
+
else:
|
74
|
+
return df
|
75
|
+
|
76
|
+
except Exception:
|
77
|
+
logging.exception("Failed to fetch centres")
|
78
|
+
return pd.DataFrame()
|
79
|
+
|
80
|
+
def _raise_fetch_error(self) -> None:
|
81
|
+
"""
|
82
|
+
Helper function to raise a ValueError for failed centre fetches.
|
83
|
+
"""
|
84
|
+
raise ValueError("Failed to fetch centres.")
|
85
|
+
|
86
|
+
def print_centres(self, cols: int = 4, col_width: int = 40) -> None:
|
87
|
+
"""
|
88
|
+
Prints the stored list of centres to the console.
|
89
|
+
"""
|
90
|
+
lines = []
|
91
|
+
for i, name in enumerate(self.centre_list):
|
92
|
+
# Use ljust or rjust to format columns
|
93
|
+
name_column = name[:col_width].ljust(col_width)
|
94
|
+
# Insert a newline after every 'cols' items
|
95
|
+
if (i + 1) % cols == 0:
|
96
|
+
lines.append(name_column + "\n")
|
97
|
+
else:
|
98
|
+
lines.append(name_column)
|
99
|
+
|
100
|
+
final_str = "".join(lines)
|
101
|
+
logger.info(f"Available centres:\n{final_str}")
|
102
|
+
|
103
|
+
@property
|
104
|
+
def df_centres(self) -> DataFrame | None:
|
105
|
+
"""
|
106
|
+
Returns the stored DataFrame of centres (or None if fetch_centres hasn't been called).
|
107
|
+
"""
|
108
|
+
return self._df_centres
|
pysportbot/endpoints.py
ADDED
@@ -0,0 +1,40 @@
|
|
1
|
+
from enum import Enum
|
2
|
+
|
3
|
+
|
4
|
+
class Endpoints(str, Enum):
|
5
|
+
"""
|
6
|
+
Enum class for API endpoints used in the application.
|
7
|
+
Each member is a string, so you can use them directly.
|
8
|
+
"""
|
9
|
+
|
10
|
+
# Base URLs
|
11
|
+
BASE_SOCIAL = "https://social.resasports.com"
|
12
|
+
BASE_NUBAPP = "https://sport.nubapp.com/web"
|
13
|
+
|
14
|
+
# Centre list
|
15
|
+
CENTRE = f"{BASE_SOCIAL}/ajax/applications/bounds/"
|
16
|
+
|
17
|
+
# Authentication Endpoints
|
18
|
+
USER_LOGIN = f"{BASE_SOCIAL}/popup/login"
|
19
|
+
LOGIN_CHECK = f"{BASE_SOCIAL}/popup/login_check"
|
20
|
+
NUBAP_LOGIN = f"{BASE_NUBAPP}/resources/login_from_social.php"
|
21
|
+
|
22
|
+
# User, activities, and slots
|
23
|
+
USER = f"{BASE_NUBAPP}/ajax/users/getUser.php"
|
24
|
+
ACTIVITIES = f"{BASE_NUBAPP}/ajax/application/getActivities.php"
|
25
|
+
SLOTS = f"{BASE_NUBAPP}/ajax/activities/getActivitiesCalendar.php"
|
26
|
+
|
27
|
+
# Booking and Cancellation
|
28
|
+
BOOKING = f"{BASE_NUBAPP}/ajax/bookings/bookBookings.php"
|
29
|
+
CANCELLATION = f"{BASE_NUBAPP}/ajax/activities/leaveActivityCalendar.php"
|
30
|
+
|
31
|
+
@classmethod
|
32
|
+
def get_cred_endpoint(cls, centre_slug: str) -> str:
|
33
|
+
"""
|
34
|
+
Build the booking request URL for a given centre slug.
|
35
|
+
"""
|
36
|
+
return f"{cls.BASE_SOCIAL}/ajax/application/{centre_slug}/book/request"
|
37
|
+
|
38
|
+
def __str__(self) -> str:
|
39
|
+
"""Return the underlying string value instead of the member name."""
|
40
|
+
return str(self.value)
|
File without changes
|
@@ -0,0 +1,32 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
|
3
|
+
import argparse
|
4
|
+
from typing import Any, Dict
|
5
|
+
|
6
|
+
from .config_loader import load_config
|
7
|
+
from .service import run_service
|
8
|
+
|
9
|
+
|
10
|
+
def main() -> None:
|
11
|
+
parser = argparse.ArgumentParser(description="Run the pysportbot as a service.")
|
12
|
+
parser.add_argument("--config", type=str, required=True, help="Path to the JSON configuration file.")
|
13
|
+
parser.add_argument("--offset-seconds", type=int, default=10, help="Time offset in seconds before booking.")
|
14
|
+
parser.add_argument("--retry-attempts", type=int, default=3, help="Number of retry attempts for weekly bookings.")
|
15
|
+
parser.add_argument(
|
16
|
+
"--retry-delay-minutes", type=int, default=2, help="Delay in minutes between retries for weekly bookings."
|
17
|
+
)
|
18
|
+
parser.add_argument("--time_zone", type=str, default="Europe/Madrid", help="Timezone for the service.")
|
19
|
+
args = parser.parse_args()
|
20
|
+
|
21
|
+
config: Dict[str, Any] = load_config(args.config)
|
22
|
+
run_service(
|
23
|
+
config,
|
24
|
+
offset_seconds=args.offset_seconds,
|
25
|
+
retry_attempts=args.retry_attempts,
|
26
|
+
retry_delay_minutes=args.retry_delay_minutes,
|
27
|
+
time_zone=args.time_zone,
|
28
|
+
)
|
29
|
+
|
30
|
+
|
31
|
+
if __name__ == "__main__":
|
32
|
+
main()
|