Compare commits

...

22 Commits

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 14:13:39 +02:00
kbe
b6ea2a4ff1 refactor: Remove CrossfitBooker class and simplify booking system 2025-10-01 17:54:43 +02:00
kbe
5f89af44aa Now include session ID in booking message 2025-08-30 15:08:34 +02:00
kbe
39d408d882 fix(booker): Recalculate today target time each day
The previous script was calculating target booking time at start. 
So when it was 20:00, it was not working correctly after the first day.
2025-08-30 13:14:51 +02:00
kbe
5bdde5fee1 feat: Add QWEN agent.md for AI use 2025-08-30 12:56:12 +02:00
kbe
8b8dd68b34 No more logging outside reservation window 2025-08-16 08:17:45 +02:00
5aee4dbaf4 Merge pull request 'fix: main script on docker launch' (#11) from develop into main
Reviewed-on: #11
2025-08-12 00:10:50 +00:00
kbe
81adedba6f fix: main script on docker launch 2025-08-12 02:10:24 +02:00
2a8299300c Merge pull request 'Improve error handling and logging' (#10) from develop into main
Reviewed-on: #10
2025-08-12 00:03:11 +00:00
kbe
fa9d73a9a9 feat: enhance booking process with improved error handling and logging
Added logging to display message when outside booking window in booker.py
Added error handling for asyncio.run in crossfit_booker.py
Added logging for errors during booking process
2025-08-12 02:02:20 +02:00
kbe
cfbb857cfb chore: Revised code linter 2025-08-12 01:50:33 +02:00
8d04f0075d Merge pull request 'Refactor code and split into multiple files' (#9) from develop into main
Reviewed-on: #9
2025-08-11 23:44:32 +00:00
30 changed files with 456 additions and 1085 deletions

3
.gitignore vendored
View File

@@ -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
View File

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

View File

@@ -28,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"]

View File

@@ -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
View File

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

View File

@@ -4,7 +4,7 @@ services:
build:
context: .
dockerfile: Dockerfile
container_name: crossfit-booker
# container_name: crossfit-booker
environment:
- TZ=Europe/Paris
- CROSSFIT_USERNAME=${CROSSFIT_USERNAME}
@@ -18,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

View File

@@ -1,46 +0,0 @@
#!/usr/bin/env python3
"""
Script to demonstrate how to execute the book_session method from crossfit_booker.py
"""
import os
import sys
import logging
from crossfit_booker import CrossFitBooker
# 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]
# Create an instance of CrossFitBooker
booker = CrossFitBooker()
# Login to authenticate
print("Attempting to authenticate...")
if not booker.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)

58
main.py
View File

@@ -4,13 +4,19 @@ Main entry point for the CrossFit Booker application.
This script initializes the CrossFitBooker and starts the booking process.
"""
import asyncio
import logging
from src.crossfit_booker import CrossFitBooker
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 CrossFitBooker and start the booking process.
Main function to initialize the Booker and start the booking process.
"""
# Set up logging
logging.basicConfig(
@@ -21,11 +27,51 @@ def main():
]
)
# Initialize the CrossFitBooker
booker = CrossFitBooker()
# 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
booker.run()
import asyncio
asyncio.run(booker.run())
if __name__ == "__main__":
main()

View File

@@ -1,357 +0,0 @@
# Native modules
import logging, traceback, os, time
from datetime import datetime, timedelta, date
# Third-party modules
import requests, pytz
from dateutil.parser import parse
from dotenv import load_dotenv
from urllib.parse import urlencode
from typing import List, Dict, Optional, Any, Tuple
from session_notifier import SessionNotifier
from session_config import PREFERRED_SESSIONS
load_dotenv()
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"
TIMEZONE = "Europe/Paris"
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_MAX = 3
RETRY_BACKOFF = 1
APP_VERSION = "5.09.21"
class CrossFitBooker:
def __init__(self) -> None:
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)
self.mandatory_params: Dict[str, str] = {
"app_version": APP_VERSION, "device_type": DEVICE_TYPE,
"id_application": APPLICATION_ID, "id_category_activity": CATEGORY_ID
}
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")
self.notifier = SessionNotifier(email_credentials, telegram_credentials, enable_email=enable_email, enable_telegram=enable_telegram)
def _auth_headers(self) -> Dict[str, str]:
h = self.base_headers.copy()
if self.auth_token:
h["Authorization"] = f"Bearer {self.auth_token}"
return h
# Public method expected by tests
def get_auth_headers(self) -> Dict[str, str]:
"""
Return headers with Authorization when token is present.
This wraps _auth_headers() to satisfy tests expecting a public method.
"""
return self._auth_headers()
def _post(self, url: str, data: Dict[str, Any], headers: Optional[Dict[str, str]] = None, expect_json: bool = True) -> Optional[Any]:
for retry in range(RETRY_MAX):
try:
resp = self.session.post(url, headers=(headers or self._auth_headers()), data=urlencode(data), timeout=10)
sc = resp.status_code
if sc == 200:
return resp.json() if expect_json else resp
if sc == 401:
logging.error("401 Unauthorized")
return None
# Guard sc to ensure it's an int for comparison (fix tests using Mock without status_code int semantics)
if isinstance(sc, int) and 500 <= sc < 600:
logging.error(f"Server error {sc}")
raise requests.exceptions.ConnectionError(f"Server error {sc}")
logging.error(f"HTTP {sc}: {getattr(resp, 'text', '')[:100]}")
return None
except requests.exceptions.RequestException as e:
if retry == RETRY_MAX - 1:
logging.error(f"Final retry failed: {e}")
raise
wt = RETRY_BACKOFF * (2 ** retry)
logging.warning(f"Request failed ({retry+1}/{RETRY_MAX}): {e}. Retrying in {wt}s...")
time.sleep(wt)
return None
def _parse_local(self, ts: str) -> datetime:
dt = parse(ts)
return dt if dt.tzinfo else pytz.timezone(TIMEZONE).localize(dt)
def _fmt_session(self, s: Dict[str, Any], dt: Optional[datetime] = None) -> str:
dt = dt or self._parse_local(s["start_timestamp"])
return f"{s['id_activity_calendar']} {s['name_activity']} at {dt.strftime('%Y-%m-%d %H:%M')}"
def login(self) -> bool:
try:
# Directly use requests to align with tests that mock requests.Session.post
a = {"app_version": APP_VERSION, "device_type": DEVICE_TYPE, "username": USERNAME, "password": PASSWORD}
resp1 = self.session.post(
"https://sport.nubapp.com/api/v4/users/checkUser.php",
headers={"Content-Type": "application/x-www-form-urlencoded"},
data=urlencode(a)
)
if not getattr(resp1, "ok", False):
logging.error("First login step failed")
return False
try:
r1 = resp1.json()
self.user_id = str(r1["data"]["user"]["id_user"])
except Exception as e:
logging.error(f"Error during login: {e} - Response: {getattr(resp1, 'text', '')}")
return False
resp2 = 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 getattr(resp2, "ok", False):
try:
r2 = resp2.json()
self.auth_token = r2.get("token")
except Exception as e:
logging.error(f"Error during login: {e} - Response: {getattr(resp2, 'text', '')}")
return False
if self.auth_token and self.user_id:
logging.info("Successfully logged in")
return True
logging.error("Login failed")
return False
except requests.exceptions.RequestException as e:
logging.error(f"Request error during login: {e}")
return False
except Exception as e:
logging.error(f"Unexpected error during login: {e}")
return False
def get_available_sessions(self, start_date: date, end_date: date) -> Optional[Dict[str, Any]]:
if not self.auth_token or not self.user_id:
logging.error("Authentication required - missing token or user ID")
return None
data = {
**self.mandatory_params,
"id_user": self.user_id,
"start_timestamp": start_date.strftime("%d-%m-%Y"),
"end_timestamp": end_date.strftime("%d-%m-%Y"),
}
logging.debug(f"[get_available_sessions] URL=https://sport.nubapp.com/api/v4/activities/getActivitiesCalendar.php "
f"method=POST content_type=application/x-www-form-urlencoded "
f"keys={list(data.keys())} id_user_present={bool(self.user_id)}")
r = self._post("https://sport.nubapp.com/api/v4/activities/getActivitiesCalendar.php", data)
# Display available sessions in debug console
if r is not None:
success = r.get("success", False) if isinstance(r, dict) else None
count = 0
try:
if isinstance(r, dict):
activities = r.get("data", {}).get("activities_calendar", [])
count = len(activities) if isinstance(activities, list) else 0
except Exception:
pass
logging.debug(f"[get_available_sessions] success={success} activities_count={count}")
# Log concise session summary to aid debugging
if success and count:
for s in r.get("data", {}).get("activities_calendar", [])[:50]:
try:
summary = self._fmt_session(s)
except Exception:
summary = f"{s.get('id_activity_calendar')} {s.get('name_activity')} at {s.get('start_timestamp')}"
logging.debug(f"[session] {summary}")
else:
logging.debug(f"[get_available_sessions] raw_response_preview={str(r)[:500]}")
else:
logging.debug("[get_available_sessions] No response (None) from API")
return r
async def book_session(self, session_id: str) -> bool:
# Debug payload composition (without secrets)
safe_user_id = str(self.user_id) if self.user_id else None
debug_payload = {
**self.mandatory_params,
"id_activity_calendar": session_id,
"id_user": safe_user_id,
"action_by": safe_user_id,
"n_guests": "0",
"booked_on": "1",
"device_type": self.mandatory_params.get("device_type"),
"token_present": bool(self.auth_token),
}
logging.debug(f"[book_session] URL=https://sport.nubapp.com/api/v4/activities/bookActivityCalendar.php "
f"method=POST content_type=application/x-www-form-urlencoded "
f"keys={list(debug_payload.keys())} "
f"id_activity_calendar_present={bool(session_id)} "
f"user_id_present={bool(safe_user_id)} token_present={debug_payload['token_present']}")
data = {
**self.mandatory_params,
"id_activity_calendar": session_id,
"id_user": self.user_id,
"action_by": self.user_id,
"n_guests": "0",
"booked_on": "1",
"device_type": self.mandatory_params["device_type"],
"token": self.auth_token
}
r = self._post("https://sport.nubapp.com/api/v4/activities/bookActivityCalendar.php", data)
if r is None:
logging.error("Booking call failed")
return False
# Check booking status and notify accordingly
if r.get("success", False):
logging.info(f"Successfully booked session {session_id}")
details = f"{session_id} at {datetime.now().strftime('%Y-%m-%d %H:%M')}"
await self.notifier.notify_session_booking(details)
return True
# Handle failure case with error message
error_message = r.get("message", "Unknown error")
error_code = r.get("error", "unknown")
logging.error(f"Booking failed: {error_message} (error code: {error_code})")
details = f"{session_id} at {datetime.now().strftime('%Y-%m-%d %H:%M')}"
await self.notifier.notify_impossible_booking(details)
return False
def get_booked_sessions(self) -> List[Dict[str, Any]]:
data = {**self.mandatory_params, "id_user": self.user_id, "action_by": self.user_id}
r = self._post("https://sport.nubapp.com/api/v4/activities/getBookedActivities.php", data)
if r and r.get("success", False):
return r.get("data", [])
logging.error(f"Failed to retrieve booked sessions: {r}" if r is not None else "Call failed")
return []
def is_session_bookable(self, session: Dict[str, Any], current_time: datetime) -> bool:
ui = session.get("user_info", {})
if ui.get("can_join", False): return True
d, t = ui.get("unableToBookUntilDate", ""), ui.get("unableToBookUntilTime", "")
if d and t:
try:
bd = pytz.timezone(TIMEZONE).localize(datetime.strftime(f"{d} {t}", "%d-%m-%Y %H:%M"))
if current_time >= bd: return True
except ValueError: pass
return False
def matches_preferred_session(self, session: Dict[str, Any], current_time: datetime) -> bool:
try:
st = self._parse_local(session["start_timestamp"])
dow, hhmm = st.weekday(), st.strftime("%H:%M")
name = session.get("name_activity", "").upper()
for pd, pt, pn in PREFERRED_SESSIONS:
if dow == pd and hhmm == pt and pn in name: return True
return False
except Exception as e:
logging.error(f"Failed to check session: {e} - Session: {session}")
return False
async def run_booking_cycle(self, current_time: datetime) -> None:
start_date, end_date = current_time.date(), current_time.date() + timedelta(days=2)
sessions_data = 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", [])
sessions_to_book: List[Tuple[str, Dict[str, Any]]] = []
upcoming_sessions: List[Dict[str, Any]] = []
found_preferred_sessions: List[Dict[str, Any]] = []
# Debug: list all preferred sessions detected (bookable or not)
preferred_debug: List[str] = []
for s in activities:
st = self._parse_local(s["start_timestamp"])
days_diff = (st.date() - current_time.date()).days
if not (0 <= days_diff <= 2):
continue
is_preferred = self.matches_preferred_session(s, current_time)
if is_preferred:
# Collect concise summaries to debug output
try:
preferred_debug.append(self._fmt_session(s, st))
except Exception:
preferred_debug.append(f"{s.get('id_activity_calendar')} {s.get('name_activity')} at {s.get('start_timestamp')}")
if self.is_session_bookable(s, current_time):
if is_preferred:
sessions_to_book.append(("Preferred", s))
found_preferred_sessions.append(s)
else:
if is_preferred:
found_preferred_sessions.append(s)
if days_diff == 1:
upcoming_sessions.append(s)
# Emit debug of preferred sessions
if preferred_debug:
logging.debug("[preferred_sessions] " + " | ".join(preferred_debug[:50]))
else:
logging.debug("[preferred_sessions] none found in window")
if not sessions_to_book and not upcoming_sessions:
logging.info("No matching sessions found to book")
return
for s in found_preferred_sessions:
details = self._fmt_session(s)
await self.notifier.notify_session_booking(details)
logging.info(f"Notified about found preferred session: {details}")
for s in upcoming_sessions:
details = self._fmt_session(s)
await self.notifier.notify_upcoming_session(details, 1)
logging.info(f"Notified about upcoming session: {details}")
sessions_to_book.sort(key=lambda x: 0 if x[0] == "Preferred" else 1)
for stype, s in sessions_to_book:
st_dt = datetime.strptime(s["start_timestamp"], "%Y-%m-%d %H:%M:%S")
logging.info(f"Attempting to book {stype} session at {st_dt} ({s['name_activity']})")
if await self.book_session(s["id_activity_calendar"]):
details = f"{s['name_activity']} at {st_dt.strftime('%Y-%m-%d %H:%M')}"
await self.notifier.notify_session_booking(details)
logging.info(f"Successfully booked {stype} session at {st_dt}")
else:
logging.error(f"Failed to book {stype} session at {st_dt}")
details = f"{s['name_activity']} at {st_dt.strftime('%Y-%m-%d %H:%M')}"
await self.notifier.notify_impossible_booking(details)
logging.info(f"Notified about impossible booking for {stype} session at {st_dt}")
async def run(self) -> None:
tz = pytz.timezone(TIMEZONE)
th, tm = map(int, TARGET_RESERVATION_TIME.split(":"))
target_time = datetime.now(tz).replace(hour=th, minute=tm, second=0, microsecond=0)
booking_window_end = target_time + timedelta(minutes=BOOKING_WINDOW_END_DELTA_MINUTES)
if not self.login():
logging.error("Authentication failed - exiting program"); return
try:
while True:
try:
now = datetime.now(tz)
logging.info(f"Current time: {now}")
if target_time <= now <= booking_window_end:
await self.run_booking_cycle(now); time.sleep(60)
else:
time.sleep(300)
except Exception as e:
logging.error(f"Unexpected error in booking cycle: {e} - Traceback: {traceback.format_exc()}"); time.sleep(60)
except KeyboardInterrupt:
self.quit()
def quit(self) -> None:
logging.info("Script interrupted by user. Quitting..."); exit(0)

