Compare commits

...

56 Commits

Author SHA1 Message Date
Kevin B
bc5796d989 Share local folder with container 2025-10-06 14:21:30 +00:00
Kevin Bataille
c073006592 fix: Display ascii art on startup 2025-10-06 16:16:32 +02:00
Kevin Bataille
ed7165632a No more preferred sesion in VCS 2025-10-06 16:11:42 +02:00
Kevin Bataille
d809dd6753 Update readme 2025-10-06 16:11:26 +02:00
Kevin Bataille
ea05c847ba Display ascii art and username on startup 2025-10-06 15:54:24 +02:00
Kevin Bataille
f5e0bba298 remove container name 2025-10-06 15:23:42 +02:00
Kevin Bataille
9a35a57c7f fix(auth): Update API response parsing to handle nested structure
- Fix 'id_user' key not found error by navigating the correct nested path
- Extract id_user from data.user.applications[0].users[0].id_user
- Add fallback to old structure for backward compatibility
- Prevents authentication failures due to API response format changes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 15:21:21 +02:00
Kevin Bataille
cd5f0a54ac feat(notifier): Add username display and improve config error handling
- Add CROSSFIT_USERNAME from .env to all Telegram notifications
- Make session_config exit safely when config file is missing or invalid
- Remove default hardcoded sessions, return empty list instead
- Update unit tests to reflect new error handling behavior

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 15:19:27 +02:00
Kevin Bataille
d1e5fc1003 feat: Use preferred sesions as a template 2025-10-06 15:05:20 +02:00
Kevin Bataille
7d03f5b40c chore: Move docs and scripts into directories 2025-10-06 14:55:20 +02:00
Kevin Bataille
795016a60c fix(notifier): Add retry mechanism for Telegram timeout errors
- Add exponential backoff retry logic for Telegram notifications
- Handle TimedOut and NetworkError exceptions gracefully
- Add logging for retry attempts and failures
- Prevent booking system crashes due to network timeouts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 14:13:39 +02:00
kbe
b6ea2a4ff1 refactor: Remove CrossfitBooker class and simplify booking system 2025-10-01 17:54:43 +02:00
kbe
5f89af44aa Now include session ID in booking message 2025-08-30 15:08:34 +02:00
kbe
39d408d882 fix(booker): Recalculate today target time each day
The previous script was calculating target booking time at start. 
So when it was 20:00, it was not working correctly after the first day.
2025-08-30 13:14:51 +02:00
kbe
5bdde5fee1 feat: Add QWEN agent.md for AI use 2025-08-30 12:56:12 +02:00
kbe
8b8dd68b34 No more logging outside reservation window 2025-08-16 08:17:45 +02:00
5aee4dbaf4 Merge pull request 'fix: main script on docker launch' (#11) from develop into main
Reviewed-on: #11
2025-08-12 00:10:50 +00:00
kbe
81adedba6f fix: main script on docker launch 2025-08-12 02:10:24 +02:00
2a8299300c Merge pull request 'Improve error handling and logging' (#10) from develop into main
Reviewed-on: #10
2025-08-12 00:03:11 +00:00
kbe
fa9d73a9a9 feat: enhance booking process with improved error handling and logging
Added logging to display message when outside booking window in booker.py
Added error handling for asyncio.run in crossfit_booker.py
Added logging for errors during booking process
2025-08-12 02:02:20 +02:00
kbe
cfbb857cfb chore: Revised code linter 2025-08-12 01:50:33 +02:00
8d04f0075d Merge pull request 'Refactor code and split into multiple files' (#9) from develop into main
Reviewed-on: #9
2025-08-11 23:44:32 +00:00
kbe
4872b817b3 Merge branch 'refactor/booker' into develop 2025-08-12 01:43:32 +02:00
kbe
a7f9e6bacd Fixed failing tests in test_crossfit_booker_sessions.py
Fixed test_run_auth_failure by patching the correct method (CrossFitBooker.login) and calling the correct method (booker.login())
Fixed test_run_booking_outside_window by patching the correct method (Booker.run) and adding the necessary mocking for the booking cycle
Added proper mocking for auth_token and user_id to avoid authentication errors in the tests
Updated imports to use the src prefix for all imports
Added proper test structure for the booking window logic test
All tests now pass successfully
2025-08-12 01:38:50 +02:00
kbe
8d882ad091 refactor: Move files into src directory
Refactored project structure: Moved all Python modules to a src/ directory, updated imports accordingly. Added new environment variables to Dockerfile and docker-compose.yml. Removed Dockerfile.test and TODO file.
2025-08-12 01:10:26 +02:00
kbe
90230832ee refactor: Split code into many files 2025-08-12 00:53:16 +02:00
kbe
944421c68b refactor: Booking preferred sessions works
I modified the code to book only preferred sessions.
First it displays available sessions in console INFO.
Then if there is sessions that matches preferred ones,
it tries to book them and the notify bout it.
2025-08-12 00:20:21 +02:00
kbe
7161a11905 feat: Scripts display preferred sessions during execution 2025-08-12 00:09:56 +02:00
kbe
3b8a755a25 feat: Add book session script 2025-08-08 22:21:17 +02:00
kbe
6c29fc0802 chore: Renamed datetime to dt 2025-08-08 22:19:40 +02:00
kbe
439c5f3d6f feat: New script for booking test function in shell and python 2025-08-08 22:09:48 +02:00
kbe
30eb9863a0 refactor: does not notify if no session found 2025-08-08 21:54:12 +02:00
kbe
888728729f feat: more debug logs 2025-08-05 01:24:25 +02:00
kbe
a6cb6cb7b6 refactor: Reduce code size 2025-08-04 23:34:07 +02:00
kbe
d31976084a feat: Time delta as env var 2025-08-04 21:45:35 +02:00
kbe
90923b0f1c Less window timing and sessions change 2025-07-29 00:10:06 +02:00
kbe
d2b63ea807 More window timing 2025-07-29 00:06:03 +02:00
kbe
7b4d66c779 10 minutes window end 2025-07-26 09:13:06 +02:00
kbe
4401adfebb chore: no more 80% match for booking 2025-07-25 15:50:09 +02:00
kbe
2b99bc37de more tests? 2025-07-25 15:47:35 +02:00
kbe
f975cb529f test: More coverage on methods. All tests are passed. 2025-07-25 14:17:12 +02:00
kbe
0baa5b6e3f test: add more coverage 2025-07-25 14:09:03 +02:00
kbe
b2f923a6c3 test: more code coverage 2025-07-25 13:31:03 +02:00
kbe
17cb728dd9 test: add coverage for most methods but still bugs 2025-07-25 13:25:12 +02:00
kbe
5e597c4d1a refactor: try to reduce code size 2025-07-24 20:51:39 +02:00
kbe
352fae2d25 Merge branch 'develop' 2025-07-24 16:23:00 +02:00
kbe
3e33ae5132 add a functional file 2025-07-24 15:08:53 +02:00
kbe
cf4780d9d0 feat: only book session in current day, +1 and +2 2025-07-24 12:06:42 +02:00
kbe
ef65069592 feat: only book session during window + 1h 2025-07-21 23:48:44 +02:00
kbe
cacdd74184 feat: only book session during window + 1h 2025-07-21 23:48:14 +02:00
ef7f82bc76 Merge pull request 'feat: ENV var to toggle impossible notification' (#7) from develop into main
Reviewed-on: #7
2025-07-21 19:38:02 +00:00
kbe
fcd227e3ed feat: ENV var to toggle impossible notification 2025-07-21 21:32:05 +02:00
kbe
ed65cd836f Merge branch 'develop' 2025-07-21 21:12:41 +02:00
fa382f4c6b Merge pull request 'feat: Add a restart file to setup docker' (#6) from develop into main
Reviewed-on: #6
2025-07-21 18:51:29 +00:00
0c4a7224d3 Merge pull request 'Async notfication' (#5) from develop into main
Reviewed-on: #5
2025-07-21 18:40:20 +00:00
e4656eaf54 Merge pull request 'Tolerance window' (#4) from develop into main
Reviewed-on: #4
2025-07-21 13:21:11 +00:00
38 changed files with 2242 additions and 682 deletions

View File

@@ -1,10 +1,14 @@
# CrossFit booking credentials # CrossFit booking credentials
CROSSFIT_USERNAME=your_username CROSSFIT_USERNAME=your_username
CROSSFIT_PASSWORD=your_password CROSSFIT_PASSWORD=your_password
TARGET_RESERVATION_TIME="21:01"
BOOKING_WINDOW_END_DELTA_MINUTES="59"
# Notification settings # Notification settings
ENABLE_EMAIL_NOTIFICATIONS=true ENABLE_EMAIL_NOTIFICATIONS=true
ENABLE_TELEGRAM_NOTIFICATIONS=true ENABLE_TELEGRAM_NOTIFICATIONS=true
NOTIFY_IMPOSSIBLE_BOOKING=false
# Email notification credentials # Email notification credentials
SMTP_SERVER=mail.infomaniak.com SMTP_SERVER=mail.infomaniak.com

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
.env .env
preferred_sessions.json
log/ log/
!log/.gitkeep !log/.gitkeep

63
AGENTS.md Normal file
View File

@@ -0,0 +1,63 @@
# Qwen Code Agent Configuration
This file configures the behavior and preferences for the Qwen Code agent for this specific project.
## Project Context
- **Name**: CrossFit Scheduler
- **Primary Language**: Python
- **Key Technologies**: Python scripts, JSON, Docker
- **Purpose**: Automates booking CrossFit classes based on user preferences.
## Agent Interaction Preferences
### Code Style & Conventions
- Follow PEP 8 for Python code.
- Use clear, descriptive variable and function names.
- Prefer explicit over implicit code.
- Use docstrings for modules, classes, and functions.
- Keep functions small and focused on a single task.
### Project Structure Awareness
- `main.py`: Entry point for the application.
- `execute_book_session.py`: Alternative execution script for booking sessions.
- `preferred_sessions.json`: User preferences for session booking.
- `src/`: Directory for source code modules.
- `auth.py`: Authentication handling with bearer token management.
- `booker.py`: Main booking logic with time-based booking windows.
- `session_manager.py`: Session fetching, filtering, and booking operations.
- `session_notifier.py`: Email and Telegram notifications for booking status.
- `session_config.py`: Configuration for preferred session matching.
- `test_book_session.sh`: Shell script for testing session booking.
- `requirements.txt`: Python dependencies.
- `Dockerfile` and `docker-compose.yml`: Container configuration.
### Preferred Tools & Commands
- Use `pytest` for running tests.
- Use `docker compose` for container orchestration.
- Use `python` for running scripts.
- Refer to `requirements.txt` for dependencies.
### Communication Style
- Be concise and direct.
- Use markdown for formatting responses.
- Prioritize clarity and correctness.
- Avoid unnecessary explanations or chit-chat.
### Testing & Verification
- Always run tests after making changes.
- Use `pytest.ini` for test configuration.
- Ensure Docker containers are properly configured before running.
- Verify JSON files are correctly formatted.
### Security & Best Practices
- Never commit sensitive information like passwords or API keys.
- Use `.env` files for environment variables.
- Follow Docker best practices for containerization.
- Keep dependencies up to date.
## Custom Instructions
- When modifying `preferred_sessions.json`, ensure the structure remains valid.
- When writing shell scripts, ensure they are executable and follow best practices.
- When creating new Python files, ensure they are properly formatted and tested.
- Environment variables required: `CROSSFIT_USERNAME`, `CROSSFIT_PASSWORD`, plus optional email/telegram settings.
- Booking window controlled by `TARGET_RESERVATION_TIME` (default: "20:01") and `BOOKING_WINDOW_END_DELTA_MINUTES` (default: 10).

View File

@@ -5,6 +5,8 @@ FROM python:3.11-slim
WORKDIR /app WORKDIR /app
# Set environment variables using the ARG values # Set environment variables using the ARG values
ENV TARGET_RESERVATION_TIME=${TARGET_RESERVATION_TIME}
ENV BOOKING_WINDOW_END_DELTA_MINUTES=${BOOKING_WINDOW_END_DELTA_MINUTES}
ENV CROSSFIT_USERNAME=${CROSSFIT_USERNAME} ENV CROSSFIT_USERNAME=${CROSSFIT_USERNAME}
ENV CROSSFIT_PASSWORD=${CROSSFIT_PASSWORD} ENV CROSSFIT_PASSWORD=${CROSSFIT_PASSWORD}
ENV EMAIL_FROM=${EMAIL_FROM} ENV EMAIL_FROM=${EMAIL_FROM}
@@ -26,4 +28,4 @@ COPY . .
RUN mkdir -p log RUN mkdir -p log
# Set the entry point to run the main script # Set the entry point to run the main script
ENTRYPOINT ["python", "book_crossfit.py"] ENTRYPOINT ["python", "main.py"]

View File

@@ -1,17 +0,0 @@
# Use the official Python image from the Docker Hub
FROM python:3.11-slim
# Set the working directory
WORKDIR /app
# Copy the requirements file into the container
COPY requirements.txt .
# Install the dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy the rest of the application code
COPY . .
# Run the test script
CMD ["python", "test_telegram_notifier.py"]

View File

@@ -12,12 +12,16 @@ This is a Python application for managing Crossfit bookings and notifications. T
## Prerequisites ## Prerequisites
- Docker - Python 3.8+
- Docker Compose - Docker (optional)
- Docker Compose (optional)
## Setup ## Setup
1. Create a `.env` file based on `.env.example` and fill in the required credentials. 1. Create a `.env` file based on `.env.example` and fill in the required credentials.
### Running with Docker (Recommended)
2. Build and run the application using Docker Compose: 2. Build and run the application using Docker Compose:
```bash ```bash
@@ -26,6 +30,20 @@ docker-compose up --build
3. The application will run in a Docker container, and the logs will be stored in the `./log` directory. 3. The application will run in a Docker container, and the logs will be stored in the `./log` directory.
### Manual Setup
2. Install dependencies:
```bash
pip install -r requirements.txt
```
3. Run the application:
```bash
python main.py
```
## Usage ## Usage
The application will automatically check for available sessions and book them based on your preferences. It will send notifications via email and Telegram when a booking is successful. The application will automatically check for available sessions and book them based on your preferences. It will send notifications via email and Telegram when a booking is successful.
@@ -76,32 +94,46 @@ The application will automatically load the preferred sessions from this file. I
## Files ## Files
- `main.py`: Main entry point for the application
- `src/`: Source code directory
- `auth.py`: Authentication handling
- `booker.py`: Main booking logic
- `session_manager.py`: Session management
- `session_notifier.py`: Notification handling
- `session_config.py`: Session configuration
- `tools/`: Utility scripts
- `scripts/`: Additional scripts
- `test/`: Test files
- `Dockerfile`: Docker image definition - `Dockerfile`: Docker image definition
- `docker-compose.yml`: Docker Compose service definition - `docker-compose.yml`: Docker Compose service definition
- `.env.example`: Example environment variables file - `.env.example`: Example environment variables file
- `.dockerignore`: Docker ignore file - `.dockerignore`: Docker ignore file
- `.gitignore`: Git ignore file - `.gitignore`: Git ignore file
- `book_crossfit.py`: Main application script - `preferred_sessions.json.example`: Example configuration file for preferred sessions
- `crossfit_booker.py`: Crossfit booking script
- `session_notifier.py`: Session notification script
- `preferred_sessions.json`: Configuration file for preferred sessions
- `requirements.txt`: Python dependencies - `requirements.txt`: Python dependencies
## Project Structure ## Project Structure
``` ```
. .
├── main.py
├── src/
│ ├── auth.py
│ ├── booker.py
│ ├── session_manager.py
│ ├── session_notifier.py
│ └── session_config.py
├── tools/
├── scripts/
├── test/
├── Dockerfile ├── Dockerfile
├── docker-compose.yml ├── docker-compose.yml
├── .env.example ├── .env.example
├── .dockerignore ├── .dockerignore
├── .gitignore ├── .gitignore
├── book_crossfit.py ├── preferred_sessions.json.example
├── crossfit_booker.py
├── session_notifier.py
├── requirements.txt ├── requirements.txt
── preferred_sessions.json ── log/
└── log
└── crossfit_booking.log └── crossfit_booking.log
``` ```

9
ascii.md Normal file
View File

@@ -0,0 +1,9 @@
▄▄▄ ▄▄▄▄▄▄ ▀
▄▀ ▀ ▄ ▄▄ ▄▄▄ ▄▄▄ ▄▄▄ █ ▄▄▄ ▄ ▄
█ █▀ ▀ █▀ ▀█ █ ▀ █ ▀ █▄▄▄▄▄ █ █▄█
█ █ █ █ ▀▀▀▄ ▀▀▀▄ █ █ ▄█▄
▀▄▄▄▀ █ ▀█▄█▀ ▀▄▄▄▀ ▀▄▄▄▀ █ ▄▄█▄▄ ▄▀ ▀▄

View File

@@ -1,35 +0,0 @@
#!/usr/bin/env python3
import logging
import traceback
import asyncio
from crossfit_booker import CrossFitBooker
if __name__ == "__main__":
# Configure logging once at script startup
logging.basicConfig(
level=logging.DEBUG, # Change to DEBUG for more detailed logs
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler("log/crossfit_booking.log"),
logging.StreamHandler()
]
)
# Reduce the verbosity of the requests library's logging
logging.getLogger("requests").setLevel(logging.WARNING)
logging.info("Logging enhanced with request library noise reduction")
# Create an instance of the CrossFitBooker class
booker = CrossFitBooker()
# Attempt to log in to the CrossFit booking system
# TODO: Make a authentication during running request to not get kicked out
if not booker.login():
# If login fails, log the error and exit the script
logging.error("Failed to login - Traceback: %s", traceback.format_exc())
exit(1)
# Start the continuous booking loop
asyncio.run(booker.run())
logging.info("Script completed")

View File

@@ -1,569 +0,0 @@
# Native modules
import logging
import traceback
import os
import time
import difflib
from datetime import datetime, timedelta, date
# Third-party modules
import requests
from dateutil.parser import parse
import pytz
from dotenv import load_dotenv
from urllib.parse import urlencode
from typing import List, Dict, Optional, Any, Tuple
# Import the SessionNotifier class
from session_notifier import SessionNotifier
# Import the preferred sessions from the session_config module
from session_config import PREFERRED_SESSIONS
load_dotenv()
# Configuration
USERNAME = os.environ.get("CROSSFIT_USERNAME")
PASSWORD = os.environ.get("CROSSFIT_PASSWORD")
if not all([USERNAME, PASSWORD]):
raise ValueError("Missing environment variables: CROSSFIT_USERNAME and/or CROSSFIT_PASSWORD")
APPLICATION_ID = "81560887"
CATEGORY_ID = "677" # Activity category ID for CrossFit
TIMEZONE = "Europe/Paris" # Adjust to your timezone
TARGET_RESERVATION_TIME = "20:01" # When bookings open (8 PM)
DEVICE_TYPE = "3"
# Retry configuration
RETRY_MAX = 3
RETRY_BACKOFF = 1
APP_VERSION = "5.09.21"
class CrossFitBooker:
"""
A class for automating the booking of CrossFit sessions.
This class handles authentication, session availability checking,
booking, and notifications for CrossFit sessions.
"""
def __init__(self) -> None:
"""
Initialize the CrossFitBooker with necessary attributes.
Sets up authentication tokens, session headers, mandatory parameters,
and initializes the SessionNotifier for sending notifications.
"""
self.auth_token: Optional[str] = None
self.user_id: Optional[str] = None
self.session: requests.Session = requests.Session()
self.base_headers: Dict[str, str] = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0",
"Content-Type": "application/x-www-form-urlencoded",
"Nubapp-Origin": "user_apps",
}
self.session.headers.update(self.base_headers)
# Define mandatory parameters for API calls
self.mandatory_params: Dict[str, str] = {
"app_version": APP_VERSION,
"device_type": DEVICE_TYPE,
"id_application": APPLICATION_ID,
"id_category_activity": CATEGORY_ID
}
# Initialize the SessionNotifier with credentials from environment variables
email_credentials = {
"from": os.environ.get("EMAIL_FROM"),
"to": os.environ.get("EMAIL_TO"),
"password": os.environ.get("EMAIL_PASSWORD")
}
telegram_credentials = {
"token": os.environ.get("TELEGRAM_TOKEN"),
"chat_id": os.environ.get("TELEGRAM_CHAT_ID")
}
# Get notification settings from environment variables
enable_email = os.environ.get("ENABLE_EMAIL_NOTIFICATIONS", "true").lower() in ("true", "1", "yes")
enable_telegram = os.environ.get("ENABLE_TELEGRAM_NOTIFICATIONS", "true").lower() in ("true", "1", "yes")
self.notifier = SessionNotifier(
email_credentials,
telegram_credentials,
enable_email=enable_email,
enable_telegram=enable_telegram
)
def get_auth_headers(self) -> Dict[str, str]:
"""
Return headers with authorization if available.
Returns:
Dict[str, str]: Headers dictionary with authorization if available.
"""
headers: Dict[str, str] = self.base_headers.copy()
if self.auth_token:
headers["Authorization"] = f"Bearer {self.auth_token}"
return headers
def login(self) -> bool:
"""
Authenticate and get the bearer token.
Returns:
bool: True if login is successful, False otherwise.
"""
try:
# First login endpoint
login_params: Dict[str, str] = {
"app_version": APP_VERSION,
"device_type": DEVICE_TYPE,
"username": USERNAME,
"password": PASSWORD
}
response: requests.Response = self.session.post(
"https://sport.nubapp.com/api/v4/users/checkUser.php",
headers={"Content-Type": "application/x-www-form-urlencoded"},
data=urlencode(login_params))
if not response.ok:
logging.error(f"First login step failed: {response.status_code} - {response.text} - Response: {response.text[:100]}")
return False
try:
login_data: Dict[str, Any] = response.json()
self.user_id = str(login_data["data"]["user"]["id_user"])
except KeyError as ke:
logging.error(f"Key error during login: {str(ke)} - Response: {response.text}")
return False
except ValueError as ve:
logging.error(f"Value error during login: {str(ve)} - Response: {response.text}")
return False
# Second login endpoint
response: requests.Response = self.session.post(
"https://sport.nubapp.com/api/v4/login",
headers={"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"},
data=urlencode({
"device_type": DEVICE_TYPE,
"username": USERNAME,
"password": PASSWORD
}))
if response.ok:
try:
login_data: Dict[str, Any] = response.json()
self.auth_token = login_data.get("token")
except KeyError as ke:
logging.error(f"Key error during login: {str(ke)} - Response: {response.text}")
return False
except ValueError as ve:
logging.error(f"Value error during login: {str(ve)} - Response: {response.text}")
return False
if self.auth_token and self.user_id:
logging.info("Successfully logged in")
return True
else:
logging.error(f"Login failed: {response.status_code} - {response.text} - Response: {response.text[:100]}")
return False
except requests.exceptions.JSONDecodeError:
logging.error("Failed to decode JSON response during login")
return False
except requests.exceptions.RequestException as e:
logging.error(f"Request error during login: {str(e)}")
return False
except Exception as e:
logging.error(f"Unexpected error during login: {str(e)}")
return False
def get_available_sessions(self, start_date: datetime, end_date: datetime) -> Optional[Dict[str, Any]]:
"""
Fetch available sessions from the API with comprehensive error handling.
Args:
start_date (datetime): Start date for fetching sessions.
end_date (datetime): End date for fetching sessions.
Returns:
Optional[Dict[str, Any]]: Dictionary containing available sessions if successful, None otherwise.
"""
if not self.auth_token or not self.user_id:
logging.error("Authentication required - missing token or user ID")
return None
url: str = "https://sport.nubapp.com/api/v4/activities/getActivitiesCalendar.php"
# Prepare request with mandatory parameters
request_data: Dict[str, str] = self.mandatory_params.copy()
request_data.update({
"id_user": self.user_id,
"start_timestamp": start_date.strftime("%d-%m-%Y"),
"end_timestamp": end_date.strftime("%d-%m-%Y")
})
# Add retry logic with exponential backoff and more informative error messages
for retry in range(RETRY_MAX):
try:
try:
response: requests.Response = self.session.post(
url,
headers=self.get_auth_headers(),
data=urlencode(request_data),
timeout=10
)
except requests.exceptions.Timeout:
logging.error(f"Request timed out after 10 seconds for URL: {url}. Retry {retry+1}/{RETRY_MAX}")
return None
except requests.exceptions.ConnectionError as e:
logging.error(f"Connection error for URL: {url} - Error: {str(e)}")
return None
except requests.exceptions.RequestException as e:
logging.error(f"Request failed for URL: {url} - Error: {str(e)}")
return None
break # Success, exit retry loop
except requests.exceptions.JSONDecodeError:
logging.error("Failed to decode JSON response")
return None
except requests.exceptions.RequestException as e:
if retry == RETRY_MAX - 1:
logging.error(f"Final retry failed: {str(e)}")
raise # Propagate error
wait_time: int = RETRY_BACKOFF * (2 ** retry)
logging.warning(f"Request failed (attempt {retry+1}/{RETRY_MAX}): {str(e)}. Retrying in {wait_time}s...")
time.sleep(wait_time)
else:
# All retries exhausted
logging.error(f"Failed after {RETRY_MAX} attempts")
return None
# Handle response
if response.status_code == 200:
try:
json_response: Dict[str, Any] = response.json()
return json_response
except ValueError:
logging.error("Failed to decode JSON response")
return None
elif response.status_code == 400:
logging.error("400 Bad Request - likely missing or invalid parameters")
logging.error(f"Request Data: {request_data}")
logging.error(f"Response: {response.text[:100]}")
return None
elif response.status_code == 401:
logging.error("401 Unauthorized - token may be expired or invalid")
logging.error(f"Response: {response.text[:100]}")
return None
elif 500 <= response.status_code < 600:
logging.error(f"Server error {response.status_code} - Response: {response.text[:100]}")
raise requests.exceptions.ConnectionError(f"Server error {response.status_code}")
else:
logging.error(f"Unexpected status code: {response.status_code}")
return None
def book_session(self, session_id: str) -> bool:
"""
Book a specific session with debug logging.
Args:
session_id (str): ID of the session to book.
Returns:
bool: True if booking is successful, False otherwise.
"""
return self._make_request(
url="https://sport.nubapp.com/api/v4/activities/bookActivityCalendar.php",
data=self._prepare_booking_data(session_id),
success_msg=f"Successfully booked session {session_id}"
)
def _prepare_booking_data(self, session_id: str) -> Dict[str, str]:
"""
Prepare request data for booking a session.
Args:
session_id (str): ID of the session to book.
Returns:
Dict[str, str]: Dictionary containing request data for booking a session.
"""
return {
**self.mandatory_params,
"id_activity_calendar": session_id,
"id_user": self.user_id,
"action_by": self.user_id,
"n_guests": "0",
"booked_on": "3" # Target CrossFit Louvre 3 ?
}
def _make_request(self, url: str, data: Dict[str, str], success_msg: str) -> bool:
"""
Handle API requests with retry logic and response processing.
Args:
url (str): URL for the API request.
data (Dict[str, str]): Data to send with the request.
success_msg (str): Message to log on successful request.
Returns:
bool: True if request is successful, False otherwise.
"""
for retry in range(RETRY_MAX):
try:
response: requests.Response = self.session.post(
url,
headers=self.get_auth_headers(),
data=urlencode(data),
timeout=10
)
if response.status_code == 200:
json_response: Dict[str, Any] = response.json()
if json_response.get("success", False):
logging.info(success_msg)
return True
logging.error(f"API returned success:false: {json_response}")
return False
logging.error(f"HTTP {response.status_code}: {response.text[:100]}")
return False
except requests.exceptions.JSONDecodeError:
logging.error("Failed to decode JSON response")
return False
except requests.exceptions.RequestException as e:
if retry == RETRY_MAX - 1:
logging.error(f"Final retry failed: {str(e)}")
raise # Propagate error
wait_time: int = RETRY_BACKOFF * (2 ** retry)
logging.warning(f"Request failed (attempt {retry+1}/{RETRY_MAX}): {str(e)}. Retrying in {wait_time}s...")
time.sleep(wait_time)
logging.error(f"Failed to complete request after {RETRY_MAX} attempts")
return False
def is_session_bookable(self, session: Dict[str, Any], current_time: datetime) -> bool:
"""
Check if a session is bookable based on user_info, ignoring error codes.
Args:
session (Dict[str, Any]): Session data.
current_time (datetime): Current time for comparison.
Returns:
bool: True if the session is bookable, False otherwise.
"""
user_info: Dict[str, Any] = session.get("user_info", {})
# First check if can_join is true (primary condition)
if user_info.get("can_join", False):
activity_name = session.get("name_activity")
activity_date = session.get("start_timestamp", "Unknown date")
activity_time = activity_date.split(" ")[1] if " " in activity_date else "Unknown time"
logging.debug(f"Session is bookable: {activity_name} on {activity_date} at {activity_time} - can_join is True")
return True
# If can_join is False, check if there's a booking window
booking_date_str: str = user_info.get("unableToBookUntilDate", "")
booking_time_str: str = user_info.get("unableToBookUntilTime", "")
if booking_date_str and booking_time_str:
try:
booking_datetime: datetime = datetime.strptime(
f"{booking_date_str} {booking_time_str}",
"%d-%m-%Y %H:%M"
)
booking_datetime = pytz.timezone(TIMEZONE).localize(booking_datetime)
if current_time >= booking_datetime:
logging.debug(f"Session is bookable: current_time {current_time} >= booking_datetime {booking_datetime}")
return True # Booking window is open
else:
return False # Still waiting for booking to open
except ValueError:
pass # Ignore invalid date formats
# Default case: not bookable
return False
def matches_preferred_session(self, session: Dict[str, Any], current_time: datetime) -> bool:
"""
Check if session matches one of your preferred sessions with fuzzy matching.
Args:
session (Dict[str, Any]): Session data.
current_time (datetime): Current time for comparison.
Returns:
bool: True if the session matches a preferred session, False otherwise.
"""
try:
session_time: datetime = parse(session["start_timestamp"])
if not session_time.tzinfo:
session_time = pytz.timezone(TIMEZONE).localize(session_time)
day_of_week: int = session_time.weekday()
session_time_str: str = session_time.strftime("%H:%M")
session_name: str = session.get("name_activity", "").upper()
for preferred_day, preferred_time, preferred_name in PREFERRED_SESSIONS:
# Exact match first
if (day_of_week == preferred_day and
session_time_str == preferred_time and
preferred_name in session_name):
return True
# Fuzzy match fallback (80% similarity)
ratio: float = difflib.SequenceMatcher(
None,
session_name.lower(),
preferred_name.lower()
).ratio()
if (day_of_week == preferred_day and
abs(session_time.hour - int(preferred_time.split(':')[0])) <= 1 and
ratio >= 0.8):
logging.debug(f"Fuzzy match: {session_name}{preferred_name} ({ratio:.2%})")
return True
return False
except Exception as e:
logging.error(f"Failed to check session: {str(e)} - Session: {session}")
return False
async def run_booking_cycle(self, current_time: datetime) -> None:
"""
Run one cycle of checking and booking sessions.
Args:
current_time (datetime): Current time for comparison.
"""
# Calculate date range to check (next 3 days)
start_date: date = current_time.date()
end_date: date = start_date + timedelta(days=3)
# Get available sessions
sessions_data: Optional[Dict[str, Any]] = self.get_available_sessions(start_date, end_date)
if not sessions_data or not sessions_data.get("success", False):
logging.error("No sessions available or error fetching sessions - Sessions Data: {sessions_data}")
return
activities: List[Dict[str, Any]] = sessions_data.get("data", {}).get("activities_calendar", [])
# Find sessions to book (preferred only)
sessions_to_book: List[Tuple[str, Dict[str, Any]]] = []
upcoming_sessions: List[Dict[str, Any]] = []
found_preferred_sessions: List[Dict[str, Any]] = []
for session in activities:
session_time: datetime = parse(session["start_timestamp"])
if not session_time.tzinfo:
session_time = pytz.timezone(TIMEZONE).localize(session_time)
# Check if session is preferred and bookable
if self.is_session_bookable(session, current_time):
if self.matches_preferred_session(session, current_time):
sessions_to_book.append(("Preferred", session))
found_preferred_sessions.append(session)
else:
# Check if it's a preferred session that's not bookable yet
if self.matches_preferred_session(session, current_time):
found_preferred_sessions.append(session)
# Check if it's available tomorrow
if (session_time.date() - current_time.date()).days == 1:
upcoming_sessions.append(session)
if not sessions_to_book and not upcoming_sessions:
logging.info("No matching sessions found to book")
return
# Notify about all found preferred sessions, regardless of bookability
for session in found_preferred_sessions:
session_time: datetime = parse(session["start_timestamp"])
if not session_time.tzinfo:
session_time = pytz.timezone(TIMEZONE).localize(session_time)
session_details = f"{session['name_activity']} at {session_time.strftime('%Y-%m-%d %H:%M')}"
await self.notifier.notify_session_booking(session_details)
logging.info(f"Notified about found preferred session: {session_details}")
# Notify about upcoming sessions
for session in upcoming_sessions:
session_time: datetime = parse(session["start_timestamp"])
if not session_time.tzinfo:
session_time = pytz.timezone(TIMEZONE).localize(session_time)
session_details = f"{session['name_activity']} at {session_time.strftime('%Y-%m-%d %H:%M')}"
await self.notifier.notify_upcoming_session(session_details, 1) # Days until is 1 for tomorrow
logging.info(f"Notified about upcoming session: {session_details}")
# Book sessions (preferred first)
sessions_to_book.sort(key=lambda x: 0 if x[0] == "Preferred" else 1)
for session_type, session in sessions_to_book:
session_time: datetime = datetime.strptime(session["start_timestamp"], "%Y-%m-%d %H:%M:%S")
logging.info(f"Attempting to book {session_type} session at {session_time} ({session['name_activity']})")
if self.book_session(session["id_activity_calendar"]):
# Send notification after successful booking
session_details = f"{session['name_activity']} at {session_time.strftime('%Y-%m-%d %H:%M')}"
await self.notifier.notify_session_booking(session_details)
logging.info(f"Successfully booked {session_type} session at {session_time}")
else:
logging.error(f"Failed to book {session_type} session at {session_time} - Session: {session}")
# Send notification about the failed booking
session_details = f"{session['name_activity']} at {session_time.strftime('%Y-%m-%d %H:%M')}"
await self.notifier.notify_impossible_booking(session_details)
logging.info(f"Notified about impossible booking for {session_type} session at {session_time}")
async def run(self) -> None:
"""
Main execution loop.
"""
# Set up timezone
tz: pytz.timezone = pytz.timezone(TIMEZONE)
# Initial login
if not self.login():
logging.error("Authentication failed - exiting program")
return
try:
while True:
try:
current_time: datetime = datetime.now(tz)
logging.info(f"Current time: {current_time}")
# Always run booking cycle to check for preferred sessions and notify
await self.run_booking_cycle(current_time)
# Run booking cycle at the target time for actual booking
target_time_str = current_time.strftime("%H:%M")
target_time = datetime.strptime(target_time_str, "%H:%M").replace(year=current_time.year, month=current_time.month, day=current_time.day, tzinfo=tz)
tolerance_window = timedelta(minutes=5)
# Check if current time is within the tolerance window after the target time
if (target_time <= current_time <= (target_time + tolerance_window)):
# Calculate the next booking window
next_booking_window = current_time + timedelta(minutes=20)
# Wait until the next booking window
time.sleep((next_booking_window - current_time).total_seconds())
else:
# Check again in 5 minutes
time.sleep(300)
except Exception as e:
logging.error(f"Unexpected error in booking cycle: {str(e)} - Traceback: {traceback.format_exc()}")
time.sleep(60) # Wait before retrying after error
except KeyboardInterrupt:
self.quit()
def quit(self) -> None:
"""
Clean up resources and exit the script.
"""
logging.info("Script interrupted by user. Quitting...")
# Add any cleanup code here
exit(0)

View File

@@ -4,11 +4,13 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: crossfit-booker # container_name: crossfit-booker
environment: environment:
- TZ=Europe/Paris - TZ=Europe/Paris
- CROSSFIT_USERNAME=${CROSSFIT_USERNAME} - CROSSFIT_USERNAME=${CROSSFIT_USERNAME}
- CROSSFIT_PASSWORD=${CROSSFIT_PASSWORD} - CROSSFIT_PASSWORD=${CROSSFIT_PASSWORD}
- TARGET_RESERVATION_TIME=${TARGET_RESERVATION_TIME}
- BOOKING_WINDOW_END_DELTA_MINUTES=${BOOKING_WINDOW_END_DELTA_MINUTES}
- SMTP_SERVER=${SMTP_SERVER} - SMTP_SERVER=${SMTP_SERVER}
- EMAIL_FROM=${EMAIL_FROM} - EMAIL_FROM=${EMAIL_FROM}
- EMAIL_TO=${EMAIL_TO} - EMAIL_TO=${EMAIL_TO}
@@ -16,5 +18,5 @@ services:
- TELEGRAM_TOKEN=${TELEGRAM_TOKEN} - TELEGRAM_TOKEN=${TELEGRAM_TOKEN}
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID} - TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID}
volumes: volumes:
- ./log:/app/log - ./:/app
restart: unless-stopped restart: unless-stopped

View File

@@ -23,21 +23,23 @@ curl 'https://sport.nubapp.com/api/v4/activities/getActivitiesCalendar.php' \
-X POST \ -X POST \
-H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0' \ -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0' \
-H 'Accept: application/json, text/plain, */*' \ -H 'Accept: application/json, text/plain, */*' \
-H 'Accept-Language: en-GB,en;q=0.8,fr-FR;q=0.5,fr;q=0.3' \ -H 'Accept-Language: fr-FR,fr;q=0.8,en-GB;q=0.5,en;q=0.3' \
-H 'Accept-Encoding: gzip, deflate, br, zstd' \ -H 'Accept-Encoding: gzip, deflate, br, zstd' \
-H 'Referer: https://box.resawod.com/' \
-H 'Content-Type: application/x-www-form-urlencoded' \ -H 'Content-Type: application/x-www-form-urlencoded' \
-H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE3NTI3NDI5ODYsImV4cCI6MTc2ODY0NDE4Niwicm9sZXMiOlsiUk9MRV9VU0VSIl0sImlkX3VzZXIiOjMxOTE0MjksImlkX2FwcGxpY2F0aW9uIjo4MTU2MDg4NywicGFzc3dvcmRfaGFzaCI6ImEzZGZiOGQzY2NhNTA5NmJhYzYxMWM4MmEwMzYyYTg2ODc3MjQ4MjIiLCJhdXRoZW50aWNhdGlvbl90eXBlIjoiYXBpIiwidXNlcm5hbWUiOiJLZXZpbjg0MDcifQ.BQ-o2-f0-Pj36nvnWz_ZVag6nWIlus5YPCh5tJSrt2XdpxvU6RYVbx4uobyicqS8jyK6G5OmQ1dJU8H5l3KRdK7enSFC4ClbyX7hXCRfQ0EW2bndNj_eIeR5qxbU8jgELCi6JTiCOouICdx6gkqvT9uYk4jSVdDWjYP9lATnzvNpwEIg2Aac1aqXflZtgFPSgwxEuotknLYFxRgMB6nTnMS34iIcvY4WVqsN_geYAtvhZM40NgEqK3Q4XMJusg7wStfiR7d8sqk-9Gqm3thE7Qsu-EjO9T7840zVyTWlLRa5TtUZiqDHjex-KYIh3p8xATbg_Ssct62o_FYkpN0XNA' \ -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE3NTQ2NjQ4ODYsImV4cCI6MTc3MDU2NjA4Niwicm9sZXMiOlsiUk9MRV9VU0VSIl0sImlkX3VzZXIiOjMxOTE0MjksImlkX2FwcGxpY2F0aW9uIjo4MTU2MDg4NywicGFzc3dvcmRfaGFzaCI6ImEzZGZiOGQzY2NhNTA5NmJhYzYxMWM4MmEwMzYyYTg2ODc3MjQ4MjIiLCJhdXRoZW50aWNhdGlvbl90eXBlIjoiYXBpIiwidXNlcm5hbWUiOiJLZXZpbjg0MDcifQ.AuFvEW5dWWD1SVWwj6dVNOKpTCNeBzSY6HLtbz2z6KGh30VhMoswICWv6KcKqMNtJmt-UV5kpQkdfWAKOHZy843QGEnpI4scMAe1KUAl--Tn7pvwmTYxpx4Ta5E7f4DEV7m6YqZmdClOy-6YbDmf1qb-bR_v20gORMmutkdmcS8btteW11Lc2Uk6k8I2AWMulygQkV1PURzTIm7cmMNX0_R1Z_7M0HWsq_PLuZ0h0gaxKBS_BUqiMNeh8Hvu_sS56OPeDNRXQmFHuDI7CLMTNWBbmNAhfauIP293I8-cwC7eqjP_pp-v6zLTObHndJgNFEoce82iX11KCINyLryqcQ' \
-H 'Nubapp-Origin: user_apps' \ -H 'Nubapp-Origin: user_apps' \
-H 'Origin: https://box.resawod.com' \ -H 'Origin: https://box.resawod.com' \
-H 'Sec-GPC: 1' \
-H 'Connection: keep-alive' \ -H 'Connection: keep-alive' \
-H 'Referer: https://box.resawod.com/' \
-H 'Sec-Fetch-Dest: empty' \ -H 'Sec-Fetch-Dest: empty' \
-H 'Sec-Fetch-Mode: cors' \ -H 'Sec-Fetch-Mode: cors' \
-H 'Sec-Fetch-Site: cross-site' \ -H 'Sec-Fetch-Site: cross-site' \
-H 'Priority: u=0' \
-H 'TE: trailers' \ -H 'TE: trailers' \
--data-raw 'app_version=5.09.21&id_application=81560887&id_user=3191429&start_timestamp=21-07-2025&end_timestamp=27-07-2025&id_category_activity=677' --data-raw 'app_version=5.09.25&id_application=81560887&id_user=3191429&start_timestamp=04-08-2025&end_timestamp=10-08-2025&id_category_activity=677'
Book activity ## Book activity
curl 'https://sport.nubapp.com/api/v4/activities/bookActivityCalendar.php' \ curl 'https://sport.nubapp.com/api/v4/activities/bookActivityCalendar.php' \
-X POST \ -X POST \
@@ -47,7 +49,7 @@ Book activity
-H 'Accept-Encoding: gzip, deflate, br, zstd' \ -H 'Accept-Encoding: gzip, deflate, br, zstd' \
-H 'Referer: https://box.resawod.com/' \ -H 'Referer: https://box.resawod.com/' \
-H 'Content-Type: application/x-www-form-urlencoded' \ -H 'Content-Type: application/x-www-form-urlencoded' \
-H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE3NTI2NzYwNDksImV4cCI6MTc2ODU3NzI0OSwicm9sZXMiOlsiUk9MRV9VU0VSIl0sImlkX3VzZXIiOjMxOTE0MjksImlkX2FwcGxpY2F0aW9uIjo4MTU2MDg4NywicGFzc3dvcmRfaGFzaCI6ImEzZGZiOGQzY2NhNTA5NmJhYzYxMWM4MmEwMzYyYTg2ODc3MjQ4MjIiLCJhdXRoZW50aWNhdGlvbl90eXBlIjoiYXBpIiwidXNlcm5hbWUiOiJLZXZpbjg0MDcifQ.mQhKOTtEt1QUwDzK7BGzA3yk1R4RozhQW1xqfhmfmk_mesrkz5RM9IOnLFbP-ZMvx4_9ZlWv4qvgx3XjAzDWf8M86BPWhY6nNAoKAdTbD_Pg-fsjmRVVEadNv0pizavue5--K2XvU50AQinkzHawihm7HtTqcWOJgH3J7PM5NQO0Y1azd2nkt9mqTBf1l5MrvDZPWR_KbiztNavacr5SY9vSk1pfnf1A9jbR9ca3wCxZNKhXfxCWxNHCBqh_VnXP3Wwh518xL94xCx0nziKDR5VQYNQCLTO3cb9lDTmCILgZSnvSXIpFVNw1mTkz34MKS2WCkF540okzIFTooNhf_A' \ -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE3NTQ2NjQ4ODYsImV4cCI6MTc3MDU2NjA4Niwicm9sZXMiOlsiUk9MRV9VU0VSIl0sImlkX3VzZXIiOjMxOTE0MjksImlkX2FwcGxpY2F0aW9uIjo4MTU2MDg4NywicGFzc3dvcmRfaGFzaCI6ImEzZGZiOGQzY2NhNTA5NmJhYzYxMWM4MmEwMzYyYTg2ODc3MjQ4MjIiLCJhdXRoZW50aWNhdGlvbl90eXBlIjoiYXBpIiwidXNlcm5hbWUiOiJLZXZpbjg0MDcifQ.AuFvEW5dWWD1SVWwj6dVNOKpTCNeBzSY6HLtbz2z6KGh30VhMoswICWv6KcKqMNtJmt-UV5kpQkdfWAKOHZy843QGEnpI4scMAe1KUAl--Tn7pvwmTYxpx4Ta5E7f4DEV7m6YqZmdClOy-6YbDmf1qb-bR_v20gORMmutkdmcS8btteW11Lc2Uk6k8I2AWMulygQkV1PURzTIm7cmMNX0_R1Z_7M0HWsq_PLuZ0h0gaxKBS_BUqiMNeh8Hvu_sS56OPeDNRXQmFHuDI7CLMTNWBbmNAhfauIP293I8-cwC7eqjP_pp-v6zLTObHndJgNFEoce82iX11KCINyLryqcQ' \
-H 'Nubapp-Origin: user_apps' \ -H 'Nubapp-Origin: user_apps' \
-H 'Origin: https://box.resawod.com' \ -H 'Origin: https://box.resawod.com' \
-H 'Connection: keep-alive' \ -H 'Connection: keep-alive' \
@@ -56,7 +58,7 @@ Book activity
-H 'Sec-Fetch-Site: cross-site' \ -H 'Sec-Fetch-Site: cross-site' \
-H 'Priority: u=0' \ -H 'Priority: u=0' \
-H 'TE: trailers' \ -H 'TE: trailers' \
--data-raw 'app_version=5.09.21&id_application=81560887^&id_activity_calendar=19291304&id_user=3191429&action_by=3191429&n_guests=0&booked_on=3' --data-raw 'app_version=5.09.21&id_application=81560887^&id_activity_calendar=19291766&id_user=3191429&action_by=3191429&n_guests=0&booked_on=3'

77
main.py Executable file
View File

@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""
Main entry point for the CrossFit Booker application.
This script initializes the CrossFitBooker and starts the booking process.
"""
import logging
import os
from src.auth import AuthHandler
from src.booker import Booker
from src.session_notifier import SessionNotifier
# Load environment variables
from dotenv import load_dotenv
load_dotenv()
def main():
"""
Main function to initialize the Booker and start the booking process.
"""
# Set up logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler()
]
)
# Display ASCII art and username
try:
with open('ascii.md', 'r') as f:
ascii_art = f.read()
print(ascii_art, flush=True)
username = os.environ.get("CROSSFIT_USERNAME")
print(f"Username: {username}", flush=True)
print(flush=True)
except Exception as e:
logging.error(f"Error displaying ASCII: {e}")
# Initialize components
auth_handler = AuthHandler(
str(os.environ.get("CROSSFIT_USERNAME")),
str(os.environ.get("CROSSFIT_PASSWORD"))
)
# Initialize notification system
email_credentials = {
"from": os.environ.get("EMAIL_FROM"),
"to": os.environ.get("EMAIL_TO"),
"password": os.environ.get("EMAIL_PASSWORD")
}
telegram_credentials = {
"token": os.environ.get("TELEGRAM_TOKEN"),
"chat_id": os.environ.get("TELEGRAM_CHAT_ID")
}
enable_email = os.environ.get("ENABLE_EMAIL_NOTIFICATIONS", "true").lower() in ("true", "1", "yes")
enable_telegram = os.environ.get("ENABLE_TELEGRAM_NOTIFICATIONS", "true").lower() in ("true", "1", "yes")
notifier = SessionNotifier(
email_credentials,
telegram_credentials,
enable_email=enable_email,
enable_telegram=enable_telegram
)
# Initialize the Booker
booker = Booker(auth_handler, notifier)
# Run the booking process
import asyncio
asyncio.run(booker.run())
if __name__ == "__main__":
main()

View File

@@ -1,17 +0,0 @@
[
{
"day_of_week": 2,
"start_time": "18:30",
"session_name_contains": "CONDITIONING"
},
{
"day_of_week": 4,
"start_time": "17:00",
"session_name_contains": "WEIGHTLIFTING"
},
{
"day_of_week": 5,
"start_time": "12:30",
"session_name_contains": "HYROX"
}
]

View File

@@ -0,0 +1,22 @@
[
{
"day_of_week": 0,
"start_time": "17:45",
"session_name_contains": "WEIGHTLIFTING LOUVRE 3"
},
{
"day_of_week": 4,
"start_time": "17:00",
"session_name_contains": "CONDITIONING LOUVRE 3"
},
{
"day_of_week": 5,
"start_time": "12:30",
"session_name_contains": "HYROX"
},
{
"day_of_week": 5,
"start_time": "12:30",
"session_name_contains": "CONDITIONING"
}
]

2
pytest.ini Normal file
View File

@@ -0,0 +1,2 @@
[pytest]
asyncio_mode = auto

View File

@@ -6,6 +6,12 @@ h11==0.16.0
httpcore==1.0.9 httpcore==1.0.9
httpx==0.28.1 httpx==0.28.1
idna==3.10 idna==3.10
iniconfig==2.1.0
packaging==25.0
pluggy==1.6.0
Pygments==2.19.2
pytest==8.4.1
pytest-asyncio==1.1.0
python-dateutil==2.9.0.post0 python-dateutil==2.9.0.post0
python-dotenv==1.1.1 python-dotenv==1.1.1
python-telegram-bot==22.2 python-telegram-bot==22.2

80
scripts/execute_book_session.py Executable file
View File

@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""
Script to demonstrate how to execute the book_session method from crossfit_booker.py
"""
import sys
import os
import logging
from src.auth import AuthHandler
from src.booker import Booker
from src.session_notifier import SessionNotifier
# Load environment variables
from dotenv import load_dotenv
load_dotenv()
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
def main():
# Check if a session ID was provided as an argument
if len(sys.argv) < 2:
print("Usage: python execute_book_session.py <session_id>")
sys.exit(1)
session_id = sys.argv[1]
# Initialize components
auth_handler = AuthHandler(
os.environ.get("CROSSFIT_USERNAME"),
os.environ.get("CROSSFIT_PASSWORD")
)
# Initialize notification system (minimal for single session booking)
email_credentials = {
"from": os.environ.get("EMAIL_FROM"),
"to": os.environ.get("EMAIL_TO"),
"password": os.environ.get("EMAIL_PASSWORD")
}
telegram_credentials = {
"token": os.environ.get("TELEGRAM_TOKEN"),
"chat_id": os.environ.get("TELEGRAM_CHAT_ID")
}
enable_email = os.environ.get("ENABLE_EMAIL_NOTIFICATIONS", "true").lower() in ("true", "1", "yes")
enable_telegram = os.environ.get("ENABLE_TELEGRAM_NOTIFICATIONS", "true").lower() in ("true", "1", "yes")
notifier = SessionNotifier(
email_credentials,
telegram_credentials,
enable_email=enable_email,
enable_telegram=enable_telegram
)
# Create an instance of Booker
booker = Booker(auth_handler, notifier)
# Login to authenticate
print("Attempting to authenticate...")
if not auth_handler.login():
print("Failed to authenticate. Please check your credentials and try again.")
sys.exit(1)
print("Authentication successful!")
# Book the session
print(f"Attempting to book session with ID: {session_id}")
success = booker.book_session(session_id)
if success:
print(f"Successfully booked session {session_id}")
else:
print(f"Failed to book session {session_id}")
if __name__ == "__main__":
try:
main()
except Exception as e:
print(f"An error occurred: {e}")
sys.exit(1)

9
scripts/test_book_session.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/bin/bash
# Test script to demonstrate how to use the execute_book_session.py script
# Sample session ID (this should be a valid session ID from the crossfit booking system)
SESSION_ID="19291768"
# Run the script with the sample session ID
echo "Attempting to book session with ID: $SESSION_ID"
python execute_book_session.py $SESSION_ID

14
setup.py Normal file
View File

@@ -0,0 +1,14 @@
from setuptools import setup, find_packages
setup(
name="crossfit_booker",
version="0.1",
packages=find_packages(where="src"),
package_dir={"": "src"},
install_requires=[
"requests",
"python-dotenv",
"pytz",
"python-dateutil",
],
)

16
src/__init__.py Normal file
View File

@@ -0,0 +1,16 @@
# src/__init__.py
# Import all public modules to make them available as src.module
from .auth import AuthHandler
from .booker import Booker
from .session_config import PREFERRED_SESSIONS
from .session_manager import SessionManager
from .session_notifier import SessionNotifier
__all__ = [
"AuthHandler",
"Booker",
"PREFERRED_SESSIONS",
"SessionManager",
"SessionNotifier"
]

121
src/auth.py Normal file
View File

@@ -0,0 +1,121 @@
# Native modules
import logging
from typing import Dict, Any, Optional
# Third-party modules
import requests
from urllib.parse import urlencode
# Configuration constants (will be moved from crossfit_booker.py)
APPLICATION_ID = "81560887"
DEVICE_TYPE = "3"
APP_VERSION = "5.09.21"
class AuthHandler:
"""
A class for handling authentication with the CrossFit booking system.
This class is responsible for performing login, retrieving auth tokens,
and providing authentication headers.
"""
def __init__(self, username: str, password: str) -> None:
"""
Initialize the AuthHandler with credentials.
Args:
username (str): The username for authentication.
password (str): The password for authentication.
"""
self.username = username
self.password = password
self.auth_token: Optional[str] = None
self.user_id: Optional[str] = None
self.session = requests.Session()
self.base_headers: Dict[str, str] = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0",
"Content-Type": "application/x-www-form-urlencoded",
"Nubapp-Origin": "user_apps",
}
self.session.headers.update(self.base_headers)
def get_auth_headers(self) -> Dict[str, str]:
"""
Return headers with authorization if available.
Returns:
Dict[str, str]: Headers dictionary with authorization if available.
"""
headers: Dict[str, str] = self.base_headers.copy()
if self.auth_token:
headers["Authorization"] = f"Bearer {self.auth_token}"
return headers
def login(self) -> bool:
"""
Authenticate and get the bearer token.
Returns:
bool: True if login is successful, False otherwise.
"""
try:
# First login endpoint
login_params: Dict[str, str] = {
"app_version": APP_VERSION,
"device_type": DEVICE_TYPE,
"username": self.username,
"password": self.password
}
response: requests.Response = self.session.post(
"https://sport.nubapp.com/api/v4/users/checkUser.php",
headers={"Content-Type": "application/x-www-form-urlencoded"},
data=urlencode(login_params))
if not response.ok:
logging.error(f"First login step failed: {response.status_code} - {response.text[:100]}")
return False
try:
login_data: Dict[str, Any] = response.json()
# Try to find id_user in the nested structure
try:
# First try the new structure: data.user.applications[0].users[0].id_user
self.user_id = str(login_data["data"]["user"]["applications"][0]["users"][0]["id_user"])
except (KeyError, IndexError, TypeError):
# Fallback to old structure if it exists
self.user_id = str(login_data["data"]["user"]["id_user"])
except (KeyError, ValueError) as e:
logging.error(f"Error during login: {str(e)} - Response: {response.text}")
return False
# Second login endpoint
response: requests.Response = self.session.post(
"https://sport.nubapp.com/api/v4/login",
headers={"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"},
data=urlencode({
"device_type": DEVICE_TYPE,
"username": self.username,
"password": self.password
}))
if response.ok:
try:
login_data: Dict[str, Any] = response.json()
self.auth_token = login_data.get("token")
except (KeyError, ValueError) as e:
logging.error(f"Error during login: {str(e)} - Response: {response.text}")
return False
if self.auth_token and self.user_id:
logging.info("Successfully logged in")
return True
else:
logging.error(f"Login failed: {response.status_code} - {response.text[:100]}")
return False
except requests.exceptions.RequestException as e:
logging.error(f"Request error during login: {str(e)}")
return False
except Exception as e:
logging.error(f"Unexpected error during login: {str(e)}")
return False

70
src/book_crossfit.py Executable file
View File

@@ -0,0 +1,70 @@
#!/usr/bin/env python3
import logging
import os
import traceback
from src.auth import AuthHandler
from src.booker import Booker
from src.session_notifier import SessionNotifier
# Load environment variables
from dotenv import load_dotenv
load_dotenv()
if __name__ == "__main__":
# Configure logging once at script startup
logging.basicConfig(
level=logging.DEBUG, # Change to DEBUG for more detailed logs
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler("log/crossfit_booking.log"),
logging.StreamHandler()
]
)
# Reduce the verbosity of the requests library's logging
logging.getLogger("requests").setLevel(logging.WARNING)
logging.info("Logging enhanced with request library noise reduction")
# Initialize components
auth_handler = AuthHandler(
os.environ.get("CROSSFIT_USERNAME"),
os.environ.get("CROSSFIT_PASSWORD")
)
# Initialize notification system
email_credentials = {
"from": os.environ.get("EMAIL_FROM"),
"to": os.environ.get("EMAIL_TO"),
"password": os.environ.get("EMAIL_PASSWORD")
}
telegram_credentials = {
"token": os.environ.get("TELEGRAM_TOKEN"),
"chat_id": os.environ.get("TELEGRAM_CHAT_ID")
}
enable_email = os.environ.get("ENABLE_EMAIL_NOTIFICATIONS", "true").lower() in ("true", "1", "yes")
enable_telegram = os.environ.get("ENABLE_TELEGRAM_NOTIFICATIONS", "true").lower() in ("true", "1", "yes")
notifier = SessionNotifier(
email_credentials,
telegram_credentials,
enable_email=enable_email,
enable_telegram=enable_telegram
)
# Create an instance of the Booker class
booker = Booker(auth_handler, notifier)
# Attempt to log in to the CrossFit booking system
# TODO: Make a authentication during running request to not get kicked out
if not auth_handler.login():
# If login fails, log the error and exit the script
logging.error("Failed to login - Traceback: %s", traceback.format_exc())
exit(1)
# Start the continuous booking loop
import asyncio
asyncio.run(booker.run())
logging.info("Script completed")

309
src/booker.py Normal file
View File

@@ -0,0 +1,309 @@
# Native modules
import logging
import traceback
import os
import time
from datetime import datetime, timedelta, date
# Third-party modules
import requests
from dateutil.parser import parse
import pytz
from dotenv import load_dotenv
from typing import List, Dict, Optional, Any
# Import the SessionNotifier class
from src.session_notifier import SessionNotifier
# Import the preferred sessions from the session_config module
# Import the AuthHandler class
from src.auth import AuthHandler
# Import SessionManager
from src.session_manager import SessionManager
load_dotenv()
# Configuration
USERNAME = os.environ.get("CROSSFIT_USERNAME")
PASSWORD = os.environ.get("CROSSFIT_PASSWORD")
if not all([USERNAME, PASSWORD]):
raise ValueError("Missing environment variables: CROSSFIT_USERNAME and/or CROSSFIT_PASSWORD")
APPLICATION_ID = "81560887"
CATEGORY_ID = "677" # Activity category ID for CrossFit
TIMEZONE = "Europe/Paris" # Adjust to your timezone
# Booking window configuration (can be overridden by environment variables)
# TARGET_RESERVATION_TIME: string "HH:MM" local time when bookings open (default 20:01)
# BOOKING_WINDOW_END_DELTA_MINUTES: int minutes after target time to stop booking (default 10)
TARGET_RESERVATION_TIME = os.environ.get("TARGET_RESERVATION_TIME", "20:01")
BOOKING_WINDOW_END_DELTA_MINUTES = int(os.environ.get("BOOKING_WINDOW_END_DELTA_MINUTES", "10"))
DEVICE_TYPE = "3"
# Retry configuration
RETRY_MAX = 3
RETRY_BACKOFF = 1
APP_VERSION = "5.09.21"
class Booker:
"""
A class for handling the main booking logic.
This class is designed to be used as a standalone component
that can be initialized with authentication and session management
and used to perform the booking process.
"""
def __init__(self, auth_handler: AuthHandler, notifier: SessionNotifier) -> None:
"""
Initialize the Booker with necessary attributes.
Args:
auth_handler (AuthHandler): AuthHandler instance for authentication.
notifier (SessionNotifier): SessionNotifier instance for sending notifications.
"""
self.auth_handler = auth_handler
self.notifier = notifier
# Initialize the session and headers
self.session: requests.Session = requests.Session()
self.base_headers: Dict[str, str] = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0",
"Content-Type": "application/x-www-form-urlencoded",
"Nubapp-Origin": "user_apps",
}
self.session.headers.update(self.base_headers)
# Define mandatory parameters for API calls
self.mandatory_params: Dict[str, str] = {
"app_version": APP_VERSION,
"device_type": DEVICE_TYPE,
"id_application": APPLICATION_ID,
"id_category_activity": CATEGORY_ID
}
def get_auth_headers(self) -> Dict[str, str]:
"""
Return headers with authorization from the AuthHandler.
Returns:
Dict[str, str]: Headers dictionary with authorization if available.
"""
return self.auth_handler.get_auth_headers()
async def booker(self, current_time: datetime) -> None:
"""
Run one cycle of checking and booking sessions.
Args:
current_time (datetime): Current time for comparison.
"""
# Calculate date range to check (current day, day + 1, and day + 2)
start_date: date = current_time.date()
end_date: date = start_date + timedelta(days=2) # Only go up to day + 2
# Get available sessions
sessions_data: Optional[Dict[str, Any]] = self.get_available_sessions(start_date, end_date)
if not sessions_data or not sessions_data.get("success", False):
logging.error("No sessions available or error fetching sessions")
return
activities: List[Dict[str, Any]] = sessions_data.get("data", {}).get("activities_calendar", [])
# Display all available sessions within the date range
self.display_upcoming_sessions(activities, current_time)
# Find sessions to book (preferred only) within allowed date range
found_preferred_sessions: List[Dict[str, Any]] = []
for session in activities:
session_time: datetime = parse(session["start_timestamp"])
if not session_time.tzinfo:
session_time = pytz.timezone(TIMEZONE).localize(session_time)
# Check if session is within allowed date range (current day, day + 1, or day + 2)
days_diff = (session_time.date() - current_time.date()).days
if not (0 <= days_diff <= 2):
continue # Skip sessions outside the allowed date range
# Check if session is preferred and bookable
if self.is_session_bookable(session, current_time):
if self.matches_preferred_session(session, current_time):
found_preferred_sessions.append(session)
# Display preferred sessions found
if found_preferred_sessions:
logging.info("Preferred sessions found:")
for session in found_preferred_sessions:
session_time: datetime = parse(session["start_timestamp"])
if not session_time.tzinfo:
session_time = pytz.timezone(TIMEZONE).localize(session_time)
logging.info(f"ID: {session['id_activity_calendar']}, Name: {session['name_activity']}, Time: {session_time.strftime('%Y-%m-%d %H:%M')}")
else:
logging.info("No matching preferred sessions found")
# Book preferred sessions
if not found_preferred_sessions:
logging.info("No matching sessions found to book")
return
# Book sessions (preferred first)
sessions_to_book = [("Preferred", session) for session in found_preferred_sessions]
sessions_to_book.sort(key=lambda x: 0 if x[0] == "Preferred" else 1)
booked_sessions = []
for session_type, session in sessions_to_book:
session_time: datetime = parse(session["start_timestamp"])
logging.info(f"Attempting to book {session_type} session at {session_time} ({session['name_activity']})")
if self.book_session(session["id_activity_calendar"]):
# Display booked session
booked_sessions.append(session)
logging.info(f"Successfully booked {session_type} session at {session_time}")
# Notify about booked session
session_details = f"ID: {session['id_activity_calendar']}, Name: {session['name_activity']}, Date: {session_time.strftime('%Y-%m-%d %H:%M')}"
await self.notifier.notify_session_booking(session_details)
else:
logging.error(f"Failed to book {session_type} session at {session_time}")
# Notify about failed booking
session_details = f"ID: {session['id_activity_calendar']}, Name: {session['name_activity']}, Date: {session_time.strftime('%Y-%m-%d %H:%M')}"
await self.notifier.notify_impossible_booking(session_details)
logging.info(f"Notified about impossible booking for {session_type} session at {session_time}")
# Display all booked session(s)
if booked_sessions:
logging.info("Booked sessions:")
for session in booked_sessions:
session_time: datetime = parse(session["start_timestamp"])
if not session_time.tzinfo:
session_time = pytz.timezone(TIMEZONE).localize(session_time)
logging.info(f"ID: {session['id_activity_calendar']}, Name: {session['name_activity']}, Time: {session_time.strftime('%Y-%m-%d %H:%M')}")
else:
logging.info("No sessions were booked")
async def run(self) -> None:
"""
Main execution loop.
"""
# Set up timezone
tz: pytz.timezone = pytz.timezone(TIMEZONE)
# Parse TARGET_RESERVATION_TIME to get the target hour and minute
target_hour, target_minute = map(int, TARGET_RESERVATION_TIME.split(":"))
# Initial login
if not self.auth_handler.login():
logging.error("Authentication failed - exiting program")
return
try:
while True:
try:
current_time: datetime = datetime.now(tz)
logging.info(f"Current time: {current_time}")
# Recalculate target time and end time for the current day
today_target_time = current_time.replace(hour=target_hour, minute=target_minute, second=0, microsecond=0)
booking_window_end = today_target_time + timedelta(minutes=BOOKING_WINDOW_END_DELTA_MINUTES)
# Only book sessions if current time is within the booking window
if today_target_time <= current_time <= booking_window_end:
# Run booking cycle to check for preferred sessions and book
await self.booker(current_time)
# Wait for a short time before next check
time.sleep(60)
else:
# Display message when outside booking window
# logging.info(f"Not booking now - current time {current_time} is outside the booking window ({today_target_time} to {booking_window_end})")
# Check again in 5 minutes if outside booking window
time.sleep(300)
except Exception as e:
logging.error(f"Unexpected error in booking cycle: {str(e)} - Traceback: {traceback.format_exc()}")
time.sleep(60) # Wait before retrying after error
except KeyboardInterrupt:
self.quit()
def quit(self) -> None:
"""
Clean up resources and exit the script.
"""
logging.info("Script interrupted by user. Quitting...")
exit(0)
def get_available_sessions(self, start_date: date, end_date: date) -> Optional[Dict[str, Any]]:
"""
Fetch available sessions from the API.
Args:
start_date (date): Start date for fetching sessions.
end_date (date): End date for fetching sessions.
Returns:
Optional[Dict[str, Any]]: Dictionary containing available sessions if successful, None otherwise.
"""
session_manager = SessionManager(self.auth_handler)
return session_manager.get_available_sessions(start_date, end_date)
def book_session(self, session_id: str) -> bool:
"""
Book a specific session.
Args:
session_id (str): ID of the session to book.
Returns:
bool: True if booking is successful, False otherwise.
"""
session_manager = SessionManager(self.auth_handler)
return session_manager.book_session(session_id)
def get_booked_sessions(self) -> List[Dict[str, Any]]:
"""
Get a list of booked sessions.
Returns:
A list of dictionaries containing information about the booked sessions.
"""
session_manager = SessionManager(self.auth_handler)
return session_manager.get_booked_sessions()
def is_session_bookable(self, session: Dict[str, Any], current_time: datetime) -> bool:
"""
Check if a session is bookable based on user_info.
Args:
session (Dict[str, Any]): Session data.
current_time (datetime): Current time for comparison.
Returns:
bool: True if the session is bookable, False otherwise.
"""
session_manager = SessionManager(self.auth_handler)
return session_manager.is_session_bookable(session, current_time)
def matches_preferred_session(self, session: Dict[str, Any], current_time: datetime) -> bool:
"""
Check if session matches one of your preferred sessions with exact matching.
Args:
session (Dict[str, Any]): Session data.
current_time (datetime): Current time for comparison.
Returns:
bool: True if the session matches a preferred session, False otherwise.
"""
session_manager = SessionManager(self.auth_handler)
return session_manager.matches_preferred_session(session, current_time)
def display_upcoming_sessions(self, sessions: List[Dict[str, Any]], current_time: datetime) -> None:
"""
Display upcoming sessions with ID, name, date, and time.
Args:
sessions (List[Dict[str, Any]]): List of session data.
current_time (datetime): Current time for comparison.
"""
session_manager = SessionManager(self.auth_handler)
session_manager.display_upcoming_sessions(sessions, current_time)

