feat: Add hotel and webhook endpoint management
- Introduced Hotel and WebhookEndpoint models to manage hotel configurations and webhook settings. - Implemented sync_config_to_database function to synchronize hotel data from configuration to the database. - Added HotelService for accessing hotel configurations and managing customer data. - Created WebhookProcessor interface and specific processors for handling different webhook types (Wix form and generic). - Enhanced webhook processing logic to handle incoming requests and create/update reservations and customers. - Added logging for better traceability of operations related to hotels and webhooks.
This commit is contained in:
246
src/alpine_bits_python/hotel_service.py
Normal file
246
src/alpine_bits_python/hotel_service.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""Hotel service for managing hotel configuration."""
|
||||
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import bcrypt
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from .db import Hotel, WebhookEndpoint
|
||||
from .logging_config import get_logger
|
||||
|
||||
_LOGGER = get_logger(__name__)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash password using bcrypt.
|
||||
|
||||
Args:
|
||||
password: Plain text password
|
||||
|
||||
Returns:
|
||||
Bcrypt hashed password
|
||||
"""
|
||||
salt = bcrypt.gensalt(rounds=12)
|
||||
return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
"""Verify password against bcrypt hash.
|
||||
|
||||
Args:
|
||||
password: Plain text password
|
||||
password_hash: Bcrypt hash to verify against
|
||||
|
||||
Returns:
|
||||
True if password matches, False otherwise
|
||||
"""
|
||||
return bcrypt.checkpw(
|
||||
password.encode('utf-8'),
|
||||
password_hash.encode('utf-8')
|
||||
)
|
||||
|
||||
|
||||
def generate_webhook_secret() -> str:
|
||||
"""Generate cryptographically secure webhook secret.
|
||||
|
||||
Returns:
|
||||
64-character URL-safe random string
|
||||
"""
|
||||
return secrets.token_urlsafe(48) # 48 bytes = 64 URL-safe chars
|
||||
|
||||
|
||||
async def sync_config_to_database(
|
||||
db_session: AsyncSession,
|
||||
config: dict[str, Any]
|
||||
) -> dict[str, int]:
|
||||
"""Sync alpine_bits_auth from config.yaml to database.
|
||||
|
||||
Creates/updates hotels and generates webhook_endpoints if missing.
|
||||
Idempotent - safe to run on every startup.
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
config: Application configuration dict
|
||||
|
||||
Returns:
|
||||
Statistics dict with counts of created/updated records
|
||||
"""
|
||||
stats = {"hotels_created": 0, "hotels_updated": 0, "endpoints_created": 0}
|
||||
|
||||
alpine_bits_auth = config.get("alpine_bits_auth", [])
|
||||
if not alpine_bits_auth:
|
||||
_LOGGER.info("No hotels found in alpine_bits_auth config")
|
||||
return stats
|
||||
|
||||
for hotel_config in alpine_bits_auth:
|
||||
hotel_id = hotel_config.get("hotel_id")
|
||||
if not hotel_id:
|
||||
_LOGGER.warning("Skipping hotel config without hotel_id: %s", hotel_config)
|
||||
continue
|
||||
|
||||
# Check if hotel exists
|
||||
result = await db_session.execute(
|
||||
select(Hotel).where(Hotel.hotel_id == hotel_id)
|
||||
)
|
||||
hotel = result.scalar_one_or_none()
|
||||
|
||||
if not hotel:
|
||||
# Create new hotel
|
||||
password_hash = hash_password(hotel_config["password"])
|
||||
|
||||
hotel = Hotel(
|
||||
hotel_id=hotel_id,
|
||||
hotel_name=hotel_config.get("hotel_name", hotel_id),
|
||||
username=hotel_config["username"],
|
||||
password_hash=password_hash,
|
||||
meta_account_id=hotel_config.get("meta_account"),
|
||||
google_account_id=hotel_config.get("google_account"),
|
||||
push_endpoint_url=hotel_config.get("push_endpoint", {}).get("url"),
|
||||
push_endpoint_token=hotel_config.get("push_endpoint", {}).get("token"),
|
||||
push_endpoint_username=hotel_config.get("push_endpoint", {}).get("username"),
|
||||
created_at=datetime.now(UTC),
|
||||
updated_at=datetime.now(UTC),
|
||||
is_active=True,
|
||||
)
|
||||
db_session.add(hotel)
|
||||
await db_session.flush()
|
||||
stats["hotels_created"] += 1
|
||||
_LOGGER.info("Created hotel: %s", hotel_id)
|
||||
else:
|
||||
# Update existing hotel (config may have changed)
|
||||
# Note: We do NOT update password_hash for security reasons
|
||||
hotel.hotel_name = hotel_config.get("hotel_name", hotel_id)
|
||||
hotel.meta_account_id = hotel_config.get("meta_account")
|
||||
hotel.google_account_id = hotel_config.get("google_account")
|
||||
push_endpoint = hotel_config.get("push_endpoint", {})
|
||||
hotel.push_endpoint_url = push_endpoint.get("url")
|
||||
hotel.push_endpoint_token = push_endpoint.get("token")
|
||||
hotel.push_endpoint_username = push_endpoint.get("username")
|
||||
hotel.updated_at = datetime.now(UTC)
|
||||
stats["hotels_updated"] += 1
|
||||
_LOGGER.debug("Updated hotel: %s", hotel_id)
|
||||
|
||||
# Ensure hotel has at least default webhook endpoints
|
||||
result = await db_session.execute(
|
||||
select(WebhookEndpoint).where(WebhookEndpoint.hotel_id == hotel_id)
|
||||
)
|
||||
existing_endpoints = result.scalars().all()
|
||||
|
||||
if not existing_endpoints:
|
||||
# Create default webhook endpoints for backward compatibility
|
||||
for webhook_type in ["wix_form", "generic"]:
|
||||
webhook_secret = generate_webhook_secret()
|
||||
endpoint = WebhookEndpoint(
|
||||
hotel_id=hotel_id,
|
||||
webhook_secret=webhook_secret,
|
||||
webhook_type=webhook_type,
|
||||
description=f"Auto-generated {webhook_type} endpoint",
|
||||
is_enabled=True,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
db_session.add(endpoint)
|
||||
stats["endpoints_created"] += 1
|
||||
_LOGGER.info(
|
||||
"Created webhook endpoint for hotel %s, type=%s, secret=%s",
|
||||
hotel_id,
|
||||
webhook_type,
|
||||
webhook_secret
|
||||
)
|
||||
|
||||
await db_session.commit()
|
||||
|
||||
_LOGGER.info(
|
||||
"Config sync complete: %d hotels created, %d updated, %d endpoints created",
|
||||
stats["hotels_created"],
|
||||
stats["hotels_updated"],
|
||||
stats["endpoints_created"]
|
||||
)
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
class HotelService:
|
||||
"""Service for hotel configuration access.
|
||||
|
||||
Always reads from database (synced from config at startup).
|
||||
"""
|
||||
|
||||
def __init__(self, db_session: AsyncSession):
|
||||
"""Initialize HotelService.
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
"""
|
||||
self.db_session = db_session
|
||||
|
||||
async def get_hotel_by_id(self, hotel_id: str) -> Hotel | None:
|
||||
"""Get hotel by hotel_id.
|
||||
|
||||
Args:
|
||||
hotel_id: Hotel identifier
|
||||
|
||||
Returns:
|
||||
Hotel instance or None if not found
|
||||
"""
|
||||
result = await self.db_session.execute(
|
||||
select(Hotel)
|
||||
.where(
|
||||
and_(
|
||||
Hotel.hotel_id == hotel_id,
|
||||
Hotel.is_active == True
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_hotel_by_webhook_secret(
|
||||
self,
|
||||
webhook_secret: str
|
||||
) -> tuple[Hotel, WebhookEndpoint] | tuple[None, None]:
|
||||
"""Get hotel and webhook_endpoint by webhook_secret.
|
||||
|
||||
Args:
|
||||
webhook_secret: Webhook secret string
|
||||
|
||||
Returns:
|
||||
Tuple of (Hotel, WebhookEndpoint) or (None, None) if not found
|
||||
"""
|
||||
result = await self.db_session.execute(
|
||||
select(WebhookEndpoint)
|
||||
.where(
|
||||
and_(
|
||||
WebhookEndpoint.webhook_secret == webhook_secret,
|
||||
WebhookEndpoint.is_enabled == True
|
||||
)
|
||||
)
|
||||
.options(joinedload(WebhookEndpoint.hotel))
|
||||
)
|
||||
endpoint = result.scalar_one_or_none()
|
||||
|
||||
if endpoint and endpoint.hotel.is_active:
|
||||
return endpoint.hotel, endpoint
|
||||
return None, None
|
||||
|
||||
async def get_hotel_by_username(self, username: str) -> Hotel | None:
|
||||
"""Get hotel by AlpineBits username.
|
||||
|
||||
Args:
|
||||
username: AlpineBits username
|
||||
|
||||
Returns:
|
||||
Hotel instance or None if not found
|
||||
"""
|
||||
result = await self.db_session.execute(
|
||||
select(Hotel)
|
||||
.where(
|
||||
and_(
|
||||
Hotel.username == username,
|
||||
Hotel.is_active == True
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
Reference in New Issue
Block a user