pysportbot 0.0.17__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 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 = Activities(self._session)
29
- self._bookings: Bookings = Bookings(self._session)
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 # State variable for login status
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._activities.daily_slots(self._df_activities, activity, day)
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["capacity"]
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._bookings.book(slot_id)
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._bookings.cancel(slot_id)
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 get_unix_day_bounds
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, session: Session) -> None:
19
+ def __init__(self, authenticator: Authenticator) -> None:
19
20
  """Initialize the Activities class."""
20
- self.session = session.session
21
- self.headers = session.headers
22
-
23
- def fetch(self) -> DataFrame:
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
- response = self.session.post(Endpoints.ACTIVITIES, headers=self.headers)
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
- activities = response.json().get("activities", {})
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
- if not activities:
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
- # Extract activity ID and category ID
85
- # Ensures activity_id is an integer
86
- activity_id = activity_match.index[0]
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
- unix_day_bounds = get_unix_day_bounds(day)
136
+ day_bounds = get_day_bounds(day)
91
137
 
92
138
  # Fetch slots
93
- params = {
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.get(Endpoints.SLOTS, headers=self.headers, params=params)
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
- slots = response.json()
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
- "capacity",
181
+ "n_capacity",
129
182
  "n_waiting_list",
130
183
  "cancelled",
131
- "can_join",
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["id_activity"] == activity_id]
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)
@@ -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,18 +20,18 @@ 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
- # Has the user successfully authenticated?
28
- self.authenticated = False
29
- # User ID for the authenticated user
30
- self.user_id = None
31
- # Centre selected by the user
27
+ self.creds: dict[str, str] = {}
32
28
  self.centre = centre
33
- # Timeout of requests
34
29
  self.timeout = (5, 10)
35
30
 
31
+ # Authentication state
32
+ self.authenticated = False
33
+ self.user_id: str | None = None
34
+
36
35
  def is_session_valid(self) -> bool:
37
36
  """
38
37
  Check if the current session is still valid.
@@ -45,13 +44,14 @@ class Authenticator:
45
44
  if response.status_code == 200:
46
45
  response_dict = json.loads(response.content.decode("utf-8"))
47
46
  return bool(response_dict.get("user"))
48
- else:
49
- logger.debug(f"Session validation failed with status code: {response.status_code}")
50
- return False
47
+
51
48
  except Exception as e:
52
49
  logger.debug(f"Session validation failed with exception: {e}")
53
50
  return False
54
51
 
52
+ logger.debug(f"Session validation failed with status code: {response.status_code}")
53
+ return False
54
+
55
55
  def login(self, email: str, password: str) -> None:
56
56
  """
57
57
  Authenticate the user with email and password and log in to Nubapp.
@@ -61,25 +61,53 @@ class Authenticator:
61
61
  password (str): The user's password.
62
62
 
63
63
  Raises:
