Compare commits

...

18 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
29 changed files with 451 additions and 784 deletions

1
.gitignore vendored
View File

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

63
AGENTS.md Normal file
View File

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

View File

@@ -28,4 +28,4 @@ COPY . .
RUN mkdir -p log RUN mkdir -p log
# Set the entry point to run the main script # Set the entry point to run the main script
ENTRYPOINT ["python", "book_crossfit.py"] ENTRYPOINT ["python", "main.py"]

View File

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

9
ascii.md Normal file
View File

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

View File

@@ -4,7 +4,7 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: crossfit-booker # container_name: crossfit-booker
environment: environment:
- TZ=Europe/Paris - TZ=Europe/Paris
- CROSSFIT_USERNAME=${CROSSFIT_USERNAME} - CROSSFIT_USERNAME=${CROSSFIT_USERNAME}
@@ -18,5 +18,5 @@ services:
- TELEGRAM_TOKEN=${TELEGRAM_TOKEN} - TELEGRAM_TOKEN=${TELEGRAM_TOKEN}
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID} - TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID}
volumes: volumes:
- ./log:/app/log - ./:/app
restart: unless-stopped restart: unless-stopped

View File

@@ -1,45 +0,0 @@
#!/usr/bin/env python3
"""
Script to demonstrate how to execute the book_session method from crossfit_booker.py
"""
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)

57
main.py
View File