View File

@@ -36,20 +36,19 @@ class SessionConfig:
preferred_sessions.append((day_of_week, start_time, session_name_contains)) preferred_sessions.append((day_of_week, start_time, session_name_contains))
except FileNotFoundError: except FileNotFoundError:
# Log a warning if the file is not found # Log error and exit if the file is not found
logging.warning(f"Configuration file '{config_file}' not found. Falling back to default settings.") logging.error(f"Configuration file '{config_file}' not found. Please create the configuration file.")
logging.error("You can copy 'preferred_sessions.json.example' to 'preferred_sessions.json' as a template.")
raise SystemExit(1)
except json.JSONDecodeError: except json.JSONDecodeError:
# Log a warning if the file is not a valid JSON # Log error and exit if the file is not a valid JSON
logging.warning(f"Failed to decode JSON from file '{config_file}'. Falling back to default settings.") logging.error(f"Failed to decode JSON from file '{config_file}'. Please check the file format.")
raise SystemExit(1)
# Fallback to default hardcoded sessions if no valid sessions were loaded # Return empty list if no valid sessions were loaded
if not preferred_sessions: if not preferred_sessions:
preferred_sessions = [ return []
(2, "18:30", "CONDITIONING"), # Wednesday 18:30 CONDITIONING
(4, "17:00", "WEIGHTLIFTING"), # Friday 17:00 WEIGHTLIFTING
(5, "12:30", "HYROX"), # Saturday 12:30 HYROX
]
return preferred_sessions return preferred_sessions

