refactor: Split code into many files
This commit is contained in:
116
auth.py
Normal file
116
auth.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# Native modules
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
|
# Third-party modules
|
||||||
|
import requests
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
# Configuration constants (will be moved from crossfit_booker.py)
|
||||||
|
APPLICATION_ID = "81560887"
|
||||||
|
DEVICE_TYPE = "3"
|
||||||
|
APP_VERSION = "5.09.21"
|
||||||
|
|
||||||
|
class AuthHandler:
|
||||||
|
"""
|
||||||
|
A class for handling authentication with the CrossFit booking system.
|
||||||
|
This class is responsible for performing login, retrieving auth tokens,
|
||||||
|
and providing authentication headers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, username: str, password: str) -> None:
|
||||||
|
"""
|
||||||
|
Initialize the AuthHandler with credentials.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
username (str): The username for authentication.
|
||||||
|
password (str): The password for authentication.
|
||||||
|
"""
|
||||||
|
self.username = username
|
||||||
|
self.password = password
|
||||||
|
self.auth_token: Optional[str] = None
|
||||||
|
self.user_id: Optional[str] = None
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.base_headers: Dict[str, str] = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"Nubapp-Origin": "user_apps",
|
||||||
|
}
|
||||||
|
self.session.headers.update(self.base_headers)
|
||||||
|
|
||||||
|
def get_auth_headers(self) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
Return headers with authorization if available.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, str]: Headers dictionary with authorization if available.
|
||||||
|
"""
|
||||||
|
headers: Dict[str, str] = self.base_headers.copy()
|
||||||
|
if self.auth_token:
|
||||||
|
headers["Authorization"] = f"Bearer {self.auth_token}"
|
||||||
|
return headers
|
||||||
|
|
||||||
|
def login(self) -> bool:
|
||||||
|
"""
|
||||||
|
Authenticate and get the bearer token.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if login is successful, False otherwise.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# First login endpoint
|
||||||
|
login_params: Dict[str, str] = {
|
||||||
|
"app_version": APP_VERSION,
|
||||||
|
"device_type": DEVICE_TYPE,
|
||||||
|
"username": self.username,
|
||||||
|
"password": self.password
|
||||||
|
}
|
||||||
|
|
||||||
|
response: requests.Response = self.session.post(
|
||||||
|
"https://sport.nubapp.com/api/v4/users/checkUser.php",
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
data=urlencode(login_params))
|
||||||
|
|
||||||
|
if not response.ok:
|
||||||
|
logging.error(f"First login step failed: {response.status_code} - {response.text[:100]}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
login_data: Dict[str, Any] = response.json()
|
||||||
|
self.user_id = str(login_data["data"]["user"]["id_user"])
|
||||||
|
except (KeyError, ValueError) as e:
|
||||||
|
logging.error(f"Error during login: {str(e)} - Response: {response.text}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Second login endpoint
|
||||||
|
response: requests.Response = self.session.post(
|
||||||
|
"https://sport.nubapp.com/api/v4/login",
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"},
|
||||||
|
data=urlencode({
|
||||||
|
"device_type": DEVICE_TYPE,
|
||||||
|
"username": self.username,
|
||||||
|
"password": self.password
|
||||||
|
}))
|
||||||
|
|
||||||
|
if response.ok:
|
||||||
|
try:
|
||||||
|
login_data: Dict[str, Any] = response.json()
|
||||||
|
self.auth_token = login_data.get("token")
|
||||||
|
except (KeyError, ValueError) as e:
|
||||||
|
logging.error(f"Error during login: {str(e)} - Response: {response.text}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.auth_token and self.user_id:
|
||||||
|
logging.info("Successfully logged in")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logging.error(f"Login failed: {response.status_code} - {response.text[:100]}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logging.error(f"Request error during login: {str(e)}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Unexpected error during login: {str(e)}")
|
||||||
|
return False
|
||||||
@@ -31,5 +31,5 @@ if __name__ == "__main__":
|
|||||||
exit(1)
|
exit(1)
|
||||||
|
|
||||||
# Start the continuous booking loop
|
# Start the continuous booking loop
|
||||||
asyncio.run(booker.run())
|
booker.run()
|
||||||
logging.info("Script completed")
|
logging.info("Script completed")
|
||||||
|
|||||||
310
booker.py
Normal file
310
booker.py
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
# Native modules
|
||||||
|
import logging
|
||||||
|
import traceback
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta, date
|
||||||
|
|
||||||
|
# Third-party modules
|
||||||
|
import requests
|
||||||
|
from dateutil.parser import parse
|
||||||
|
import pytz
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
from typing import List, Dict, Optional, Any, Tuple
|
||||||
|
|
||||||
|
# Import the SessionNotifier class
|
||||||
|
from session_notifier import SessionNotifier
|
||||||
|
|
||||||
|
# Import the preferred sessions from the session_config module
|
||||||
|
from session_config import PREFERRED_SESSIONS
|
||||||
|
|
||||||
|
# Import the AuthHandler class
|
||||||
|
from auth import AuthHandler
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
USERNAME = os.environ.get("CROSSFIT_USERNAME")
|
||||||
|
PASSWORD = os.environ.get("CROSSFIT_PASSWORD")
|
||||||
|
|
||||||
|
if not all([USERNAME, PASSWORD]):
|
||||||
|
raise ValueError("Missing environment variables: CROSSFIT_USERNAME and/or CROSSFIT_PASSWORD")
|
||||||
|
|
||||||
|
APPLICATION_ID = "81560887"
|
||||||
|
CATEGORY_ID = "677" # Activity category ID for CrossFit
|
||||||
|
TIMEZONE = "Europe/Paris" # Adjust to your timezone
|
||||||
|
# Booking window configuration (can be overridden by environment variables)
|
||||||
|
# TARGET_RESERVATION_TIME: string "HH:MM" local time when bookings open (default 20:01)
|
||||||
|
# BOOKING_WINDOW_END_DELTA_MINUTES: int minutes after target time to stop booking (default 10)
|
||||||
|
TARGET_RESERVATION_TIME = os.environ.get("TARGET_RESERVATION_TIME", "20:01")
|
||||||
|
BOOKING_WINDOW_END_DELTA_MINUTES = int(os.environ.get("BOOKING_WINDOW_END_DELTA_MINUTES", "10"))
|
||||||
|
DEVICE_TYPE = "3"
|
||||||
|
|
||||||
|
# Retry configuration
|
||||||
|
RETRY_MAX = 3
|
||||||
|
RETRY_BACKOFF = 1
|
||||||
|
APP_VERSION = "5.09.21"
|
||||||
|
|
||||||
|
class Booker:
|
||||||
|
"""
|
||||||
|
A class for handling the main booking logic.
|
||||||
|
This class is designed to be used as a standalone component
|
||||||
|
that can be initialized with authentication and session management
|
||||||
|
and used to perform the booking process.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, auth_handler: AuthHandler, notifier: SessionNotifier) -> None:
|
||||||
|
"""
|
||||||
|
Initialize the Booker with necessary attributes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
auth_handler (AuthHandler): AuthHandler instance for authentication.
|
||||||
|
notifier (SessionNotifier): SessionNotifier instance for sending notifications.
|
||||||
|
"""
|
||||||
|
self.auth_handler = auth_handler
|
||||||
|
self.notifier = notifier
|
||||||
|
|
||||||
|
# Initialize the session and headers
|
||||||
|
self.session: requests.Session = requests.Session()
|
||||||
|
self.base_headers: Dict[str, str] = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"Nubapp-Origin": "user_apps",
|
||||||
|
}
|
||||||
|
self.session.headers.update(self.base_headers)
|
||||||
|
|
||||||
|
# Define mandatory parameters for API calls
|
||||||
|
self.mandatory_params: Dict[str, str] = {
|
||||||
|
"app_version": APP_VERSION,
|
||||||
|
"device_type": DEVICE_TYPE,
|
||||||
|
"id_application": APPLICATION_ID,
|
||||||
|
"id_category_activity": CATEGORY_ID
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_auth_headers(self) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
Return headers with authorization from the AuthHandler.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, str]: Headers dictionary with authorization if available.
|
||||||
|
"""
|
||||||
|
return self.auth_handler.get_auth_headers()
|
||||||
|
|
||||||
|
async def booker(self, current_time: datetime) -> None:
|
||||||
|
"""
|
||||||
|
Run one cycle of checking and booking sessions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current_time (datetime): Current time for comparison.
|
||||||
|
"""
|
||||||
|
# Calculate date range to check (current day, day + 1, and day + 2)
|
||||||
|
start_date: date = current_time.date()
|
||||||
|
end_date: date = start_date + timedelta(days=2) # Only go up to day + 2
|
||||||
|
|
||||||
|
# Get available sessions
|
||||||
|
sessions_data: Optional[Dict[str, Any]] = self.get_available_sessions(start_date, end_date)
|
||||||
|
if not sessions_data or not sessions_data.get("success", False):
|
||||||
|
logging.error("No sessions available or error fetching sessions")
|
||||||
|
return
|
||||||
|
|
||||||
|
activities: List[Dict[str, Any]] = sessions_data.get("data", {}).get("activities_calendar", [])
|
||||||
|
|
||||||
|
# Display all available sessions within the date range
|
||||||
|
self.display_upcoming_sessions(activities, current_time)
|
||||||
|
|
||||||
|
# Find sessions to book (preferred only) within allowed date range
|
||||||
|
found_preferred_sessions: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
for session in activities:
|
||||||
|
session_time: datetime = parse(session["start_timestamp"])
|
||||||
|
if not session_time.tzinfo:
|
||||||
|
session_time = pytz.timezone(TIMEZONE).localize(session_time)
|
||||||
|
|
||||||
|
# Check if session is within allowed date range (current day, day + 1, or day + 2)
|
||||||
|
days_diff = (session_time.date() - current_time.date()).days
|
||||||
|
if not (0 <= days_diff <= 2):
|
||||||
|
continue # Skip sessions outside the allowed date range
|
||||||
|
|
||||||
|
# Check if session is preferred and bookable
|
||||||
|
if self.is_session_bookable(session, current_time):
|
||||||
|
if self.matches_preferred_session(session, current_time):
|
||||||
|
found_preferred_sessions.append(session)
|
||||||
|
|
||||||
|
# Display preferred sessions found
|
||||||
|
if found_preferred_sessions:
|
||||||
|
logging.info("Preferred sessions found:")
|
||||||
|
for session in found_preferred_sessions:
|
||||||
|
session_time: datetime = parse(session["start_timestamp"])
|
||||||
|
if not session_time.tzinfo:
|
||||||
|
session_time = pytz.timezone(TIMEZONE).localize(session_time)
|
||||||
|
logging.info(f"ID: {session['id_activity_calendar']}, Name: {session['name_activity']}, Time: {session_time.strftime('%Y-%m-%d %H:%M')}")
|
||||||
|
else:
|
||||||
|
logging.info("No matching preferred sessions found")
|
||||||
|
|
||||||
|
# Book preferred sessions
|
||||||
|
if not found_preferred_sessions:
|
||||||
|
logging.info("No matching sessions found to book")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Book sessions (preferred first)
|
||||||
|
sessions_to_book = [("Preferred", session) for session in found_preferred_sessions]
|
||||||
|
sessions_to_book.sort(key=lambda x: 0 if x[0] == "Preferred" else 1)
|
||||||
|
booked_sessions = []
|
||||||
|
|
||||||
|
for session_type, session in sessions_to_book:
|
||||||
|
session_time: datetime = parse(session["start_timestamp"])
|
||||||
|
logging.info(f"Attempting to book {session_type} session at {session_time} ({session['name_activity']})")
|
||||||
|
if self.book_session(session["id_activity_calendar"]):
|
||||||
|
# Display booked session
|
||||||
|
booked_sessions.append(session)
|
||||||
|
logging.info(f"Successfully booked {session_type} session at {session_time}")
|
||||||
|
|
||||||
|
# Notify about booked session
|
||||||
|
session_details = f"{session['name_activity']} at {session_time.strftime('%Y-%m-%d %H:%M')}"
|
||||||
|
await self.notifier.notify_session_booking(session_details)
|
||||||
|
else:
|
||||||
|
logging.error(f"Failed to book {session_type} session at {session_time}")
|
||||||
|
|
||||||
|
# Notify about failed booking
|
||||||
|
session_details = f"{session['name_activity']} at {session_time.strftime('%Y-%m-%d %H:%M')}"
|
||||||
|
await self.notifier.notify_impossible_booking(session_details)
|
||||||
|
logging.info(f"Notified about impossible booking for {session_type} session at {session_time}")
|
||||||
|
|
||||||
|
# Display all booked session(s)
|
||||||
|
if booked_sessions:
|
||||||
|
logging.info("Booked sessions:")
|
||||||
|
for session in booked_sessions:
|
||||||
|
session_time: datetime = parse(session["start_timestamp"])
|
||||||
|
if not session_time.tzinfo:
|
||||||
|
session_time = pytz.timezone(TIMEZONE).localize(session_time)
|
||||||
|
logging.info(f"ID: {session['id_activity_calendar']}, Name: {session['name_activity']}, Time: {session_time.strftime('%Y-%m-%d %H:%M')}")
|
||||||
|
else:
|
||||||
|
logging.info("No sessions were booked")
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
"""
|
||||||
|
Main execution loop.
|
||||||
|
"""
|
||||||
|
# Set up timezone
|
||||||
|
tz: pytz.timezone = pytz.timezone(TIMEZONE)
|
||||||
|
|
||||||
|
# Parse TARGET_RESERVATION_TIME to get the target hour and minute
|
||||||
|
target_hour, target_minute = map(int, TARGET_RESERVATION_TIME.split(":"))
|
||||||
|
target_time = datetime.now(tz).replace(hour=target_hour, minute=target_minute, second=0, microsecond=0)
|
||||||
|
booking_window_end = target_time + timedelta(minutes=BOOKING_WINDOW_END_DELTA_MINUTES)
|
||||||
|
|
||||||
|
# Initial login
|
||||||
|
if not self.auth_handler.login():
|
||||||
|
logging.error("Authentication failed - exiting program")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
current_time: datetime = datetime.now(tz)
|
||||||
|
logging.info(f"Current time: {current_time}")
|
||||||
|
|
||||||
|
# Only book sessions if current time is within the booking window
|
||||||
|
if target_time <= current_time <= booking_window_end:
|
||||||
|
# Run booking cycle to check for preferred sessions and book
|
||||||
|
await self.booker(current_time)
|
||||||
|
# Wait for a short time before next check
|
||||||
|
time.sleep(60)
|
||||||
|
else:
|
||||||
|
# Check again in 5 minutes if outside booking window
|
||||||
|
time.sleep(300)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Unexpected error in booking cycle: {str(e)} - Traceback: {traceback.format_exc()}")
|
||||||
|
time.sleep(60) # Wait before retrying after error
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
self.quit()
|
||||||
|
|
||||||
|
def quit(self) -> None:
|
||||||
|
"""
|
||||||
|
Clean up resources and exit the script.
|
||||||
|
"""
|
||||||
|
logging.info("Script interrupted by user. Quitting...")
|
||||||
|
exit(0)
|
||||||
|
|
||||||
|
def get_available_sessions(self, start_date: date, end_date: date) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Fetch available sessions from the API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_date (date): Start date for fetching sessions.
|
||||||
|
end_date (date): End date for fetching sessions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optional[Dict[str, Any]]: Dictionary containing available sessions if successful, None otherwise.
|
||||||
|
"""
|
||||||
|
from session_manager import SessionManager
|
||||||
|
session_manager = SessionManager(self.auth_handler)
|
||||||
|
return session_manager.get_available_sessions(start_date, end_date)
|
||||||
|
|
||||||
|
def book_session(self, session_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Book a specific session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id (str): ID of the session to book.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if booking is successful, False otherwise.
|
||||||
|
"""
|
||||||
|
from session_manager import SessionManager
|
||||||
|
session_manager = SessionManager(self.auth_handler)
|
||||||
|
return session_manager.book_session(session_id)
|
||||||
|
|
||||||
|
def get_booked_sessions(self) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get a list of booked sessions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of dictionaries containing information about the booked sessions.
|
||||||
|
"""
|
||||||
|
from session_manager import SessionManager
|
||||||
|
session_manager = SessionManager(self.auth_handler)
|
||||||
|
return session_manager.get_booked_sessions()
|
||||||
|
|
||||||
|
def is_session_bookable(self, session: Dict[str, Any], current_time: datetime) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a session is bookable based on user_info.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session (Dict[str, Any]): Session data.
|
||||||
|
current_time (datetime): Current time for comparison.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the session is bookable, False otherwise.
|
||||||
|
"""
|
||||||
|
from session_manager import SessionManager
|
||||||
|
session_manager = SessionManager(self.auth_handler)
|
||||||
|
return session_manager.is_session_bookable(session, current_time)
|
||||||
|
|
||||||
|
def matches_preferred_session(self, session: Dict[str, Any], current_time: datetime) -> bool:
|
||||||
|
"""
|
||||||
|
Check if session matches one of your preferred sessions with exact matching.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session (Dict[str, Any]): Session data.
|
||||||
|
current_time (datetime): Current time for comparison.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the session matches a preferred session, False otherwise.
|
||||||
|
"""
|
||||||
|
from session_manager import SessionManager
|
||||||
|
session_manager = SessionManager(self.auth_handler)
|
||||||
|
return session_manager.matches_preferred_session(session, current_time)
|
||||||
|
|
||||||
|
def display_upcoming_sessions(self, sessions: List[Dict[str, Any]], current_time: datetime) -> None:
|
||||||
|
"""
|
||||||
|
Display upcoming sessions with ID, name, date, and time.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sessions (List[Dict[str, Any]]): List of session data.
|
||||||
|
current_time (datetime): Current time for comparison.
|
||||||
|
"""
|
||||||
|
from session_manager import SessionManager
|
||||||
|
session_manager = SessionManager(self.auth_handler)
|
||||||
|
session_manager.display_upcoming_sessions(sessions, current_time)
|
||||||
@@ -1,25 +1,22 @@
|
|||||||
# Native modules
|
# Native modules
|
||||||
import logging
|
import logging
|
||||||
import traceback
|
|
||||||
import os
|
import os
|
||||||
import time
|
from typing import Dict, Any, Optional
|
||||||
import difflib
|
|
||||||
from datetime import datetime, timedelta, date
|
|
||||||
|
|
||||||
# Third-party modules
|
# Import the AuthHandler class
|
||||||
import requests
|
from auth import AuthHandler
|
||||||
from dateutil.parser import parse
|
|
||||||
import pytz
|
# Import the SessionManager class
|
||||||
from dotenv import load_dotenv
|
from session_manager import SessionManager
|
||||||
from urllib.parse import urlencode
|
|
||||||
from typing import List, Dict, Optional, Any, Tuple
|
# Import the Booker class
|
||||||
|
from booker import Booker
|
||||||
|
|
||||||
# Import the SessionNotifier class
|
# Import the SessionNotifier class
|
||||||
from session_notifier import SessionNotifier
|
from session_notifier import SessionNotifier
|
||||||
|
|
||||||
# Import the preferred sessions from the session_config module
|
# Load environment variables
|
||||||
from session_config import PREFERRED_SESSIONS
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
@@ -29,52 +26,23 @@ PASSWORD = os.environ.get("CROSSFIT_PASSWORD")
|
|||||||
if not all([USERNAME, PASSWORD]):
|
if not all([USERNAME, PASSWORD]):
|
||||||
raise ValueError("Missing environment variables: CROSSFIT_USERNAME and/or CROSSFIT_PASSWORD")
|
raise ValueError("Missing environment variables: CROSSFIT_USERNAME and/or CROSSFIT_PASSWORD")
|
||||||
|
|
||||||
APPLICATION_ID = "81560887"
|
|
||||||
CATEGORY_ID = "677" # Activity category ID for CrossFit
|
|
||||||
TIMEZONE = "Europe/Paris" # Adjust to your timezone
|
|
||||||
# Booking window configuration (can be overridden by environment variables)
|
|
||||||
# TARGET_RESERVATION_TIME: string "HH:MM" local time when bookings open (default 20:01)
|
|
||||||
# BOOKING_WINDOW_END_DELTA_MINUTES: int minutes after target time to stop booking (default 10)
|
|
||||||
TARGET_RESERVATION_TIME = os.environ.get("TARGET_RESERVATION_TIME", "20:01")
|
|
||||||
BOOKING_WINDOW_END_DELTA_MINUTES = int(os.environ.get("BOOKING_WINDOW_END_DELTA_MINUTES", "10"))
|
|
||||||
DEVICE_TYPE = "3"
|
|
||||||
|
|
||||||
# Retry configuration
|
|
||||||
RETRY_MAX = 3
|
|
||||||
RETRY_BACKOFF = 1
|
|
||||||
APP_VERSION = "5.09.21"
|
|
||||||
|
|
||||||
|
|
||||||
class CrossFitBooker:
|
class CrossFitBooker:
|
||||||
"""
|
"""
|
||||||
A class for automating the booking of CrossFit sessions.
|
A simple orchestrator class for the CrossFit booking system.
|
||||||
This class handles authentication, session availability checking,
|
|
||||||
booking, and notifications for CrossFit sessions.
|
This class is responsible for initializing and coordinating the other components
|
||||||
|
(AuthHandler, SessionManager, and Booker) but does not implement the actual functionality.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
"""
|
"""
|
||||||
Initialize the CrossFitBooker with necessary attributes.
|
Initialize the CrossFitBooker with necessary components.
|
||||||
Sets up authentication tokens, session headers, mandatory parameters,
|
|
||||||
and initializes the SessionNotifier for sending notifications.
|
|
||||||
"""
|
"""
|
||||||
self.auth_token: Optional[str] = None
|
# Initialize the AuthHandler with credentials from environment variables
|
||||||
self.user_id: Optional[str] = None
|
self.auth_handler = AuthHandler(USERNAME, PASSWORD)
|
||||||
self.session: requests.Session = requests.Session()
|
|
||||||
self.base_headers: Dict[str, str] = {
|
|
||||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0",
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
"Nubapp-Origin": "user_apps",
|
|
||||||
}
|
|
||||||
self.session.headers.update(self.base_headers)
|
|
||||||
|
|
||||||
# Define mandatory parameters for API calls
|
# Initialize the SessionManager with the AuthHandler
|
||||||
self.mandatory_params: Dict[str, str] = {
|
self.session_manager = SessionManager(self.auth_handler)
|
||||||
"app_version": APP_VERSION,
|
|
||||||
"device_type": DEVICE_TYPE,
|
|
||||||
"id_application": APPLICATION_ID,
|
|
||||||
"id_category_activity": CATEGORY_ID
|
|
||||||
}
|
|
||||||
|
|
||||||
# Initialize the SessionNotifier with credentials from environment variables
|
# Initialize the SessionNotifier with credentials from environment variables
|
||||||
email_credentials = {
|
email_credentials = {
|
||||||
@@ -99,441 +67,14 @@ class CrossFitBooker:
|
|||||||
enable_telegram=enable_telegram
|
enable_telegram=enable_telegram
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_auth_headers(self) -> Dict[str, str]:
|
# Initialize the Booker with the AuthHandler and SessionNotifier
|
||||||
|
self.booker = Booker(self.auth_handler, self.notifier)
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
"""
|
"""
|
||||||
Return headers with authorization if available.
|
Start the booking process.
|
||||||
Returns:
|
|
||||||
Dict[str, str]: Headers dictionary with authorization if available.
|
This method initiates the booking process by running the Booker's main execution loop.
|
||||||
"""
|
"""
|
||||||
headers: Dict[str, str] = self.base_headers.copy()
|
import asyncio
|
||||||
if self.auth_token:
|
asyncio.run(self.booker.run())
|
||||||
headers["Authorization"] = f"Bearer {self.auth_token}"
|
|
||||||
return headers
|
|
||||||
|
|
||||||
def login(self) -> bool:
|
|
||||||
"""
|
|
||||||
Authenticate and get the bearer token.
|
|
||||||
Returns:
|
|
||||||
bool: True if login is successful, False otherwise.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# First login endpoint
|
|
||||||
login_params: Dict[str, str] = {
|
|
||||||
"app_version": APP_VERSION,
|
|
||||||
"device_type": DEVICE_TYPE,
|
|
||||||
"username": USERNAME,
|
|
||||||
"password": PASSWORD
|
|
||||||
}
|
|
||||||
|
|
||||||
response: requests.Response = self.session.post(
|
|
||||||
"https://sport.nubapp.com/api/v4/users/checkUser.php",
|
|
||||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
||||||
data=urlencode(login_params))
|
|
||||||
|
|
||||||
if not response.ok:
|
|
||||||
logging.error(f"First login step failed: {response.status_code} - {response.text[:100]}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
login_data: Dict[str, Any] = response.json()
|
|
||||||
self.user_id = str(login_data["data"]["user"]["id_user"])
|
|
||||||
except (KeyError, ValueError) as e:
|
|
||||||
logging.error(f"Error during login: {str(e)} - Response: {response.text}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Second login endpoint
|
|
||||||
response: requests.Response = self.session.post(
|
|
||||||
"https://sport.nubapp.com/api/v4/login",
|
|
||||||
headers={"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"},
|
|
||||||
data=urlencode({
|
|
||||||
"device_type": DEVICE_TYPE,
|
|
||||||
"username": USERNAME,
|
|
||||||
"password": PASSWORD
|
|
||||||
}))
|
|
||||||
|
|
||||||
if response.ok:
|
|
||||||
try:
|
|
||||||
login_data: Dict[str, Any] = response.json()
|
|
||||||
self.auth_token = login_data.get("token")
|
|
||||||
except (KeyError, ValueError) as e:
|
|
||||||
logging.error(f"Error during login: {str(e)} - Response: {response.text}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
if self.auth_token and self.user_id:
|
|
||||||
logging.info("Successfully logged in")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
logging.error(f"Login failed: {response.status_code} - {response.text[:100]}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
logging.error(f"Request error during login: {str(e)}")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Unexpected error during login: {str(e)}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_available_sessions(self, start_date: date, end_date: date) -> Optional[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Fetch available sessions from the API.
|
|
||||||
Args:
|
|
||||||
start_date (date): Start date for fetching sessions.
|
|
||||||
end_date (date): End date for fetching sessions.
|
|
||||||
Returns:
|
|
||||||
Optional[Dict[str, Any]]: Dictionary containing available sessions if successful, None otherwise.
|
|
||||||
"""
|
|
||||||
if not self.auth_token or not self.user_id:
|
|
||||||
logging.error("Authentication required - missing token or user ID")
|
|
||||||
return None
|
|
||||||
|
|
||||||
url: str = "https://sport.nubapp.com/api/v4/activities/getActivitiesCalendar.php"
|
|
||||||
|
|
||||||
# Prepare request with mandatory parameters
|
|
||||||
request_data: Dict[str, str] = self.mandatory_params.copy()
|
|
||||||
request_data.update({
|
|
||||||
"id_user": self.user_id,
|
|
||||||
"start_timestamp": start_date.strftime("%d-%m-%Y"),
|
|
||||||
"end_timestamp": end_date.strftime("%d-%m-%Y")
|
|
||||||
})
|
|
||||||
|
|
||||||
# Add retry logic
|
|
||||||
for retry in range(RETRY_MAX):
|
|
||||||
try:
|
|
||||||
response: requests.Response = self.session.post(
|
|
||||||
url,
|
|
||||||
headers=self.get_auth_headers(),
|
|
||||||
data=urlencode(request_data),
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
|
||||||
return response.json()
|
|
||||||
elif response.status_code == 401:
|
|
||||||
logging.error("401 Unauthorized - token may be expired or invalid")
|
|
||||||
return None
|
|
||||||
elif 500 <= response.status_code < 600:
|
|
||||||
logging.error(f"Server error {response.status_code}")
|
|
||||||
raise requests.exceptions.ConnectionError(f"Server error {response.status_code}")
|
|
||||||
else:
|
|
||||||
logging.error(f"Unexpected status code: {response.status_code} - {response.text[:100]}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
if retry == RETRY_MAX - 1:
|
|
||||||
logging.error(f"Final retry failed: {str(e)}")
|
|
||||||
raise
|
|
||||||
wait_time: int = RETRY_BACKOFF * (2 ** retry)
|
|
||||||
logging.warning(f"Request failed (attempt {retry+1}/{RETRY_MAX}): {str(e)}. Retrying in {wait_time}s...")
|
|
||||||
time.sleep(wait_time)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def book_session(self, session_id: str) -> bool:
|
|
||||||
"""
|
|
||||||
Book a specific session.
|
|
||||||
Args:
|
|
||||||
session_id (str): ID of the session to book.
|
|
||||||
Returns:
|
|
||||||
bool: True if booking is successful, False otherwise.
|
|
||||||
"""
|
|
||||||
url = "https://sport.nubapp.com/api/v4/activities/bookActivityCalendar.php"
|
|
||||||
data = {
|
|
||||||
**self.mandatory_params,
|
|
||||||
"id_activity_calendar": session_id,
|
|
||||||
"id_user": self.user_id,
|
|
||||||
"action_by": self.user_id,
|
|
||||||
"n_guests": "0",
|
|
||||||
"booked_on": "1",
|
|
||||||
"device_type": self.mandatory_params["device_type"],
|
|
||||||
"token": self.auth_token
|
|
||||||
}
|
|
||||||
|
|
||||||
for retry in range(RETRY_MAX):
|
|
||||||
try:
|
|
||||||
response: requests.Response = self.session.post(
|
|
||||||
url,
|
|
||||||
headers=self.get_auth_headers(),
|
|
||||||
data=urlencode(data),
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
|
||||||
json_response: Dict[str, Any] = response.json()
|
|
||||||
if json_response.get("success", False):
|
|
||||||
logging.info(f"Successfully booked session {session_id}")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
logging.error(f"API returned success:false: {json_response} - Session ID: {session_id}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
logging.error(f"HTTP {response.status_code}: {response.text[:100]}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
if retry == RETRY_MAX - 1:
|
|
||||||
logging.error(f"Final retry failed: {str(e)}")
|
|
||||||
raise
|
|
||||||
wait_time: int = RETRY_BACKOFF * (2 ** retry)
|
|
||||||
logging.warning(f"Request failed (attempt {retry+1}/{RETRY_MAX}): {str(e)}. Retrying in {wait_time}s...")
|
|
||||||
time.sleep(wait_time)
|
|
||||||
|
|
||||||
logging.error(f"Failed to complete request after {RETRY_MAX} attempts")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_booked_sessions(self) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Get a list of booked sessions.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A list of dictionaries containing information about the booked sessions.
|
|
||||||
"""
|
|
||||||
url = "https://sport.nubapp.com/api/v4/activities/getBookedActivities.php"
|
|
||||||
data = {
|
|
||||||
**self.mandatory_params,
|
|
||||||
"id_user": self.user_id,
|
|
||||||
"action_by": self.user_id
|
|
||||||
}
|
|
||||||
|
|
||||||
for retry in range(RETRY_MAX):
|
|
||||||
try:
|
|
||||||
response: requests.Response = self.session.post(
|
|
||||||
url,
|
|
||||||
headers=self.get_auth_headers(),
|
|
||||||
data=urlencode(data),
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
|
||||||
json_response: Dict[str, Any] = response.json()
|
|
||||||
if json_response.get("success", False):
|
|
||||||
return json_response.get("data", [])
|
|
||||||
logging.error(f"API returned success:false: {json_response}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
logging.error(f"HTTP {response.status_code}: {response.text[:100]}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
if retry == RETRY_MAX - 1:
|
|
||||||
logging.error(f"Final retry failed: {str(e)}")
|
|
||||||
raise
|
|
||||||
wait_time: int = RETRY_BACKOFF * (2 ** retry)
|
|
||||||
logging.warning(f"Request failed (attempt {retry+1}/{RETRY_MAX}): {str(e)}. Retrying in {wait_time}s...")
|
|
||||||
time.sleep(wait_time)
|
|
||||||
|
|
||||||
logging.error(f"Failed to complete request after {RETRY_MAX} attempts")
|
|
||||||
return []
|
|
||||||
|
|
||||||
def is_session_bookable(self, session: Dict[str, Any], current_time: datetime) -> bool:
|
|
||||||
"""
|
|
||||||
Check if a session is bookable based on user_info.
|
|
||||||
Args:
|
|
||||||
session (Dict[str, Any]): Session data.
|
|
||||||
current_time (datetime): Current time for comparison.
|
|
||||||
Returns:
|
|
||||||
bool: True if the session is bookable, False otherwise.
|
|
||||||
"""
|
|
||||||
user_info: Dict[str, Any] = session.get("user_info", {})
|
|
||||||
|
|
||||||
# First check if can_join is true (primary condition)
|
|
||||||
if user_info.get("can_join", False):
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Default case: not bookable
|
|
||||||
return False
|
|
||||||
|
|
||||||
def matches_preferred_session(self, session: Dict[str, Any], current_time: datetime) -> bool:
|
|
||||||
"""
|
|
||||||
Check if session matches one of your preferred sessions with exact matching.
|
|
||||||
Args:
|
|
||||||
session (Dict[str, Any]): Session data.
|
|
||||||
current_time (datetime): Current time for comparison.
|
|
||||||
Returns:
|
|
||||||
bool: True if the session matches a preferred session, False otherwise.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
session_time: datetime = parse(session["start_timestamp"])
|
|
||||||
if not session_time.tzinfo:
|
|
||||||
session_time = pytz.timezone(TIMEZONE).localize(session_time)
|
|
||||||
|
|
||||||
day_of_week: int = session_time.weekday()
|
|
||||||
session_time_str: str = session_time.strftime("%H:%M")
|
|
||||||
session_name: str = session.get("name_activity", "").upper()
|
|
||||||
|
|
||||||
for preferred_day, preferred_time, preferred_name in PREFERRED_SESSIONS:
|
|
||||||
# Exact match
|
|
||||||
if (day_of_week == preferred_day and
|
|
||||||
session_time_str == preferred_time and
|
|
||||||
preferred_name in session_name):
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Failed to check session: {str(e)} - Session: {session}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def display_upcoming_sessions(self, sessions: List[Dict[str, Any]], current_time: datetime) -> None:
|
|
||||||
"""
|
|
||||||
Display upcoming sessions with ID, name, date, and time.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
sessions (List[Dict[str, Any]]): List of session data.
|
|
||||||
current_time (datetime): Current time for comparison.
|
|
||||||
"""
|
|
||||||
if not sessions:
|
|
||||||
logging.info("No sessions to display")
|
|
||||||
return
|
|
||||||
|
|
||||||
logging.info("Upcoming sessions:")
|
|
||||||
logging.info("ID\t\tName\t\tDate\t\tTime")
|
|
||||||
logging.info("="*50)
|
|
||||||
|
|
||||||
for session in sessions:
|
|
||||||
session_time: datetime = parse(session["start_timestamp"])
|
|
||||||
if not session_time.tzinfo:
|
|
||||||
session_time = pytz.timezone(TIMEZONE).localize(session_time)
|
|
||||||
|
|
||||||
# Format session details
|
|
||||||
session_id = session.get("id_activity_calendar", "N/A")
|
|
||||||
session_name = session.get("name_activity", "N/A")
|
|
||||||
session_date = session_time.strftime("%Y-%m-%d")
|
|
||||||
session_time_str = session_time.strftime("%H:%M")
|
|
||||||
|
|
||||||
# Display session details
|
|
||||||
logging.info(f"{session_id}\t{session_name}\t{session_date}\t{session_time_str}")
|
|
||||||
|
|
||||||
async def booker(self, current_time: datetime) -> None:
|
|
||||||
"""
|
|
||||||
Run one cycle of checking and booking sessions.
|
|
||||||
Args:
|
|
||||||
current_time (datetime): Current time for comparison.
|
|
||||||
"""
|
|
||||||
# Calculate date range to check (current day, day + 1, and day + 2)
|
|
||||||
start_date: date = current_time.date()
|
|
||||||
end_date: date = start_date + timedelta(days=2) # Only go up to day + 2
|
|
||||||
|
|
||||||
# Get available sessions
|
|
||||||
sessions_data: Optional[Dict[str, Any]] = self.get_available_sessions(start_date, end_date)
|
|
||||||
if not sessions_data or not sessions_data.get("success", False):
|
|
||||||
logging.error("No sessions available or error fetching sessions")
|
|
||||||
return
|
|
||||||
|
|
||||||
activities: List[Dict[str, Any]] = sessions_data.get("data", {}).get("activities_calendar", [])
|
|
||||||
|
|
||||||
# Display all available sessions within the date range
|
|
||||||
self.display_upcoming_sessions(activities, current_time)
|
|
||||||
|
|
||||||
# Find sessions to book (preferred only) within allowed date range
|
|
||||||
found_preferred_sessions: List[Dict[str, Any]] = []
|
|
||||||
|
|
||||||
for session in activities:
|
|
||||||
session_time: datetime = parse(session["start_timestamp"])
|
|
||||||
if not session_time.tzinfo:
|
|
||||||
session_time = pytz.timezone(TIMEZONE).localize(session_time)
|
|
||||||
|
|
||||||
# Check if session is within allowed date range (current day, day + 1, or day + 2)
|
|
||||||
days_diff = (session_time.date() - current_time.date()).days
|
|
||||||
if not (0 <= days_diff <= 2):
|
|
||||||
continue # Skip sessions outside the allowed date range
|
|
||||||
|
|
||||||
# Check if session is preferred and bookable
|
|
||||||
if self.is_session_bookable(session, current_time):
|
|
||||||
if self.matches_preferred_session(session, current_time):
|
|
||||||
found_preferred_sessions.append(session)
|
|
||||||
|
|
||||||
# Display preferred sessions found
|
|
||||||
if found_preferred_sessions:
|
|
||||||
logging.info("Preferred sessions found:")
|
|
||||||
for session in found_preferred_sessions:
|
|
||||||
session_time: datetime = parse(session["start_timestamp"])
|
|
||||||
if not session_time.tzinfo:
|
|
||||||
session_time = pytz.timezone(TIMEZONE).localize(session_time)
|
|
||||||
logging.info(f"ID: {session['id_activity_calendar']}, Name: {session['name_activity']}, Time: {session_time.strftime('%Y-%m-%d %H:%M')}")
|
|
||||||
else:
|
|
||||||
logging.info("No matching preferred sessions found")
|
|
||||||
|
|
||||||
# Book preferred sessions
|
|
||||||
if not found_preferred_sessions:
|
|
||||||
logging.info("No matching sessions found to book")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Book sessions (preferred first)
|
|
||||||
sessions_to_book = [("Preferred", session) for session in found_preferred_sessions]
|
|
||||||
sessions_to_book.sort(key=lambda x: 0 if x[0] == "Preferred" else 1)
|
|
||||||
booked_sessions = []
|
|
||||||
|
|
||||||
for session_type, session in sessions_to_book:
|
|
||||||
session_time: datetime = parse(session["start_timestamp"])
|
|
||||||
logging.info(f"Attempting to book {session_type} session at {session_time} ({session['name_activity']})")
|
|
||||||
if self.book_session(session["id_activity_calendar"]):
|
|
||||||
# Display booked session
|
|
||||||
booked_sessions.append(session)
|
|
||||||
logging.info(f"Successfully booked {session_type} session at {session_time}")
|
|
||||||
|
|
||||||
# Notify about booked session
|
|
||||||
session_details = f"{session['name_activity']} at {session_time.strftime('%Y-%m-%d %H:%M')}"
|
|
||||||
await self.notifier.notify_session_booking(session_details)
|
|
||||||
else:
|
|
||||||
logging.error(f"Failed to book {session_type} session at {session_time}")
|
|
||||||
|
|
||||||
# Notify about failed booking
|
|
||||||
session_details = f"{session['name_activity']} at {session_time.strftime('%Y-%m-%d %H:%M')}"
|
|
||||||
await self.notifier.notify_impossible_booking(session_details)
|
|
||||||
logging.info(f"Notified about impossible booking for {session_type} session at {session_time}")
|
|
||||||
|
|
||||||
# Display all booked session(s)
|
|
||||||
if booked_sessions:
|
|
||||||
logging.info("Booked sessions:")
|
|
||||||
for session in booked_sessions:
|
|
||||||
session_time: datetime = parse(session["start_timestamp"])
|
|
||||||
if not session_time.tzinfo:
|
|
||||||
session_time = pytz.timezone(TIMEZONE).localize(session_time)
|
|
||||||
logging.info(f"ID: {session['id_activity_calendar']}, Name: {session['name_activity']}, Time: {session_time.strftime('%Y-%m-%d %H:%M')}")
|
|
||||||
else:
|
|
||||||
logging.info("No sessions were booked")
|
|
||||||
|
|
||||||
async def run(self) -> None:
|
|
||||||
"""
|
|
||||||
Main execution loop.
|
|
||||||
"""
|
|
||||||
# Set up timezone
|
|
||||||
tz: pytz.timezone = pytz.timezone(TIMEZONE)
|
|
||||||
|
|
||||||
# Parse TARGET_RESERVATION_TIME to get the target hour and minute
|
|
||||||
target_hour, target_minute = map(int, TARGET_RESERVATION_TIME.split(":"))
|
|
||||||
target_time = datetime.now(tz).replace(hour=target_hour, minute=target_minute, second=0, microsecond=0)
|
|
||||||
booking_window_end = target_time + timedelta(minutes=BOOKING_WINDOW_END_DELTA_MINUTES)
|
|
||||||
|
|
||||||
# Initial login
|
|
||||||
if not self.login():
|
|
||||||
logging.error("Authentication failed - exiting program")
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
current_time: datetime = datetime.now(tz)
|
|
||||||
logging.info(f"Current time: {current_time}")
|
|
||||||
|
|
||||||
# Only book sessions if current time is within the booking window
|
|
||||||
if target_time <= current_time <= booking_window_end:
|
|
||||||
# Run booking cycle to check for preferred sessions and book
|
|
||||||
await self.booker(current_time)
|
|
||||||
# Wait for a short time before next check
|
|
||||||
time.sleep(60)
|
|
||||||
else:
|
|
||||||
# Check again in 5 minutes if outside booking window
|
|
||||||
time.sleep(300)
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Unexpected error in booking cycle: {str(e)} - Traceback: {traceback.format_exc()}")
|
|
||||||
time.sleep(60) # Wait before retrying after error
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
self.quit()
|
|
||||||
|
|
||||||
def quit(self) -> None:
|
|
||||||
"""
|
|
||||||
Clean up resources and exit the script.
|
|
||||||
"""
|
|
||||||
logging.info("Script interrupted by user. Quitting...")
|
|
||||||
exit(0)
|
|
||||||
|
|||||||
31
main.py
Executable file
31
main.py
Executable file
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Main entry point for the CrossFit Booker application.
|
||||||
|
This script initializes the CrossFitBooker and starts the booking process.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from crossfit_booker import CrossFitBooker
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""
|
||||||
|
Main function to initialize the CrossFitBooker and start the booking process.
|
||||||
|
"""
|
||||||
|
# Set up logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||||
|
handlers=[
|
||||||
|
logging.StreamHandler()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initialize the CrossFitBooker
|
||||||
|
booker = CrossFitBooker()
|
||||||
|
|
||||||
|
# Run the booking process
|
||||||
|
booker.run()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
295
session_manager.py
Normal file
295
session_manager.py
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
# Native modules
|
||||||
|
import logging
|
||||||
|
import pytz
|
||||||
|
import time
|
||||||
|
from datetime import date
|
||||||
|
from typing import List, Dict, Optional, Any
|
||||||
|
from datetime import datetime, timedelta, date
|
||||||
|
|
||||||
|
# Third-party modules
|
||||||
|
import requests
|
||||||
|
from dateutil.parser import parse
|
||||||
|
|
||||||
|
# Import the preferred sessions from the session_config module
|
||||||
|
from session_config import PREFERRED_SESSIONS
|
||||||
|
|
||||||
|
# Import the AuthHandler class
|
||||||
|
from auth import AuthHandler
|
||||||
|
|
||||||
|
class SessionManager:
|
||||||
|
"""
|
||||||
|
A class for managing CrossFit sessions.
|
||||||
|
This class handles session availability checking, booking,
|
||||||
|
and session-related operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, auth_handler: AuthHandler) -> None:
|
||||||
|
"""
|
||||||
|
Initialize the SessionManager with necessary attributes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
auth_handler (AuthHandler): AuthHandler instance for authentication.
|
||||||
|
"""
|
||||||
|
self.auth_handler = auth_handler
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.base_headers = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"Nubapp-Origin": "user_apps",
|
||||||
|
}
|
||||||
|
self.session.headers.update(self.base_headers)
|
||||||
|
|
||||||
|
# Define mandatory parameters for API calls
|
||||||
|
self.mandatory_params = {
|
||||||
|
"app_version": "5.09.21",
|
||||||
|
"device_type": "3",
|
||||||
|
"id_application": "81560887",
|
||||||
|
"id_category_activity": "677"
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_auth_headers(self) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
Return headers with authorization from the AuthHandler.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, str]: Headers dictionary with authorization if available.
|
||||||
|
"""
|
||||||
|
return self.auth_handler.get_auth_headers()
|
||||||
|
|
||||||
|
def get_available_sessions(self, start_date: date, end_date: date) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Fetch available sessions from the API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_date (date): Start date for fetching sessions.
|
||||||
|
end_date (date): End date for fetching sessions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optional[Dict[str, Any]]: Dictionary containing available sessions if successful, None otherwise.
|
||||||
|
"""
|
||||||
|
if not self.auth_handler.auth_token or not self.auth_handler.user_id:
|
||||||
|
logging.error("Authentication required - missing token or user ID")
|
||||||
|
return None
|
||||||
|
|
||||||
|
url = "https://sport.nubapp.com/api/v4/activities/getActivitiesCalendar.php"
|
||||||
|
|
||||||
|
# Prepare request with mandatory parameters
|
||||||
|
request_data = self.mandatory_params.copy()
|
||||||
|
request_data.update({
|
||||||
|
"id_user": self.auth_handler.user_id,
|
||||||
|
"start_timestamp": start_date.strftime("%d-%m-%Y"),
|
||||||
|
"end_timestamp": end_date.strftime("%d-%m-%Y")
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add retry logic
|
||||||
|
for retry in range(3):
|
||||||
|
try:
|
||||||
|
response = self.session.post(
|
||||||
|
url,
|
||||||
|
headers=self.get_auth_headers(),
|
||||||
|
data=request_data,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
return response.json()
|
||||||
|
elif response.status_code == 401:
|
||||||
|
logging.error("401 Unauthorized - token may be expired or invalid")
|
||||||
|
return None
|
||||||
|
elif 500 <= response.status_code < 600:
|
||||||
|
logging.error(f"Server error {response.status_code}")
|
||||||
|
raise requests.exceptions.ConnectionError(f"Server error {response.status_code}")
|
||||||
|
else:
|
||||||
|
logging.error(f"Unexpected status code: {response.status_code} - {response.text[:100]}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
if retry == 2:
|
||||||
|
logging.error(f"Final retry failed: {str(e)}")
|
||||||
|
raise
|
||||||
|
wait_time = 1 * (2 ** retry)
|
||||||
|
logging.warning(f"Request failed (attempt {retry+1}/3): {str(e)}. Retrying in {wait_time}s...")
|
||||||
|
time.sleep(wait_time)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def book_session(self, session_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Book a specific session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id (str): ID of the session to book.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if booking is successful, False otherwise.
|
||||||
|
"""
|
||||||
|
url = "https://sport.nubapp.com/api/v4/activities/bookActivityCalendar.php"
|
||||||
|
data = {
|
||||||
|
**self.mandatory_params,
|
||||||
|
"id_activity_calendar": session_id,
|
||||||
|
"id_user": self.auth_handler.user_id,
|
||||||
|
"action_by": self.auth_handler.user_id,
|
||||||
|
"n_guests": "0",
|
||||||
|
"booked_on": "1",
|
||||||
|
"device_type": self.mandatory_params["device_type"],
|
||||||
|
"token": self.auth_handler.auth_token
|
||||||
|
}
|
||||||
|
|
||||||
|
for retry in range(3):
|
||||||
|
try:
|
||||||
|
response = self.session.post(
|
||||||
|
url,
|
||||||
|
headers=self.get_auth_headers(),
|
||||||
|
data=data,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
json_response = response.json()
|
||||||
|
if json_response.get("success", False):
|
||||||
|
logging.info(f"Successfully booked session {session_id}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logging.error(f"API returned success:false: {json_response} - Session ID: {session_id}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
logging.error(f"HTTP {response.status_code}: {response.text[:100]}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
if retry == 2:
|
||||||
|
logging.error(f"Final retry failed: {str(e)}")
|
||||||
|
raise
|
||||||
|
wait_time = 1 * (2 ** retry)
|
||||||
|
logging.warning(f"Request failed (attempt {retry+1}/3): {str(e)}. Retrying in {wait_time}s...")
|
||||||
|
time.sleep(wait_time)
|
||||||
|
|
||||||
|
logging.error(f"Failed to complete request after 3 attempts")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_booked_sessions(self) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get a list of booked sessions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of dictionaries containing information about the booked sessions.
|
||||||
|
"""
|
||||||
|
url = "https://sport.nubapp.com/api/v4/activities/getBookedActivities.php"
|
||||||
|
data = {
|
||||||
|
**self.mandatory_params,
|
||||||
|
"id_user": self.auth_handler.user_id,
|
||||||
|
"action_by": self.auth_handler.user_id
|
||||||
|
}
|
||||||
|
|
||||||
|
for retry in range(3):
|
||||||
|
try:
|
||||||
|
response = self.session.post(
|
||||||
|
url,
|
||||||
|
headers=self.get_auth_headers(),
|
||||||
|
data=data,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
json_response = response.json()
|
||||||
|
if json_response.get("success", False):
|
||||||
|
return json_response.get("data", [])
|
||||||
|
logging.error(f"API returned success:false: {json_response}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
logging.error(f"HTTP {response.status_code}: {response.text[:100]}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
if retry == 2:
|
||||||
|
logging.error(f"Final retry failed: {str(e)}")
|
||||||
|
raise
|
||||||
|
wait_time = 1 * (2 ** retry)
|
||||||
|
logging.warning(f"Request failed (attempt {retry+1}/3): {str(e)}. Retrying in {wait_time}s...")
|
||||||
|
time.sleep(wait_time)
|
||||||
|
|
||||||
|
logging.error(f"Failed to complete request after 3 attempts")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def is_session_bookable(self, session: Dict[str, Any], current_time: datetime) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a session is bookable based on user_info.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session (Dict[str, Any]): Session data.
|
||||||
|
current_time (datetime): Current time for comparison.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the session is bookable, False otherwise.
|
||||||
|
"""
|
||||||
|
user_info = session.get("user_info", {})
|
||||||
|
|
||||||
|
# First check if can_join is true (primary condition)
|
||||||
|
if user_info.get("can_join", False):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Default case: not bookable
|
||||||
|
return False
|
||||||
|
|
||||||
|
def matches_preferred_session(self, session: Dict[str, Any], current_time: datetime) -> bool:
|
||||||
|
"""
|
||||||
|
Check if session matches one of your preferred sessions with exact matching.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session (Dict[str, Any]): Session data.
|
||||||
|
current_time (datetime): Current time for comparison.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the session matches a preferred session, False otherwise.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
session_time = parse(session["start_timestamp"])
|
||||||
|
if not session_time.tzinfo:
|
||||||
|
session_time = pytz.timezone("Europe/Paris").localize(session_time)
|
||||||
|
|
||||||
|
day_of_week = session_time.weekday()
|
||||||
|
session_time_str = session_time.strftime("%H:%M")
|
||||||
|
session_name = session.get("name_activity", "").upper()
|
||||||
|
|
||||||
|
for preferred_day, preferred_time, preferred_name in PREFERRED_SESSIONS:
|
||||||
|
# Exact match
|
||||||
|
if (day_of_week == preferred_day and
|
||||||
|
session_time_str == preferred_time and
|
||||||
|
preferred_name in session_name):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Failed to check session: {str(e)} - Session: {session}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def display_upcoming_sessions(self, sessions: List[Dict[str, Any]], current_time: datetime) -> None:
|
||||||
|
"""
|
||||||
|
Display upcoming sessions with ID, name, date, and time.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sessions (List[Dict[str, Any]]): List of session data.
|
||||||
|
current_time (datetime): Current time for comparison.
|
||||||
|
"""
|
||||||
|
if not sessions:
|
||||||
|
logging.info("No sessions to display")
|
||||||
|
return
|
||||||
|
|
||||||
|
logging.info("Upcoming sessions:")
|
||||||
|
logging.info("ID\t\tName\t\tDate\t\tTime")
|
||||||
|
logging.info("="*50)
|
||||||
|
|
||||||
|
for session in sessions:
|
||||||
|
session_time = parse(session["start_timestamp"])
|
||||||
|
if not session_time.tzinfo:
|
||||||
|
session_time = pytz.timezone("Europe/Paris").localize(session_time)
|
||||||
|
|
||||||
|
# Format session details
|
||||||
|
session_id = session.get("id_activity_calendar", "N/A")
|
||||||
|
session_name = session.get("name_activity", "N/A")
|
||||||
|
session_date = session_time.strftime("%Y-%m-%d")
|
||||||
|
session_time_str = session_time.strftime("%H:%M")
|
||||||
|
|
||||||
|
# Display session details
|
||||||
|
logging.info(f"{session_id}\t{session_name}\t{session_date}\t{session_time_str}")
|
||||||
135
test/test_auth.py
Normal file
135
test/test_auth.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Unit tests for AuthHandler class
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import requests
|
||||||
|
from unittest.mock import patch, Mock
|
||||||
|
|
||||||
|
# Add the parent directory to the path
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from auth import AuthHandler
|
||||||
|
|
||||||
|
class TestAuthHandlerAuthHeaders:
|
||||||
|
"""Test cases for get_auth_headers method"""
|
||||||
|
|
||||||
|
def test_get_auth_headers_without_token(self):
|
||||||
|
"""Test headers without auth token"""
|
||||||
|
auth_handler = AuthHandler('test_user', 'test_pass')
|
||||||
|
headers = auth_handler.get_auth_headers()
|
||||||
|
assert "Authorization" not in headers
|
||||||
|
assert headers["User-Agent"] == "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0"
|
||||||
|
|
||||||
|
def test_get_auth_headers_with_token(self):
|
||||||
|
"""Test headers with auth token"""
|
||||||
|
auth_handler = AuthHandler('test_user', 'test_pass')
|
||||||
|
auth_handler.auth_token = "test_token_123"
|
||||||
|
headers = auth_handler.get_auth_headers()
|
||||||
|
assert headers["Authorization"] == "Bearer test_token_123"
|
||||||
|
|
||||||
|
class TestAuthHandlerLogin:
|
||||||
|
"""Test cases for login method"""
|
||||||
|
|
||||||
|
@patch('requests.Session.post')
|
||||||
|
def test_login_success(self, mock_post):
|
||||||
|
"""Test successful login flow"""
|
||||||
|
# Mock first login response
|
||||||
|
mock_response1 = Mock()
|
||||||
|
mock_response1.ok = True
|
||||||
|
mock_response1.json.return_value = {
|
||||||
|
"data": {
|
||||||
|
"user": {
|
||||||
|
"id_user": "12345"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mock second login response
|
||||||
|
mock_response2 = Mock()
|
||||||
|
mock_response2.ok = True
|
||||||
|
mock_response2.json.return_value = {
|
||||||
|
"token": "test_bearer_token"
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_post.side_effect = [mock_response1, mock_response2]
|
||||||
|
|
||||||
|
auth_handler = AuthHandler('test_user', 'test_pass')
|
||||||
|
result = auth_handler.login()
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert auth_handler.user_id == "12345"
|
||||||
|
assert auth_handler.auth_token == "test_bearer_token"
|
||||||
|
|
||||||
|
@patch('requests.Session.post')
|
||||||
|
def test_login_first_step_failure(self, mock_post):
|
||||||
|
"""Test login failure on first step"""
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.ok = False
|
||||||
|
mock_response.status_code = 400
|
||||||
|
mock_response.text = "Bad Request"
|
||||||
|
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
auth_handler = AuthHandler('test_user', 'test_pass')
|
||||||
|
result = auth_handler.login()
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
assert auth_handler.user_id is None
|
||||||
|
assert auth_handler.auth_token is None
|
||||||
|
|
||||||
|
@patch('requests.Session.post')
|
||||||
|
def test_login_second_step_failure(self, mock_post):
|
||||||
|
"""Test login failure on second step"""
|
||||||
|
# First response succeeds
|
||||||
|
mock_response1 = Mock()
|
||||||
|
mock_response1.ok = True
|
||||||
|
mock_response1.json.return_value = {
|
||||||
|
"data": {
|
||||||
|
"user": {
|
||||||
|
"id_user": "12345"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Second response fails
|
||||||
|
mock_response2 = Mock()
|
||||||
|
mock_response2.ok = False
|
||||||
|
mock_response2.status_code = 401
|
||||||
|
|
||||||
|
mock_post.side_effect = [mock_response1, mock_response2]
|
||||||
|
|
||||||
|
auth_handler = AuthHandler('test_user', 'test_pass')
|
||||||
|
result = auth_handler.login()
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
@patch('requests.Session.post')
|
||||||
|
def test_login_json_parsing_error(self, mock_post):
|
||||||
|
"""Test login with JSON parsing error"""
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.ok = True
|
||||||
|
mock_response.json.side_effect = ValueError("Invalid JSON")
|
||||||
|
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
auth_handler = AuthHandler('test_user', 'test_pass')
|
||||||
|
result = auth_handler.login()
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
@patch('requests.Session.post')
|
||||||
|
def test_login_request_exception(self, mock_post):
|
||||||
|
"""Test login with request exception"""
|
||||||
|
mock_post.side_effect = requests.exceptions.ConnectionError("Network error")
|
||||||
|
|
||||||
|
auth_handler = AuthHandler('test_user', 'test_pass')
|
||||||
|
result = auth_handler.login()
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
pytest.main([__file__, "-v"])
|
||||||
@@ -191,6 +191,7 @@ def test_book_session():
|
|||||||
booker = CrossFitBooker()
|
booker = CrossFitBooker()
|
||||||
|
|
||||||
# Login to get the authentication token
|
# Login to get the authentication token
|
||||||
|
# The login method now uses the AuthHandler internally
|
||||||
booker.login()
|
booker.login()
|
||||||
|
|
||||||
# Get available sessions
|
# Get available sessions
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Unit tests for CrossFitBooker authentication methods
|
Unit tests for CrossFitBooker authentication methods using AuthHandler
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -13,7 +13,7 @@ from unittest.mock import patch, Mock
|
|||||||
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 crossfit_booker import CrossFitBooker
|
from crossfit_booker import CrossFitBooker
|
||||||
|
from auth import AuthHandler
|
||||||
|
|
||||||
class TestCrossFitBookerAuthHeaders:
|
class TestCrossFitBookerAuthHeaders:
|
||||||
"""Test cases for get_auth_headers method"""
|
"""Test cases for get_auth_headers method"""
|
||||||
@@ -36,11 +36,10 @@ class TestCrossFitBookerAuthHeaders:
|
|||||||
'CROSSFIT_PASSWORD': 'test_pass'
|
'CROSSFIT_PASSWORD': 'test_pass'
|
||||||
}):
|
}):
|
||||||
booker = CrossFitBooker()
|
booker = CrossFitBooker()
|
||||||
booker.auth_token = "test_token_123"
|
booker.auth_handler.auth_token = "test_token_123"
|
||||||
headers = booker.get_auth_headers()
|
headers = booker.get_auth_headers()
|
||||||
assert headers["Authorization"] == "Bearer test_token_123"
|
assert headers["Authorization"] == "Bearer test_token_123"
|
||||||
|
|
||||||
|
|
||||||
class TestCrossFitBookerLogin:
|
class TestCrossFitBookerLogin:
|
||||||
"""Test cases for login method"""
|
"""Test cases for login method"""
|
||||||
|
|
||||||
@@ -75,8 +74,8 @@ class TestCrossFitBookerLogin:
|
|||||||
result = booker.login()
|
result = booker.login()
|
||||||
|
|
||||||
assert result is True
|
assert result is True
|
||||||
assert booker.user_id == "12345"
|
assert booker.auth_handler.user_id == "12345"
|
||||||
assert booker.auth_token == "test_bearer_token"
|
assert booker.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):
|
||||||
@@ -96,8 +95,8 @@ class TestCrossFitBookerLogin:
|
|||||||
result = booker.login()
|
result = booker.login()
|
||||||
|
|
||||||
assert result is False
|
assert result is False
|
||||||
assert booker.user_id is None
|
assert booker.auth_handler.user_id is None
|
||||||
assert booker.auth_token is None
|
assert booker.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):
|
||||||
@@ -161,6 +160,5 @@ class TestCrossFitBookerLogin:
|
|||||||
|
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
pytest.main([__file__, "-v"])
|
pytest.main([__file__, "-v"])
|
||||||
@@ -14,7 +14,6 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|||||||
|
|
||||||
from crossfit_booker import CrossFitBooker
|
from crossfit_booker import CrossFitBooker
|
||||||
|
|
||||||
|
|
||||||
class TestCrossFitBookerInit:
|
class TestCrossFitBookerInit:
|
||||||
"""Test cases for CrossFitBooker initialization"""
|
"""Test cases for CrossFitBooker initialization"""
|
||||||
|
|
||||||
@@ -30,8 +29,8 @@ class TestCrossFitBookerInit:
|
|||||||
'TELEGRAM_CHAT_ID': '12345'
|
'TELEGRAM_CHAT_ID': '12345'
|
||||||
}):
|
}):
|
||||||
booker = CrossFitBooker()
|
booker = CrossFitBooker()
|
||||||
assert booker.auth_token is None
|
assert booker.auth_handler.auth_token is None
|
||||||
assert booker.user_id is None
|
assert booker.auth_handler.user_id is None
|
||||||
assert booker.session is not None
|
assert booker.session is not None
|
||||||
assert booker.notifier is not None
|
assert booker.notifier is not None
|
||||||
|
|
||||||
@@ -54,7 +53,6 @@ class TestCrossFitBookerInit:
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
assert str(e) == "Missing environment variables"
|
assert str(e) == "Missing environment variables"
|
||||||
|
|
||||||
|
|
||||||
class TestCrossFitBookerAuthHeaders:
|
class TestCrossFitBookerAuthHeaders:
|
||||||
"""Test cases for get_auth_headers method"""
|
"""Test cases for get_auth_headers method"""
|
||||||
|
|
||||||
@@ -76,11 +74,10 @@ class TestCrossFitBookerAuthHeaders:
|
|||||||
'CROSSFIT_PASSWORD': 'test_pass'
|
'CROSSFIT_PASSWORD': 'test_pass'
|
||||||
}):
|
}):
|
||||||
booker = CrossFitBooker()
|
booker = CrossFitBooker()
|
||||||
booker.auth_token = "test_token_123"
|
booker.auth_handler.auth_token = "test_token_123"
|
||||||
headers = booker.get_auth_headers()
|
headers = booker.get_auth_headers()
|
||||||
assert headers["Authorization"] == "Bearer test_token_123"
|
assert headers["Authorization"] == "Bearer test_token_123"
|
||||||
|
|
||||||
|
|
||||||
class TestCrossFitBookerLogin:
|
class TestCrossFitBookerLogin:
|
||||||
"""Test cases for login method"""
|
"""Test cases for login method"""
|
||||||
|
|
||||||
@@ -115,8 +112,8 @@ class TestCrossFitBookerLogin:
|
|||||||
result = booker.login()
|
result = booker.login()
|
||||||
|
|
||||||
assert result is True
|
assert result is True
|
||||||
assert booker.user_id == "12345"
|
assert booker.auth_handler.user_id == "12345"
|
||||||
assert booker.auth_token == "test_bearer_token"
|
assert booker.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):
|
||||||
@@ -136,8 +133,8 @@ class TestCrossFitBookerLogin:
|
|||||||
result = booker.login()
|
result = booker.login()
|
||||||
|
|
||||||
assert result is False
|
assert result is False
|
||||||
assert booker.user_id is None
|
assert booker.auth_handler.user_id is None
|
||||||
assert booker.auth_token is None
|
assert booker.auth_handler.auth_token is None
|
||||||
|
|
||||||
@patch('requests.Session.post')
|
@patch('requests.Session.post')
|
||||||
def test_login_json_parsing_error(self, mock_post):
|
def test_login_json_parsing_error(self, mock_post):
|
||||||
@@ -171,7 +168,6 @@ class TestCrossFitBookerLogin:
|
|||||||
|
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
class TestCrossFitBookerGetAvailableSessions:
|
class TestCrossFitBookerGetAvailableSessions:
|
||||||
"""Test cases for get_available_sessions method"""
|
"""Test cases for get_available_sessions method"""
|
||||||
|
|
||||||
@@ -206,8 +202,8 @@ class TestCrossFitBookerGetAvailableSessions:
|
|||||||
'CROSSFIT_PASSWORD': 'test_pass'
|
'CROSSFIT_PASSWORD': 'test_pass'
|
||||||
}):
|
}):
|
||||||
booker = CrossFitBooker()
|
booker = CrossFitBooker()
|
||||||
booker.auth_token = "test_token"
|
booker.auth_handler.auth_token = "test_token"
|
||||||
booker.user_id = "12345"
|
booker.auth_handler.user_id = "12345"
|
||||||
|
|
||||||
result = booker.get_available_sessions(date(2025, 7, 24), date(2025, 7, 25))
|
result = booker.get_available_sessions(date(2025, 7, 24), date(2025, 7, 25))
|
||||||
|
|
||||||
@@ -228,8 +224,8 @@ class TestCrossFitBookerGetAvailableSessions:
|
|||||||
'CROSSFIT_PASSWORD': 'test_pass'
|
'CROSSFIT_PASSWORD': 'test_pass'
|
||||||
}):
|
}):
|
||||||
booker = CrossFitBooker()
|
booker = CrossFitBooker()
|
||||||
booker.auth_token = "test_token"
|
booker.auth_handler.auth_token = "test_token"
|
||||||
booker.user_id = "12345"
|
booker.auth_handler.user_id = "12345"
|
||||||
|
|
||||||
result = booker.get_available_sessions(date(2025, 7, 24), date(2025, 7, 25))
|
result = booker.get_available_sessions(date(2025, 7, 24), date(2025, 7, 25))
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import pytz
|
|||||||
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 crossfit_booker import CrossFitBooker
|
from crossfit_booker import CrossFitBooker
|
||||||
|
from session_manager import SessionManager
|
||||||
|
from auth import AuthHandler
|
||||||
|
|
||||||
class TestCrossFitBookerGetAvailableSessions:
|
class TestCrossFitBookerGetAvailableSessions:
|
||||||
"""Test cases for get_available_sessions method"""
|
"""Test cases for get_available_sessions method"""
|
||||||
@@ -25,8 +26,9 @@ 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')
|
||||||
result = booker.get_available_sessions(date(2025, 7, 24), date(2025, 7, 25))
|
session_manager = SessionManager(auth_handler)
|
||||||
|
result = session_manager.get_available_sessions(date(2025, 7, 24), date(2025, 7, 25))
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@patch('requests.Session.post')
|
@patch('requests.Session.post')
|
||||||
@@ -49,11 +51,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_token = "test_token"
|
auth_handler.auth_token = "test_token"
|
||||||
booker.user_id = "12345"
|
auth_handler.user_id = "12345"
|
||||||
|
session_manager = SessionManager(auth_handler)
|
||||||
|
|
||||||
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 not None
|
assert result is not None
|
||||||
assert result["success"] is True
|
assert result["success"] is True
|
||||||
@@ -71,14 +74,13 @@ class TestCrossFitBookerGetAvailableSessions:
|
|||||||
'CROSSFIT_PASSWORD': 'test_pass'
|
'CROSSFIT_PASSWORD': 'test_pass'
|
||||||
}):
|
}):
|
||||||
booker = CrossFitBooker()
|
booker = CrossFitBooker()
|
||||||
booker.auth_token = "test_token"
|
booker.auth_handler.auth_token = "test_token"
|
||||||
booker.user_id = "12345"
|
booker.auth_handler.user_id = "12345"
|
||||||
|
|
||||||
result = booker.get_available_sessions(date(2025, 7, 24), date(2025, 7, 25))
|
result = booker.get_available_sessions(date(2025, 7, 24), date(2025, 7, 25))
|
||||||
|
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
class TestCrossFitBookerBookSession:
|
class TestCrossFitBookerBookSession:
|
||||||
"""Test cases for book_session method"""
|
"""Test cases for book_session method"""
|
||||||
|
|
||||||
@@ -88,8 +90,9 @@ class TestCrossFitBookerBookSession:
|
|||||||
'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.book_session("session_123")
|
session_manager = SessionManager(auth_handler)
|
||||||
|
result = session_manager.book_session("session_123")
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
@patch('requests.Session.post')
|
@patch('requests.Session.post')
|
||||||
@@ -105,11 +108,12 @@ class TestCrossFitBookerBookSession:
|
|||||||
'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_token = "test_token"
|
auth_handler.auth_token = "test_token"
|
||||||
booker.user_id = "12345"
|
auth_handler.user_id = "12345"
|
||||||
|
session_manager = SessionManager(auth_handler)
|
||||||
|
|
||||||
result = booker.book_session("session_123")
|
result = session_manager.book_session("session_123")
|
||||||
|
|
||||||
assert result is True
|
assert result is True
|
||||||
|
|
||||||
@@ -126,15 +130,15 @@ class TestCrossFitBookerBookSession:
|
|||||||
'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_token = "test_token"
|
auth_handler.auth_token = "test_token"
|
||||||
booker.user_id = "12345"
|
auth_handler.user_id = "12345"
|
||||||
|
session_manager = SessionManager(auth_handler)
|
||||||
|
|
||||||
result = booker.book_session("session_123")
|
result = session_manager.book_session("session_123")
|
||||||
|
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
class TestCrossFitBookerIsSessionBookable:
|
class TestCrossFitBookerIsSessionBookable:
|
||||||
"""Test cases for is_session_bookable method"""
|
"""Test cases for is_session_bookable method"""
|
||||||
|
|
||||||
@@ -144,11 +148,12 @@ class TestCrossFitBookerIsSessionBookable:
|
|||||||
'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')
|
||||||
|
session_manager = SessionManager(auth_handler)
|
||||||
session = {"user_info": {"can_join": True}}
|
session = {"user_info": {"can_join": True}}
|
||||||
current_time = datetime.now(pytz.timezone("Europe/Paris"))
|
current_time = datetime.now(pytz.timezone("Europe/Paris"))
|
||||||
|
|
||||||
result = booker.is_session_bookable(session, current_time)
|
result = session_manager.is_session_bookable(session, current_time)
|
||||||
assert result is True
|
assert result is True
|
||||||
|
|
||||||
def test_is_session_bookable_booking_window_past(self):
|
def test_is_session_bookable_booking_window_past(self):
|
||||||
@@ -157,7 +162,8 @@ class TestCrossFitBookerIsSessionBookable:
|
|||||||
'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')
|
||||||
|
session_manager = SessionManager(auth_handler)
|
||||||
session = {
|
session = {
|
||||||
"user_info": {
|
"user_info": {
|
||||||
"can_join": False,
|
"can_join": False,
|
||||||
@@ -167,7 +173,7 @@ class TestCrossFitBookerIsSessionBookable:
|
|||||||
}
|
}
|
||||||
current_time = datetime.now(pytz.timezone("Europe/Paris"))
|
current_time = datetime.now(pytz.timezone("Europe/Paris"))
|
||||||
|
|
||||||
result = booker.is_session_bookable(session, current_time)
|
result = session_manager.is_session_bookable(session, current_time)
|
||||||
assert result is True
|
assert result is True
|
||||||
|
|
||||||
def test_is_session_bookable_booking_window_future(self):
|
def test_is_session_bookable_booking_window_future(self):
|
||||||
@@ -176,7 +182,8 @@ class TestCrossFitBookerIsSessionBookable:
|
|||||||
'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')
|
||||||
|
session_manager = SessionManager(auth_handler)
|
||||||
session = {
|
session = {
|
||||||
"user_info": {
|
"user_info": {
|
||||||
"can_join": False,
|
"can_join": False,
|
||||||
@@ -186,10 +193,9 @@ class TestCrossFitBookerIsSessionBookable:
|
|||||||
}
|
}
|
||||||
current_time = datetime.now(pytz.timezone("Europe/Paris"))
|
current_time = datetime.now(pytz.timezone("Europe/Paris"))
|
||||||
|
|
||||||
result = booker.is_session_bookable(session, current_time)
|
result = session_manager.is_session_bookable(session, current_time)
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
class TestCrossFitBookerRunBookingCycle:
|
class TestCrossFitBookerRunBookingCycle:
|
||||||
"""Test cases for run_booking_cycle method"""
|
"""Test cases for run_booking_cycle method"""
|
||||||
|
|
||||||
@@ -308,6 +314,7 @@ class TestCrossFitBookerQuit:
|
|||||||
with pytest.raises(SystemExit) as excinfo:
|
with pytest.raises(SystemExit) as excinfo:
|
||||||
booker.quit()
|
booker.quit()
|
||||||
assert excinfo.value.code == 0
|
assert excinfo.value.code == 0
|
||||||
|
|
||||||
class TestCrossFitBookerMatchesPreferredSession:
|
class TestCrossFitBookerMatchesPreferredSession:
|
||||||
"""Test cases for matches_preferred_session method"""
|
"""Test cases for matches_preferred_session method"""
|
||||||
|
|
||||||
@@ -317,7 +324,8 @@ class TestCrossFitBookerMatchesPreferredSession:
|
|||||||
'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')
|
||||||
|
session_manager = SessionManager(auth_handler)
|
||||||
session = {
|
session = {
|
||||||
"start_timestamp": "2025-07-30 18:30:00",
|
"start_timestamp": "2025-07-30 18:30:00",
|
||||||
"name_activity": "CONDITIONING"
|
"name_activity": "CONDITIONING"
|
||||||
@@ -325,8 +333,8 @@ class TestCrossFitBookerMatchesPreferredSession:
|
|||||||
current_time = datetime(2025, 7, 30, 12, 0, 0, tzinfo=pytz.timezone("Europe/Paris"))
|
current_time = datetime(2025, 7, 30, 12, 0, 0, tzinfo=pytz.timezone("Europe/Paris"))
|
||||||
|
|
||||||
# Mock PREFERRED_SESSIONS
|
# Mock PREFERRED_SESSIONS
|
||||||
with patch('crossfit_booker.PREFERRED_SESSIONS', [(2, "18:30", "CONDITIONING")]):
|
with patch('session_manager.PREFERRED_SESSIONS', [(2, "18:30", "CONDITIONING")]):
|
||||||
result = booker.matches_preferred_session(session, current_time)
|
result = session_manager.matches_preferred_session(session, current_time)
|
||||||
assert result is True
|
assert result is True
|
||||||
|
|
||||||
def test_matches_preferred_session_fuzzy_match(self):
|
def test_matches_preferred_session_fuzzy_match(self):
|
||||||
@@ -335,7 +343,8 @@ class TestCrossFitBookerMatchesPreferredSession:
|
|||||||
'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')
|
||||||
|
session_manager = SessionManager(auth_handler)
|
||||||
session = {
|
session = {
|
||||||
"start_timestamp": "2025-07-30 18:30:00",
|
"start_timestamp": "2025-07-30 18:30:00",
|
||||||
"name_activity": "CONDITIONING WORKOUT"
|
"name_activity": "CONDITIONING WORKOUT"
|
||||||
@@ -343,8 +352,8 @@ class TestCrossFitBookerMatchesPreferredSession:
|
|||||||
current_time = datetime(2025, 7, 30, 12, 0, 0, tzinfo=pytz.timezone("Europe/Paris"))
|
current_time = datetime(2025, 7, 30, 12, 0, 0, tzinfo=pytz.timezone("Europe/Paris"))
|
||||||
|
|
||||||
# Mock PREFERRED_SESSIONS
|
# Mock PREFERRED_SESSIONS
|
||||||
with patch('crossfit_booker.PREFERRED_SESSIONS', [(2, "18:30", "CONDITIONING")]):
|
with patch('session_manager.PREFERRED_SESSIONS', [(2, "18:30", "CONDITIONING")]):
|
||||||
result = booker.matches_preferred_session(session, current_time)
|
result = session_manager.matches_preferred_session(session, current_time)
|
||||||
assert result is True
|
assert result is True
|
||||||
|
|
||||||
def test_matches_preferred_session_no_match(self):
|
def test_matches_preferred_session_no_match(self):
|
||||||
@@ -353,7 +362,8 @@ class TestCrossFitBookerMatchesPreferredSession:
|
|||||||
'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')
|
||||||
|
session_manager = SessionManager(auth_handler)
|
||||||
session = {
|
session = {
|
||||||
"start_timestamp": "2025-07-30 18:30:00",
|
"start_timestamp": "2025-07-30 18:30:00",
|
||||||
"name_activity": "YOGA"
|
"name_activity": "YOGA"
|
||||||
@@ -361,6 +371,6 @@ class TestCrossFitBookerMatchesPreferredSession:
|
|||||||
current_time = datetime(2025, 7, 30, 12, 0, 0, tzinfo=pytz.timezone("Europe/Paris"))
|
current_time = datetime(2025, 7, 30, 12, 0, 0, tzinfo=pytz.timezone("Europe/Paris"))
|
||||||
|
|
||||||
# Mock PREFERRED_SESSIONS
|
# Mock PREFERRED_SESSIONS
|
||||||
with patch('crossfit_booker.PREFERRED_SESSIONS', [(2, "18:30", "CONDITIONING")]):
|
with patch('session_manager.PREFERRED_SESSIONS', [(2, "18:30", "CONDITIONING")]):
|
||||||
result = booker.matches_preferred_session(session, current_time)
|
result = session_manager.matches_preferred_session(session, current_time)
|
||||||
assert result is False
|
assert result is False
|
||||||
Reference in New Issue
Block a user