@@ -5,11 +5,18 @@ This script initializes the CrossFitBooker and starts the booking process.
""" """
import logging 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(): 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 # Set up logging
logging.basicConfig( logging.basicConfig(
@@ -20,11 +27,51 @@ def main():
] ]
) )
# Initialize the CrossFitBooker # Display ASCII art and username
booker = CrossFitBooker() 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 # Run the booking process
booker.run() import asyncio
asyncio.run(booker.run())
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@@ -1,13 +1,13 @@
[ [
{ {
"day_of_week": 2, "day_of_week": 0,
"start_time": "18:30", "start_time": "17:45",
"session_name_contains": "CONDITIONING" "session_name_contains": "WEIGHTLIFTING LOUVRE 3"
}, },
{ {
"day_of_week": 4, "day_of_week": 4,
"start_time": "17:00", "start_time": "17:00",
"session_name_contains": "WEIGHTLIFTING" "session_name_contains": "CONDITIONING LOUVRE 3"
}, },
{ {
"day_of_week": 5, "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 # Import all public modules to make them available as src.module
from .auth import AuthHandler from .auth import AuthHandler
from .booker import Booker from .booker import Booker
from .crossfit_booker import CrossFitBooker
from .session_config import PREFERRED_SESSIONS from .session_config import PREFERRED_SESSIONS
from .session_manager import SessionManager from .session_manager import SessionManager
from .session_notifier import SessionNotifier from .session_notifier import SessionNotifier
@@ -11,7 +10,6 @@ from .session_notifier import SessionNotifier
__all__ = [ __all__ = [
"AuthHandler", "AuthHandler",
"Booker", "Booker",
"CrossFitBooker",
"PREFERRED_SESSIONS", "PREFERRED_SESSIONS",
"SessionManager", "SessionManager",
"SessionNotifier" "SessionNotifier"

View File

@@ -77,7 +77,13 @@ class AuthHandler:
try: try:
login_data: Dict[str, Any] = response.json() 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: except (KeyError, ValueError) as e:
logging.error(f"Error during login: {str(e)} - Response: {response.text}") logging.error(f"Error during login: {str(e)} - Response: {response.text}")
return False return False

View File

@@ -1,8 +1,15 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import logging import logging
import os
import traceback import traceback
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__": if __name__ == "__main__":
# Configure logging once at script startup # Configure logging once at script startup
@@ -19,16 +26,45 @@ if __name__ == "__main__":
logging.getLogger("requests").setLevel(logging.WARNING) logging.getLogger("requests").setLevel(logging.WARNING)
logging.info("Logging enhanced with request library noise reduction") logging.info("Logging enhanced with request library noise reduction")
# Create an instance of the CrossFitBooker class # Initialize components
booker = CrossFitBooker() 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 # Attempt to log in to the CrossFit booking system
# TODO: Make a authentication during running request to not get kicked out # 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 # If login fails, log the error and exit the script
logging.error("Failed to login - Traceback: %s", traceback.format_exc()) logging.error("Failed to login - Traceback: %s", traceback.format_exc())
exit(1) exit(1)
# Start the continuous booking loop # Start the continuous booking loop
booker.run() import asyncio
asyncio.run(booker.run())
logging.info("Script completed") logging.info("Script completed")

View File

@@ -162,13 +162,13 @@ class Booker:
logging.info(f"Successfully booked {session_type} session at {session_time}") logging.info(f"Successfully booked {session_type} session at {session_time}")
# Notify about booked session # 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) await self.notifier.notify_session_booking(session_details)
else: else:
logging.error(f"Failed to book {session_type} session at {session_time}") logging.error(f"Failed to book {session_type} session at {session_time}")
# Notify about failed booking # 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) await self.notifier.notify_impossible_booking(session_details)
logging.info(f"Notified about impossible booking for {session_type} session at {session_time}") logging.info(f"Notified about impossible booking for {session_type} session at {session_time}")
@@ -192,8 +192,6 @@ class Booker:
# Parse TARGET_RESERVATION_TIME to get the target hour and minute # Parse TARGET_RESERVATION_TIME to get the target hour and minute
target_hour, target_minute = map(int, TARGET_RESERVATION_TIME.split(":")) 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 # Initial login
if not self.auth_handler.login(): if not self.auth_handler.login():
@@ -206,15 +204,19 @@ class Booker:
current_time: datetime = datetime.now(tz) current_time: datetime = datetime.now(tz)
logging.info(f"Current time: {current_time}") 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 # 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 # Run booking cycle to check for preferred sessions and book
await self.booker(current_time) await self.booker(current_time)
# Wait for a short time before next check # Wait for a short time before next check
time.sleep(60) time.sleep(60)
else: else:
# Display message when outside booking window # Display message when outside booking window
logging.info(f"Not booking now - current time {current_time} is outside the booking window ({target_time} to {booking_window_end})") # 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 # Check again in 5 minutes if outside booking window
time.sleep(300) time.sleep(300)
except Exception as e: except Exception as e:

View File

@@ -1,193 +0,0 @@
# Native modules
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
try:
asyncio.run(self.booker.run())
except Exception as e:
logging.error(f"Error in booking process: {str(e)}")
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)) preferred_sessions.append((day_of_week, start_time, session_name_contains))
except FileNotFoundError: except FileNotFoundError:
# Log a warning if the file is not found # Log error and exit if the file is not found
logging.warning(f"Configuration file '{config_file}' not found. Falling back to default settings.") logging.error(f"Configuration file '{config_file}' not found. Please create the configuration file.")
logging.error("You can copy 'preferred_sessions.json.example' to 'preferred_sessions.json' as a template.")
raise SystemExit(1)
except json.JSONDecodeError: except json.JSONDecodeError:
# Log a warning if the file is not a valid JSON # Log error and exit if the file is not a valid JSON
logging.warning(f"Failed to decode JSON from file '{config_file}'. Falling back to default settings.") logging.error(f"Failed to decode JSON from file '{config_file}'. Please check the file format.")
raise SystemExit(1)
# Fallback to default hardcoded sessions if no valid sessions were loaded # Return empty list if no valid sessions were loaded
if not preferred_sessions: if not preferred_sessions:
preferred_sessions = [ return []
(2, "18:30", "CONDITIONING"), # Wednesday 18:30 CONDITIONING
(4, "17:00", "WEIGHTLIFTING"), # Friday 17:00 WEIGHTLIFTING
(5, "12:30", "HYROX"), # Saturday 12:30 HYROX
]
return preferred_sessions return preferred_sessions

View File

@@ -1,8 +1,10 @@
import smtplib import smtplib
import os import os
import logging import logging
import asyncio
from email.message import EmailMessage from email.message import EmailMessage
from telegram import Bot from telegram import Bot
from telegram.error import TimedOut, NetworkError
class SessionNotifier: class SessionNotifier:
@@ -37,6 +39,9 @@ class SessionNotifier:
# Check environment variable for impossible booking notifications # Check environment variable for impossible booking notifications
self.notify_impossible = os.environ.get("NOTIFY_IMPOSSIBLE_BOOKING", "true").lower() in ("true", "1", "yes") self.notify_impossible = os.environ.get("NOTIFY_IMPOSSIBLE_BOOKING", "true").lower() in ("true", "1", "yes")
# Get crossfit username from environment
self.crossfit_username = os.environ.get("CROSSFIT_USERNAME", "User")
def send_email_notification(self, message): def send_email_notification(self, message):
""" """
Send an email notification with the given message. Send an email notification with the given message.
@@ -75,17 +80,34 @@ class SessionNotifier:
logging.error(f"Failed to send email: {str(e)}") logging.error(f"Failed to send email: {str(e)}")
raise raise
async def send_telegram_notification(self, message): async def send_telegram_notification(self, message, max_retries=3):
""" """
Send a Telegram notification with the given message. Send a Telegram notification with the given message.
Args: Args:
message (str): The message content to be sent in the Telegram chat message (str): The message content to be sent in the Telegram chat
max_retries (int): Maximum number of retry attempts (default: 3)
""" """
# Create a Bot instance with the provided token # Create a Bot instance with the provided token
bot = Bot(token=self.telegram_credentials["token"]) bot = Bot(token=self.telegram_credentials["token"])
# 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): async def notify_session_booking(self, session_details):
""" """
@@ -95,8 +117,8 @@ class SessionNotifier:
session_details (str): Details about the booked session session_details (str): Details about the booked session
""" """
# Create messages for both email and Telegram # Create messages for both email and Telegram
email_message = f"Session booked: {session_details}" email_message = f"Session booked for {self.crossfit_username}: {session_details}"
telegram_message = f"Session booked: {session_details}" telegram_message = f"Session booked for {self.crossfit_username}: {session_details}"
# Send notifications through enabled channels # Send notifications through enabled channels
if self.enable_email: if self.enable_email:
@@ -114,8 +136,8 @@ class SessionNotifier:
days_until (int): Number of days until the session days_until (int): Number of days until the session
""" """
# Create messages for both email and Telegram # Create messages for both email and Telegram
email_message = f"Session available soon: {session_details} (in {days_until} days)" email_message = f"Session available soon for {self.crossfit_username}: {session_details} (in {days_until} days)"
telegram_message = f"Session available soon: {session_details} (in {days_until} days)" telegram_message = f"Session available soon for {self.crossfit_username}: {session_details} (in {days_until} days)"
# Send notifications through enabled channels # Send notifications through enabled channels
if self.enable_email: if self.enable_email:
@@ -145,8 +167,8 @@ class SessionNotifier:
return return
# Create messages for both email and Telegram # Create messages for both email and Telegram
email_message = f"Failed to book session: {session_details}" email_message = f"Failed to book session for {self.crossfit_username}: {session_details}"
telegram_message = f"Failed to book session: {session_details}" telegram_message = f"Failed to book session for {self.crossfit_username}: {session_details}"
# Send notifications through enabled channels # Send notifications through enabled channels
if self.enable_email: if self.enable_email:

View File

@@ -1,66 +0,0 @@
#!/usr/bin/env python3
"""
Test script to verify booking window functionality.
"""
import os
import sys
import logging
from datetime import datetime, timedelta
import pytz
# Add the parent directory to the path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from src.crossfit_booker import CrossFitBooker
from src.booker import Booker
from src.auth import AuthHandler
# Set up logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
# Mock the login method to avoid actual authentication
def mock_login(self) -> bool:
self.auth_token = "mock_token"
self.user_id = "12345"
return True
# Test the booking window functionality
def test_booking_window():
"""Test the booking window functionality."""
# Create a booker instance
booker = CrossFitBooker()
# Replace the login method with our mock
original_login = AuthHandler.login
AuthHandler.login = mock_login
# Set up timezone and target time
tz = pytz.timezone("Europe/Paris")
current_time = datetime.now(tz)
# Get the target time from the environment variable or use default
target_time_str = os.environ.get("TARGET_RESERVATION_TIME", "20:01")
target_hour, target_minute = map(int, target_time_str.split(":"))
target_time = current_time.replace(hour=target_hour, minute=target_minute, second=0, microsecond=0)
# Calculate booking window end
booking_window_end = target_time + timedelta(minutes=10)
# Display current time and booking window
logging.info(f"Current time: {current_time}")
logging.info(f"Target booking time: {target_time}")
logging.info(f"Booking window end: {booking_window_end}")
# Check if we're in the booking window
if target_time <= current_time <= booking_window_end:
logging.info("We are within the booking window!")
else:
logging.info("We are outside the booking window.")
time_diff = (target_time - current_time).total_seconds()
logging.info(f"Next booking window starts in: {time_diff//60} minutes and {time_diff%60:.0f} seconds")
# Restore the original login method
AuthHandler.login = original_login
if __name__ == "__main__":
test_booking_window()

View File

@@ -12,7 +12,9 @@ from unittest.mock import patch, Mock
# Add the parent directory to the path # Add the parent directory to the path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 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: class TestCrossFitBookerAuthHeaders:
"""Test cases for get_auth_headers method""" """Test cases for get_auth_headers method"""
@@ -23,8 +25,8 @@ class TestCrossFitBookerAuthHeaders:
'CROSSFIT_USERNAME': 'test_user', 'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass' 'CROSSFIT_PASSWORD': 'test_pass'
}): }):
booker = CrossFitBooker() auth_handler = AuthHandler('test_user', 'test_pass')
headers = booker.get_auth_headers() headers = auth_handler.get_auth_headers()
assert "Authorization" not in 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" assert headers["User-Agent"] == "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0"
@@ -34,13 +36,13 @@ class TestCrossFitBookerAuthHeaders:
'CROSSFIT_USERNAME': 'test_user', 'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass' 'CROSSFIT_PASSWORD': 'test_pass'
}): }):
booker = CrossFitBooker() auth_handler = AuthHandler('test_user', 'test_pass')
booker.auth_handler.auth_token = "test_token_123" auth_handler.auth_token = "test_token_123"
headers = booker.get_auth_headers() headers = auth_handler.get_auth_headers()
assert headers["Authorization"] == "Bearer test_token_123" assert headers["Authorization"] == "Bearer test_token_123"
class TestCrossFitBookerLogin: class TestAuthHandlerLogin:
"""Test cases for login method""" """Test cases for AuthHandler login method"""
@patch('requests.Session.post') @patch('requests.Session.post')
def test_login_success(self, mock_post): def test_login_success(self, mock_post):
@@ -69,12 +71,12 @@ class TestCrossFitBookerLogin:
'CROSSFIT_USERNAME': 'test_user', 'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass' 'CROSSFIT_PASSWORD': 'test_pass'
}): }):
booker = CrossFitBooker() auth_handler = AuthHandler('test_user', 'test_pass')
result = booker.login() result = auth_handler.login()
assert result is True assert result is True
assert booker.auth_handler.user_id == "12345" assert auth_handler.user_id == "12345"
assert booker.auth_handler.auth_token == "test_bearer_token" assert auth_handler.auth_token == "test_bearer_token"
@patch('requests.Session.post') @patch('requests.Session.post')
def test_login_first_step_failure(self, mock_post): def test_login_first_step_failure(self, mock_post):
@@ -90,12 +92,12 @@ class TestCrossFitBookerLogin:
'CROSSFIT_USERNAME': 'test_user', 'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass' 'CROSSFIT_PASSWORD': 'test_pass'
}): }):
booker = CrossFitBooker() auth_handler = AuthHandler('test_user', 'test_pass')
result = booker.login() result = auth_handler.login()
assert result is False assert result is False
assert booker.auth_handler.user_id is None assert auth_handler.user_id is None
assert booker.auth_handler.auth_token is None assert auth_handler.auth_token is None
@patch('requests.Session.post') @patch('requests.Session.post')
def test_login_second_step_failure(self, mock_post): def test_login_second_step_failure(self, mock_post):
@@ -122,8 +124,8 @@ class TestCrossFitBookerLogin:
'CROSSFIT_USERNAME': 'test_user', 'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass' 'CROSSFIT_PASSWORD': 'test_pass'
}): }):
booker = CrossFitBooker() auth_handler = AuthHandler('test_user', 'test_pass')
result = booker.login() result = auth_handler.login()
assert result is False assert result is False
@@ -140,8 +142,8 @@ class TestCrossFitBookerLogin:
'CROSSFIT_USERNAME': 'test_user', 'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass' 'CROSSFIT_PASSWORD': 'test_pass'
}): }):
booker = CrossFitBooker() auth_handler = AuthHandler('test_user', 'test_pass')
result = booker.login() result = auth_handler.login()
assert result is False assert result is False
@@ -154,8 +156,8 @@ class TestCrossFitBookerLogin:
'CROSSFIT_USERNAME': 'test_user', 'CROSSFIT_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass' 'CROSSFIT_PASSWORD': 'test_pass'
}): }):
booker = CrossFitBooker() auth_handler = AuthHandler('test_user', 'test_pass')
result = booker.login() result = auth_handler.login()
assert result is False 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