314
src/session_manager.py Normal file
View File

@@ -0,0 +1,314 @@
# Native modules
import logging
import pytz
import time
from datetime import date
from typing import List, Dict, Optional, Any
from datetime import datetime
# Third-party modules
import requests
from dateutil.parser import parse
# Import the preferred sessions from the session_config module
from src.session_config import PREFERRED_SESSIONS
# Import the AuthHandler class
from src.auth import AuthHandler
class SessionManager:
"""
A class for managing CrossFit sessions.
This class handles session availability checking, booking,
and session-related operations.
"""
def __init__(self, auth_handler: AuthHandler) -> None:
"""
Initialize the SessionManager with necessary attributes.
Args:
auth_handler (AuthHandler): AuthHandler instance for authentication.
"""
self.auth_handler = auth_handler
self.session = requests.Session()
self.base_headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0",
"Content-Type": "application/x-www-form-urlencoded",
"Nubapp-Origin": "user_apps",
}
self.session.headers.update(self.base_headers)
# Define mandatory parameters for API calls
self.mandatory_params = {
"app_version": "5.09.21",
"device_type": "3",
"id_application": "81560887",
"id_category_activity": "677"
}
def get_auth_headers(self) -> Dict[str, str]:
"""
Return headers with authorization from the AuthHandler.
Returns:
Dict[str, str]: Headers dictionary with authorization if available.
"""
return self.auth_handler.get_auth_headers()
def get_available_sessions(self, start_date: date, end_date: date) -> Optional[Dict[str, Any]]:
"""
Fetch available sessions from the API.
Args:
start_date (date): Start date for fetching sessions.
end_date (date): End date for fetching sessions.
Returns:
Optional[Dict[str, Any]]: Dictionary containing available sessions if successful, None otherwise.
"""
if not self.auth_handler.auth_token or not self.auth_handler.user_id:
logging.error("Authentication required - missing token or user ID")
return None
url = "https://sport.nubapp.com/api/v4/activities/getActivitiesCalendar.php"
# Prepare request with mandatory parameters
request_data = self.mandatory_params.copy()
request_data.update({
"id_user": self.auth_handler.user_id,
"start_timestamp": start_date.strftime("%d-%m-%Y"),
"end_timestamp": end_date.strftime("%d-%m-%Y")
})
# Add retry logic
for retry in range(3):
try:
response = self.session.post(
url,
headers=self.get_auth_headers(),
data=request_data,
timeout=10
)
if response.status_code == 200:
return response.json()
elif response.status_code == 401:
logging.error("401 Unauthorized - token may be expired or invalid")
return None
elif 500 <= response.status_code < 600:
logging.error(f"Server error {response.status_code}")
raise requests.exceptions.ConnectionError(f"Server error {response.status_code}")
else:
logging.error(f"Unexpected status code: {response.status_code} - {response.text[:100]}")
return None
except requests.exceptions.RequestException as e:
if retry == 2:
logging.error(f"Final retry failed: {str(e)}")
raise
wait_time = 1 * (2 ** retry)
logging.warning(f"Request failed (attempt {retry+1}/3): {str(e)}. Retrying in {wait_time}s...")
time.sleep(wait_time)
return None
def book_session(self, session_id: str) -> bool:
"""
Book a specific session.
Args:
session_id (str): ID of the session to book.
Returns:
bool: True if booking is successful, False otherwise.
"""
url = "https://sport.nubapp.com/api/v4/activities/bookActivityCalendar.php"
data = {
**self.mandatory_params,
"id_activity_calendar": session_id,
"id_user": self.auth_handler.user_id,
"action_by": self.auth_handler.user_id,
"n_guests": "0",
"booked_on": "1",
"device_type": self.mandatory_params["device_type"],
"token": self.auth_handler.auth_token
}
for retry in range(3):
try:
response = self.session.post(
url,
headers=self.get_auth_headers(),
data=data,
timeout=10
)
if response.status_code == 200:
json_response = response.json()
if json_response.get("success", False):
logging.info(f"Successfully booked session {session_id}")
return True
else:
logging.error(f"API returned success:false: {json_response} - Session ID: {session_id}")
return False
logging.error(f"HTTP {response.status_code}: {response.text[:100]}")
return False
except requests.exceptions.RequestException as e:
if retry == 2:
logging.error(f"Final retry failed: {str(e)}")
raise
wait_time = 1 * (2 ** retry)
logging.warning(f"Request failed (attempt {retry+1}/3): {str(e)}. Retrying in {wait_time}s...")
time.sleep(wait_time)
logging.error("Failed to complete request after 3 attempts")
return False
def get_booked_sessions(self) -> List[Dict[str, Any]]:
"""
Get a list of booked sessions.
Returns:
A list of dictionaries containing information about the booked sessions.
"""
url = "https://sport.nubapp.com/api/v4/activities/getBookedActivities.php"
data = {
**self.mandatory_params,
"id_user": self.auth_handler.user_id,
"action_by": self.auth_handler.user_id
}
for retry in range(3):
try:
response = self.session.post(
url,
headers=self.get_auth_headers(),
data=data,
timeout=10
)
if response.status_code == 200:
json_response = response.json()
if json_response.get("success", False):
return json_response.get("data", [])
logging.error(f"API returned success:false: {json_response}")
return []
logging.error(f"HTTP {response.status_code}: {response.text[:100]}")
return []
except requests.exceptions.RequestException as e:
if retry == 2:
logging.error(f"Final retry failed: {str(e)}")
raise
wait_time = 1 * (2 ** retry)
logging.warning(f"Request failed (attempt {retry+1}/3): {str(e)}. Retrying in {wait_time}s...")
time.sleep(wait_time)
logging.error("Failed to complete request after 3 attempts")
return []
def is_session_bookable(self, session: Dict[str, Any], current_time: datetime) -> bool:
"""
Check if a session is bookable based on user_info.
Args:
session (Dict[str, Any]): Session data.
current_time (datetime): Current time for comparison.
Returns:
bool: True if the session is bookable, False otherwise.
"""
user_info = session.get("user_info", {})
# First check if can_join is true (primary condition)
if user_info.get("can_join", False):
return True
# Check if booking window is in the past
unable_to_book_until_date = user_info.get("unableToBookUntilDate", "")
unable_to_book_until_time = user_info.get("unableToBookUntilTime", "")
if unable_to_book_until_date and unable_to_book_until_time:
try:
# Parse the date and time
booking_window_time_str = f"{unable_to_book_until_date} {unable_to_book_until_time}"
booking_window_time = parse(booking_window_time_str)
if not booking_window_time.tzinfo:
booking_window_time = pytz.timezone("Europe/Paris").localize(booking_window_time)
# If current time is after the booking window, session is bookable
if current_time > booking_window_time:
return True
except (ValueError, TypeError):
# If parsing fails, default to not bookable
pass
# Default case: not bookable
return False
def matches_preferred_session(self, session: Dict[str, Any], current_time: datetime) -> bool:
"""
Check if session matches one of your preferred sessions with exact matching.
Args:
session (Dict[str, Any]): Session data.
current_time (datetime): Current time for comparison.
Returns:
bool: True if the session matches a preferred session, False otherwise.
"""
try:
session_time = parse(session["start_timestamp"])
if not session_time.tzinfo:
session_time = pytz.timezone("Europe/Paris").localize(session_time)
day_of_week = session_time.weekday()
session_time_str = session_time.strftime("%H:%M")
session_name = session.get("name_activity", "").upper()
for preferred_day, preferred_time, preferred_name in PREFERRED_SESSIONS:
# Exact match
if (day_of_week == preferred_day and
session_time_str == preferred_time and
preferred_name in session_name):
return True
return False
except Exception as e:
logging.error(f"Failed to check session: {str(e)} - Session: {session}")
return False
def display_upcoming_sessions(self, sessions: List[Dict[str, Any]], current_time: datetime) -> None:
"""
Display upcoming sessions with ID, name, date, and time.
Args:
sessions (List[Dict[str, Any]]): List of session data.
current_time (datetime): Current time for comparison.
"""
if not sessions:
logging.info("No sessions to display")
return
logging.info("Upcoming sessions:")
logging.info("ID\t\tName\t\tDate\t\tTime")
logging.info("="*50)
for session in sessions:
session_time = parse(session["start_timestamp"])
if not session_time.tzinfo:
session_time = pytz.timezone("Europe/Paris").localize(session_time)
# Format session details
session_id = session.get("id_activity_calendar", "N/A")
session_name = session.get("name_activity", "N/A")
session_date = session_time.strftime("%Y-%m-%d")
session_time_str = session_time.strftime("%H:%M")
# Display session details
logging.info(f"{session_id}\t{session_name}\t{session_date}\t{session_time_str}")