64
- RuntimeError: If the login process fails at any stage.
64
+ ValueError: If login credentials are invalid or authentication fails.
65
+ RuntimeError: If the login process fails due to system errors.
65
66
  """
66
67
  logger.info("Starting login process...")
67
68
 
68
- # Step 1: Fetch CSRF token
69
- logger.debug(f"GET {Endpoints.USER_LOGIN} | Headers: {json.dumps(self.headers, indent=2)}")
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
+
70
94
  response = self.session.get(Endpoints.USER_LOGIN, headers=self.headers, timeout=self.timeout)
71
95
  if response.status_code != 200:
72
- logger.error(f"Failed to fetch login popup: {response.status_code}")
73
96
  raise RuntimeError(ErrorMessages.failed_fetch("login popup"))
74
- logger.debug("Login popup fetched successfully.")
75
97
 
76
98
  soup = BeautifulSoup(response.text, "html.parser")
77
99
  csrf_tag = soup.find("input", {"name": "_csrf_token"})
78
100
  if csrf_tag is None:
79
101
  raise ValueError("CSRF token input not found on the page")
80
- csrf_token = csrf_tag["value"] # type: ignore[index]
81
102
 
82
- # Step 2: Perform login
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
+
83
111
  payload = {
84
112
  "_username": email,
85
113
  "_password": password,
@@ -87,60 +115,103 @@ class Authenticator:
87
115
  "_submit": "",
88
116
  "_force": "true",
89
117
  }
90
- self.headers.update({"Content-Type": "application/x-www-form-urlencoded"})
91
- logger.debug(
92
- f"POST {Endpoints.LOGIN_CHECK} | Headers: {json.dumps(self.headers, indent=2)} | "
93
- f"Payload: {json.dumps(payload, indent=2)}"
94
- )
95
- response = self.session.post(Endpoints.LOGIN_CHECK, data=payload, headers=self.headers, timeout=self.timeout)
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
+
96
124
  if response.status_code != 200:
97
- logger.error(f"Login failed: {response.status_code}, {response.text}")
125
+ logger.error(f"Site login failed: {response.status_code}")
98
126
  raise ValueError(ErrorMessages.failed_login())
99
- logger.info("Login successful!")
100
127
 
101
- # Step 3: Retrieve credentials for Nubapp
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
+
102
134
  cred_endpoint = Endpoints.get_cred_endpoint(self.centre)
103
- logger.debug(f"GET {cred_endpoint} | Headers: {json.dumps(self.headers, indent=2)}")
104
135
  response = self.session.get(cred_endpoint, headers=self.headers, timeout=self.timeout)
136
+
105
137
  if response.status_code != 200:
106
- logger.error(f"Failed to retrieve Nubapp credentials: {response.status_code}")
107
138
  raise RuntimeError(ErrorMessages.failed_fetch("credentials"))
108
- nubapp_creds = ast.literal_eval(response.content.decode("utf-8"))["payload"]
109
- nubapp_creds = {k: v[0] for k, v in parse_qs(nubapp_creds).items()}
110
- nubapp_creds["platform"] = "resasocial"
111
- nubapp_creds["network"] = "resasports"
112
- logger.debug(f"Nubapp credentials retrieved: {json.dumps(nubapp_creds, indent=2)}")
113
-
114
- # Step 4: Log in to Nubapp
115
- logger.debug(
116
- f"GET {Endpoints.NUBAP_LOGIN} | Headers: {json.dumps(self.headers, indent=2)} | "
117
- f"Params: {json.dumps(nubapp_creds, indent=2)}"
118
- )
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
+
119
156
  response = self.session.get(
120
- Endpoints.NUBAP_LOGIN, headers=self.headers, params=nubapp_creds, timeout=self.timeout
157
+ Endpoints.NUBAPP_LOGIN,
158
+ headers=self.headers,
159
+ params=self.creds,
160
+ timeout=self.timeout,
161
+ allow_redirects=False,
121
162
  )
122
- if response.status_code != 200:
123
- logger.error(f"Login to Nubapp failed: {response.status_code}, {response.text}")
124
- raise ValueError(ErrorMessages.failed_login_nubapp())
125
- logger.info("Login to Nubapp successful!")
126
163
 
127
- # Step 5: Get user information
128
- response = self.session.post(Endpoints.USER, headers=self.headers, allow_redirects=True, timeout=self.timeout)
164
+ if response.status_code != 302:
165
+ logger.error(f"Nubapp login failed: {response.status_code}")
166
+ raise ValueError(ErrorMessages.failed_login())
129
167
 
130
- if response.status_code == 200:
131
- response_dict = json.loads(response.content.decode("utf-8"))
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")
132
190
 
133
- if response_dict["user"]:
134
- self.user_id = response_dict.get("user", {}).get("id_user")
135
- if self.user_id:
136
- self.authenticated = True
137
- logger.info(f"Authentication successful. User ID: {self.user_id}")
138
- else:
139
- self.authenticated = False
140
- raise ValueError()
141
- else:
142
- self.authenticated = False
143
- raise ValueError(ErrorMessages.failed_login())
144
- else:
145
- 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:
146
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, session: Session) -> None:
14
+ def __init__(self, authenticator: Authenticator) -> None:
15
15
  """Initialize the Bookings class."""
16
- self.session = session.session
17
- self.headers = session.headers
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
- # Handle response
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
- logger.error(f"Booking failed with error code: {response_json['error']}")
61
- raise RuntimeError(ErrorMessages.unknown_error("booking"))
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
- Enum class for API endpoints used in the application.
7
- Each member is a string, so you can use them directly.
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/web"
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 list
20
+ # === Centre Management ===
15
21
  CENTRE = f"{BASE_SOCIAL}/ajax/applications/bounds/"
16
22
 
17
- # Authentication Endpoints
23
+ # === Authentication ===
18
24
  USER_LOGIN = f"{BASE_SOCIAL}/popup/login"
19
25
  LOGIN_CHECK = f"{BASE_SOCIAL}/popup/login_check"
20
- NUBAP_LOGIN = f"{BASE_NUBAPP}/resources/login_from_social.php"
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
- # User, activities, and slots
23
- USER = f"{BASE_NUBAPP}/ajax/users/getUser.php"
24
- ACTIVITIES = f"{BASE_NUBAPP}/ajax/application/getActivities.php"
25
- SLOTS = f"{BASE_NUBAPP}/ajax/activities/getActivitiesCalendar.php"
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 and Cancellation
28
- BOOKING = f"{BASE_NUBAPP}/ajax/bookings/bookBookings.php"
29
- CANCELLATION = f"{BASE_NUBAPP}/ajax/activities/leaveActivityCalendar.php"
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
- Build the booking request URL for a given centre slug.
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 underlying string value instead of the member name."""
57
+ """Return the URL string for direct use in HTTP requests."""
40
58
  return str(self.value)
