pysportbot 0.0.18__tar.gz → 0.0.20__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.
Files changed (25) hide show
  1. {pysportbot-0.0.18 → pysportbot-0.0.20}/PKG-INFO +10 -9
  2. {pysportbot-0.0.18 → pysportbot-0.0.20}/README.md +5 -4
  3. {pysportbot-0.0.18 → pysportbot-0.0.20}/pyproject.toml +23 -24
  4. pysportbot-0.0.20/pysportbot/authenticator.py +304 -0
  5. pysportbot-0.0.20/pysportbot/endpoints.py +69 -0
  6. pysportbot-0.0.18/pysportbot/authenticator.py +0 -217
  7. pysportbot-0.0.18/pysportbot/endpoints.py +0 -58
  8. {pysportbot-0.0.18 → pysportbot-0.0.20}/LICENSE +0 -0
  9. {pysportbot-0.0.18 → pysportbot-0.0.20}/pysportbot/__init__.py +0 -0
  10. {pysportbot-0.0.18 → pysportbot-0.0.20}/pysportbot/activities.py +0 -0
  11. {pysportbot-0.0.18 → pysportbot-0.0.20}/pysportbot/bookings.py +0 -0
  12. {pysportbot-0.0.18 → pysportbot-0.0.20}/pysportbot/centres.py +0 -0
  13. {pysportbot-0.0.18 → pysportbot-0.0.20}/pysportbot/service/__init__.py +0 -0
  14. {pysportbot-0.0.18 → pysportbot-0.0.20}/pysportbot/service/__main__.py +0 -0
  15. {pysportbot-0.0.18 → pysportbot-0.0.20}/pysportbot/service/booking.py +0 -0
  16. {pysportbot-0.0.18 → pysportbot-0.0.20}/pysportbot/service/config_loader.py +0 -0
  17. {pysportbot-0.0.18 → pysportbot-0.0.20}/pysportbot/service/config_validator.py +0 -0
  18. {pysportbot-0.0.18 → pysportbot-0.0.20}/pysportbot/service/scheduling.py +0 -0
  19. {pysportbot-0.0.18 → pysportbot-0.0.20}/pysportbot/service/service.py +0 -0
  20. {pysportbot-0.0.18 → pysportbot-0.0.20}/pysportbot/service/threading.py +0 -0
  21. {pysportbot-0.0.18 → pysportbot-0.0.20}/pysportbot/session.py +0 -0
  22. {pysportbot-0.0.18 → pysportbot-0.0.20}/pysportbot/utils/__init__.py +0 -0
  23. {pysportbot-0.0.18 → pysportbot-0.0.20}/pysportbot/utils/errors.py +0 -0
  24. {pysportbot-0.0.18 → pysportbot-0.0.20}/pysportbot/utils/logger.py +0 -0
  25. {pysportbot-0.0.18 → pysportbot-0.0.20}/pysportbot/utils/time.py +0 -0
@@ -1,7 +1,8 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: pysportbot
3
- Version: 0.0.18
3
+ Version: 0.0.20
4
4
  Summary: A python-based bot for automatic resasports slot booking
5
+ License-File: LICENSE
5
6
  Author: Joshua Falco Beirer
6
7
  Author-email: jbeirer@cern.ch
7
8
  Requires-Python: >=3.10,<3.14
@@ -10,10 +11,9 @@ Classifier: Programming Language :: Python :: 3.10
10
11
  Classifier: Programming Language :: Python :: 3.11
11
12
  Classifier: Programming Language :: Python :: 3.12
12
13
  Classifier: Programming Language :: Python :: 3.13
13
- Requires-Dist: beautifulsoup4 (>=4.13.3,<5.0.0)
14
- Requires-Dist: pandas (>=2.2.3,<3.0.0)
14
+ Requires-Dist: pandas (>=2.3.2,<3.0.0)
15
15
  Requires-Dist: pytz (>=2025.2,<2026.0)