View File

@@ -1,8 +1,10 @@
import smtplib import smtplib
import os import os
import logging import logging
import asyncio
from email.message import EmailMessage from email.message import EmailMessage
from telegram import Bot from telegram import Bot
from telegram.error import TimedOut, NetworkError
class SessionNotifier: class SessionNotifier:
@@ -34,6 +36,12 @@ class SessionNotifier:
self.enable_email = enable_email self.enable_email = enable_email
self.enable_telegram = enable_telegram self.enable_telegram = enable_telegram
# Check environment variable for impossible booking notifications
self.notify_impossible = os.environ.get("NOTIFY_IMPOSSIBLE_BOOKING", "true").lower() in ("true", "1", "yes")
# Get crossfit username from environment
self.crossfit_username = os.environ.get("CROSSFIT_USERNAME", "User")
def send_email_notification(self, message): def send_email_notification(self, message):
""" """
Send an email notification with the given message. Send an email notification with the given message.
@@ -72,17 +80,34 @@ class SessionNotifier:
logging.error(f"Failed to send email: {str(e)}") logging.error(f"Failed to send email: {str(e)}")
raise raise
async def send_telegram_notification(self, message): async def send_telegram_notification(self, message, max_retries=3):
""" """
Send a Telegram notification with the given message. Send a Telegram notification with the given message.
Args: Args:
message (str): The message content to be sent in the Telegram chat message (str): The message content to be sent in the Telegram chat
max_retries (int): Maximum number of retry attempts (default: 3)
""" """
# Create a Bot instance with the provided token # Create a Bot instance with the provided token
bot = Bot(token=self.telegram_credentials["token"]) bot = Bot(token=self.telegram_credentials["token"])
for attempt in range(max_retries):
try:
# Send the message to the specified chat ID and await the result # Send the message to the specified chat ID and await the result
await bot.send_message(chat_id=self.telegram_credentials["chat_id"], text=message) await bot.send_message(chat_id=self.telegram_credentials["chat_id"], text=message)
logging.debug("Telegram notification sent successfully")
return
except (TimedOut, NetworkError) as e:
if attempt < max_retries - 1:
wait_time = 2 ** attempt # Exponential backoff: 1s, 2s, 4s
logging.warning(f"Telegram notification failed (attempt {attempt + 1}/{max_retries}): {str(e)}. Retrying in {wait_time} seconds...")
await asyncio.sleep(wait_time)
else:
logging.error(f"Failed to send Telegram notification after {max_retries} attempts: {str(e)}")
raise
except Exception as e:
logging.error(f"Unexpected error sending Telegram notification: {str(e)}")
raise
async def notify_session_booking(self, session_details): async def notify_session_booking(self, session_details):
""" """
@@ -92,8 +117,8 @@ class SessionNotifier:
session_details (str): Details about the booked session session_details (str): Details about the booked session
""" """
# Create messages for both email and Telegram # Create messages for both email and Telegram
email_message = f"Session booked: {session_details}" email_message = f"Session booked for {self.crossfit_username}: {session_details}"
telegram_message = f"Session booked: {session_details}" telegram_message = f"Session booked for {self.crossfit_username}: {session_details}"
# Send notifications through enabled channels # Send notifications through enabled channels
if self.enable_email: if self.enable_email:
@@ -111,8 +136,8 @@ class SessionNotifier:
days_until (int): Number of days until the session days_until (int): Number of days until the session
""" """
# Create messages for both email and Telegram # Create messages for both email and Telegram
email_message = f"Session available soon: {session_details} (in {days_until} days)" email_message = f"Session available soon for {self.crossfit_username}: {session_details} (in {days_until} days)"
telegram_message = f"Session available soon: {session_details} (in {days_until} days)" telegram_message = f"Session available soon for {self.crossfit_username}: {session_details} (in {days_until} days)"
# Send notifications through enabled channels # Send notifications through enabled channels
if self.enable_email: if self.enable_email:
@@ -121,16 +146,29 @@ class SessionNotifier:
if self.enable_telegram: if self.enable_telegram:
await self.send_telegram_notification(telegram_message) await self.send_telegram_notification(telegram_message)
async def notify_impossible_booking(self, session_details): async def notify_impossible_booking(self, session_details, notify_if_impossible=None):
""" """
Notify about an impossible session booking via email and Telegram. Notify about an impossible session booking via email and Telegram.
Args: Args:
session_details (str): Details about the session that couldn't be booked session_details (str): Details about the session that couldn't be booked
notify_if_impossible (bool, optional): Whether to send notifications for impossible bookings.
If None, uses the value from the NOTIFY_IMPOSSIBLE_BOOKING
environment variable.
""" """
# Determine if notifications should be sent
# First check the method parameter (if provided), then the environment variable
should_notify = (
notify_if_impossible if notify_if_impossible is not None else self.notify_impossible
)
# Only proceed if notifications for impossible bookings are enabled
if not should_notify:
return
# Create messages for both email and Telegram # Create messages for both email and Telegram
email_message = f"Failed to book session: {session_details}" email_message = f"Failed to book session for {self.crossfit_username}: {session_details}"
telegram_message = f"Failed to book session: {session_details}" telegram_message = f"Failed to book session for {self.crossfit_username}: {session_details}"
# Send notifications through enabled channels # Send notifications through enabled channels
if self.enable_email: if self.enable_email:

