Refactor SSO authentication to support multiple providers and enhance error handling
This commit is contained in:
parent
862518a45f
commit
c4cef0d9b5
10 changed files with 233 additions and 66 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -1,2 +1,4 @@
|
||||||
/.idea
|
/.idea
|
||||||
*.env
|
*.env
|
||||||
|
*pyc
|
||||||
|
__pycache__/
|
||||||
72
README.md
72
README.md
|
|
@ -1,17 +1,17 @@
|
||||||
# 📚 KIT Anny Booking Automation
|
# 📚 Anny Booking Automation
|
||||||
|
|
||||||
This Python project automates booking of study spaces or resources via the [anny.eu](https://anny.eu) platform used by the KIT (Karlsruhe Institute of Technology) library system. It logs in automatically using KIT SAML SSO, searches for available slots, and makes bookings without user interaction — ideal for recurring reservations.
|
This Python project automates booking of study spaces or resources via the [anny.eu](https://anny.eu) platform used by university library systems. It logs in automatically using SAML SSO (with pluggable provider support), searches for available slots, and makes bookings without user interaction — ideal for recurring reservations.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ⚙️ Features
|
## ⚙️ Features
|
||||||
|
|
||||||
- 🔐 Automatic KIT login via SAML SSO
|
- 🔐 Pluggable SSO providers (KIT included, easily extendable for other universities)
|
||||||
- 📆 Configurable 3-days-ahead reservation window
|
- 📆 Configurable 3-days-ahead reservation window
|
||||||
- 🔎 Auto-detection of available time slots
|
- 🔎 Auto-detection of available time slots
|
||||||
- ⏳ Pre-login shortly before midnight to instantly book a slot at 00:00
|
- ⏳ Smart midnight wait: only waits if within 10 minutes of midnight, otherwise executes immediately
|
||||||
- 🛠️ Clean and modular object-oriented codebase
|
- 🛠️ Clean and modular object-oriented codebase
|
||||||
- 🔁 Fully automated execution using [cron-job.org](https://cron-job.org) + GitHub API
|
- 🔁 Fully automated execution using [cron-job.org](https://cron-job.org) + GitHub API
|
||||||
- 📦 Easy to extend and maintain
|
- 📦 Easy to extend and maintain
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -25,13 +25,17 @@ anny_booking/
|
||||||
├── requirements.txt # Python dependencies
|
├── requirements.txt # Python dependencies
|
||||||
│
|
│
|
||||||
├── auth/
|
├── auth/
|
||||||
│ └── session.py # AnnySession class (login logic)
|
│ ├── session.py # AnnySession class (login logic)
|
||||||
|
│ └── providers/ # SSO provider implementations
|
||||||
|
│ ├── __init__.py # Provider registry
|
||||||
|
│ ├── base.py # Abstract base class for providers
|
||||||
|
│ └── kit.py # KIT (Karlsruhe) SSO provider
|
||||||
│
|
│
|
||||||
├── booking/
|
├── booking/
|
||||||
│ └── client.py # BookingClient class (resource booking)
|
│ └── client.py # BookingClient class (resource booking)
|
||||||
│
|
│
|
||||||
├── config/
|
├── config/
|
||||||
│ └── constants.py # API URLs and shared constants
|
│ └── constants.py # API URLs, timezone, SSO provider, and shared constants
|
||||||
│
|
│
|
||||||
├── utils/
|
├── utils/
|
||||||
│ └── helpers.py # Utility functions
|
│ └── helpers.py # Utility functions
|
||||||
|
|
@ -71,6 +75,46 @@ PASSWORD=your_kit_password
|
||||||
|
|
||||||
> 🔒 Never commit this file to version control!
|
> 🔒 Never commit this file to version control!
|
||||||
|
|
||||||
|
### 4. Configure SSO provider
|
||||||
|
|
||||||
|
In `config/constants.py`, set your SSO provider:
|
||||||
|
|
||||||
|
```python
|
||||||
|
SSO_PROVIDER = "kit" # Available: kit (add more in auth/providers/)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔌 Adding a New SSO Provider
|
||||||
|
|
||||||
|
To add support for another university (e.g., TUM), create a new file `auth/providers/tum.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from auth.providers.base import SSOProvider
|
||||||
|
from utils.helpers import extract_html_value
|
||||||
|
|
||||||
|
class TUMProvider(SSOProvider):
|
||||||
|
name = "TUM"
|
||||||
|
domain = "tum.de"
|
||||||
|
|
||||||
|
def authenticate(self) -> str:
|
||||||
|
# Implement TUM-specific SAML authentication
|
||||||
|
# Use self.session, self.redirect_response, self.username, self.password
|
||||||
|
# Return the HTML containing the SAMLResponse
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
Then register it in `auth/providers/__init__.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from auth.providers.tum import TUMProvider
|
||||||
|
|
||||||
|
PROVIDERS: dict[str, type[SSOProvider]] = {
|
||||||
|
"kit": KITProvider,
|
||||||
|
"tum": TUMProvider,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ⏱️ Automated Execution via cron-job.org + GitHub API
|
## ⏱️ Automated Execution via cron-job.org + GitHub API
|
||||||
|
|
@ -103,9 +147,9 @@ This ensures your booking script runs exactly when needed — already logged in
|
||||||
|
|
||||||
### How it works
|
### How it works
|
||||||
|
|
||||||
1. `cron-job.org` triggers the GitHub Actions workflow at **23:58** (Europe/Berlin).
|
1. `cron-job.org` triggers the GitHub Actions workflow at **23:58** (Europe/Berlin).
|
||||||
2. The script logs in via KIT SAML SSO and maintains an active session.
|
2. The script logs in via your configured SSO provider and maintains an active session.
|
||||||
3. It waits internally until **00:00**.
|
3. If within 10 minutes of midnight, it waits until **00:00**. Otherwise, it executes immediately (useful for testing or manual runs).
|
||||||
4. As soon as new booking slots are released, it instantly reserves the first suitable slot.
|
4. As soon as new booking slots are released, it instantly reserves the first suitable slot.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -161,7 +205,7 @@ jobs:
|
||||||
python main.py
|
python main.py
|
||||||
```
|
```
|
||||||
|
|
||||||
> 💡 Store your KIT credentials securely as [GitHub Secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets): `USERNAME` and `PASSWORD`.
|
> 💡 Store your university credentials securely as [GitHub Secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets): `USERNAME` and `PASSWORD`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
16
auth/providers/__init__.py
Normal file
16
auth/providers/__init__.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
from auth.providers.base import SSOProvider
|
||||||
|
from auth.providers.kit import KITProvider
|
||||||
|
|
||||||
|
# Registry of available SSO providers
|
||||||
|
PROVIDERS: dict[str, type[SSOProvider]] = {
|
||||||
|
"kit": KITProvider,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_provider(name: str) -> type[SSOProvider]:
|
||||||
|
"""Get an SSO provider class by name."""
|
||||||
|
provider = PROVIDERS.get(name.lower())
|
||||||
|
if not provider:
|
||||||
|
available = ", ".join(PROVIDERS.keys())
|
||||||
|
raise ValueError(f"Unknown SSO provider: {name}. Available: {available}")
|
||||||
|
return provider
|
||||||
35
auth/providers/base.py
Normal file
35
auth/providers/base.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
class SSOProvider(ABC):
|
||||||
|
"""Base class for SSO authentication providers."""
|
||||||
|
|
||||||
|
# Override these in subclasses
|
||||||
|
name: str = "base"
|
||||||
|
domain: str = ""
|
||||||
|
|
||||||
|
def __init__(self, username: str, password: str):
|
||||||
|
self.username = username
|
||||||
|
self.password = password
|
||||||
|
self.session: requests.Session = None
|
||||||
|
self.redirect_response: requests.Response = None
|
||||||
|
self.saml_response_html: str = None
|
||||||
|
|
||||||
|
def set_session(self, session: requests.Session):
|
||||||
|
"""Set the shared session from AnnySession."""
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
def set_redirect_response(self, response: requests.Response):
|
||||||
|
"""Set the redirect response from Anny SSO initiation."""
|
||||||
|
self.redirect_response = response
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def authenticate(self) -> str:
|
||||||
|
"""
|
||||||
|
Perform institution-specific authentication.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The HTML containing the SAML response, or raises an exception on failure.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
36
auth/providers/kit.py
Normal file
36
auth/providers/kit.py
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import html
|
||||||
|
from auth.providers.base import SSOProvider
|
||||||
|
from utils.helpers import extract_html_value
|
||||||
|
|
||||||
|
|
||||||
|
class KITProvider(SSOProvider):
|
||||||
|
"""SSO provider for Karlsruhe Institute of Technology (KIT)."""
|
||||||
|
|
||||||
|
name = "KIT"
|
||||||
|
domain = "kit.edu"
|
||||||
|
|
||||||
|
def authenticate(self) -> str:
|
||||||
|
self.session.headers.pop('x-requested-with', None)
|
||||||
|
self.session.headers.pop('x-inertia', None)
|
||||||
|
self.session.headers.pop('x-inertia-version', None)
|
||||||
|
|
||||||
|
csrf_token = extract_html_value(
|
||||||
|
self.redirect_response.text,
|
||||||
|
r'name="csrf_token" value="([^"]+)"'
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.session.post(
|
||||||
|
'https://idp.scc.kit.edu/idp/profile/SAML2/Redirect/SSO?execution=e1s1',
|
||||||
|
data={
|
||||||
|
'csrf_token': csrf_token,
|
||||||
|
'j_username': self.username,
|
||||||
|
'j_password': self.password,
|
||||||
|
'_eventId_proceed': '',
|
||||||
|
'fudis_web_authn_assertion_input': '',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if "/consume" not in html.unescape(response.text):
|
||||||
|
raise ValueError("KIT authentication failed - invalid credentials or SSO error")
|
||||||
|
|
||||||
|
return response.text
|
||||||
|
|
@ -1,27 +1,38 @@
|
||||||
import requests
|
import requests
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import re
|
import re
|
||||||
import html
|
|
||||||
from config.constants import AUTH_BASE_URL, ANNY_BASE_URL, DEFAULT_HEADERS
|
from config.constants import AUTH_BASE_URL, ANNY_BASE_URL, DEFAULT_HEADERS
|
||||||
from utils.helpers import extract_html_value
|
from utils.helpers import extract_html_value
|
||||||
|
from auth.providers import get_provider, SSOProvider
|
||||||
|
|
||||||
|
|
||||||
class AnnySession:
|
class AnnySession:
|
||||||
def __init__(self, username, password):
|
def __init__(self, username: str, password: str, provider_name: str = "kit"):
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
self.username = username
|
self.username = username
|
||||||
self.password = password
|
self.password = password
|
||||||
|
|
||||||
|
# Initialize the SSO provider
|
||||||
|
provider_class = get_provider(provider_name)
|
||||||
|
self.provider: SSOProvider = provider_class(username, password)
|
||||||
|
|
||||||
def login(self):
|
def login(self):
|
||||||
try:
|
try:
|
||||||
self._init_headers()
|
self._init_headers()
|
||||||
self._sso_login()
|
self._sso_login()
|
||||||
self._kit_auth()
|
self._provider_auth()
|
||||||
self._consume_saml()
|
self._consume_saml()
|
||||||
print("✅ Login successful.")
|
print(f"✅ Login successful via {self.provider.name}.")
|
||||||
return self.session.cookies
|
return self.session.cookies
|
||||||
except Exception as e:
|
except requests.RequestException as e:
|
||||||
|
print(f"[Login Error] Network error: {type(e).__name__}")
|
||||||
|
return None
|
||||||
|
except ValueError as e:
|
||||||
print(f"[Login Error] {e}")
|
print(f"[Login Error] {e}")
|
||||||
return None
|
return None
|
||||||
|
except KeyError as e:
|
||||||
|
print(f"[Login Error] Missing expected field: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
def _init_headers(self):
|
def _init_headers(self):
|
||||||
self.session.headers.update({
|
self.session.headers.update({
|
||||||
|
|
@ -45,32 +56,17 @@ class AnnySession:
|
||||||
'x-inertia-version': x_inertia_version
|
'x-inertia-version': x_inertia_version
|
||||||
})
|
})
|
||||||
|
|
||||||
r2 = self.session.post(f"{AUTH_BASE_URL}/login/sso", json={"domain": "kit.edu"})
|
r2 = self.session.post(f"{AUTH_BASE_URL}/login/sso", json={"domain": self.provider.domain})
|
||||||
redirect_url = r2.headers['x-inertia-location']
|
redirect_url = r2.headers['x-inertia-location']
|
||||||
self.redirect_response = self.session.get(redirect_url)
|
redirect_response = self.session.get(redirect_url)
|
||||||
|
|
||||||
def _kit_auth(self):
|
# Pass session and redirect response to provider
|
||||||
self.session.headers.pop('x-requested-with', None)
|
self.provider.set_session(self.session)
|
||||||
self.session.headers.pop('x-inertia', None)
|
self.provider.set_redirect_response(redirect_response)
|
||||||
self.session.headers.pop('x-inertia-version', None)
|
|
||||||
|
|
||||||
csrf_token = extract_html_value(self.redirect_response.text, r'name="csrf_token" value="([^"]+)"')
|
def _provider_auth(self):
|
||||||
|
"""Delegate authentication to the SSO provider."""
|
||||||
r4 = self.session.post(
|
self.saml_response_html = self.provider.authenticate()
|
||||||
'https://idp.scc.kit.edu/idp/profile/SAML2/Redirect/SSO?execution=e1s1',
|
|
||||||
data={
|
|
||||||
'csrf_token': csrf_token,
|
|
||||||
'j_username': self.username,
|
|
||||||
'j_password': self.password,
|
|
||||||
'_eventId_proceed': '',
|
|
||||||
'fudis_web_authn_assertion_input': '',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if "/consume" not in html.unescape(r4.text):
|
|
||||||
raise Exception("KIT authentication failed")
|
|
||||||
|
|
||||||
self.saml_response_html = r4.text
|
|
||||||
|
|
||||||
def _consume_saml(self):
|
def _consume_saml(self):
|
||||||
consume_url = extract_html_value(self.saml_response_html, r'form action="([^"]+)"')
|
consume_url = extract_html_value(self.saml_response_html, r'form action="([^"]+)"')
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import requests
|
import requests
|
||||||
|
from requests.exceptions import JSONDecodeError
|
||||||
from config.constants import RESOURCE_URL, BOOKING_API_BASE, CHECKOUT_FORM_API, ANNY_BASE_URL, SERVICE_ID
|
from config.constants import RESOURCE_URL, BOOKING_API_BASE, CHECKOUT_FORM_API, ANNY_BASE_URL, SERVICE_ID
|
||||||
from utils.helpers import extract_html_value
|
|
||||||
|
|
||||||
class BookingClient:
|
class BookingClient:
|
||||||
def __init__(self, cookies):
|
def __init__(self, cookies):
|
||||||
|
|
@ -30,7 +30,14 @@ class BookingClient:
|
||||||
'filter[pre_order_ids]': '',
|
'filter[pre_order_ids]': '',
|
||||||
'sort': 'name'
|
'sort': 'name'
|
||||||
})
|
})
|
||||||
resources = response.json().get('data', [])
|
if not response.ok:
|
||||||
|
print(f"❌ Failed to fetch resources: HTTP {response.status_code}")
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
resources = response.json().get('data', [])
|
||||||
|
except (ValueError, JSONDecodeError):
|
||||||
|
print(f"❌ Invalid JSON response when fetching resources: {response.text[:200]}")
|
||||||
|
return None
|
||||||
return resources[0]['id'] if resources else None
|
return resources[0]['id'] if resources else None
|
||||||
|
|
||||||
def reserve(self, resource_id, start, end):
|
def reserve(self, resource_id, start, end):
|
||||||
|
|
@ -50,15 +57,32 @@ class BookingClient:
|
||||||
)
|
)
|
||||||
|
|
||||||
if not booking.ok:
|
if not booking.ok:
|
||||||
print("❌ Slot already taken.")
|
print(f"❌ Booking failed: HTTP {booking.status_code}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = booking.json().get("data", {})
|
||||||
|
except (ValueError, JSONDecodeError):
|
||||||
|
print(f"❌ Invalid JSON response from booking request: {booking.text[:200]}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
data = booking.json().get("data", {})
|
|
||||||
oid = data.get("id")
|
oid = data.get("id")
|
||||||
oat = data.get("attributes", {}).get("access_token")
|
oat = data.get("attributes", {}).get("access_token")
|
||||||
|
|
||||||
|
if not oid or not oat:
|
||||||
|
print("❌ Missing booking ID or access token in response")
|
||||||
|
return False
|
||||||
|
|
||||||
checkout = self.session.get(f"{CHECKOUT_FORM_API}?oid={oid}&oat={oat}&stateless=1")
|
checkout = self.session.get(f"{CHECKOUT_FORM_API}?oid={oid}&oat={oat}&stateless=1")
|
||||||
customer = checkout.json().get("default", {}).get("customer", {})
|
if not checkout.ok:
|
||||||
|
print(f"❌ Checkout form failed: HTTP {checkout.status_code}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
customer = checkout.json().get("default", {}).get("customer", {})
|
||||||
|
except (ValueError, JSONDecodeError):
|
||||||
|
print(f"❌ Invalid JSON response from checkout form: {checkout.text[:200]}")
|
||||||
|
return False
|
||||||
|
|
||||||
final = self.session.post(
|
final = self.session.post(
|
||||||
f"{BOOKING_API_BASE}/order?oid={oid}&oat={oat}&stateless=1",
|
f"{BOOKING_API_BASE}/order?oid={oid}&oat={oat}&stateless=1",
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@ BOOKING_API_BASE = "https://b.anny.eu/api/v1"
|
||||||
CHECKOUT_FORM_API = "https://b.anny.eu/api/ui/checkout-form"
|
CHECKOUT_FORM_API = "https://b.anny.eu/api/ui/checkout-form"
|
||||||
RESOURCE_URL = f"{BOOKING_API_BASE}/resources/1-lehrbuchsammlung-eg-und-1-og/children"
|
RESOURCE_URL = f"{BOOKING_API_BASE}/resources/1-lehrbuchsammlung-eg-und-1-og/children"
|
||||||
SERVICE_ID = "449"
|
SERVICE_ID = "449"
|
||||||
RESSOURCE_ID = "5993" # None Will be set dynamically if None, else use the given ID
|
RESOURCE_ID = "5960" # Will be set dynamically if None, else use the given ID
|
||||||
|
TIMEZONE = "Europe/Berlin"
|
||||||
|
SSO_PROVIDER = "kit" # Available: kit (add more in auth/providers/)
|
||||||
|
|
||||||
DEFAULT_HEADERS = {
|
DEFAULT_HEADERS = {
|
||||||
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0',
|
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0',
|
||||||
|
|
|
||||||
43
main.py
43
main.py
|
|
@ -7,20 +7,20 @@ from auth.session import AnnySession
|
||||||
from booking.client import BookingClient
|
from booking.client import BookingClient
|
||||||
from utils.helpers import get_future_datetime
|
from utils.helpers import get_future_datetime
|
||||||
import pytz
|
import pytz
|
||||||
from config.constants import RESSOURCE_ID
|
from config.constants import RESOURCE_ID, TIMEZONE, SSO_PROVIDER
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
load_dotenv('.env', override=True)
|
load_dotenv('.env', override=True)
|
||||||
username = os.getenv("USERNAME")
|
username = os.getenv("USERNAME")
|
||||||
password = os.getenv("PASSWORD")
|
password = os.getenv("PASSWORD")
|
||||||
|
|
||||||
start_time = datetime.datetime.now(pytz.timezone('Europe/Berlin'))
|
tz = pytz.timezone(TIMEZONE)
|
||||||
|
|
||||||
if not username or not password:
|
if not username or not password:
|
||||||
print("❌ Missing USERNAME or PASSWORD in .env")
|
print("❌ Missing USERNAME or PASSWORD in .env")
|
||||||
return
|
return
|
||||||
|
|
||||||
session = AnnySession(username, password)
|
session = AnnySession(username, password, provider_name=SSO_PROVIDER)
|
||||||
cookies = session.login()
|
cookies = session.login()
|
||||||
|
|
||||||
if not cookies:
|
if not cookies:
|
||||||
|
|
@ -28,8 +28,17 @@ def main():
|
||||||
|
|
||||||
booking = BookingClient(cookies)
|
booking = BookingClient(cookies)
|
||||||
|
|
||||||
while start_time.day == datetime.datetime.now(pytz.timezone('Europe/Berlin')).day:
|
# Only wait for midnight if within 10 minutes, otherwise execute immediately
|
||||||
time.sleep(1)
|
now = datetime.datetime.now(tz)
|
||||||
|
midnight = (now + datetime.timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
seconds_until_midnight = (midnight - now).total_seconds()
|
||||||
|
max_wait_seconds = 10 * 60 # 10 minutes
|
||||||
|
|
||||||
|
if 0 < seconds_until_midnight <= max_wait_seconds:
|
||||||
|
print(f"⏳ Waiting {seconds_until_midnight:.0f} seconds until midnight...")
|
||||||
|
time.sleep(seconds_until_midnight)
|
||||||
|
elif seconds_until_midnight > max_wait_seconds:
|
||||||
|
print(f"⚡ More than 10 min until midnight, executing immediately...")
|
||||||
|
|
||||||
times = [
|
times = [
|
||||||
{
|
{
|
||||||
|
|
@ -47,19 +56,21 @@ def main():
|
||||||
]
|
]
|
||||||
|
|
||||||
for time_ in times:
|
for time_ in times:
|
||||||
|
try:
|
||||||
|
start = get_future_datetime(hour=time_['start'])
|
||||||
|
end = get_future_datetime(hour=time_['end'])
|
||||||
|
|
||||||
start = get_future_datetime(hour=time_['start'])
|
if RESOURCE_ID:
|
||||||
end = get_future_datetime(hour=time_['end'])
|
resource_id = RESOURCE_ID
|
||||||
|
else:
|
||||||
|
resource_id = booking.find_available_resource(start, end)
|
||||||
|
|
||||||
if RESSOURCE_ID:
|
if resource_id:
|
||||||
resource_id = RESSOURCE_ID
|
booking.reserve(resource_id, start, end)
|
||||||
else:
|
else:
|
||||||
resource_id = booking.find_available_resource(start, end)
|
print("⚠️ No available slots found.")
|
||||||
|
except Exception as e:
|
||||||
if resource_id:
|
print(f"❌ Error booking slot {time_['start']}-{time_['end']}: {e}")
|
||||||
booking.reserve(resource_id, start, end)
|
|
||||||
else:
|
|
||||||
print("⚠️ No available slots found.")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,10 @@ import re
|
||||||
import html
|
import html
|
||||||
import datetime
|
import datetime
|
||||||
import pytz
|
import pytz
|
||||||
|
from config.constants import TIMEZONE
|
||||||
|
|
||||||
def get_future_datetime(days_ahead=3, hour="13:00:00"):
|
def get_future_datetime(days_ahead=3, hour="13:00:00"):
|
||||||
dt = datetime.datetime.now(pytz.timezone('Europe/Berlin')) + datetime.timedelta(days=days_ahead)
|
dt = datetime.datetime.now(pytz.timezone(TIMEZONE)) + datetime.timedelta(days=days_ahead)
|
||||||
return dt.strftime(f"%Y-%m-%dT{hour}+02:00")
|
return dt.strftime(f"%Y-%m-%dT{hour}+02:00")
|
||||||
|
|
||||||
def extract_html_value(text, pattern):
|
def extract_html_value(text, pattern):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue