Compare commits
63 Commits
66b62d4034
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc5796d989 | ||
|
|
c073006592 | ||
|
|
ed7165632a | ||
|
|
d809dd6753 | ||
|
|
ea05c847ba | ||
|
|
f5e0bba298 | ||
|
|
9a35a57c7f | ||
|
|
cd5f0a54ac | ||
|
|
d1e5fc1003 | ||
|
|
7d03f5b40c | ||
|
|
795016a60c | ||
|
|
b6ea2a4ff1 | ||
|
|
5f89af44aa | ||
|
|
39d408d882 | ||
|
|
5bdde5fee1 | ||
|
|
8b8dd68b34 | ||
| 5aee4dbaf4 | |||
|
|
81adedba6f | ||
| 2a8299300c | |||
|
|
fa9d73a9a9 | ||
|
|
cfbb857cfb | ||
| 8d04f0075d | |||
|
|
4872b817b3 | ||
|
|
a7f9e6bacd | ||
|
|
8d882ad091 | ||
|
|
90230832ee | ||
|
|
944421c68b | ||
|
|
7161a11905 | ||
|
|
3b8a755a25 | ||
|
|
6c29fc0802 | ||
|
|
439c5f3d6f | ||
|
|
30eb9863a0 | ||
|
|
888728729f | ||
|
|
a6cb6cb7b6 | ||
|
|
d31976084a | ||
|
|
90923b0f1c | ||
|
|
d2b63ea807 | ||
|
|
7b4d66c779 | ||
|
|
4401adfebb | ||
|
|
2b99bc37de | ||
|
|
f975cb529f | ||
|
|
0baa5b6e3f | ||
|
|
b2f923a6c3 | ||
|
|
17cb728dd9 | ||
|
|
5e597c4d1a | ||
|
|
352fae2d25 | ||
|
|
3e33ae5132 | ||
|
|
cf4780d9d0 | ||
|
|
ef65069592 | ||
|
|
cacdd74184 | ||
| ef7f82bc76 | |||
|
|
fcd227e3ed | ||
|
|
ed65cd836f | ||
|
|
d6e9373321 | ||
| fa382f4c6b | |||
|
|
13f18b2854 | ||
| 0c4a7224d3 | |||
|
|
9b5dd8745b | ||
|
|
d71bfff10e | ||
| e4656eaf54 | |||
|
|
3a378c03a6 | ||
|
|
18cf2a0a18 | ||
|
|
16d66b74ea |
@@ -1,10 +1,14 @@
|
||||
# CrossFit booking credentials
|
||||
CROSSFIT_USERNAME=your_username
|
||||
CROSSFIT_PASSWORD=your_password
|
||||
TARGET_RESERVATION_TIME="21:01"
|
||||
BOOKING_WINDOW_END_DELTA_MINUTES="59"
|
||||
|
||||
|
||||
# Notification settings
|
||||
ENABLE_EMAIL_NOTIFICATIONS=true
|
||||
ENABLE_TELEGRAM_NOTIFICATIONS=true
|
||||
NOTIFY_IMPOSSIBLE_BOOKING=false
|
||||
|
||||
# Email notification credentials
|
||||
SMTP_SERVER=mail.infomaniak.com
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
.env
|
||||
preferred_sessions.json
|
||||
log/
|
||||
!log/.gitkeep
|
||||
|
||||
@@ -177,4 +178,4 @@ poetry.toml
|
||||
# LSP config files
|
||||
pyrightconfig.json
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/python
|
||||
# End of https://www.toptal.com/developers/gitignore/api/python
|
||||
|
||||
63
AGENTS.md
Normal file
63
AGENTS.md
Normal 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).
|
||||
@@ -5,6 +5,8 @@ FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
|
||||
# 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_PASSWORD=${CROSSFIT_PASSWORD}
|
||||
ENV EMAIL_FROM=${EMAIL_FROM}
|
||||
@@ -26,4 +28,4 @@ COPY . .
|
||||
RUN mkdir -p log
|
||||
|
||||
# Set the entry point to run the main script
|
||||
ENTRYPOINT ["python", "book_crossfit.py"]
|
||||
ENTRYPOINT ["python", "main.py"]
|
||||
|
||||
@@ -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"]
|
||||
54
README.md
54
README.md
@@ -12,12 +12,16 @@ This is a Python application for managing Crossfit bookings and notifications. T
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker
|
||||
- Docker Compose
|
||||
- Python 3.8+
|
||||
- Docker (optional)
|
||||
- Docker Compose (optional)
|
||||
|
||||
## Setup
|
||||
|
||||
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:
|
||||
|
||||
```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.
|
||||
|
||||
### Manual Setup
|
||||
|
||||
2. Install dependencies:
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. Run the application:
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
## 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.
|
||||
@@ -76,32 +94,46 @@ The application will automatically load the preferred sessions from this file. I
|
||||
|
||||
## 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
|
||||
- `docker-compose.yml`: Docker Compose service definition
|
||||
- `.env.example`: Example environment variables file
|
||||
- `.dockerignore`: Docker ignore file
|
||||
- `.gitignore`: Git ignore file
|
||||
- `book_crossfit.py`: Main application script
|
||||
- `crossfit_booker.py`: Crossfit booking script
|
||||
- `session_notifier.py`: Session notification script
|
||||
- `preferred_sessions.json`: Configuration file for preferred sessions
|
||||
- `preferred_sessions.json.example`: Example configuration file for preferred sessions
|
||||
- `requirements.txt`: Python dependencies
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
.
|
||||
├── main.py
|
||||
├── src/
|
||||
│ ├── auth.py
|
||||
│ ├── booker.py
|
||||
│ ├── session_manager.py
|
||||
│ ├── session_notifier.py
|
||||
│ └── session_config.py
|
||||
├── tools/
|
||||
├── scripts/
|
||||
├── test/
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── .env.example
|
||||
├── .dockerignore
|
||||
├── .gitignore
|
||||
├── book_crossfit.py
|
||||
├── crossfit_booker.py
|
||||
├── session_notifier.py
|
||||
├── preferred_sessions.json.example
|
||||
├── requirements.txt
|
||||
├── preferred_sessions.json
|
||||
└── log
|
||||
└── log/
|
||||
└── crossfit_booking.log
|
||||
```
|
||||
|
||||
|
||||
9
ascii.md
Normal file
9
ascii.md
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
▄▄▄ ▄▄▄▄▄▄ ▀
|
||||
▄▀ ▀ ▄ ▄▄ ▄▄▄ ▄▄▄ ▄▄▄ █ ▄▄▄ ▄ ▄
|
||||
█ █▀ ▀ █▀ ▀█ █ ▀ █ ▀ █▄▄▄▄▄ █ █▄█
|
||||
█ █ █ █ ▀▀▀▄ ▀▀▀▄ █ █ ▄█▄
|
||||
▀▄▄▄▀ █ ▀█▄█▀ ▀▄▄▄▀ ▀▄▄▄▀ █ ▄▄█▄▄ ▄▀ ▀▄
|
||||
|
||||
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import logging
|
||||
import traceback
|
||||
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
|
||||
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
|
||||
booker.run()
|
||||
logging.info("Script completed")
|
||||
@@ -1,556 +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")
|
||||
logging.debug(f"Session is bookable: {activity_name} 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
|
||||
|
||||
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')}"
|
||||
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')}"
|
||||
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')}"
|
||||
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}")
|
||||
|
||||
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
|
||||
self.run_booking_cycle(current_time)
|
||||
|
||||
# Run booking cycle at the target time for actual booking
|
||||
if current_time.strftime("%H:%M") == TARGET_RESERVATION_TIME:
|
||||
# Wait until the next booking window
|
||||
wait_until = current_time + timedelta(minutes=60)
|
||||
time.sleep((wait_until - 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)
|
||||
@@ -4,10 +4,13 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: crossfit-booker
|
||||
# container_name: crossfit-booker
|
||||
environment:
|
||||
- TZ=Europe/Paris
|
||||
- CROSSFIT_USERNAME=${CROSSFIT_USERNAME}
|
||||
- CROSSFIT_PASSWORD=${CROSSFIT_PASSWORD}
|
||||
- TARGET_RESERVATION_TIME=${TARGET_RESERVATION_TIME}
|
||||
- BOOKING_WINDOW_END_DELTA_MINUTES=${BOOKING_WINDOW_END_DELTA_MINUTES}
|
||||
- SMTP_SERVER=${SMTP_SERVER}
|
||||
- EMAIL_FROM=${EMAIL_FROM}
|
||||
- EMAIL_TO=${EMAIL_TO}
|
||||
@@ -15,5 +18,5 @@ services:
|
||||
- TELEGRAM_TOKEN=${TELEGRAM_TOKEN}
|
||||
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID}
|
||||
volumes:
|
||||
- ./log:/app/log
|
||||
restart: unless-stopped
|
||||
- ./:/app
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -23,21 +23,23 @@ curl 'https://sport.nubapp.com/api/v4/activities/getActivitiesCalendar.php' \
|
||||
-X POST \
|
||||
-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-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 'Referer: https://box.resawod.com/' \
|
||||
-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 'Origin: https://box.resawod.com' \
|
||||
-H 'Sec-GPC: 1' \
|
||||
-H 'Connection: keep-alive' \
|
||||
-H 'Referer: https://box.resawod.com/' \
|
||||
-H 'Sec-Fetch-Dest: empty' \
|
||||
-H 'Sec-Fetch-Mode: cors' \
|
||||
-H 'Sec-Fetch-Site: cross-site' \
|
||||
-H 'Priority: u=0' \
|
||||
-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' \
|
||||
-X POST \
|
||||
@@ -47,7 +49,7 @@ Book activity
|
||||
-H 'Accept-Encoding: gzip, deflate, br, zstd' \
|
||||
-H 'Referer: https://box.resawod.com/' \
|
||||
-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 'Origin: https://box.resawod.com' \
|
||||
-H 'Connection: keep-alive' \
|
||||
@@ -56,7 +58,7 @@ Book activity
|
||||
-H 'Sec-Fetch-Site: cross-site' \
|
||||
-H 'Priority: u=0' \
|
||||
-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'
|
||||
|
||||
|
||||
|
||||
188
documents/crossfit_old.txt
Normal file
188
documents/crossfit_old.txt
Normal file
@@ -0,0 +1,188 @@
|
||||
https://sport.nubapp.com/web/index.php?id_application=81560887#/bookings
|
||||
|
||||
|
||||
curl 'https://sport.nubapp.com/web/ajax/users/checkUser.php' \
|
||||
-X POST \
|
||||
-H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:139.0) Gecko/20100101 Firefox/139.0' \
|
||||
-H 'Accept: application/json, text/plain, */*' \
|
||||
-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 'Referer: https://sport.nubapp.com/web/index.php' \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
-H 'Origin: https://sport.nubapp.com' \
|
||||
-H 'Sec-GPC: 1' \
|
||||
-H 'Connection: keep-alive' \
|
||||
-H 'Cookie: applicationId=81560887; AWSALBTG=qZgepZq2iNqzI/cWNnAKBHv6qQNvEDQYbzdJs6cIbqhKfbfFM8GypnXrqH92HmKGpd7kbXgq8wyHjriS2RameqUTePW7wj70Ulvb0yrQC8hE2qq9sX/SDV0Jcv09jIUYiOmoe1P7YoqKve9EtJAJ0oIGXsvuR+ZTZfei2kkIssbE; AWSALBTGCORS=qZgepZq2iNqzI/cWNnAKBHv6qQNvEDQYbzdJs6cIbqhKfbfFM8GypnXrqH92HmKGpd7kbXgq8wyHjriS2RameqUTePW7wj70Ulvb0yrQC8hE2qq9sX/SDV0Jcv09jIUYiOmoe1P7YoqKve9EtJAJ0oIGXsvuR+ZTZfei2kkIssbE; PHPSESSID-FRONT=a8f7efe6df3a7bbaf6e2ab88f925df33' \
|
||||
-H 'Sec-Fetch-Dest: empty' \
|
||||
-H 'Sec-Fetch-Mode: cors' \
|
||||
-H 'Sec-Fetch-Site: same-origin' \
|
||||
-H 'Priority: u=0' \
|
||||
--data-raw 'username=Kevin8407^&password=9vx03OSE'
|
||||
|
||||
|
||||
|
||||
curl 'https://sport.nubapp.com/web/ajax/bookings/checkBookings.php' \
|
||||
-X POST \
|
||||
-H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:139.0) Gecko/20100101 Firefox/139.0' \
|
||||
-H 'Accept: application/json, text/plain, */*' \
|
||||
-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 'Referer: https://sport.nubapp.com/web/index.php' \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
-H 'Origin: https://sport.nubapp.com' \
|
||||
-H 'Sec-GPC: 1' \
|
||||
-H 'Connection: keep-alive' \
|
||||
-H 'Cookie: applicationId=81560887; AWSALBTG=qZgepZq2iNqzI/cWNnAKBHv6qQNvEDQYbzdJs6cIbqhKfbfFM8GypnXrqH92HmKGpd7kbXgq8wyHjriS2RameqUTePW7wj70Ulvb0yrQC8hE2qq9sX/SDV0Jcv09jIUYiOmoe1P7YoqKve9EtJAJ0oIGXsvuR+ZTZfei2kkIssbE; AWSALBTGCORS=qZgepZq2iNqzI/cWNnAKBHv6qQNvEDQYbzdJs6cIbqhKfbfFM8GypnXrqH92HmKGpd7kbXgq8wyHjriS2RameqUTePW7wj70Ulvb0yrQC8hE2qq9sX/SDV0Jcv09jIUYiOmoe1P7YoqKve9EtJAJ0oIGXsvuR+ZTZfei2kkIssbE; PHPSESSID-FRONT=a8f7efe6df3a7bbaf6e2ab88f925df33' \
|
||||
-H 'Sec-Fetch-Dest: empty' \
|
||||
-H 'Sec-Fetch-Mode: cors' \
|
||||
-H 'Sec-Fetch-Site: same-origin' \
|
||||
-H 'Priority: u=0' \
|
||||
--data-raw 'items%5Bactivities%5D%5B0%5D%5Bid_activity_calendar%5D=18980977^&items%5Bactivities%5D%5B0%5D%5Bunit_price%5D=0^&items%5Bactivities%5D%5B0%5D%5Bn_guests%5D=0^&items%5Bactivities%5D%5B0%5D%5Bid_resource%5D=false^&discount_code=false'
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
curl 'https://sport.nubapp.com/api/v4/users/checkUser.php' \
|
||||
-X POST \
|
||||
-H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:139.0) Gecko/20100101 Firefox/139.0' \
|
||||
-H 'Accept: application/json, text/plain, */*' \
|
||||
-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 'Referer: https://box.resawod.com/' \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
-H 'Nubapp-Origin: user_apps' \
|
||||
-H 'Origin: https://box.resawod.com' \
|
||||
-H 'Connection: keep-alive' \
|
||||
-H 'Sec-Fetch-Dest: empty' \
|
||||
-H 'Sec-Fetch-Mode: cors' \
|
||||
-H 'Sec-Fetch-Site: cross-site' \
|
||||
-H 'Priority: u=0' \
|
||||
-H 'TE: trailers' \
|
||||
--data-raw 'app_version=5.09.21^&device_type=3^&username=Kevin8407^&password=1dd67168'
|
||||
|
||||
|
||||
|
||||
curl 'https://sport.nubapp.com/api/v4/login' \
|
||||
-X POST \
|
||||
-H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:139.0) Gecko/20100101 Firefox/139.0' \
|
||||
-H 'Accept: application/json, text/plain, */*' \
|
||||
-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 'Referer: https://box.resawod.com/' \
|
||||
-H 'Nubapp-Origin: user_apps' \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded;charset=UTF-8' \
|
||||
-H 'Origin: https://box.resawod.com' \
|
||||
-H 'Connection: keep-alive' \
|
||||
-H 'Sec-Fetch-Dest: empty' \
|
||||
-H 'Sec-Fetch-Mode: cors' \
|
||||
-H 'Sec-Fetch-Site: cross-site' \
|
||||
-H 'TE: trailers' \
|
||||
--data-raw 'device_type=3^&username=Kevin8407^&password=1dd67168'
|
||||
|
||||
|
||||
|
||||
curl 'https://sport.nubapp.com/api/v4/users/3191429/id_type_of_user' \
|
||||
-X PUT \
|
||||
-H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:139.0) Gecko/20100101 Firefox/139.0' \
|
||||
-H 'Accept: application/json, text/plain, */*' \
|
||||
-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 'Referer: https://box.resawod.com/' \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
-H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE3NTEyOTM0NTYsImV4cCI6MTc2NzEwODI1Niwicm9sZXMiOlsiUk9MRV9VU0VSIl0sImlkX3VzZXIiOjMxOTE0MjksImlkX2FwcGxpY2F0aW9uIjo4MTU2MDg4NywicGFzc3dvcmRfaGFzaCI6ImMwZmFiNGVlNDFjMGFkZGU5ODU1OTAxZGIxNjQ0YmEwZjk2ZDZhMGEiLCJhdXRoZW50aWNhdGlvbl90eXBlIjoiYXBpIiwidXNlcm5hbWUiOiJLZXZpbjg0MDcifQ.axAfZnzewEinSjnspgpO66tYJsbn1vAY6Dw-FivNjBZ4ylNWHQ1uWRbDAKYIhQMbQqcZ2_6HH_sa2YheIUA0fO2L276Qr9KZetGs_qwfn8AOkm63ALUQVKqwc-B6RGtmlE83m7gC5SgcPl6oNSoqV4k8lQ156V0Xr7E9fjcCNgjCJ8wFcHUp-XeVy54ixZHVxiLxFu1HHrlw0sisTwiQ7I3WAREn7XP7EwqFun9wwx8kyG1oggjShOecgiMX-68NIeJGwTcEBt2ssyKSRkibILw2p5BmABSmuYe_Hue4kYkyqvncBUJqMbpj8yeBKQUrT4undWPuYuWDftClClkM4A' \
|
||||
-H 'Nubapp-Origin: user_apps' \
|
||||
-H 'Origin: https://box.resawod.com' \
|
||||
-H 'Connection: keep-alive' \
|
||||
-H 'Sec-Fetch-Dest: empty' \
|
||||
-H 'Sec-Fetch-Mode: cors' \
|
||||
-H 'Sec-Fetch-Site: cross-site' \
|
||||
-H 'Priority: u=0' \
|
||||
-H 'TE: trailers' \
|
||||
--data-raw 'app_version=5.09.21&id_type_of_user=1'
|
||||
|
||||
|
||||
|
||||
curl 'https://sport.nubapp.com/api/v4/users/changePassword.php' \
|
||||
-X POST \
|
||||
-H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:139.0) Gecko/20100101 Firefox/139.0' \
|
||||
-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-Encoding: gzip, deflate, br, zstd' \
|
||||
-H 'Referer: https://box.resawod.com/' \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
-H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE3NTEyOTM0NTYsImV4cCI6MTc2NzEwODI1Niwicm9sZXMiOlsiUk9MRV9VU0VSIl0sImlkX3VzZXIiOjMxOTE0MjksImlkX2FwcGxpY2F0aW9uIjo4MTU2MDg4NywicGFzc3dvcmRfaGFzaCI6ImMwZmFiNGVlNDFjMGFkZGU5ODU1OTAxZGIxNjQ0YmEwZjk2ZDZhMGEiLCJhdXRoZW50aWNhdGlvbl90eXBlIjoiYXBpIiwidXNlcm5hbWUiOiJLZXZpbjg0MDcifQ.axAfZnzewEinSjnspgpO66tYJsbn1vAY6Dw-FivNjBZ4ylNWHQ1uWRbDAKYIhQMbQqcZ2_6HH_sa2YheIUA0fO2L276Qr9KZetGs_qwfn8AOkm63ALUQVKqwc-B6RGtmlE83m7gC5SgcPl6oNSoqV4k8lQ156V0Xr7E9fjcCNgjCJ8wFcHUp-XeVy54ixZHVxiLxFu1HHrlw0sisTwiQ7I3WAREn7XP7EwqFun9wwx8kyG1oggjShOecgiMX-68NIeJGwTcEBt2ssyKSRkibILw2p5BmABSmuYe_Hue4kYkyqvncBUJqMbpj8yeBKQUrT4undWPuYuWDftClClkM4A' \
|
||||
-H 'Nubapp-Origin: user_apps' \
|
||||
-H 'Origin: https://box.resawod.com' \
|
||||
-H 'Connection: keep-alive' \
|
||||
-H 'Sec-Fetch-Dest: empty' \
|
||||
-H 'Sec-Fetch-Mode: cors' \
|
||||
-H 'Sec-Fetch-Site: cross-site' \
|
||||
-H 'Priority: u=0' \
|
||||
-H 'TE: trailers' \
|
||||
--data-raw 'app_version=5.09.21^&id_application=81560887^&id_user=3191429^&password=9vx03OSE^&old_password=1dd67168'
|
||||
|
||||
|
||||
|
||||
curl 'https://sport.nubapp.com/api/v4/users/getUsers.php' \
|
||||
-X POST \
|
||||
-H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:139.0) Gecko/20100101 Firefox/139.0' \
|
||||
-H 'Accept: application/json, text/plain, */*' \
|
||||
-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 'Referer: https://box.resawod.com/' \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
-H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE3NTEyOTM0NTYsImV4cCI6MTc2NzEwODI1Niwicm9sZXMiOlsiUk9MRV9VU0VSIl0sImlkX3VzZXIiOjMxOTE0MjksImlkX2FwcGxpY2F0aW9uIjo4MTU2MDg4NywicGFzc3dvcmRfaGFzaCI6ImMwZmFiNGVlNDFjMGFkZGU5ODU1OTAxZGIxNjQ0YmEwZjk2ZDZhMGEiLCJhdXRoZW50aWNhdGlvbl90eXBlIjoiYXBpIiwidXNlcm5hbWUiOiJLZXZpbjg0MDcifQ.axAfZnzewEinSjnspgpO66tYJsbn1vAY6Dw-FivNjBZ4ylNWHQ1uWRbDAKYIhQMbQqcZ2_6HH_sa2YheIUA0fO2L276Qr9KZetGs_qwfn8AOkm63ALUQVKqwc-B6RGtmlE83m7gC5SgcPl6oNSoqV4k8lQ156V0Xr7E9fjcCNgjCJ8wFcHUp-XeVy54ixZHVxiLxFu1HHrlw0sisTwiQ7I3WAREn7XP7EwqFun9wwx8kyG1oggjShOecgiMX-68NIeJGwTcEBt2ssyKSRkibILw2p5BmABSmuYe_Hue4kYkyqvncBUJqMbpj8yeBKQUrT4undWPuYuWDftClClkM4A' \
|
||||
-H 'Nubapp-Origin: user_apps' \
|
||||
-H 'Origin: https://box.resawod.com' \
|
||||
-H 'Connection: keep-alive' \
|
||||
-H 'Sec-Fetch-Dest: empty' \
|
||||
-H 'Sec-Fetch-Mode: cors' \
|
||||
-H 'Sec-Fetch-Site: cross-site' \
|
||||
-H 'Priority: u=0' \
|
||||
-H 'TE: trailers' \
|
||||
--data-raw 'app_version=5.09.21'
|
||||
|
||||
|
||||
|
||||
# Rejoindre la liste d'attente
|
||||
|
||||
|
||||
curl 'https://sport.nubapp.com/api/v4/activities/bookWaitingActivityCalendar.php' \
|
||||
-X POST \
|
||||
-H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:139.0) Gecko/20100101 Firefox/139.0' \
|
||||
-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-Encoding: gzip, deflate, br, zstd' \
|
||||
-H 'Referer: https://box.resawod.com/' \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
-H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE3NTEyOTQxMDYsImV4cCI6MTc2NzEwODkwNiwicm9sZXMiOlsiUk9MRV9VU0VSIl0sImlkX3VzZXIiOjMxOTE0MjksImlkX2FwcGxpY2F0aW9uIjo4MTU2MDg4NywicGFzc3dvcmRfaGFzaCI6ImMwZmFiNGVlNDFjMGFkZGU5ODU1OTAxZGIxNjQ0YmEwZjk2ZDZhMGEiLCJhdXRoZW50aWNhdGlvbl90eXBlIjoiYXBpIiwidXNlcm5hbWUiOiJLZXZpbjg0MDcifQ.O5XhReqJkqOq1XLv-D_1nJE4hnPX7cyJXHEb_moJ2wV2JXVjbJ5yUf6Jvl0s4xVsN-oIcrvjK7ZmH82cWa2TevIpsaeqARjPpsxDbmWEQO5Cgo9ZHk_btEHJMsl0hMBEQ3uKDcALSxZQLPzDPFm9hdsObng3eKl96oCCZU-UqwkjDRPJPqAIIu3bNbf2okbB0KFMoYClWCvVMb9jV5MFY-OCfV8Eap140kIuZSTRmu9VfBSndcC5L5AN0tmDtSIwJRat3YWZQUbsDTkBSv1h1L72geJhw_yBGUUg7FJ06WJ2QbbuONmZmBcmTiololJCdOZ_3I-usDaL_TdoGnXapQ' \
|
||||
-H 'Nubapp-Origin: user_apps' \
|
||||
-H 'Origin: https://box.resawod.com' \
|
||||
-H 'Connection: keep-alive' \
|
||||
-H 'Sec-Fetch-Dest: empty' \
|
||||
-H 'Sec-Fetch-Mode: cors' \
|
||||
-H 'Sec-Fetch-Site: cross-site' \
|
||||
-H 'Priority: u=0' \
|
||||
-H 'TE: trailers' \
|
||||
--data-raw 'app_version=5.09.21&id_application=81560887&id_activity_calendar=19113851&id_user=3191429&action_by=3191429'
|
||||
|
||||
|
||||
|
||||
curl 'https://sport.nubapp.com/web/ajax/users/checkUser.php' \
|
||||
-X POST \
|
||||
-H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:139.0) Gecko/20100101 Firefox/139.0' \
|
||||
-H 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' \
|
||||
-H 'Accept-Language: en-GB,en;q=0.8,fr-FR;q=0.5,fr;q=0.3' \
|
||||
-H 'Accept-Encoding: gzip, deflate, br, zstd' \
|
||||
-H 'Connection: keep-alive' \
|
||||
-H 'Cookie: applicationId=81560887; AWSALBTG=tdjfBXoHwnzTmQh0eDNuYHrW68ZQDdXa582yNdxiDUr+GEh2R+4Lx42mY6vO2d+fpQ5hdkoyamXmR6wcwUOALAarNyBKGRID8eW3/63w9f7fF1qMJB4Yq6jp/yv02dkzCw9W8lsDFnJAQ4XJOsq8oGyGVTGdS1Z5psXTHrO1qrfV; AWSALBTGCORS=tdjfBXoHwnzTmQh0eDNuYHrW68ZQDdXa582yNdxiDUr+GEh2R+4Lx42mY6vO2d+fpQ5hdkoyamXmR6wcwUOALAarNyBKGRID8eW3/63w9f7fF1qMJB4Yq6jp/yv02dkzCw9W8lsDFnJAQ4XJOsq8oGyGVTGdS1Z5psXTHrO1qrfV; PHPSESSID-FRONT=66ef232a6bd57e8f75128347e2b1e144' \
|
||||
-H 'Upgrade-Insecure-Requests: 1' \
|
||||
-H 'Sec-Fetch-Dest: document' \
|
||||
-H 'Sec-Fetch-Mode: navigate' \
|
||||
-H 'Sec-Fetch-Site: none' \
|
||||
-H 'Sec-Fetch-User: ?1' \
|
||||
-H 'Priority: u=0, i' \
|
||||
--data-raw 'username=Kevin8407&password=9vx03OSE'
|
||||
|
||||
72
documents/waiting_list.json
Normal file
72
documents/waiting_list.json
Normal file
@@ -0,0 +1,72 @@
|
||||
curl 'https://sport.nubapp.com/api/v4/activities/activity-calendar/19291431/basicInfo?app_version=5.09.21^&id_application=81560887' \
|
||||
-H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0' \
|
||||
-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-Encoding: gzip, deflate, br, zstd' \
|
||||
-H 'Referer: https://box.resawod.com/' \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
-H 'Nubapp-Origin: user_apps' \
|
||||
-H 'Origin: https://box.resawod.com' \
|
||||
-H 'Sec-GPC: 1' \
|
||||
-H 'Connection: keep-alive' \
|
||||
-H 'Sec-Fetch-Dest: empty' \
|
||||
-H 'Sec-Fetch-Mode: cors' \
|
||||
-H 'Sec-Fetch-Site: cross-site' \
|
||||
-H 'Priority: u=0' \
|
||||
-H 'TE: trailers'
|
||||
|
||||
{
|
||||
"message": "All right! Here is your data.",
|
||||
"data": {
|
||||
"activity_calendar": {
|
||||
"id_activity_calendar": 19291431,
|
||||
"name_activity": "CONDITIONING LOUVRE 3",
|
||||
"description": "\\u003Cp\\u003EWod Condi, de la simplicit\\u00e9 mais beaucoup d\\u0027intensit\\u00e9... des erg mais pas seulement, des mouvements de CrossFit qui vous permettrons d\\u0027am\\u00e9liorer vos capacit\\u00e9s a\\u00e9robies\\u003C\\/p\\u003E",
|
||||
"id_activity": 43932,
|
||||
"id_category_activity": 677,
|
||||
"start_timestamp": "2025-07-23 18:30:00",
|
||||
"end_timestamp": "2025-07-23 19:15:00",
|
||||
"n_inscribed": 12,
|
||||
"n_capacity": 12,
|
||||
"n_waiting_list": 3,
|
||||
"n_capacity_waiting_list": 8,
|
||||
"id_trainer": null,
|
||||
"is_course": false,
|
||||
"cancelled": false,
|
||||
"guest_info": {
|
||||
"can_join": true,
|
||||
"available_booking_options": [],
|
||||
"errors": 0,
|
||||
"errors_by_memberships": [],
|
||||
"join_as_normal_user": false,
|
||||
"vouchers": [],
|
||||
"voucher_autopay": false,
|
||||
"unableToBookUntilDate": null,
|
||||
"unableToBookUntilTime": null,
|
||||
"best_booking_option": {
|
||||
"type": 2,
|
||||
"price": 0,
|
||||
"membershipId": null,
|
||||
"discountId": null,
|
||||
"discountValue": null
|
||||
},
|
||||
"price": 0,
|
||||
"price_befdis": 0,
|
||||
"user_attending": false,
|
||||
"user_waiting": false
|
||||
},
|
||||
"facilities": [],
|
||||
"image": null,
|
||||
"color": "#a74cd6",
|
||||
"max_guests": 0,
|
||||
"guest_prices": {
|
||||
"1": 0,
|
||||
"2": 0,
|
||||
"3": 0
|
||||
},
|
||||
"id_language": null
|
||||
}
|
||||
},
|
||||
"success": true,
|
||||
"status": 200
|
||||
}
|
||||
77
main.py
Executable file
77
main.py
Executable 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()
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
22
preferred_sessions.json.example
Normal file
22
preferred_sessions.json.example
Normal 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
2
pytest.ini
Normal file
@@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
@@ -6,6 +6,12 @@ h11==0.16.0
|
||||
httpcore==1.0.9
|
||||
httpx==0.28.1
|
||||
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-dotenv==1.1.1
|
||||
python-telegram-bot==22.2
|
||||
|
||||
5
restart.sh
Executable file
5
restart.sh
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
docker compose down
|
||||
docker rmi crossfit-crossfit-booker
|
||||
docker compose up -d
|
||||
80
scripts/execute_book_session.py
Executable file
80
scripts/execute_book_session.py
Executable 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
9
scripts/test_book_session.sh
Executable 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
14
setup.py
Normal 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
16
src/__init__.py
Normal 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
121
src/auth.py
Normal 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
70
src/book_crossfit.py
Executable 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
309
src/booker.py
Normal 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)
|
||||
@@ -36,20 +36,19 @@ class SessionConfig:
|
||||
preferred_sessions.append((day_of_week, start_time, session_name_contains))
|
||||
|
||||
except FileNotFoundError:
|
||||
# Log a warning if the file is not found
|
||||
logging.warning(f"Configuration file '{config_file}' not found. Falling back to default settings.")
|
||||
# Log error and exit if the file is not found
|
||||
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:
|
||||
# Log a warning if the file is not a valid JSON
|
||||
logging.warning(f"Failed to decode JSON from file '{config_file}'. Falling back to default settings.")
|
||||
# Log error and exit if the file is not a valid JSON
|
||||
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:
|
||||
preferred_sessions = [
|
||||
(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 []
|
||||
|
||||
return preferred_sessions
|
||||
|
||||
314
src/session_manager.py
Normal file
314
src/session_manager.py
Normal 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}")
|
||||
@@ -1,8 +1,10 @@
|
||||
import smtplib
|
||||
import os
|
||||
import logging
|
||||
import asyncio
|
||||
from email.message import EmailMessage
|
||||
from telegram import Bot
|
||||
from telegram.error import TimedOut, NetworkError
|
||||
|
||||
|
||||
class SessionNotifier:
|
||||
@@ -34,6 +36,12 @@ class SessionNotifier:
|
||||
self.enable_email = enable_email
|
||||
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):
|
||||
"""
|
||||
Send an email notification with the given message.
|
||||
@@ -72,17 +80,34 @@ class SessionNotifier:
|
||||
logging.error(f"Failed to send email: {str(e)}")
|
||||
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.
|
||||
|
||||
Args:
|
||||
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
|
||||
bot = Bot(token=self.telegram_credentials["token"])
|
||||
# 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)
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
# 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)
|
||||
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):
|
||||
"""
|
||||
@@ -92,8 +117,8 @@ class SessionNotifier:
|
||||
session_details (str): Details about the booked session
|
||||
"""
|
||||
# Create messages for both email and Telegram
|
||||
email_message = f"Session booked: {session_details}"
|
||||
telegram_message = f"Session booked: {session_details}"
|
||||
email_message = f"Session booked for {self.crossfit_username}: {session_details}"
|
||||
telegram_message = f"Session booked for {self.crossfit_username}: {session_details}"
|
||||
|
||||
# Send notifications through enabled channels
|
||||
if self.enable_email:
|
||||
@@ -111,8 +136,39 @@ class SessionNotifier:
|
||||
days_until (int): Number of days until the session
|
||||
"""
|
||||
# Create messages for both email and Telegram
|
||||
email_message = f"Session available soon: {session_details} (in {days_until} days)"
|
||||
telegram_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 for {self.crossfit_username}: {session_details} (in {days_until} days)"
|
||||
|
||||
# Send notifications through enabled channels
|
||||
if self.enable_email:
|
||||
self.send_email_notification(email_message)
|
||||
|
||||
if self.enable_telegram:
|
||||
await self.send_telegram_notification(telegram_message)
|
||||
|
||||
async def notify_impossible_booking(self, session_details, notify_if_impossible=None):
|
||||
"""
|
||||
Notify about an impossible session booking via email and Telegram.
|
||||
|
||||
Args:
|
||||
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
|
||||
email_message = f"Failed to book session for {self.crossfit_username}: {session_details}"
|
||||
telegram_message = f"Failed to book session for {self.crossfit_username}: {session_details}"
|
||||
|
||||
# Send notifications through enabled channels
|
||||
if self.enable_email:
|
||||
135
test/test_auth.py
Normal file
135
test/test_auth.py
Normal 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"])
|
||||
165
test/test_crossfit_booker_auth.py
Normal file
165
test/test_crossfit_booker_auth.py
Normal 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"])
|
||||
262
test/test_crossfit_booker_sessions.py
Normal file
262
test/test_crossfit_booker_sessions.py
Normal 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
169
test/test_session_config.py
Normal 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"])
|
||||
186
test/test_session_notifier.py
Normal file
186
test/test_session_notifier.py
Normal 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"])
|
||||
80
tools/execute_book_session.py
Normal file
80
tools/execute_book_session.py
Normal 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
tools/execute_book_session.sh
Normal file
9
tools/execute_book_session.sh
Normal 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
|
||||
@@ -8,10 +8,9 @@ import os
|
||||
import logging
|
||||
from dotenv import load_dotenv
|
||||
import sys
|
||||
import os
|
||||
|
||||
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_dotenv()
|
||||
|
||||
Reference in New Issue
Block a user