pysportbot 0.0.19__tar.gz → 0.0.21__tar.gz
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-0.0.19 → pysportbot-0.0.21}/LICENSE +1 -1
- {pysportbot-0.0.19 → pysportbot-0.0.21}/PKG-INFO +3 -4
- {pysportbot-0.0.19 → pysportbot-0.0.21}/pyproject.toml +3 -4
- pysportbot-0.0.21/pysportbot/authenticator.py +304 -0
- pysportbot-0.0.21/pysportbot/endpoints.py +69 -0
- {pysportbot-0.0.19 → pysportbot-0.0.21}/pysportbot/service/booking.py +0 -1
- {pysportbot-0.0.19 → pysportbot-0.0.21}/pysportbot/utils/errors.py +1 -1
- pysportbot-0.0.19/pysportbot/authenticator.py +0 -217
- pysportbot-0.0.19/pysportbot/endpoints.py +0 -58
- {pysportbot-0.0.19 → pysportbot-0.0.21}/README.md +0 -0
- {pysportbot-0.0.19 → pysportbot-0.0.21}/pysportbot/__init__.py +0 -0
- {pysportbot-0.0.19 → pysportbot-0.0.21}/pysportbot/activities.py +0 -0
- {pysportbot-0.0.19 → pysportbot-0.0.21}/pysportbot/bookings.py +0 -0
- {pysportbot-0.0.19 → pysportbot-0.0.21}/pysportbot/centres.py +0 -0
- {pysportbot-0.0.19 → pysportbot-0.0.21}/pysportbot/service/__init__.py +0 -0
- {pysportbot-0.0.19 → pysportbot-0.0.21}/pysportbot/service/__main__.py +0 -0
- {pysportbot-0.0.19 → pysportbot-0.0.21}/pysportbot/service/config_loader.py +0 -0
- {pysportbot-0.0.19 → pysportbot-0.0.21}/pysportbot/service/config_validator.py +0 -0
- {pysportbot-0.0.19 → pysportbot-0.0.21}/pysportbot/service/scheduling.py +0 -0
- {pysportbot-0.0.19 → pysportbot-0.0.21}/pysportbot/service/service.py +0 -0
- {pysportbot-0.0.19 → pysportbot-0.0.21}/pysportbot/service/threading.py +0 -0
- {pysportbot-0.0.19 → pysportbot-0.0.21}/pysportbot/session.py +0 -0
- {pysportbot-0.0.19 → pysportbot-0.0.21}/pysportbot/utils/__init__.py +0 -0
- {pysportbot-0.0.19 → pysportbot-0.0.21}/pysportbot/utils/logger.py +0 -0
- {pysportbot-0.0.19 → pysportbot-0.0.21}/pysportbot/utils/time.py +0 -0
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pysportbot
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.21
|
|
4
4
|
Summary: A python-based bot for automatic resasports slot booking
|
|
5
5
|
License-File: LICENSE
|
|
6
6
|
Author: Joshua Falco Beirer
|
|
7
7
|
Author-email: jbeirer@cern.ch
|
|
8
|
-
Requires-Python: >=3.
|
|
8
|
+
Requires-Python: >=3.11,<3.15
|
|
9
9
|
Classifier: Programming Language :: Python :: 3
|
|
10
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
11
10
|
Classifier: Programming Language :: Python :: 3.11
|
|
12
11
|
Classifier: Programming Language :: Python :: 3.12
|
|
13
12
|
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
-
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
14
|
Requires-Dist: pandas (>=2.3.2,<3.0.0)
|
|
16
15
|
Requires-Dist: pytz (>=2025.2,<2026.0)
|
|
17
16
|
Requires-Dist: requests (>=2.32.5,<3.0.0)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pysportbot"
|
|
3
|
-
version = "
|
|
3
|
+
version = "0.0.21"
|
|
4
4
|
description = "A python-based bot for automatic resasports slot booking"
|
|
5
5
|
authors = [
|
|
6
6
|
{ name = "Joshua Falco Beirer", email = "jbeirer@cern.ch" }
|
|
@@ -14,9 +14,8 @@ packages = [
|
|
|
14
14
|
dynamic = ["requires-python", "dependencies"]
|
|
15
15
|
|
|
16
16
|
[tool.poetry.dependencies]
|
|
17
|
-
python = ">=3.
|
|
17
|
+
python = ">=3.11,<3.15"
|
|
18
18
|
requests = "^2.32.5"
|
|
19
|
-
beautifulsoup4 = "^4.13.5"
|
|
20
19
|
pandas = "^2.3.2"
|
|
21
20
|
pytz = "^2025.2"
|
|
22
21
|
|
|
@@ -37,7 +36,7 @@ mkdocs-material = "^9.6.20"
|
|
|
37
36
|
mkdocstrings = {extras = ["python"], version = "^0.30.0"}
|
|
38
37
|
|
|
39
38
|
[build-system]
|
|
40
|
-
requires = ["poetry-core>=2.2.
|
|
39
|
+
requires = ["poetry-core>=2.2.1"]
|
|
41
40
|
build-backend = "poetry.core.masonry.api"
|
|
42
41
|
|
|
43
42
|
[tool.black]
|
|
@@ -0,0 +1,304 @@
|
|
|
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 Authenticator:
|
|
12
|
+
"""
|
|
13
|
+
Handles user authentication and Nubapp login functionality.
|
|
14
|
+
|
|
15
|
+
Flow overview:
|
|
16
|
+
|
|
17
|
+
1. Login to Resasocial (api.resasocial.com) via /user/login
|
|
18
|
+
-> get Resasocial JWT + (id_user, id_application).
|
|
19
|
+
|
|
20
|
+
2. Store (id_user, id_application) in self.creds for use by Activities.
|
|
21
|
+
|
|
22
|
+
3. Call /secure/user/getSportUserToken with the Resasocial JWT
|
|
23
|
+
-> get Nubapp (sport.nubapp.com) JWT.
|
|
24
|
+
|
|
25
|
+
4. Store Nubapp JWT in self.headers["Authorization"].
|
|
26
|
+
|
|
27
|
+
5. Use Nubapp JWT to call /api/v4/users/getUser.php and verify the user.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, session: Session, centre: str) -> None:
|
|
31
|
+
self.session = session.session
|
|
32
|
+
# Base headers; will be enriched with Nubapp JWT after login
|
|
33
|
+
self.headers = session.headers
|
|
34
|
+
# Centre is still passed in from the bot, but not used in the new flow
|
|
35
|
+
self.centre = centre
|
|
36
|
+
self.timeout = (5, 10)
|
|
37
|
+
|
|
38
|
+
# Authentication state
|
|
39
|
+
self.authenticated: bool = False
|
|
40
|
+
self.user_id: str | None = None
|
|
41
|
+
|
|
42
|
+
# Minimal "credentials" object used by Activities
|
|
43
|
+
# (id_application, id_user) are filled after /user/login.
|
|
44
|
+
self.creds: dict[str, str] = {}
|
|
45
|
+
|
|
46
|
+
# Resasocial (resasports) JWT state
|
|
47
|
+
self.resasocial_jwt: str | None = None
|
|
48
|
+
self.resasocial_refresh: str | None = None
|
|
49
|
+
self.id_user: str | None = None
|
|
50
|
+
self.id_application: str | None = None
|
|
51
|
+
|
|
52
|
+
# Nubapp JWT tokens used for authenticated sport.nubapp.com requests
|
|
53
|
+
self.sport_jwt: str | None = None
|
|
54
|
+
self.sport_refresh: str | None = None
|
|
55
|
+
|
|
56
|
+
# ------------------------------------------------------------------
|
|
57
|
+
# Public API
|
|
58
|
+
# ------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
def login(self, email: str, password: str) -> None:
|
|
61
|
+
"""
|
|
62
|
+
Full login flow:
|
|
63
|
+
|
|
64
|
+
1. Resasocial JSON login (/user/login)
|
|
65
|
+
2. Fill self.creds with (id_application, id_user) for Activities
|
|
66
|
+
3. /secure/user/getSportUserToken -> Nubapp JWT
|
|
67
|
+
4. Store Nubapp JWT in headers and fetch user info to confirm identity
|
|
68
|
+
"""
|
|
69
|
+
logger.info("Starting login process...")
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
self._resasocial_jwt_login(email, password)
|
|
73
|
+
|
|
74
|
+
# Expose IDs in the same structure Activities expects
|
|
75
|
+
self.creds = {
|
|
76
|
+
"id_application": str(self.id_application),
|
|
77
|
+
"id_user": str(self.id_user),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
self._get_sport_user_token()
|
|
81
|
+
self._authenticate_with_bearer_token(self.sport_jwt)
|
|
82
|
+
self._fetch_user_information()
|
|
83
|
+
|
|
84
|
+
self.authenticated = True
|
|
85
|
+
logger.info("Login process completed successfully!")
|
|
86
|
+
|
|
87
|
+
except Exception as exc:
|
|
88
|
+
self.authenticated = False
|
|
89
|
+
self.user_id = None
|
|
90
|
+
logger.error(f"Login process failed: {exc}")
|
|
91
|
+
# Normalize to a consistent login error for callers/tests
|
|
92
|
+
raise ValueError(ErrorMessages.failed_login()) from exc
|
|
93
|
+
|
|
94
|
+
def is_session_valid(self) -> bool:
|
|
95
|
+
"""
|
|
96
|
+
Check whether the current Nubapp JWT is still valid by probing USER.
|
|
97
|
+
"""
|
|
98
|
+
if not self.sport_jwt:
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
# At this point self.headers already contains Nubapp Authorization
|
|
103
|
+
response = self.session.post(
|
|
104
|
+
Endpoints.USER,
|
|
105
|
+
headers=self.headers,
|
|
106
|
+
timeout=self.timeout,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if response.status_code != 200:
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
data = response.json()
|
|
113
|
+
return bool(data.get("data", {}).get("user"))
|
|
114
|
+
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
logger.debug(f"Session validation failed: {exc}")
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
# ------------------------------------------------------------------
|
|
120
|
+
# Step 1: Resasocial JWT login
|
|
121
|
+
# ------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def _resasocial_jwt_login(self, email: str, password: str) -> None:
|
|
124
|
+
"""Perform login via the /user/login JSON endpoint on api.resasocial.com."""
|
|
125
|
+
logger.debug("Logging in via Resasocial /user/login (JWT flow)")
|
|
126
|
+
|
|
127
|
+
payload = {"username": email, "password": password}
|
|
128
|
+
# Use a copy of the base headers: no Authorization here.
|
|
129
|
+
headers = self.headers.copy()
|
|
130
|
+
|
|
131
|
+
response = self.session.post(
|
|
132
|
+
Endpoints.USER_LOGIN,
|
|
133
|
+
json=payload,
|
|
134
|
+
headers=headers,
|
|
135
|
+
timeout=self.timeout,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
if response.status_code != 200:
|
|
139
|
+
logger.error(
|
|
140
|
+
"JWT login failed with status %s. Body (truncated): %r",
|
|
141
|
+
response.status_code,
|
|
142
|
+
response.text[:200],
|
|
143
|
+
)
|
|
144
|
+
raise ValueError(ErrorMessages.failed_login())
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
data = response.json()
|
|
148
|
+
except Exception as exc:
|
|
149
|
+
logger.error(
|
|
150
|
+
"JWT login returned non-JSON response. Body (truncated): %r",
|
|
151
|
+
response.text[:200],
|
|
152
|
+
)
|
|
153
|
+
raise ValueError(ErrorMessages.failed_login()) from exc
|
|
154
|
+
|
|
155
|
+
logger.debug(
|
|
156
|
+
"/user/login response keys: %s; applications count=%d",
|
|
157
|
+
list(data.keys()),
|
|
158
|
+
len(data.get("applications") or []),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
self.resasocial_jwt = data.get("jwt_token")
|
|
162
|
+
self.resasocial_refresh = data.get("refresh_token")
|
|
163
|
+
|
|
164
|
+
apps = data.get("applications") or []
|
|
165
|
+
if not apps:
|
|
166
|
+
logger.error("No applications returned in /user/login response")
|
|
167
|
+
raise ValueError(ErrorMessages.failed_login())
|
|
168
|
+
|
|
169
|
+
first_app = apps[0]
|
|
170
|
+
self.id_application = first_app.get("id_application")
|
|
171
|
+
self.id_user = first_app.get("id_user")
|
|
172
|
+
|
|
173
|
+
if not self.resasocial_jwt or not self.id_user or not self.id_application:
|
|
174
|
+
logger.error(
|
|
175
|
+
"Missing required fields in /user/login response: "
|
|
176
|
+
f"jwt_token={self.resasocial_jwt}, id_user={self.id_user}, "
|
|
177
|
+
f"id_application={self.id_application}"
|
|
178
|
+
)
|
|
179
|
+
raise ValueError(ErrorMessages.failed_login())
|
|
180
|
+
|
|
181
|
+
logger.info(
|
|
182
|
+
"JWT login successful. id_user=%s, id_application=%s",
|
|
183
|
+
self.id_user,
|
|
184
|
+
self.id_application,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# ------------------------------------------------------------------
|
|
188
|
+
# Step 3: getSportUserToken -> Nubapp JWT
|
|
189
|
+
# ------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
def _get_sport_user_token(self) -> None:
|
|
192
|
+
"""
|
|
193
|
+
Request the Nubapp JWT token using the Resasocial JWT.
|
|
194
|
+
This replaces the old login_from_social.php redirect method.
|
|
195
|
+
"""
|
|
196
|
+
logger.debug("Fetching Nubapp JWT via getSportUserToken")
|
|
197
|
+
|
|
198
|
+
if not self.resasocial_jwt or not self.id_user or not self.id_application:
|
|
199
|
+
logger.error("Cannot fetch sport user token without resasocial_jwt, id_user, id_application")
|
|
200
|
+
raise ValueError(ErrorMessages.failed_login())
|
|
201
|
+
|
|
202
|
+
# Local headers specific to this Resasocial-authenticated call
|
|
203
|
+
social_auth_headers = self.headers.copy()
|
|
204
|
+
social_auth_headers["Authorization"] = f"Bearer {self.resasocial_jwt}"
|
|
205
|
+
|
|
206
|
+
params = {
|
|
207
|
+
"id_user": self.id_user,
|
|
208
|
+
"id_application": self.id_application,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
response = self.session.get(
|
|
212
|
+
Endpoints.SPORT_USER_TOKEN,
|
|
213
|
+
params=params,
|
|
214
|
+
headers=social_auth_headers,
|
|
215
|
+
timeout=self.timeout,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
if response.status_code != 200:
|
|
219
|
+
logger.error(
|
|
220
|
+
"getSportUserToken failed with status %s. Body (truncated): %r",
|
|
221
|
+
response.status_code,
|
|
222
|
+
response.text[:200],
|
|
223
|
+
)
|
|
224
|
+
raise ValueError(ErrorMessages.failed_login())
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
data = response.json()
|
|
228
|
+
except Exception as exc:
|
|
229
|
+
logger.error(
|
|
230
|
+
"getSportUserToken returned non-JSON response. Body (truncated): %r",
|
|
231
|
+
response.text[:200],
|
|
232
|
+
)
|
|
233
|
+
raise ValueError(ErrorMessages.failed_login()) from exc
|
|
234
|
+
|
|
235
|
+
logger.debug("response keys: %s", list(data.keys()))
|
|
236
|
+
|
|
237
|
+
self.sport_jwt = data.get("jwt_token")
|
|
238
|
+
self.sport_refresh = data.get("refresh_token")
|
|
239
|
+
|
|
240
|
+
if not self.sport_jwt:
|
|
241
|
+
logger.error("No jwt_token found in getSportUserToken response")
|
|
242
|
+
raise ValueError(ErrorMessages.failed_login())
|
|
243
|
+
|
|
244
|
+
logger.info("Nubapp JWT obtained successfully.")
|
|
245
|
+
|
|
246
|
+
# ------------------------------------------------------------------
|
|
247
|
+
# Step 4: Set Authorization header for Nubapp
|
|
248
|
+
# ------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
def _authenticate_with_bearer_token(self, token: str | None) -> None:
|
|
251
|
+
"""
|
|
252
|
+
Store the Nubapp JWT in self.headers so that all subsequent
|
|
253
|
+
Nubapp API calls (including Activities) share the same auth.
|
|
254
|
+
"""
|
|
255
|
+
if not token:
|
|
256
|
+
raise ValueError(ErrorMessages.failed_login())
|
|
257
|
+
|
|
258
|
+
logger.debug("Setting Nubapp Authorization header")
|
|
259
|
+
self.headers["Authorization"] = f"Bearer {token}"
|
|
260
|
+
|
|
261
|
+
# ------------------------------------------------------------------
|
|
262
|
+
# Step 5: Nubapp User info
|
|
263
|
+
# ------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
def _fetch_user_information(self) -> None:
|
|
266
|
+
"""
|
|
267
|
+
Fetch and validate user information from Nubapp.
|
|
268
|
+
|
|
269
|
+
No extra payload — we just rely on the Nubapp JWT already set in self.headers.
|
|
270
|
+
"""
|
|
271
|
+
logger.debug("Fetching user info from Nubapp")
|
|
272
|
+
|
|
273
|
+
if not self.sport_jwt:
|
|
274
|
+
raise ValueError(ErrorMessages.failed_login())
|
|
275
|
+
|
|
276
|
+
response = self.session.post(
|
|
277
|
+
Endpoints.USER,
|
|
278
|
+
headers=self.headers,
|
|
279
|
+
timeout=self.timeout,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
if response.status_code != 200:
|
|
283
|
+
logger.error(
|
|
284
|
+
"Fetching user info failed with status %s. Body (truncated): %r",
|
|
285
|
+
response.status_code,
|
|
286
|
+
response.text[:200],
|
|
287
|
+
)
|
|
288
|
+
raise ValueError(ErrorMessages.failed_login())
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
data = response.json()
|
|
292
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
293
|
+
raise ValueError(f"Failed to parse user information: {exc}") from exc
|
|
294
|
+
|
|
295
|
+
user_data = data.get("data", {}).get("user")
|
|
296
|
+
if not user_data:
|
|
297
|
+
raise ValueError("No user data found in response")
|
|
298
|
+
|
|
299
|
+
user_id = user_data.get("id_user")
|
|
300
|
+
if not user_id:
|
|
301
|
+
raise ValueError("No user ID found in response")
|
|
302
|
+
|
|
303
|
+
self.user_id = str(user_id)
|
|
304
|
+
logger.info("Authentication successful. User ID: %s", self.user_id)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Endpoints(StrEnum):
|
|
5
|
+
"""
|
|
6
|
+
Centralized collection of API endpoints used by the bot.
|
|
7
|
+
|
|
8
|
+
This enum provides type-safe access to all URLs with clear structure.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
# ============================================================
|
|
12
|
+
# Base URLs
|
|
13
|
+
# ============================================================
|
|
14
|
+
|
|
15
|
+
# Resasocial / Resasports API (used for login, centre data, etc.)
|
|
16
|
+
BASE_SOCIAL = "https://api.resasocial.com"
|
|
17
|
+
|
|
18
|
+
# Nubapp API (used for bookings, user data, etc.)
|
|
19
|
+
BASE_NUBAPP = "https://sport.nubapp.com"
|
|
20
|
+
|
|
21
|
+
# Path used for all Nubapp JSON API endpoints
|
|
22
|
+
NUBAPP_API = "api/v4"
|
|
23
|
+
|
|
24
|
+
# ============================================================
|
|
25
|
+
# Centre Management
|
|
26
|
+
# ============================================================
|
|
27
|
+
|
|
28
|
+
# Returns the list of centres with their bounds / metadata.
|
|
29
|
+
# Used by Centres.fetch_centres() to populate the centre list (slug, name, etc).
|
|
30
|
+
CENTRE = f"{BASE_SOCIAL}/ajax/applications/bounds/"
|
|
31
|
+
|
|
32
|
+
# ============================================================
|
|
33
|
+
# Authentication
|
|
34
|
+
# ============================================================
|
|
35
|
+
|
|
36
|
+
# Resasports login endpoint
|
|
37
|
+
USER_LOGIN = f"{BASE_SOCIAL}/user/login"
|
|
38
|
+
|
|
39
|
+
# Nubapp authentification via JWT token
|
|
40
|
+
SPORT_USER_TOKEN = f"{BASE_SOCIAL}/secure/user/getSportUserToken"
|
|
41
|
+
|
|
42
|
+
# ============================================================
|
|
43
|
+
# Nubapp User & Application
|
|
44
|
+
# ============================================================
|
|
45
|
+
|
|
46
|
+
# User information endpoint (requires Nubapp JWT)
|
|
47
|
+
USER = f"{BASE_NUBAPP}/{NUBAPP_API}/users/getUser.php"
|
|
48
|
+
|
|
49
|
+
# ============================================================
|
|
50
|
+
# Activities & Scheduling
|
|
51
|
+
# ============================================================
|
|
52
|
+
|
|
53
|
+
ACTIVITIES = f"{BASE_NUBAPP}/{NUBAPP_API}/activities/getActivities.php"
|
|
54
|
+
SLOTS = f"{BASE_NUBAPP}/{NUBAPP_API}/activities/getActivitiesCalendar.php"
|
|
55
|
+
|
|
56
|
+
# ============================================================
|
|
57
|
+
# Booking
|
|
58
|
+
# ============================================================
|
|
59
|
+
|
|
60
|
+
BOOKING = f"{BASE_NUBAPP}/{NUBAPP_API}/activities/bookActivityCalendar.php"
|
|
61
|
+
CANCELLATION = f"{BASE_NUBAPP}/{NUBAPP_API}/activities/leaveActivityCalendar.php"
|
|
62
|
+
|
|
63
|
+
# ============================================================
|
|
64
|
+
# Utility
|
|
65
|
+
# ============================================================
|
|
66
|
+
|
|
67
|
+
def __str__(self) -> str:
|
|
68
|
+
"""Return URL string for direct HTTP usage."""
|
|
69
|
+
return str(self.value)
|
|
@@ -103,7 +103,6 @@ def schedule_bookings(
|
|
|
103
103
|
time_until_execution = (execution_time - now).total_seconds()
|
|
104
104
|
|
|
105
105
|
if time_until_execution > 0:
|
|
106
|
-
|
|
107
106
|
logger.info(
|
|
108
107
|
f"Waiting {time_until_execution:.2f} seconds until global execution time: "
|
|
109
108
|
f"{execution_time.strftime('%Y-%m-%d %H:%M:%S %z')}."
|
|
@@ -20,7 +20,7 @@ class ErrorMessages:
|
|
|
20
20
|
|
|
21
21
|
@staticmethod
|
|
22
22
|
def invalid_class_definition() -> str:
|
|
23
|
-
return "Each class must include 'activity', 'class_day',
|
|
23
|
+
return "Each class must include 'activity', 'class_day', 'class_time'"
|
|
24
24
|
|
|
25
25
|
@staticmethod
|
|
26
26
|
def invalid_booking_execution_format() -> str:
|
|
@@ -1,217 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
from urllib.parse import parse_qs, urlparse
|
|
3
|
-
|
|
4
|
-
from bs4 import BeautifulSoup
|
|
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
|
-
|
|
11
|
-
logger = get_logger(__name__)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
class Authenticator:
|
|
15
|
-
"""Handles user authentication and Nubapp login functionality."""
|
|
16
|
-
|
|
17
|
-
def __init__(self, session: Session, centre: str) -> None:
|
|
18
|
-
"""
|
|
19
|
-
Initialize the Authenticator.
|
|
20
|
-
|
|
21
|
-
Args:
|
|
22
|
-
session (Session): An instance of the Session class.
|
|
23
|
-
centre (str): The centre selected by the user.
|
|
24
|
-
"""
|
|
25
|
-
self.session = session.session
|
|
26
|
-
self.headers = session.headers
|
|
27
|
-
self.creds: dict[str, str] = {}
|
|
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
|
|
54
|
-
|
|
55
|
-
def login(self, email: str, password: str) -> None:
|
|
56
|
-
"""
|
|
57
|
-
Authenticate the user with email and password and log in to Nubapp.
|
|
58
|
-
|
|
59
|
-
Args:
|
|
60
|
-
email (str): The user's email address.
|
|
61
|
-
password (str): The user's password.
|
|
62
|
-
|
|
63
|
-
Raises:
|
|
64
|
-
ValueError: If login credentials are invalid or authentication fails.
|
|
65
|
-
RuntimeError: If the login process fails due to system errors.
|
|
66
|
-
"""
|
|
67
|
-
logger.info("Starting login process...")
|
|
68
|
-
|
|
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)
|
|
95
|
-
if response.status_code != 200:
|
|
96
|
-
raise RuntimeError(ErrorMessages.failed_fetch("login popup"))
|
|
97
|
-
|
|
98
|
-
soup = BeautifulSoup(response.text, "html.parser")
|
|
99
|
-
csrf_tag = soup.find("input", {"name": "_csrf_token"})
|
|
100
|
-
if csrf_tag is None:
|
|
101
|
-
raise ValueError("CSRF token input not found on the page")
|
|
102
|
-
|
|
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
|
-
|
|
111
|
-
payload = {
|
|
112
|
-
"_username": email,
|
|
113
|
-
"_password": password,
|
|
114
|
-
"_csrf_token": csrf_token,
|
|
115
|
-
"_submit": "",
|
|
116
|
-
"_force": "true",
|
|
117
|
-
}
|
|
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
|
-
|
|
124
|
-
if response.status_code != 200:
|
|
125
|
-
logger.error(f"Site login failed: {response.status_code}")
|
|
126
|
-
raise ValueError(ErrorMessages.failed_login())
|
|
127
|
-
|
|
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
|
-
|
|
134
|
-
cred_endpoint = Endpoints.get_cred_endpoint(self.centre)
|
|
135
|
-
response = self.session.get(cred_endpoint, headers=self.headers, timeout=self.timeout)
|
|
136
|
-
|
|
137
|
-
if response.status_code != 200:
|
|
138
|
-
raise RuntimeError(ErrorMessages.failed_fetch("credentials"))
|
|
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,
|
|
162
|
-
)
|
|
163
|
-
|
|
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())
|
|
172
|
-
|
|
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")
|
|
190
|
-
|
|
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:
|
|
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
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
from enum import Enum
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
class Endpoints(str, Enum):
|
|
5
|
-
"""
|
|
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.
|
|
10
|
-
"""
|
|
11
|
-
|
|
12
|
-
# === Base URLs ===
|
|
13
|
-
BASE_SOCIAL = "https://social.resasports.com"
|
|
14
|
-
BASE_NUBAPP = "https://sport.nubapp.com"
|
|
15
|
-
|
|
16
|
-
# === URL Components ===
|
|
17
|
-
NUBAPP_RESOURCES = "web/resources"
|
|
18
|
-
NUBAPP_API = "api/v4"
|
|
19
|
-
|
|
20
|
-
# === Centre Management ===
|
|
21
|
-
CENTRE = f"{BASE_SOCIAL}/ajax/applications/bounds/"
|
|
22
|
-
|
|
23
|
-
# === Authentication ===
|
|
24
|
-
USER_LOGIN = f"{BASE_SOCIAL}/popup/login"
|
|
25
|
-
LOGIN_CHECK = f"{BASE_SOCIAL}/popup/login_check"
|
|
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"
|
|
30
|
-
|
|
31
|
-
# === Activities & Scheduling ===
|
|
32
|
-
ACTIVITIES = f"{BASE_NUBAPP}/{NUBAPP_API}/activities/getActivities.php"
|
|
33
|
-
SLOTS = f"{BASE_NUBAPP}/{NUBAPP_API}/activities/getActivitiesCalendar.php"
|
|
34
|
-
|
|
35
|
-
# === Booking Management ===
|
|
36
|
-
BOOKING = f"{BASE_NUBAPP}/{NUBAPP_API}/activities/bookActivityCalendar.php"
|
|
37
|
-
CANCELLATION = f"{BASE_NUBAPP}/{NUBAPP_API}/activities/leaveActivityCalendar.php"
|
|
38
|
-
|
|
39
|
-
@classmethod
|
|
40
|
-
def get_cred_endpoint(cls, centre_slug: str) -> str:
|
|
41
|
-
"""
|
|
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"
|
|
53
|
-
"""
|
|
54
|
-
return f"{cls.BASE_SOCIAL}/ajax/application/{centre_slug}/book/request"
|
|
55
|
-
|
|
56
|
-
def __str__(self) -> str:
|
|
57
|
-
"""Return the URL string for direct use in HTTP requests."""
|
|
58
|
-
return str(self.value)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|