Compare commits
10 Commits
795016a60c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc5796d989 | ||
|
|
c073006592 | ||
|
|
ed7165632a | ||
|
|
d809dd6753 | ||
|
|
ea05c847ba | ||
|
|
f5e0bba298 | ||
|
|
9a35a57c7f | ||
|
|
cd5f0a54ac | ||
|
|
d1e5fc1003 | ||
|
|
7d03f5b40c |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
.env
|
||||
preferred_sessions.json
|
||||
log/
|
||||
!log/.gitkeep
|
||||
|
||||
|
||||
54
README.md
54
README.md
@@ -12,12 +12,16 @@ This is a Python application for managing Crossfit bookings and notifications. T
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker
|
||||
- Docker Compose
|
||||
- Python 3.8+
|
||||
- Docker (optional)
|
||||
- Docker Compose (optional)
|
||||
|
||||
## Setup
|
||||
|
||||
1. Create a `.env` file based on `.env.example` and fill in the required credentials.
|
||||
|
||||
### Running with Docker (Recommended)
|
||||
|
||||
2. Build and run the application using Docker Compose:
|
||||
|
||||
```bash
|
||||
@@ -26,6 +30,20 @@ docker-compose up --build
|
||||
|
||||
3. The application will run in a Docker container, and the logs will be stored in the `./log` directory.
|
||||
|
||||
### Manual Setup
|
||||
|
||||
2. Install dependencies:
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. Run the application:
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
The application will automatically check for available sessions and book them based on your preferences. It will send notifications via email and Telegram when a booking is successful.
|
||||
@@ -76,32 +94,46 @@ The application will automatically load the preferred sessions from this file. I
|
||||
|
||||
## Files
|
||||
|
||||
- `main.py`: Main entry point for the application
|
||||
- `src/`: Source code directory
|
||||
- `auth.py`: Authentication handling
|
||||
- `booker.py`: Main booking logic
|
||||
- `session_manager.py`: Session management
|
||||
- `session_notifier.py`: Notification handling
|
||||
- `session_config.py`: Session configuration
|
||||
- `tools/`: Utility scripts
|
||||
- `scripts/`: Additional scripts
|
||||
- `test/`: Test files
|
||||
- `Dockerfile`: Docker image definition
|
||||
- `docker-compose.yml`: Docker Compose service definition
|
||||
- `.env.example`: Example environment variables file
|
||||
- `.dockerignore`: Docker ignore file
|
||||
- `.gitignore`: Git ignore file
|
||||
- `book_crossfit.py`: Main application script
|
||||
- `crossfit_booker.py`: Crossfit booking script
|
||||
- `session_notifier.py`: Session notification script
|
||||
- `preferred_sessions.json`: Configuration file for preferred sessions
|
||||
- `preferred_sessions.json.example`: Example configuration file for preferred sessions
|
||||
- `requirements.txt`: Python dependencies
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
.
|
||||
├── main.py
|
||||
├── src/
|
||||
│ ├── auth.py
|
||||
│ ├── booker.py
|
||||
│ ├── session_manager.py
|
||||
│ ├── session_notifier.py
|
||||
│ └── session_config.py
|
||||
├── tools/
|
||||
├── scripts/
|
||||
├── test/
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── .env.example
|
||||
├── .dockerignore
|
||||
├── .gitignore
|
||||
├── book_crossfit.py
|
||||
├── crossfit_booker.py
|
||||
├── session_notifier.py
|
||||
├── preferred_sessions.json.example
|
||||
├── requirements.txt
|
||||
├── preferred_sessions.json
|
||||
└── log
|
||||
└── log/
|
||||
└── crossfit_booking.log
|
||||
```
|
||||
|
||||
|
||||
9
ascii.md
Normal file
9
ascii.md
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
▄▄▄ ▄▄▄▄▄▄ ▀
|
||||
▄▀ ▀ ▄ ▄▄ ▄▄▄ ▄▄▄ ▄▄▄ █ ▄▄▄ ▄ ▄
|
||||
█ █▀ ▀ █▀ ▀█ █ ▀ █ ▀ █▄▄▄▄▄ █ █▄█
|
||||
█ █ █ █ ▀▀▀▄ ▀▀▀▄ █ █ ▄█▄
|
||||
▀▄▄▄▀ █ ▀█▄█▀ ▀▄▄▄▀ ▀▄▄▄▀ █ ▄▄█▄▄ ▄▀ ▀▄
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
- ./:/app
|
||||
restart: unless-stopped
|
||||
15
main.py
15
main.py
@@ -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
|
||||
auth_handler = AuthHandler(
|
||||
os.environ.get("CROSSFIT_USERNAME"),
|
||||
os.environ.get("CROSSFIT_PASSWORD")
|
||||
str(os.environ.get("CROSSFIT_USERNAME")),
|
||||
str(os.environ.get("CROSSFIT_PASSWORD"))
|
||||
)
|
||||
|
||||
# Initialize notification system
|
||||
|
||||
@@ -77,6 +77,12 @@ class AuthHandler:
|
||||
|
||||
try:
|
||||
login_data: Dict[str, Any] = response.json()
|
||||
# Try to find id_user in the nested structure
|
||||
try:
|
||||
# First try the new structure: data.user.applications[0].users[0].id_user
|
||||
self.user_id = str(login_data["data"]["user"]["applications"][0]["users"][0]["id_user"])
|
||||
except (KeyError, IndexError, TypeError):
|
||||
# Fallback to old structure if it exists
|
||||
self.user_id = str(login_data["data"]["user"]["id_user"])
|
||||
except (KeyError, ValueError) as e:
|
||||
logging.error(f"Error during login: {str(e)} - Response: {response.text}")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -39,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.
|
||||
@@ -114,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:
|
||||
@@ -133,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:
|
||||
@@ -164,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:
|
||||
|
||||
@@ -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"""
|
||||
|
||||
Reference in New Issue
Block a user