diff --git a/logs/push_requests/alpinebits_push_12345_c52702c9-55b9-44e1-b158-ec9544c73cc7_20251007_155426.xml b/logs/push_requests/alpinebits_push_12345_c52702c9-55b9-44e1-b158-ec9544c73cc7_20251007_155426.xml new file mode 100644 index 0000000..ae95a6f --- /dev/null +++ b/logs/push_requests/alpinebits_push_12345_c52702c9-55b9-44e1-b158-ec9544c73cc7_20251007_155426.xml @@ -0,0 +1,4 @@ + + + + diff --git a/logs/wix_test_data_20251006_154435.json b/logs/wix_test_data_20251006_154435.json index e1eefe4..d48564c 100644 --- a/logs/wix_test_data_20251006_154435.json +++ b/logs/wix_test_data_20251006_154435.json @@ -259,4 +259,4 @@ "accept": "*/*", "content-length": "7081" } -} \ No newline at end of file +} diff --git a/logs/wix_test_data_20251007_155426.json b/logs/wix_test_data_20251007_155426.json new file mode 100644 index 0000000..74b111d --- /dev/null +++ b/logs/wix_test_data_20251007_155426.json @@ -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" + } +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 11a764a..830503d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "generateds>=2.44.3", "httpx>=0.28.1", "lxml>=6.0.1", + "pydantic[email]>=2.11.9", "pytest>=8.4.2", "pytest-asyncio>=1.2.0", "redis>=6.4.0", diff --git a/src/alpine_bits_python/alpine_bits_helpers.py b/src/alpine_bits_python/alpine_bits_helpers.py index 1f865f7..f5d0489 100644 --- a/src/alpine_bits_python/alpine_bits_helpers.py +++ b/src/alpine_bits_python/alpine_bits_helpers.py @@ -6,6 +6,9 @@ from enum import Enum from typing import Any from alpine_bits_python.db import Customer, Reservation +from alpine_bits_python.schemas import ( + HotelReservationIdData as HotelReservationIdDataValidated, +) # Import the generated classes from .generated.alpinebits import ( @@ -21,12 +24,12 @@ _LOGGER = logging.getLogger(__name__) _LOGGER.setLevel(logging.INFO) # Define type aliases for the two Customer types -NotifCustomer = OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGuests.ResGuest.Profiles.ProfileInfo.Profile.Customer -RetrieveCustomer = OtaResRetrieveRs.ReservationsList.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 # noqa: E501 # Define type aliases for HotelReservationId types -NotifHotelReservationId = OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGlobalInfo.HotelReservationIds.HotelReservationId -RetrieveHotelReservationId = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.HotelReservationIds.HotelReservationId +NotifHotelReservationId = OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGlobalInfo.HotelReservationIds.HotelReservationId # noqa: E501 +RetrieveHotelReservationId = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.HotelReservationIds.HotelReservationId # noqa: E501 # Define type aliases for Comments types NotifComments = ( @@ -326,7 +329,7 @@ class CustomerFactory: @dataclass 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_value: None | str = None # Max 64 characters @@ -359,7 +362,7 @@ class HotelReservationIdFactory: def _create_hotel_reservation_id( hotel_reservation_id_class: type, data: HotelReservationIdData ) -> 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( res_id_type=data.res_id_type, res_id_value=data.res_id_value, @@ -538,7 +541,7 @@ class ResGuestFactory: def _create_res_guests( res_guests_class: type, customer_class: type, customer_data: CustomerData ) -> Any: - """Internal method to create complete ResGuests structure.""" + """Create the complete ResGuests structure.""" # Create the customer using the existing CustomerFactory customer = CustomerFactory._create_customer(customer_class, customer_data) @@ -712,8 +715,6 @@ def _process_single_reservation( reservation.num_adults, children_ages, message_type ) - unique_id_string = reservation.unique_id - if message_type == OtaMessageType.NOTIF: UniqueId = NotifUniqueId RoomStays = NotifRoomStays @@ -727,8 +728,15 @@ def _process_single_reservation( else: 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 - 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 time_span = RoomStays.RoomStay.TimeSpan( @@ -744,52 +752,37 @@ def _process_single_reservation( ) res_id_source = "website" + klick_id = None if reservation.fbclid != "": - klick_id = reservation.fbclid + klick_id = str(reservation.fbclid) res_id_source = "meta" elif reservation.gclid != "": - klick_id = reservation.gclid + klick_id = str(reservation.gclid) res_id_source = "google" - # explicitly set klick_id to None otherwise an empty string will be sent - if klick_id in (None, "", "None"): - klick_id = None - else: # extract string from Column object - klick_id = str(klick_id) + # Get utm_medium if available, otherwise use source + if reservation.utm_medium is not None and str(reservation.utm_medium) != "": + res_id_source = str(reservation.utm_medium) - 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_value=klick_id, res_id_source=res_id_source, res_id_source_context="99tales", ) - # explicitly set klick_id to None otherwise an empty string will be sent - 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 - + # Convert back to dataclass for the factory hotel_res_id_data = HotelReservationIdData( - res_id_type="13", - res_id_value=klick_id, - res_id_source=utm_medium, - res_id_source_context="99tales", + res_id_type=hotel_res_id_data_validated.res_id_type, + res_id_value=hotel_res_id_data_validated.res_id_value, + res_id_source=hotel_res_id_data_validated.res_id_source, + 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) diff --git a/src/alpine_bits_python/api.py b/src/alpine_bits_python/api.py index f767e89..5b878a9 100644 --- a/src/alpine_bits_python/api.py +++ b/src/alpine_bits_python/api.py @@ -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()) - # TODO MAGIC shortening - if len(unique_id) > 32: - # strip to first 35 chars - unique_id = unique_id[:32] - # use database session # Save all relevant data to DB (including new fields) diff --git a/src/alpine_bits_python/schemas.py b/src/alpine_bits_python/schemas.py new file mode 100644 index 0000000..cceafa8 --- /dev/null +++ b/src/alpine_bits_python/schemas.py @@ -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 diff --git a/uv.lock b/uv.lock index b9286a4..d63a246 100644 --- a/uv.lock +++ b/uv.lock @@ -26,6 +26,7 @@ dependencies = [ { name = "generateds" }, { name = "httpx" }, { name = "lxml" }, + { name = "pydantic", extra = ["email"] }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "redis" }, @@ -47,6 +48,7 @@ requires-dist = [ { name = "generateds", specifier = ">=2.44.3" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "lxml", specifier = ">=6.0.1" }, + { name = "pydantic", extras = ["email"], specifier = ">=2.11.9" }, { name = "pytest", specifier = ">=8.4.2" }, { name = "pytest-asyncio", specifier = ">=1.2.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" }, ] +[[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]] name = "docformatter" 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" }, ] +[[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]] name = "fastapi" 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" }, ] +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + [[package]] name = "pydantic-core" version = "2.33.2"