50 Commits

Author SHA1 Message Date
Jonas Linter
52f95bd677 Updated config 2025-10-08 15:28:36 +02:00
Jonas Linter
6701dcd6bf Probably added gzip 2025-10-08 14:36:21 +02:00
Jonas Linter
9f0a77ca39 Removed unneccessary scripts 2025-10-08 14:26:11 +02:00
Jonas Linter
259243d44b updated db 2025-10-08 13:53:44 +02:00
Jonas Linter
84a57f3d98 Created endpoint for export 2025-10-08 13:28:38 +02:00
Jonas Linter
ff25142f62 All tests pass again. Handeling the children is difficult 2025-10-08 11:23:18 +02:00
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
Jonas Linter
a8f46016be Merge branch 'main' into db_modeling_for_capi 2025-10-08 08:48:51 +02:00
Jonas Linter
e0c9afe227 Hotfix. Echodata unverändert zurückgeben 2025-10-08 08:33:54 +02:00
Jonas Linter
9094f3e3b7 More tests. Hard to say how useful they are though. Need further work 2025-10-07 17:25:27 +02:00
Jonas Linter
867b2632df Created copilot instructions and testing readRequests 2025-10-07 17:16:41 +02:00
Jonas Linter
a69816baa4 Additonal validation and better type hints 2025-10-07 16:28:43 +02:00
Jonas Linter
e605af1231 Using pydantic instead of dataclasses 2025-10-07 16:06:53 +02:00
Jonas Linter
e5a295faba Experimenting with pydantic 2025-10-07 15:59:00 +02:00
Jonas Linter
5ec47b8332 More cleanup. 2025-10-07 15:12:46 +02:00
Jonas Linter
122c7c8be4 Trying to gzip compress response 2025-10-07 11:34:33 +02:00
Jonas Linter
6102194712 moved tests 2025-10-07 10:24:01 +02:00
Jonas Linter
f0945ed431 Linting and formatting 2025-10-07 09:46:44 +02:00
Jonas Linter
b4b7a537e1 Formatting 2025-10-07 09:40:23 +02:00
Jonas Linter
2d9e90c9a4 Fixed test. Now compares json contents instead of strings 2025-10-07 09:33:13 +02:00
Jonas Linter
4e03d1e089 Don't send exceptions back as responses 2025-10-07 09:04:10 +02:00
Jonas Linter
1f9c969e69 formatting changes 2025-10-07 09:00:01 +02:00
Jonas Linter
106316dc6d Probably fixed handshake 2025-10-07 08:31:22 +02:00
Jonas Linter
951d3a2a26 Test stub 2025-10-07 08:28:25 +02:00
Jonas Linter
1248ba3f3a Updated example test files 2025-10-06 16:01:36 +02:00
Jonas Linter
3b33e552a9 Config an prod angepasst 2025-10-06 15:45:14 +02:00
Jonas Linter
35531ff925 Simplified comments 2025-10-06 15:41:12 +02:00
Jonas Linter
c4fa774a86 Added test forms 2025-10-06 15:38:19 +02:00
Jonas Linter
4b37d8c52c Unique_ids waren zu lange 2025-10-06 15:22:56 +02:00
Jonas Linter
c320fe866d == for string compare whupps 2025-10-06 15:15:14 +02:00
Jonas Linter
201f218c23 Really ensuring res_id isn't "" 2025-10-06 15:13:56 +02:00
Jonas Linter
808f0eccc8 Added build file 2025-10-06 14:48:16 +02:00
Jonas Linter
b8e4f4fd01 Merging to main 2025-10-06 14:46:58 +02:00
Jonas Linter
17c3fc57b2 Push requests should be mostly done 2025-10-06 11:47:28 +02:00
Jonas Linter
87668e6dc0 Unhappy with push_listener 2025-10-06 11:09:08 +02:00
Jonas Linter
68e49aab34 Made helper methods more userfriendly. Guest requests still works as expected 2025-10-06 10:58:05 +02:00
Jonas Linter
2944b52d43 Super simple email newsletter parsing. Better safe then sorry 2025-10-06 10:21:41 +02:00
Jonas Linter
325965bb10 Fixed up ping test 2025-10-02 15:44:52 +02:00
Jonas Linter
48aec92794 Fixed a small handshaking bug thanks to tests 2025-10-02 15:34:23 +02:00
Jonas Linter
82118a1fa8 Added some tests for Handshakes 2025-10-02 14:26:06 +02:00
Jonas Linter
233a682e35 Fixed OTA_NotifReport by matching on entire ActionEnum and not just one action string. Now OTA_NotifReport:GuestRequests is distinct even if its corresponding capability action is technically identical OTA_Read:GuestRequests 2025-10-02 13:43:15 +02:00
Jonas Linter
9c292a9897 FFS notifReport is another special case 2025-10-02 11:58:30 +02:00
Jonas Linter
277bd1934e Fixed empty klick_ids 2025-10-01 16:44:47 +02:00
Jonas Linter
b7afe4f528 Fixed some shoddy typing 2025-10-01 16:43:50 +02:00
Jonas Linter
36c32c44d8 Created a listener for wix-form to do push actions with but unsure how to best handle it 2025-10-01 16:32:15 +02:00
Jonas Linter
ea9b6c72e4 fixed config 2025-10-01 15:38:23 +02:00
Jonas Linter
dbfbd53ad9 Removed unused old experiments 2025-10-01 12:02:40 +02:00
Jonas Linter
579db2231f Barebones notif works. Doing nothing with warnings at the moment. Not sure what I can do exept log the things 2025-10-01 11:23:54 +02:00
Jonas Linter
9f289e4750 Fixed unique_id issue in reservation table 2025-10-01 10:15:27 +02:00
54 changed files with 6288 additions and 2004 deletions

139
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,139 @@
# AlpineBits Python Server - AI Agent Instructions
## Project Overview
This is an **AlpineBits 2024-10 server** that bridges booking requests from Wix landing pages to hotel partners. It's a dual-purpose system:
1. **FastAPI webhook receiver** - accepts booking forms from wix.com landing pages via `/api/webhook/wix-form`
2. **AlpineBits OTA server** - exposes hotel reservation data at `/api/alpinebits/server-2024-10` using OpenTravel Alliance XML protocol
Data flows: Wix form → Database → AlpineBits XML → Hotel systems (pull or push)
## Architecture Patterns
### XML Generation with xsdata
- **Never manually construct XML strings**. Use xsdata-generated Pydantic dataclasses from `src/alpine_bits_python/generated/alpinebits.py`
- Parse XML: `XmlParser().from_string(xml_string, OtaPingRq)`
- Serialize XML: `XmlSerializer(config=SerializerConfig(...)).render(ota_object)`
- Factory pattern: Use classes in `alpine_bits_helpers.py` (e.g., `CustomerFactory`, `GuestCountsFactory`) to build complex OTA objects from DB models
- Example: `create_res_retrieve_response()` builds OTA_ResRetrieveRS from `(Reservation, Customer)` tuples
- **Regenerating XML classes**: Run `xsdata` on `AlpineBits-HotelData-2024-10/files/schema-xsd/alpinebits.xsd` to regenerate `generated/alpinebits.py` (only if XSD spec changes)
### Configuration System
- Config loaded from YAML with secret injection via `!secret` tags (see `config_loader.py`)
- Default config location: `config/config.yaml` + `config/secrets.yaml`
- Override via `ALPINEBITS_CONFIG_DIR` environment variable
- Multi-hotel support: Each hotel in `alpine_bits_auth` array gets own credentials and optional `push_endpoint`
### Database Layer
- **Async-only SQLAlchemy** with `AsyncSession` (see `db.py`)
- Three core tables: `Customer`, `Reservation`, `AckedRequest` (tracks which clients acknowledged which reservations)
- DB URL configurable: SQLite for dev (`sqlite+aiosqlite:///alpinebits.db`), PostgreSQL for prod
- Database auto-created on startup in `api.py:create_app()`
### Event-Driven Push System
- `EventDispatcher` in `api.py` enables hotel-specific listeners: `event_dispatcher.register_hotel_listener("reservation:created", hotel_code, push_listener)`
- Push listener sends OTA_HotelResNotif XML to hotel's configured `push_endpoint.url` with Bearer token auth
- Push requests logged to `logs/push_requests/` with timestamp and unique ID
- **Note**: Push endpoint support is currently dormant - configured but not actively used by partners
### AlpineBits Action Pattern
- Each OTA action is a class inheriting `AlpineBitsActionHandler` (see `alpinebits_server.py`)
- Actions: `PingAction`, `ReadAction`, `NotifReportAction`, `PushAction`
- Request flow: Parse XML → Call `handle()` → Return `AlpineBitsActionResult` with XML response + HTTP status
- `AlpineBitsActionName` enum maps capability names to request names (e.g., `OTA_READ``"OTA_Read:GuestRequests"`)
- Server supports multiple AlpineBits versions (2024-10, 2022-10) when actions are identical across versions
### Acknowledgment System
- `AckedRequest` table tracks which clients acknowledged which reservations via `OTA_NotifReport:GuestRequests`
- Read requests filter out acknowledged reservations for clients with `client_id`
- Prevents duplicate reservation sends: once acknowledged, data won't appear in subsequent reads for that client
## Critical Workflows
### Running Locally
```bash
uv sync # Install dependencies (uses uv, not pip!)
uv run python -m alpine_bits_python.run_api # Start server on port 8080, clears DB on startup
```
### Testing
```bash
uv run pytest # Run all tests
uv run pytest tests/test_alpine_bits_server_read.py # Specific test file
```
- Tests use in-memory SQLite via `test_db_engine` fixture (see `tests/test_alpine_bits_server_read.py`)
- Test data fixtures in `tests/test_data/` directory
### Building for Deployment
```bash
uv sync
docker build . -t gitea.linter-home.com/jonas/asa_api:master
```
- Multi-stage Dockerfile: builder stage installs deps with uv, production stage copies `.venv`
- Runs as non-root user (UID 1000) for security
- Requires `ALPINEBITS_CONFIG_DIR=/config` volume mount for config files
- **Deployment**: Docker build pipeline exists and works; can also build manually on target system
## Project-Specific Conventions
### Naming Patterns
- OTA message types use full AlpineBits names: `OtaReadRq`, `OtaResRetrieveRs`, `OtaHotelResNotifRq`
- Factory classes suffix with `Factory`: `CustomerFactory`, `HotelReservationIdFactory`
- DB models in `db.py`, validation schemas in `schemas.py`, OTA helpers in `alpine_bits_helpers.py`
### Data Validation Flow
1. **API Layer** → Pydantic schemas (`schemas.py`) validate incoming data
2. **DB Layer** → SQLAlchemy models (`db.py`) persist validated data
3. **XML Layer** → xsdata classes (`generated/alpinebits.py`) + factories (`alpine_bits_helpers.py`) generate OTA XML
This separation prevents mixing concerns (validation ≠ persistence ≠ XML generation).
### Unique ID Generation
- Reservation IDs: 35-char max, format `{hotel_code}_{uuid4}_{timestamp}`
- Generated via `generate_unique_id()` in `auth.py`
### Rate Limiting
- Uses `slowapi` with Redis backend
- Three tiers: `DEFAULT_RATE_LIMIT` (100/hour), `WEBHOOK_RATE_LIMIT` (300/hour), `BURST_RATE_LIMIT` (10/minute)
- Applied via decorators: `@limiter.limit(DEFAULT_RATE_LIMIT)`
## Common Pitfalls
1. **Don't use synchronous SQLAlchemy calls** - Always `await session.execute()`, never `session.query()`
2. **Don't hardcode XML namespaces** - Let xsdata handle them via generated classes
3. **Don't skip config validation** - Voluptuous schemas in `config_loader.py` catch config errors early
4. **Auth is per-hotel** - HTTP Basic Auth credentials from `alpine_bits_auth` config array
5. **AlpineBits version matters** - Server implements 2024-10 spec (see `AlpineBits-HotelData-2024-10/` directory)
## Key Files Reference
- `api.py` - FastAPI app, all endpoints, event dispatcher
- `alpinebits_server.py` - AlpineBits action handlers (Ping, Read, NotifReport)
- `alpine_bits_helpers.py` - Factory classes for building OTA XML from DB models
- `config_loader.py` - YAML config loading with secret injection
- `db.py` - SQLAlchemy async models (Customer, Reservation, AckedRequest)
- `schemas.py` - Pydantic validation schemas
- `generated/alpinebits.py` - xsdata-generated OTA XML classes (DO NOT EDIT - regenerate from XSD)
## Testing Strategy
- Fixtures create isolated in-memory databases per test
- Use `test_config()` fixture for test configuration
- XML serialization/parsing tested via xsdata round-trips
- Push endpoint mocking via httpx in tests

88
.github/workflows/build.yaml vendored Normal file
View File

@@ -0,0 +1,88 @@
name: CI to Docker Hub
# Controls when the workflow will run
on:
# Triggers the workflow on push or pull request events but only for the main branch
push:
branches: [ "*" ]
tags: [ "*" ]
pull_request:
branches: [ main ]
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
# This workflow contains a single job called "build"
build:
# The type of runner that the job will run on
runs-on: ubuntu-latest
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: UV sync
run: uv auth login gitea.linter-home.com --username jonas --password ${{ secrets.CI_TOKEN }} && uv lock
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Login to Gitea Docker Registry
uses: docker/login-action@v2
with:
registry: ${{ vars.REGISTRY }}
username: ${{ vars.USER_NAME }}
password: ${{ secrets.CI_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ vars.REGISTRY }}/${{ vars.USER_NAME }}/asa_api
# generate Docker tags based on the following events/attributes
tags: |
type=schedule
type=ref,event=branch
type=ref,event=pr
type=ref,event=tag
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha
# - name: Debug DNS Resolution
# run: sudo apt-get update && sudo apt-get install -y dnsutils &&
# nslookup https://${{ vars.REGISTRY }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
registry: ${{ vars.REGISTRY }}
username: ${{ vars.USER_NAME }}
password: ${{ secrets.CI_TOKEN }}
- name: Build and push
id: docker_build
uses: docker/build-push-action@v6
with:
context: .
build-args: |
CI_TOKEN=${{ secrets.CI_TOKEN }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}

2
.gitignore vendored
View File

@@ -17,6 +17,8 @@ wheels/
# ignore test_data content but keep the folder
test_data/*
test/test_output/*
# ignore secrets
secrets.yaml

57
.vscode/settings.json vendored
View File

@@ -1,8 +1,53 @@
{
"python.testing.pytestArgs": [
"test"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
"python.analysis.typeCheckingMode": "basic"
"editor.formatOnSave": true,
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit"
}
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"notebook.formatOnSave.enabled": true,
"notebook.codeActionsOnSave": {
// "notebook.source.fixAll": "explicit",
// "notebook.source.organizeImports": "explicit"
},
"notebook.output.wordWrap": true,
"notebook.output.textLineLimit": 200,
"jupyter.debugJustMyCode": false,
"python.testing.pytestEnabled": true,
"files.exclude": {
"**/*.egg-info": true,
"**/htmlcov": true,
"**/~$*": true,
"**/.coverage.*": true,
"**/.venv": true,
"**/__pycache__": true,
"**/.mypy_cache": true,
"**/.pytest_cache": true,
}
}
// .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"name": "Python: Debug Tests",
"type": "debugpy",
"request": "launch",
"program": "${file}",
"purpose": [
"debug-test"
],
"console": "integratedTerminal",
"justMyCode": false,
"env": {
"PYTEST_ADDOPTS": "--no-cov"
}
}
]
}

24
99Tales_Testexport.xml Normal file
View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<reservations>
<reservation id="2409" number="191" date="2025-08-28" creationTime="2025-08-28T11:53:45" type="reservation" bookingGroup="" bookingChannel="99TALES" advertisingMedium="99TALES" advertisingPartner="399">
<guest id="364" lastName="Busch" firstName="Sebastian" language="de" gender="male" dateOfBirth="" postalCode="58454" city="Witten" countryCode="DE" country="DEUTSCHLAND" email="test@test.com"/>
<company/>
<roomReservations>
<roomReservation arrival="2025-09-03" departure="2025-09-12" status="reserved" roomType="EZ" roomNumber="106" adults="1" children="0" infants="0" ratePlanCode="WEEK" connectedRoomType="0">
<connectedRooms/>
<dailySales>
<dailySale date="2025-09-03" revenueTotal="174" revenueLogis="127.5" revenueBoard="9" revenueFB="10" revenueSpa="1" revenueOther="26.5" revenueResources=""/>
<dailySale date="2025-09-04" revenueTotal="164" revenueLogis="127.5" revenueBoard="9" revenueFB="10" revenueSpa="1" revenueOther="16.5" revenueResources=""/>
<dailySale date="2025-09-05" revenueTotal="164" revenueLogis="127.5" revenueBoard="9" revenueFB="10" revenueSpa="1" revenueOther="16.5" revenueResources=""/>
<dailySale date="2025-09-06" revenueTotal="164" revenueLogis="127.5" revenueBoard="9" revenueFB="10" revenueSpa="1" revenueOther="16.5" revenueResources=""/>
<dailySale date="2025-09-07" revenueTotal="164" revenueLogis="127.5" revenueBoard="9" revenueFB="10" revenueSpa="1" revenueOther="16.5" revenueResources=""/>
<dailySale date="2025-09-08" revenueTotal="164" revenueLogis="127.5" revenueBoard="9" revenueFB="10" revenueSpa="1" revenueOther="16.5" revenueResources=""/>
<dailySale date="2025-09-09" revenueTotal="164" revenueLogis="127.5" revenueBoard="9" revenueFB="10" revenueSpa="1" revenueOther="16.5" revenueResources=""/>
<dailySale date="2025-09-10" revenueTotal="164" revenueLogis="127.5" revenueBoard="9" revenueFB="10" revenueSpa="1" revenueOther="16.5" revenueResources=""/>
<dailySale date="2025-09-11" revenueTotal="149" revenueLogis="127.5" revenueBoard="9" revenueFB="10" revenueSpa="1" revenueOther="1.5" revenueResources=""/>
<dailySale date="2025-09-12" revenueTotal="" revenueLogis="" revenueBoard="" revenueFB="" revenueSpa="" revenueOther="" revenueResources=""/>
</dailySales>
</roomReservation>
</roomReservations>
</reservation>
</reservations>

View File

@@ -5,12 +5,25 @@ database:
url: "sqlite+aiosqlite:///alpinebits.db" # For local dev, use SQLite. For prod, override with PostgreSQL URL.
# url: "postgresql://user:password@host:port/dbname" # Example for Postgres
# AlpineBits Python config
# Use annotatedyaml for secrets and environment-specific overrides
alpine_bits_auth:
- hotel_id: "123"
hotel_name: "Frangart Inn"
username: "alice"
password: !secret ALICE_PASSWORD
- hotel_id: "456"
hotel_name: "Bemelmans"
username: "bob"
- hotel_id: "39054_001"
hotel_name: "Bemelmans Post"
username: "bemelman"
password: !secret BEMELMANS_PASSWORD
- hotel_id: "135"
hotel_name: "Testhotel"
username: "sebastian"
password: !secret BOB_PASSWORD
- hotel_id: "39052_001"
hotel_name: "Jagthof Kaltern"
username: "jagthof"
password: !secret JAGTHOF_PASSWORD
- hotel_id: "39040_001"
hotel_name: "Residence Erika"
username: "erika"
password: !secret ERIKA_PASSWORD

View File

@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="UTF-8"?>
<OTA_HotelResNotifRQ xmlns="http://www.opentravel.org/OTA/2003/05" Version="7.000">
<HotelReservations>
<HotelReservation CreateDateTime="2025-10-07T15:13:38.831800+00:00" ResStatus="Requested" RoomStayReservation="true">
<UniqueID Type="14" ID="8e68dab6-7c2e-4c67-9471-b8cbfb7b"/>
<RoomStays>
<RoomStay>
<GuestCounts>
<GuestCount Count="13"/>
</GuestCounts>
<TimeSpan Start="2025-10-25" End="2025-10-26"/>
</RoomStay>
</RoomStays>
<ResGuests>
<ResGuest>
<Profiles>
<ProfileInfo>
<Profile>
<Customer Language="de">
<PersonName>
<NamePrefix>Frau</NamePrefix>
<GivenName>Christine</GivenName>
<Surname>Niederkofler</Surname>
</PersonName>
<Telephone PhoneTechType="5" PhoneNumber="+4953346312"/>
<Email Remark="newsletter:yes">info@ledermode.at</Email>
</Customer>
</Profile>
</ProfileInfo>
</Profiles>
</ResGuest>
</ResGuests>
<ResGlobalInfo>
<Comments>
<Comment Name="additional info">
<Text>Angebot/Offerta: Törggelewochen - Herbstliche Genüsse &amp; Südtiroler Tradition</Text>
</Comment>
<Comment Name="customer comment">
<Text>Hallo. Wir würden gerne mit unseren Mitarbeitern vom 25.10 - 26.10.25 nach Südtirol fahren.
Geplant wäre am Samstagabend Törggelen und am Sonntag nach dem Frühstück mit der Gondel zur Seiser Alm zu fahren.
Wir sind ca. 13 Personen (es können gerne auch 3-Bettzimmer dabei sein falls vorhanden. Sonst DZ und wir benötigen 1 EZ).
Bitte um ein Angebot für Törggelen, Übernachtung und Frühstück. Vielen lieben Dank! Christine Niederkofler</Text>
</Comment>
</Comments>
<HotelReservationIDs>
<HotelReservationID ResID_Type="13" ResID_Value="Cj0KCQjw3OjGBhDYARIsADd-uX65gXKdbOti_3OOA50T-B9Uj-zsOzXJ7g2-8Tz_" ResID_Source="google" ResID_SourceContext="99tales"/>
</HotelReservationIDs>
<Profiles>
<ProfileInfo>
<Profile ProfileType="4">
<CompanyInfo>
<CompanyName Code="who knows?" CodeContext="who knows?">99tales GmbH</CompanyName>
</CompanyInfo>
</Profile>
</ProfileInfo>
</Profiles>
<BasicPropertyInfo HotelCode="12345" HotelName="Frangart Inn"/>
</ResGlobalInfo>
</HotelReservation>
</HotelReservations>
</OTA_HotelResNotifRQ>

View File

@@ -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>

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<OTA_HotelResNotifRQ xmlns="http://www.opentravel.org/OTA/2003/05" Version="7.000">
<HotelReservations>
<HotelReservation CreateDateTime="2025-10-07T14:05:37.563674+00:00" ResStatus="Requested" RoomStayReservation="true">
<UniqueID Type="14" ID="c52702c9-55b9-44e1-b158-ec9544c7"/>
<RoomStays>
<RoomStay>
<GuestCounts>
<GuestCount Count="3"/>
<GuestCount Count="1" Age="12"/>
</GuestCounts>
<TimeSpan Start="2026-01-02" End="2026-01-07"/>
</RoomStay>
</RoomStays>
<ResGuests>
<ResGuest>
<Profiles>
<ProfileInfo>
<Profile>
<Customer Language="it">
<PersonName>
<NamePrefix>Frau</NamePrefix>
<GivenName>Genesia</GivenName>
<Surname>Supino</Surname>
</PersonName>
<Telephone PhoneTechType="5" PhoneNumber="+393406259979"/>
<Email Remark="newsletter:yes">supinogenesia@gmail.com</Email>
</Customer>
</Profile>
</ProfileInfo>
</Profiles>
</ResGuest>
</ResGuests>
<ResGlobalInfo>
<HotelReservationIDs>
<HotelReservationID ResID_Type="13" ResID_Value="IwZXh0bgNhZW0BMABhZGlkAassWPh1b8QBHoRc2S24gMktdNKiPwEvGYMK3rB-mn" ResID_Source="Facebook_Mobile_Feed" ResID_SourceContext="99tales"/>
</HotelReservationIDs>
<Profiles>
<ProfileInfo>
<Profile ProfileType="4">
<CompanyInfo>
<CompanyName Code="who knows?" CodeContext="who knows?">99tales GmbH</CompanyName>
</CompanyInfo>
</Profile>
</ProfileInfo>
</Profiles>
<BasicPropertyInfo HotelCode="12345" HotelName="Bemelmans Post"/>
</ResGlobalInfo>
</HotelReservation>
</HotelReservations>
</OTA_HotelResNotifRQ>

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<OTA_HotelResNotifRQ xmlns="http://www.opentravel.org/OTA/2003/05" Version="7.000">
<HotelReservations>
<HotelReservation CreateDateTime="2025-10-07T14:24:04.943026+00:00" ResStatus="Requested" RoomStayReservation="true">
<UniqueID Type="14" ID="c52702c9-55b9-44e1-b158-ec9544c7"/>
<RoomStays>
<RoomStay>
<GuestCounts>
<GuestCount Count="3"/>
<GuestCount Count="1" Age="12"/>
</GuestCounts>
<TimeSpan Start="2026-01-02" End="2026-01-07"/>
</RoomStay>
</RoomStays>
<ResGuests>
<ResGuest>
<Profiles>
<ProfileInfo>
<Profile>
<Customer Language="it">
<PersonName>
<NamePrefix>Frau</NamePrefix>
<GivenName>Genesia</GivenName>
<Surname>Supino</Surname>
</PersonName>
<Telephone PhoneTechType="5" PhoneNumber="+393406259979"/>
<Email Remark="newsletter:yes">supinogenesia@gmail.com</Email>
</Customer>
</Profile>
</ProfileInfo>
</Profiles>
</ResGuest>
</ResGuests>
<ResGlobalInfo>
<HotelReservationIDs>
<HotelReservationID ResID_Type="13" ResID_Value="IwZXh0bgNhZW0BMABhZGlkAassWPh1b8QBHoRc2S24gMktdNKiPwEvGYMK3rB-mn" ResID_Source="Facebook_Mobile_Feed" ResID_SourceContext="99tales"/>
</HotelReservationIDs>
<Profiles>
<ProfileInfo>
<Profile ProfileType="4">
<CompanyInfo>
<CompanyName Code="who knows?" CodeContext="who knows?">99tales GmbH</CompanyName>
</CompanyInfo>
</Profile>
</ProfileInfo>
</Profiles>
<BasicPropertyInfo HotelCode="12345" HotelName="Bemelmans Post"/>
</ResGlobalInfo>
</HotelReservation>
</HotelReservations>
</OTA_HotelResNotifRQ>

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<OTA_HotelResNotifRQ xmlns="http://www.opentravel.org/OTA/2003/05" Version="7.000">
<HotelReservations>
<HotelReservation CreateDateTime="2025-10-07T14:32:52.523968+00:00" ResStatus="Requested" RoomStayReservation="true">
<UniqueID Type="14" ID="c52702c9-55b9-44e1-b158-ec9544c7"/>
<RoomStays>
<RoomStay>
<GuestCounts>
<GuestCount Count="3"/>
<GuestCount Count="1" Age="12"/>
</GuestCounts>
<TimeSpan Start="2026-01-02" End="2026-01-07"/>
</RoomStay>
</RoomStays>
<ResGuests>
<ResGuest>
<Profiles>
<ProfileInfo>
<Profile>
<Customer Language="it">
<PersonName>
<NamePrefix>Frau</NamePrefix>
<GivenName>Genesia</GivenName>
<Surname>Supino</Surname>
</PersonName>
<Telephone PhoneTechType="5" PhoneNumber="+393406259979"/>
<Email Remark="newsletter:yes">supinogenesia@gmail.com</Email>
</Customer>
</Profile>
</ProfileInfo>
</Profiles>
</ResGuest>
</ResGuests>
<ResGlobalInfo>
<HotelReservationIDs>
<HotelReservationID ResID_Type="13" ResID_Value="IwZXh0bgNhZW0BMABhZGlkAassWPh1b8QBHoRc2S24gMktdNKiPwEvGYMK3rB-mn" ResID_Source="Facebook_Mobile_Feed" ResID_SourceContext="99tales"/>
</HotelReservationIDs>
<Profiles>
<ProfileInfo>
<Profile ProfileType="4">
<CompanyInfo>
<CompanyName Code="who knows?" CodeContext="who knows?">99tales GmbH</CompanyName>
</CompanyInfo>
</Profile>
</ProfileInfo>
</Profiles>
<BasicPropertyInfo HotelCode="12345" HotelName="Bemelmans Post"/>
</ResGlobalInfo>
</HotelReservation>
</HotelReservations>
</OTA_HotelResNotifRQ>

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<OTA_HotelResNotifRQ xmlns="http://www.opentravel.org/OTA/2003/05" Version="7.000">
<HotelReservations>
<HotelReservation CreateDateTime="2025-10-07T15:12:25.274095+00:00" ResStatus="Requested" RoomStayReservation="true">
<UniqueID Type="14" ID="c52702c9-55b9-44e1-b158-ec9544c7"/>
<RoomStays>
<RoomStay>
<GuestCounts>
<GuestCount Count="3"/>
<GuestCount Count="1" Age="12"/>
</GuestCounts>
<TimeSpan Start="2026-01-02" End="2026-01-07"/>
</RoomStay>
</RoomStays>
<ResGuests>
<ResGuest>
<Profiles>
<ProfileInfo>
<Profile>
<Customer Language="it">
<PersonName>
<NamePrefix>Frau</NamePrefix>
<GivenName>Genesia</GivenName>
<Surname>Supino</Surname>
</PersonName>
<Telephone PhoneTechType="5" PhoneNumber="+393406259979"/>
<Email Remark="newsletter:yes">supinogenesia@gmail.com</Email>
</Customer>
</Profile>
</ProfileInfo>
</Profiles>
</ResGuest>
</ResGuests>
<ResGlobalInfo>
<HotelReservationIDs>
<HotelReservationID ResID_Type="13" ResID_Value="IwZXh0bgNhZW0BMABhZGlkAassWPh1b8QBHoRc2S24gMktdNKiPwEvGYMK3rB-mn" ResID_Source="Facebook_Mobile_Feed" ResID_SourceContext="99tales"/>
</HotelReservationIDs>
<Profiles>
<ProfileInfo>
<Profile ProfileType="4">
<CompanyInfo>
<CompanyName Code="who knows?" CodeContext="who knows?">99tales GmbH</CompanyName>
</CompanyInfo>
</Profile>
</ProfileInfo>
</Profiles>
<BasicPropertyInfo HotelCode="12345" HotelName="Bemelmans Post"/>
</ResGlobalInfo>
</HotelReservation>
</HotelReservations>
</OTA_HotelResNotifRQ>

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<OTA_HotelResNotifRQ xmlns="http://www.opentravel.org/OTA/2003/05" Version="7.000">
<HotelReservations>
<HotelReservation CreateDateTime="2025-10-07T09:38:38.167778+00:00" ResStatus="Requested" RoomStayReservation="true">
<UniqueID Type="14" ID="c52702c9-55b9-44e1-b158-ec9544c7"/>
<RoomStays>
<RoomStay>
<GuestCounts>
<GuestCount Count="3"/>
<GuestCount Count="1" Age="12"/>
</GuestCounts>
<TimeSpan Start="2026-01-02" End="2026-01-07"/>
</RoomStay>
</RoomStays>
<ResGuests>
<ResGuest>
<Profiles>
<ProfileInfo>
<Profile>
<Customer Language="it">
<PersonName>
<NamePrefix>Frau</NamePrefix>
<GivenName>Genesia</GivenName>
<Surname>Supino</Surname>
</PersonName>
<Telephone PhoneTechType="5" PhoneNumber="+393406259979"/>
<Email Remark="newsletter:yes">supinogenesia@gmail.com</Email>
</Customer>
</Profile>
</ProfileInfo>
</Profiles>
</ResGuest>
</ResGuests>
<ResGlobalInfo>
<HotelReservationIDs>
<HotelReservationID ResID_Type="13" ResID_Value="IwZXh0bgNhZW0BMABhZGlkAassWPh1b8QBHoRc2S24gMktdNKiPwEvGYMK3rB-mn" ResID_Source="Facebook_Mobile_Feed" ResID_SourceContext="99tales"/>
</HotelReservationIDs>
<Profiles>
<ProfileInfo>
<Profile ProfileType="4">
<CompanyInfo>
<CompanyName Code="who knows?" CodeContext="who knows?">99tales GmbH</CompanyName>
</CompanyInfo>
</Profile>
</ProfileInfo>
</Profiles>
<BasicPropertyInfo HotelCode="12345" HotelName="Bemelmans Post"/>
</ResGlobalInfo>
</HotelReservation>
</HotelReservations>
</OTA_HotelResNotifRQ>