View File

@@ -1,13 +1,13 @@
[
{
"day_of_week": 2,
"start_time": "18:30",
"session_name_contains": "CONDITIONING"
"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": "WEIGHTLIFTING"
"session_name_contains": "CONDITIONING LOUVRE 3"
},
{
"day_of_week": 5,

80
scripts/execute_book_session.py Executable file
View File

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

View File

@@ -3,7 +3,6 @@
# Import all public modules to make them available as src.module
from .auth import AuthHandler
from .booker import Booker
from .crossfit_booker import CrossFitBooker
from .session_config import PREFERRED_SESSIONS
from .session_manager import SessionManager
from .session_notifier import SessionNotifier
@@ -11,7 +10,6 @@ from .session_notifier import SessionNotifier
__all__ = [
"AuthHandler",
"Booker",
"CrossFitBooker",
"PREFERRED_SESSIONS",
"SessionManager",
"SessionNotifier"

View File

@@ -1,6 +1,5 @@
# Native modules
import logging
import os
from typing import Dict, Any, Optional
# Third-party modules
@@ -78,7 +77,13 @@ class AuthHandler:
try:
login_data: Dict[str, Any] = response.json()
self.user_id = str(login_data["data"]["user"]["id_user"])
# 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

View File

@@ -1,9 +1,15 @@
#!/usr/bin/env python3
import logging
import os
import traceback
import asyncio
from crossfit_booker import CrossFitBooker
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
@@ -20,16 +26,45 @@ if __name__ == "__main__":
logging.getLogger("requests").setLevel(logging.WARNING)
logging.info("Logging enhanced with request library noise reduction")
# Create an instance of the CrossFitBooker class
booker = CrossFitBooker()
# 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 booker.login():
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
booker.run()
import asyncio
asyncio.run(booker.run())
logging.info("Script completed")

View File

@@ -10,14 +10,12 @@ 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
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
from src.session_config import PREFERRED_SESSIONS
# Import the AuthHandler class
from src.auth import AuthHandler
@@ -164,13 +162,13 @@ class Booker:
logging.info(f"Successfully booked {session_type} session at {session_time}")
# Notify about booked session
session_details = f"{session['name_activity']} at {session_time.strftime('%Y-%m-%d %H:%M')}"
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"{session['name_activity']} at {session_time.strftime('%Y-%m-%d %H:%M')}"
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}")
@@ -194,8 +192,6 @@ class Booker:
# Parse TARGET_RESERVATION_TIME to get the target hour and minute
target_hour, target_minute = map(int, TARGET_RESERVATION_TIME.split(":"))
target_time = datetime.now(tz).replace(hour=target_hour, minute=target_minute, second=0, microsecond=0)
booking_window_end = target_time + timedelta(minutes=BOOKING_WINDOW_END_DELTA_MINUTES)
# Initial login
if not self.auth_handler.login():
@@ -208,13 +204,19 @@ class Booker:
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 target_time <= current_time <= booking_window_end:
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:

View File

@@ -1,191 +0,0 @@
# Native modules
import logging
import os
from typing import Dict, Any, Optional, List
from datetime import date, datetime
# Third-party modules
import requests
# Import the AuthHandler class
from src.auth import AuthHandler
# Import the SessionManager class
from src.session_manager import SessionManager
# Import the Booker class
from src.booker import Booker
# Import the SessionNotifier class
from src.session_notifier import SessionNotifier
# Load environment variables
from dotenv import load_dotenv
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")
class CrossFitBooker:
"""
A simple orchestrator class for the CrossFit booking system.
This class is responsible for initializing and coordinating the other components
(AuthHandler, SessionManager, and Booker) and provides a unified interface for
interacting with the booking system.
"""
def __init__(self) -> None:
"""
Initialize the CrossFitBooker with necessary components.
"""
# Initialize the AuthHandler with credentials from environment variables
self.auth_handler = AuthHandler(USERNAME, PASSWORD)
# Initialize the SessionManager with the AuthHandler
self.session_manager = SessionManager(self.auth_handler)
# 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
)
# Initialize the Booker with the AuthHandler and SessionNotifier
self.booker = Booker(self.auth_handler, self.notifier)
# Initialize a session for direct API calls
self.session = requests.Session()
def run(self) -> None:
"""
Start the booking process.
This method initiates the booking process by running the Booker's main execution loop.
"""
import asyncio
asyncio.run(self.booker.run())
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 login(self) -> bool:
"""
Authenticate and get the bearer token.
Returns:
bool: True if login is successful, False otherwise.
"""
return self.auth_handler.login()
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.
"""
return self.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.
"""
return self.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.
"""
return self.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.
"""
return self.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.
"""
return self.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.
"""
self.session_manager.display_upcoming_sessions(sessions, current_time)
async def run_booking_cycle(self, current_time: datetime) -> None:
"""
Run one cycle of checking and booking sessions.
Args:
current_time (datetime): Current time for comparison.
"""
await self.booker.booker(current_time)
def quit(self) -> None:
"""
Clean up resources and exit the script.
"""
self.booker.quit()

View File

@@ -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

View File

@@ -4,7 +4,7 @@ import pytz
import time
from datetime import date
from typing import List, Dict, Optional, Any
from datetime import datetime, timedelta, date
from datetime import datetime
# Third-party modules
import requests
@@ -164,7 +164,7 @@ class SessionManager:
logging.warning(f"Request failed (attempt {retry+1}/3): {str(e)}. Retrying in {wait_time}s...")
time.sleep(wait_time)
logging.error(f"Failed to complete request after 3 attempts")
logging.error("Failed to complete request after 3 attempts")
return False
def get_booked_sessions(self) -> List[Dict[str, Any]]:
@@ -208,7 +208,7 @@ class SessionManager:
logging.warning(f"Request failed (attempt {retry+1}/3): {str(e)}. Retrying in {wait_time}s...")
time.sleep(wait_time)
logging.error(f"Failed to complete request after 3 attempts")
logging.error("Failed to complete request after 3 attempts")
return []
def is_session_bookable(self, session: Dict[str, Any], current_time: datetime) -> bool:

View File

@@ -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:
@@ -37,6 +39,9 @@ class SessionNotifier:
# 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.
@@ -75,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):
"""
@@ -95,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:
@@ -114,8 +136,8 @@ 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:
@@ -145,8 +167,8 @@ class SessionNotifier:
return
# Create messages for both email and Telegram
email_message = f"Failed to book session: {session_details}"
telegram_message = f"Failed to book session: {session_details}"
email_message = f"Failed to book session for {self.crossfit_username}: {session_details}"
telegram_message = f"Failed to book session for {self.crossfit_username}: {session_details}"
# Send notifications through enabled channels
if self.enable_email:

View File

@@ -12,8 +12,9 @@ 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.crossfit_booker import CrossFitBooker
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"""
@@ -24,8 +25,8 @@ class TestCrossFitBookerAuthHeaders:
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass'
}):
booker = CrossFitBooker()
headers = booker.get_auth_headers()
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"
@@ -35,13 +36,13 @@ class TestCrossFitBookerAuthHeaders:
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass'
}):
booker = CrossFitBooker()
booker.auth_handler.auth_token = "test_token_123"
headers = booker.get_auth_headers()
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 TestCrossFitBookerLogin:
"""Test cases for login method"""
class TestAuthHandlerLogin:
"""Test cases for AuthHandler login method"""
@patch('requests.Session.post')
def test_login_success(self, mock_post):
@@ -70,12 +71,12 @@ class TestCrossFitBookerLogin:
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass'
}):
booker = CrossFitBooker()
result = booker.login()
auth_handler = AuthHandler('test_user', 'test_pass')
result = auth_handler.login()
assert result is True
assert booker.auth_handler.user_id == "12345"
assert booker.auth_handler.auth_token == "test_bearer_token"
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):
@@ -91,12 +92,12 @@ class TestCrossFitBookerLogin:
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass'
}):
booker = CrossFitBooker()
result = booker.login()
auth_handler = AuthHandler('test_user', 'test_pass')
result = auth_handler.login()
assert result is False
assert booker.auth_handler.user_id is None
assert booker.auth_handler.auth_token is None
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):
@@ -123,8 +124,8 @@ class TestCrossFitBookerLogin:
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass'
}):
booker = CrossFitBooker()
result = booker.login()
auth_handler = AuthHandler('test_user', 'test_pass')
result = auth_handler.login()
assert result is False
@@ -141,8 +142,8 @@ class TestCrossFitBookerLogin:
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass'
}):
booker = CrossFitBooker()
result = booker.login()
auth_handler = AuthHandler('test_user', 'test_pass')
result = auth_handler.login()
assert result is False
@@ -155,8 +156,8 @@ class TestCrossFitBookerLogin:
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass'
}):
booker = CrossFitBooker()
result = booker.login()
auth_handler = AuthHandler('test_user', 'test_pass')
result = auth_handler.login()
assert result is False

