Starting unique_id migration

This commit is contained in:
Jonas Linter
2025-10-08 10:45:00 +02:00
parent a8f46016be
commit 584def323c
4 changed files with 85 additions and 109 deletions

View File

@@ -518,7 +518,7 @@ class ReadAction(AlpineBitsAction):
select(Reservation.id)
.join(
AckedRequest,
AckedRequest.unique_id == Reservation.unique_id,
Reservation.unique_id.like(str(AckedRequest.unique_id) + "%"),
)
.filter(AckedRequest.client_id == client_info.client_id)
)

View File

@@ -4,6 +4,7 @@ import json
import logging
import os
import urllib.parse
from collections import defaultdict
from datetime import UTC, date, datetime
from functools import partial
from typing import Any
@@ -16,6 +17,8 @@ from fastapi.security import HTTPBasic, HTTPBasicCredentials
from slowapi.errors import RateLimitExceeded
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from alpine_bits_python.schemas import ReservationData
from .alpinebits_server import (
AlpineBitsActionName,
AlpineBitsClientInfo,
@@ -24,9 +27,10 @@ from .alpinebits_server import (
)
from .auth import generate_api_key, generate_unique_id, validate_api_key
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 Reservation as DBReservation
from .db import get_database_url
from .rate_limit import (
BURST_RATE_LIMIT,
DEFAULT_RATE_LIMIT,
@@ -43,8 +47,6 @@ _LOGGER = logging.getLogger(__name__)
# HTTP Basic auth for AlpineBits
security_basic = HTTPBasic()
from collections import defaultdict
# --- Enhanced event dispatcher with hotel-specific routing ---
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("/")
@limiter.limit(DEFAULT_RATE_LIMIT)
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
async def process_wix_form_submission(request: Request, data: dict[str, Any], db):
"""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")
# 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
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
)
db_reservation = DBReservation(
customer_id=db_customer.id,
reservation = ReservationData(
unique_id=unique_id,
start_date=date.fromisoformat(start_date) if start_date else None,
end_date=date.fromisoformat(end_date) if end_date else None,
start_date=date.fromisoformat(start_date),
end_date=date.fromisoformat(end_date),
num_adults=num_adults,
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,
created_at=datetime.now(UTC),
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", ""),
fbclid=data.get("field:fbclid"),
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)
await db.commit()
await db.refresh(db_reservation)

View File

@@ -44,7 +44,8 @@ class Reservation(Base):
__tablename__ = "reservations"
id = Column(Integer, primary_key=True)
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)
end_date = Column(Date)
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.
"""
import hashlib
from datetime import date
from enum import Enum
@@ -35,6 +36,55 @@ class PhoneNumber(BaseModel):
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):
"""Validated customer data for creating reservations and guests."""
@@ -168,58 +218,6 @@ class CommentsData(BaseModel):
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
class ReservationService:
"""Example service showing how to use Pydantic models with SQLAlchemy."""