Compare commits

..

10 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
17 changed files with 122 additions and 69 deletions

1
.gitignore vendored
View File

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

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

15
main.py
View File

@@ -27,10 +27,21 @@ def main():
] ]
) )
# 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 # Initialize components
auth_handler = AuthHandler( auth_handler = AuthHandler(
os.environ.get("CROSSFIT_USERNAME"), str(os.environ.get("CROSSFIT_USERNAME")),
os.environ.get("CROSSFIT_PASSWORD") str(os.environ.get("CROSSFIT_PASSWORD"))
) )
# Initialize notification system # Initialize notification system

View File

@@ -77,6 +77,12 @@ class AuthHandler:
try: try:
login_data: Dict[str, Any] = response.json() login_data: Dict[str, Any] = response.json()
# Try to find id_user in the nested structure
try:
# First try the new structure: data.user.applications[0].users[0].id_user
self.user_id = str(login_data["data"]["user"]["applications"][0]["users"][0]["id_user"])
except (KeyError, IndexError, TypeError):
# Fallback to old structure if it exists
self.user_id = str(login_data["data"]["user"]["id_user"]) 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}")

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

@@ -39,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.
@@ -114,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:
@@ -133,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:
@@ -164,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

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