View File

@@ -1,232 +0,0 @@
#!/usr/bin/env python3
"""
Comprehensive unit tests for the CrossFitBooker class in crossfit_booker.py
"""
import os
import sys
from unittest.mock import Mock, patch
from datetime import date
import requests
# Add the parent directory to the path to import crossfit_booker
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from src.crossfit_booker import CrossFitBooker
class TestCrossFitBookerInit:
"""Test cases for CrossFitBooker initialization"""
def test_init_success(self):
"""Test successful initialization with all required env vars"""
with patch.dict(os.environ, {
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass',
'EMAIL_FROM': 'from@test.com',
'EMAIL_TO': 'to@test.com',
'EMAIL_PASSWORD': 'email_pass',
'TELEGRAM_TOKEN': 'telegram_token',
'TELEGRAM_CHAT_ID': '12345'
}):
booker = CrossFitBooker()
assert booker.auth_handler.auth_token is None
assert booker.auth_handler.user_id is None
assert booker.session is not None
assert booker.notifier is not None
def test_init_missing_credentials(self):
"""Test initialization fails with missing credentials"""
with patch.dict(os.environ, {}, clear=True):
try:
CrossFitBooker()
except ValueError as e:
assert str(e) == "Missing environment variables"
def test_init_partial_credentials(self):
"""Test initialization fails with partial credentials"""
with patch.dict(os.environ, {
'CROSSFIT_USERNAME': 'test_user'
# Missing PASSWORD
}, clear=True):
try:
CrossFitBooker()
except ValueError as e:
assert str(e) == "Missing environment variables"
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'
}):
booker = CrossFitBooker()
headers = booker.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'
}):
booker = CrossFitBooker()
booker.auth_handler.auth_token = "test_token_123"
headers = booker.get_auth_headers()
assert headers["Authorization"] == "Bearer test_token_123"
class TestCrossFitBookerLogin:
"""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]
with patch.dict(os.environ, {
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass'
}):
booker = CrossFitBooker()
result = booker.login()
assert result is True
assert booker.auth_handler.user_id == "12345"
assert booker.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'
}):
booker = CrossFitBooker()
result = booker.login()
assert result is False
assert booker.auth_handler.user_id is None
assert booker.auth_handler.auth_token is None
@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'
}):
booker = CrossFitBooker()
result = booker.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'
}):
booker = CrossFitBooker()
result = booker.login()
assert result is False
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'
}):
booker = CrossFitBooker()
result = booker.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'
}):
booker = CrossFitBooker()
booker.auth_handler.auth_token = "test_token"
booker.auth_handler.user_id = "12345"
result = booker.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_failure(self, mock_post):
"""Test get_available_sessions with API failure"""
mock_response = Mock()
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'
}):
booker = CrossFitBooker()
booker.auth_handler.auth_token = "test_token"
booker.auth_handler.user_id = "12345"
result = booker.get_available_sessions(date(2025, 7, 24), date(2025, 7, 25))
assert result is None

View File

@@ -6,16 +6,17 @@ Unit tests for CrossFitBooker session-related methods
import pytest
import os
import sys
from unittest.mock import patch, Mock, AsyncMock
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.crossfit_booker import CrossFitBooker
from src.session_manager import SessionManager
from src.auth import AuthHandler
from src.booker import Booker
from src.session_notifier import SessionNotifier
from src.session_manager import SessionManager
@@ -75,11 +76,12 @@ class TestCrossFitBookerGetAvailableSessions:
'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass'
}):
booker = CrossFitBooker()
booker.auth_handler.auth_token = "test_token"
booker.auth_handler.user_id = "12345"
auth_handler = AuthHandler('test_user', 'test_pass')
session_manager = SessionManager(auth_handler)
auth_handler.auth_token = "test_token"
auth_handler.user_id = "12345"
result = booker.get_available_sessions(date(2025, 7, 24), date(2025, 7, 25))
result = session_manager.get_available_sessions(date(2025, 7, 24), date(2025, 7, 25))
assert result is None
@@ -198,130 +200,6 @@ class TestCrossFitBookerIsSessionBookable:
result = session_manager.is_session_bookable(session, current_time)
assert result is False
class TestCrossFitBookerExcuteCycle:
"""Test cases for execute_cycle method"""
@patch('src.crossfit_booker.CrossFitBooker.get_available_sessions')
@patch('src.crossfit_booker.CrossFitBooker.is_session_bookable')
@patch('src.crossfit_booker.CrossFitBooker.matches_preferred_session')
@patch('src.crossfit_booker.CrossFitBooker.book_session')
async def test_run_booking_cycle_no_sessions(self, mock_book_session, mock_matches_preferred, mock_is_bookable, mock_get_sessions):
"""Test run_booking_cycle with no available sessions"""
mock_get_sessions.return_value = {"success": False}
booker = CrossFitBooker()
# Mock the auth_token and user_id to avoid authentication errors
booker.auth_handler.auth_token = "test_token"
booker.auth_handler.user_id = "12345"
# Mock the booker method to use our mocked methods
with patch.object(booker.booker, 'get_available_sessions', mock_get_sessions):
await booker.run_booking_cycle(datetime.now(pytz.timezone("Europe/Paris")))
mock_get_sessions.assert_called_once()
mock_book_session.assert_not_called()
@patch('src.crossfit_booker.CrossFitBooker.get_available_sessions')
@patch('src.crossfit_booker.CrossFitBooker.is_session_bookable')
@patch('src.crossfit_booker.CrossFitBooker.matches_preferred_session')
@patch('src.crossfit_booker.CrossFitBooker.book_session')
async def test_run_booking_cycle_with_sessions(self, mock_book_session, mock_matches_preferred, mock_is_bookable, mock_get_sessions):
"""Test run_booking_cycle with available sessions"""
# Use current date for the session to ensure it falls within 0-2 day window
current_time = datetime.now(pytz.timezone("Europe/Paris"))
session_date = current_time.date()
mock_get_sessions.return_value = {
"success": True,
"data": {
"activities_calendar": [
{
"id_activity_calendar": "1",
"name_activity": "CONDITIONING",
"start_timestamp": session_date.strftime("%Y-%m-%d") + " 18:30:00",
"user_info": {"can_join": True}
}
]
}
}
mock_is_bookable.return_value = True
mock_matches_preferred.return_value = True
mock_book_session.return_value = True
booker = CrossFitBooker()
# Mock the auth_token and user_id to avoid authentication errors
booker.auth_handler.auth_token = "test_token"
booker.auth_handler.user_id = "12345"
# Mock the booker method to use our mocked methods
with patch.object(booker.booker, 'get_available_sessions', mock_get_sessions):
with patch.object(booker.booker, 'is_session_bookable', mock_is_bookable):
with patch.object(booker.booker, 'matches_preferred_session', mock_matches_preferred):
with patch.object(booker.booker, 'book_session', mock_book_session):
await booker.run_booking_cycle(current_time)
mock_get_sessions.assert_called_once()
mock_is_bookable.assert_called_once()
mock_matches_preferred.assert_called_once()
mock_book_session.assert_called_once()
assert mock_book_session.call_count == 1
class TestCrossFitBookerRun:
"""Test cases for run method"""
def test_run_auth_failure(self):
"""Test run with authentication failure"""
with patch('src.crossfit_booker.CrossFitBooker.login', return_value=False) as mock_login:
booker = CrossFitBooker()
# Test the authentication failure path through the booker
result = booker.login()
assert result is False
mock_login.assert_called_once()
def test_run_booking_outside_window(self):
"""Test run with booking outside window"""
with patch('src.booker.Booker.run') as mock_run:
with patch('datetime.datetime') as mock_datetime:
with patch('time.sleep') as mock_sleep:
# Create a time outside the booking window (19:00)
tz = pytz.timezone("Europe/Paris")
mock_now = datetime(2025, 7, 25, 19, 0, tzinfo=tz)
mock_datetime.now.return_value = mock_now
# Make sleep return immediately to allow one iteration, then break
call_count = 0
def sleep_side_effect(seconds):
nonlocal call_count
call_count += 1
if call_count >= 1:
# Break the loop after first sleep
raise KeyboardInterrupt("Test complete")
return None
mock_sleep.side_effect = sleep_side_effect
booker = CrossFitBooker()
# Test the booking window logic directly
target_hour, target_minute = map(int, "20:01".split(":"))
target_time = datetime.now(tz).replace(hour=target_hour, minute=target_minute, second=0, microsecond=0)
booking_window_end = target_time + timedelta(minutes=10)
# Current time is outside the booking window
assert not (target_time <= mock_now <= booking_window_end)
# Run the booker to trigger the login
booker.run()
# Verify run was called
mock_run.assert_called_once()
class TestCrossFitBookerQuit:
"""Test cases for quit method"""
def test_quit(self):
"""Test quit method"""
booker = CrossFitBooker()
with patch('sys.exit') as mock_exit:
with pytest.raises(SystemExit) as excinfo:
booker.quit()
assert excinfo.value.code == 0
class TestCrossFitBookerMatchesPreferredSession:
"""Test cases for matches_preferred_session method"""

View File

@@ -37,18 +37,17 @@ class TestSessionConfig:
"""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.warning') as mock_warning:
sessions = SessionConfig.load_preferred_sessions()
with patch('logging.error') as mock_error:
with pytest.raises(SystemExit) as excinfo:
SessionConfig.load_preferred_sessions()
# Verify warning was logged
mock_warning.assert_called_once()
assert "not found" in mock_warning.call_args[0][0]
# 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 default sessions are returned
assert len(sessions) == 3
assert sessions[0] == (2, "18:30", "CONDITIONING")
assert sessions[1] == (4, "17:00", "WEIGHTLIFTING")
assert sessions[2] == (5, "12:30", "HYROX")
# 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"""
@@ -57,18 +56,16 @@ class TestSessionConfig:
# Mock the open function to return invalid JSON
with patch('builtins.open', mock_open(read_data=invalid_json)):
with patch('logging.warning') as mock_warning:
sessions = SessionConfig.load_preferred_sessions()
with patch('logging.error') as mock_error:
with pytest.raises(SystemExit) as excinfo:
SessionConfig.load_preferred_sessions()
# Verify warning was logged
mock_warning.assert_called_once()
assert "decode" in mock_warning.call_args[0][0]
# Verify error was logged
mock_error.assert_called_once()
assert "decode" in mock_error.call_args[0][0]
# Verify default sessions are returned
assert len(sessions) == 3
assert sessions[0] == (2, "18:30", "CONDITIONING")
assert sessions[1] == (4, "17:00", "WEIGHTLIFTING")
assert sessions[2] == (5, "12:30", "HYROX")
# 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"""
@@ -79,11 +76,8 @@ class TestSessionConfig:
with patch('builtins.open', mock_open(read_data=empty_json)):
sessions = SessionConfig.load_preferred_sessions()
# Verify default sessions are returned
assert len(sessions) == 3
assert sessions[0] == (2, "18:30", "CONDITIONING")
assert sessions[1] == (4, "17:00", "WEIGHTLIFTING")
assert sessions[2] == (5, "12:30", "HYROX")
# 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"""
@@ -111,18 +105,16 @@ class TestSessionConfig:
# Mock the open function to return partial JSON
with patch('builtins.open', mock_open(read_data=partial_json)):
with patch('logging.warning') as mock_warning:
sessions = SessionConfig.load_preferred_sessions()
with patch('logging.error') as mock_error:
with pytest.raises(SystemExit) as excinfo:
SessionConfig.load_preferred_sessions()
# Verify warning was logged
mock_warning.assert_called_once()
assert "decode" in mock_warning.call_args[0][0]
# Verify error was logged
mock_error.assert_called_once()
assert "decode" in mock_error.call_args[0][0]
# Verify default sessions are returned
assert len(sessions) == 3
assert sessions[0] == (2, "18:30", "CONDITIONING")
assert sessions[1] == (4, "17:00", "WEIGHTLIFTING")
assert sessions[2] == (5, "12:30", "HYROX")
# 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"""

View File

@@ -3,10 +3,16 @@
Script to demonstrate how to execute the book_session method from crossfit_booker.py
"""
import os
import sys
import os
import logging
from crossfit_booker import CrossFitBooker
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')
@@ -19,12 +25,40 @@ def main():
session_id = sys.argv[1]
# Create an instance of CrossFitBooker
booker = CrossFitBooker()
# 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 booker.login():
if not auth_handler.login():
print("Failed to authenticate. Please check your credentials and try again.")
sys.exit(1)
print("Authentication successful!")