135
test/test_auth.py Normal file
View File

@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""
Unit tests for AuthHandler class
"""
import pytest
import os
import sys
import requests
from unittest.mock import patch, Mock
# Add the parent directory to the path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from src.auth import AuthHandler
class TestAuthHandlerAuthHeaders:
"""Test cases for get_auth_headers method"""
def test_get_auth_headers_without_token(self):
"""Test headers without auth token"""
auth_handler = AuthHandler('test_user', 'test_pass')
headers = auth_handler.get_auth_headers()
assert "Authorization" not in headers
assert headers["User-Agent"] == "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0"
def test_get_auth_headers_with_token(self):
"""Test headers with auth token"""
auth_handler = AuthHandler('test_user', 'test_pass')
auth_handler.auth_token = "test_token_123"
headers = auth_handler.get_auth_headers()
assert headers["Authorization"] == "Bearer test_token_123"
class TestAuthHandlerLogin:
"""Test cases for login method"""
@patch('requests.Session.post')
def test_login_success(self, mock_post):
"""Test successful login flow"""
# Mock first login response
mock_response1 = Mock()
mock_response1.ok = True
mock_response1.json.return_value = {
"data": {
"user": {
"id_user": "12345"
}
}
}
# Mock second login response
mock_response2 = Mock()
mock_response2.ok = True
mock_response2.json.return_value = {
"token": "test_bearer_token"
}
mock_post.side_effect = [mock_response1, mock_response2]
auth_handler = AuthHandler('test_user', 'test_pass')
result = auth_handler.login()
assert result is True
assert auth_handler.user_id == "12345"
assert auth_handler.auth_token == "test_bearer_token"
@patch('requests.Session.post')
def test_login_first_step_failure(self, mock_post):
"""Test login failure on first step"""
mock_response = Mock()
mock_response.ok = False
mock_response.status_code = 400
mock_response.text = "Bad Request"
mock_post.return_value = mock_response
auth_handler = AuthHandler('test_user', 'test_pass')
result = auth_handler.login()
assert result is False
assert auth_handler.user_id is None
assert auth_handler.auth_token is None
@patch('requests.Session.post')
def test_login_second_step_failure(self, mock_post):
"""Test login failure on second step"""
# First response succeeds
mock_response1 = Mock()
mock_response1.ok = True
mock_response1.json.return_value = {
"data": {
"user": {
"id_user": "12345"
}
}
}
# Second response fails
mock_response2 = Mock()
mock_response2.ok = False
mock_response2.status_code = 401
mock_post.side_effect = [mock_response1, mock_response2]
auth_handler = AuthHandler('test_user', 'test_pass')
result = auth_handler.login()
assert result is False
@patch('requests.Session.post')
def test_login_json_parsing_error(self, mock_post):
"""Test login with JSON parsing error"""
mock_response = Mock()
mock_response.ok = True
mock_response.json.side_effect = ValueError("Invalid JSON")
mock_post.return_value = mock_response
auth_handler = AuthHandler('test_user', 'test_pass')
result = auth_handler.login()
assert result is False
@patch('requests.Session.post')
def test_login_request_exception(self, mock_post):
"""Test login with request exception"""
mock_post.side_effect = requests.exceptions.ConnectionError("Network error")
auth_handler = AuthHandler('test_user', 'test_pass')
result = auth_handler.login()
assert result is False
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""
Unit tests for CrossFitBooker authentication methods using AuthHandler
"""
import pytest
import os
import sys
import requests
from unittest.mock import patch, Mock
# Add the parent directory to the path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from src.auth import AuthHandler
from src.booker import Booker
from src.session_notifier import SessionNotifier
class TestCrossFitBookerAuthHeaders:
"""Test cases for get_auth_headers method"""
def test_get_auth_headers_without_token(self):
"""Test headers without auth token"""
with patch.dict(os.environ, {
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass'
}):
auth_handler = AuthHandler('test_user', 'test_pass')
headers = auth_handler.get_auth_headers()
assert "Authorization" not in headers
assert headers["User-Agent"] == "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0"
def test_get_auth_headers_with_token(self):
"""Test headers with auth token"""
with patch.dict(os.environ, {
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass'
}):
auth_handler = AuthHandler('test_user', 'test_pass')
auth_handler.auth_token = "test_token_123"
headers = auth_handler.get_auth_headers()
assert headers["Authorization"] == "Bearer test_token_123"
class TestAuthHandlerLogin:
"""Test cases for AuthHandler login method"""
@patch('requests.Session.post')
def test_login_success(self, mock_post):
"""Test successful login flow"""
# Mock first login response
mock_response1 = Mock()
mock_response1.ok = True
mock_response1.json.return_value = {
"data": {
"user": {
"id_user": "12345"
}
}
}
# Mock second login response
mock_response2 = Mock()
mock_response2.ok = True
mock_response2.json.return_value = {
"token": "test_bearer_token"
}
mock_post.side_effect = [mock_response1, mock_response2]
with patch.dict(os.environ, {
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass'
}):
auth_handler = AuthHandler('test_user', 'test_pass')
result = auth_handler.login()
assert result is True
assert auth_handler.user_id == "12345"
assert auth_handler.auth_token == "test_bearer_token"
@patch('requests.Session.post')
def test_login_first_step_failure(self, mock_post):
"""Test login failure on first step"""
mock_response = Mock()
mock_response.ok = False
mock_response.status_code = 400
mock_response.text = "Bad Request"
mock_post.return_value = mock_response
with patch.dict(os.environ, {
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass'
}):
auth_handler = AuthHandler('test_user', 'test_pass')
result = auth_handler.login()
assert result is False
assert auth_handler.user_id is None
assert auth_handler.auth_token is None
@patch('requests.Session.post')
def test_login_second_step_failure(self, mock_post):
"""Test login failure on second step"""
# First response succeeds
mock_response1 = Mock()
mock_response1.ok = True
mock_response1.json.return_value = {
"data": {
"user": {
"id_user": "12345"
}
}
}
# Second response fails
mock_response2 = Mock()
mock_response2.ok = False
mock_response2.status_code = 401
mock_post.side_effect = [mock_response1, mock_response2]
with patch.dict(os.environ, {
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass'
}):
auth_handler = AuthHandler('test_user', 'test_pass')
result = auth_handler.login()
assert result is False
@patch('requests.Session.post')
def test_login_json_parsing_error(self, mock_post):
"""Test login with JSON parsing error"""
mock_response = Mock()
mock_response.ok = True
mock_response.json.side_effect = ValueError("Invalid JSON")
mock_post.return_value = mock_response
with patch.dict(os.environ, {
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass'
}):
auth_handler = AuthHandler('test_user', 'test_pass')
result = auth_handler.login()
assert result is False
@patch('requests.Session.post')
def test_login_request_exception(self, mock_post):
"""Test login with request exception"""
mock_post.side_effect = requests.exceptions.ConnectionError("Network error")
with patch.dict(os.environ, {
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass'
}):
auth_handler = AuthHandler('test_user', 'test_pass')
result = auth_handler.login()
assert result is False
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -0,0 +1,262 @@
#!/usr/bin/env python3
"""
Unit tests for CrossFitBooker session-related methods
"""
import pytest
import os
import sys
from unittest.mock import patch, Mock
from datetime import datetime, timedelta, date
import pytz
# Add the parent directory to the path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from src.auth import AuthHandler
from src.booker import Booker
from src.session_notifier import SessionNotifier
from src.session_manager import SessionManager
class TestCrossFitBookerGetAvailableSessions:
"""Test cases for get_available_sessions method"""
def test_get_available_sessions_no_auth(self):
"""Test get_available_sessions without authentication"""
with patch.dict(os.environ, {
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass'
}):
auth_handler = AuthHandler('test_user', 'test_pass')
session_manager = SessionManager(auth_handler)
result = session_manager.get_available_sessions(date(2025, 7, 24), date(2025, 7, 25))
assert result is None
@patch('requests.Session.post')
def test_get_available_sessions_success(self, mock_post):
"""Test successful get_available_sessions"""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"success": True,
"data": {
"activities_calendar": [
{"id": "1", "name": "Test Session"}
]
}
}
mock_post.return_value = mock_response
with patch.dict(os.environ, {
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass'
}):
auth_handler = AuthHandler('test_user', 'test_pass')
auth_handler.auth_token = "test_token"
auth_handler.user_id = "12345"
session_manager = SessionManager(auth_handler)
result = session_manager.get_available_sessions(date(2025, 7, 24), date(2025, 7, 25))
assert result is not None
assert result["success"] is True
@patch('requests.Session.post')
def test_get_available_sessions_401_error(self, mock_post):
"""Test get_available_sessions with 401 error"""
mock_response = Mock()
mock_response.status_code = 401
mock_post.return_value = mock_response
with patch.dict(os.environ, {
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass'
}):
auth_handler = AuthHandler('test_user', 'test_pass')
session_manager = SessionManager(auth_handler)
auth_handler.auth_token = "test_token"
auth_handler.user_id = "12345"
result = session_manager.get_available_sessions(date(2025, 7, 24), date(2025, 7, 25))
assert result is None
class TestCrossFitBookerBookSession:
"""Test cases for book_session method"""
def test_book_session_no_auth(self):
"""Test book_session without authentication"""
with patch.dict(os.environ, {
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass'
}):
auth_handler = AuthHandler('test_user', 'test_pass')
session_manager = SessionManager(auth_handler)
result = session_manager.book_session("session_123")
assert result is False
@patch('requests.Session.post')
def test_book_session_success(self, mock_post):
"""Test successful book_session"""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"success": True}
mock_post.return_value = mock_response
with patch.dict(os.environ, {
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass'
}):
auth_handler = AuthHandler('test_user', 'test_pass')
auth_handler.auth_token = "test_token"
auth_handler.user_id = "12345"
session_manager = SessionManager(auth_handler)
result = session_manager.book_session("session_123")
assert result is True
@patch('requests.Session.post')
def test_book_session_api_failure(self, mock_post):
"""Test book_session with API failure"""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"success": False, "error": "Session full"}
mock_post.return_value = mock_response
with patch.dict(os.environ, {
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass'
}):
auth_handler = AuthHandler('test_user', 'test_pass')
auth_handler.auth_token = "test_token"
auth_handler.user_id = "12345"
session_manager = SessionManager(auth_handler)
result = session_manager.book_session("session_123")
assert result is False
class TestCrossFitBookerIsSessionBookable:
"""Test cases for is_session_bookable method"""
def test_is_session_bookable_can_join_true(self):
"""Test session bookable with can_join=True"""
with patch.dict(os.environ, {
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass'
}):
auth_handler = AuthHandler('test_user', 'test_pass')
session_manager = SessionManager(auth_handler)
session = {"user_info": {"can_join": True}}
current_time = datetime.now(pytz.timezone("Europe/Paris"))
result = session_manager.is_session_bookable(session, current_time)
assert result is True
def test_is_session_bookable_booking_window_past(self):
"""Test session bookable with booking window in past"""
with patch.dict(os.environ, {
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass'
}):
auth_handler = AuthHandler('test_user', 'test_pass')
session_manager = SessionManager(auth_handler)
session = {
"user_info": {
"can_join": False,
"unableToBookUntilDate": "01-01-2020",
"unableToBookUntilTime": "10:00"
}
}
current_time = datetime.now(pytz.timezone("Europe/Paris"))
result = session_manager.is_session_bookable(session, current_time)
assert result is True
def test_is_session_bookable_booking_window_future(self):
"""Test session not bookable with booking window in future"""
with patch.dict(os.environ, {
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass'
}):
auth_handler = AuthHandler('test_user', 'test_pass')
session_manager = SessionManager(auth_handler)
session = {
"user_info": {
"can_join": False,
"unableToBookUntilDate": "01-01-2030",
"unableToBookUntilTime": "10:00"
}
}
current_time = datetime.now(pytz.timezone("Europe/Paris"))
result = session_manager.is_session_bookable(session, current_time)
assert result is False
class TestCrossFitBookerMatchesPreferredSession:
"""Test cases for matches_preferred_session method"""
def test_matches_preferred_session_exact_match(self):
"""Test exact match with preferred session"""
with patch.dict(os.environ, {
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass'
}):
auth_handler = AuthHandler('test_user', 'test_pass')
session_manager = SessionManager(auth_handler)
session = {
"start_timestamp": "2025-07-30 18:30:00",
"name_activity": "CONDITIONING"
}
current_time = datetime(2025, 7, 30, 12, 0, 0, tzinfo=pytz.timezone("Europe/Paris"))
# Mock PREFERRED_SESSIONS
with patch('src.session_manager.PREFERRED_SESSIONS', [(2, "18:30", "CONDITIONING")]):
result = session_manager.matches_preferred_session(session, current_time)
assert result is True
def test_matches_preferred_session_fuzzy_match(self):
"""Test fuzzy match with preferred session"""
with patch.dict(os.environ, {
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass'
}):
auth_handler = AuthHandler('test_user', 'test_pass')
session_manager = SessionManager(auth_handler)
session = {
"start_timestamp": "2025-07-30 18:30:00",
"name_activity": "CONDITIONING WORKOUT"
}
current_time = datetime(2025, 7, 30, 12, 0, 0, tzinfo=pytz.timezone("Europe/Paris"))
# Mock PREFERRED_SESSIONS
with patch('src.session_manager.PREFERRED_SESSIONS', [(2, "18:30", "CONDITIONING")]):
result = session_manager.matches_preferred_session(session, current_time)
assert result is True
def test_matches_preferred_session_no_match(self):
"""Test no match with preferred session"""
with patch.dict(os.environ, {
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass'
}):
auth_handler = AuthHandler('test_user', 'test_pass')
session_manager = SessionManager(auth_handler)
session = {
"start_timestamp": "2025-07-30 18:30:00",
"name_activity": "YOGA"
}
current_time = datetime(2025, 7, 30, 12, 0, 0, tzinfo=pytz.timezone("Europe/Paris"))
# Mock PREFERRED_SESSIONS
with patch('src.session_manager.PREFERRED_SESSIONS', [(2, "18:30", "CONDITIONING")]):
result = session_manager.matches_preferred_session(session, current_time)
assert result is False