@@ -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,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pysportbot
3
- Version: 0.0.17
3
+ Version: 0.0.18
4
4
  Summary: A python-based bot for automatic resasports slot booking
5
5
  Author: Joshua Falco Beirer
6
6
  Author-email: jbeirer@cern.ch
@@ -1,9 +1,9 @@
1
- pysportbot/__init__.py,sha256=tZwOIuLO1-a3d4KKA5nNfXfN6PjJxem_hxyuff9utk4,6425
2
- pysportbot/activities.py,sha256=MATA0feSsDSoeFnHa4Y_dLv3gw4P6MFg-0kAEybqVIY,4991
3
- pysportbot/authenticator.py,sha256=rs3QG9aEFxG0THMm-7STqnfMlvBG3a56KnBYMYNvSmQ,6142
4
- pysportbot/bookings.py,sha256=W91AYGPu0sCpGliuiVdlENsoGYzH8P6v-SyKisbSb7g,3127
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=ANh5JAbdzyZQ-i4ODrhYlskPpU1gkBrw9UhMC7kRSvU,1353
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
9
  pysportbot/service/booking.py,sha256=ifeo1LbtPvls-hpQOZW5TvoqeZ1MJxIipim4RHTH7nI,5908
@@ -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=IRarfGtk1tETxUGAhPAIbeits_u2tJvVCGTtkHIdYFQ,5146
17
+ pysportbot/utils/errors.py,sha256=hPbWJT_uOIgYjxqm5VojlRy3wWnFj9HLZGIcbv9tq8c,4956
18
18
  pysportbot/utils/logger.py,sha256=ANayMEeeAIVGKvITAxOFm2EdCbzBBTpNywuytAr4Z90,5366
19
- pysportbot/utils/time.py,sha256=VZSW8AxFIoFD5ZSmLUPcwawp6PmpkcxNjP3Db-Hl_fw,1244
20
- pysportbot-0.0.17.dist-info/LICENSE,sha256=6ov3DypdEVYpp2pn_B1MniKWO5C9iDA4O6PGcbork6c,1077
21
- pysportbot-0.0.17.dist-info/METADATA,sha256=PW2ipkWtyRp83h_Zd1fTre_5LTuKFfGR2AT-96pnmno,8045
22
- pysportbot-0.0.17.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
23
- pysportbot-0.0.17.dist-info/RECORD,,
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,,