pysportbot 0.0.16__py3-none-any.whl → 0.0.18__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 +37 -29
- pysportbot/activities.py +89 -37
- pysportbot/authenticator.py +154 -59
- pysportbot/bookings.py +27 -28
- pysportbot/endpoints.py +34 -16
- pysportbot/service/booking.py +10 -6
- pysportbot/utils/errors.py +0 -5
- pysportbot/utils/time.py +19 -0
- {pysportbot-0.0.16.dist-info → pysportbot-0.0.18.dist-info}/METADATA +1 -1
- {pysportbot-0.0.16.dist-info → pysportbot-0.0.18.dist-info}/RECORD +12 -12
- {pysportbot-0.0.16.dist-info → pysportbot-0.0.18.dist-info}/LICENSE +0 -0
- {pysportbot-0.0.16.dist-info → pysportbot-0.0.18.dist-info}/WHEEL +0 -0
pysportbot/__init__.py
CHANGED
@@ -25,10 +25,32 @@ class SportBot:
|
|
25
25
|
self._centres = Centres(print_centres)
|
26
26
|
self._session: Session = Session()
|
27
27
|
self._auth: Authenticator | None = None
|
28
|
-
self._activities: Activities =
|
29
|
-
self._bookings: Bookings =
|
28
|
+
self._activities: Activities | None = None
|
29
|
+
self._bookings: Bookings | None = None
|
30
30
|
self._df_activities: DataFrame | None = None
|
31
|
-
self._is_logged_in: bool = False
|
31
|
+
self._is_logged_in: bool = False
|
32
|
+
|
33
|
+
@property
|
34
|
+
def activities_manager(self) -> Activities:
|
35
|
+
"""Get the activities manager, ensuring user is logged in."""
|
36
|
+
if not self._is_logged_in or self._auth is None:
|
37
|
+
raise PermissionError(ErrorMessages.not_logged_in())
|
38
|
+
|
39
|
+
# Lazy initialization - create only when first needed
|
40
|
+
if self._activities is None:
|
41
|
+
self._activities = Activities(self._auth)
|
42
|
+
return self._activities
|
43
|
+
|
44
|
+
@property
|
45
|
+
def bookings_manager(self) -> Bookings:
|
46
|
+
"""Get the bookings manager, ensuring user is logged in."""
|
47
|
+
if not self._is_logged_in or self._auth is None:
|
48
|
+
raise PermissionError(ErrorMessages.not_logged_in())
|
49
|
+
|
50
|
+
# Lazy initialization - create only when first needed
|
51
|
+
if self._bookings is None:
|
52
|
+
self._bookings = Bookings(self._auth)
|
53
|
+
return self._bookings
|
32
54
|
|
33
55
|
def set_log_level(self, log_level: str) -> None:
|
34
56
|
set_log_level(log_level)
|
@@ -44,12 +66,19 @@ class SportBot:
|
|
44
66
|
|
45
67
|
self._logger.info("Attempting to log in...")
|
46
68
|
try:
|
69
|
+
# Login to get valid credentials
|
47
70
|
self._auth.login(email, password)
|
48
|
-
self._df_activities = self._activities.fetch()
|
49
71
|
self._is_logged_in = True
|
50
72
|
self._logger.info("Login successful!")
|
73
|
+
|
74
|
+
# Fetch activities on first successful login
|
75
|
+
self._df_activities = self.activities_manager.fetch()
|
51
76
|
except Exception:
|
52
77
|
self._is_logged_in = False
|
78
|
+
# Clean up on failure
|
79
|
+
self._activities = None
|
80
|
+
self._bookings = None
|
81
|
+
self._auth = None
|
53
82
|
self._logger.exception(ErrorMessages.login_failed())
|
54
83
|
raise
|
55
84
|
|
@@ -58,37 +87,21 @@ class SportBot:
|
|
58
87
|
return self._is_logged_in
|
59
88
|
|
60
89
|
def activities(self, limit: int | None = None) -> DataFrame:
|
61
|
-
if not self._is_logged_in:
|
62
|
-
self._logger.error(ErrorMessages.not_logged_in())
|
63
|
-
raise PermissionError(ErrorMessages.not_logged_in())
|
64
|
-
|
65
90
|
if self._df_activities is None:
|
66
|
-
self._logger.error(ErrorMessages.no_activities_loaded())
|
67
91
|
raise ValueError(ErrorMessages.no_activities_loaded())
|
68
92
|
|
69
93
|
df = self._df_activities[["name_activity", "id_activity"]]
|
70
94
|
return df.head(limit) if limit else df
|
71
95
|
|
72
96
|
def daily_slots(self, activity: str, day: str, limit: int | None = None) -> DataFrame:
|
73
|
-
if not self._is_logged_in:
|
74
|
-
self._logger.error(ErrorMessages.not_logged_in())
|
75
|
-
raise PermissionError(ErrorMessages.not_logged_in())
|
76
|
-
|
77
97
|
if self._df_activities is None:
|
78
|
-
self._logger.error(ErrorMessages.no_activities_loaded())
|
79
98
|
raise ValueError(ErrorMessages.no_activities_loaded())
|
80
99
|
|
81
|
-
df = self.
|
100
|
+
df = self.activities_manager.daily_slots(self._df_activities, activity, day)
|
82
101
|
return df.head(limit) if limit else df
|
83
102
|
|
84
103
|
def book(self, activity: str, start_time: str) -> None:
|
85
|
-
|
86
|
-
if not self._is_logged_in:
|
87
|
-
self._logger.error(ErrorMessages.not_logged_in())
|
88
|
-
raise PermissionError(ErrorMessages.not_logged_in())
|
89
|
-
|
90
104
|
if self._df_activities is None:
|
91
|
-
self._logger.error(ErrorMessages.no_activities_loaded())
|
92
105
|
raise ValueError(ErrorMessages.no_activities_loaded())
|
93
106
|
|
94
107
|
# Fetch the daily slots for the activity
|
@@ -108,7 +121,7 @@ class SportBot:
|
|
108
121
|
# The unique slot ID
|
109
122
|
slot_id = target_slot["id_activity_calendar"]
|
110
123
|
# The total member capacity of the slot
|
111
|
-
slot_capacity = target_slot["
|
124
|
+
slot_capacity = target_slot["n_capacity"]
|
112
125
|
# The number of members already inscribed in the slot
|
113
126
|
slot_n_inscribed = target_slot["n_inscribed"]
|
114
127
|
# Log slot capacity
|
@@ -123,20 +136,15 @@ class SportBot:
|
|
123
136
|
|
124
137
|
# Attempt to book the slot
|
125
138
|
try:
|
126
|
-
self.
|
139
|
+
self.bookings_manager.book(slot_id)
|
127
140
|
self._logger.info(f"Successfully booked class '{activity}' on {start_time}")
|
128
141
|
except ValueError:
|
129
142
|
self._logger.error(f"Failed to book class '{activity}' on {start_time}")
|
130
143
|
|
131
144
|
def cancel(self, activity: str, start_time: str) -> None:
|
132
|
-
|
133
145
|
self._logger.debug(f"Attempting to cancel class '{activity}' on {start_time}")
|
134
|
-
if not self._is_logged_in:
|
135
|
-
self._logger.error(ErrorMessages.not_logged_in())
|
136
|
-
raise PermissionError(ErrorMessages.not_logged_in())
|
137
146
|
|
138
147
|
if self._df_activities is None:
|
139
|
-
self._logger.error(ErrorMessages.no_activities_loaded())
|
140
148
|
raise ValueError(ErrorMessages.no_activities_loaded())
|
141
149
|
|
142
150
|
slots = self.daily_slots(activity, start_time.split(" ")[0])
|
@@ -148,7 +156,7 @@ class SportBot:
|
|
148
156
|
|
149
157
|
slot_id = matching_slot.iloc[0]["id_activity_calendar"]
|
150
158
|
try:
|
151
|
-
self.
|
159
|
+
self.bookings_manager.cancel(slot_id)
|
152
160
|
self._logger.info(f"Successfully cancelled class '{activity}' on {start_time}")
|
153
161
|
except ValueError:
|
154
162
|
self._logger.error(f"Failed to cancel class '{activity}' on {start_time}")
|
pysportbot/activities.py
CHANGED
@@ -1,13 +1,14 @@
|
|
1
1
|
import json
|
2
|
+
from datetime import datetime, timedelta
|
2
3
|
|
3
4
|
import pandas as pd
|
4
5
|
from pandas import DataFrame
|
5
6
|
|
7
|
+
from .authenticator import Authenticator
|
6
8
|
from .endpoints import Endpoints
|
7
|
-
from .session import Session
|
8
9
|
from .utils.errors import ErrorMessages
|
9
10
|
from .utils.logger import get_logger
|
10
|
-
from .utils.time import
|
11
|
+
from .utils.time import get_day_bounds
|
11
12
|
|
12
13
|
logger = get_logger(__name__)
|
13
14
|
|
@@ -15,43 +16,89 @@ logger = get_logger(__name__)
|
|
15
16
|
class Activities:
|
16
17
|
"""Handles activity fetching and slot management."""
|
17
18
|
|
18
|
-
def __init__(self,
|
19
|
+
def __init__(self, authenticator: Authenticator) -> None:
|
19
20
|
"""Initialize the Activities class."""
|
20
|
-
|
21
|
-
self.
|
22
|
-
|
23
|
-
|
21
|
+
# Session
|
22
|
+
self.session = authenticator.session
|
23
|
+
# Nubapp credentials
|
24
|
+
self.creds = authenticator.creds
|
25
|
+
# Headers for requests
|
26
|
+
self.headers = authenticator.headers
|
27
|
+
|
28
|
+
def fetch(self, days_ahead: int = 7) -> DataFrame:
|
24
29
|
"""
|
25
|
-
Fetch all available activities.
|
30
|
+
Fetch all available unique activities within a specified time range using SLOTS endpoint.
|
31
|
+
|
32
|
+
Args:
|
33
|
+
days_ahead (int): Number of days from now to fetch activities for. Defaults to 7.
|
26
34
|
|
27
35
|
Returns:
|
28
|
-
DataFrame: A DataFrame containing activity details
|
36
|
+
DataFrame: A DataFrame containing unique activity details with columns:
|
37
|
+
['id_activity', 'name_activity', 'id_category_activity']
|
29
38
|
|
30
39
|
Raises:
|
31
|
-
RuntimeError: If the request fails.
|
40
|
+
RuntimeError: If the request fails or JSON parsing fails.
|
32
41
|
"""
|
33
|
-
logger.info("Fetching activities...")
|
34
|
-
|
42
|
+
logger.info(f"Fetching activities for the next {days_ahead} days. This might take a while...")
|
43
|
+
|
44
|
+
# Calculate date range
|
45
|
+
start_date = datetime.now().strftime("%Y-%m-%d")
|
46
|
+
end_date = (datetime.now() + timedelta(days=days_ahead)).strftime("%Y-%m-%d")
|
47
|
+
|
48
|
+
# Get day bounds for the date range
|
49
|
+
start_timestamp = get_day_bounds(start_date)[0]
|
50
|
+
end_timestamp = get_day_bounds(end_date)[1]
|
51
|
+
|
52
|
+
# Prepare payload for SLOTS endpoint
|
53
|
+
payload = {
|
54
|
+
"id_application": self.creds.get("id_application"),
|
55
|
+
"id_user": self.creds.get("id_user"),
|
56
|
+
"start_timestamp": start_timestamp,
|
57
|
+
"end_timestamp": end_timestamp,
|
58
|
+
}
|
59
|
+
# Make request to SLOTS endpoint
|
60
|
+
response = self.session.post(Endpoints.SLOTS, headers=self.headers, data=payload)
|
35
61
|
|
36
62
|
if response.status_code != 200:
|
37
|
-
error_msg = ErrorMessages.failed_fetch("activities")
|
38
|
-
logger.error(error_msg)
|
63
|
+
error_msg = ErrorMessages.failed_fetch("activities from slots")
|
64
|
+
logger.error(f"{error_msg} Status Code: {response.status_code}")
|
39
65
|
raise RuntimeError(error_msg)
|
40
66
|
|
41
67
|
try:
|
42
|
-
|
68
|
+
data = json.loads(response.content.decode("utf-8"))
|
69
|
+
activities = data["data"]["activities_calendar"]
|
70
|
+
|
71
|
+
if not activities:
|
72
|
+
logger.warning("No activities found in the response.")
|
73
|
+
return pd.DataFrame(columns=["id_activity", "name_activity", "id_category_activity"])
|
74
|
+
|
75
|
+
# Create DataFrame from activities
|
76
|
+
df_activities = pd.DataFrame(activities)
|
77
|
+
|
78
|
+
# Drop duplicates based on 'id_activity' and keep first occurrence
|
79
|
+
df_activities = df_activities.drop_duplicates(subset=["id_activity"])
|
80
|
+
|
81
|
+
# Select only required columns and reset index
|
82
|
+
df_activities = df_activities[["id_activity", "name_activity", "id_category_activity"]].reset_index(
|
83
|
+
drop=True
|
84
|
+
)
|
85
|
+
|
43
86
|
except json.JSONDecodeError as err:
|
44
|
-
error_msg = "Invalid JSON response while fetching activities."
|
87
|
+
error_msg = "Invalid JSON response while fetching activities from slots."
|
88
|
+
logger.error(error_msg)
|
89
|
+
logger.error(f"Raw response: {response.content.decode('utf-8')}")
|
90
|
+
raise RuntimeError(error_msg) from err
|
91
|
+
except KeyError as err:
|
92
|
+
error_msg = f"Missing expected key in response: {err}"
|
93
|
+
logger.error(error_msg)
|
94
|
+
logger.error(f"Raw response: {response.content.decode('utf-8')}")
|
95
|
+
raise RuntimeError(error_msg) from err
|
96
|
+
except Exception as err:
|
97
|
+
error_msg = f"Unexpected error while parsing activities: {err}"
|
45
98
|
logger.error(error_msg)
|
46
99
|
raise RuntimeError(error_msg) from err
|
47
100
|
|
48
|
-
|
49
|
-
logger.warning("No activities found in the response.")
|
50
|
-
|
51
|
-
df_activities = pd.DataFrame.from_dict(activities, orient="index")
|
52
|
-
df_activities.index = df_activities.index.astype(int) # Ensure index is integer for consistency
|
53
|
-
|
54
|
-
logger.info("Activities fetched successfully.")
|
101
|
+
logger.info(f"Successfully fetched {len(df_activities)} unique activities.")
|
55
102
|
return df_activities
|
56
103
|
|
57
104
|
def daily_slots(self, df_activities: DataFrame, activity_name: str, day: str) -> DataFrame:
|
@@ -81,21 +128,22 @@ class Activities:
|
|
81
128
|
logger.error(error_msg)
|
82
129
|
raise ValueError(error_msg)
|
83
130
|
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
id_category_activity = activity_match.at[activity_id, "activityCategoryId"]
|
131
|
+
activity = activity_match.iloc[0]
|
132
|
+
id_activity = activity["id_activity"]
|
133
|
+
id_category_activity = activity["id_category_activity"]
|
88
134
|
|
89
135
|
# Get Unix timestamp bounds for the day
|
90
|
-
|
136
|
+
day_bounds = get_day_bounds(day)
|
91
137
|
|
92
138
|
# Fetch slots
|
93
|
-
|
139
|
+
payload = {
|
140
|
+
"id_application": self.creds["id_application"],
|
141
|
+
"id_user": self.creds["id_user"],
|
142
|
+
"start_timestamp": day_bounds[0],
|
143
|
+
"end_timestamp": day_bounds[1],
|
94
144
|
"id_category_activity": id_category_activity,
|
95
|
-
"start": unix_day_bounds[0],
|
96
|
-
"end": unix_day_bounds[1],
|
97
145
|
}
|
98
|
-
response = self.session.
|
146
|
+
response = self.session.post(Endpoints.SLOTS, headers=self.headers, data=payload)
|
99
147
|
|
100
148
|
if response.status_code != 200:
|
101
149
|
error_msg = ErrorMessages.failed_fetch("slots")
|
@@ -103,11 +151,16 @@ class Activities:
|
|
103
151
|
raise RuntimeError(error_msg)
|
104
152
|
|
105
153
|
try:
|
106
|
-
|
154
|
+
data = json.loads(response.content.decode("utf-8"))
|
155
|
+
slots = data["data"]["activities_calendar"]
|
107
156
|
except json.JSONDecodeError as err:
|
108
157
|
error_msg = "Invalid JSON response while fetching slots."
|
109
158
|
logger.error(error_msg)
|
110
159
|
raise RuntimeError(error_msg) from err
|
160
|
+
except KeyError as err:
|
161
|
+
error_msg = f"Missing expected key in response: {err}"
|
162
|
+
logger.error(error_msg)
|
163
|
+
raise RuntimeError(error_msg) from err
|
111
164
|
|
112
165
|
if not slots:
|
113
166
|
warning_msg = ErrorMessages.no_slots(activity_name, day)
|
@@ -125,11 +178,10 @@ class Activities:
|
|
125
178
|
"start_timestamp",
|
126
179
|
"end_timestamp",
|
127
180
|
"n_inscribed",
|
128
|
-
"
|
181
|
+
"n_capacity",
|
129
182
|
"n_waiting_list",
|
130
183
|
"cancelled",
|
131
|
-
"
|
132
|
-
"trainer",
|
184
|
+
"trainer_name",
|
133
185
|
]
|
134
186
|
df_slots = pd.DataFrame(slots)
|
135
187
|
|
@@ -137,7 +189,7 @@ class Activities:
|
|
137
189
|
df_slots = df_slots.loc[:, df_slots.columns.intersection(columns)]
|
138
190
|
|
139
191
|
# Only select rows of the specified activity
|
140
|
-
df_slots = df_slots[df_slots
|
192
|
+
df_slots = df_slots[df_slots.id_activity == int(id_activity)]
|
141
193
|
if df_slots.empty:
|
142
194
|
warning_msg = ErrorMessages.no_matching_slots(activity_name, day)
|
143
195
|
logger.warning(warning_msg)
|
pysportbot/authenticator.py
CHANGED
@@ -1,6 +1,5 @@
|
|
1
|
-
import ast
|
2
1
|
import json
|
3
|
-
from urllib.parse import parse_qs
|
2
|
+
from urllib.parse import parse_qs, urlparse
|
4
3
|
|
5
4
|
from bs4 import BeautifulSoup
|
6
5
|
|
@@ -21,15 +20,37 @@ class Authenticator:
|
|
21
20
|
|
22
21
|
Args:
|
23
22
|
session (Session): An instance of the Session class.
|
23
|
+
centre (str): The centre selected by the user.
|
24
24
|
"""
|
25
25
|
self.session = session.session
|
26
26
|
self.headers = session.headers
|
27
|
-
|
28
|
-
self.authenticated = False
|
29
|
-
# User ID for the authenticated user
|
30
|
-
self.user_id = None
|
31
|
-
# Centre selected by the user
|
27
|
+
self.creds: dict[str, str] = {}
|
32
28
|
self.centre = centre
|
29
|
+
self.timeout = (5, 10)
|
30
|
+
|
31
|
+
# Authentication state
|
32
|
+
self.authenticated = False
|
33
|
+
self.user_id: str | None = None
|
34
|
+
|
35
|
+
def is_session_valid(self) -> bool:
|
36
|
+
"""
|
37
|
+
Check if the current session is still valid.
|
38
|
+
|
39
|
+
Returns:
|
40
|
+
bool: True if session is valid, False otherwise.
|
41
|
+
"""
|
42
|
+
try:
|
43
|
+
response = self.session.post(Endpoints.USER, headers=self.headers, timeout=self.timeout)
|
44
|
+
if response.status_code == 200:
|
45
|
+
response_dict = json.loads(response.content.decode("utf-8"))
|
46
|
+
return bool(response_dict.get("user"))
|
47
|
+
|
48
|
+
except Exception as e:
|
49
|
+
logger.debug(f"Session validation failed with exception: {e}")
|
50
|
+
return False
|
51
|
+
|
52
|
+
logger.debug(f"Session validation failed with status code: {response.status_code}")
|
53
|
+
return False
|
33
54
|
|
34
55
|
def login(self, email: str, password: str) -> None:
|
35
56
|
"""
|
@@ -40,24 +61,53 @@ class Authenticator:
|
|
40
61
|
password (str): The user's password.
|
41
62
|
|
42
63
|
Raises:
|
43
|
-
|
64
|
+
ValueError: If login credentials are invalid or authentication fails.
|
65
|
+
RuntimeError: If the login process fails due to system errors.
|
44
66
|
"""
|
45
67
|
logger.info("Starting login process...")
|
46
68
|
|
47
|
-
|
48
|
-
|
49
|
-
|
69
|
+
try:
|
70
|
+
# Fetch the CSRF token and perform login
|
71
|
+
csrf_token = self._fetch_csrf_token()
|
72
|
+
# Resasport login with CSRF token
|
73
|
+
self._resasports_login(email, password, csrf_token)
|
74
|
+
# Retrieve Nubapp credentials
|
75
|
+
self._retrieve_nubapp_credentials()
|
76
|
+
bearer_token = self._login_to_nubapp()
|
77
|
+
# Authenticate with the bearer token
|
78
|
+
self._authenticate_with_bearer_token(bearer_token)
|
79
|
+
# Fetch user information to complete the login process
|
80
|
+
self._fetch_user_information()
|
81
|
+
|
82
|
+
logger.info("Login process completed successfully!")
|
83
|
+
|
84
|
+
except Exception as e:
|
85
|
+
self.authenticated = False
|
86
|
+
self.user_id = None
|
87
|
+
logger.error(f"Login process failed: {e}")
|
88
|
+
raise
|
89
|
+
|
90
|
+
def _fetch_csrf_token(self) -> str:
|
91
|
+
"""Fetch CSRF token from the login page."""
|
92
|
+
logger.debug(f"Fetching CSRF token from {Endpoints.USER_LOGIN}")
|
93
|
+
|
94
|
+
response = self.session.get(Endpoints.USER_LOGIN, headers=self.headers, timeout=self.timeout)
|
50
95
|
if response.status_code != 200:
|
51
|
-
logger.error(f"Failed to fetch login popup: {response.status_code}")
|
52
96
|
raise RuntimeError(ErrorMessages.failed_fetch("login popup"))
|
53
|
-
|
97
|
+
|
54
98
|
soup = BeautifulSoup(response.text, "html.parser")
|
55
99
|
csrf_tag = soup.find("input", {"name": "_csrf_token"})
|
56
100
|
if csrf_tag is None:
|
57
101
|
raise ValueError("CSRF token input not found on the page")
|
58
|
-
csrf_token = csrf_tag["value"] # type: ignore[index]
|
59
102
|
|
60
|
-
#
|
103
|
+
csrf_token = str(csrf_tag["value"]) # type: ignore[index]
|
104
|
+
logger.debug("CSRF token fetched successfully")
|
105
|
+
return csrf_token
|
106
|
+
|
107
|
+
def _resasports_login(self, email: str, password: str, csrf_token: str) -> None:
|
108
|
+
"""Perform login to the main site."""
|
109
|
+
logger.debug("Performing site login")
|
110
|
+
|
61
111
|
payload = {
|
62
112
|
"_username": email,
|
63
113
|
"_password": password,
|
@@ -65,58 +115,103 @@ class Authenticator:
|
|
65
115
|
"_submit": "",
|
66
116
|
"_force": "true",
|
67
117
|
}
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
)
|
73
|
-
|
118
|
+
|
119
|
+
headers = self.headers.copy()
|
120
|
+
headers.update({"Content-Type": "application/x-www-form-urlencoded"})
|
121
|
+
|
122
|
+
response = self.session.post(Endpoints.LOGIN_CHECK, data=payload, headers=headers, timeout=self.timeout)
|
123
|
+
|
74
124
|
if response.status_code != 200:
|
75
|
-
logger.error(f"
|
125
|
+
logger.error(f"Site login failed: {response.status_code}")
|
76
126
|
raise ValueError(ErrorMessages.failed_login())
|
77
|
-
logger.info("Login successful!")
|
78
127
|
|
79
|
-
|
128
|
+
logger.info("Site login successful!")
|
129
|
+
|
130
|
+
def _retrieve_nubapp_credentials(self) -> None:
|
131
|
+
"""Retrieve Nubapp credentials from the centre endpoint."""
|
132
|
+
logger.debug("Retrieving Nubapp credentials")
|
133
|
+
|
80
134
|
cred_endpoint = Endpoints.get_cred_endpoint(self.centre)
|
81
|
-
|
82
|
-
|
135
|
+
response = self.session.get(cred_endpoint, headers=self.headers, timeout=self.timeout)
|
136
|
+
|
83
137
|
if response.status_code != 200:
|
84
|
-
logger.error(f"Failed to retrieve Nubapp credentials: {response.status_code}")
|
85
138
|
raise RuntimeError(ErrorMessages.failed_fetch("credentials"))
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
139
|
+
|
140
|
+
try:
|
141
|
+
response_data = json.loads(response.content.decode("utf-8"))
|
142
|
+
creds_payload = response_data.get("payload", "")
|
143
|
+
creds = {k: v[0] for k, v in parse_qs(creds_payload).items()}
|
144
|
+
creds.update({"platform": "resasocial", "network": "resasports"})
|
145
|
+
|
146
|
+
self.creds = creds
|
147
|
+
logger.debug("Nubapp credentials retrieved successfully")
|
148
|
+
|
149
|
+
except (ValueError, KeyError, SyntaxError) as e:
|
150
|
+
raise RuntimeError(f"Failed to parse credentials: {e}") from e
|
151
|
+
|
152
|
+
def _login_to_nubapp(self) -> str:
|
153
|
+
"""Login to Nubapp and extract bearer token."""
|
154
|
+
logger.debug("Logging in to Nubapp")
|
155
|
+
|
156
|
+
response = self.session.get(
|
157
|
+
Endpoints.NUBAPP_LOGIN,
|
158
|
+
headers=self.headers,
|
159
|
+
params=self.creds,
|
160
|
+
timeout=self.timeout,
|
161
|
+
allow_redirects=False,
|
96
162
|
)
|
97
|
-
response = self.session.get(Endpoints.NUBAP_LOGIN, headers=self.headers, params=nubapp_creds)
|
98
|
-
if response.status_code != 200:
|
99
|
-
logger.error(f"Login to Nubapp failed: {response.status_code}, {response.text}")
|
100
|
-
raise ValueError(ErrorMessages.failed_login_nubapp())
|
101
|
-
logger.info("Login to Nubapp successful!")
|
102
163
|
|
103
|
-
|
104
|
-
|
164
|
+
if response.status_code != 302:
|
165
|
+
logger.error(f"Nubapp login failed: {response.status_code}")
|
166
|
+
raise ValueError(ErrorMessages.failed_login())
|
167
|
+
|
168
|
+
# Extract bearer token from redirect URL
|
169
|
+
redirect_url = response.headers.get("Location", "")
|
170
|
+
if not redirect_url:
|
171
|
+
raise ValueError(ErrorMessages.failed_login())
|
105
172
|
|
106
|
-
|
107
|
-
|
173
|
+
parsed_url = urlparse(redirect_url)
|
174
|
+
token = parse_qs(parsed_url.query).get("token", [None])[0]
|
175
|
+
|
176
|
+
if not token:
|
177
|
+
raise ValueError(ErrorMessages.failed_login())
|
178
|
+
|
179
|
+
logger.info("Nubapp login successful!")
|
180
|
+
return token
|
181
|
+
|
182
|
+
def _authenticate_with_bearer_token(self, token: str) -> None:
|
183
|
+
"""Add bearer token to headers for authentication."""
|
184
|
+
logger.debug("Setting up bearer token authentication")
|
185
|
+
self.headers["Authorization"] = f"Bearer {token}"
|
186
|
+
|
187
|
+
def _fetch_user_information(self) -> None:
|
188
|
+
"""Fetch and validate user information."""
|
189
|
+
logger.debug("Fetching user information")
|
108
190
|
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
else:
|
118
|
-
self.authenticated = False
|
119
|
-
raise ValueError(ErrorMessages.failed_login())
|
120
|
-
else:
|
121
|
-
logger.error(f"Failed to retrieve user information: {response.status_code}, {response.text}")
|
191
|
+
payload = {
|
192
|
+
"id_application": self.creds["id_application"],
|
193
|
+
"id_user": self.creds["id_user"],
|
194
|
+
}
|
195
|
+
|
196
|
+
response = self.session.post(Endpoints.USER, headers=self.headers, data=payload, timeout=self.timeout)
|
197
|
+
|
198
|
+
if response.status_code != 200:
|
122
199
|
raise ValueError(ErrorMessages.failed_login())
|
200
|
+
|
201
|
+
try:
|
202
|
+
response_dict = json.loads(response.content.decode("utf-8"))
|
203
|
+
user_data = response_dict.get("data", {}).get("user")
|
204
|
+
|
205
|
+
if not user_data:
|
206
|
+
raise ValueError("No user data found in response")
|
207
|
+
|
208
|
+
user_id = user_data.get("id_user")
|
209
|
+
if not user_id:
|
210
|
+
raise ValueError("No user ID found in response")
|
211
|
+
|
212
|
+
self.user_id = str(user_id)
|
213
|
+
self.authenticated = True
|
214
|
+
logger.info(f"Authentication successful. User ID: {self.user_id}")
|
215
|
+
|
216
|
+
except (json.JSONDecodeError, KeyError) as e:
|
217
|
+
raise ValueError(f"Failed to parse user information: {e}") from e
|
pysportbot/bookings.py
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
import json
|
2
2
|
|
3
|
+
from .authenticator import Authenticator
|
3
4
|
from .endpoints import Endpoints
|
4
|
-
from .session import Session
|
5
5
|
from .utils.errors import ErrorMessages
|
6
6
|
from .utils.logger import get_logger
|
7
7
|
|
@@ -11,10 +11,14 @@ logger = get_logger(__name__)
|
|
11
11
|
class Bookings:
|
12
12
|
"""Handles booking and cancellation of activity slots."""
|
13
13
|
|
14
|
-
def __init__(self,
|
14
|
+
def __init__(self, authenticator: Authenticator) -> None:
|
15
15
|
"""Initialize the Bookings class."""
|
16
|
-
|
17
|
-
self.
|
16
|
+
# Session
|
17
|
+
self.session = authenticator.session
|
18
|
+
# Nubapp credentials
|
19
|
+
self.creds = authenticator.creds
|
20
|
+
# Headers for requests
|
21
|
+
self.headers = authenticator.headers
|
18
22
|
|
19
23
|
def book(self, slot_id: str) -> None:
|
20
24
|
"""
|
@@ -30,35 +34,30 @@ class Bookings:
|
|
30
34
|
logger.debug(f"Attempting to book slot {slot_id}...")
|
31
35
|
|
32
36
|
# 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
|
-
}
|
37
|
+
payload = {"id_user": self.creds["id_user"], "id_activity_calendar": slot_id}
|
42
38
|
|
43
39
|
# Send booking request
|
44
40
|
response = self.session.post(Endpoints.BOOKING, data=payload, headers=self.headers)
|
45
41
|
response_json = json.loads(response.content.decode("utf-8"))
|
46
|
-
|
47
|
-
|
48
|
-
if response_json["error"] == 0:
|
42
|
+
# Check success directly
|
43
|
+
if response_json["success"]:
|
49
44
|
logger.debug(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
45
|
else:
|
60
|
-
|
61
|
-
|
46
|
+
# Handle error cases
|
47
|
+
error_code = response_json["error"] # Now we know it exists when success=False
|
48
|
+
|
49
|
+
if error_code == 5:
|
50
|
+
logger.warning(f"Slot {slot_id} is already booked.")
|
51
|
+
raise ValueError(ErrorMessages.slot_already_booked())
|
52
|
+
elif error_code == 6:
|
53
|
+
logger.warning(f"Slot {slot_id} is not available.")
|
54
|
+
raise ValueError(ErrorMessages.slot_unavailable())
|
55
|
+
elif error_code == 28:
|
56
|
+
logger.warning(f"Slot {slot_id} is not bookable yet.")
|
57
|
+
raise ValueError(ErrorMessages.slot_not_bookable_yet())
|
58
|
+
else:
|
59
|
+
logger.error(f"Booking failed with error code: {error_code}")
|
60
|
+
raise RuntimeError(ErrorMessages.unknown_error("booking"))
|
62
61
|
|
63
62
|
def cancel(self, slot_id: str) -> None:
|
64
63
|
"""
|
@@ -73,7 +72,7 @@ class Bookings:
|
|
73
72
|
logger.debug(f"Attempting to cancel slot {slot_id}...")
|
74
73
|
|
75
74
|
# Payload for cancellation
|
76
|
-
payload = {"id_activity_calendar": slot_id}
|
75
|
+
payload = {"id_user": self.creds["id_user"], "id_activity_calendar": slot_id}
|
77
76
|
|
78
77
|
# Send cancellation request
|
79
78
|
response = self.session.post(Endpoints.CANCELLATION, data=payload, headers=self.headers)
|
pysportbot/endpoints.py
CHANGED
@@ -3,38 +3,56 @@ from enum import Enum
|
|
3
3
|
|
4
4
|
class Endpoints(str, Enum):
|
5
5
|
"""
|
6
|
-
|
7
|
-
|
6
|
+
API endpoints used throughout the application.
|
7
|
+
|
8
|
+
This enum provides type-safe access to all API endpoints with clear organization
|
9
|
+
and automatic string conversion for use in HTTP requests.
|
8
10
|
"""
|
9
11
|
|
10
|
-
# Base URLs
|
12
|
+
# === Base URLs ===
|
11
13
|
BASE_SOCIAL = "https://social.resasports.com"
|
12
|
-
BASE_NUBAPP = "https://sport.nubapp.com
|
14
|
+
BASE_NUBAPP = "https://sport.nubapp.com"
|
15
|
+
|
16
|
+
# === URL Components ===
|
17
|
+
NUBAPP_RESOURCES = "web/resources"
|
18
|
+
NUBAPP_API = "api/v4"
|
13
19
|
|
14
|
-
# Centre
|
20
|
+
# === Centre Management ===
|
15
21
|
CENTRE = f"{BASE_SOCIAL}/ajax/applications/bounds/"
|
16
22
|
|
17
|
-
# Authentication
|
23
|
+
# === Authentication ===
|
18
24
|
USER_LOGIN = f"{BASE_SOCIAL}/popup/login"
|
19
25
|
LOGIN_CHECK = f"{BASE_SOCIAL}/popup/login_check"
|
20
|
-
|
26
|
+
NUBAPP_LOGIN = f"{BASE_NUBAPP}/{NUBAPP_RESOURCES}/login_from_social.php"
|
27
|
+
|
28
|
+
# === User Management ===
|
29
|
+
USER = f"{BASE_NUBAPP}/{NUBAPP_API}/users/getUser.php"
|
21
30
|
|
22
|
-
#
|
23
|
-
|
24
|
-
|
25
|
-
SLOTS = f"{BASE_NUBAPP}/ajax/activities/getActivitiesCalendar.php"
|
31
|
+
# === Activities & Scheduling ===
|
32
|
+
ACTIVITIES = f"{BASE_NUBAPP}/{NUBAPP_API}/activities/getActivities.php"
|
33
|
+
SLOTS = f"{BASE_NUBAPP}/{NUBAPP_API}/activities/getActivitiesCalendar.php"
|
26
34
|
|
27
|
-
# Booking
|
28
|
-
BOOKING = f"{BASE_NUBAPP}/
|
29
|
-
CANCELLATION = f"{BASE_NUBAPP}/
|
35
|
+
# === Booking Management ===
|
36
|
+
BOOKING = f"{BASE_NUBAPP}/{NUBAPP_API}/activities/bookActivityCalendar.php"
|
37
|
+
CANCELLATION = f"{BASE_NUBAPP}/{NUBAPP_API}/activities/leaveActivityCalendar.php"
|
30
38
|
|
31
39
|
@classmethod
|
32
40
|
def get_cred_endpoint(cls, centre_slug: str) -> str:
|
33
41
|
"""
|
34
|
-
|
42
|
+
Generate the credentials endpoint for a specific centre.
|
43
|
+
|
44
|
+
Args:
|
45
|
+
centre_slug (str): The unique identifier for the sports centre
|
46
|
+
|
47
|
+
Returns:
|
48
|
+
str: Complete URL for fetching centre credentials
|
49
|
+
|
50
|
+
Example:
|
51
|
+
>>> Endpoints.get_cred_endpoint("kirolklub")
|
52
|
+
"https://social.resasports.com/ajax/application/kirolklub/book/request"
|
35
53
|
"""
|
36
54
|
return f"{cls.BASE_SOCIAL}/ajax/application/{centre_slug}/book/request"
|
37
55
|
|
38
56
|
def __str__(self) -> str:
|
39
|
-
"""Return the
|
57
|
+
"""Return the URL string for direct use in HTTP requests."""
|
40
58
|
return str(self.value)
|
pysportbot/service/booking.py
CHANGED
@@ -117,19 +117,23 @@ def schedule_bookings(
|
|
117
117
|
logger.debug(f"Re-authenticating in {reauth_time:.2f} seconds.")
|
118
118
|
time.sleep(reauth_time)
|
119
119
|
|
120
|
-
# Re-authenticate before booking
|
121
|
-
logger.info("Re-authenticating before booking.")
|
120
|
+
# Re-authenticate before booking if necessary
|
122
121
|
try:
|
123
|
-
bot.
|
124
|
-
|
125
|
-
|
122
|
+
if bot._auth and bot._auth.is_session_valid():
|
123
|
+
logger.info("Session still valid. Skipping re-authentication.")
|
124
|
+
else:
|
125
|
+
logger.info("Attempting re-authenticating before booking.")
|
126
|
+
bot.login(config["email"], config["password"], config["centre"])
|
127
|
+
|
128
|
+
except Exception as e:
|
129
|
+
logger.warning(f"Re-authentication failed before booking execution with {e}.")
|
126
130
|
|
127
131
|
# Wait the remaining time until execution
|
128
132
|
now = datetime.now(pytz.timezone(time_zone))
|
129
133
|
remaining_time = (execution_time - now).total_seconds()
|
130
134
|
if remaining_time > 0:
|
131
135
|
logger.info(f"Waiting {remaining_time:.2f} seconds until booking execution.")
|
132
|
-
|
136
|
+
time.sleep(max(0, remaining_time))
|
133
137
|
|
134
138
|
# Global booking delay
|
135
139
|
if booking_delay > 0:
|
pysportbot/utils/errors.py
CHANGED
@@ -130,11 +130,6 @@ class ErrorMessages:
|
|
130
130
|
"""Return an error message for a failed login."""
|
131
131
|
return "Login failed. Please check your credentials and try again."
|
132
132
|
|
133
|
-
@staticmethod
|
134
|
-
def failed_login_nubapp() -> str:
|
135
|
-
"""Return an error message for a failed login to Nubapp."""
|
136
|
-
return "Login to Nubapp failed. Please try again later."
|
137
|
-
|
138
133
|
@staticmethod
|
139
134
|
def unknown_error(action: str) -> str:
|
140
135
|
"""Return an error message for an unknown error."""
|
pysportbot/utils/time.py
CHANGED
@@ -3,6 +3,25 @@ from datetime import datetime, time
|
|
3
3
|
import pytz
|
4
4
|
|
5
5
|
|
6
|
+
def get_day_bounds(date_string: str, fmt: str = "%Y-%m-%d", tz: str = "UTC") -> tuple[str, str]:
|
7
|
+
"""
|
8
|
+
Get start and end bounds for a given date.
|
9
|
+
|
10
|
+
Args:
|
11
|
+
date_string (str): Date in specified format
|
12
|
+
fmt (str): Date format, defaults to "%Y-%m-%d"
|
13
|
+
tz (str): Timezone, defaults to "UTC"
|
14
|
+
|
15
|
+
Returns:
|
16
|
+
tuple: (start_timestamp, end_timestamp) as strings
|
17
|
+
"""
|
18
|
+
tzinfo = pytz.timezone(tz)
|
19
|
+
date = datetime.strptime(date_string, fmt).replace(tzinfo=tzinfo)
|
20
|
+
start = datetime.combine(date.date(), time.min, tzinfo=tzinfo)
|
21
|
+
end = datetime.combine(date.date(), time.max, tzinfo=tzinfo)
|
22
|
+
return start.strftime(fmt), end.strftime(fmt)
|
23
|
+
|
24
|
+
|
6
25
|
def get_unix_day_bounds(date_string: str, fmt: str = "%Y-%m-%d", tz: str = "UTC") -> tuple[int, int]:
|
7
26
|
"""
|
8
27
|
Get the Unix timestamp bounds for a given day.
|
@@ -1,12 +1,12 @@
|
|
1
|
-
pysportbot/__init__.py,sha256=
|
2
|
-
pysportbot/activities.py,sha256=
|
3
|
-
pysportbot/authenticator.py,sha256=
|
4
|
-
pysportbot/bookings.py,sha256=
|
1
|
+
pysportbot/__init__.py,sha256=PdDUmkEBSOkdw3BaKMv1sXAUgYSTl6_qUOnAEj9UDBM,6570
|
2
|
+
pysportbot/activities.py,sha256=fvj1Pnf3xxDuNY2FBGDOaach6tcZ_HJxCfoyGVju0oM,7598
|
3
|
+
pysportbot/authenticator.py,sha256=MadHojTiDRcpPNABgT76j-fJ4cw9yydibHbEtDNktPg,7950
|
4
|
+
pysportbot/bookings.py,sha256=yJPgCTECXBEVtQzwj3lUJ8QR3h43ycCUw80mTx-Ck60,3189
|
5
5
|
pysportbot/centres.py,sha256=FTK-tXUOxiJvLCHP6Bk9XEQKODQZOwwkYLlioSJPBEk,3399
|
6
|
-
pysportbot/endpoints.py,sha256=
|
6
|
+
pysportbot/endpoints.py,sha256=vysTBx6fEn6LtjOlYx2Dj-LLe1ts7JmeEbFZiWcE5qU,1965
|
7
7
|
pysportbot/service/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
8
8
|
pysportbot/service/__main__.py,sha256=gsKfDMOmsVC3LHCQs0Dmp7YWJBlZeGcTsX-Mx4mk6ro,1496
|
9
|
-
pysportbot/service/booking.py,sha256=
|
9
|
+
pysportbot/service/booking.py,sha256=ifeo1LbtPvls-hpQOZW5TvoqeZ1MJxIipim4RHTH7nI,5908
|
10
10
|
pysportbot/service/config_loader.py,sha256=t086yaAyAKkCJTpxedwhyJ7QqSf5XROGDzjrFLsUxJE,179
|
11
11
|
pysportbot/service/config_validator.py,sha256=0P_pcXU7s3T3asODqFtv3mSp1HyPoVBphE4Qe1mxcps,2615
|
12
12
|
pysportbot/service/scheduling.py,sha256=trz4zweZB2W9rwWAnW9Y6YkCo-f4dqtKyhaY_yqpJ-Y,2024
|
@@ -14,10 +14,10 @@ pysportbot/service/service.py,sha256=eW-roFozDzkK7kTzYbSzNwZhbZpBr-22yUrAHVnrD-0
|
|
14
14
|
pysportbot/service/threading.py,sha256=j0tHgGty8iWDjpZtTzIu-5TUnDb9S6SJErm3QBpG-nY,1947
|
15
15
|
pysportbot/session.py,sha256=pTQrz3bGzLYBtzVOgKv04l4UXDSgtA3Infn368bjg5I,1529
|
16
16
|
pysportbot/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
17
|
-
pysportbot/utils/errors.py,sha256=
|
17
|
+
pysportbot/utils/errors.py,sha256=hPbWJT_uOIgYjxqm5VojlRy3wWnFj9HLZGIcbv9tq8c,4956
|
18
18
|
pysportbot/utils/logger.py,sha256=ANayMEeeAIVGKvITAxOFm2EdCbzBBTpNywuytAr4Z90,5366
|
19
|
-
pysportbot/utils/time.py,sha256=
|
20
|
-
pysportbot-0.0.
|
21
|
-
pysportbot-0.0.
|
22
|
-
pysportbot-0.0.
|
23
|
-
pysportbot-0.0.
|
19
|
+
pysportbot/utils/time.py,sha256=GKfFU5WwRSaWz2xNJ6EM0w1lHOVo8of5qhqnfh9xbJM,1926
|
20
|
+
pysportbot-0.0.18.dist-info/LICENSE,sha256=6ov3DypdEVYpp2pn_B1MniKWO5C9iDA4O6PGcbork6c,1077
|
21
|
+
pysportbot-0.0.18.dist-info/METADATA,sha256=sr_LZRcSuLIOIcHn43lr6_4vJS5A481Pv3NNs_LfdDc,8045
|
22
|
+
pysportbot-0.0.18.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
|
23
|
+
pysportbot-0.0.18.dist-info/RECORD,,
|
File without changes
|
File without changes
|