169
test/test_session_config.py Normal file
View File

@@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""
Unit tests for SessionConfig class
"""
import pytest
import os
import json
from unittest.mock import patch, mock_open
# Add the parent directory to the path
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from src.session_config import SessionConfig
class TestSessionConfig:
def test_load_preferred_sessions_valid_file(self):
"""Test loading preferred sessions from a valid JSON file"""
# Create a mock JSON file content
mock_content = json.dumps([
{"day_of_week": 1, "start_time": "08:00", "session_name_contains": "Morning"},
{"day_of_week": 3, "start_time": "18:00", "session_name_contains": "Evening"}
])
# Mock the open function to return our mock file content
with patch('builtins.open', mock_open(read_data=mock_content)):
sessions = SessionConfig.load_preferred_sessions()
# Verify the returned sessions match our mock content
assert len(sessions) == 2
assert sessions[0] == (1, "08:00", "Morning")
assert sessions[1] == (3, "18:00", "Evening")
def test_load_preferred_sessions_file_not_found(self):
"""Test behavior when the config file is not found"""
# Mock the open function to raise FileNotFoundError
with patch('builtins.open', side_effect=FileNotFoundError):
with patch('logging.error') as mock_error:
with pytest.raises(SystemExit) as excinfo:
SessionConfig.load_preferred_sessions()
# Verify error was logged
assert mock_error.call_count == 2
assert "not found" in mock_error.call_args_list[0][0][0]
assert "example" in mock_error.call_args_list[1][0][0]
# Verify SystemExit was raised with exit code 1
assert excinfo.value.code == 1
def test_load_preferred_sessions_invalid_json(self):
"""Test behavior when the config file contains invalid JSON"""
# Create invalid JSON content
invalid_json = "{invalid json content}"
# Mock the open function to return invalid JSON
with patch('builtins.open', mock_open(read_data=invalid_json)):
with patch('logging.error') as mock_error:
with pytest.raises(SystemExit) as excinfo:
SessionConfig.load_preferred_sessions()
# Verify error was logged
mock_error.assert_called_once()
assert "decode" in mock_error.call_args[0][0]
# Verify SystemExit was raised with exit code 1
assert excinfo.value.code == 1
def test_load_preferred_sessions_empty_file(self):
"""Test behavior when the config file is empty"""
# Create empty JSON content
empty_json = json.dumps([])
# Mock the open function to return empty JSON
with patch('builtins.open', mock_open(read_data=empty_json)):
sessions = SessionConfig.load_preferred_sessions()
# Verify empty list is returned
assert sessions == []
def test_load_preferred_sessions_missing_fields(self):
"""Test behavior when some fields are missing in the JSON data"""
# Create JSON with missing fields
mock_content = json.dumps([
{"day_of_week": 1}, # Missing start_time and session_name_contains
{"start_time": "18:00"}, # Missing day_of_week and session_name_contains
{"session_name_contains": "Test"} # Missing day_of_week and start_time
])
# Mock the open function to return our mock file content
with patch('builtins.open', mock_open(read_data=mock_content)):
sessions = SessionConfig.load_preferred_sessions()
# Verify the returned sessions have default values for missing fields
assert len(sessions) == 3
assert sessions[0] == (1, "00:00", "")
assert sessions[1] == (0, "18:00", "")
assert sessions[2] == (0, "00:00", "Test")
def test_load_preferred_sessions_partial_json(self):
"""Test behavior when the config file contains partial JSON content"""
# Create partial JSON content
partial_json = '{"day_of_week": 1, "start_time": "08:00" ' # Missing closing brace
# Mock the open function to return partial JSON
with patch('builtins.open', mock_open(read_data=partial_json)):
with patch('logging.error') as mock_error:
with pytest.raises(SystemExit) as excinfo:
SessionConfig.load_preferred_sessions()
# Verify error was logged
mock_error.assert_called_once()
assert "decode" in mock_error.call_args[0][0]
# Verify SystemExit was raised with exit code 1
assert excinfo.value.code == 1
def test_load_preferred_sessions_incorrect_field_types(self):
"""Test behavior when the config file contains JSON with incorrect field types"""
# Create JSON with incorrect field types
mock_content = json.dumps([
{"day_of_week": "Monday", "start_time": "08:00", "session_name_contains": "Morning"},
{"day_of_week": 1, "start_time": 800, "session_name_contains": "Morning"},
])
# Mock the open function to return our mock file content
with patch('builtins.open', mock_open(read_data=mock_content)):
sessions = SessionConfig.load_preferred_sessions()
# Verify the returned sessions use the values as provided
assert len(sessions) == 2
assert sessions[0] == ("Monday", "08:00", "Morning")
assert sessions[1] == (1, 800, "Morning")
def test_load_preferred_sessions_extra_fields(self):
"""Test behavior when the config file contains JSON with extra fields"""
# Create JSON with extra fields
mock_content = json.dumps([
{"day_of_week": 1, "start_time": "08:00", "session_name_contains": "Morning", "extra_field": "extra_value"},
])
# Mock the open function to return our mock file content
with patch('builtins.open', mock_open(read_data=mock_content)):
sessions = SessionConfig.load_preferred_sessions()
# Verify the returned sessions ignore extra fields
assert len(sessions) == 1
assert sessions[0] == (1, "08:00", "Morning")
def test_load_preferred_sessions_duplicate_entries(self):
"""Test behavior when the config file contains duplicate session entries"""
# Create JSON with duplicate entries
mock_content = json.dumps([
{"day_of_week": 1, "start_time": "08:00", "session_name_contains": "Morning"},
{"day_of_week": 1, "start_time": "08:00", "session_name_contains": "Morning"},
])
# Mock the open function to return our mock file content
with patch('builtins.open', mock_open(read_data=mock_content)):
sessions = SessionConfig.load_preferred_sessions()
# Verify the returned sessions contain duplicates
assert len(sessions) == 2
assert sessions[0] == (1, "08:00", "Morning")
assert sessions[1] == (1, "08:00", "Morning")
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -0,0 +1,186 @@
#!/usr/bin/env python3
"""
Unit tests for SessionNotifier class
"""
import pytest
import os
import asyncio
from unittest.mock import patch, MagicMock
# Add the parent directory to the path
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from src.session_notifier import SessionNotifier
@pytest.fixture
def email_credentials():
return {
"from": "test@example.com",
"to": "recipient@example.com",
"password": "password123"
}
@pytest.fixture
def telegram_credentials():
return {
"token": "telegram_token",
"chat_id": "123456789"
}
@pytest.fixture
def session_notifier(email_credentials, telegram_credentials):
return SessionNotifier(
email_credentials=email_credentials,
telegram_credentials=telegram_credentials,
enable_email=True,
enable_telegram=True
)
@pytest.mark.asyncio
async def test_notify_session_booking(session_notifier, email_credentials, telegram_credentials):
"""Test session booking notification with both email and Telegram enabled"""
# Mock the email and Telegram notification methods
with patch.object(session_notifier, 'send_email_notification') as mock_email, \
patch.object(session_notifier, 'send_telegram_notification', new=MagicMock()) as mock_telegram:
# Set up the mock for the async method
mock_telegram.return_value = asyncio.Future()
mock_telegram.return_value.set_result(None)
# Call the method to test
await session_notifier.notify_session_booking("Test session")
# Verify both notification methods were called
mock_email.assert_called_once_with("Session booked: Test session")
mock_telegram.assert_called_once_with("Session booked: Test session")
@pytest.mark.asyncio
async def test_notify_session_booking_email_only(session_notifier, email_credentials):
"""Test session booking notification with only email enabled"""
# Disable Telegram notifications
session_notifier.enable_telegram = False
# Mock the email notification method
with patch.object(session_notifier, 'send_email_notification') as mock_email, \
patch.object(session_notifier, 'send_telegram_notification') as mock_telegram:
# Call the method to test
await session_notifier.notify_session_booking("Test session")
# Verify only email notification was called
mock_email.assert_called_once_with("Session booked: Test session")
mock_telegram.assert_not_called()
@pytest.mark.asyncio
async def test_notify_session_booking_telegram_only(session_notifier, telegram_credentials):
"""Test session booking notification with only Telegram enabled"""
# Disable email notifications
session_notifier.enable_email = False
# Mock the Telegram notification method
with patch.object(session_notifier, 'send_telegram_notification', new=MagicMock()) as mock_telegram:
# Set up the mock for the async method
mock_telegram.return_value = asyncio.Future()
mock_telegram.return_value.set_result(None)
# Call the method to test
await session_notifier.notify_session_booking("Test session")
# Verify only Telegram notification was called
mock_telegram.assert_called_once_with("Session booked: Test session")
@pytest.mark.asyncio
async def test_notify_upcoming_session(session_notifier, email_credentials, telegram_credentials):
"""Test upcoming session notification with both email and Telegram enabled"""
# Mock the email and Telegram notification methods
with patch.object(session_notifier, 'send_email_notification') as mock_email, \
patch.object(session_notifier, 'send_telegram_notification', new=MagicMock()) as mock_telegram:
# Set up the mock for the async method
mock_telegram.return_value = asyncio.Future()
mock_telegram.return_value.set_result(None)
# Call the method to test
await session_notifier.notify_upcoming_session("Test session", 3)
# Verify both notification methods were called
mock_email.assert_called_once_with("Session available soon: Test session (in 3 days)")
mock_telegram.assert_called_once_with("Session available soon: Test session (in 3 days)")
@pytest.mark.asyncio
async def test_notify_impossible_booking_enabled(session_notifier, email_credentials, telegram_credentials):
"""Test impossible booking notification when notifications are enabled"""
# Set the notify_impossible attribute to True
session_notifier.notify_impossible = True
# Mock the email and Telegram notification methods
with patch.object(session_notifier, 'send_email_notification') as mock_email, \
patch.object(session_notifier, 'send_telegram_notification', new=MagicMock()) as mock_telegram:
# Set up the mock for the async method
mock_telegram.return_value = asyncio.Future()
mock_telegram.return_value.set_result(None)
# Call the method to test
await session_notifier.notify_impossible_booking("Test session")
# Verify both notification methods were called
mock_email.assert_called_once_with("Failed to book session: Test session")
mock_telegram.assert_called_once_with("Failed to book session: Test session")
@pytest.mark.asyncio
async def test_notify_impossible_booking_disabled(session_notifier, email_credentials, telegram_credentials):
"""Test impossible booking notification when notifications are disabled"""
# Mock the email and Telegram notification methods
with patch.object(session_notifier, 'send_email_notification') as mock_email, \
patch.object(session_notifier, 'send_telegram_notification', new=MagicMock()) as mock_telegram:
# Set up the mock for the async method
mock_telegram.return_value = asyncio.Future()
mock_telegram.return_value.set_result(None)
# Call the method to test with notify_if_impossible=False
await session_notifier.notify_impossible_booking("Test session", notify_if_impossible=False)
# Verify neither notification method was called
mock_email.assert_not_called()
mock_telegram.assert_not_called()
@pytest.mark.asyncio
async def test_send_email_notification_success(session_notifier, email_credentials):
"""Test successful email notification"""
# Mock the smtplib.SMTP_SSL class
with patch('smtplib.SMTP_SSL') as mock_smtp:
# Set up the mock to return a context manager
mock_smtp_instance = MagicMock()
mock_smtp.return_value.__enter__.return_value = mock_smtp_instance
# Set up environment variable for SMTP server
with patch.dict(os.environ, {"SMTP_SERVER": "smtp.example.com"}):
# Call the method to test
session_notifier.send_email_notification("Test email")
# Verify SMTP methods were called
mock_smtp.assert_called_once_with("smtp.example.com", 465)
mock_smtp_instance.login.assert_called_once_with(
email_credentials["from"],
email_credentials["password"]
)
mock_smtp_instance.send_message.assert_called_once()
@pytest.mark.asyncio
async def test_send_email_notification_failure(session_notifier, email_credentials):
"""Test email notification failure"""
# Mock the smtplib.SMTP_SSL class to raise an exception
with patch('smtplib.SMTP_SSL', side_effect=Exception("SMTP error")):
# Set up environment variable for SMTP server
with patch.dict(os.environ, {"SMTP_SERVER": "smtp.example.com"}):
# Verify the method raises an exception
with pytest.raises(Exception):
session_notifier.send_email_notification("Test email")
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""
Script to demonstrate how to execute the book_session method from crossfit_booker.py
"""
import sys
import os
import logging
from src.auth import AuthHandler
from src.booker import Booker
from src.session_notifier import SessionNotifier
# Load environment variables
from dotenv import load_dotenv
load_dotenv()
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
def main():
# Check if a session ID was provided as an argument
if len(sys.argv) < 2:
print("Usage: python execute_book_session.py <session_id>")
sys.exit(1)
session_id = sys.argv[1]
# Initialize components
auth_handler = AuthHandler(
os.environ.get("CROSSFIT_USERNAME"),
os.environ.get("CROSSFIT_PASSWORD")
)
# Initialize notification system (minimal for single session booking)
email_credentials = {
"from": os.environ.get("EMAIL_FROM"),
"to": os.environ.get("EMAIL_TO"),
"password": os.environ.get("EMAIL_PASSWORD")
}
telegram_credentials = {
"token": os.environ.get("TELEGRAM_TOKEN"),
"chat_id": os.environ.get("TELEGRAM_CHAT_ID")
}
enable_email = os.environ.get("ENABLE_EMAIL_NOTIFICATIONS", "true").lower() in ("true", "1", "yes")
enable_telegram = os.environ.get("ENABLE_TELEGRAM_NOTIFICATIONS", "true").lower() in ("true", "1", "yes")
notifier = SessionNotifier(
email_credentials,
telegram_credentials,
enable_email=enable_email,
enable_telegram=enable_telegram
)
# Create an instance of Booker
booker = Booker(auth_handler, notifier)
# Login to authenticate
print("Attempting to authenticate...")
if not auth_handler.login():
print("Failed to authenticate. Please check your credentials and try again.")
sys.exit(1)
print("Authentication successful!")
# Book the session
print(f"Attempting to book session with ID: {session_id}")
success = booker.book_session(session_id)
if success:
print(f"Successfully booked session {session_id}")
else:
print(f"Failed to book session {session_id}")
if __name__ == "__main__":
try:
main()
except Exception as e:
print(f"An error occurred: {e}")
sys.exit(1)

View File

@@ -0,0 +1,9 @@
#!/bin/bash
# Test script to demonstrate how to use the execute_book_session.py script
# Sample session ID (this should be a valid session ID from the crossfit booking system)
SESSION_ID="19291768"
# Run the script with the sample session ID
echo "Attempting to book session with ID: $SESSION_ID"
python execute_book_session.py $SESSION_ID

View File

@@ -10,7 +10,7 @@ from dotenv import load_dotenv
import sys import sys
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from session_notifier import SessionNotifier from src.session_notifier import SessionNotifier
# Load environment variables from .env file # Load environment variables from .env file
load_dotenv() load_dotenv()