Experimenting with pydantic
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<OTA_HotelResNotifRQ xmlns="http://www.opentravel.org/OTA/2003/05" Version="7.000">
|
||||||
|
<HotelReservations/>
|
||||||
|
</OTA_HotelResNotifRQ>
|
||||||
@@ -259,4 +259,4 @@
|
|||||||
"accept": "*/*",
|
"accept": "*/*",
|
||||||
"content-length": "7081"
|
"content-length": "7081"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
257
logs/wix_test_data_20251007_155426.json
Normal file
257
logs/wix_test_data_20251007_155426.json
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
{
|
||||||
|
"timestamp": "2025-10-07T15:54:26.898008",
|
||||||
|
"client_ip": "127.0.0.1",
|
||||||
|
"headers": {
|
||||||
|
"host": "localhost:8080",
|
||||||
|
"content-type": "application/json",
|
||||||
|
"user-agent": "insomnia/2023.5.8",
|
||||||
|
"accept": "*/*",
|
||||||
|
"content-length": "7335"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"data": {
|
||||||
|
"formName": "Contact us",
|
||||||
|
"submissions": [
|
||||||
|
{
|
||||||
|
"label": "Anreisedatum",
|
||||||
|
"value": "2026-01-02"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Abreisedatum",
|
||||||
|
"value": "2026-01-07"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Anzahl Erwachsene",
|
||||||
|
"value": "3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Anzahl Kinder",
|
||||||
|
"value": "1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Alter Kind 1",
|
||||||
|
"value": "12"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Anrede",
|
||||||
|
"value": "Frau"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Vorname",
|
||||||
|
"value": "Genesia "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Nachname",
|
||||||
|
"value": "Supino "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Email",
|
||||||
|
"value": "supinogenesia@gmail.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Phone",
|
||||||
|
"value": "+39 340 625 9979"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Einwilligung Marketing",
|
||||||
|
"value": "Selezionato"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "utm_Source",
|
||||||
|
"value": "fb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "utm_Medium",
|
||||||
|
"value": "Facebook_Mobile_Feed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "utm_Campaign",
|
||||||
|
"value": "Conversions_Hotel_Bemelmans_ITA"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "utm_Term",
|
||||||
|
"value": "Cold_Traffic_Conversions_Hotel_Bemelmans_ITA"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "utm_Content",
|
||||||
|
"value": "Grafik_AuszeitDezember_9.12_23.12"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "utm_term_id",
|
||||||
|
"value": "120238574626400196"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "utm_content_id",
|
||||||
|
"value": "120238574626400196"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "gad_source",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "gad_campaignid",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "gbraid",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "gclid",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "fbclid",
|
||||||
|
"value": "IwZXh0bgNhZW0BMABhZGlkAassWPh1b8QBHoRc2S24gMktdNKiPwEvGYMK3rB-mne_0IJQvQRIGH60wLvLfOm0XWP8wJ9s_aem_rbpAFMODwOh4UnF5UVxwWg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "hotelid",
|
||||||
|
"value": "12345"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "hotelname",
|
||||||
|
"value": "Bemelmans Post"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"field:date_picker_7e65": "2026-01-07",
|
||||||
|
"field:number_7cf5": "3",
|
||||||
|
"field:utm_source": "fb",
|
||||||
|
"submissionTime": "2025-10-07T05:48:41.855Z",
|
||||||
|
"field:alter_kind_3": "12",
|
||||||
|
"field:gad_source": "",
|
||||||
|
"field:form_field_5a7b": "Selezionato",
|
||||||
|
"field:gad_campaignid": "",
|
||||||
|
"field:utm_medium": "Facebook_Mobile_Feed",
|
||||||
|
"field:utm_term_id": "120238574626400196",
|
||||||
|
"context": {
|
||||||
|
"metaSiteId": "1dea821c-8168-4736-96e4-4b92e8b364cf",
|
||||||
|
"activationId": "2421c9cd-6565-49ba-b60f-165d3dacccba"
|
||||||
|
},
|
||||||
|
"field:email_5139": "supinogenesia@gmail.com",
|
||||||
|
"field:phone_4c77": "+39 340 625 9979",
|
||||||
|
"_context": {
|
||||||
|
"activation": {
|
||||||
|
"id": "2421c9cd-6565-49ba-b60f-165d3dacccba"
|
||||||
|
},
|
||||||
|
"configuration": {
|
||||||
|
"id": "a976f18c-fa86-495d-be1e-676df188eeae"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"id": "225dd912-7dea-4738-8688-4b8c6955ffc2"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"id": "152db4d7-5263-40c4-be2b-1c81476318b7"
|
||||||
|
},
|
||||||
|
"trigger": {
|
||||||
|
"key": "wix_form_app-form_submitted"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"field:gclid": "",
|
||||||
|
"formFieldMask": [
|
||||||
|
"field:",
|
||||||
|
"field:",
|
||||||
|
"field:angebot_auswaehlen",
|
||||||
|
"field:date_picker_a7c8",
|
||||||
|
"field:date_picker_7e65",
|
||||||
|
"field:",
|
||||||
|
"field:number_7cf5",
|
||||||
|
"field:anzahl_kinder",
|
||||||
|
"field:alter_kind_3",
|
||||||
|
"field:alter_kind_25",
|
||||||
|
"field:alter_kind_4",
|
||||||
|
"field:alter_kind_5",
|
||||||
|
"field:alter_kind_6",
|
||||||
|
"field:alter_kind_7",
|
||||||
|
"field:alter_kind_8",
|
||||||
|
"field:alter_kind_9",
|
||||||
|
"field:alter_kind_10",
|
||||||
|
"field:alter_kind_11",
|
||||||
|
"field:",
|
||||||
|
"field:anrede",
|
||||||
|
"field:first_name_abae",
|
||||||
|
"field:last_name_d97c",
|
||||||
|
"field:email_5139",
|
||||||
|
"field:phone_4c77",
|
||||||
|
"field:long_answer_3524",
|
||||||
|
"field:form_field_5a7b",
|
||||||
|
"field:",
|
||||||
|
"field:utm_source",
|
||||||
|
"field:utm_medium",
|
||||||
|
"field:utm_campaign",
|
||||||
|
"field:utm_term",
|
||||||
|
"field:utm_content",
|
||||||
|
"field:utm_term_id",
|
||||||
|
"field:utm_content_id",
|
||||||
|
"field:gad_source",
|
||||||
|
"field:gad_campaignid",
|
||||||
|
"field:gbraid",
|
||||||
|
"field:gclid",
|
||||||
|
"field:fbclid",
|
||||||
|
"field:hotelid",
|
||||||
|
"field:hotelname",
|
||||||
|
"field:",
|
||||||
|
"metaSiteId"
|
||||||
|
],
|
||||||
|
"contact": {
|
||||||
|
"name": {
|
||||||
|
"first": "Genesia",
|
||||||
|
"last": "Supino"
|
||||||
|
},
|
||||||
|
"email": "supinogenesia@gmail.com",
|
||||||
|
"locale": "it-it",
|
||||||
|
"phones": [
|
||||||
|
{
|
||||||
|
"tag": "UNTAGGED",
|
||||||
|
"formattedPhone": "+39 340 625 9979",
|
||||||
|
"id": "198f04fb-5b2c-4a7b-b7ea-adc150ec4212",
|
||||||
|
"countryCode": "IT",
|
||||||
|
"e164Phone": "+393406259979",
|
||||||
|
"primary": true,
|
||||||
|
"phone": "340 625 9979"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"contactId": "4d695011-36c1-4480-b225-ae9c6eef9e83",
|
||||||
|
"emails": [
|
||||||
|
{
|
||||||
|
"id": "e09d7bab-1f11-4b5d-b3c5-32d43c1dc584",
|
||||||
|
"tag": "UNTAGGED",
|
||||||
|
"email": "supinogenesia@gmail.com",
|
||||||
|
"primary": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"updatedDate": "2025-10-07T05:48:44.764Z",
|
||||||
|
"phone": "+393406259979",
|
||||||
|
"createdDate": "2025-10-07T05:48:43.567Z"
|
||||||
|
},
|
||||||
|
"submissionId": "c52702c9-55b9-44e1-b158-ec9544c73cc7",
|
||||||
|
"field:anzahl_kinder": "1",
|
||||||
|
"field:first_name_abae": "Genesia ",
|
||||||
|
"field:utm_content_id": "120238574626400196",
|
||||||
|
"field:utm_campaign": "Conversions_Hotel_Bemelmans_ITA",
|
||||||
|
"field:utm_term": "Cold_Traffic_Conversions_Hotel_Bemelmans_ITA",
|
||||||
|
"contactId": "4d695011-36c1-4480-b225-ae9c6eef9e83",
|
||||||
|
"field:date_picker_a7c8": "2026-01-02",
|
||||||
|
"field:hotelname": "Bemelmans Post",
|
||||||
|
"field:utm_content": "Grafik_AuszeitDezember_9.12_23.12",
|
||||||
|
"field:last_name_d97c": "Supino ",
|
||||||
|
"field:hotelid": "12345",
|
||||||
|
"submissionsLink": "https://manage.wix.app/forms/submissions/1dea821c-8168-4736-96e4-4b92e8b364cf/e084006b-ae83-4e4d-b2f5-074118cdb3b1?d=https%3A%2F%2Fmanage.wix.com%2Fdashboard%2F1dea821c-8168-4736-96e4-4b92e8b364cf%2Fwix-forms%2Fform%2Fe084006b-ae83-4e4d-b2f5-074118cdb3b1%2Fsubmissions&s=true",
|
||||||
|
"field:gbraid": "",
|
||||||
|
"field:fbclid": "IwZXh0bgNhZW0BMABhZGlkAassWPh1b8QBHoRc2S24gMktdNKiPwEvGYMK3rB-mne_0IJQvQRIGH60wLvLfOm0XWP8wJ9s_aem_rbpAFMODwOh4UnF5UVxwWg",
|
||||||
|
"submissionPdf": {
|
||||||
|
"fileName": "c52702c9-55b9-44e1-b158-ec9544c73cc7.pdf",
|
||||||
|
"downloadUrl": "https://manage.wix.com/_api/form-submission-service/v4/submissions/c52702c9-55b9-44e1-b158-ec9544c73cc7/download?accessToken=JWS.eyJraWQiOiJWLVNuLWhwZSIsImFsZyI6IkhTMjU2In0.eyJkYXRhIjoie1wibWV0YVNpdGVJZFwiOlwiMWRlYTgyMWMtODE2OC00NzM2LTk2ZTQtNGI5MmU4YjM2NGNmXCJ9IiwiaWF0IjoxNzU5ODE2MTI0LCJleHAiOjE3NTk4MTY3MjR9.quBfp9UL9Ddqb2CWERXoVkh9OdmHlIBvlLAyhoXElaY"
|
||||||
|
},
|
||||||
|
"field:anrede": "Frau",
|
||||||
|
"formId": "e084006b-ae83-4e4d-b2f5-074118cdb3b1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"origin_header": null,
|
||||||
|
"all_headers": {
|
||||||
|
"host": "localhost:8080",
|
||||||
|
"content-type": "application/json",
|
||||||
|
"user-agent": "insomnia/2023.5.8",
|
||||||
|
"accept": "*/*",
|
||||||
|
"content-length": "7335"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ dependencies = [
|
|||||||
"generateds>=2.44.3",
|
"generateds>=2.44.3",
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
"lxml>=6.0.1",
|
"lxml>=6.0.1",
|
||||||
|
"pydantic[email]>=2.11.9",
|
||||||
"pytest>=8.4.2",
|
"pytest>=8.4.2",
|
||||||
"pytest-asyncio>=1.2.0",
|
"pytest-asyncio>=1.2.0",
|
||||||
"redis>=6.4.0",
|
"redis>=6.4.0",
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ from enum import Enum
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from alpine_bits_python.db import Customer, Reservation
|
from alpine_bits_python.db import Customer, Reservation
|
||||||
|
from alpine_bits_python.schemas import (
|
||||||
|
HotelReservationIdData as HotelReservationIdDataValidated,
|
||||||
|
)
|
||||||
|
|
||||||
# Import the generated classes
|
# Import the generated classes
|
||||||
from .generated.alpinebits import (
|
from .generated.alpinebits import (
|
||||||
@@ -21,12 +24,12 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
_LOGGER.setLevel(logging.INFO)
|
_LOGGER.setLevel(logging.INFO)
|
||||||
|
|
||||||
# Define type aliases for the two Customer types
|
# Define type aliases for the two Customer types
|
||||||
NotifCustomer = OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGuests.ResGuest.Profiles.ProfileInfo.Profile.Customer
|
NotifCustomer = OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGuests.ResGuest.Profiles.ProfileInfo.Profile.Customer # noqa: E501
|
||||||
RetrieveCustomer = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGuests.ResGuest.Profiles.ProfileInfo.Profile.Customer
|
RetrieveCustomer = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGuests.ResGuest.Profiles.ProfileInfo.Profile.Customer # noqa: E501
|
||||||
|
|
||||||
# Define type aliases for HotelReservationId types
|
# Define type aliases for HotelReservationId types
|
||||||
NotifHotelReservationId = OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGlobalInfo.HotelReservationIds.HotelReservationId
|
NotifHotelReservationId = OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGlobalInfo.HotelReservationIds.HotelReservationId # noqa: E501
|
||||||
RetrieveHotelReservationId = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.HotelReservationIds.HotelReservationId
|
RetrieveHotelReservationId = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.HotelReservationIds.HotelReservationId # noqa: E501
|
||||||
|
|
||||||
# Define type aliases for Comments types
|
# Define type aliases for Comments types
|
||||||
NotifComments = (
|
NotifComments = (
|
||||||
@@ -326,7 +329,7 @@ class CustomerFactory:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class HotelReservationIdData:
|
class HotelReservationIdData:
|
||||||
"""Simple data class to hold hotel reservation ID information without nested type constraints."""
|
"""Hold hotel reservation ID information without nested type constraints."""
|
||||||
|
|
||||||
res_id_type: str # Required field - pattern: [0-9]+
|
res_id_type: str # Required field - pattern: [0-9]+
|
||||||
res_id_value: None | str = None # Max 64 characters
|
res_id_value: None | str = None # Max 64 characters
|
||||||
@@ -359,7 +362,7 @@ class HotelReservationIdFactory:
|
|||||||
def _create_hotel_reservation_id(
|
def _create_hotel_reservation_id(
|
||||||
hotel_reservation_id_class: type, data: HotelReservationIdData
|
hotel_reservation_id_class: type, data: HotelReservationIdData
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Internal method to create a hotel reservation id of the specified type."""
|
"""Create a hotel reservation id of the specified type."""
|
||||||
return hotel_reservation_id_class(
|
return hotel_reservation_id_class(
|
||||||
res_id_type=data.res_id_type,
|
res_id_type=data.res_id_type,
|
||||||
res_id_value=data.res_id_value,
|
res_id_value=data.res_id_value,
|
||||||
@@ -538,7 +541,7 @@ class ResGuestFactory:
|
|||||||
def _create_res_guests(
|
def _create_res_guests(
|
||||||
res_guests_class: type, customer_class: type, customer_data: CustomerData
|
res_guests_class: type, customer_class: type, customer_data: CustomerData
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Internal method to create complete ResGuests structure."""
|
"""Create the complete ResGuests structure."""
|
||||||
# Create the customer using the existing CustomerFactory
|
# Create the customer using the existing CustomerFactory
|
||||||
customer = CustomerFactory._create_customer(customer_class, customer_data)
|
customer = CustomerFactory._create_customer(customer_class, customer_data)
|
||||||
|
|
||||||
@@ -712,8 +715,6 @@ def _process_single_reservation(
|
|||||||
reservation.num_adults, children_ages, message_type
|
reservation.num_adults, children_ages, message_type
|
||||||
)
|
)
|
||||||
|
|
||||||
unique_id_string = reservation.unique_id
|
|
||||||
|
|
||||||
if message_type == OtaMessageType.NOTIF:
|
if message_type == OtaMessageType.NOTIF:
|
||||||
UniqueId = NotifUniqueId
|
UniqueId = NotifUniqueId
|
||||||
RoomStays = NotifRoomStays
|
RoomStays = NotifRoomStays
|
||||||
@@ -727,8 +728,15 @@ 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
|
||||||
|
|
||||||
|
# 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_string)
|
unique_id = UniqueId(type_value=UniqueIdType2.VALUE_14, id=unique_id_str)
|
||||||
|
|
||||||
# TimeSpan
|
# TimeSpan
|
||||||
time_span = RoomStays.RoomStay.TimeSpan(
|
time_span = RoomStays.RoomStay.TimeSpan(
|
||||||
@@ -744,52 +752,37 @@ def _process_single_reservation(
|
|||||||
)
|
)
|
||||||
|
|
||||||
res_id_source = "website"
|
res_id_source = "website"
|
||||||
|
klick_id = None
|
||||||
|
|
||||||
if reservation.fbclid != "":
|
if reservation.fbclid != "":
|
||||||
klick_id = reservation.fbclid
|
klick_id = str(reservation.fbclid)
|
||||||
res_id_source = "meta"
|
res_id_source = "meta"
|
||||||
elif reservation.gclid != "":
|
elif reservation.gclid != "":
|
||||||
klick_id = reservation.gclid
|
klick_id = str(reservation.gclid)
|
||||||
res_id_source = "google"
|
res_id_source = "google"
|
||||||
|
|
||||||
# explicitly set klick_id to None otherwise an empty string will be sent
|
# Get utm_medium if available, otherwise use source
|
||||||
if klick_id in (None, "", "None"):
|
if reservation.utm_medium is not None and str(reservation.utm_medium) != "":
|
||||||
klick_id = None
|
res_id_source = str(reservation.utm_medium)
|
||||||
else: # extract string from Column object
|
|
||||||
klick_id = str(klick_id)
|
|
||||||
|
|
||||||
hotel_res_id_data = HotelReservationIdData(
|
# Use Pydantic model for automatic validation and truncation
|
||||||
|
# It will automatically:
|
||||||
|
# - Trim whitespace
|
||||||
|
# - Truncate to 64 characters if needed
|
||||||
|
# - Convert empty strings to None
|
||||||
|
hotel_res_id_data_validated = HotelReservationIdDataValidated(
|
||||||
res_id_type="13",
|
res_id_type="13",
|
||||||
res_id_value=klick_id,
|
res_id_value=klick_id,
|
||||||
res_id_source=res_id_source,
|
res_id_source=res_id_source,
|
||||||
res_id_source_context="99tales",
|
res_id_source_context="99tales",
|
||||||
)
|
)
|
||||||
|
|
||||||
# explicitly set klick_id to None otherwise an empty string will be sent
|
# Convert back to dataclass for the factory
|
||||||
if klick_id in (None, "", "None"):
|
|
||||||
klick_id = None
|
|
||||||
else: # extract string from Column object
|
|
||||||
klick_id = str(klick_id)
|
|
||||||
|
|
||||||
utm_medium = (
|
|
||||||
str(reservation.utm_medium)
|
|
||||||
if reservation.utm_medium is not None and str(reservation.utm_medium) != ""
|
|
||||||
else "website"
|
|
||||||
)
|
|
||||||
|
|
||||||
# shorten klick_id if longer than 64 characters
|
|
||||||
# TODO MAGIC SHORTENING
|
|
||||||
if klick_id is not None and len(klick_id) > 64:
|
|
||||||
klick_id = klick_id[:64]
|
|
||||||
|
|
||||||
if klick_id == "":
|
|
||||||
klick_id = None
|
|
||||||
|
|
||||||
hotel_res_id_data = HotelReservationIdData(
|
hotel_res_id_data = HotelReservationIdData(
|
||||||
res_id_type="13",
|
res_id_type=hotel_res_id_data_validated.res_id_type,
|
||||||
res_id_value=klick_id,
|
res_id_value=hotel_res_id_data_validated.res_id_value,
|
||||||
res_id_source=utm_medium,
|
res_id_source=hotel_res_id_data_validated.res_id_source,
|
||||||
res_id_source_context="99tales",
|
res_id_source_context=hotel_res_id_data_validated.res_id_source_context,
|
||||||
)
|
)
|
||||||
|
|
||||||
hotel_res_id = alpine_bits_factory.create(hotel_res_id_data, message_type)
|
hotel_res_id = alpine_bits_factory.create(hotel_res_id_data, message_type)
|
||||||
|
|||||||
@@ -405,11 +405,6 @@ async def process_wix_form_submission(request: Request, data: dict[str, Any], db
|
|||||||
|
|
||||||
unique_id = data.get("submissionId", generate_unique_id())
|
unique_id = data.get("submissionId", generate_unique_id())
|
||||||
|
|
||||||
# TODO MAGIC shortening
|
|
||||||
if len(unique_id) > 32:
|
|
||||||
# strip to first 35 chars
|
|
||||||
unique_id = unique_id[:32]
|
|
||||||
|
|
||||||
# use database session
|
# use database session
|
||||||
|
|
||||||
# Save all relevant data to DB (including new fields)
|
# Save all relevant data to DB (including new fields)
|
||||||
|
|||||||
247
src/alpine_bits_python/schemas.py
Normal file
247
src/alpine_bits_python/schemas.py
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
"""Pydantic models for data validation in AlpineBits.
|
||||||
|
|
||||||
|
These models provide validation for data before it's passed to:
|
||||||
|
- SQLAlchemy database models
|
||||||
|
- AlpineBits XML generation
|
||||||
|
- API endpoints
|
||||||
|
|
||||||
|
Separating validation (Pydantic) from persistence (SQLAlchemy) and
|
||||||
|
from XML generation (xsdata) follows clean architecture principles.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from pydantic import BaseModel, EmailStr, Field, field_validator, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
class PhoneNumber(BaseModel):
|
||||||
|
"""Phone number with optional type."""
|
||||||
|
|
||||||
|
number: str = Field(..., min_length=1, max_length=50, pattern=r"^\+?[0-9\s\-()]+$")
|
||||||
|
tech_type: str | None = Field(None, pattern="^[135]$") # 1=voice, 3=fax, 5=mobile
|
||||||
|
|
||||||
|
@field_validator("number")
|
||||||
|
@classmethod
|
||||||
|
def clean_phone_number(cls, v: str) -> str:
|
||||||
|
"""Remove extra spaces from phone number."""
|
||||||
|
return " ".join(v.split())
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerData(BaseModel):
|
||||||
|
"""Validated customer data for creating reservations and guests."""
|
||||||
|
|
||||||
|
given_name: str = Field(..., min_length=1, max_length=100)
|
||||||
|
surname: str = Field(..., min_length=1, max_length=100)
|
||||||
|
name_prefix: str | None = Field(None, max_length=20)
|
||||||
|
name_title: str | None = Field(None, max_length=20)
|
||||||
|
phone_numbers: list[PhoneNumber] = Field(default_factory=list)
|
||||||
|
email_address: EmailStr | None = None
|
||||||
|
email_newsletter: bool | None = None
|
||||||
|
address_line: str | None = Field(None, max_length=255)
|
||||||
|
city_name: str | None = Field(None, max_length=100)
|
||||||
|
postal_code: str | None = Field(None, max_length=20)
|
||||||
|
country_code: str | None = Field(
|
||||||
|
None, min_length=2, max_length=2, pattern="^[A-Z]{2}$"
|
||||||
|
)
|
||||||
|
address_catalog: bool | None = None
|
||||||
|
gender: str | None = Field(None, pattern="^(Male|Female|Unknown)$")
|
||||||
|
birth_date: str | None = Field(None, pattern=r"^\d{4}-\d{2}-\d{2}$") # ISO format
|
||||||
|
language: str | None = Field(None, min_length=2, max_length=2, pattern="^[a-z]{2}$")
|
||||||
|
|
||||||
|
@field_validator("given_name", "surname")
|
||||||
|
@classmethod
|
||||||
|
def name_must_not_be_empty(cls, v: str) -> str:
|
||||||
|
"""Ensure names are not just whitespace."""
|
||||||
|
if not v.strip():
|
||||||
|
raise ValueError("Name cannot be empty or whitespace")
|
||||||
|
return v.strip()
|
||||||
|
|
||||||
|
@field_validator("country_code")
|
||||||
|
@classmethod
|
||||||
|
def normalize_country_code(cls, v: str | None) -> str | None:
|
||||||
|
"""Normalize country code to uppercase."""
|
||||||
|
return v.upper() if v else None
|
||||||
|
|
||||||
|
@field_validator("language")
|
||||||
|
@classmethod
|
||||||
|
def normalize_language(cls, v: str | None) -> str | None:
|
||||||
|
"""Normalize language code to lowercase."""
|
||||||
|
return v.lower() if v else None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True} # Allow creation from ORM models
|
||||||
|
|
||||||
|
|
||||||
|
class HotelReservationIdData(BaseModel):
|
||||||
|
"""Validated hotel reservation ID data."""
|
||||||
|
|
||||||
|
res_id_type: str = Field(..., pattern=r"^[0-9]+$") # Must be numeric string
|
||||||
|
res_id_value: str | None = None
|
||||||
|
res_id_source: str | None = None
|
||||||
|
res_id_source_context: str | None = None
|
||||||
|
|
||||||
|
@field_validator(
|
||||||
|
"res_id_value", "res_id_source", "res_id_source_context", mode="before"
|
||||||
|
)
|
||||||
|
@classmethod
|
||||||
|
def trim_and_truncate(cls, v: str | None) -> str | None:
|
||||||
|
"""Trim whitespace and truncate to max length if needed.
|
||||||
|
|
||||||
|
Runs BEFORE field validation to ensure values are cleaned and truncated
|
||||||
|
before max_length constraints are checked.
|
||||||
|
"""
|
||||||
|
if not v:
|
||||||
|
return None
|
||||||
|
# Convert to string if needed
|
||||||
|
v = str(v)
|
||||||
|
# Strip whitespace
|
||||||
|
v = v.strip()
|
||||||
|
# Convert empty strings to None
|
||||||
|
if not v:
|
||||||
|
return None
|
||||||
|
# Truncate to 64 characters if needed
|
||||||
|
if len(v) > 64:
|
||||||
|
v = v[:64]
|
||||||
|
return v
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class CommentListItemData(BaseModel):
|
||||||
|
"""Validated comment list item."""
|
||||||
|
|
||||||
|
value: str = Field(..., min_length=1, max_length=1000)
|
||||||
|
list_item: str = Field(..., pattern=r"^[0-9]+$") # Numeric identifier
|
||||||
|
language: str = Field(..., min_length=2, max_length=2, pattern=r"^[a-z]{2}$")
|
||||||
|
|
||||||
|
@field_validator("language")
|
||||||
|
@classmethod
|
||||||
|
def normalize_language(cls, v: str) -> str:
|
||||||
|
"""Normalize language to lowercase."""
|
||||||
|
return v.lower()
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class CommentData(BaseModel):
|
||||||
|
"""Validated comment data."""
|
||||||
|
|
||||||
|
name: str # Should be validated against CommentName2 enum
|
||||||
|
text: str | None = Field(None, max_length=4000)
|
||||||
|
list_items: list[CommentListItemData] = Field(default_factory=list)
|
||||||
|
|
||||||
|
@field_validator("list_items")
|
||||||
|
@classmethod
|
||||||
|
def validate_list_items(
|
||||||
|
cls, v: list[CommentListItemData]
|
||||||
|
) -> list[CommentListItemData]:
|
||||||
|
"""Ensure list items have unique identifiers."""
|
||||||
|
if v:
|
||||||
|
item_ids = [item.list_item for item in v]
|
||||||
|
if len(item_ids) != len(set(item_ids)):
|
||||||
|
raise ValueError("List items must have unique identifiers")
|
||||||
|
return v
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class CommentsData(BaseModel):
|
||||||
|
"""Validated comments collection."""
|
||||||
|
|
||||||
|
comments: list[CommentData] = Field(default_factory=list, max_length=3)
|
||||||
|
|
||||||
|
@field_validator("comments")
|
||||||
|
@classmethod
|
||||||
|
def validate_comment_count(cls, v: list[CommentData]) -> list[CommentData]:
|
||||||
|
"""Ensure maximum 3 comments."""
|
||||||
|
if len(v) > 3:
|
||||||
|
raise ValueError("Maximum 3 comments allowed")
|
||||||
|
return v
|
||||||
|
|
||||||
|
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."""
|
||||||
|
|
||||||
|
def __init__(self, db_session):
|
||||||
|
self.db_session = db_session
|
||||||
|
|
||||||
|
async def create_reservation(
|
||||||
|
self, reservation_data: ReservationData, customer_data: CustomerData
|
||||||
|
):
|
||||||
|
"""Create a reservation with validated data.
|
||||||
|
|
||||||
|
The data has already been validated by Pydantic before reaching here.
|
||||||
|
"""
|
||||||
|
from alpine_bits_python.db import Customer, Reservation
|
||||||
|
|
||||||
|
# Convert validated Pydantic model to SQLAlchemy model
|
||||||
|
db_customer = Customer(**customer_data.model_dump(exclude_none=True))
|
||||||
|
self.db_session.add(db_customer)
|
||||||
|
await self.db_session.flush() # Get the customer ID
|
||||||
|
|
||||||
|
# Create reservation linked to customer
|
||||||
|
db_reservation = Reservation(
|
||||||
|
customer_id=db_customer.id,
|
||||||
|
**reservation_data.model_dump(
|
||||||
|
exclude={"children_ages"}
|
||||||
|
), # Handle separately
|
||||||
|
children_ages=",".join(map(str, reservation_data.children_ages)),
|
||||||
|
)
|
||||||
|
self.db_session.add(db_reservation)
|
||||||
|
await self.db_session.commit()
|
||||||
|
|
||||||
|
return db_reservation, db_customer
|
||||||
29
uv.lock
generated
29
uv.lock
generated
@@ -26,6 +26,7 @@ dependencies = [
|
|||||||
{ name = "generateds" },
|
{ name = "generateds" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "lxml" },
|
{ name = "lxml" },
|
||||||
|
{ name = "pydantic", extra = ["email"] },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-asyncio" },
|
{ name = "pytest-asyncio" },
|
||||||
{ name = "redis" },
|
{ name = "redis" },
|
||||||
@@ -47,6 +48,7 @@ requires-dist = [
|
|||||||
{ name = "generateds", specifier = ">=2.44.3" },
|
{ name = "generateds", specifier = ">=2.44.3" },
|
||||||
{ name = "httpx", specifier = ">=0.28.1" },
|
{ name = "httpx", specifier = ">=0.28.1" },
|
||||||
{ name = "lxml", specifier = ">=6.0.1" },
|
{ name = "lxml", specifier = ">=6.0.1" },
|
||||||
|
{ name = "pydantic", extras = ["email"], specifier = ">=2.11.9" },
|
||||||
{ name = "pytest", specifier = ">=8.4.2" },
|
{ name = "pytest", specifier = ">=8.4.2" },
|
||||||
{ name = "pytest-asyncio", specifier = ">=1.2.0" },
|
{ name = "pytest-asyncio", specifier = ">=1.2.0" },
|
||||||
{ name = "redis", specifier = ">=6.4.0" },
|
{ name = "redis", specifier = ">=6.4.0" },
|
||||||
@@ -206,6 +208,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/6e/c6/ac0b6c1e2d138f1002bcf799d330bd6d85084fece321e662a14223794041/Deprecated-1.2.18-py2.py3-none-any.whl", hash = "sha256:bd5011788200372a32418f888e326a09ff80d0214bd961147cfed01b5c018eec", size = 9998, upload-time = "2025-01-27T10:46:09.186Z" },
|
{ url = "https://files.pythonhosted.org/packages/6e/c6/ac0b6c1e2d138f1002bcf799d330bd6d85084fece321e662a14223794041/Deprecated-1.2.18-py2.py3-none-any.whl", hash = "sha256:bd5011788200372a32418f888e326a09ff80d0214bd961147cfed01b5c018eec", size = 9998, upload-time = "2025-01-27T10:46:09.186Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dnspython"
|
||||||
|
version = "2.8.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "docformatter"
|
name = "docformatter"
|
||||||
version = "1.7.7"
|
version = "1.7.7"
|
||||||
@@ -230,6 +241,19 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" },
|
{ url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "email-validator"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "dnspython" },
|
||||||
|
{ name = "idna" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.117.1"
|
version = "0.117.1"
|
||||||
@@ -508,6 +532,11 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/3e/d3/108f2006987c58e76691d5ae5d200dd3e0f532cb4e5fa3560751c3a1feba/pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2", size = 444855, upload-time = "2025-09-13T11:26:36.909Z" },
|
{ url = "https://files.pythonhosted.org/packages/3e/d3/108f2006987c58e76691d5ae5d200dd3e0f532cb4e5fa3560751c3a1feba/pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2", size = 444855, upload-time = "2025-09-13T11:26:36.909Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
email = [
|
||||||
|
{ name = "email-validator" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic-core"
|
name = "pydantic-core"
|
||||||
version = "2.33.2"
|
version = "2.33.2"
|
||||||
|
|||||||
Reference in New Issue
Block a user