@@ -13,9 +13,10 @@ import pytz
# Add the parent directory to the path # Add the parent directory to the path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 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.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_USERNAME': 'test_user',
'CROSSFIT_PASSWORD': 'test_pass' 'CROSSFIT_PASSWORD': 'test_pass'
}): }):
booker = CrossFitBooker() auth_handler = AuthHandler('test_user', 'test_pass')
booker.auth_handler.auth_token = "test_token" session_manager = SessionManager(auth_handler)
booker.auth_handler.user_id = "12345" 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 assert result is None
@@ -198,129 +200,6 @@ class TestCrossFitBookerIsSessionBookable:
result = session_manager.is_session_bookable(session, current_time) result = session_manager.is_session_bookable(session, current_time)
assert result is False 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 pytest.raises(SystemExit) as excinfo:
booker.quit()
assert excinfo.value.code == 0
class TestCrossFitBookerMatchesPreferredSession: class TestCrossFitBookerMatchesPreferredSession:
"""Test cases for matches_preferred_session method""" """Test cases for matches_preferred_session method"""

View File

@@ -37,18 +37,17 @@ class TestSessionConfig:
"""Test behavior when the config file is not found""" """Test behavior when the config file is not found"""
# Mock the open function to raise FileNotFoundError # Mock the open function to raise FileNotFoundError
with patch('builtins.open', side_effect=FileNotFoundError): with patch('builtins.open', side_effect=FileNotFoundError):
with patch('logging.warning') as mock_warning: with patch('logging.error') as mock_error:
sessions = SessionConfig.load_preferred_sessions() with pytest.raises(SystemExit) as excinfo:
SessionConfig.load_preferred_sessions()
# Verify warning was logged # Verify error was logged
mock_warning.assert_called_once() assert mock_error.call_count == 2
assert "not found" in mock_warning.call_args[0][0] 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 # Verify SystemExit was raised with exit code 1
assert len(sessions) == 3 assert excinfo.value.code == 1
assert sessions[0] == (2, "18:30", "CONDITIONING")
assert sessions[1] == (4, "17:00", "WEIGHTLIFTING")
assert sessions[2] == (5, "12:30", "HYROX")
def test_load_preferred_sessions_invalid_json(self): def test_load_preferred_sessions_invalid_json(self):
"""Test behavior when the config file contains invalid JSON""" """Test behavior when the config file contains invalid JSON"""
@@ -57,18 +56,16 @@ class TestSessionConfig:
# Mock the open function to return invalid JSON # Mock the open function to return invalid JSON
with patch('builtins.open', mock_open(read_data=invalid_json)): with patch('builtins.open', mock_open(read_data=invalid_json)):
with patch('logging.warning') as mock_warning: with patch('logging.error') as mock_error:
sessions = SessionConfig.load_preferred_sessions() with pytest.raises(SystemExit) as excinfo:
SessionConfig.load_preferred_sessions()
# Verify warning was logged # Verify error was logged
mock_warning.assert_called_once() mock_error.assert_called_once()
assert "decode" in mock_warning.call_args[0][0] assert "decode" in mock_error.call_args[0][0]
# Verify default sessions are returned # Verify SystemExit was raised with exit code 1
assert len(sessions) == 3 assert excinfo.value.code == 1
assert sessions[0] == (2, "18:30", "CONDITIONING")
assert sessions[1] == (4, "17:00", "WEIGHTLIFTING")
assert sessions[2] == (5, "12:30", "HYROX")
def test_load_preferred_sessions_empty_file(self): def test_load_preferred_sessions_empty_file(self):
"""Test behavior when the config file is empty""" """Test behavior when the config file is empty"""
@@ -79,11 +76,8 @@ class TestSessionConfig:
with patch('builtins.open', mock_open(read_data=empty_json)): with patch('builtins.open', mock_open(read_data=empty_json)):
sessions = SessionConfig.load_preferred_sessions() sessions = SessionConfig.load_preferred_sessions()
# Verify default sessions are returned # Verify empty list is returned
assert len(sessions) == 3 assert sessions == []
assert sessions[0] == (2, "18:30", "CONDITIONING")
assert sessions[1] == (4, "17:00", "WEIGHTLIFTING")
assert sessions[2] == (5, "12:30", "HYROX")
def test_load_preferred_sessions_missing_fields(self): def test_load_preferred_sessions_missing_fields(self):
"""Test behavior when some fields are missing in the JSON data""" """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 # Mock the open function to return partial JSON
with patch('builtins.open', mock_open(read_data=partial_json)): with patch('builtins.open', mock_open(read_data=partial_json)):
with patch('logging.warning') as mock_warning: with patch('logging.error') as mock_error:
sessions = SessionConfig.load_preferred_sessions() with pytest.raises(SystemExit) as excinfo:
SessionConfig.load_preferred_sessions()
# Verify warning was logged # Verify error was logged
mock_warning.assert_called_once() mock_error.assert_called_once()
assert "decode" in mock_warning.call_args[0][0] assert "decode" in mock_error.call_args[0][0]
# Verify default sessions are returned # Verify SystemExit was raised with exit code 1
assert len(sessions) == 3 assert excinfo.value.code == 1
assert sessions[0] == (2, "18:30", "CONDITIONING")
assert sessions[1] == (4, "17:00", "WEIGHTLIFTING")
assert sessions[2] == (5, "12:30", "HYROX")
def test_load_preferred_sessions_incorrect_field_types(self): def test_load_preferred_sessions_incorrect_field_types(self):
"""Test behavior when the config file contains JSON with incorrect field types""" """Test behavior when the config file contains JSON with incorrect field types"""

View File

@@ -4,8 +4,15 @@ Script to demonstrate how to execute the book_session method from crossfit_booke
""" """
import sys import sys
import os
import logging 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 # Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
@@ -18,12 +25,40 @@ def main():
session_id = sys.argv[1] session_id = sys.argv[1]
# Create an instance of CrossFitBooker # Initialize components
booker = CrossFitBooker() 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 # Login to authenticate
print("Attempting 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.") print("Failed to authenticate. Please check your credentials and try again.")
sys.exit(1) sys.exit(1)
print("Authentication successful!") print("Authentication successful!")