16
- Requires-Dist: requests (>=2.32.3,<3.0.0)
16
+ Requires-Dist: requests (>=2.32.5,<3.0.0)
17
17
  Description-Content-Type: text/markdown
18
18
 
19
19
  # No queues. Just gains.
@@ -24,10 +24,11 @@ Description-Content-Type: text/markdown
24
24
  [![Release](https://img.shields.io/github/v/release/jbeirer/resasports-bot)](https://github.com/jbeirer/resasports-bot/releases)
25
25
  [![Build status](https://img.shields.io/github/actions/workflow/status/jbeirer/resasports-bot/main.yml?branch=main)](https://github.com/jbeirer/resasports-bot/actions/workflows/main.yml?query=branch%3Amain)
26
26
  [![codecov](https://codecov.io/gh/jbeirer/resasports-bot/graph/badge.svg?token=ZCJV384TXF)](https://codecov.io/gh/jbeirer/resasports-bot)
27
- [![Commit activity](https://img.shields.io/github/commit-activity/m/jbeirer/resasports-bot)](https://github.com/jbeirer/resasports-bot/commits/main/)
27
+ [![Activity](https://img.shields.io/github/commit-activity/m/jbeirer/resasports-bot?label=activity)](https://github.com/jbeirer/resasports-bot/commits/main/)
28
28
  [![License](https://img.shields.io/github/license/jbeirer/resasports-bot)](https://github.com/jbeirer/resasports-bot/blob/main/LICENSE)
29
29
  [![Documentation](https://img.shields.io/badge/api-docs-blue)](https://jbeirer.github.io/resasports-bot/)
30
- [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](https://github.com/jbeirer/resasports-bot/blob/main/CODE_OF_CONDUCT.md)
30
+ [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg?label=Contributing)](https://github.com/jbeirer/resasports-bot/blob/main/CODE_OF_CONDUCT.md)
31
+ [![Downloads](https://static.pepy.tech/badge/pysportbot)](https://pepy.tech/projects/pysportbot)
31
32
 
32
33
  PySportBot empowers you to programmatically book fitness classes at any sports center that uses the [Resasports](https://social.resasports.com/en/) booking management software.
33
34
 
@@ -154,10 +155,10 @@ jobs:
154
155
 
155
156
  steps:
156
157
  - name: Check out repository
157
- uses: actions/checkout@v2
158
+ uses: actions/checkout@v5
158
159
 
159
160
  - name: Set up Python 3.12
160
- uses: actions/setup-python@v2
161
+ uses: actions/setup-python@v6
161
162
  with:
162
163
  python-version: '3.12'
163
164
 
@@ -6,10 +6,11 @@
6
6
  [![Release](https://img.shields.io/github/v/release/jbeirer/resasports-bot)](https://github.com/jbeirer/resasports-bot/releases)
7
7
  [![Build status](https://img.shields.io/github/actions/workflow/status/jbeirer/resasports-bot/main.yml?branch=main)](https://github.com/jbeirer/resasports-bot/actions/workflows/main.yml?query=branch%3Amain)
8
8
  [![codecov](https://codecov.io/gh/jbeirer/resasports-bot/graph/badge.svg?token=ZCJV384TXF)](https://codecov.io/gh/jbeirer/resasports-bot)
9
- [![Commit activity](https://img.shields.io/github/commit-activity/m/jbeirer/resasports-bot)](https://github.com/jbeirer/resasports-bot/commits/main/)
9
+ [![Activity](https://img.shields.io/github/commit-activity/m/jbeirer/resasports-bot?label=activity)](https://github.com/jbeirer/resasports-bot/commits/main/)
10
10
  [![License](https://img.shields.io/github/license/jbeirer/resasports-bot)](https://github.com/jbeirer/resasports-bot/blob/main/LICENSE)
11
11
  [![Documentation](https://img.shields.io/badge/api-docs-blue)](https://jbeirer.github.io/resasports-bot/)
12
- [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](https://github.com/jbeirer/resasports-bot/blob/main/CODE_OF_CONDUCT.md)
12
+ [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg?label=Contributing)](https://github.com/jbeirer/resasports-bot/blob/main/CODE_OF_CONDUCT.md)
13
+ [![Downloads](https://static.pepy.tech/badge/pysportbot)](https://pepy.tech/projects/pysportbot)
13
14
 
14
15
  PySportBot empowers you to programmatically book fitness classes at any sports center that uses the [Resasports](https://social.resasports.com/en/) booking management software.
15
16
 
@@ -136,10 +137,10 @@ jobs:
136
137
 
137
138
  steps:
138
139
  - name: Check out repository
139
- uses: actions/checkout@v2
140
+ uses: actions/checkout@v5
140
141
 
141
142
  - name: Set up Python 3.12
142
- uses: actions/setup-python@v2
143
+ uses: actions/setup-python@v6
143
144
  with:
144
145
  python-version: '3.12'
145
146
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pysportbot"
3
- version = "v0.0.18"
3
+ version = "v0.0.20"
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" }
@@ -15,29 +15,28 @@ dynamic = ["requires-python", "dependencies"]
15
15
 
16
16
  [tool.poetry.dependencies]
17
17
  python = ">=3.10,<3.14"
18
- requests = "^2.32.3"
19
- beautifulsoup4 = "^4.13.3"
20
- pandas = "^2.2.3"
18
+ requests = "^2.32.5"
19
+ pandas = "^2.3.2"
21
20
  pytz = "^2025.2"
22
21
 
23
22
  [tool.poetry.group.dev.dependencies]
24
- pytest = "^8.3.5"
25
- pytest-cov = "^6.0.0"
26
- deptry = "^0.23.0"
27
- mypy = "^1.15.0"
28
- pre-commit = "^4.2.0"
29
- tox = "^4.24.2"
30
- ipykernel = "^6.29.5"
31
- types-pytz = "^2025.1.0.20250318"
32
- types-requests = "^2.32.0.20250306"
23
+ pytest = "^8.4.2"
24
+ pytest-cov = "^7.0.0"
25
+ deptry = "^0.23.1"
26
+ mypy = "^1.18.1"
27
+ pre-commit = "^4.3.0"
28
+ tox = "^4.30.2"
29
+ ipykernel = "^6.30.1"
30
+ types-pytz = "^2025.2.0.20250809"
31
+ types-requests = "^2.32.4.20250913"
33
32
 
34
33
  [tool.poetry.group.docs.dependencies]
35
34
  mkdocs = "^1.6.1"
36
- mkdocs-material = "^9.6.9"
37
- mkdocstrings = {extras = ["python"], version = "^0.29.0"}
35
+ mkdocs-material = "^9.6.20"
36
+ mkdocstrings = {extras = ["python"], version = "^0.30.0"}
38
37
 
39
38
  [build-system]
40
- requires = ["poetry-core>=2.1.3"]
39
+ requires = ["poetry-core>=2.2.1"]
41
40
  build-backend = "poetry.core.masonry.api"
42
41
 
43
42
  [tool.black]
@@ -47,14 +46,14 @@ preview = true
47
46
 
48
47
  [tool.mypy]
49
48
  files = ["pysportbot"]
50
- disallow_untyped_defs = "True"
51
- no_implicit_optional = "True"
52
- check_untyped_defs = "True"
53
- warn_return_any = "True"
54
- warn_unused_ignores = "True"
55
- show_error_codes = "True"
56
- ignore_missing_imports= "True"
57
- disallow_any_unimported = "False"
49
+ disallow_untyped_defs = true
50
+ no_implicit_optional = true
51
+ check_untyped_defs = true
52
+ warn_return_any = true
53
+ warn_unused_ignores = true
54
+ show_error_codes = true
55
+ ignore_missing_imports= true
56
+ disallow_any_unimported = false
58
57
 
59
58
 
60
59
  [tool.pytest.ini_options]
@@ -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 Enum
2
+
3
+
4
+ class Endpoints(str, Enum):
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)
@@ -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