View File

@@ -0,0 +1,262 @@
{
"timestamp": "2025-10-06T10:46:42.527300",
"client_ip": "127.0.0.1",
"headers": {
"host": "localhost:8080",
"content-type": "application/json",
"user-agent": "insomnia/2023.5.8",
"accept": "*/*",
"content-length": "7499"
},
"data": {
"data": {
"formName": "Contact us",
"submissions": [
{
"label": "Angebot auswählen",
"value": "Zimmer: Doppelzimmer"
},
{
"label": "Anreisedatum",
"value": "2025-12-21"
},
{
"label": "Abreisedatum",
"value": "2025-10-28"
},
{
"label": "Anzahl Erwachsene",
"value": "2"
},
{
"label": "Anzahl Kinder",
"value": "0"
},
{
"label": "Anrede",
"value": "Herr"
},
{
"label": "Vorname",
"value": "Ernst-Dieter"
},
{
"label": "Nachname",
"value": "Koepper"
},
{
"label": "Email",
"value": "koepper-ed@t-online.de"
},
{
"label": "Phone",
"value": "+49 175 8555456"
},
{
"label": "Message",
"value": "Guten Morgen,\nwir sind nicht gebau an die Reisedaten gebunden: Anreise ist möglich ab 20. Dezember, Aufenthalt mindestens eine Woche, gern auch 8 oder 9 Tage. Natürlich mit Halbpension. Mit freundlichem Gruß D. Köpper"
},
{
"label": "Einwilligung Marketing",
"value": "Angekreuzt"
},
{
"label": "utm_Source",
"value": ""
},
{
"label": "utm_Medium",
"value": ""
},
{
"label": "utm_Campaign",
"value": ""
},
{
"label": "utm_Term",
"value": ""
},
{
"label": "utm_Content",
"value": ""
},
{
"label": "utm_term_id",
"value": ""
},
{
"label": "utm_content_id",
"value": ""
},
{
"label": "gad_source",
"value": "5"
},
{
"label": "gad_campaignid",
"value": "23065043477"
},
{
"label": "gbraid",
"value": ""
},
{
"label": "gclid",
"value": "EAIaIQobChMI-d7Bn_-OkAMVuZJQBh09uD0vEAAYASAAEgKR8_D_BwE"
},
{
"label": "fbclid",
"value": ""
},
{
"label": "hotelid",
"value": "12345"
},
{
"label": "hotelname",
"value": "Bemelmans Post"
}
],
"field:date_picker_7e65": "2025-10-28",
"field:number_7cf5": "2",
"field:utm_source": "",
"submissionTime": "2025-10-06T07:05:34.001Z",
"field:gad_source": "5",
"field:form_field_5a7b": "Angekreuzt",
"field:gad_campaignid": "23065043477",
"field:utm_medium": "",
"field:utm_term_id": "",
"context": {
"metaSiteId": "1dea821c-8168-4736-96e4-4b92e8b364cf",
"activationId": "fd8e9c90-0335-4fd2-976d-985f065f3f80"
},
"field:email_5139": "koepper-ed@t-online.de",
"field:phone_4c77": "+49 175 8555456",
"_context": {
"activation": {
"id": "fd8e9c90-0335-4fd2-976d-985f065f3f80"
},
"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": "EAIaIQobChMI-d7Bn_-OkAMVuZJQBh09uD0vEAAYASAAEgKR8_D_BwE",
"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": "Ernst-Dieter",
"last": "Koepper"
},
"email": "koepper-ed@t-online.de",
"locale": "de-de",
"phones": [
{
"tag": "UNTAGGED",
"formattedPhone": "+49 175 8555456",
"id": "530a3bf4-6dbe-4611-8963-a50df805785d",
"countryCode": "DE",
"e164Phone": "+491758555456",
"primary": true,
"phone": "175 8555456"
}
],
"contactId": "13659da8-4035-47fe-a66b-6ce461ad290f",
"emails": [
{
"id": "e1d2168e-ca3c-4844-8f93-f2e1b0ae70e3",
"tag": "UNTAGGED",
"email": "koepper-ed@t-online.de",
"primary": true
}
],
"updatedDate": "2025-10-06T07:05:35.675Z",
"phone": "+491758555456",
"createdDate": "2025-10-06T07:05:35.675Z"
},
"submissionId": "86d247dc-9d5a-4eb7-87a7-677bf64645ad",
"field:anzahl_kinder": "0",
"field:first_name_abae": "Ernst-Dieter",
"field:utm_content_id": "",
"field:utm_campaign": "",
"field:utm_term": "",
"contactId": "13659da8-4035-47fe-a66b-6ce461ad290f",
"field:date_picker_a7c8": "2025-12-21",
"field:hotelname": "Bemelmans Post",
"field:angebot_auswaehlen": "Zimmer: Doppelzimmer",
"field:utm_content": "",
"field:last_name_d97c": "Koepper",
"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": "",
"submissionPdf": {
"fileName": "86d247dc-9d5a-4eb7-87a7-677bf64645ad.pdf",
"downloadUrl": "https://manage.wix.com/_api/form-submission-service/v4/submissions/86d247dc-9d5a-4eb7-87a7-677bf64645ad/download?accessToken=JWS.eyJraWQiOiJWLVNuLWhwZSIsImFsZyI6IkhTMjU2In0.eyJkYXRhIjoie1wibWV0YVNpdGVJZFwiOlwiMWRlYTgyMWMtODE2OC00NzM2LTk2ZTQtNGI5MmU4YjM2NGNmXCJ9IiwiaWF0IjoxNzU5NzM0MzM1LCJleHAiOjE3NTk3MzQ5MzV9.9koy-O_ptm0dRspjh01Yefkt2rCHiUlRCFtE_S3auYw"
},
"field:anrede": "Herr",
"field:long_answer_3524": "Guten Morgen,\nwir sind nicht gebau an die Reisedaten gebunden: Anreise ist möglich ab 20. Dezember, Aufenthalt mindestens eine Woche, gern auch 8 oder 9 Tage. Natürlich mit Halbpension. Mit freundlichem Gruß D. Köpper",
"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": "7499"
}
}

View File

@@ -0,0 +1,262 @@
{
"timestamp": "2025-10-06T10:57:32.973217",
"client_ip": "127.0.0.1",
"headers": {
"host": "localhost:8080",
"content-type": "application/json",
"user-agent": "insomnia/2023.5.8",
"accept": "*/*",
"content-length": "7499"
},
"data": {
"data": {
"formName": "Contact us",
"submissions": [
{
"label": "Angebot auswählen",
"value": "Zimmer: Doppelzimmer"
},
{
"label": "Anreisedatum",
"value": "2025-12-21"
},
{
"label": "Abreisedatum",
"value": "2025-10-28"
},
{
"label": "Anzahl Erwachsene",
"value": "2"
},
{
"label": "Anzahl Kinder",
"value": "0"
},
{
"label": "Anrede",
"value": "Herr"
},
{
"label": "Vorname",
"value": "Ernst-Dieter"
},
{
"label": "Nachname",
"value": "Koepper"
},
{
"label": "Email",
"value": "koepper-ed@t-online.de"
},
{
"label": "Phone",
"value": "+49 175 8555456"
},
{
"label": "Message",
"value": "Guten Morgen,\nwir sind nicht gebau an die Reisedaten gebunden: Anreise ist möglich ab 20. Dezember, Aufenthalt mindestens eine Woche, gern auch 8 oder 9 Tage. Natürlich mit Halbpension. Mit freundlichem Gruß D. Köpper"
},
{
"label": "Einwilligung Marketing",
"value": "Angekreuzt"
},
{
"label": "utm_Source",
"value": ""
},
{
"label": "utm_Medium",
"value": ""
},
{
"label": "utm_Campaign",
"value": ""
},
{
"label": "utm_Term",
"value": ""
},
{
"label": "utm_Content",
"value": ""
},
{
"label": "utm_term_id",
"value": ""
},
{
"label": "utm_content_id",
"value": ""
},
{
"label": "gad_source",
"value": "5"
},
{
"label": "gad_campaignid",
"value": "23065043477"
},
{
"label": "gbraid",
"value": ""
},
{
"label": "gclid",
"value": "EAIaIQobChMI-d7Bn_-OkAMVuZJQBh09uD0vEAAYASAAEgKR8_D_BwE"
},
{
"label": "fbclid",
"value": ""
},
{
"label": "hotelid",
"value": "12345"
},
{
"label": "hotelname",
"value": "Bemelmans Post"
}
],
"field:date_picker_7e65": "2025-10-28",
"field:number_7cf5": "2",
"field:utm_source": "",
"submissionTime": "2025-10-06T07:05:34.001Z",
"field:gad_source": "5",
"field:form_field_5a7b": "Angekreuzt",
"field:gad_campaignid": "23065043477",
"field:utm_medium": "",
"field:utm_term_id": "",
"context": {
"metaSiteId": "1dea821c-8168-4736-96e4-4b92e8b364cf",
"activationId": "fd8e9c90-0335-4fd2-976d-985f065f3f80"
},
"field:email_5139": "koepper-ed@t-online.de",
"field:phone_4c77": "+49 175 8555456",
"_context": {
"activation": {
"id": "fd8e9c90-0335-4fd2-976d-985f065f3f80"
},
"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": "EAIaIQobChMI-d7Bn_-OkAMVuZJQBh09uD0vEAAYASAAEgKR8_D_BwE",
"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": "Ernst-Dieter",
"last": "Koepper"
},
"email": "koepper-ed@t-online.de",
"locale": "de-de",
"phones": [
{
"tag": "UNTAGGED",
"formattedPhone": "+49 175 8555456",
"id": "530a3bf4-6dbe-4611-8963-a50df805785d",
"countryCode": "DE",
"e164Phone": "+491758555456",
"primary": true,
"phone": "175 8555456"
}
],
"contactId": "13659da8-4035-47fe-a66b-6ce461ad290f",
"emails": [
{
"id": "e1d2168e-ca3c-4844-8f93-f2e1b0ae70e3",
"tag": "UNTAGGED",
"email": "koepper-ed@t-online.de",
"primary": true
}
],
"updatedDate": "2025-10-06T07:05:35.675Z",
"phone": "+491758555456",
"createdDate": "2025-10-06T07:05:35.675Z"
},
"submissionId": "86d247dc-9d5a-4eb7-87a7-677bf64645ad",
"field:anzahl_kinder": "0",
"field:first_name_abae": "Ernst-Dieter",
"field:utm_content_id": "",
"field:utm_campaign": "",
"field:utm_term": "",
"contactId": "13659da8-4035-47fe-a66b-6ce461ad290f",
"field:date_picker_a7c8": "2025-12-21",
"field:hotelname": "Bemelmans Post",
"field:angebot_auswaehlen": "Zimmer: Doppelzimmer",
"field:utm_content": "",
"field:last_name_d97c": "Koepper",
"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": "",
"submissionPdf": {
"fileName": "86d247dc-9d5a-4eb7-87a7-677bf64645ad.pdf",
"downloadUrl": "https://manage.wix.com/_api/form-submission-service/v4/submissions/86d247dc-9d5a-4eb7-87a7-677bf64645ad/download?accessToken=JWS.eyJraWQiOiJWLVNuLWhwZSIsImFsZyI6IkhTMjU2In0.eyJkYXRhIjoie1wibWV0YVNpdGVJZFwiOlwiMWRlYTgyMWMtODE2OC00NzM2LTk2ZTQtNGI5MmU4YjM2NGNmXCJ9IiwiaWF0IjoxNzU5NzM0MzM1LCJleHAiOjE3NTk3MzQ5MzV9.9koy-O_ptm0dRspjh01Yefkt2rCHiUlRCFtE_S3auYw"
},
"field:anrede": "Herr",
"field:long_answer_3524": "Guten Morgen,\nwir sind nicht gebau an die Reisedaten gebunden: Anreise ist möglich ab 20. Dezember, Aufenthalt mindestens eine Woche, gern auch 8 oder 9 Tage. Natürlich mit Halbpension. Mit freundlichem Gruß D. Köpper",
"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": "7499"
}
}

View File

@@ -0,0 +1,262 @@
{
"timestamp": "2025-10-06T15:43:06.732884",
"client_ip": "127.0.0.1",
"headers": {
"host": "localhost:8080",
"content-type": "application/json",
"user-agent": "insomnia/2023.5.8",
"accept": "*/*",
"content-length": "7081"
},
"data": {
"data": {
"formName": "Contact us",
"submissions": [
{
"label": "Angebot auswählen",
"value": "Zimmer: Doppelzimmer"
},
{
"label": "Anreisedatum",
"value": "2025-10-21"
},
{
"label": "Abreisedatum",
"value": "2025-12-28"
},
{
"label": "Anzahl Erwachsene",
"value": "4"
},
{
"label": "Anzahl Kinder",
"value": "0"
},
{
"label": "Anrede",
"value": "Herr"
},
{
"label": "Vorname",
"value": "Jonas"
},
{
"label": "Nachname",
"value": "Linter"
},
{
"label": "Email",
"value": "jonas@vaius.ai"
},
{
"label": "Phone",
"value": "+39 392 007 6982"
},
{
"label": "Message",
"value": "Hallo nachricht in der Kommentarsection"
},
{
"label": "Einwilligung Marketing",
"value": "Angekreuzt"
},
{
"label": "utm_Source",
"value": ""
},
{
"label": "utm_Medium",
"value": ""
},
{
"label": "utm_Campaign",
"value": ""
},
{
"label": "utm_Term",
"value": ""
},
{
"label": "utm_Content",
"value": ""
},
{
"label": "utm_term_id",
"value": ""
},
{
"label": "utm_content_id",
"value": ""
},
{
"label": "gad_source",
"value": "5"
},
{
"label": "gad_campaignid",
"value": "23065043477"
},
{
"label": "gbraid",
"value": ""
},
{
"label": "gclid",
"value": "EAIaIQobChMI-d7Bn_-OkAMVuZJQBh09uD0vEAAYASAAEgKR8_D_BwE"
},
{
"label": "fbclid",
"value": ""
},
{
"label": "hotelid",
"value": "12345"
},
{
"label": "hotelname",
"value": "Bemelmans Post"
}
],
"field:date_picker_7e65": "2025-10-28",
"field:number_7cf5": "2",
"field:utm_source": "",
"submissionTime": "2025-10-06T07:05:34.001Z",
"field:gad_source": "5",
"field:form_field_5a7b": "Angekreuzt",
"field:gad_campaignid": "23065043477",
"field:utm_medium": "",
"field:utm_term_id": "",
"context": {
"metaSiteId": "1dea821c-8168-4736-96e4-4b92e8b364cf",
"activationId": "fd8e9c90-0335-4fd2-976d-985f065f3f80"
},
"field:email_5139": "jonas@vaius.ai",
"field:phone_4c77": "+39 392 007 6982",
"_context": {
"activation": {
"id": "fd8e9c90-0335-4fd2-976d-985f065f3f80"
},
"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": "EAIaIQobChMI-d7Bn_-OkAMVuZJQBh09uD0vEAAYASAAEgKR8_D_BwE",
"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": "Jonas",
"last": "Linter"
},
"email": "jonas@vaius.ai",
"locale": "de-de",
"phones": [
{
"tag": "UNTAGGED",
"formattedPhone": "+39 392 007 6982",
"id": "530a3bf4-6dbe-4611-8963-a50df805785d",
"countryCode": "DE",
"e164Phone": "+493920076982",
"primary": true,
"phone": "392 0076982"
}
],
"contactId": "66659da8-4035-47fe-a66b-6ce461ad290f",
"emails": [
{
"id": "e1d2168e-ca3c-4844-8f93-f2e1b0ae70e3",
"tag": "UNTAGGED",
"email": "koepper-ed@t-online.de",
"primary": true
}
],
"updatedDate": "2025-10-06T07:05:35.675Z",
"phone": "+491758555456",
"createdDate": "2025-10-06T07:05:35.675Z"
},
"submissionId": "666247dc-9d5a-4eb7-87a7-677bf64645ad",
"field:anzahl_kinder": "0",
"field:first_name_abae": "Ernst-Dieter",
"field:utm_content_id": "",
"field:utm_campaign": "",
"field:utm_term": "",
"contactId": "66659da8-4035-47fe-a66b-6ce461ad290f",
"field:date_picker_a7c8": "2025-12-21",
"field:hotelname": "Testhotel",
"field:angebot_auswaehlen": "Zimmer: Doppelzimmer",
"field:utm_content": "",
"field:last_name_d97c": "Linter",
"field:hotelid": "135",
"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": "",
"submissionPdf": {
"fileName": "86d247dc-9d5a-4eb7-87a7-677bf64645ad.pdf",
"downloadUrl": "https://manage.wix.com/_api/form-submission-service/v4/submissions/86d247dc-9d5a-4eb7-87a7-677bf64645ad/download?accessToken=JWS.eyJraWQiOiJWLVNuLWhwZSIsImFsZyI6IkhTMjU2In0.eyJkYXRhIjoie1wibWV0YVNpdGVJZFwiOlwiMWRlYTgyMWMtODE2OC00NzM2LTk2ZTQtNGI5MmU4YjM2NGNmXCJ9IiwiaWF0IjoxNzU5NzM0MzM1LCJleHAiOjE3NTk3MzQ5MzV9.9koy-O_ptm0dRspjh01Yefkt2rCHiUlRCFtE_S3auYw"
},
"field:anrede": "Herr",
"field:long_answer_3524": "Kommentarsektion vermutlich",
"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": "7081"
}
}

View File

@@ -0,0 +1,262 @@
{
"timestamp": "2025-10-06T15:44:35.341703",
"client_ip": "127.0.0.1",
"headers": {
"host": "localhost:8080",
"content-type": "application/json",
"user-agent": "insomnia/2023.5.8",
"accept": "*/*",
"content-length": "7081"
},
"data": {
"data": {
"formName": "Contact us",
"submissions": [
{
"label": "Angebot auswählen",
"value": "Zimmer: Doppelzimmer"
},
{
"label": "Anreisedatum",
"value": "2025-10-21"
},
{
"label": "Abreisedatum",
"value": "2025-12-28"
},
{
"label": "Anzahl Erwachsene",
"value": "4"
},
{
"label": "Anzahl Kinder",
"value": "0"
},
{
"label": "Anrede",
"value": "Herr"
},
{
"label": "Vorname",
"value": "Jonas"
},
{
"label": "Nachname",
"value": "Linter"
},
{
"label": "Email",
"value": "jonas@vaius.ai"
},
{
"label": "Phone",
"value": "+39 392 007 6982"
},
{
"label": "Message",
"value": "Hallo nachricht in der Kommentarsection"
},
{
"label": "Einwilligung Marketing",
"value": "Angekreuzt"
},
{
"label": "utm_Source",
"value": ""
},
{
"label": "utm_Medium",
"value": ""
},
{
"label": "utm_Campaign",
"value": ""
},
{
"label": "utm_Term",
"value": ""
},
{
"label": "utm_Content",
"value": ""
},
{
"label": "utm_term_id",
"value": ""
},
{
"label": "utm_content_id",
"value": ""
},
{
"label": "gad_source",
"value": "5"
},
{
"label": "gad_campaignid",
"value": "23065043477"
},
{
"label": "gbraid",
"value": ""
},
{
"label": "gclid",
"value": "EAIaIQobChMI-d7Bn_-OkAMVuZJQBh09uD0vEAAYASAAEgKR8_D_BwE"
},
{
"label": "fbclid",
"value": ""
},
{
"label": "hotelid",
"value": "12345"
},
{
"label": "hotelname",
"value": "Bemelmans Post"
}
],
"field:date_picker_7e65": "2025-10-28",
"field:number_7cf5": "2",
"field:utm_source": "",
"submissionTime": "2025-10-06T07:05:34.001Z",
"field:gad_source": "5",
"field:form_field_5a7b": "Angekreuzt",
"field:gad_campaignid": "23065043477",
"field:utm_medium": "",
"field:utm_term_id": "",
"context": {
"metaSiteId": "1dea821c-8168-4736-96e4-4b92e8b364cf",
"activationId": "fd8e9c90-0335-4fd2-976d-985f065f3f80"
},
"field:email_5139": "jonas@vaius.ai",
"field:phone_4c77": "+39 392 007 6982",
"_context": {
"activation": {
"id": "fd8e9c90-0335-4fd2-976d-985f065f3f80"
},
"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": "EAIaIQobChMI-d7Bn_-OkAMVuZJQBh09uD0vEAAYASAAEgKR8_D_BwE",
"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": "Jonas",
"last": "Linter"
},
"email": "jonas@vaius.ai",
"locale": "de-de",
"phones": [
{
"tag": "UNTAGGED",
"formattedPhone": "+39 392 007 6982",
"id": "530a3bf4-6dbe-4611-8963-a50df805785d",
"countryCode": "DE",
"e164Phone": "+493920076982",
"primary": true,
"phone": "392 0076982"
}
],
"contactId": "66659da8-4035-47fe-a66b-6ce461ad290f",
"emails": [
{
"id": "e1d2168e-ca3c-4844-8f93-f2e1b0ae70e3",
"tag": "UNTAGGED",
"email": "koepper-ed@t-online.de",
"primary": true
}
],
"updatedDate": "2025-10-06T07:05:35.675Z",
"phone": "+491758555456",
"createdDate": "2025-10-06T07:05:35.675Z"
},
"submissionId": "666247dc-9d5a-4eb7-87a7-677bf64645ad",
"field:anzahl_kinder": "0",
"field:first_name_abae": "Ernst-Dieter",
"field:utm_content_id": "",
"field:utm_campaign": "",
"field:utm_term": "",
"contactId": "66659da8-4035-47fe-a66b-6ce461ad290f",
"field:date_picker_a7c8": "2025-12-21",
"field:hotelname": "Testhotel",
"field:angebot_auswaehlen": "Zimmer: Doppelzimmer",
"field:utm_content": "",
"field:last_name_d97c": "Linter",
"field:hotelid": "135",
"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": "",
"submissionPdf": {
"fileName": "86d247dc-9d5a-4eb7-87a7-677bf64645ad.pdf",
"downloadUrl": "https://manage.wix.com/_api/form-submission-service/v4/submissions/86d247dc-9d5a-4eb7-87a7-677bf64645ad/download?accessToken=JWS.eyJraWQiOiJWLVNuLWhwZSIsImFsZyI6IkhTMjU2In0.eyJkYXRhIjoie1wibWV0YVNpdGVJZFwiOlwiMWRlYTgyMWMtODE2OC00NzM2LTk2ZTQtNGI5MmU4YjM2NGNmXCJ9IiwiaWF0IjoxNzU5NzM0MzM1LCJleHAiOjE3NTk3MzQ5MzV9.9koy-O_ptm0dRspjh01Yefkt2rCHiUlRCFtE_S3auYw"
},
"field:anrede": "Herr",
"field:long_answer_3524": "Kommentarsektion vermutlich",
"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": "7081"
}
}

