2 Commits

Author SHA1 Message Date
Jonas Linter
ebbea84a4c Fixed acknowledgments 2025-10-08 10:47:18 +02:00
Jonas Linter
584def323c Starting unique_id migration 2025-10-08 10:45:00 +02:00
5 changed files with 97 additions and 119 deletions

View File

@@ -658,12 +658,7 @@ def _process_single_reservation(
else: else:
raise ValueError("Unsupported message type: %s", message_type.value) raise ValueError("Unsupported message type: %s", message_type.value)
unique_id_str = reservation.unique_id unique_id_str = reservation.md5_unique_id
# TODO MAGIC shortening
if len(unique_id_str) > 32:
# strip to first 35 chars
unique_id_str = unique_id_str[:32]
# UniqueID # UniqueID
unique_id = UniqueId(type_value=UniqueIdType2.VALUE_14, id=unique_id_str) unique_id = UniqueId(type_value=UniqueIdType2.VALUE_14, id=unique_id_str)

View File

@@ -21,12 +21,19 @@ from xsdata.formats.dataclass.serializers.config import SerializerConfig
from xsdata_pydantic.bindings import XmlParser, XmlSerializer from xsdata_pydantic.bindings import XmlParser, XmlSerializer
from alpine_bits_python.alpine_bits_helpers import ( from alpine_bits_python.alpine_bits_helpers import (
create_res_notif_push_message, create_res_retrieve_response) create_res_notif_push_message,
create_res_retrieve_response,
)
from .db import AckedRequest, Customer, Reservation from .db import AckedRequest, Customer, Reservation
from .generated.alpinebits import (OtaNotifReportRq, OtaNotifReportRs, from .generated.alpinebits import (
OtaPingRq, OtaPingRs, OtaReadRq, OtaNotifReportRq,
WarningStatus) OtaNotifReportRs,
OtaPingRq,
OtaPingRs,
OtaReadRq,
WarningStatus,
)
# Configure logging # Configure logging
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@@ -518,7 +525,7 @@ class ReadAction(AlpineBitsAction):
select(Reservation.id) select(Reservation.id)
.join( .join(
AckedRequest, AckedRequest,
AckedRequest.unique_id == Reservation.unique_id, Reservation.md5_unique_id == AckedRequest.unique_id,
) )
.filter(AckedRequest.client_id == client_info.client_id) .filter(AckedRequest.client_id == client_info.client_id)
) )

View File

