From 584def323c2cf14a6340e514a248796f89ef7c1a Mon Sep 17 00:00:00 2001 From: Jonas Linter Date: Wed, 8 Oct 2025 10:45:00 +0200 Subject: [PATCH] Starting unique_id migration --- src/alpine_bits_python/alpinebits_server.py | 2 +- src/alpine_bits_python/api.py | 87 ++++++----------- src/alpine_bits_python/db.py | 3 +- src/alpine_bits_python/schemas.py | 102 ++++++++++---------- 4 files changed, 85 insertions(+), 109 deletions(-) diff --git a/src/alpine_bits_python/alpinebits_server.py b/src/alpine_bits_python/alpinebits_server.py index e08ebac..7642492 100644 --- a/src/alpine_bits_python/alpinebits_server.py +++ b/src/alpine_bits_python/alpinebits_server.py @@ -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) ) diff --git a/src/alpine_bits_python/api.py b/src/alpine_bits_python/api.py index 5b878a9..5113c3f 100644 --- a/src/alpine_bits_python/api.py +++ b/src/alpine_bits_python/api.py @@ -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) diff --git a/src/alpine_bits_python/db.py b/src/alpine_bits_python/db.py index 9a58840..ce82c23 100644 --- a/src/alpine_bits_python/db.py +++ b/src/alpine_bits_python/db.py @@ -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) diff --git a/src/alpine_bits_python/schemas.py b/src/alpine_bits_python/schemas.py index 4cbb24f..5b76afe 100644 --- a/src/alpine_bits_python/schemas.py +++ b/src/alpine_bits_python/schemas.py @@ -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."""