View 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"
}
}

View File

@@ -0,0 +1,257 @@
{
"timestamp": "2025-10-07T16:05:37.531417",
"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"
}
}

View File

@@ -14,8 +14,11 @@ dependencies = [
"dotenv>=0.9.9",
"fastapi>=0.117.1",
"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",
"ruff>=0.13.1",
"slowapi>=0.1.9",
@@ -33,8 +36,115 @@ alpine-bits-server = "alpine_bits_python.main:main"
packages = ["src/alpine_bits_python"]
[tool.pytest.ini_options]
testpaths = ["test"]
testpaths = ["tests"]
pythonpath = ["src"]
[tool.ruff]
src = ["src", "test"]
[tool.ruff.lint]
select = [
"A001", # Variable {name} is shadowing a Python builtin
"ASYNC210", # Async functions should not call blocking HTTP methods
"ASYNC220", # Async functions should not create subprocesses with blocking methods
"ASYNC221", # Async functions should not run processes with blocking methods
"ASYNC222", # Async functions should not wait on processes with blocking methods
"ASYNC230", # Async functions should not open files with blocking methods like open
"ASYNC251", # Async functions should not call time.sleep
"B002", # Python does not support the unary prefix increment
"B005", # Using .strip() with multi-character strings is misleading
"B007", # Loop control variable {name} not used within loop body
"B014", # Exception handler with duplicate exception
"B015", # Pointless comparison. Did you mean to assign a value? Otherwise, prepend assert or remove it.
"B017", # pytest.raises(BaseException) should be considered evil
"B018", # Found useless attribute access. Either assign it to a variable or remove it.
"B023", # Function definition does not bind loop variable {name}
"B024", # `{name}` is an abstract base class, but it has no abstract methods or properties
"B026", # Star-arg unpacking after a keyword argument is strongly discouraged
"B032", # Possible unintentional type annotation (using :). Did you mean to assign (using =)?
"B035", # Dictionary comprehension uses static key
"B904", # Use raise from to specify exception cause
"B905", # zip() without an explicit strict= parameter
"BLE",
"C", # complexity
"COM818", # Trailing comma on bare tuple prohibited
"D", # docstrings
"DTZ003", # Use datetime.now(tz=) instead of datetime.utcnow()
"DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts)
"E", # pycodestyle
"F", # pyflakes/autoflake
"F541", # f-string without any placeholders
"FLY", # flynt
"FURB", # refurb
"G", # flake8-logging-format
"I", # isort
"INP", # flake8-no-pep420
"ISC", # flake8-implicit-str-concat
"ICN001", # import concentions; {name} should be imported as {asname}
"LOG", # flake8-logging
"N804", # First argument of a class method should be named cls
"N805", # First argument of a method should be named self
"N815", # Variable {name} in class scope should not be mixedCase
"PERF", # Perflint
"PGH", # pygrep-hooks
"PIE", # flake8-pie
"PL", # pylint
"PT", # flake8-pytest-style
"PTH", # flake8-pathlib
"PYI", # flake8-pyi
"RET", # flake8-return
"RSE", # flake8-raise
"RUF005", # Consider iterable unpacking instead of concatenation
"RUF006", # Store a reference to the return value of asyncio.create_task
"RUF007", # Prefer itertools.pairwise() over zip() when iterating over successive pairs
"RUF008", # Do not use mutable default values for dataclass attributes
"RUF010", # Use explicit conversion flag
"RUF013", # PEP 484 prohibits implicit Optional
"RUF016", # Slice in indexed access to type {value_type} uses type {index_type} instead of an integer
"RUF017", # Avoid quadratic list summation
"RUF018", # Avoid assignment expressions in assert statements
"RUF019", # Unnecessary key check before dictionary access
"RUF020", # {never_like} | T is equivalent to T
"RUF021", # Parenthesize a and b expressions when chaining and and or together, to make the precedence clear
"RUF022", # Sort __all__
"RUF023", # Sort __slots__
"RUF024", # Do not pass mutable objects as values to dict.fromkeys
"RUF026", # default_factory is a positional-only argument to defaultdict
"RUF030", # print() call in assert statement is likely unintentional
"RUF032", # Decimal() called with float literal argument
"RUF033", # __post_init__ method with argument defaults
"RUF034", # Useless if-else condition
"RUF100", # Unused `noqa` directive
"RUF101", # noqa directives that use redirected rule codes
"RUF200", # Failed to parse pyproject.toml: {message}
"S102", # Use of exec detected
"S103", # bad-file-permissions
"S108", # hardcoded-temp-file
"S306", # suspicious-mktemp-usage
"S307", # suspicious-eval-usage
"S313", # suspicious-xmlc-element-tree-usage
"S314", # suspicious-xml-element-tree-usage
"S315", # suspicious-xml-expat-reader-usage
"S316", # suspicious-xml-expat-builder-usage
"S317", # suspicious-xml-sax-usage
"S318", # suspicious-xml-mini-dom-usage
"S319", # suspicious-xml-pull-dom-usage
"S601", # paramiko-call
"S602", # subprocess-popen-with-shell-equals-true
"S604", # call-with-shell-equals-true
"S608", # hardcoded-sql-expression
"S609", # unix-command-wildcard-injection
"SIM", # flake8-simplify
"SLF", # flake8-self
"SLOT", # flake8-slots
"T100", # Trace found: {name} used
"T20", # flake8-print
"TC", # flake8-type-checking
"TID", # Tidy imports
"TRY", # tryceratops
"UP", # pyupgrade
"UP031", # Use format specifiers instead of percent format
"UP032", # Use f-string instead of `format` call
"W", # pycodestyle
]

View File

@@ -1,33 +1,40 @@
from datetime import datetime, timezone
from typing import Union, Optional, Any, TypeVar
from pydantic import BaseModel, ConfigDict, Field
import logging
import traceback
from dataclasses import dataclass
from datetime import UTC, datetime
from enum import Enum
from typing import Tuple
from typing import Any
from alpine_bits_python.db import Customer, Reservation
from alpine_bits_python.schemas import (
CommentData,
CommentListItemData,
CommentsData,
CustomerData,
HotelReservationIdData,
PhoneTechType,
)
# Import the generated classes
from .generated.alpinebits import (
CommentName2,
HotelReservationResStatus,
OtaHotelResNotifRq,
OtaResRetrieveRs,
CommentName2,
ProfileProfileType,
UniqueIdType2,
)
import logging
_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 = (
@@ -51,12 +58,21 @@ RetrieveGuestCounts = (
OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays.RoomStay.GuestCounts
)
NotifUniqueId = OtaHotelResNotifRq.HotelReservations.HotelReservation.UniqueId
RetrieveUniqueId = OtaResRetrieveRs.ReservationsList.HotelReservation.UniqueId
# phonetechtype enum 1,3,5 voice, fax, mobile
class PhoneTechType(Enum):
VOICE = "1"
FAX = "3"
MOBILE = "5"
NotifTimeSpan = (
OtaHotelResNotifRq.HotelReservations.HotelReservation.RoomStays.RoomStay.TimeSpan
)
RetrieveTimeSpan = (
OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays.RoomStay.TimeSpan
)
NotifRoomStays = OtaHotelResNotifRq.HotelReservations.HotelReservation.RoomStays
RetrieveRoomStays = OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays
NotifHotelReservation = OtaHotelResNotifRq.HotelReservations.HotelReservation
RetrieveHotelReservation = OtaResRetrieveRs.ReservationsList.HotelReservation
# Enum to specify which OTA message type to use
@@ -72,70 +88,37 @@ class KidsAgeData:
ages: list[int]
@dataclass
class CustomerData:
"""Simple data class to hold customer information without nested type constraints."""
given_name: str
surname: str
name_prefix: None | str = None
name_title: None | str = None
phone_numbers: list[tuple[str, None | PhoneTechType]] = (
None # (phone_number, phone_tech_type)
)
email_address: None | str = None
email_newsletter: None | bool = (
None # True for "yes", False for "no", None for not specified
)
address_line: None | str = None
city_name: None | str = None
postal_code: None | str = None
country_code: None | str = None # Two-letter country code
address_catalog: None | bool = (
None # True for "yes", False for "no", None for not specified
)
gender: None | str = None # "Unknown", "Male", "Female"
birth_date: None | str = None
language: None | str = None # Two-letter language code
def __post_init__(self):
if self.phone_numbers is None:
self.phone_numbers = []
class GuestCountsFactory:
@staticmethod
def create_notif_guest_counts(
adults: int, kids: Optional[list[int]] = None
) -> NotifGuestCounts:
"""
Create a GuestCounts object for OtaHotelResNotifRq.
:param adults: Number of adults
:param kids: List of ages for each kid (optional)
:return: GuestCounts instance
"""
return GuestCountsFactory._create_guest_counts(adults, kids, NotifGuestCounts)
"""Factory class to create GuestCounts instances for both OtaHotelResNotifRq and OtaResRetrieveRs."""
@staticmethod
def create_retrieve_guest_counts(
adults: int, kids: Optional[list[int]] = None
) -> RetrieveGuestCounts:
"""
Create a GuestCounts object for OtaResRetrieveRs.
def create_guest_counts(
adults: int,
kids: list[int] | None = None,
message_type: OtaMessageType = OtaMessageType.RETRIEVE,
) -> NotifGuestCounts:
"""Create a GuestCounts object for OtaHotelResNotifRq or OtaResRetrieveRs.
:param adults: Number of adults
:param kids: List of ages for each kid (optional)
:return: GuestCounts instance
"""
if message_type == OtaMessageType.RETRIEVE:
return GuestCountsFactory._create_guest_counts(
adults, kids, RetrieveGuestCounts
)
if message_type == OtaMessageType.NOTIF:
return GuestCountsFactory._create_guest_counts(
adults, kids, NotifGuestCounts
)
raise ValueError(f"Unsupported message type: {message_type}")
@staticmethod
def _create_guest_counts(
adults: int, kids: Optional[list[int]], guest_counts_class: type
adults: int, kids: list[int] | None, guest_counts_class: type
) -> Any:
"""
Internal method to create a GuestCounts object of the specified type.
"""Create a GuestCounts object of the specified type.
:param adults: Number of adults
:param kids: List of ages for each kid (optional)
:param guest_counts_class: The GuestCounts class to instantiate
@@ -161,7 +144,7 @@ class GuestCountsFactory:
class CustomerFactory:
"""Factory class to create Customer instances for both OtaHotelResNotifRq and OtaResRetrieveRs."""
"""Factory class to create Customer instances for both Retrieve and Notif."""
@staticmethod
def create_notif_customer(data: CustomerData) -> NotifCustomer:
@@ -174,9 +157,10 @@ class CustomerFactory:
return CustomerFactory._create_customer(RetrieveCustomer, data)
@staticmethod
def _create_customer(customer_class: type, data: CustomerData) -> Any:
"""Internal method to create a customer of the specified type."""
def _create_customer(
customer_class: type[RetrieveCustomer | NotifCustomer], data: CustomerData
) -> Any:
"""Create a customer of the specified type."""
# Create PersonName
person_name = customer_class.PersonName(
given_name=data.given_name,
@@ -249,19 +233,20 @@ class CustomerFactory:
@staticmethod
def _customer_to_data(customer: Any) -> CustomerData:
"""Internal method to convert any customer type to CustomerData."""
"""Convert any customer type to CustomerData."""
# Extract phone numbers
phone_numbers = []
if customer.telephone:
for tel in customer.telephone:
phone_numbers.append(
phone_numbers.extend(
[
(
tel.phone_number,
PhoneTechType(tel.phone_tech_type)
if tel.phone_tech_type
else None,
)
for tel in customer.telephone
]
)
# Extract email info
@@ -315,16 +300,6 @@ class CustomerFactory:
)
@dataclass
class HotelReservationIdData:
"""Simple data class to 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
res_id_source: None | str = None # Max 64 characters
res_id_source_context: None | str = None # Max 64 characters
class HotelReservationIdFactory:
"""Factory class to create HotelReservationId instances for both OtaHotelResNotifRq and OtaResRetrieveRs."""
@@ -350,7 +325,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,
@@ -389,39 +364,6 @@ class HotelReservationIdFactory:
)
@dataclass
class CommentListItemData:
"""Simple data class to hold comment list item information."""
value: str # The text content of the list item
list_item: str # Numeric identifier (pattern: [0-9]+)
language: str # Two-letter language code (pattern: [a-z][a-z])
@dataclass
class CommentData:
"""Simple data class to hold comment information without nested type constraints."""
name: CommentName2 # Required: "included services", "customer comment", "additional info"
text: Optional[str] = None # Optional text content
list_items: list[CommentListItemData] = None # Optional list items
def __post_init__(self):
if self.list_items is None:
self.list_items = []
@dataclass
class CommentsData:
"""Simple data class to hold multiple comments (1-3 max)."""
comments: list[CommentData] = None # 1-3 comments maximum
def __post_init__(self):
if self.comments is None:
self.comments = []
class CommentFactory:
"""Factory class to create Comment instances for both OtaHotelResNotifRq and OtaResRetrieveRs."""
@@ -442,7 +384,6 @@ class CommentFactory:
data: CommentsData,
) -> Any:
"""Internal method to create comments of the specified type."""
comments_list = []
for comment_data in data.comments:
# Create list items
@@ -481,7 +422,6 @@ class CommentFactory:
@staticmethod
def _comments_to_data(comments: Any) -> CommentsData:
"""Internal method to convert any comments type to CommentsData."""
comments_data_list = []
for comment in comments.comment:
# Extract list items
@@ -496,11 +436,7 @@ class CommentFactory:
)
)
# Extract comment data
comment_data = CommentData(
name=comment.name, text=comment.text, list_items=list_items_data
)
comments_data_list.append(comment_data)
comments_data_list.append(comment)
return CommentsData(comments=comments_data_list)
@@ -529,10 +465,11 @@ class ResGuestFactory:
@staticmethod
def _create_res_guests(
res_guests_class: type, customer_class: type, customer_data: CustomerData
res_guests_class: type[RetrieveResGuests] | type[NotifResGuests],
customer_class: type[NotifCustomer | RetrieveCustomer],
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)
@@ -555,17 +492,15 @@ class ResGuestFactory:
@staticmethod
def extract_primary_customer(
res_guests: Union[NotifResGuests, RetrieveResGuests],
res_guests: NotifResGuests | RetrieveResGuests,
) -> CustomerData:
"""Extract the primary customer data from a ResGuests structure."""
# Navigate down the nested structure to get the customer
customer = res_guests.res_guest.profiles.profile_info.profile.customer
# Use the existing CustomerFactory conversion method
if isinstance(res_guests, NotifResGuests):
return CustomerFactory.from_notif_customer(customer)
else:
return CustomerFactory.from_retrieve_customer(customer)
@@ -574,11 +509,10 @@ class AlpineBitsFactory:
@staticmethod
def create(
data: Union[CustomerData, HotelReservationIdData, CommentsData],
data: CustomerData | HotelReservationIdData | CommentsData,
message_type: OtaMessageType,
) -> Any:
"""
Create an AlpineBits object based on the data type and message type.
"""Create an AlpineBits object based on the data type and message type.
Args:
data: The data object (CustomerData, HotelReservationIdData, CommentsData, etc.)
@@ -586,36 +520,30 @@ class AlpineBitsFactory:
Returns:
The appropriate AlpineBits object based on the data type and message type
"""
if isinstance(data, CustomerData):
if message_type == OtaMessageType.NOTIF:
return CustomerFactory.create_notif_customer(data)
else:
return CustomerFactory.create_retrieve_customer(data)
elif isinstance(data, HotelReservationIdData):
if isinstance(data, HotelReservationIdData):
if message_type == OtaMessageType.NOTIF:
return HotelReservationIdFactory.create_notif_hotel_reservation_id(data)
else:
return HotelReservationIdFactory.create_retrieve_hotel_reservation_id(
data
)
return HotelReservationIdFactory.create_retrieve_hotel_reservation_id(data)
elif isinstance(data, CommentsData):
if isinstance(data, CommentsData):
if message_type == OtaMessageType.NOTIF:
return CommentFactory.create_notif_comments(data)
else:
return CommentFactory.create_retrieve_comments(data)
else:
raise ValueError(f"Unsupported data type: {type(data)}")
@staticmethod
def create_res_guests(
customer_data: CustomerData, message_type: OtaMessageType
) -> Union[NotifResGuests, RetrieveResGuests]:
"""
Create a complete ResGuests structure with a primary customer.
) -> NotifResGuests | RetrieveResGuests:
"""Create a complete ResGuests structure with a primary customer.
Args:
customer_data: The customer data
@@ -623,44 +551,44 @@ class AlpineBitsFactory:
Returns:
The appropriate ResGuests object
"""
if message_type == OtaMessageType.NOTIF:
return ResGuestFactory.create_notif_res_guests(customer_data)
else:
return ResGuestFactory.create_retrieve_res_guests(customer_data)
@staticmethod
def extract_data(
obj: Any,
) -> Union[CustomerData, HotelReservationIdData, CommentsData]:
"""
Extract data from an AlpineBits object back to a simple data class.
) -> CustomerData | HotelReservationIdData | CommentsData:
"""Extract data from an AlpineBits object back to a simple data class.
Args:
obj: The AlpineBits object to extract data from
Returns:
The appropriate data object
"""
# Check if it's a Customer object
if hasattr(obj, "person_name") and hasattr(obj.person_name, "given_name"):
if isinstance(obj, NotifCustomer):
return CustomerFactory.from_notif_customer(obj)
elif isinstance(obj, RetrieveCustomer):
if isinstance(obj, RetrieveCustomer):
return CustomerFactory.from_retrieve_customer(obj)
# Check if it's a HotelReservationId object
elif hasattr(obj, "res_id_type"):
if isinstance(obj, NotifHotelReservationId):
return HotelReservationIdFactory.from_notif_hotel_reservation_id(obj)
elif isinstance(obj, RetrieveHotelReservationId):
if isinstance(obj, RetrieveHotelReservationId):
return HotelReservationIdFactory.from_retrieve_hotel_reservation_id(obj)
# Check if it's a Comments object
elif hasattr(obj, "comment"):
if isinstance(obj, NotifComments):
return CommentFactory.from_notif_comments(obj)
elif isinstance(obj, RetrieveComments):
if isinstance(obj, RetrieveComments):
return CommentFactory.from_retrieve_comments(obj)
# Check if it's a ResGuests object
@@ -669,27 +597,28 @@ class AlpineBitsFactory:
else:
raise ValueError(f"Unsupported object type: {type(obj)}")
return None
def create_xml_from_db(list: list[Tuple[Reservation, Customer]]):
"""Create RetrievedReservation XML from database entries.
def create_res_retrieve_response(
list: list[tuple[Reservation, Customer]],
) -> OtaResRetrieveRs:
"""Create RetrievedReservation XML from database entries."""
return _create_xml_from_db(list, OtaMessageType.RETRIEVE)
list of pairs (Reservation, Customer)
"""
reservations_list = []
def create_res_notif_push_message(list: tuple[Reservation, Customer]):
"""Create Reservation Notification XML from database entries."""
return _create_xml_from_db(list, OtaMessageType.NOTIF)
for reservation, customer in list:
_LOGGER.info(
f"Creating XML for reservation {reservation.form_id} and customer {customer.given_name}"
)
try:
def _process_single_reservation(
reservation: Reservation, customer: Customer, message_type: OtaMessageType
):
phone_numbers = (
[(customer.phone, PhoneTechType.MOBILE)]
if customer.phone is not None
else []
[(customer.phone, PhoneTechType.MOBILE)] if customer.phone is not None else []
)
customer_data = CustomerData(
given_name=customer.given_name,
surname=customer.surname,
@@ -708,58 +637,87 @@ def create_xml_from_db(list: list[Tuple[Reservation, Customer]]):
language=customer.language,
)
alpine_bits_factory = AlpineBitsFactory()
res_guests = alpine_bits_factory.create_res_guests(
customer_data, OtaMessageType.RETRIEVE
)
res_guests = alpine_bits_factory.create_res_guests(customer_data, message_type)
# Guest counts
children_ages = [int(a) for a in reservation.children_ages.split(",") if a]
guest_counts = GuestCountsFactory.create_retrieve_guest_counts(
reservation.num_adults, children_ages
guest_counts = GuestCountsFactory.create_guest_counts(
reservation.num_adults, children_ages, message_type
)
unique_id_string = reservation.form_id
if message_type == OtaMessageType.NOTIF:
UniqueId = NotifUniqueId
RoomStays = NotifRoomStays
HotelReservation = NotifHotelReservation
Profile = OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGlobalInfo.Profiles.ProfileInfo.Profile
elif message_type == OtaMessageType.RETRIEVE:
UniqueId = RetrieveUniqueId
RoomStays = RetrieveRoomStays
HotelReservation = RetrieveHotelReservation
Profile = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.Profiles.ProfileInfo.Profile
else:
raise ValueError("Unsupported message type: %s", message_type.value)
if len(unique_id_string) > 32:
unique_id_string = unique_id_string[:32] # Truncate to 32 characters
unique_id_str = reservation.md5_unique_id
# UniqueID
unique_id = OtaResRetrieveRs.ReservationsList.HotelReservation.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 = OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays.RoomStay.TimeSpan(
start=reservation.start_date.isoformat()
if reservation.start_date
else None,
time_span = RoomStays.RoomStay.TimeSpan(
start=reservation.start_date.isoformat() if reservation.start_date else None,
end=reservation.end_date.isoformat() if reservation.end_date else None,
)
room_stay = (
OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays.RoomStay(
room_stay = RoomStays.RoomStay(
time_span=time_span,
guest_counts=guest_counts,
)
)
room_stays = OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays(
room_stays = RoomStays(
room_stay=[room_stay],
)
res_id_source = "website"
klick_id = None
if reservation.fbclid != "":
klick_id = str(reservation.fbclid)
res_id_source = "meta"
elif reservation.gclid != "":
klick_id = str(reservation.gclid)
res_id_source = "google"
# 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)
# 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 = HotelReservationIdData(
res_id_type="13",
res_id_value=reservation.fbclid or reservation.gclid,
res_id_source=None,
res_id_value=klick_id,
res_id_source=res_id_source,
res_id_source_context="99tales",
)
hotel_res_id = alpine_bits_factory.create(
hotel_res_id_data, OtaMessageType.RETRIEVE
)
hotel_res_ids = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.HotelReservationIds(
hotel_res_id = alpine_bits_factory.create(hotel_res_id_data, message_type)
hotel_res_ids = HotelReservation.ResGlobalInfo.HotelReservationIds(
hotel_reservation_id=[hotel_res_id]
)
basic_property_info = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.BasicPropertyInfo(
hotel_code=reservation.hotel_code,
hotel_name=reservation.hotel_name,
if reservation.hotel_code is None:
raise ValueError("Reservation hotel_code is None")
hotel_code = str(reservation.hotel_code)
if reservation.hotel_name is None:
hotel_name = None
else:
hotel_name = str(reservation.hotel_name)
basic_property_info = HotelReservation.ResGlobalInfo.BasicPropertyInfo(
hotel_code=hotel_code,
hotel_name=hotel_name,
)
# Comments
@@ -767,27 +725,27 @@ def create_xml_from_db(list: list[Tuple[Reservation, Customer]]):
if reservation.offer is not None:
offer_comment = CommentData(
name=CommentName2.ADDITIONAL_INFO,
text="Angebot/Offerta",
list_items=[
CommentListItemData(
value=reservation.offer,
language=customer.language,
list_item="1",
)
],
text="Angebot/Offerta: " + reservation.offer,
# list_items=[
# CommentListItemData(
# value=reservation.offer,
# language=customer.language,
# list_item="1",
# )
# ],
)
comment = None
if reservation.user_comment:
comment = CommentData(
name=CommentName2.CUSTOMER_COMMENT,
text=reservation.user_comment,
list_items=[
CommentListItemData(
value="Landing page comment",
language=customer.language,
list_item="1",
)
],
# list_items=[
# CommentListItemData(
# value="Landing page comment",
# language=customer.language,
# list_item="1",
# )
# ],
)
comments = [offer_comment, comment]
@@ -802,20 +760,33 @@ def create_xml_from_db(list: list[Tuple[Reservation, Customer]]):
)
comments_data = CommentsData(comments=comments)
comments_xml = alpine_bits_factory.create(
comments_data, OtaMessageType.RETRIEVE
comments_xml = alpine_bits_factory.create(comments_data, message_type)
company_name = Profile.CompanyInfo.CompanyName(
value="99tales GmbH", code="who knows?", code_context="who knows?"
)
res_global_info = (
OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo(
company_info = Profile.CompanyInfo(company_name=company_name)
profile = Profile(
company_info=company_info, profile_type=ProfileProfileType.VALUE_4
)
profile_info = HotelReservation.ResGlobalInfo.Profiles.ProfileInfo(profile=profile)
_LOGGER.info(f"Type of profile_info: {type(profile_info)}")
profiles = HotelReservation.ResGlobalInfo.Profiles(profile_info=profile_info)
res_global_info = HotelReservation.ResGlobalInfo(
hotel_reservation_ids=hotel_res_ids,
basic_property_info=basic_property_info,
comments=comments_xml,
)
profiles=profiles,
)
hotel_reservation = OtaResRetrieveRs.ReservationsList.HotelReservation(
create_date_time=datetime.now(timezone.utc).isoformat(),
hotel_reservation = HotelReservation(
create_date_time=datetime.now(UTC).isoformat(),
res_status=HotelReservationResStatus.REQUESTED,
room_stay_reservation="true",
unique_id=unique_id,
@@ -824,19 +795,63 @@ def create_xml_from_db(list: list[Tuple[Reservation, Customer]]):
res_global_info=res_global_info,
)
return hotel_reservation
def _create_xml_from_db(
entries: list[tuple[Reservation, Customer]] | tuple[Reservation, Customer],
type: OtaMessageType,
):
"""Create RetrievedReservation XML from database entries.
list of pairs (Reservation, Customer)
"""
reservations_list = []
# if entries isn't a list wrap the element in a list
if not isinstance(entries, list):
entries = [entries]
for reservation, customer in entries:
_LOGGER.info(
f"Creating XML for reservation {reservation.unique_id} and customer {customer.given_name}"
)
try:
hotel_reservation = _process_single_reservation(reservation, customer, type)
reservations_list.append(hotel_reservation)
except Exception as e:
_LOGGER.error(
f"Error creating XML for reservation {reservation.form_id} and customer {customer.given_name}: {e}"
f"Error creating XML for reservation {reservation.unique_id} and customer {customer.given_name}: {e}"
)
_LOGGER.debug(traceback.format_exc())
if type == OtaMessageType.NOTIF:
res_list_obj = OtaHotelResNotifRq.HotelReservations(
hotel_reservation=reservations_list
)
retrieved_reservations = OtaResRetrieveRs.ReservationsList(
ota_hotel_res_notif_rq = OtaHotelResNotifRq(
version="7.000", hotel_reservations=res_list_obj
)
try:
ota_hotel_res_notif_rq.model_validate(ota_hotel_res_notif_rq.model_dump())
except Exception as e:
_LOGGER.error(f"Validation error: {e}")
raise
return ota_hotel_res_notif_rq
if type == OtaMessageType.RETRIEVE:
res_list_obj = OtaResRetrieveRs.ReservationsList(
hotel_reservation=reservations_list
)
ota_res_retrieve_rs = OtaResRetrieveRs(
version="7.000", success="", reservations_list=retrieved_reservations
version="7.000", success="", reservations_list=res_list_obj
)
try:
@@ -847,6 +862,8 @@ def create_xml_from_db(list: list[Tuple[Reservation, Customer]]):
return ota_res_retrieve_rs
raise ValueError(f"Unsupported message type: {type}")
# Usage examples
if __name__ == "__main__":

View File

@@ -1,169 +0,0 @@
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from typing import List, Optional
# TimeSpan class according to XSD: <TimeSpan Start="..." End="..." Duration="..." StartWindow="..." EndWindow="..."/>
class TimeSpan:
def __init__(
self,
start: str,
end: str = None,
duration: str = None,
start_window: str = None,
end_window: str = None,
):
self.start = start
self.end = end
self.duration = duration
self.start_window = start_window
self.end_window = end_window
def to_xml(self):
attrib = {"Start": self.start}
if self.end:
attrib["End"] = self.end
if self.duration:
attrib["Duration"] = self.duration
if self.start_window:
attrib["StartWindow"] = self.start_window
if self.end_window:
attrib["EndWindow"] = self.end_window
return ET.Element(_ns("TimeSpan"), attrib)
NAMESPACE = "http://www.opentravel.org/OTA/2003/05"
ET.register_namespace("", NAMESPACE)
def _ns(tag):
return f"{{{NAMESPACE}}}{tag}"
class ResGuest:
def __init__(
self,
given_name: str,
surname: str,
gender: Optional[str] = None,
birth_date: Optional[str] = None,
language: Optional[str] = None,
name_prefix: Optional[str] = None,
name_title: Optional[str] = None,
email: Optional[str] = None,
address: Optional[dict] = None,
telephones: Optional[list] = None,
):
self.given_name = given_name
self.surname = surname
self.gender = gender
self.birth_date = birth_date
self.language = language
self.name_prefix = name_prefix
self.name_title = name_title
self.email = email
self.address = address or {}
self.telephones = telephones or []
def to_xml(self):
resguest_elem = ET.Element(_ns("ResGuest"))
profiles_elem = ET.SubElement(resguest_elem, _ns("Profiles"))
profileinfo_elem = ET.SubElement(profiles_elem, _ns("ProfileInfo"))
profile_elem = ET.SubElement(profileinfo_elem, _ns("Profile"))
customer_elem = ET.SubElement(profile_elem, _ns("Customer"))
if self.gender:
customer_elem.set("Gender", self.gender)
if self.birth_date:
customer_elem.set("BirthDate", self.birth_date)
if self.language:
customer_elem.set("Language", self.language)
personname_elem = ET.SubElement(customer_elem, _ns("PersonName"))
if self.name_prefix:
ET.SubElement(personname_elem, _ns("NamePrefix")).text = self.name_prefix
ET.SubElement(personname_elem, _ns("GivenName")).text = self.given_name
ET.SubElement(personname_elem, _ns("Surname")).text = self.surname
if self.name_title:
ET.SubElement(personname_elem, _ns("NameTitle")).text = self.name_title
for tel in self.telephones:
tel_elem = ET.SubElement(customer_elem, _ns("Telephone"))
for k, v in tel.items():
tel_elem.set(k, v)
if self.email:
ET.SubElement(customer_elem, _ns("Email")).text = self.email
if self.address:
address_elem = ET.SubElement(customer_elem, _ns("Address"))
for k, v in self.address.items():
if k == "CountryName":
country_elem = ET.SubElement(address_elem, _ns("CountryName"))
if isinstance(v, dict):
for ck, cv in v.items():
country_elem.set(ck, cv)
else:
country_elem.text = v
else:
ET.SubElement(address_elem, _ns(k)).text = v
return resguest_elem
def __str__(self):
from lxml import etree
elem = self.to_xml()
xml_bytes = ET.tostring(elem, encoding="utf-8")
parser = etree.XMLParser(remove_blank_text=True)
lxml_elem = etree.fromstring(xml_bytes, parser)
return etree.tostring(lxml_elem, pretty_print=True, encoding="unicode")
class RoomStay:
def __init__(self, room_type: str, timespan: TimeSpan, guests: List[ResGuest]):
self.room_type = room_type
self.timespan = timespan
self.guests = guests
def to_xml(self):
roomstay_elem = ET.Element(_ns("RoomStay"))
ET.SubElement(roomstay_elem, _ns("RoomType")).set(
"RoomTypeCode", self.room_type
)
roomstay_elem.append(self.timespan.to_xml())
guests_elem = ET.SubElement(roomstay_elem, _ns("Guests"))
for guest in self.guests:
guests_elem.append(guest.to_xml())
return roomstay_elem
class Reservation:
def __init__(
self,
reservation_id: str,
hotel_code: str,
roomstays: List[RoomStay],
create_time: Optional[str] = None,
):
self.reservation_id = reservation_id
self.hotel_code = hotel_code
self.roomstays = roomstays
self.create_time = create_time or datetime.now(timezone.utc).isoformat()
def to_xml(self):
res_elem = ET.Element(_ns("HotelReservation"))
uniqueid_elem = ET.SubElement(res_elem, _ns("UniqueID"))
uniqueid_elem.set("Type", "14")
uniqueid_elem.set("ID", self.reservation_id)
hotel_elem = ET.SubElement(res_elem, _ns("Hotel"))
hotel_elem.set("HotelCode", self.hotel_code)
roomstays_elem = ET.SubElement(res_elem, _ns("RoomStays"))
for rs in self.roomstays:
roomstays_elem.append(rs.to_xml())
res_elem.set("CreateDateTime", self.create_time)
return res_elem
def to_xml_string(self):
root = ET.Element(
_ns("OTA_ResRetrieveRS"),
{"Version": "2024-10", "TimeStamp": datetime.now(timezone.utc).isoformat()},
)
success_elem = ET.SubElement(root, _ns("Success"))
reservations_list = ET.SubElement(root, _ns("ReservationsList"))
reservations_list.append(self.to_xml())
return ET.tostring(root, encoding="utf-8", xml_declaration=True).decode("utf-8")

View File

@@ -1,35 +1,39 @@
"""
AlpineBits Server for handling hotel data exchange.
"""AlpineBits Server for handling hotel data exchange.
This module provides an asynchronous AlpineBits server that can handle various
OTA (OpenTravel Alliance) actions for hotel data exchange. Currently implements
handshaking functionality with configurable supported actions and capabilities.
"""
import asyncio
from datetime import datetime
import difflib
import json
import inspect
import re
from typing import Dict, List, Optional, Any, Union, Tuple, Type, override
from xml.etree import ElementTree as ET
from dataclasses import dataclass
from enum import Enum, IntEnum
from alpine_bits_python.alpine_bits_helpers import PhoneTechType, create_xml_from_db
from .generated.alpinebits import OtaPingRq, OtaPingRs, WarningStatus, OtaReadRq
from xsdata_pydantic.bindings import XmlSerializer
from xsdata.formats.dataclass.serializers.config import SerializerConfig
from abc import ABC, abstractmethod
from xsdata_pydantic.bindings import XmlParser
import json
import logging
from .db import Reservation, Customer
from sqlalchemy import select
from sqlalchemy.orm import joinedload
import re
from abc import ABC
from dataclasses import dataclass
from datetime import datetime
from enum import Enum, IntEnum
from typing import Any, Optional, override
from zoneinfo import ZoneInfo
from sqlalchemy import select
from xsdata.formats.dataclass.serializers.config import SerializerConfig
from xsdata_pydantic.bindings import XmlParser, XmlSerializer
from alpine_bits_python.alpine_bits_helpers import (
create_res_notif_push_message,
create_res_retrieve_response,
)
from .db import AckedRequest, Customer, Reservation
from .generated.alpinebits import (
OtaNotifReportRq,
OtaNotifReportRs,
OtaPingRq,
OtaPingRs,
OtaReadRq,
WarningStatus,
)
# Configure logging
logging.basicConfig(level=logging.INFO)
@@ -45,6 +49,14 @@ class HttpStatusCode(IntEnum):
INTERNAL_SERVER_ERROR = 500
def dump_json_for_xml(json_content: Any) -> str:
"""Dump JSON content as a pretty-printed string for embedding in XML.
Adds newlines before and after the JSON block for better readability in XML.
"""
return json.dumps(json_content)
class AlpineBitsActionName(Enum):
"""Enum for AlpineBits action names with capability and request name mappings."""
@@ -52,10 +64,14 @@ class AlpineBitsActionName(Enum):
OTA_PING = ("action_OTA_Ping", "OTA_Ping:Handshaking")
OTA_READ = ("action_OTA_Read", "OTA_Read:GuestRequests")
OTA_HOTEL_AVAIL_NOTIF = ("action_OTA_HotelAvailNotif", "OTA_HotelAvailNotif")
OTA_HOTEL_RES_NOTIF_GUEST_REQUESTS = (
OTA_HOTEL_RES_NOTIF_GUEST_REQUESTS = ( ## Push Action for Guest Requests
"action_OTA_HotelResNotif_GuestRequests",
"OTA_HotelResNotif:GuestRequests",
)
OTA_HOTEL_NOTIF_REPORT = (
"action_OTA_Read", # if read is supported this is also supported
"OTA_NotifReport:GuestRequests",
)
OTA_HOTEL_DESCRIPTIVE_CONTENT_NOTIF_INVENTORY = (
"action_OTA_HotelDescriptiveContentNotif_Inventory",
"OTA_HotelDescriptiveContentNotif:Inventory",
@@ -154,8 +170,7 @@ class AlpineBitsAction(ABC):
dbsession=None,
server_capabilities=None,
) -> AlpineBitsResponse:
"""
Handle the incoming request XML and return response XML.
"""Handle the incoming request XML and return response XML.
Default implementation returns "not implemented" error.
Override this method in subclasses to provide actual functionality.
@@ -167,18 +182,19 @@ class AlpineBitsAction(ABC):
Returns:
AlpineBitsResponse with error or actual response
"""
return_string = f"Error: Action {action} not implemented"
return AlpineBitsResponse(return_string, HttpStatusCode.BAD_REQUEST)
async def check_version_supported(self, version: Version) -> bool:
"""
Check if the action supports the given version.
"""Check if the action supports the given version.
Args:
version: The AlpineBits version to check
Returns:
True if supported, False otherwise
"""
if isinstance(self.version, list):
return version in self.version
@@ -186,12 +202,10 @@ class AlpineBitsAction(ABC):
class ServerCapabilities:
"""
Automatically discovers AlpineBitsAction implementations and generates capabilities.
"""
"""Automatically discovers AlpineBitsAction implementations and generates capabilities."""
def __init__(self):
self.action_registry: Dict[str, Type[AlpineBitsAction]] = {}
self.action_registry: dict[AlpineBitsActionName, type[AlpineBitsAction]] = {}
self._discover_actions()
self.capability_dict = None
@@ -209,12 +223,11 @@ class ServerCapabilities:
if self._is_action_implemented(obj):
action_instance = obj()
if hasattr(action_instance, "name"):
# Use capability name for the registry key
self.action_registry[action_instance.name.capability_name] = obj
# Use capability attribute as registry key
self.action_registry[action_instance.name] = obj
def _is_action_implemented(self, action_class: Type[AlpineBitsAction]) -> bool:
"""
Check if an action is actually implemented or just uses the default behavior.
def _is_action_implemented(self, action_class: type[AlpineBitsAction]) -> bool:
"""Check if an action is actually implemented or just uses the default behavior.
This is a simple check - in practice, you might want more sophisticated detection.
"""
# Check if the class has overridden the handle method
@@ -223,13 +236,10 @@ class ServerCapabilities:
return False
def create_capabilities_dict(self) -> None:
"""
Generate the capabilities dictionary based on discovered actions.
"""
"""Generate the capabilities dictionary based on discovered actions."""
versions_dict = {}
for action_name, action_class in self.action_registry.items():
for action_enum, action_class in self.action_registry.items():
action_instance = action_class()
# Get supported versions for this action
@@ -245,7 +255,7 @@ class ServerCapabilities:
if version_str not in versions_dict:
versions_dict[version_str] = {"version": version_str, "actions": []}
action_dict = {"action": action_name}
action_dict = {"action": action_enum.capability_name}
# Add supports field if the action has custom supports
if hasattr(action_instance, "supports") and action_instance.supports:
@@ -255,22 +265,32 @@ class ServerCapabilities:
self.capability_dict = {"versions": list(versions_dict.values())}
return None
# filter duplicates in actions for each version
for version in self.capability_dict["versions"]:
seen_actions = set()
unique_actions = []
for action in version["actions"]:
if action["action"] not in seen_actions:
seen_actions.add(action["action"])
unique_actions.append(action)
version["actions"] = unique_actions
def get_capabilities_dict(self) -> Dict:
"""
Get capabilities as a dictionary. Generates if not already created.
"""
# remove action_OTA_Ping from version 2024-10
for version in self.capability_dict["versions"]:
if version["version"] == "2024-10":
version["actions"] = [
action
for action in version["actions"]
if action.get("action") != "action_OTA_Ping"
]
def get_capabilities_dict(self) -> dict:
"""Get capabilities as a dictionary. Generates if not already created."""
if self.capability_dict is None:
self.create_capabilities_dict()
return self.capability_dict
def get_capabilities_json(self) -> str:
"""Get capabilities as formatted JSON string."""
return json.dumps(self.get_capabilities_dict(), indent=2)
def get_supported_actions(self) -> List[str]:
def get_supported_actions(self) -> list[str]:
"""Get list of all supported action names."""
return list(self.action_registry.keys())
@@ -281,7 +301,7 @@ class ServerCapabilities:
class PingAction(AlpineBitsAction):
"""Implementation for OTA_Ping action (handshaking)."""
def __init__(self, config: Dict = {}):
def __init__(self, config: dict = {}):
self.name = AlpineBitsActionName.OTA_PING
self.version = [
Version.V2024_10,
@@ -299,10 +319,9 @@ class PingAction(AlpineBitsAction):
server_capabilities: None | ServerCapabilities = None,
) -> AlpineBitsResponse:
"""Handle ping requests."""
if request_xml is None:
return AlpineBitsResponse(
f"Error: Xml Request missing", HttpStatusCode.BAD_REQUEST
"Error: Xml Request missing", HttpStatusCode.BAD_REQUEST
)
if server_capabilities is None:
@@ -316,10 +335,10 @@ class PingAction(AlpineBitsAction):
try:
parsed_request = parser.from_string(request_xml, OtaPingRq)
echo_data = json.loads(parsed_request.echo_data)
except Exception as e:
echo_data_client = json.loads(parsed_request.echo_data)
except Exception:
return AlpineBitsResponse(
f"Error: Invalid XML request", HttpStatusCode.BAD_REQUEST
"Error: Invalid XML request", HttpStatusCode.BAD_REQUEST
)
# compare echo data with capabilities, create a dictionary containing the matching capabilities
@@ -329,7 +348,7 @@ class PingAction(AlpineBitsAction):
matching_capabilities = {"versions": []}
# Iterate through client's requested versions
for client_version in echo_data.get("versions", []):
for client_version in echo_data_client.get("versions", []):
client_version_str = client_version.get("version", "")
# Find matching server version
@@ -364,22 +383,22 @@ class PingAction(AlpineBitsAction):
# Debug print to see what we matched
# Create successful ping response with matched capabilities
capabilities_json = json.dumps(matching_capabilities, indent=2)
capabilities_json_str = dump_json_for_xml(matching_capabilities)
warning = OtaPingRs.Warnings.Warning(
status=WarningStatus.ALPINEBITS_HANDSHAKE,
type_value="11",
content=[capabilities_json],
content=[capabilities_json_str],
)
warning_response = OtaPingRs.Warnings(warning=[warning])
all_capabilities = server_capabilities.get_capabilities_json()
client_response_echo_data = parsed_request.echo_data
response_ota_ping = OtaPingRs(
version="7.000",
warnings=warning_response,
echo_data=all_capabilities,
echo_data=client_response_echo_data,
success="",
)
@@ -402,7 +421,7 @@ def strip_control_chars(s):
def validate_hotel_authentication(
username: str, password: str, hotelid: str, config: Dict
username: str, password: str, hotelid: str, config: dict
) -> bool:
"""Validate hotel authentication based on username, password, and hotel ID.
@@ -413,7 +432,6 @@ def validate_hotel_authentication(
username: "alice"
password: !secret ALICE_PASSWORD
"""
if not config or "alpine_bits_auth" not in config:
return False
auth_list = config["alpine_bits_auth"]
@@ -432,7 +450,7 @@ def validate_hotel_authentication(
class ReadAction(AlpineBitsAction):
"""Implementation for OTA_Read action."""
def __init__(self, config: Dict = {}):
def __init__(self, config: dict = {}):
self.name = AlpineBitsActionName.OTA_READ
self.version = [Version.V2024_10, Version.V2022_10]
self.config = config
@@ -447,7 +465,6 @@ class ReadAction(AlpineBitsAction):
server_capabilities=None,
) -> AlpineBitsResponse:
"""Handle read requests."""
clean_action = strip_control_chars(str(action)).strip()
clean_expected = strip_control_chars(self.name.value[1]).strip()
@@ -474,11 +491,13 @@ class ReadAction(AlpineBitsAction):
if hotelid is None:
return AlpineBitsResponse(
f"Error: Unauthorized Read Request. No target hotel specified. Check credentials",
"Error: Unauthorized Read Request. No target hotel specified. Check credentials",
HttpStatusCode.UNAUTHORIZED,
)
if not validate_hotel_authentication(client_info.username, client_info.password, hotelid, self.config):
if not validate_hotel_authentication(
client_info.username, client_info.password, hotelid, self.config
):
return AlpineBitsResponse(
f"Error: Unauthorized Read Request for this specific hotel {hotelname}. Check credentials",
HttpStatusCode.UNAUTHORIZED,
@@ -500,6 +519,17 @@ class ReadAction(AlpineBitsAction):
)
if start_date:
stmt = stmt.filter(Reservation.start_date >= start_date)
# remove reservations that have been acknowledged via client_id
elif client_info.client_id:
subquery = (
select(Reservation.id)
.join(
AckedRequest,
Reservation.md5_unique_id == AckedRequest.unique_id,
)
.filter(AckedRequest.client_id == client_info.client_id)
)
stmt = stmt.filter(~Reservation.id.in_(subquery))
result = await dbsession.execute(stmt)
reservation_customer_pairs: list[tuple[Reservation, Customer]] = (
@@ -514,7 +544,7 @@ class ReadAction(AlpineBitsAction):
f"Reservation: {reservation.id}, Customer: {customer.given_name}"
)
res_retrive_rs = create_xml_from_db(reservation_customer_pairs)
res_retrive_rs = create_res_retrieve_response(reservation_customer_pairs)
config = SerializerConfig(
pretty_print=True, xml_declaration=True, encoding="UTF-8"
@@ -530,8 +560,8 @@ class ReadAction(AlpineBitsAction):
class NotifReportReadAction(AlpineBitsAction):
"""Necessary for read action to follow specification. Clients need to report acknowledgements"""
def __init__(self, config: Dict = {}):
self.name = AlpineBitsActionName.OTA_HOTEL_RES_NOTIF_GUEST_REQUESTS
def __init__(self, config: dict = {}):
self.name = AlpineBitsActionName.OTA_HOTEL_NOTIF_REPORT
self.version = [Version.V2024_10, Version.V2022_10]
self.config = config
@@ -540,37 +570,102 @@ class NotifReportReadAction(AlpineBitsAction):
action: str,
request_xml: str,
version: Version,
client_info: AlpineBitsClientInfo,
dbsession=None,
username=None,
password=None,
server_capabilities=None,
) -> AlpineBitsResponse:
"""Handle read requests."""
notif_report = XmlParser().from_string(request_xml, OtaNotifReportRq)
# we can't check hotel auth here, because this action does not contain hotel info
warnings = notif_report.warnings
notif_report_details = notif_report.notif_details
success_message = OtaNotifReportRs(version="7.000", success="")
if client_info.client_id is None:
return AlpineBitsResponse(
f"Error: Action {action} not implemented", HttpStatusCode.BAD_REQUEST
"ERROR:no valid client id provided", HttpStatusCode.BAD_REQUEST
)
config = SerializerConfig(
pretty_print=True, xml_declaration=True, encoding="UTF-8"
)
serializer = XmlSerializer(config=config)
response_xml = serializer.render(
success_message, ns_map={None: "http://www.opentravel.org/OTA/2003/05"}
)
class GuestRequestsAction(AlpineBitsAction):
"""Unimplemented action - will not appear in capabilities."""
if (warnings is None and notif_report_details is None) or (
notif_report_details is not None
and notif_report_details.hotel_notif_report is None
):
return AlpineBitsResponse(
response_xml, HttpStatusCode.OK
) # Nothing to process
if dbsession is None:
return AlpineBitsResponse(
"Error: Something went wrong", HttpStatusCode.INTERNAL_SERVER_ERROR
)
def __init__(self):
timestamp = datetime.now(ZoneInfo("UTC"))
for entry in (
notif_report_details.hotel_notif_report.hotel_reservations.hotel_reservation
): # type: ignore
unique_id = entry.unique_id.id
acked_request = AckedRequest(
unique_id=unique_id,
client_id=client_info.client_id,
timestamp=timestamp,
)
dbsession.add(acked_request)
await dbsession.commit()
return AlpineBitsResponse(response_xml, HttpStatusCode.OK)
class PushAction(AlpineBitsAction):
"""Creates the necessary xml for OTA_HotelResNotif:GuestRequests"""
def __init__(self, config: dict = {}):
self.name = AlpineBitsActionName.OTA_HOTEL_RES_NOTIF_GUEST_REQUESTS
self.version = Version.V2024_10
self.version = [Version.V2024_10, Version.V2022_10]
self.config = config
# Note: This class doesn't override the handle method, so it won't be discovered
async def handle(
self,
action: str,
request_xml: tuple[Reservation, Customer],
version: Version,
client_info: AlpineBitsClientInfo,
dbsession=None,
server_capabilities=None,
) -> AlpineBitsResponse:
"""Create push request XML."""
xml_push_request = create_res_notif_push_message(request_xml)
config = SerializerConfig(
pretty_print=True, xml_declaration=True, encoding="UTF-8"
)
serializer = XmlSerializer(config=config)
xml_push_request = serializer.render(
xml_push_request, ns_map={None: "http://www.opentravel.org/OTA/2003/05"}
)
return AlpineBitsResponse(xml_push_request, HttpStatusCode.OK)
class AlpineBitsServer:
"""
Asynchronous AlpineBits server for handling hotel data exchange requests.
"""Asynchronous AlpineBits server for handling hotel data exchange requests.
This server handles various OTA actions and implements the AlpineBits protocol
for hotel data exchange. It maintains a registry of supported actions and
their capabilities, and can respond to handshake requests with its capabilities.
"""
def __init__(self, config: Dict = None):
def __init__(self, config: dict = None):
self.capabilities = ServerCapabilities()
self._action_instances = {}
self.config = config
@@ -579,34 +674,31 @@ class AlpineBitsServer:
def _initialize_action_instances(self):
"""Initialize instances of all discovered action classes."""
for capability_name, action_class in self.capabilities.action_registry.items():
_LOGGER.info(f"Initializing action instance for {capability_name}")
self._action_instances[capability_name] = action_class(config=self.config)
def get_capabilities(self) -> Dict:
def get_capabilities(self) -> dict:
"""Get server capabilities."""
return self.capabilities.get_capabilities_dict()
def get_capabilities_json(self) -> str:
"""Get server capabilities as JSON."""
return self.capabilities.get_capabilities_json()
async def handle_request(
self,
request_action_name: str,
request_xml: str,
request_xml: str | tuple[Reservation, Customer],
client_info: AlpineBitsClientInfo,
version: str = "2024-10",
dbsession=None,
) -> AlpineBitsResponse:
"""
Handle an incoming AlpineBits request by routing to appropriate action handler.
"""Handle an incoming AlpineBits request by routing to appropriate action handler.
Args:
request_action_name: The action name from the request (e.g., "OTA_Read:GuestRequests")
request_xml: The XML request body
request_xml: The XML request body. Gets passed to the action handler. In case of PushRequest can be the data to be pushed
version: The AlpineBits version (defaults to "2024-10")
Returns:
AlpineBitsResponse with the result
"""
# Convert string version to enum
try:
@@ -618,6 +710,10 @@ class AlpineBitsServer:
# Find the action by request name
action_enum = AlpineBitsActionName.get_by_request_name(request_action_name)
_LOGGER.info(
f"Handling request for action: {request_action_name} with action enum: {action_enum}"
)
if not action_enum:
return AlpineBitsResponse(
f"Error: Unknown action {request_action_name}",
@@ -625,14 +721,14 @@ class AlpineBitsServer:
)
# Check if we have an implementation for this action
capability_name = action_enum.capability_name
if capability_name not in self._action_instances:
if action_enum not in self._action_instances:
return AlpineBitsResponse(
f"Error: Action {request_action_name} is not implemented",
HttpStatusCode.BAD_REQUEST,
)
action_instance: AlpineBitsAction = self._action_instances[capability_name]
action_instance: AlpineBitsAction = self._action_instances[action_enum]
# Check if the action supports the requested version
if not await action_instance.check_version_supported(version_enum):
@@ -644,11 +740,29 @@ class AlpineBitsServer:
# Handle the request
try:
# Special case for ping action - pass server capabilities
if capability_name == "action_OTA_Ping":
return await action_instance.handle(
action=request_action_name, request_xml=request_xml, version=version_enum, server_capabilities=self.capabilities, client_info=client_info
if action_enum == AlpineBitsActionName.OTA_HOTEL_RES_NOTIF_GUEST_REQUESTS:
action_instance: PushAction
if request_xml is None or not isinstance(request_xml, tuple):
return AlpineBitsResponse(
"Error: Invalid data for push request",
HttpStatusCode.BAD_REQUEST,
)
return await action_instance.handle(
action=request_action_name,
request_xml=request_xml,
version=version_enum,
client_info=client_info,
)
if action_enum == AlpineBitsActionName.OTA_PING:
return await action_instance.handle(
action=request_action_name,
request_xml=request_xml,
version=version_enum,
server_capabilities=self.capabilities,
client_info=client_info,
)
else:
return await action_instance.handle(
action=request_action_name,
request_xml=request_xml,
@@ -657,17 +771,17 @@ class AlpineBitsServer:
client_info=client_info,
)
except Exception as e:
print(f"Error handling request {request_action_name}: {str(e)}")
print(f"Error handling request {request_action_name}: {e!s}")
# print stack trace for debugging
import traceback
traceback.print_exc()
return AlpineBitsResponse(
f"Error: Internal server error while processing {request_action_name}: {str(e)}",
f"Error: Internal server error while processing {request_action_name}: {e!s}",
HttpStatusCode.INTERNAL_SERVER_ERROR,
)
def get_supported_request_names(self) -> List[str]:
def get_supported_request_names(self) -> list[str]:
"""Get all supported request names (not capability names)."""
request_names = []
for capability_name in self._action_instances.keys():
@@ -679,8 +793,7 @@ class AlpineBitsServer:
def is_action_supported(
self, request_action_name: str, version: str | None = None
) -> bool:
"""
Check if a request action is supported.
"""Check if a request action is supported.
Args:
request_action_name: The request action name (e.g., "OTA_Read:GuestRequests")
@@ -688,6 +801,7 @@ class AlpineBitsServer:
Returns:
True if supported, False otherwise
"""
action_enum = AlpineBitsActionName.get_by_request_name(request_action_name)
if not action_enum:
@@ -704,76 +818,8 @@ class AlpineBitsServer:
# This would need to be async, but for simplicity we'll just check if version exists
if isinstance(action_instance.version, list):
return version_enum in action_instance.version
else:
return action_instance.version == version_enum
except ValueError:
return False
return True
async def main():
"""Demonstrate the automatic capabilities discovery and request handling."""
print("🚀 AlpineBits Server Capabilities Discovery & Request Handling Demo")
print("=" * 70)
# Create server instance
server = AlpineBitsServer()
print("\n📋 Discovered Action Classes:")
print("-" * 30)
for capability_name, action_class in server.capabilities.action_registry.items():
action_enum = AlpineBitsActionName.get_by_capability_name(capability_name)
request_name = action_enum.request_name if action_enum else "unknown"
print(f"{capability_name} -> {action_class.__name__}")
print(f" Request name: {request_name}")
print(
f"\n📊 Total Implemented Actions: {len(server.capabilities.get_supported_actions())}"
)
print("\n🔍 Generated Capabilities JSON:")
print("-" * 30)
capabilities_json = server.get_capabilities_json()
print(capabilities_json)
print("\n🎯 Supported Request Names:")
print("-" * 30)
for request_name in server.get_supported_request_names():
print(f"{request_name}")
print("\n🧪 Testing Request Handling:")
print("-" * 30)
test_xml = "<test>sample request</test>"
# Test different request formats
test_cases = [
("OTA_Ping:Handshaking", "2024-10"),
("OTA_Read:GuestRequests", "2024-10"),
("OTA_Read:GuestRequests", "2022-10"),
("OTA_HotelAvailNotif", "2024-10"),
("UnknownAction", "2024-10"),
("OTA_Ping:Handshaking", "unsupported-version"),
]
for request_name, version in test_cases:
print(f"\n<EFBFBD> Testing: {request_name} (v{version})")
# Check if supported first
is_supported = server.is_action_supported(request_name, version)
print(f" Supported: {is_supported}")
# Handle the request
response = await server.handle_request(request_name, test_xml, version)
print(f" Status: {response.status_code}")
if len(response.xml_content) > 100:
print(f" Response: {response.xml_content[:100]}...")
else:
print(f" Response: {response.xml_content}")
print("\n✅ Demo completed successfully!")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -1,49 +1,44 @@
from fastapi import (
FastAPI,
HTTPException,
BackgroundTasks,
Request,
Depends,
APIRouter,
Form,
File,
UploadFile,
)
from fastapi.concurrency import asynccontextmanager
import asyncio
import gzip
import json
import logging
import os
import urllib.parse
from collections import defaultdict
from datetime import UTC, date, datetime
from functools import partial
from pathlib import Path
from typing import Any
import httpx
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPBearer, HTTPBasicCredentials, HTTPBasic
from fastapi.responses import HTMLResponse, Response
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,
AlpineBitsServer,
Version,
)
from .auth import generate_api_key, generate_unique_id, validate_api_key
from .config_loader import load_config
from fastapi.responses import HTMLResponse, PlainTextResponse, Response
from .models import WixFormSubmission
from datetime import datetime, date, timezone
from .auth import validate_api_key, validate_wix_signature, generate_api_key
from .db import Base, get_database_url
from .db import Customer as DBCustomer
from .db import Reservation as DBReservation
from .rate_limit import (
limiter,
webhook_limiter,
custom_rate_limit_handler,
BURST_RATE_LIMIT,
DEFAULT_RATE_LIMIT,
WEBHOOK_RATE_LIMIT,
BURST_RATE_LIMIT,
custom_rate_limit_handler,
limiter,
webhook_limiter,
)
from slowapi.errors import RateLimitExceeded
import logging
from datetime import datetime
from typing import Dict, Any, Optional, List
import json
import os
import gzip
import xml.etree.ElementTree as ET
from .alpinebits_server import AlpineBitsClientInfo, AlpineBitsServer, Version
import urllib.parse
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from .db import (
Base,
Customer as DBCustomer,
Reservation as DBReservation,
get_database_url,
)
# Configure logging
logging.basicConfig(level=logging.INFO)
@@ -52,26 +47,154 @@ _LOGGER = logging.getLogger(__name__)
# HTTP Basic auth for AlpineBits
security_basic = HTTPBasic()
# --- Enhanced event dispatcher with hotel-specific routing ---
class EventDispatcher:
def __init__(self):
self.listeners = defaultdict(list)
self.hotel_listeners = defaultdict(list) # hotel_code -> list of listeners
def register(self, event_name, func):
self.listeners[event_name].append(func)
def register_hotel_listener(self, event_name, hotel_code, func):
"""Register a listener for a specific hotel"""
self.hotel_listeners[f"{event_name}:{hotel_code}"].append(func)
async def dispatch(self, event_name, *args, **kwargs):
for func in self.listeners[event_name]:
await func(*args, **kwargs)
async def dispatch_for_hotel(self, event_name, hotel_code, *args, **kwargs):
"""Dispatch event only to listeners registered for specific hotel"""
key = f"{event_name}:{hotel_code}"
for func in self.hotel_listeners[key]:
await func(*args, **kwargs)
event_dispatcher = EventDispatcher()
# Load config at startup
@asynccontextmanager
async def push_listener(customer: DBCustomer, reservation: DBReservation, hotel):
"""Push listener that sends reservation data to hotel's push endpoint.
Only called for reservations that match this hotel's hotel_id.
"""
push_endpoint = hotel.get("push_endpoint")
if not push_endpoint:
_LOGGER.warning(
f"No push endpoint configured for hotel {hotel.get('hotel_id')}"
)
return
server: AlpineBitsServer = app.state.alpine_bits_server
hotel_id = hotel["hotel_id"]
reservation_hotel_id = reservation.hotel_code
# Double-check hotel matching (should be guaranteed by dispatcher)
if hotel_id != reservation_hotel_id:
_LOGGER.warning(
f"Hotel ID mismatch: listener for {hotel_id}, reservation for {reservation_hotel_id}"
)
return
_LOGGER.info(
f"Processing push notification for hotel {hotel_id}, reservation {reservation.unique_id}"
)
# Prepare payload for push notification
request = await server.handle_request(
request_action_name=AlpineBitsActionName.OTA_HOTEL_RES_NOTIF_GUEST_REQUESTS.request_name,
request_xml=(reservation, customer),
client_info=None,
version=Version.V2024_10,
)
if request.status_code != 200:
_LOGGER.error(
f"Failed to generate push request for hotel {hotel_id}, reservation {reservation.unique_id}: {request.xml_content}"
)
return
# save push request to file
logs_dir = "logs/push_requests"
if not os.path.exists(logs_dir):
os.makedirs(logs_dir, mode=0o755, exist_ok=True)
stat_info = os.stat(logs_dir)
_LOGGER.info(
f"Created directory owner: uid:{stat_info.st_uid}, gid:{stat_info.st_gid}"
)
_LOGGER.info(f"Directory mode: {oct(stat_info.st_mode)[-3:]}")
log_filename = f"{logs_dir}/alpinebits_push_{hotel_id}_{reservation.unique_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xml"
with open(log_filename, "w", encoding="utf-8") as f:
f.write(request.xml_content)
return
headers = (
{"Authorization": f"Bearer {push_endpoint.get('token', '')}"}
if push_endpoint.get("token")
else {}
)
""
try:
async with httpx.AsyncClient() as client:
resp = await client.post(
push_endpoint["url"], json=payload, headers=headers, timeout=10
)
_LOGGER.info(
f"Push event fired to {push_endpoint['url']} for hotel {hotel['hotel_id']}, status: {resp.status_code}"
)
if resp.status_code not in [200, 201, 202]:
_LOGGER.warning(
f"Push endpoint returned non-success status {resp.status_code}: {resp.text}"
)
except Exception as e:
_LOGGER.error(f"Push event failed for hotel {hotel['hotel_id']}: {e}")
# Optionally implement retry logic here@asynccontextmanager
async def lifespan(app: FastAPI):
# Setup DB
try:
config = load_config()
except Exception as e:
_LOGGER.error(f"Failed to load config: {str(e)}")
_LOGGER.error(f"Failed to load config: {e!s}")
config = {}
DATABASE_URL = get_database_url(config)
engine = create_async_engine(DATABASE_URL, echo=True)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
app.state.engine = engine
app.state.async_sessionmaker = AsyncSessionLocal
app.state.config = config
app.state.alpine_bits_server = AlpineBitsServer(config)
app.state.event_dispatcher = event_dispatcher
# Register push listeners for hotels with push_endpoint
for hotel in config.get("alpine_bits_auth", []):
push_endpoint = hotel.get("push_endpoint")
hotel_id = hotel.get("hotel_id")
if push_endpoint and hotel_id:
# Register hotel-specific listener
event_dispatcher.register_hotel_listener(
"form_processed", hotel_id, partial(push_listener, hotel=hotel)
)
_LOGGER.info(
f"Registered push listener for hotel {hotel_id} with endpoint {push_endpoint.get('url')}"
)
elif push_endpoint and not hotel_id:
_LOGGER.warning(f"Hotel has push_endpoint but no hotel_id: {hotel}")
elif hotel_id and not push_endpoint:
_LOGGER.info(f"Hotel {hotel_id} has no push_endpoint configured")
# Create tables
async with engine.begin() as conn:
@@ -119,43 +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: {str(e)}")
@api_router.get("/")
@limiter.limit(DEFAULT_RATE_LIMIT)
async def root(request: Request):
@@ -187,11 +273,25 @@ 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).
"""
async def process_wix_form_submission(request: Request, data: dict[str, Any], db):
"""Shared business logic for handling Wix form submissions (test and production)."""
timestamp = datetime.now().isoformat()
_LOGGER.info(f"Received Wix form data at {timestamp}")
@@ -236,7 +336,9 @@ async def process_wix_form_submission(request: Request, data: Dict[str, Any], db
contact_id = contact_info.get("contactId")
name_prefix = data.get("field:anrede")
email_newsletter = data.get("field:form_field_5a7b", "") != "Non selezionato"
email_newsletter_string = data.get("field:form_field_5a7b", "")
yes_values = {"Selezionato", "Angekreuzt", "Checked"}
email_newsletter = email_newsletter_string in yes_values
address_line = None
city_name = None
postal_code = None
@@ -272,20 +374,9 @@ 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"),
]
utm_comment_text = []
for label, field in utm_fields:
val = data.get(f"field:{field}") or data.get(label)
if val:
utm_comment_text.append(f"{label}: {val}")
utm_comment = ",".join(utm_comment_text) if utm_comment_text else None
# get submissionId and ensure max length 35. Generate one if not present
unique_id = data.get("submissionId", generate_unique_id())
# use database session
@@ -309,20 +400,36 @@ async def process_wix_form_submission(request: Request, data: Dict[str, Any], db
name_title=None,
)
db.add(db_customer)
await db.commit()
await db.refresh(db_customer)
await db.flush() # This assigns db_customer.id without committing
# await db.refresh(db_customer)
db_reservation = DBReservation(
customer_id=db_customer.id,
form_id=data.get("submissionId"),
start_date=date.fromisoformat(start_date) if start_date else None,
end_date=date.fromisoformat(end_date) if end_date else None,
# Determine hotel_code and hotel_name
# Priority: 1) Form field, 2) Configuration default, 3) Hardcoded fallback
hotel_code = (
data.get("field:hotelid")
or data.get("hotelid")
or request.app.state.config.get("default_hotel_code")
or "123" # fallback
)
hotel_name = (
data.get("field:hotelname")
or data.get("hotelname")
or request.app.state.config.get("default_hotel_name")
or "Frangart Inn" # fallback
)
reservation = ReservationData(
unique_id=unique_id,
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,
utm_comment=utm_comment,
created_at=datetime.now(timezone.utc),
created_at=datetime.now(UTC),
utm_source=data.get("field:utm_source"),
utm_medium=data.get("field:utm_medium"),
utm_campaign=data.get("field:utm_campaign"),
@@ -331,13 +438,34 @@ 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="123",
hotel_name="Frangart Inn",
)
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)
async def push_event():
# Fire event for listeners (push, etc.) - hotel-specific dispatch
dispatcher = getattr(request.app.state, "event_dispatcher", None)
if dispatcher:
# Get hotel_code from reservation to target the right listeners
hotel_code = getattr(db_reservation, "hotel_code", None)
if hotel_code and hotel_code.strip():
await dispatcher.dispatch_for_hotel(
"form_processed", hotel_code, db_customer, db_reservation
)
_LOGGER.info("Dispatched form_processed event for hotel %s", hotel_code)
else:
_LOGGER.warning(
"No hotel_code in reservation, skipping push notifications"
)
asyncio.create_task(push_event())
return {
"status": "success",
"message": "Wix form data received successfully",
@@ -348,76 +476,11 @@ async def process_wix_form_submission(request: Request, data: Dict[str, Any], db
}
@api_router.post("/webhook/wix-form")
@webhook_limiter.limit(WEBHOOK_RATE_LIMIT)
async def handle_wix_form(
request: Request, data: Dict[str, Any], db_session=Depends(get_async_session)
):
"""
Unified endpoint to handle Wix form submissions (test and production).
No authentication required for this endpoint.
"""
try:
return await process_wix_form_submission(request, data, db_session)
except Exception as e:
_LOGGER.error(f"Error in handle_wix_form: {str(e)}")
# log stacktrace
import traceback
traceback_str = traceback.format_exc()
_LOGGER.error(f"Stack trace for handle_wix_form: {traceback_str}")
raise HTTPException(
status_code=500, detail=f"Error processing Wix form data: {str(e)}"
)
@api_router.post("/webhook/wix-form/test")
@limiter.limit(DEFAULT_RATE_LIMIT)
async def handle_wix_form_test(
request: Request, data: Dict[str, Any], db_session=Depends(get_async_session)
):
"""
Test endpoint to verify the API is working with raw JSON data.
No authentication required for testing purposes.
"""
try:
return await process_wix_form_submission(request, data, db_session)
except Exception as e:
_LOGGER.error(f"Error in handle_wix_form_test: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error processing test data: {str(e)}"
)
@api_router.post("/admin/generate-api-key")
@limiter.limit("5/hour") # Very restrictive for admin operations
async def generate_new_api_key(
request: Request, admin_key: str = Depends(validate_api_key)
):
"""
Admin endpoint to generate new API keys.
Requires admin API key and is heavily rate limited.
"""
if admin_key != "admin-key":
raise HTTPException(status_code=403, detail="Admin access required")
new_key = generate_api_key()
_LOGGER.info(f"Generated new API key (requested by: {admin_key})")
return {
"status": "success",
"message": "New API key generated",
"api_key": new_key,
"timestamp": datetime.now().isoformat(),
"note": "Store this key securely - it won't be shown again",
}
async def validate_basic_auth(
credentials: HTTPBasicCredentials = Depends(security_basic),
) -> str:
"""
Validate basic authentication for AlpineBits protocol.
"""Validate basic authentication for AlpineBits protocol.
Returns username if valid, raises HTTPException if not.
"""
# Accept any username/password pair present in config['alpine_bits_auth']
@@ -444,14 +507,151 @@ async def validate_basic_auth(
headers={"WWW-Authenticate": "Basic"},
)
_LOGGER.info(
f"AlpineBits authentication successful for user: {credentials.username} (from config)"
"AlpineBits authentication successful for user: %s (from config)",
credentials.username,
)
return credentials.username, credentials.password
def parse_multipart_data(content_type: str, body: bytes) -> Dict[str, Any]:
@api_router.post("/webhook/wix-form")
@webhook_limiter.limit(WEBHOOK_RATE_LIMIT)
async def handle_wix_form(
request: Request, data: dict[str, Any], db_session=Depends(get_async_session)
):
"""Unified endpoint to handle Wix form submissions (test and production).
No authentication required for this endpoint.
"""
Parse multipart/form-data from raw request body.
try:
return await process_wix_form_submission(request, data, db_session)
except Exception as e:
_LOGGER.error(f"Error in handle_wix_form: {e!s}")
# log stacktrace
import traceback
traceback_str = traceback.format_exc()
_LOGGER.error(f"Stack trace for handle_wix_form: {traceback_str}")
raise HTTPException(status_code=500, detail="Error processing Wix form data")
@api_router.post("/webhook/wix-form/test")
@limiter.limit(DEFAULT_RATE_LIMIT)
async def handle_wix_form_test(
request: Request, data: dict[str, Any], db_session=Depends(get_async_session)
):
"""Test endpoint to verify the API is working with raw JSON data.
No authentication required for testing purposes.
"""
try:
return await process_wix_form_submission(request, data, db_session)
except Exception as e:
_LOGGER.error(f"Error in handle_wix_form_test: {e!s}")
raise HTTPException(status_code=500, detail="Error processing test data")
@api_router.post("/hoteldata/conversions_import")
@limiter.limit(DEFAULT_RATE_LIMIT)
async def handle_xml_upload(
request: Request, credentials_tupel: tuple = Depends(validate_basic_auth)
):
"""Endpoint for receiving XML files for conversion processing.
Requires basic authentication and saves XML files to log directory.
Supports gzip compression via Content-Encoding header.
"""
try:
# Get the raw body content
body = await request.body()
if not body:
raise HTTPException(
status_code=400, detail="ERROR: No XML content provided"
)
# Check if content is gzip compressed
content_encoding = request.headers.get("content-encoding", "").lower()
is_gzipped = content_encoding == "gzip"
# Decompress if gzipped
if is_gzipped:
try:
body = gzip.decompress(body)
except Exception as e:
raise HTTPException(
status_code=400,
detail=f"ERROR: Failed to decompress gzip content: {e}",
) from e
# Try to decode as UTF-8
try:
xml_content = body.decode("utf-8")
except UnicodeDecodeError:
# If UTF-8 fails, try with latin-1 as fallback
xml_content = body.decode("latin-1")
# Basic validation that it's XML-like
if not xml_content.strip().startswith("<"):
raise HTTPException(
status_code=400, detail="ERROR: Content does not appear to be XML"
)
# Create logs directory for XML conversions
logs_dir = Path("logs/conversions_import")
if not logs_dir.exists():
logs_dir.mkdir(parents=True, mode=0o755, exist_ok=True)
_LOGGER.info("Created directory: %s", logs_dir)
# Generate filename with timestamp and authenticated user
username, _ = credentials_tupel
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
log_filename = logs_dir / f"xml_import_{username}_{timestamp}.xml"
# Save XML content to file
log_filename.write_text(xml_content, encoding="utf-8")
_LOGGER.info("XML file saved to %s by user %s", log_filename, username)
response_headers = {
"Content-Type": "application/xml; charset=utf-8",
"X-AlpineBits-Server-Accept-Encoding": "gzip",
}
return Response(
content="Xml received", headers=response_headers, status_code=200
)
except HTTPException:
raise
except Exception:
_LOGGER.exception("Error in handle_xml_upload")
raise HTTPException(status_code=500, detail="Error processing XML upload")
# UNUSED
@api_router.post("/admin/generate-api-key")
@limiter.limit("5/hour") # Very restrictive for admin operations
async def generate_new_api_key(
request: Request, admin_key: str = Depends(validate_api_key)
):
"""Admin endpoint to generate new API keys.
Requires admin API key and is heavily rate limited.
"""
if admin_key != "admin-key":
raise HTTPException(status_code=403, detail="Admin access required")
new_key = generate_api_key()
_LOGGER.info(f"Generated new API key (requested by: {admin_key})")
return {
"status": "success",
"message": "New API key generated",
"api_key": new_key,
"timestamp": datetime.now().isoformat(),
"note": "Store this key securely - it won't be shown again",
}
# TODO Bit sketchy. May need requests-toolkit in the future
def parse_multipart_data(content_type: str, body: bytes) -> dict[str, Any]:
"""Parse multipart/form-data from raw request body.
This is a simplified parser for the AlpineBits use case.
"""
if "multipart/form-data" not in content_type:
@@ -515,8 +715,7 @@ async def alpinebits_server_handshake(
credentials_tupel: tuple = Depends(validate_basic_auth),
dbsession=Depends(get_async_session),
):
"""
AlpineBits server endpoint implementing the handshake protocol.
"""AlpineBits server endpoint implementing the handshake protocol.
This endpoint handles:
- Protocol version negotiation via X-AlpineBits-ClientProtocolVersion header
@@ -542,12 +741,12 @@ async def alpinebits_server_handshake(
"No X-AlpineBits-ClientProtocolVersion header found, assuming pre-2013-04"
)
else:
_LOGGER.info(f"Client protocol version: {client_protocol_version}")
_LOGGER.info("Client protocol version: %s", client_protocol_version)
# Optional client ID
client_id = request.headers.get("X-AlpineBits-ClientID")
if client_id:
_LOGGER.info(f"Client ID: {client_id}")
_LOGGER.info("Client ID: %s", client_id)
# Check content encoding
content_encoding = request.headers.get("Content-Encoding")
@@ -559,50 +758,14 @@ async def alpinebits_server_handshake(
# Get content type before processing
content_type = request.headers.get("Content-Type", "")
_LOGGER.info(f"Content-Type: {content_type}")
_LOGGER.info(f"Content-Encoding: {content_encoding}")
_LOGGER.info("Content-Type: %s", content_type)
_LOGGER.info("Content-Encoding: %s", content_encoding)
# Get request body
body = await request.body()
# Decompress if needed
if is_compressed:
try:
body = gzip.decompress(body)
except Exception as e:
raise HTTPException(
status_code=400,
detail=f"ERROR: Failed to decompress gzip content: {str(e)}",
)
# Check content type (after decompression)
if (
"multipart/form-data" not in content_type
and "application/x-www-form-urlencoded" not in content_type
):
raise HTTPException(
status_code=400,
detail="ERROR: Content-Type must be multipart/form-data or application/x-www-form-urlencoded",
)
# Parse multipart data
if "multipart/form-data" in content_type:
try:
form_data = parse_multipart_data(content_type, body)
except Exception as e:
raise HTTPException(
status_code=400,
detail=f"ERROR: Failed to parse multipart/form-data: {str(e)}",
)
elif "application/x-www-form-urlencoded" in content_type:
# Parse as urlencoded
form_data = dict(urllib.parse.parse_qsl(body.decode("utf-8")))
else:
raise HTTPException(
status_code=400,
detail="ERROR: Content-Type must be multipart/form-data or application/x-www-form-urlencoded",
)
form_data = validate_alpinebits_body(is_compressed, content_type, body)
# Check for required action parameter
action = form_data.get("action")
@@ -622,7 +785,9 @@ async def alpinebits_server_handshake(
username, password = credentials_tupel
client_info = AlpineBitsClientInfo(username=username, password=password, client_id=client_id)
client_info = AlpineBitsClientInfo(
username=username, password=password, client_id=client_id
)
# Create successful handshake response
response = await server.handle_request(
@@ -642,6 +807,11 @@ async def alpinebits_server_handshake(
"X-AlpineBits-Server-Version": "2024-10",
}
if is_compressed:
# Compress response if client sent compressed request
response_xml = gzip.compress(response_xml.encode("utf-8"))
headers["Content-Encoding"] = "gzip"
return Response(
content=response_xml, status_code=response.status_code, headers=headers
)
@@ -650,15 +820,57 @@ async def alpinebits_server_handshake(
# Re-raise HTTP exceptions (auth errors, etc.)
raise
except Exception as e:
_LOGGER.error(f"Error in AlpineBits handshake: {str(e)}")
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
_LOGGER.error(f"Error in AlpineBits handshake: {e!s}")
raise HTTPException(status_code=500, detail="Internal server error")
def validate_alpinebits_body(is_compressed, content_type, body):
"""Check if the body conforms to AlpineBits expectations."""
if is_compressed:
try:
body = gzip.decompress(body)
except Exception:
raise HTTPException(
status_code=400,
detail="ERROR: Failed to decompress gzip content",
)
# Check content type (after decompression)
if (
"multipart/form-data" not in content_type
and "application/x-www-form-urlencoded" not in content_type
):
raise HTTPException(
status_code=400,
detail="ERROR: Content-Type must be multipart/form-data or application/x-www-form-urlencoded",
)
# Parse multipart data
if "multipart/form-data" in content_type:
try:
form_data = parse_multipart_data(content_type, body)
except Exception:
raise HTTPException(
status_code=400,
detail="ERROR: Failed to parse multipart/form-data",
)
elif "application/x-www-form-urlencoded" in content_type:
# Parse as urlencoded
form_data = dict(urllib.parse.parse_qsl(body.decode("utf-8")))
else:
raise HTTPException(
status_code=400,
detail="ERROR: Content-Type must be multipart/form-data or application/x-www-form-urlencoded",
)
return form_data
@api_router.get("/admin/stats")
@limiter.limit("10/minute")
async def get_api_stats(request: Request, admin_key: str = Depends(validate_api_key)):
"""
Admin endpoint to get API usage statistics.
"""Admin endpoint to get API usage statistics.
Requires admin API key.
"""
if admin_key != "admin-key":
@@ -683,16 +895,12 @@ app.include_router(api_router)
@app.get("/", response_class=HTMLResponse)
async def landing_page():
"""
Serve the under construction landing page at the root route
"""
"""Serve the under construction landing page at the root route."""
try:
# Get the path to the HTML file
import os
html_path = os.path.join(os.path.dirname(__file__), "templates", "index.html")
with open(html_path, "r", encoding="utf-8") as f:
with open(html_path, encoding="utf-8") as f:
html_content = f.read()
return HTMLResponse(content=html_content, status_code=200)

View File

@@ -1,13 +1,12 @@
import os
import secrets
from typing import Optional
from fastapi import HTTPException, Security, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
import hashlib
import hmac
from datetime import datetime, timedelta
import logging
import os
import secrets
from dotenv import load_dotenv
from fastapi import HTTPException, Security, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
# Load environment variables from .env file
load_dotenv()
@@ -31,6 +30,11 @@ if os.getenv("ADMIN_API_KEY"):
API_KEYS["admin-key"] = os.getenv("ADMIN_API_KEY")
def generate_unique_id() -> str:
"""Generate a unique ID with max length 35 characters"""
return secrets.token_urlsafe(26)[:35] # 26 bytes -> 35 chars in base64url
def generate_api_key() -> str:
"""Generate a secure API key"""
return f"sk_live_{secrets.token_urlsafe(32)}"
@@ -39,8 +43,7 @@ def generate_api_key() -> str:
def validate_api_key(
credentials: HTTPAuthorizationCredentials = Security(security),
) -> str:
"""
Validate API key from Authorization header.
"""Validate API key from Authorization header.
Expected format: Authorization: Bearer your_api_key_here
"""
token = credentials.credentials
@@ -60,8 +63,7 @@ def validate_api_key(
def validate_wix_signature(payload: bytes, signature: str, secret: str) -> bool:
"""
Validate Wix webhook signature for additional security.
"""Validate Wix webhook signature for additional security.
Wix signs their webhooks with HMAC-SHA256.
"""
if not signature or not secret:
@@ -69,8 +71,7 @@ def validate_wix_signature(payload: bytes, signature: str, secret: str) -> bool:
try:
# Remove 'sha256=' prefix if present
if signature.startswith("sha256="):
signature = signature[7:]
signature = signature.removeprefix("sha256=")
# Calculate expected signature
expected_signature = hmac.new(
@@ -90,7 +91,7 @@ class APIKeyAuth:
def __init__(self, api_keys: dict):
self.api_keys = api_keys
def authenticate(self, api_key: str) -> Optional[str]:
def authenticate(self, api_key: str) -> str | None:
"""Authenticate an API key and return the key name if valid"""
for key_name, valid_key in self.api_keys.items():
if secrets.compare_digest(api_key, valid_key):

View File

@@ -1,25 +1,20 @@
import os
from pathlib import Path
from typing import Any, Dict, List
from annotatedyaml.loader import (
HAS_C_LOADER,
JSON_TYPE,
LoaderType,
Secrets,
add_constructor,
)
from annotatedyaml.loader import (
load_yaml as load_annotated_yaml,
load_yaml_dict as load_annotated_yaml_dict,
parse_yaml as parse_annotated_yaml,
secret_yaml as annotated_secret_yaml,
)
from voluptuous import (
Schema,
Required,
PREVENT_EXTRA,
All,
Length,
PREVENT_EXTRA,
MultipleInvalid,
Optional,
Required,
Schema,
)
# --- Voluptuous schemas ---
@@ -101,7 +96,7 @@ class Config:
return self.basic_auth["hotel_name"]
@property
def users(self) -> List[Dict[str, str]]:
def users(self) -> list[dict[str, str]]:
return self.basic_auth["users"]

View File

@@ -1,8 +1,8 @@
from sqlalchemy import Column, Integer, String, Date, Boolean, ForeignKey, DateTime
from sqlalchemy.orm import declarative_base, relationship
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
import os
from sqlalchemy import Boolean, Column, Date, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import declarative_base, relationship
Base = declarative_base()
@@ -44,14 +44,14 @@ class Reservation(Base):
__tablename__ = "reservations"
id = Column(Integer, primary_key=True)
customer_id = Column(Integer, ForeignKey("customers.id"))
form_id = Column(String, unique=True)
unique_id = Column(String, unique=True)
md5_unique_id = Column(String(32), unique=True) # max length 32 guaranteed
start_date = Column(Date)
end_date = Column(Date)
num_adults = Column(Integer)
num_children = Column(Integer)
children_ages = Column(String) # comma-separated
offer = Column(String)
utm_comment = Column(String)
created_at = Column(DateTime)
# Add all UTM fields and user comment for XML
utm_source = Column(String)
@@ -68,11 +68,12 @@ class Reservation(Base):
customer = relationship("Customer", back_populates="reservations")
class HashedCustomer(Base):
__tablename__ = "hashed_customers"
# Table for tracking acknowledged requests by client
class AckedRequest(Base):
__tablename__ = "acked_requests"
id = Column(Integer, primary_key=True)
customer_id = Column(Integer)
hashed_email = Column(String)
hashed_phone = Column(String)
hashed_name = Column(String)
redacted_at = Column(DateTime)
client_id = Column(String, index=True)
unique_id = Column(
String, index=True
) # Should match Reservation.form_id or another unique field
timestamp = Column(DateTime)

View File

@@ -85,6 +85,7 @@ __all__ = [
"CommentName1",
"CommentName2",
"ContactInfoLocation",
"DefSendComplete",
"DescriptionName",
"DescriptionTextFormat1",
"DescriptionTextFormat2",
@@ -103,6 +104,7 @@ __all__ = [
"MealsIncludedMealPlanIndicator",
"MultimediaDescriptionInfoCode1",
"MultimediaDescriptionInfoCode2",
"OccupancyAgeQualifyingCode",
"OtaHotelDescriptiveContentNotifRq",
"OtaHotelDescriptiveContentNotifRs",
"OtaHotelDescriptiveInfoRq",
@@ -123,7 +125,6 @@ __all__ = [
"OtaPingRs",
"OtaReadRq",
"OtaResRetrieveRs",
"OccupancyAgeQualifyingCode",
"PositionAltitudeUnitOfMeasureCode",
"PrerequisiteInventoryInvType",
"ProfileProfileType",
@@ -150,12 +151,11 @@ __all__ = [
"TextTextFormat2",
"TimeUnitType",
"TypeRoomRoomType",
"UrlType",
"UniqueIdInstance",
"UniqueIdType1",
"UniqueIdType2",
"UniqueIdType3",
"UrlType",
"VideoItemCategory",
"WarningStatus",
"DefSendComplete",
]

File diff suppressed because it is too large Load Diff

View File

@@ -1,52 +1,36 @@
import asyncio
import json
import logging
from .alpinebits_guestrequests import ResGuest, RoomStay
from .generated import alpinebits as ab
from io import BytesIO
import sys
from datetime import datetime, timezone, date
import re
from xsdata_pydantic.bindings import XmlSerializer
from .alpine_bits_helpers import (
CustomerData,
GuestCountsFactory,
HotelReservationIdData,
AlpineBitsFactory,
OtaMessageType,
CommentData,
CommentsData,
CommentListItemData,
)
from .generated import alpinebits as ab
from datetime import datetime, timezone
import os
from datetime import UTC, date, datetime
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from .alpine_bits_helpers import (
AlpineBitsFactory,
CommentData,
CommentsData,
CommentListItemData,
CommentsData,
CustomerData,
GuestCountsFactory,
HotelReservationIdData,
PhoneTechType,
AlpineBitsFactory,
OtaMessageType,
PhoneTechType,
)
from .config_loader import load_config
# DB and config
from .db import (
Base,
Customer as DBCustomer,
Reservation as DBReservation,
HashedCustomer,
get_database_url,
)
from .config_loader import load_config
import hashlib
import json
import os
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
import asyncio
from alpine_bits_python import db
from .db import (
Customer as DBCustomer,
)
from .db import (
Reservation as DBReservation,
)
from .generated import alpinebits as ab
# Configure logging
logging.basicConfig(level=logging.INFO)
@@ -101,7 +85,7 @@ async def main():
os.path.dirname(__file__),
"../../test_data/wix_test_data_20250928_132611.json",
)
with open(json_path, "r", encoding="utf-8") as f:
with open(json_path, encoding="utf-8") as f:
wix_data = json.load(f)
data = wix_data["data"]["data"]
@@ -197,7 +181,7 @@ async def main():
children_ages=",".join(str(a) for a in children_ages),
offer=offer,
utm_comment=utm_comment,
created_at=datetime.now(timezone.utc),
created_at=datetime.now(UTC),
utm_source=data.get("field:utm_source"),
utm_medium=data.get("field:utm_medium"),
utm_campaign=data.get("field:utm_campaign"),
@@ -256,7 +240,7 @@ def create_xml_from_db(customer: DBCustomer, reservation: DBReservation):
# UniqueID
unique_id = ab.OtaResRetrieveRs.ReservationsList.HotelReservation.UniqueId(
type_value=ab.UniqueIdType2.VALUE_14, id=reservation.form_id
type_value=ab.UniqueIdType2.VALUE_14, id=reservation.unique_id
)
# TimeSpan
@@ -330,7 +314,7 @@ def create_xml_from_db(customer: DBCustomer, reservation: DBReservation):
)
hotel_reservation = ab.OtaResRetrieveRs.ReservationsList.HotelReservation(
create_date_time=datetime.now(timezone.utc).isoformat(),
create_date_time=datetime.now(UTC).isoformat(),
res_status=ab.HotelReservationResStatus.REQUESTED,
room_stay_reservation="true",
unique_id=unique_id,
@@ -361,13 +345,13 @@ def create_xml_from_db(customer: DBCustomer, reservation: DBReservation):
with open("output.xml", "w", encoding="utf-8") as outfile:
outfile.write(xml_string)
print("✅ XML serialization successful!")
print(f"Generated XML written to output.xml")
print("Generated XML written to output.xml")
print("\n📄 Generated XML:")
print(xml_string)
from xsdata_pydantic.bindings import XmlParser
parser = XmlParser()
with open("output.xml", "r", encoding="utf-8") as infile:
with open("output.xml", encoding="utf-8") as infile:
xml_content = infile.read()
parsed_result = parser.from_string(xml_content, ab.OtaResRetrieveRs)
print("✅ Round-trip validation successful!")

View File

@@ -1,6 +1,6 @@
from typing import Dict, List, Any, Optional
from typing import Any
from pydantic import BaseModel, Field
from datetime import datetime
class AlpineBitsHandshakeRequest(BaseModel):
@@ -9,64 +9,64 @@ class AlpineBitsHandshakeRequest(BaseModel):
action: str = Field(
..., description="Action parameter, typically 'OTA_Ping:Handshaking'"
)
request_xml: Optional[str] = Field(None, description="XML request document")
request_xml: str | None = Field(None, description="XML request document")
class ContactName(BaseModel):
"""Contact name structure"""
first: Optional[str] = None
last: Optional[str] = None
first: str | None = None
last: str | None = None
class ContactAddress(BaseModel):
"""Contact address structure"""
street: Optional[str] = None
city: Optional[str] = None
state: Optional[str] = None
country: Optional[str] = None
postalCode: Optional[str] = None
street: str | None = None
city: str | None = None
state: str | None = None
country: str | None = None
postalCode: str | None = None
class Contact(BaseModel):
"""Contact information from Wix form"""
name: Optional[ContactName] = None
email: Optional[str] = None
locale: Optional[str] = None
company: Optional[str] = None
birthdate: Optional[str] = None
labelKeys: Optional[Dict[str, Any]] = None
contactId: Optional[str] = None
address: Optional[ContactAddress] = None
jobTitle: Optional[str] = None
imageUrl: Optional[str] = None
updatedDate: Optional[str] = None
phone: Optional[str] = None
createdDate: Optional[str] = None
name: ContactName | None = None
email: str | None = None
locale: str | None = None
company: str | None = None
birthdate: str | None = None
labelKeys: dict[str, Any] | None = None
contactId: str | None = None
address: ContactAddress | None = None
jobTitle: str | None = None
imageUrl: str | None = None
updatedDate: str | None = None
phone: str | None = None
createdDate: str | None = None
class SubmissionPdf(BaseModel):
"""PDF submission structure"""
url: Optional[str] = None
filename: Optional[str] = None
url: str | None = None
filename: str | None = None
class WixFormSubmission(BaseModel):
"""Model for Wix form submission data"""
formName: str
submissions: List[Dict[str, Any]] = Field(default_factory=list)
submissions: list[dict[str, Any]] = Field(default_factory=list)
submissionTime: str
formFieldMask: List[str] = Field(default_factory=list)
formFieldMask: list[str] = Field(default_factory=list)
submissionId: str
contactId: str
submissionsLink: str
submissionPdf: Optional[SubmissionPdf] = None
submissionPdf: SubmissionPdf | None = None
formId: str
contact: Optional[Contact] = None
contact: Contact | None = None
# Dynamic form fields - these will capture all field:* entries
class Config:

View File

@@ -1,10 +1,11 @@
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
from fastapi import Request
import redis
import os
import logging
import os
import redis
from fastapi import Request
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address
logger = logging.getLogger(__name__)
@@ -18,8 +19,7 @@ REDIS_URL = os.getenv("REDIS_URL", None)
def get_remote_address_with_forwarded(request: Request):
"""
Get client IP address, considering forwarded headers from proxies/load balancers
"""Get client IP address, considering forwarded headers from proxies/load balancers
"""
# Check for forwarded headers (common in production behind proxies)
forwarded_for = request.headers.get("X-Forwarded-For")
@@ -58,8 +58,7 @@ else:
def get_api_key_identifier(request: Request) -> str:
"""
Get identifier for rate limiting based on API key if available, otherwise IP
"""Get identifier for rate limiting based on API key if available, otherwise IP
This allows different rate limits per API key
"""
# Try to get API key from Authorization header

View File

@@ -1,11 +1,10 @@
#!/usr/bin/env python3
"""
Startup script for the Wix Form Handler API
"""Startup script for the Wix Form Handler API
"""
import os
import uvicorn
from .api import app
if __name__ == "__main__":
db_path = "alpinebits.db" # Adjust path if needed

View File

@@ -0,0 +1,253 @@
"""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.
"""
import hashlib
from datetime import date
from enum import Enum
from pydantic import BaseModel, EmailStr, Field, field_validator, model_validator
# phonetechtype enum 1,3,5 voice, fax, mobile
class PhoneTechType(Enum):
VOICE = "1"
FAX = "3"
MOBILE = "5"
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 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."""
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[tuple[str, None | PhoneTechType]] = 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 = Field(None, min_length=1, max_length=64)
res_id_source: str | None = Field(None, min_length=1, max_length=64)
res_id_source_context: str | None = Field(None, min_length=1, max_length=64)
@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}
# 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

View File

@@ -1,135 +0,0 @@
#!/usr/bin/env python3
"""
Configuration and setup script for the Wix Form Handler API
"""
import os
import sys
import secrets
# Add parent directory to path to import from src
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from alpine_bits_python.auth import generate_api_key
def generate_secure_keys():
"""Generate secure API keys for the application"""
print("🔐 Generating Secure API Keys")
print("=" * 50)
# Generate API keys
wix_api_key = generate_api_key()
admin_api_key = generate_api_key()
webhook_secret = secrets.token_urlsafe(32)
print(f"🔑 Wix Webhook API Key: {wix_api_key}")
print(f"🔐 Admin API Key: {admin_api_key}")
print(f"🔒 Webhook Secret: {webhook_secret}")
print("\n📋 Environment Variables")
print("-" * 30)
print(f"export WIX_API_KEY='{wix_api_key}'")
print(f"export ADMIN_API_KEY='{admin_api_key}'")
print(f"export WIX_WEBHOOK_SECRET='{webhook_secret}'")
print(f"export REDIS_URL='redis://localhost:6379' # Optional for production")
print("\n🔧 .env File Content")
print("-" * 20)
print(f"WIX_API_KEY={wix_api_key}")
print(f"ADMIN_API_KEY={admin_api_key}")
print(f"WIX_WEBHOOK_SECRET={webhook_secret}")
print("REDIS_URL=redis://localhost:6379")
# Optionally write to .env file
create_env = input("\n❓ Create .env file? (y/n): ").lower().strip()
if create_env == "y":
# Create .env in the project root (two levels up from scripts)
env_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env"
)
with open(env_path, "w") as f:
f.write(f"WIX_API_KEY={wix_api_key}\n")
f.write(f"ADMIN_API_KEY={admin_api_key}\n")
f.write(f"WIX_WEBHOOK_SECRET={webhook_secret}\n")
f.write("REDIS_URL=redis://localhost:6379\n")
print(f"✅ .env file created at {env_path}!")
print("⚠️ Add .env to your .gitignore file!")
print("\n🌐 Wix Configuration")
print("-" * 20)
print("1. In your Wix site, go to Settings > Webhooks")
print("2. Add webhook URL: https://yourdomain.com/webhook/wix-form")
print("3. Add custom header: Authorization: Bearer " + wix_api_key)
print("4. Optionally configure webhook signature with the secret above")
return {
"wix_api_key": wix_api_key,
"admin_api_key": admin_api_key,
"webhook_secret": webhook_secret,
}
def check_security_setup():
"""Check current security configuration"""
print("🔍 Security Configuration Check")
print("=" * 40)
# Check environment variables
wix_key = os.getenv("WIX_API_KEY")
admin_key = os.getenv("ADMIN_API_KEY")
webhook_secret = os.getenv("WIX_WEBHOOK_SECRET")
redis_url = os.getenv("REDIS_URL")
print("Environment Variables:")
print(f" WIX_API_KEY: {'✅ Set' if wix_key else '❌ Not set'}")
print(f" ADMIN_API_KEY: {'✅ Set' if admin_key else '❌ Not set'}")
print(f" WIX_WEBHOOK_SECRET: {'✅ Set' if webhook_secret else '❌ Not set'}")
print(f" REDIS_URL: {'✅ Set' if redis_url else '⚠️ Optional (using in-memory)'}")
# Security recommendations
print("\n🛡️ Security Recommendations:")
if not wix_key:
print(" ❌ Set WIX_API_KEY environment variable")
else:
if len(wix_key) < 32:
print(" ⚠️ WIX_API_KEY should be longer for better security")
else:
print(" ✅ WIX_API_KEY looks secure")
if not admin_key:
print(" ❌ Set ADMIN_API_KEY environment variable")
elif wix_key and admin_key == wix_key:
print(" ❌ Admin and Wix keys should be different")
else:
print(" ✅ ADMIN_API_KEY configured")
if not webhook_secret:
print(" ⚠️ Consider setting WIX_WEBHOOK_SECRET for signature validation")
else:
print(" ✅ Webhook signature validation enabled")
print("\n🚀 Production Checklist:")
print(" - Use HTTPS in production")
print(" - Set up Redis for distributed rate limiting")
print(" - Configure proper CORS origins")
print(" - Set up monitoring and logging")
print(" - Regular key rotation")
if __name__ == "__main__":
print("🔐 Wix Form Handler API - Security Setup")
print("=" * 50)
choice = input(
"Choose an option:\n1. Generate new API keys\n2. Check current setup\n\nEnter choice (1 or 2): "
).strip()
if choice == "1":
generate_secure_keys()
elif choice == "2":
check_security_setup()
else:
print("Invalid choice. Please run again and choose 1 or 2.")

View File

@@ -1,221 +0,0 @@
#!/usr/bin/env python3
"""
Test script for the Secure Wix Form Handler API
"""
import asyncio
import aiohttp
import json
import os
import sys
from datetime import datetime
# Add parent directory to path to import from src
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# API Configuration
BASE_URL = "http://localhost:8000"
# API Keys for testing - replace with your actual keys
TEST_API_KEY = os.getenv("WIX_API_KEY", "sk_live_your_secure_api_key_here")
ADMIN_API_KEY = os.getenv("ADMIN_API_KEY", "sk_admin_your_admin_key_here")
# Sample Wix form data based on your example
SAMPLE_WIX_DATA = {
"formName": "Contact Form",
"submissions": [],
"submissionTime": "2024-03-20T10:30:00+00:00",
"formFieldMask": ["email", "name", "phone"],
"submissionId": "test-submission-123",
"contactId": "test-contact-456",
"submissionsLink": "https://www.wix.app/forms/test-form/submissions",
"submissionPdf": {
"url": "https://example.com/submission.pdf",
"filename": "submission.pdf",
},
"formId": "test-form-789",
"field:email_5139": "test@example.com",
"field:first_name_abae": "John",
"field:last_name_d97c": "Doe",
"field:phone_4c77": "+1234567890",
"field:anrede": "Herr",
"field:anzahl_kinder": "2",
"field:alter_kind_3": "8",
"field:alter_kind_4": "12",
"field:long_answer_3524": "This is a long answer field with more details about the inquiry.",
"contact": {
"name": {"first": "John", "last": "Doe"},
"email": "test@example.com",
"locale": "de",
"company": "Test Company",
"birthdate": "1985-05-15",
"labelKeys": {},
"contactId": "test-contact-456",
"address": {
"street": "Test Street 123",
"city": "Test City",
"country": "Germany",
"postalCode": "12345",
},
"jobTitle": "Manager",
"phone": "+1234567890",
"createdDate": "2024-03-20T10:00:00.000Z",
"updatedDate": "2024-03-20T10:30:00.000Z",
},
}
async def test_api():
"""Test the API endpoints with authentication"""
headers_with_auth = {
"Content-Type": "application/json",
"Authorization": f"Bearer {TEST_API_KEY}",
}
admin_headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {ADMIN_API_KEY}",
}
async with aiohttp.ClientSession() as session:
# Test health endpoint (no auth required)
print("1. Testing health endpoint (no auth)...")
try:
async with session.get(f"{BASE_URL}/api/health") as response:
result = await response.json()
print(f" ✅ Health check: {response.status} - {result.get('status')}")
except Exception as e:
print(f" ❌ Health check failed: {e}")
# Test root endpoint (no auth required)
print("\n2. Testing root endpoint (no auth)...")
try:
async with session.get(f"{BASE_URL}/api/") as response:
result = await response.json()
print(f" ✅ Root: {response.status} - {result.get('message')}")
except Exception as e:
print(f" ❌ Root endpoint failed: {e}")
# Test webhook endpoint without auth (should fail)
print("\n3. Testing webhook endpoint WITHOUT auth (should fail)...")
try:
async with session.post(
f"{BASE_URL}/api/webhook/wix-form",
json=SAMPLE_WIX_DATA,
headers={"Content-Type": "application/json"},
) as response:
result = await response.json()
if response.status == 401:
print(
f" ✅ Correctly rejected: {response.status} - {result.get('detail')}"
)
else:
print(f" ❌ Unexpected response: {response.status} - {result}")
except Exception as e:
print(f" ❌ Test failed: {e}")
# Test webhook endpoint with valid auth
print("\n4. Testing webhook endpoint WITH valid auth...")
try:
async with session.post(
f"{BASE_URL}/api/webhook/wix-form",
json=SAMPLE_WIX_DATA,
headers=headers_with_auth,
) as response:
result = await response.json()
if response.status == 200:
print(
f" ✅ Webhook success: {response.status} - {result.get('status')}"
)
else:
print(f" ❌ Webhook failed: {response.status} - {result}")
except Exception as e:
print(f" ❌ Webhook test failed: {e}")
# Test test endpoint with auth
print("\n5. Testing simple test endpoint WITH auth...")
try:
async with session.post(
f"{BASE_URL}/api/webhook/wix-form/test",
json={"test": "data", "timestamp": datetime.now().isoformat()},
headers=headers_with_auth,
) as response:
result = await response.json()
if response.status == 200:
print(
f" ✅ Test endpoint: {response.status} - {result.get('status')}"
)
else:
print(f" ❌ Test endpoint failed: {response.status} - {result}")
except Exception as e:
print(f" ❌ Test endpoint failed: {e}")
# Test rate limiting by making multiple rapid requests
print("\n6. Testing rate limiting (making 5 rapid requests)...")
rate_limit_test_count = 0
for i in range(5):
try:
async with session.get(f"{BASE_URL}/api/health") as response:
if response.status == 200:
rate_limit_test_count += 1
elif response.status == 429:
print(f" ✅ Rate limit triggered on request {i + 1}")
break
except Exception as e:
print(f" ❌ Rate limit test failed: {e}")
break
if rate_limit_test_count == 5:
print(" No rate limit reached (normal for low request volume)")
# Test admin endpoint (if admin key is configured)
print("\n7. Testing admin stats endpoint...")
try:
async with session.get(
f"{BASE_URL}/api/admin/stats", headers=admin_headers
) as response:
result = await response.json()
if response.status == 200:
print(
f" ✅ Admin stats: {response.status} - {result.get('status')}"
)
elif response.status == 401:
print(
f" ⚠️ Admin access denied (API key not configured): {result.get('detail')}"
)
else:
print(f" ❌ Admin endpoint failed: {response.status} - {result}")
except Exception as e:
print(f" ❌ Admin test failed: {e}")
if __name__ == "__main__":
print("🔒 Testing Secure Wix Form Handler API...")
print("=" * 60)
print("📍 API URL:", BASE_URL)
print(
"🔑 Using API Key:",
TEST_API_KEY[:20] + "..." if len(TEST_API_KEY) > 20 else TEST_API_KEY,
)
print(
"🔐 Using Admin Key:",
ADMIN_API_KEY[:20] + "..." if len(ADMIN_API_KEY) > 20 else ADMIN_API_KEY,
)
print("=" * 60)
print("Make sure the API is running with: python3 run_api.py")
print("-" * 60)
try:
asyncio.run(test_api())
print("\n" + "=" * 60)
print("✅ Testing completed!")
print("\n📋 Quick Setup Reminder:")
print("1. Set environment variables:")
print(" export WIX_API_KEY='your_secure_api_key'")
print(" export ADMIN_API_KEY='your_admin_key'")
print("2. Configure Wix webhook URL: https://yourdomain.com/webhook/wix-form")
print("3. Add Authorization header: Bearer your_api_key")
except Exception as e:
print(f"\n❌ Error testing API: {e}")
print("Make sure the API server is running!")

View File

@@ -1,6 +1,7 @@
from ..generated.alpinebits import OtaPingRq, OtaPingRs
from xsdata_pydantic.bindings import XmlParser
from ..generated.alpinebits import OtaPingRs
def main():
# test parsing a ping request sample
@@ -9,7 +10,7 @@ def main():
"AlpineBits-HotelData-2024-10/files/samples/Handshake/Handshake-OTA_PingRS.xml"
)
with open(path, "r", encoding="utf-8") as f:
with open(path, encoding="utf-8") as f:
xml = f.read()
# Parse the XML into the request object

View File

@@ -1,6 +1,5 @@
#!/usr/bin/env python3
"""
Convenience launcher for the Wix Form Handler API
"""Convenience launcher for the Wix Form Handler API
"""
import os
@@ -11,4 +10,4 @@ src_dir = os.path.join(os.path.dirname(__file__), "src/alpine_bits_python")
# Run the API using uv
if __name__ == "__main__":
subprocess.run(["uv", "run", "python", os.path.join(src_dir, "run_api.py")])
subprocess.run(["uv", "run", "python", os.path.join(src_dir, "run_api.py")], check=False)

View File

@@ -1,67 +0,0 @@
#!/usr/bin/env python3
"""
Quick test to demonstrate how the ServerCapabilities automatically
discovers implemented vs unimplemented actions.
"""
from alpine_bits_python.alpinebits_server import (
ServerCapabilities,
AlpineBitsAction,
AlpineBitsActionName,
Version,
AlpineBitsResponse,
HttpStatusCode,
)
import asyncio
class NewImplementedAction(AlpineBitsAction):
"""A new action that IS implemented."""
def __init__(self):
self.name = AlpineBitsActionName.OTA_HOTEL_DESCRIPTIVE_INFO_INFO
self.version = Version.V2024_10
async def handle(
self, action: str, request_xml: str, version: Version
) -> AlpineBitsResponse:
"""This action is implemented."""
return AlpineBitsResponse("Implemented!", HttpStatusCode.OK)
class NewUnimplementedAction(AlpineBitsAction):
"""A new action that is NOT implemented (no handle override)."""
def __init__(self):
self.name = AlpineBitsActionName.OTA_HOTEL_DESCRIPTIVE_CONTENT_NOTIF_INFO
self.version = Version.V2024_10
# Notice: No handle method override - will use default "not implemented"
async def main():
print("🔍 Testing Action Discovery Logic")
print("=" * 50)
# Create capabilities and see what gets discovered
capabilities = ServerCapabilities()
print("📋 Actions found by discovery:")
for action_name in capabilities.get_supported_actions():
print(f"{action_name}")
print(f"\n📊 Total discovered: {len(capabilities.get_supported_actions())}")
# Test the new implemented action
implemented_action = NewImplementedAction()
result = await implemented_action.handle("test", "<xml/>", Version.V2024_10)
print(f"\n🟢 NewImplementedAction result: {result.xml_content}")
# Test the unimplemented action (should use default behavior)
unimplemented_action = NewUnimplementedAction()
result = await unimplemented_action.handle("test", "<xml/>", Version.V2024_10)
print(f"🔴 NewUnimplementedAction result: {result.xml_content}")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -1,9 +1,9 @@
#!/usr/bin/env python3
"""
Test the handshake functionality with the real AlpineBits sample file.
"""Test the handshake functionality with the real AlpineBits sample file.
"""
import asyncio
from alpine_bits_python.alpinebits_server import AlpineBitsServer
@@ -17,7 +17,6 @@ async def main():
# Read the sample handshake request
with open(
"AlpineBits-HotelData-2024-10/files/samples/Handshake/Handshake-OTA_PingRQ.xml",
"r",
) as f:
ping_request_xml = f.read()

View File

@@ -1,26 +1,21 @@
import pytest
from typing import Union
import sys
import os
# Add the src directory to the path so we can import our modules
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
from simplified_access import (
from alpine_bits_python.alpine_bits_helpers import (
AlpineBitsFactory,
CustomerData,
CustomerFactory,
ResGuestFactory,
HotelReservationIdData,
HotelReservationIdFactory,
AlpineBitsFactory,
PhoneTechType,
OtaMessageType,
NotifCustomer,
RetrieveCustomer,
NotifResGuests,
RetrieveResGuests,
NotifHotelReservationId,
NotifResGuests,
OtaMessageType,
PhoneTechType,
ResGuestFactory,
RetrieveCustomer,
RetrieveHotelReservationId,
RetrieveResGuests,
)

View File

View File

@@ -0,0 +1,455 @@
"""Tests for AlpineBits server read action.
This module tests the ReadAction handler which retrieves reservations
from the database and returns them as OTA_ResRetrieveRS XML.
"""
from datetime import UTC, date, datetime
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from xsdata.formats.dataclass.serializers.config import SerializerConfig
from xsdata_pydantic.bindings import XmlParser, XmlSerializer
from alpine_bits_python.alpine_bits_helpers import create_res_retrieve_response
from alpine_bits_python.alpinebits_server import AlpineBitsClientInfo
from alpine_bits_python.db import Base, Customer, Reservation
from alpine_bits_python.generated import OtaReadRq
from alpine_bits_python.generated.alpinebits import OtaResRetrieveRs
from alpine_bits_python.schemas import ReservationData
@pytest.fixture
async def test_db_engine():
"""Create an in-memory SQLite database for testing."""
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
echo=False,
)
# Create tables
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
# Cleanup
await engine.dispose()
@pytest.fixture
async def test_db_session(test_db_engine):
"""Create a test database session."""
async_session = async_sessionmaker(
test_db_engine,
class_=AsyncSession,
expire_on_commit=False,
)
async with async_session() as session:
yield session
@pytest.fixture
def sample_customer():
"""Create a sample customer for testing."""
return Customer(
id=1,
given_name="John",
surname="Doe",
contact_id="CONTACT-12345",
name_prefix="Mr.",
name_title="Jr.",
email_address="john.doe@example.com",
phone="+1234567890",
email_newsletter=True,
address_line="123 Main Street",
city_name="Anytown",
postal_code="12345",
country_code="US",
gender="Male",
birth_date="1980-01-01",
language="en",
address_catalog=False,
)
@pytest.fixture
def sample_reservation(sample_customer):
"""Create a sample reservation for testing."""
reservation = ReservationData(
unique_id="RES-2024-001",
start_date=date(2024, 12, 25),
end_date=date(2024, 12, 31),
num_adults=2,
num_children=1,
children_ages=[8],
offer="Christmas Special",
created_at=datetime.now(UTC),
utm_source="google",
utm_medium="cpc",
utm_campaign="winter2024",
utm_term="ski resort",
utm_content="ad1",
user_comment="Late check-in requested",
fbclid="",
gclid="abc123xyz",
hotel_code="HOTEL123",
hotel_name="Alpine Paradise Resort",
)
data = reservation.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
print(data)
return Reservation(
id=1,
customer_id=1,
**data,
customer=sample_customer,
)
@pytest.fixture
def minimal_customer():
"""Create a minimal customer with only required fields."""
return Customer(
id=2,
given_name="Jane",
surname="Smith",
contact_id="CONTACT-67890",
)
@pytest.fixture
def minimal_reservation(minimal_customer):
"""Create a minimal reservation with only required fields."""
reservation = ReservationData(
unique_id="RES-2024-002",
start_date=date(2025, 1, 15),
end_date=date(2025, 1, 20),
num_adults=1,
num_children=0,
children_ages=[],
hotel_code="HOTEL123",
created_at=datetime.now(UTC),
hotel_name="Alpine Paradise Resort",
)
data = reservation.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
return Reservation(
id=2,
customer_id=2,
**data,
customer=minimal_customer,
)
@pytest.fixture
def read_request_xml():
"""Sample OTA_ReadRQ XML request."""
return """<?xml version="1.0" encoding="UTF-8"?>
<OTA_ReadRQ xmlns="http://www.opentravel.org/OTA/2003/05"
EchoToken="12345"
TimeStamp="2024-10-07T10:00:00"
Version="8.000">
<ReadRequests>
<HotelReadRequest HotelCode="HOTEL123" HotelName="Alpine Paradise Resort">
<SelectionCriteria Start="2024-12-01" End="2025-01-31"/>
</HotelReadRequest>
</ReadRequests>
</OTA_ReadRQ>"""
@pytest.fixture
def read_request_xml_no_date_filter():
"""Sample OTA_ReadRQ XML request without date filter."""
return """<?xml version="1.0" encoding="UTF-8"?>
<OTA_ReadRQ xmlns="http://www.opentravel.org/OTA/2003/05"
EchoToken="12345"
TimeStamp="2024-10-07T10:00:00"
Version="8.000">
<ReadRequests>
<HotelReadRequest HotelCode="HOTEL123" HotelName="Alpine Paradise Resort"/>
</ReadRequests>
</OTA_ReadRQ>"""
@pytest.fixture
def test_config():
"""Test configuration with hotel credentials."""
return {
"hotels": [
{
"hotel_id": "HOTEL123",
"username": "testuser",
"password": "testpass",
}
]
}
@pytest.fixture
def client_info():
"""Sample client info for testing."""
return AlpineBitsClientInfo(
username="testuser",
password="testpass",
client_id="CLIENT-001",
)
class TestCreateResRetrieveResponse:
"""Test the create_res_retrieve_response function."""
def test_empty_list(self):
"""Test creating response with empty reservation list."""
response = create_res_retrieve_response([])
assert response is not None, "Response should not be None"
# check that response is of correct type
assert isinstance(response, OtaResRetrieveRs), (
"Response should be of type OtaResRetrieveRs"
)
assert hasattr(response, "success"), "Response should have success attribute"
assert hasattr(response, "reservations_list"), (
"Response should have reservations_list attribute"
)
def test_single_reservation(self, sample_reservation, sample_customer):
"""Test creating response with single reservation."""
reservation_pairs = [(sample_reservation, sample_customer)]
response = create_res_retrieve_response(reservation_pairs)
assert response is not None
assert hasattr(response, "reservations_list"), (
"Response should have reservations_list attribute"
)
assert hasattr(response.reservations_list, "hotel_reservation"), (
"reservations_list should have reservation attribute"
)
assert len(response.reservations_list.hotel_reservation) == 1
res: OtaResRetrieveRs.ReservationsList.HotelReservation = (
response.reservations_list.hotel_reservation[0]
)
assert res.unique_id is not None, "Reservation should have unique_id"
# Verify the response can be serialized to XML
config = SerializerConfig(
pretty_print=True, xml_declaration=True, encoding="UTF-8"
)
serializer = XmlSerializer(config=config)
xml_output = serializer.render(
response, ns_map={None: "http://www.opentravel.org/OTA/2003/05"}
)
assert xml_output is not None
# assert "RES-2024-001" in xml_output does not work due to hashing
assert "John" in xml_output
assert "Doe" in xml_output
assert "HOTEL123" in xml_output
def test_multiple_reservations(
self,
sample_reservation,
sample_customer,
minimal_reservation,
minimal_customer,
):
"""Test creating response with multiple reservations."""
reservation_pairs = [
(sample_reservation, sample_customer),
(minimal_reservation, minimal_customer),
]
response = create_res_retrieve_response(reservation_pairs)
assert response is not None
# Serialize to XML and verify both reservations are present
config = SerializerConfig(
pretty_print=True, xml_declaration=True, encoding="UTF-8"
)
serializer = XmlSerializer(config=config)
xml_output = serializer.render(
response, ns_map={None: "http://www.opentravel.org/OTA/2003/05"}
)
# assert "RES-2024-001" in xml_output
# assert "RES-2024-002" in xml_output
assert "John" in xml_output
assert "Jane" in xml_output
def test_reservation_with_children(self, sample_reservation, sample_customer):
"""Test reservation with children ages."""
sample_reservation.num_children = 2
sample_reservation.children_ages = "8,5"
reservation_pairs = [(sample_reservation, sample_customer)]
response = create_res_retrieve_response(reservation_pairs)
config = SerializerConfig(pretty_print=True)
serializer = XmlSerializer(config=config)
xml_output = serializer.render(
response, ns_map={None: "http://www.opentravel.org/OTA/2003/05"}
)
assert response is not None
# Children should be represented in guest counts
assert "GuestCount" in xml_output or "Child" in xml_output
class TestXMLParsing:
"""Test XML parsing and generation."""
def test_parse_read_request(self, read_request_xml):
"""Test parsing of OTA_ReadRQ XML."""
parser = XmlParser()
read_request = parser.from_string(read_request_xml, OtaReadRq)
assert read_request is not None
assert read_request.read_requests is not None
assert read_request.read_requests.hotel_read_request is not None
hotel_req = read_request.read_requests.hotel_read_request
assert hotel_req.hotel_code == "HOTEL123"
assert hotel_req.hotel_name == "Alpine Paradise Resort"
assert hotel_req.selection_criteria is not None
assert hotel_req.selection_criteria.start == "2024-12-01"
def test_parse_read_request_no_date(self, read_request_xml_no_date_filter):
"""Test parsing of OTA_ReadRQ without date filter."""
parser = XmlParser()
read_request = parser.from_string(read_request_xml_no_date_filter, OtaReadRq)
assert read_request is not None
hotel_req = read_request.read_requests.hotel_read_request
assert hotel_req.hotel_code == "HOTEL123"
assert hotel_req.selection_criteria is None
def test_serialize_retrieve_response(
self,
sample_reservation,
sample_customer,
):
"""Test serialization of retrieve response to XML."""
reservation_pairs = [(sample_reservation, sample_customer)]
response = create_res_retrieve_response(reservation_pairs)
config = SerializerConfig(
pretty_print=True, xml_declaration=True, encoding="UTF-8"
)
serializer = XmlSerializer(config=config)
xml_output = serializer.render(
response, ns_map={None: "http://www.opentravel.org/OTA/2003/05"}
)
# Verify it's valid XML
assert xml_output.startswith('<?xml version="1.0" encoding="UTF-8"?>')
assert "OTA_ResRetrieveRS" in xml_output
# Verify customer data is present
assert "John" in xml_output
assert "Doe" in xml_output
assert "john.doe@example.com" in xml_output
# Verify reservation data is present
# assert "RES-2024-001" in xml_output
assert "HOTEL123" in xml_output
class TestEdgeCases:
"""Test edge cases and error conditions."""
def test_customer_with_special_characters(self):
"""Test customer with special characters in name."""
customer = Customer(
id=99,
given_name="François",
surname="O'Brien-Smith",
contact_id="CONTACT-SPECIAL",
)
reservation = Reservation(
id=99,
customer_id=99,
unique_id="RES-SPECIAL",
start_date=date(2025, 1, 1),
end_date=date(2025, 1, 5),
num_adults=1,
num_children=0,
children_ages="",
hotel_code="HOTEL123",
created_at=datetime.now(UTC),
)
reservation_pairs = [(reservation, customer)]
response = create_res_retrieve_response(reservation_pairs)
config = SerializerConfig(pretty_print=True, encoding="UTF-8")
serializer = XmlSerializer(config=config)
xml_output = serializer.render(
response, ns_map={None: "http://www.opentravel.org/OTA/2003/05"}
)
assert response is not None
assert xml_output is not None
def test_reservation_with_all_utm_parameters(self):
"""Test reservation with all UTM tracking parameters."""
customer = Customer(
id=97,
given_name="Marketing",
surname="Test",
contact_id="CONTACT-97",
)
reservation = ReservationData(
unique_id="RES-UTM-TEST",
start_date=date(2025, 2, 1),
end_date=date(2025, 2, 7),
num_adults=2,
num_children=0,
children_ages=[],
hotel_code="HOTEL123",
created_at=datetime.now(UTC),
utm_source="facebook",
utm_medium="social",
utm_campaign="spring2025",
utm_term="luxury resort",
utm_content="carousel_ad",
fbclid="IwAR1234567890",
gclid="",
)
reservation_db = Reservation(
id=97,
customer_id=97,
**reservation.model_dump(exclude_none=True),
)
reservation_pairs = [(reservation_db, customer)]
response = create_res_retrieve_response(reservation_pairs)
config = SerializerConfig(pretty_print=True)
serializer = XmlSerializer(config=config)
xml_output = serializer.render(
response, ns_map={None: "http://www.opentravel.org/OTA/2003/05"}
)
assert response is not None
# UTM parameters should be in comments or other fields
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -0,0 +1,100 @@
import json
import pytest
from xsdata_pydantic.bindings import XmlParser
from alpine_bits_python.alpinebits_server import AlpineBitsClientInfo, AlpineBitsServer
from alpine_bits_python.generated.alpinebits import OtaPingRs
def extract_relevant_sections(xml_string):
# Remove version attribute value, keep only presence
# Use the same XmlParser as AlpineBitsServer
parser = XmlParser()
obj = parser.from_string(xml_string, OtaPingRs)
return obj
@pytest.mark.asyncio
async def test_ping_action_response_matches_expected():
with open("tests/test_data/Handshake-OTA_PingRQ.xml", encoding="utf-8") as f:
server = AlpineBitsServer()
with open("tests/test_data/Handshake-OTA_PingRQ.xml", encoding="utf-8") as f:
request_xml = f.read()
with open("tests/test_data/Handshake-OTA_PingRS.xml", encoding="utf-8") as f:
expected_xml = f.read()
client_info = AlpineBitsClientInfo(username="irrelevant", password="irrelevant")
response = await server.handle_request(
request_action_name="OTA_Ping:Handshaking",
request_xml=request_xml,
client_info=client_info,
version="2024-10",
)
actual_obj = extract_relevant_sections(response.xml_content)
expected_obj = extract_relevant_sections(expected_xml)
actual_matches = json.loads(actual_obj.warnings.warning[0].content[0])
expected_matches = json.loads(expected_obj.warnings.warning[0].content[0])
assert actual_matches == expected_matches, (
f"Expected warnings {expected_matches}, got {actual_matches}"
)
actual_capabilities = json.loads(actual_obj.echo_data)
expected_capabilities = json.loads(expected_obj.echo_data)
assert actual_capabilities == expected_capabilities, (
f"Expected echo data {expected_capabilities}, got {actual_capabilities}"
)
@pytest.mark.asyncio
async def test_ping_action_response_success():
server = AlpineBitsServer()
with open("tests/test_data/Handshake-OTA_PingRQ.xml", encoding="utf-8") as f:
request_xml = f.read()
client_info = AlpineBitsClientInfo(username="irrelevant", password="irrelevant")
response = await server.handle_request(
request_action_name="OTA_Ping:Handshaking",
request_xml=request_xml,
client_info=client_info,
version="2024-10",
)
assert response.status_code == 200
assert "<OTA_PingRS" in response.xml_content
assert "<Success" in response.xml_content
assert "Version=" in response.xml_content
@pytest.mark.asyncio
async def test_ping_action_response_version_arbitrary():
server = AlpineBitsServer()
with open("tests/test_data/Handshake-OTA_PingRQ.xml", encoding="utf-8") as f:
request_xml = f.read()
client_info = AlpineBitsClientInfo(username="irrelevant", password="irrelevant")
response = await server.handle_request(
request_action_name="OTA_Ping:Handshaking",
request_xml=request_xml,
client_info=client_info,
version="2022-10",
)
assert response.status_code == 200
assert "<OTA_PingRS" in response.xml_content
assert "Version=" in response.xml_content
@pytest.mark.asyncio
async def test_ping_action_response_invalid_action():
server = AlpineBitsServer()
with open("tests/test_data/Handshake-OTA_PingRQ.xml", encoding="utf-8") as f:
request_xml = f.read()
client_info = AlpineBitsClientInfo(username="irrelevant", password="irrelevant")
response = await server.handle_request(
request_action_name="InvalidAction",
request_xml=request_xml,
client_info=client_info,
version="2024-10",
)
assert response.status_code == 400
assert "Error" in response.xml_content

View File

@@ -0,0 +1,158 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
AlpineBits 2024-10
https://www.alpinebits.org/
Sample message file for a Handshake request
Changelog:
v. 2024-10 1.2 Example extended with all capabilities and two supported releases
v. 2024-10 1.1 Removed the OTA_Ping action
v. 2024-10 1.0 added supported version 2024-10 in the example
v. 2018-10 1.0 initial example
-->
<OTA_PingRQ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.opentravel.org/OTA/2003/05"
xsi:schemaLocation="http://www.opentravel.org/OTA/2003/05 OTA_PingRQ.xsd"
Version="8.000">
<EchoData>
{
"versions": [
{
"version": "2024-10",
"actions": [
{
"action": "action_OTA_Read"
},
{
"action": "action_OTA_HotelResNotif_GuestRequests"
},
{
"action": "action_OTA_HotelResNotif_GuestRequests_StatusUpdate"
},
{
"action": "action_OTA_HotelInvCountNotif",
"supports": [
"OTA_HotelInvCountNotif_accept_rooms",
"OTA_HotelInvCountNotif_accept_categories",
"OTA_HotelInvCountNotif_accept_deltas",
"OTA_HotelInvCountNotif_accept_out_of_market",
"OTA_HotelInvCountNotif_accept_out_of_order",
"OTA_HotelInvCountNotif_accept_complete_set",
"OTA_HotelInvCountNotif_accept_closing_seasons"
]
},
{
"action": "action_OTA_HotelDescriptiveContentNotif_Inventory",
"supports": [
"OTA_HotelDescriptiveContentNotif_Inventory_use_rooms",
"OTA_HotelDescriptiveContentNotif_Inventory_occupancy_children"
]
},
{
"action": "action_OTA_HotelDescriptiveContentNotif_Info"
},
{
"action": "action_OTA_HotelDescriptiveInfo_Inventory"
},
{
"action": "action_OTA_HotelDescriptiveInfo_Info"
},
{
"action": "action_OTA_HotelRatePlanNotif_RatePlans",
"supports": [
"OTA_HotelRatePlanNotif_accept_ArrivalDOW",
"OTA_HotelRatePlanNotif_accept_DepartureDOW",
"OTA_HotelRatePlanNotif_accept_RatePlan_BookingRule",
"OTA_HotelRatePlanNotif_accept_RatePlan_RoomType_BookingRule",
"OTA_HotelRatePlanNotif_accept_RatePlan_mixed_BookingRule",
"OTA_HotelRatePlanNotif_accept_Supplements",
"OTA_HotelRatePlanNotif_accept_FreeNightsOffers",
"OTA_HotelRatePlanNotif_accept_FamilyOffers",
"OTA_HotelRatePlanNotif_accept_full",
"OTA_HotelRatePlanNotif_accept_overlay",
"OTA_HotelRatePlanNotif_accept_RatePlanJoin",
"OTA_HotelRatePlanNotif_accept_OfferRule_BookingOffset",
"OTA_HotelRatePlanNotif_accept_OfferRule_DOWLOS"
]
},
{
"action": "action_OTA_HotelRatePlan_BaseRates",
"supports": [
"OTA_HotelRatePlan_BaseRates_deltas"
]
},
{
"action": "action_OTA_HotelPostEventNotif_EventReports"
}
]
},
{
"version": "2022-10",
"actions": [
{
"action": "action_OTA_Ping"
},
{
"action": "action_OTA_Read"
},
{
"action": "action_OTA_HotelResNotif_GuestRequests"
},
{
"action": "action_OTA_HotelResNotif_GuestRequests_StatusUpdate"
},
{
"action": "action_OTA_HotelInvCountNotif",
"supports": [
"OTA_HotelInvCountNotif_accept_rooms",
"OTA_HotelInvCountNotif_accept_categories",
"OTA_HotelInvCountNotif_accept_deltas",
"OTA_HotelInvCountNotif_accept_out_of_market",
"OTA_HotelInvCountNotif_accept_out_of_order",
"OTA_HotelInvCountNotif_accept_complete_set",
"OTA_HotelInvCountNotif_accept_closing_seasons"
]
},
{
"action": "action_OTA_HotelDescriptiveContentNotif_Inventory",
"supports": [
"OTA_HotelDescriptiveContentNotif_Inventory_use_rooms",
"OTA_HotelDescriptiveContentNotif_Inventory_occupancy_children"
]
},
{
"action": "action_OTA_HotelDescriptiveContentNotif_Info"
},
{
"action": "action_OTA_HotelDescriptiveInfo_Inventory"
},
{
"action": "action_OTA_HotelDescriptiveInfo_Info"
},
{
"action": "action_OTA_HotelRatePlanNotif_RatePlans",
"supports": [
"OTA_HotelRatePlanNotif_accept_ArrivalDOW",
"OTA_HotelRatePlanNotif_accept_DepartureDOW",
"OTA_HotelRatePlanNotif_accept_RatePlan_BookingRule",
"OTA_HotelRatePlanNotif_accept_RatePlan_RoomType_BookingRule",
"OTA_HotelRatePlanNotif_accept_RatePlan_mixed_BookingRule",
"OTA_HotelRatePlanNotif_accept_Supplements",
"OTA_HotelRatePlanNotif_accept_FreeNightsOffers",
"OTA_HotelRatePlanNotif_accept_FamilyOffers",
"OTA_HotelRatePlanNotif_accept_overlay",
"OTA_HotelRatePlanNotif_accept_RatePlanJoin",
"OTA_HotelRatePlanNotif_accept_OfferRule_BookingOffset",
"OTA_HotelRatePlanNotif_accept_OfferRule_DOWLOS"
]
}
]
}
]
}
</EchoData>
</OTA_PingRQ>

View File

@@ -0,0 +1,190 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
AlpineBits 2024-10
https://www.alpinebits.org/
Sample message file for a Handshake response
Changelog:
v. 2024-10 1.2 Example extended with all capabilities and two supported releases
v. 2024-10 1.1 Removed the OTA_Ping action
v. 2024-10 1.0 added supported version 2024-10 in the example
v. 2018-10 1.0 initial example
-->
<OTA_PingRS xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.opentravel.org/OTA/2003/05"
xsi:schemaLocation="http://www.opentravel.org/OTA/2003/05 OTA_PingRS.xsd"
Version="8.000">
<Success/>
<Warnings>
<Warning Type="11" Status="ALPINEBITS_HANDSHAKE">{
"versions": [
{
"version": "2024-10",
"actions": [
{
"action": "action_OTA_Read"
},
{
"action": "action_OTA_HotelResNotif_GuestRequests"
}
]
},
{
"version": "2022-10",
"actions": [
{
"action": "action_OTA_Ping"
},
{
"action": "action_OTA_Read"
},
{
"action": "action_OTA_HotelResNotif_GuestRequests"
}
]
}
]
}</Warning>
</Warnings>
<EchoData>
{
"versions": [
{
"version": "2024-10",
"actions": [
{
"action": "action_OTA_Read"
},
{
"action": "action_OTA_HotelResNotif_GuestRequests"
},
{
"action": "action_OTA_HotelResNotif_GuestRequests_StatusUpdate"
},
{
"action": "action_OTA_HotelInvCountNotif",
"supports": [
"OTA_HotelInvCountNotif_accept_rooms",
"OTA_HotelInvCountNotif_accept_categories",
"OTA_HotelInvCountNotif_accept_deltas",
"OTA_HotelInvCountNotif_accept_out_of_market",
"OTA_HotelInvCountNotif_accept_out_of_order",
"OTA_HotelInvCountNotif_accept_complete_set",
"OTA_HotelInvCountNotif_accept_closing_seasons"
]
},
{
"action": "action_OTA_HotelDescriptiveContentNotif_Inventory",
"supports": [
"OTA_HotelDescriptiveContentNotif_Inventory_use_rooms",
"OTA_HotelDescriptiveContentNotif_Inventory_occupancy_children"
]
},
{
"action": "action_OTA_HotelDescriptiveContentNotif_Info"
},
{
"action": "action_OTA_HotelDescriptiveInfo_Inventory"
},
{
"action": "action_OTA_HotelDescriptiveInfo_Info"
},
{
"action": "action_OTA_HotelRatePlanNotif_RatePlans",
"supports": [
"OTA_HotelRatePlanNotif_accept_ArrivalDOW",
"OTA_HotelRatePlanNotif_accept_DepartureDOW",
"OTA_HotelRatePlanNotif_accept_RatePlan_BookingRule",
"OTA_HotelRatePlanNotif_accept_RatePlan_RoomType_BookingRule",
"OTA_HotelRatePlanNotif_accept_RatePlan_mixed_BookingRule",
"OTA_HotelRatePlanNotif_accept_Supplements",
"OTA_HotelRatePlanNotif_accept_FreeNightsOffers",
"OTA_HotelRatePlanNotif_accept_FamilyOffers",
"OTA_HotelRatePlanNotif_accept_full",
"OTA_HotelRatePlanNotif_accept_overlay",
"OTA_HotelRatePlanNotif_accept_RatePlanJoin",
"OTA_HotelRatePlanNotif_accept_OfferRule_BookingOffset",
"OTA_HotelRatePlanNotif_accept_OfferRule_DOWLOS"
]
},
{
"action": "action_OTA_HotelRatePlan_BaseRates",
"supports": [
"OTA_HotelRatePlan_BaseRates_deltas"
]
},
{
"action": "action_OTA_HotelPostEventNotif_EventReports"
}
]
},
{
"version": "2022-10",
"actions": [
{
"action": "action_OTA_Ping"
},
{
"action": "action_OTA_Read"
},
{
"action": "action_OTA_HotelResNotif_GuestRequests"
},
{
"action": "action_OTA_HotelResNotif_GuestRequests_StatusUpdate"
},
{
"action": "action_OTA_HotelInvCountNotif",
"supports": [
"OTA_HotelInvCountNotif_accept_rooms",
"OTA_HotelInvCountNotif_accept_categories",
"OTA_HotelInvCountNotif_accept_deltas",
"OTA_HotelInvCountNotif_accept_out_of_market",
"OTA_HotelInvCountNotif_accept_out_of_order",
"OTA_HotelInvCountNotif_accept_complete_set",
"OTA_HotelInvCountNotif_accept_closing_seasons"
]
},
{
"action": "action_OTA_HotelDescriptiveContentNotif_Inventory",
"supports": [
"OTA_HotelDescriptiveContentNotif_Inventory_use_rooms",
"OTA_HotelDescriptiveContentNotif_Inventory_occupancy_children"
]
},
{
"action": "action_OTA_HotelDescriptiveContentNotif_Info"
},
{
"action": "action_OTA_HotelDescriptiveInfo_Inventory"
},
{
"action": "action_OTA_HotelDescriptiveInfo_Info"
},
{
"action": "action_OTA_HotelRatePlanNotif_RatePlans",
"supports": [
"OTA_HotelRatePlanNotif_accept_ArrivalDOW",
"OTA_HotelRatePlanNotif_accept_DepartureDOW",
"OTA_HotelRatePlanNotif_accept_RatePlan_BookingRule",
"OTA_HotelRatePlanNotif_accept_RatePlan_RoomType_BookingRule",
"OTA_HotelRatePlanNotif_accept_RatePlan_mixed_BookingRule",
"OTA_HotelRatePlanNotif_accept_Supplements",
"OTA_HotelRatePlanNotif_accept_FreeNightsOffers",
"OTA_HotelRatePlanNotif_accept_FamilyOffers",
"OTA_HotelRatePlanNotif_accept_overlay",
"OTA_HotelRatePlanNotif_accept_RatePlanJoin",
"OTA_HotelRatePlanNotif_accept_OfferRule_BookingOffset",
"OTA_HotelRatePlanNotif_accept_OfferRule_DOWLOS"
]
}
]
}
]
}
</EchoData>
</OTA_PingRS>

View File

@@ -0,0 +1,241 @@
{"data": {
"formName": "Contact us",
"submissions": [
{
"label": "Angebot auswählen",
"value": "Zimmer: Doppelzimmer"
},
{
"label": "Anreisedatum",
"value": "2025-10-21"
},
{
"label": "Abreisedatum",
"value": "2025-12-28"
},
{
"label": "Anzahl Erwachsene",
"value": "4"
},
{
"label": "Anzahl Kinder",
"value": "0"
},
{
"label": "Anrede",
"value": "Herr"
},
{
"label": "Vorname",
"value": "Jonas"
},
{
"label": "Nachname",
"value": "Linter"
},
{
"label": "Email",
"value": "jonas@vaius.ai"
},
{
"label": "Phone",
"value": "+39 392 007 6982"
},
{
"label": "Message",
"value": "Hallo nachricht in der Kommentarsection"
},
{
"label": "Einwilligung Marketing",
"value": "Angekreuzt"
},
{
"label": "utm_Source",
"value": ""
},
{
"label": "utm_Medium",
"value": ""
},
{
"label": "utm_Campaign",
"value": ""
},
{
"label": "utm_Term",
"value": ""
},
{
"label": "utm_Content",
"value": ""
},
{
"label": "utm_term_id",
"value": ""
},
{
"label": "utm_content_id",
"value": ""
},
{
"label": "gad_source",
"value": "5"
},
{
"label": "gad_campaignid",
"value": "23065043477"
},
{
"label": "gbraid",
"value": ""
},
{
"label": "gclid",
"value": "EAIaIQobChMI-d7Bn_-OkAMVuZJQBh09uD0vEAAYASAAEgKR8_D_BwE"
},
{
"label": "fbclid",
"value": ""
},
{
"label": "hotelid",
"value": "12345"
},
{
"label": "hotelname",
"value": "Bemelmans Post"
}
],
"field:date_picker_7e65": "2025-10-28",
"field:number_7cf5": "2",
"field:utm_source": "",
"submissionTime": "2025-10-06T07:05:34.001Z",
"field:gad_source": "5",
"field:form_field_5a7b": "Angekreuzt",
"field:gad_campaignid": "23065043477",
"field:utm_medium": "",
"field:utm_term_id": "",
"context": {
"metaSiteId": "1dea821c-8168-4736-96e4-4b92e8b364cf",
"activationId": "fd8e9c90-0335-4fd2-976d-985f065f3f80"
},
"field:email_5139": "jonas@vaius.ai",
"field:phone_4c77": "+39 392 007 6982",
"_context": {
"activation": {
"id": "fd8e9c90-0335-4fd2-976d-985f065f3f80"
},
"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": "nduaitreuditaor",
"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": "Jonas",
"last": "Linter"
},
"email": "jonas@vaius.ai",
"locale": "de-de",
"phones": [
{
"tag": "UNTAGGED",
"formattedPhone": "+393920076982",
"id": "a3bf4-6dbe-4611-8963-a50df805785d",
"countryCode": "DE",
"e164Phone": "+393920076982",
"primary": true,
"phone": "392 0076982"
}
],
"contactId": "66659da8-4035-47fe-a66b-6ce461ad290f",
"emails": [
{
"id": "e1d2168e-ca3c-4844-8f93-f2e1b0ae70e3",
"tag": "UNTAGGED",
"email": "koepper-ed@t-online.de",
"primary": true
}
],
"updatedDate": "2025-10-06T07:05:35.675Z",
"phone": "+491758555456",
"createdDate": "2025-10-06T07:05:35.675Z"
},
"submissionId": "666247dc-9d5a-4eb7-87a7-677bf64645ad",
"field:anzahl_kinder": "0",
"field:first_name_abae": "Ernst-Dieter",
"field:utm_content_id": "",
"field:utm_campaign": "",
"field:utm_term": "",
"contactId": "66659da8-4035-47fe-a66b-6ce461ad290f",
"field:date_picker_a7c8": "2025-12-21",
"field:hotelname": "Testhotel",
"field:angebot_auswaehlen": "Zimmer: Doppelzimmer",
"field:utm_content": "",
"field:last_name_d97c": "Linter",
"field:hotelid": "135",
"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": "",
"submissionPdf": {
"fileName": "86d247dc-9d5a-4eb7-87a7-677bf64645ad.pdf",
"downloadUrl": "https://manage.wix.com/_api/form-submission-service/v4/submissions/86d247dc-9d5a-4eb7-87a7-677bf64645ad/download?accessToken=JWS.eyJraWQiOiJWLVNuLWhwZSIsImFsZyI6IkhTMjU2In0.eyJkYXRhIjoie1wibWV0YVNpdGVJZFwiOlwiMWRlYTgyMWMtODE2OC00NzM2LTk2ZTQtNGI5MmU4YjM2NGNmXCJ9IiwiaWF0IjoxNzU5NzM0MzM1LCJleHAiOjE3NTk3MzQ5MzV9.9koy-O_ptm0dRspjh01Yefkt2rCHiUlRCFtE_S3auYw"
},
"field:anrede": "Herr",
"field:long_answer_3524": "Kommentarsektion vermutlich",
"formId": "e084006b-ae83-4e4d-b2f5-074118cdb3b1"
}}

View File

@@ -0,0 +1,242 @@
{"data": {
"formName": "Reservation Request",
"submissions": [
{
"label": "Angebot auswählen",
"value": "Zimmer: Einzelzimmer"
},
{
"label": "Anreisedatum",
"value": "2025-11-15"
},
{
"label": "Abreisedatum",
"value": "2025-11-18"
},
{
"label": "Anzahl Erwachsene",
"value": "1"
},
{
"label": "Anzahl Kinder",
"value": "1"
},
{
"label": "Anrede",
"value": "Frau"
},
{
"label": "Vorname",
"value": "Maria"
},
{
"label": "Nachname",
"value": "Schmidt"
},
{
"label": "Email",
"value": "maria.schmidt@gmail.com"
},
{
"label": "Phone",
"value": "+49 173 555 1234"
},
{
"label": "Message",
"value": "Benötige ein ruhiges Zimmer, bitte."
},
{
"label": "Einwilligung Marketing",
"value": "Selezionato"
},
{
"label": "utm_Source",
"value": "google"
},
{
"label": "utm_Medium",
"value": "cpc"
},
{
"label": "utm_Campaign",
"value": "winter_2025"
},
{
"label": "utm_Term",
"value": "hotel_booking"
},
{
"label": "utm_Content",
"value": "ad_variant_a"
},
{
"label": "utm_term_id",
"value": "12345"
},
{
"label": "utm_content_id",
"value": "67890"
},
{
"label": "gad_source",
"value": "1"
},
{
"label": "gad_campaignid",
"value": "98765432"
},
{
"label": "gbraid",
"value": "1.2.abc123def456"
},
{
"label": "gclid",
"value": "CjwKCAjw9eWYBhB3EiwAA5J8_xyz123abc"
},
{
"label": "fbclid",
"value": "IwAR123fbclid456"
},
{
"label": "hotelid",
"value": "135"
},
{
"label": "hotelname",
"value": "Frangart Inn"
}
],
"field:date_picker_7e65": "2025-11-18",
"field:number_7cf5": "1",
"field:utm_source": "google",
"submissionTime": "2025-10-06T14:22:15.001Z",
"field:gad_source": "1",
"field:form_field_5a7b": "Selezionato",
"field:gad_campaignid": "98765432",
"field:utm_medium": "cpc",
"field:utm_term_id": "12345",
"context": {
"metaSiteId": "2ebc832d-9279-5847-a7f5-5c03f9c475d0",
"activationId": "0e9a0d91-1446-5fe3-a87e-a96b17f720c1"
},
"field:email_5139": "maria.schmidt@gmail.com",
"field:phone_4c77": "+49 173 555 1234",
"_context": {
"activation": {
"id": "0e9a0d91-1446-5fe3-a87e-a96b17f720c1"
},
"configuration": {
"id": "b087029d-0b97-506e-cf2f-787e0299ffbf"
},
"app": {
"id": "336ee023-8efa-5849-9799-5c9d7066aac3"
},
"action": {
"id": "263ec5e8-6374-51d5-df3c-2d92587429c8"
},
"trigger": {
"key": "wix_form_app-form_submitted"
}
},
"field:gclid": "CjwKCAjw9eWYBhB3EiwAA5J8_xyz123abc",
"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": "Maria",
"last": "Schmidt"
},
"email": "maria.schmidt@gmail.com",
"locale": "de-de",
"phones": [
{
"tag": "UNTAGGED",
"formattedPhone": "+49 173 555 1234",
"id": "641b4cf5-7ecf-5722-9a74-b61ea916391e",
"countryCode": "DE",
"e164Phone": "+393920076982",
"primary": true,
"phone": "173 5551234"
}
],
"contactId": "24760eb9-5146-58f0-b77c-7df572be401f",
"emails": [
{
"id": "f2e3279f-db4d-5955-90a4-03f2c1bf81f4",
"tag": "UNTAGGED",
"email": "maria.schmidt@gmail.com",
"primary": true
}
],
"updatedDate": "2025-10-06T14:22:16.675Z",
"phone": "+393920076982",
"createdDate": "2025-10-06T14:22:16.675Z"
},
"submissionId": "97e358ed-ae6b-5fc8-98c8-788cf75756be",
"field:anzahl_kinder": "1",
"field:first_name_abae": "Maria",
"field:utm_content_id": "67890",
"field:utm_campaign": "winter_2025",
"field:utm_term": "hotel_booking",
"contactId": "24760eb9-5146-58f0-b77c-7df572be401f",
"field:date_picker_a7c8": "2025-11-15",
"field:hotelname": "Frangart Inn",
"field:angebot_auswaehlen": "Zimmer: Einzelzimmer",
"field:utm_content": "ad_variant_a",
"field:last_name_d97c": "Schmidt",
"field:hotelid": "135",
"field:alter_kind_3": "8",
"submissionsLink": "https://manage.wix.app/forms/submissions/2ebc832d-9279-5847-a7f5-5c03f9c475d0/f195117c-bf94-5f5e-c6g6-185229dde4c2?d=https%3A%2F%2Fmanage.wix.com%2Fdashboard%2F2ebc832d-9279-5847-a7f5-5c03f9c475d0%2Fwix-forms%2Fform%2Ff195117c-bf94-5f5e-c6g6-185229dde4c2%2Fsubmissions&s=true",
"field:gbraid": "1.2.abc123def456",
"field:fbclid": "IwAR123fbclid456",
"submissionPdf": {
"fileName": "97e358ed-ae6b-5fc8-98c8-788cf75756be.pdf",
"downloadUrl": "https://manage.wix.com/_api/form-submission-service/v4/submissions/97e358ed-ae6b-5fc8-98c8-788cf75756be/download?accessToken=JWS.eyJraWQiOiJWLVNuLWhwZSIsImFsZyI6IkhTMjU2In0.eyJkYXRhIjoie1wibWV0YVNpdGVJZFwiOlwiMmViYzgzMmQtOTI3OS01ODQ3LWE3ZjUtNWMwM2Y5YzQ3NWQwXCJ9IiwiaWF0IjoxNzU5ODQ1MzM2LCJleHAiOjE3NTk4NDU5MzZ9.abc123_different_token_here"
},
"field:anrede": "Frau",
"field:long_answer_3524": "Benötige ein ruhiges Zimmer, bitte.",
"formId": "f195117c-bf94-5f5e-c6g6-185229dde4c2"
}}

View File

@@ -0,0 +1,244 @@
{"data": {
"formName": "Family Vacation Inquiry",
"submissions": [
{
"label": "Angebot auswählen",
"value": "Suite: Familiensuite"
},
{
"label": "Anreisedatum",
"value": "2025-12-20"
},
{
"label": "Abreisedatum",
"value": "2025-12-27"
},
{
"label": "Anzahl Erwachsene",
"value": "2"
},
{
"label": "Anzahl Kinder",
"value": "3"
},
{
"label": "Anrede",
"value": "Herr"
},
{
"label": "Vorname",
"value": "Alessandro"
},
{
"label": "Nachname",
"value": "Rossi"
},
{
"label": "Email",
"value": "alessandro.rossi@example.it"
},
{
"label": "Phone",
"value": "+39 348 123 4567"
},
{
"label": "Message",
"value": "Wir planen unseren Weihnachtsurlaub mit drei Kindern. Brauchen Kinderbetten und Nähe zum Spielplatz."
},
{
"label": "Einwilligung Marketing",
"value": "Angekreuzt"
},
{
"label": "utm_Source",
"value": "facebook"
},
{
"label": "utm_Medium",
"value": "social"
},
{
"label": "utm_Campaign",
"value": "christmas_special"
},
{
"label": "utm_Term",
"value": "family_hotel"
},
{
"label": "utm_Content",
"value": "carousel_ad"
},
{
"label": "utm_term_id",
"value": "54321"
},
{
"label": "utm_content_id",
"value": "09876"
},
{
"label": "gad_source",
"value": ""
},
{
"label": "gad_campaignid",
"value": ""
},
{
"label": "gbraid",
"value": ""
},
{
"label": "gclid",
"value": ""
},
{
"label": "fbclid",
"value": "IwAR3xHcVb6eJbMqQ_fbsocial789"
},
{
"label": "hotelid",
"value": "135"
},
{
"label": "hotelname",
"value": "Bemelmans"
}
],
"field:date_picker_7e65": "2025-12-27",
"field:number_7cf5": "2",
"field:utm_source": "facebook",
"submissionTime": "2025-10-06T16:45:22.001Z",
"field:gad_source": "",
"field:form_field_5a7b": "Angekreuzt",
"field:gad_campaignid": "",
"field:utm_medium": "social",
"field:utm_term_id": "54321",
"context": {
"metaSiteId": "3fcd943e-a38a-6958-b8g6-6d14gad586e1",
"activationId": "1f0b1e02-2557-6gf4-b98f-ba7c28g831d2"
},
"field:email_5139": "alessandro.rossi@example.it",
"field:phone_4c77": "+39 348 123 4567",
"_context": {
"activation": {
"id": "1f0b1e02-2557-6gf4-b98f-ba7c28g831d2"
},
"configuration": {
"id": "c198130e-1ca8-617f-dg3g-898f1300ggcg"
},
"app": {
"id": "447ff134-9g0b-6950-a8aa-6d0e8177bbdc"
},
"action": {
"id": "374fd6f9-7485-62e6-eg4d-3e03698540d9"
},
"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": "Alessandro",
"last": "Rossi"
},
"email": "alessandro.rossi@example.it",
"locale": "it-it",
"phones": [
{
"tag": "UNTAGGED",
"formattedPhone": "+39 348 123 4567",
"id": "752c5dg6-8fdf-6833-ab85-c72fb027402f",
"countryCode": "IT",
"e164Phone": "+393481234567",
"primary": true,
"phone": "348 1234567"
}
],
"contactId": "35871fca-6257-69g1-c88d-8eg683cf512g",
"emails": [
{
"id": "g3f4380g-ec5e-6a66-a1b5-14g3d2cg92g5",
"tag": "UNTAGGED",
"email": "alessandro.rossi@example.it",
"primary": true
}
],
"updatedDate": "2025-10-06T16:45:23.675Z",
"phone": "+393481234567",
"createdDate": "2025-10-06T16:45:23.675Z"
},
"submissionId": "a8g469fe-bf7c-6gd9-a9d9-899dg86867cf",
"field:anzahl_kinder": "3",
"field:first_name_abae": "Alessandro",
"field:utm_content_id": "09876",
"field:utm_campaign": "christmas_special",
"field:utm_term": "family_hotel",
"contactId": "35871fca-6257-69g1-c88d-8eg683cf512g",
"field:date_picker_a7c8": "2025-12-20",
"field:hotelname": "Bemelmans",
"field:angebot_auswaehlen": "Suite: Familiensuite",
"field:utm_content": "carousel_ad",
"field:last_name_d97c": "Rossi",
"field:hotelid": "135",
"field:alter_kind_3": "12",
"field:alter_kind_4": "9",
"field:alter_kind_5": "6",
"submissionsLink": "https://manage.wix.app/forms/submissions/3fcd943e-a38a-6958-b8g6-6d14gad586e1/g206228d-ch05-6g6f-d7h7-296330eef5d3?d=https%3A%2F%2Fmanage.wix.com%2Fdashboard%2F3fcd943e-a38a-6958-b8g6-6d14gad586e1%2Fwix-forms%2Fform%2Fg206228d-ch05-6g6f-d7h7-296330eef5d3%2Fsubmissions&s=true",
"field:gbraid": "",
"field:fbclid": "IwAR3xHcVb6eJbMqQ_fbsocial789",
"submissionPdf": {
"fileName": "a8g469fe-bf7c-6gd9-a9d9-899dg86867cf.pdf",
"downloadUrl": "https://manage.wix.com/_api/form-submission-service/v4/submissions/a8g469fe-bf7c-6gd9-a9d9-899dg86867cf/download?accessToken=JWS.eyJraWQiOiJWLVNuLWhwZSIsImFsZyI6IkhTMjU2In0.eyJkYXRhIjoie1wibWV0YVNpdGVJZFwiOlwiM2ZjZDk0M2UtYTM4YS02OTU4LWI4ZzYtNmQxNGdhZDU4NmUxXCJ9IiwiaWF0IjoxNzU5ODUyMDQ3LCJleHAiOjE3NTk4NTI2NDd9.xyz789_another_token_here"
},
"field:anrede": "Herr",
"field:long_answer_3524": "Wir planen unseren Weihnachtsurlaub mit drei Kindern. Brauchen Kinderbetten und Nähe zum Spielplatz.",
"formId": "g206228d-ch05-6g6f-d7h7-296330eef5d3"
}}

View File

@@ -0,0 +1,241 @@
{"data": {
"formName": "Business Travel Request",
"submissions": [
{
"label": "Angebot auswählen",
"value": "Zimmer: Business Suite"
},
{
"label": "Anreisedatum",
"value": "2025-11-08"
},
{
"label": "Abreisedatum",
"value": "2025-11-10"
},
{
"label": "Anzahl Erwachsene",
"value": "1"
},
{
"label": "Anzahl Kinder",
"value": "0"
},
{
"label": "Anrede",
"value": "Frau"
},
{
"label": "Vorname",
"value": "Sarah"
},
{
"label": "Nachname",
"value": "Johnson"
},
{
"label": "Email",
"value": "sarah.johnson@businesscorp.com"
},
{
"label": "Phone",
"value": "+1 555 987 6543"
},
{
"label": "Message",
"value": "Business trip for conference. Need WiFi and workspace. Will arrive late on Monday."
},
{
"label": "Einwilligung Marketing",
"value": ""
},
{
"label": "utm_Source",
"value": "direct"
},
{
"label": "utm_Medium",
"value": "none"
},
{
"label": "utm_Campaign",
"value": ""
},
{
"label": "utm_Term",
"value": ""
},
{
"label": "utm_Content",
"value": ""
},
{
"label": "utm_term_id",
"value": ""
},
{
"label": "utm_content_id",
"value": ""
},
{
"label": "gad_source",
"value": ""
},
{
"label": "gad_campaignid",
"value": ""
},
{
"label": "gbraid",
"value": ""
},
{
"label": "gclid",
"value": ""
},
{
"label": "fbclid",
"value": ""
},
{
"label": "hotelid",
"value": "135"
},
{
"label": "hotelname",
"value": "Business Hotel Alpine"
}
],
"field:date_picker_7e65": "2025-11-10",
"field:number_7cf5": "1",
"field:utm_source": "direct",
"submissionTime": "2025-10-06T09:15:45.001Z",
"field:gad_source": "",
"field:form_field_5a7b": "",
"field:gad_campaignid": "",
"field:utm_medium": "none",
"field:utm_term_id": "",
"context": {
"metaSiteId": "4hde054f-b49b-7a69-c9h7-7e25hbe697f2",
"activationId": "2g1c2f13-3668-7hg5-ca9g-cb8d39h942e3"
},
"field:email_5139": "sarah.johnson@businesscorp.com",
"field:phone_4c77": "+1 555 987 6543",
"_context": {
"activation": {
"id": "2g1c2f13-3668-7hg5-ca9g-cb8d39h942e3"
},
"configuration": {
"id": "d2a9241f-2db9-728g-eh4h-9a9g2411hhd0"
},
"app": {
"id": "558gg245-ah1c-7a61-b9bb-7e1f9288ccede"
},
"action": {
"id": "485ge7ga-8596-73f7-fh5e-4f146a9651ea"
},
"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": "Sarah",
"last": "Johnson"
},
"email": "sarah.johnson@businesscorp.com",
"locale": "en-us",
"phones": [
{
"tag": "UNTAGGED",
"formattedPhone": "+1 555 987 6543",
"id": "863d6eh7-9geg-7944-bc96-d83gc138513g",
"countryCode": "US",
"e164Phone": "+15559876543",
"primary": true,
"phone": "555 9876543"
}
],
"contactId": "46982gdb-7368-7ah2-d99e-9fh794dg623h",
"emails": [
{
"id": "h4g5491h-fd6f-7b77-b2c6-25h4e3dh03h6",
"tag": "UNTAGGED",
"email": "sarah.johnson@businesscorp.com",
"primary": true
}
],
"updatedDate": "2025-10-06T09:15:46.675Z",
"phone": "+15559876543",
"createdDate": "2025-10-06T09:15:46.675Z"
},
"submissionId": "b9h57agf-ch8d-7hea-baeb-9aaeth97978dg",
"field:anzahl_kinder": "0",
"field:first_name_abae": "Sarah",
"field:utm_content_id": "",
"field:utm_campaign": "",
"field:utm_term": "",
"contactId": "46982gdb-7368-7ah2-d99e-9fh794dg623h",
"field:date_picker_a7c8": "2025-11-08",
"field:hotelname": "Business Hotel Alpine",
"field:angebot_auswaehlen": "Zimmer: Business Suite",
"field:utm_content": "",
"field:last_name_d97c": "Johnson",
"field:hotelid": "135",
"submissionsLink": "https://manage.wix.app/forms/submissions/4hde054f-b49b-7a69-c9h7-7e25hbe697f2/h317339e-di16-7h7g-e8i8-3a7441ffg6e4?d=https%3A%2F%2Fmanage.wix.com%2Fdashboard%2F4hde054f-b49b-7a69-c9h7-7e25hbe697f2%2Fwix-forms%2Fform%2Fh317339e-di16-7h7g-e8i8-3a7441ffg6e4%2Fsubmissions&s=true",
"field:gbraid": "",
"field:fbclid": "",
"submissionPdf": {
"fileName": "b9h57agf-ch8d-7hea-baeb-9aaeth97978dg.pdf",
"downloadUrl": "https://manage.wix.com/_api/form-submission-service/v4/submissions/b9h57agf-ch8d-7hea-baeb-9aaeth97978dg/download?accessToken=JWS.eyJraWQiOiJWLVNuLWhwZSIsImFsZyI6IkhTMjU2In0.eyJkYXRhIjoie1wibWV0YVNpdGVJZFwiOlwiNGhkZTA1NGYtYjQ5Yi03YTY5LWM5aDctN2UyNWhiZTY5N2YyXCJ9IiwiaWF0IjoxNzU5ODI5MzQ2LCJleHAiOjE3NTk4Mjk5NDZ9.business_token_987654"
},
"field:anrede": "Frau",
"field:long_answer_3524": "Business trip for conference. Need WiFi and workspace. Will arrive late on Monday.",
"formId": "h317339e-di16-7h7g-e8i8-3a7441ffg6e4"
}}

View File

@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8"?>
<OTA_PingRS xmlns="http://www.opentravel.org/OTA/2003/05" Version="7.000">
<Success/>
<Warnings>
<Warning Type="11" Status="ALPINEBITS_HANDSHAKE">{
"versions": [
{
"version": "2024-10",
"actions": [
{
"action": "action_OTA_Read"
},
{
"action": "action_OTA_HotelResNotif_GuestRequests"
}
]
},
{
"version": "2022-10",
"actions": [
{
"action": "action_OTA_Ping"
},
{
"action": "action_OTA_Read"
},
{
"action": "action_OTA_HotelResNotif_GuestRequests"
}
]
}
]
}</Warning>
</Warnings>
<EchoData>{
"versions": [
{
"version": "2024-10",
"actions": [
{
"action": "action_OTA_Read"
},
{
"action": "action_OTA_HotelResNotif_GuestRequests"
}
]
},
{
"version": "2022-10",
"actions": [
{
"action": "action_OTA_Read"
},
{
"action": "action_OTA_Ping"
},
{
"action": "action_OTA_HotelResNotif_GuestRequests"
}
]
}
]
}</EchoData>
</OTA_PingRS>

View File

@@ -0,0 +1,81 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
AlpineBits 2024-10
https://www.alpinebits.org/
Sample message file for a Handshake response
Changelog:
v. 2024-10 1.2 Example extended with all capabilities and two supported releases
v. 2024-10 1.1 Removed the OTA_Ping action
v. 2024-10 1.0 added supported version 2024-10 in the example
v. 2018-10 1.0 initial example
-->
<OTA_PingRS xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.opentravel.org/OTA/2003/05"
xsi:schemaLocation="http://www.opentravel.org/OTA/2003/05 OTA_PingRS.xsd"
Version="8.000">
<Success/>
<Warnings>
<Warning Type="11" Status="ALPINEBITS_HANDSHAKE">{
"versions": [
{
"version": "2024-10",
"actions": [
{
"action": "action_OTA_Read"
},
{
"action": "action_OTA_HotelResNotif_GuestRequests"
}
]
},
{
"version": "2022-10",
"actions": [
{
"action": "action_OTA_Ping"
},
{
"action": "action_OTA_Read"
},
{
"action": "action_OTA_HotelResNotif_GuestRequests"
}
]
}
]
}</Warning>
</Warnings>
<EchoData>{
"versions": [
{
"version": "2024-10",
"actions": [
{
"action": "action_OTA_Read"
},
{
"action": "action_OTA_HotelResNotif_GuestRequests"
}
]
},
{
"version": "2022-10",
"actions": [
{
"action": "action_OTA_Read"
},
{
"action": "action_OTA_Ping"
},
{
"action": "action_OTA_HotelResNotif_GuestRequests"
}
]
}
]
}</EchoData>
</OTA_PingRS>

73
uv.lock generated
View File

@@ -24,8 +24,11 @@ dependencies = [
{ name = "dotenv" },
{ name = "fastapi" },
{ name = "generateds" },
{ name = "httpx" },
{ name = "lxml" },
{ name = "pydantic", extra = ["email"] },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "redis" },
{ name = "ruff" },
{ name = "slowapi" },
@@ -43,8 +46,11 @@ requires-dist = [
{ name = "dotenv", specifier = ">=0.9.9" },
{ name = "fastapi", specifier = ">=0.117.1" },
{ 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" },
{ name = "ruff", specifier = ">=0.13.1" },
{ name = "slowapi", specifier = ">=0.1.9" },
@@ -202,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"
@@ -226,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"
@@ -286,6 +314,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.10"
@@ -476,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"
@@ -529,6 +590,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" },
]
[[package]]
name = "python-dotenv"
version = "1.1.1"