@@ -4,6 +4,7 @@ import json
import logging import logging
import os import os
import urllib.parse import urllib.parse
from collections import defaultdict
from datetime import UTC, date, datetime from datetime import UTC, date, datetime
from functools import partial from functools import partial
from typing import Any from typing import Any
@@ -16,6 +17,8 @@ from fastapi.security import HTTPBasic, HTTPBasicCredentials
from slowapi.errors import RateLimitExceeded from slowapi.errors import RateLimitExceeded
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from alpine_bits_python.schemas import ReservationData
from .alpinebits_server import ( from .alpinebits_server import (
AlpineBitsActionName, AlpineBitsActionName,
AlpineBitsClientInfo, AlpineBitsClientInfo,
@@ -24,9 +27,10 @@ from .alpinebits_server import (
) )
from .auth import generate_api_key, generate_unique_id, validate_api_key from .auth import generate_api_key, generate_unique_id, validate_api_key
from .config_loader import load_config from .config_loader import load_config
from .db import Base, get_database_url from .db import Base
from .db import Customer as DBCustomer from .db import Customer as DBCustomer
from .db import Reservation as DBReservation from .db import Reservation as DBReservation
from .db import get_database_url
from .rate_limit import ( from .rate_limit import (
BURST_RATE_LIMIT, BURST_RATE_LIMIT,
DEFAULT_RATE_LIMIT, DEFAULT_RATE_LIMIT,
@@ -43,8 +47,6 @@ _LOGGER = logging.getLogger(__name__)
# HTTP Basic auth for AlpineBits # HTTP Basic auth for AlpineBits
security_basic = HTTPBasic() security_basic = HTTPBasic()
from collections import defaultdict
# --- Enhanced event dispatcher with hotel-specific routing --- # --- Enhanced event dispatcher with hotel-specific routing ---
class EventDispatcher: class EventDispatcher:
@@ -240,42 +242,6 @@ app.add_middleware(
) )
async def process_form_submission(submission_data: dict[str, Any]) -> None:
"""Background task to process the form submission.
Add your business logic here.
"""
try:
_LOGGER.info(
f"Processing form submission: {submission_data.get('submissionId')}"
)
# Example processing - you can replace this with your actual logic
form_name = submission_data.get("formName")
contact_email = (
submission_data.get("contact", {}).get("email")
if submission_data.get("contact")
else None
)
# Extract form fields
form_fields = {
k: v for k, v in submission_data.items() if k.startswith("field:")
}
_LOGGER.info(
f"Form: {form_name}, Contact: {contact_email}, Fields: {len(form_fields)}"
)
# Here you could:
# - Save to database
# - Send emails
# - Call external APIs
# - Process the data further
except Exception as e:
_LOGGER.error(f"Error processing form submission: {e!s}")
@api_router.get("/") @api_router.get("/")
@limiter.limit(DEFAULT_RATE_LIMIT) @limiter.limit(DEFAULT_RATE_LIMIT)
async def root(request: Request): async def root(request: Request):
@@ -307,6 +273,22 @@ async def health_check(request: Request):
} }
def create_db_reservation_from_data(
reservation_model: ReservationData, db_customer_id: int
) -> DBReservation:
"""Convert ReservationData to DBReservation, handling children_ages conversion."""
data = reservation_model.model_dump(exclude_none=True)
children_list = data.pop("children_ages", [])
children_csv = ",".join(str(int(a)) for a in children_list) if children_list else ""
data["children_ages"] = children_csv
# Inject FK
data["customer_id"] = db_customer_id
return DBReservation(**data)
# Extracted business logic for handling Wix form submissions # Extracted business logic for handling Wix form submissions
async def process_wix_form_submission(request: Request, data: dict[str, Any], db): async def process_wix_form_submission(request: Request, data: dict[str, Any], db):
"""Shared business logic for handling Wix form submissions (test and production).""" """Shared business logic for handling Wix form submissions (test and production)."""
@@ -392,15 +374,6 @@ async def process_wix_form_submission(request: Request, data: dict[str, Any], db
offer = data.get("field:angebot_auswaehlen") offer = data.get("field:angebot_auswaehlen")
# UTM and offer
utm_fields = [
("utm_Source", "utm_source"),
("utm_Medium", "utm_medium"),
("utm_Campaign", "utm_campaign"),
("utm_Term", "utm_term"),
("utm_Content", "utm_content"),
]
# get submissionId and ensure max length 35. Generate one if not present # get submissionId and ensure max length 35. Generate one if not present
unique_id = data.get("submissionId", generate_unique_id()) unique_id = data.get("submissionId", generate_unique_id())
@@ -446,14 +419,15 @@ async def process_wix_form_submission(request: Request, data: dict[str, Any], db
or "Frangart Inn" # fallback or "Frangart Inn" # fallback
) )
db_reservation = DBReservation( reservation = ReservationData(
customer_id=db_customer.id,
unique_id=unique_id, unique_id=unique_id,
start_date=date.fromisoformat(start_date) if start_date else None, start_date=date.fromisoformat(start_date),
end_date=date.fromisoformat(end_date) if end_date else None, end_date=date.fromisoformat(end_date),
num_adults=num_adults, num_adults=num_adults,
num_children=num_children, num_children=num_children,
children_ages=",".join(str(a) for a in children_ages), children_ages=children_ages,
hotel_code=hotel_code,
hotel_name=hotel_name,
offer=offer, offer=offer,
created_at=datetime.now(UTC), created_at=datetime.now(UTC),
utm_source=data.get("field:utm_source"), utm_source=data.get("field:utm_source"),
@@ -464,9 +438,12 @@ async def process_wix_form_submission(request: Request, data: dict[str, Any], db
user_comment=data.get("field:long_answer_3524", ""), user_comment=data.get("field:long_answer_3524", ""),
fbclid=data.get("field:fbclid"), fbclid=data.get("field:fbclid"),
gclid=data.get("field:gclid"), gclid=data.get("field:gclid"),
hotel_code=hotel_code,
hotel_name=hotel_name,
) )
if reservation.md5_unique_id is None:
raise HTTPException(status_code=400, detail="Failed to generate md5_unique_id")
db_reservation = create_db_reservation_from_data(reservation, db_customer.id)
db.add(db_reservation) db.add(db_reservation)
await db.commit() await db.commit()
await db.refresh(db_reservation) await db.refresh(db_reservation)

View File

@@ -44,7 +44,8 @@ class Reservation(Base):
__tablename__ = "reservations" __tablename__ = "reservations"
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
customer_id = Column(Integer, ForeignKey("customers.id")) customer_id = Column(Integer, ForeignKey("customers.id"))
unique_id = Column(String(35), unique=True) # max length 35 unique_id = Column(String, unique=True)
md5_unique_id = Column(String, unique=True) # max length 35
start_date = Column(Date) start_date = Column(Date)
end_date = Column(Date) end_date = Column(Date)
num_adults = Column(Integer) num_adults = Column(Integer)

View File

@@ -9,6 +9,7 @@ Separating validation (Pydantic) from persistence (SQLAlchemy) and
from XML generation (xsdata) follows clean architecture principles. from XML generation (xsdata) follows clean architecture principles.
""" """
import hashlib
from datetime import date from datetime import date
from enum import Enum from enum import Enum
@@ -35,6 +36,55 @@ class PhoneNumber(BaseModel):
return " ".join(v.split()) return " ".join(v.split())
class ReservationData(BaseModel):
"""Validated reservation data."""
unique_id: str = Field(..., min_length=1, max_length=200)
md5_unique_id: str | None = Field(None, min_length=1, max_length=32)
start_date: date
end_date: date
num_adults: int = Field(..., ge=1)
num_children: int = Field(0, ge=0, le=10)
children_ages: list[int] = Field(default_factory=list)
hotel_code: str = Field(..., min_length=1, max_length=50)
hotel_name: str | None = Field(None, max_length=200)
offer: str | None = Field(None, max_length=500)
user_comment: str | None = Field(None, max_length=2000)
fbclid: str | None = Field(None, max_length=100)
gclid: str | None = Field(None, max_length=100)
utm_source: str | None = Field(None, max_length=100)
utm_medium: str | None = Field(None, max_length=100)
utm_campaign: str | None = Field(None, max_length=100)
utm_term: str | None = Field(None, max_length=100)
utm_content: str | None = Field(None, max_length=100)
@model_validator(mode="after")
def ensure_md5(self) -> "ReservationData":
"""Ensure md5_unique_id is set after model validation.
Using a model_validator in 'after' mode lets us access all fields via
the instance and set md5_unique_id in-place when it wasn't provided.
"""
if not getattr(self, "md5_unique_id", None) and getattr(
self, "unique_id", None
):
self.md5_unique_id = hashlib.md5(self.unique_id.encode("utf-8")).hexdigest()
return self
@model_validator(mode="after")
def validate_children_ages(self) -> "ReservationData":
"""Ensure children_ages matches num_children."""
if len(self.children_ages) != self.num_children:
raise ValueError(
f"Number of children ages ({len(self.children_ages)}) "
f"must match num_children ({self.num_children})"
)
for age in self.children_ages:
if age < 0 or age > 17:
raise ValueError(f"Child age {age} must be between 0 and 17")
return self
class CustomerData(BaseModel): class CustomerData(BaseModel):
"""Validated customer data for creating reservations and guests.""" """Validated customer data for creating reservations and guests."""
@@ -168,58 +218,6 @@ class CommentsData(BaseModel):
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
class ReservationData(BaseModel):
"""Validated reservation data."""
unique_id: str = Field(..., min_length=1, max_length=35)
start_date: date
end_date: date
num_adults: int = Field(..., ge=1, le=20)
num_children: int = Field(0, ge=0, le=10)
children_ages: list[int] = Field(default_factory=list)
hotel_code: str = Field(..., min_length=1, max_length=50)
hotel_name: str | None = Field(None, max_length=200)
offer: str | None = Field(None, max_length=500)
user_comment: str | None = Field(None, max_length=2000)
fbclid: str | None = Field(None, max_length=100)
gclid: str | None = Field(None, max_length=100)
utm_source: str | None = Field(None, max_length=100)
utm_medium: str | None = Field(None, max_length=100)
utm_campaign: str | None = Field(None, max_length=100)
utm_term: str | None = Field(None, max_length=100)
utm_content: str | None = Field(None, max_length=100)
@model_validator(mode="after")
def validate_dates(self) -> "ReservationData":
"""Ensure end_date is after start_date."""
if self.end_date <= self.start_date:
raise ValueError("end_date must be after start_date")
return self
@model_validator(mode="after")
def validate_children_ages(self) -> "ReservationData":
"""Ensure children_ages matches num_children."""
if len(self.children_ages) != self.num_children:
raise ValueError(
f"Number of children ages ({len(self.children_ages)}) "
f"must match num_children ({self.num_children})"
)
for age in self.children_ages:
if age < 0 or age > 17:
raise ValueError(f"Child age {age} must be between 0 and 17")
return self
@field_validator("unique_id")
@classmethod
def validate_unique_id_length(cls, v: str) -> str:
"""Ensure unique_id doesn't exceed max length."""
if len(v) > 35:
raise ValueError(f"unique_id length {len(v)} exceeds maximum of 35")
return v
model_config = {"from_attributes": True}
# Example usage in a service layer # Example usage in a service layer
class ReservationService: class ReservationService:
"""Example service showing how to use Pydantic models with SQLAlchemy.""" """Example service showing how to use Pydantic models with SQLAlchemy."""