Compare commits
50 Commits
59347f504f
...
1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52f95bd677 | ||
|
|
6701dcd6bf | ||
|
|
9f0a77ca39 | ||
|
|
259243d44b | ||
|
|
84a57f3d98 | ||
|
|
ff25142f62 | ||
|
|
ebbea84a4c | ||
|
|
584def323c | ||
|
|
a8f46016be | ||
|
|
e0c9afe227 | ||
|
|
9094f3e3b7 | ||
|
|
867b2632df | ||
|
|
a69816baa4 | ||
|
|
e605af1231 | ||
|
|
e5a295faba | ||
|
|
5ec47b8332 | ||
|
|
122c7c8be4 | ||
|
|
6102194712 | ||
|
|
f0945ed431 | ||
|
|
b4b7a537e1 | ||
|
|
2d9e90c9a4 | ||
|
|
4e03d1e089 | ||
|
|
1f9c969e69 | ||
|
|
106316dc6d | ||
|
|
951d3a2a26 | ||
|
|
1248ba3f3a | ||
|
|
3b33e552a9 | ||
|
|
35531ff925 | ||
|
|
c4fa774a86 | ||
|
|
4b37d8c52c | ||
|
|
c320fe866d | ||
|
|
201f218c23 | ||
|
|
808f0eccc8 | ||
|
|
b8e4f4fd01 | ||
|
|
17c3fc57b2 | ||
|
|
87668e6dc0 | ||
|
|
68e49aab34 | ||
|
|
2944b52d43 | ||
|
|
325965bb10 | ||
|
|
48aec92794 | ||
|
|
82118a1fa8 | ||
|
|
233a682e35 | ||
|
|
9c292a9897 | ||
|
|
277bd1934e | ||
|
|
b7afe4f528 | ||
|
|
36c32c44d8 | ||
|
|
ea9b6c72e4 | ||
|
|
dbfbd53ad9 | ||
|
|
579db2231f | ||
|
|
9f289e4750 |
139
.github/copilot-instructions.md
vendored
Normal file
139
.github/copilot-instructions.md
vendored
Normal 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
88
.github/workflows/build.yaml
vendored
Normal 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
2
.gitignore
vendored
@@ -17,6 +17,8 @@ wheels/
|
|||||||
# ignore test_data content but keep the folder
|
# ignore test_data content but keep the folder
|
||||||
test_data/*
|
test_data/*
|
||||||
|
|
||||||
|
test/test_output/*
|
||||||
|
|
||||||
|
|
||||||
# ignore secrets
|
# ignore secrets
|
||||||
secrets.yaml
|
secrets.yaml
|
||||||
|
|||||||
57
.vscode/settings.json
vendored
57
.vscode/settings.json
vendored
@@ -1,8 +1,53 @@
|
|||||||
{
|
{
|
||||||
"python.testing.pytestArgs": [
|
"editor.formatOnSave": true,
|
||||||
"test"
|
"[python]": {
|
||||||
],
|
"editor.defaultFormatter": "charliermarsh.ruff",
|
||||||
"python.testing.unittestEnabled": false,
|
"editor.codeActionsOnSave": {
|
||||||
"python.testing.pytestEnabled": true,
|
"source.fixAll": "explicit",
|
||||||
"python.analysis.typeCheckingMode": "basic"
|
"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
24
99Tales_Testexport.xml
Normal 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>
|
||||||
Binary file not shown.
@@ -2,15 +2,28 @@
|
|||||||
# Use annotatedyaml for secrets and environment-specific overrides
|
# Use annotatedyaml for secrets and environment-specific overrides
|
||||||
|
|
||||||
database:
|
database:
|
||||||
url: "sqlite+aiosqlite:///alpinebits.db" # For local dev, use SQLite. For prod, override with PostgreSQL URL.
|
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
|
# url: "postgresql://user:password@host:port/dbname" # Example for Postgres
|
||||||
|
|
||||||
|
# AlpineBits Python config
|
||||||
|
# Use annotatedyaml for secrets and environment-specific overrides
|
||||||
|
|
||||||
alpine_bits_auth:
|
alpine_bits_auth:
|
||||||
- hotel_id: "123"
|
- hotel_id: "39054_001"
|
||||||
hotel_name: "Frangart Inn"
|
hotel_name: "Bemelmans Post"
|
||||||
username: "alice"
|
username: "bemelman"
|
||||||
password: !secret ALICE_PASSWORD
|
password: !secret BEMELMANS_PASSWORD
|
||||||
- hotel_id: "456"
|
- hotel_id: "135"
|
||||||
hotel_name: "Bemelmans"
|
hotel_name: "Testhotel"
|
||||||
username: "bob"
|
username: "sebastian"
|
||||||
password: !secret BOB_PASSWORD
|
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
|
||||||
|
|||||||
@@ -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 & 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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
262
logs/wix_test_data_20251006_104642.json
Normal file
262
logs/wix_test_data_20251006_104642.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
262
logs/wix_test_data_20251006_105732.json
Normal file
262
logs/wix_test_data_20251006_105732.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
262
logs/wix_test_data_20251006_154306.json
Normal file
262
logs/wix_test_data_20251006_154306.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
262
logs/wix_test_data_20251006_154435.json
Normal file
262
logs/wix_test_data_20251006_154435.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
257
logs/wix_test_data_20251007_155426.json
Normal file
257
logs/wix_test_data_20251007_155426.json
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
{
|
||||||
|
"timestamp": "2025-10-07T15:54:26.898008",
|
||||||
|
"client_ip": "127.0.0.1",
|
||||||
|
"headers": {
|
||||||
|
"host": "localhost:8080",
|
||||||
|
"content-type": "application/json",
|
||||||
|
"user-agent": "insomnia/2023.5.8",
|
||||||
|
"accept": "*/*",
|
||||||
|
"content-length": "7335"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"data": {
|
||||||
|
"formName": "Contact us",
|
||||||
|
"submissions": [
|
||||||
|
{
|
||||||
|
"label": "Anreisedatum",
|
||||||
|
"value": "2026-01-02"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Abreisedatum",
|
||||||
|
"value": "2026-01-07"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Anzahl Erwachsene",
|
||||||
|
"value": "3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Anzahl Kinder",
|
||||||
|
"value": "1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Alter Kind 1",
|
||||||
|
"value": "12"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Anrede",
|
||||||
|
"value": "Frau"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Vorname",
|
||||||
|
"value": "Genesia "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Nachname",
|
||||||
|
"value": "Supino "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Email",
|
||||||
|
"value": "supinogenesia@gmail.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Phone",
|
||||||
|
"value": "+39 340 625 9979"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Einwilligung Marketing",
|
||||||
|
"value": "Selezionato"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "utm_Source",
|
||||||
|
"value": "fb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "utm_Medium",
|
||||||
|
"value": "Facebook_Mobile_Feed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "utm_Campaign",
|
||||||
|
"value": "Conversions_Hotel_Bemelmans_ITA"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "utm_Term",
|
||||||
|
"value": "Cold_Traffic_Conversions_Hotel_Bemelmans_ITA"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "utm_Content",
|
||||||
|
"value": "Grafik_AuszeitDezember_9.12_23.12"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "utm_term_id",
|
||||||
|
"value": "120238574626400196"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "utm_content_id",
|
||||||
|
"value": "120238574626400196"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "gad_source",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "gad_campaignid",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "gbraid",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "gclid",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "fbclid",
|
||||||
|
"value": "IwZXh0bgNhZW0BMABhZGlkAassWPh1b8QBHoRc2S24gMktdNKiPwEvGYMK3rB-mne_0IJQvQRIGH60wLvLfOm0XWP8wJ9s_aem_rbpAFMODwOh4UnF5UVxwWg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "hotelid",
|
||||||
|
"value": "12345"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "hotelname",
|
||||||
|
"value": "Bemelmans Post"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"field:date_picker_7e65": "2026-01-07",
|
||||||
|
"field:number_7cf5": "3",
|
||||||
|
"field:utm_source": "fb",
|
||||||
|
"submissionTime": "2025-10-07T05:48:41.855Z",
|
||||||
|
"field:alter_kind_3": "12",
|
||||||
|
"field:gad_source": "",
|
||||||
|
"field:form_field_5a7b": "Selezionato",
|
||||||
|
"field:gad_campaignid": "",
|
||||||
|
"field:utm_medium": "Facebook_Mobile_Feed",
|
||||||
|
"field:utm_term_id": "120238574626400196",
|
||||||
|
"context": {
|
||||||
|
"metaSiteId": "1dea821c-8168-4736-96e4-4b92e8b364cf",
|
||||||
|
"activationId": "2421c9cd-6565-49ba-b60f-165d3dacccba"
|
||||||
|
},
|
||||||
|
"field:email_5139": "supinogenesia@gmail.com",
|
||||||
|
"field:phone_4c77": "+39 340 625 9979",
|
||||||
|
"_context": {
|
||||||
|
"activation": {
|
||||||
|
"id": "2421c9cd-6565-49ba-b60f-165d3dacccba"
|
||||||
|
},
|
||||||
|
"configuration": {
|
||||||
|
"id": "a976f18c-fa86-495d-be1e-676df188eeae"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"id": "225dd912-7dea-4738-8688-4b8c6955ffc2"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"id": "152db4d7-5263-40c4-be2b-1c81476318b7"
|
||||||
|
},
|
||||||
|
"trigger": {
|
||||||
|
"key": "wix_form_app-form_submitted"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"field:gclid": "",
|
||||||
|
"formFieldMask": [
|
||||||
|
"field:",
|
||||||
|
"field:",
|
||||||
|
"field:angebot_auswaehlen",
|
||||||
|
"field:date_picker_a7c8",
|
||||||
|
"field:date_picker_7e65",
|
||||||
|
"field:",
|
||||||
|
"field:number_7cf5",
|
||||||
|
"field:anzahl_kinder",
|
||||||
|
"field:alter_kind_3",
|
||||||
|
"field:alter_kind_25",
|
||||||
|
"field:alter_kind_4",
|
||||||
|
"field:alter_kind_5",
|
||||||
|
"field:alter_kind_6",
|
||||||
|
"field:alter_kind_7",
|
||||||
|
"field:alter_kind_8",
|
||||||
|
"field:alter_kind_9",
|
||||||
|
"field:alter_kind_10",
|
||||||
|
"field:alter_kind_11",
|
||||||
|
"field:",
|
||||||
|
"field:anrede",
|
||||||
|
"field:first_name_abae",
|
||||||
|
"field:last_name_d97c",
|
||||||
|
"field:email_5139",
|
||||||
|
"field:phone_4c77",
|
||||||
|
"field:long_answer_3524",
|
||||||
|
"field:form_field_5a7b",
|
||||||
|
"field:",
|
||||||
|
"field:utm_source",
|
||||||
|
"field:utm_medium",
|
||||||
|
"field:utm_campaign",
|
||||||
|
"field:utm_term",
|
||||||
|
"field:utm_content",
|
||||||
|
"field:utm_term_id",
|
||||||
|
"field:utm_content_id",
|
||||||
|
"field:gad_source",
|
||||||
|
"field:gad_campaignid",
|
||||||
|
"field:gbraid",
|
||||||
|
"field:gclid",
|
||||||
|
"field:fbclid",
|
||||||
|
"field:hotelid",
|
||||||
|
"field:hotelname",
|
||||||
|
"field:",
|
||||||
|
"metaSiteId"
|
||||||
|
],
|
||||||
|
"contact": {
|
||||||
|
"name": {
|
||||||
|
"first": "Genesia",
|
||||||
|
"last": "Supino"
|
||||||
|
},
|
||||||
|
"email": "supinogenesia@gmail.com",
|
||||||
|
"locale": "it-it",
|
||||||
|
"phones": [
|
||||||
|
{
|
||||||
|
"tag": "UNTAGGED",
|
||||||
|
"formattedPhone": "+39 340 625 9979",
|
||||||
|
"id": "198f04fb-5b2c-4a7b-b7ea-adc150ec4212",
|
||||||
|
"countryCode": "IT",
|
||||||
|
"e164Phone": "+393406259979",
|
||||||
|
"primary": true,
|
||||||
|
"phone": "340 625 9979"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"contactId": "4d695011-36c1-4480-b225-ae9c6eef9e83",
|
||||||
|
"emails": [
|
||||||
|
{
|
||||||
|
"id": "e09d7bab-1f11-4b5d-b3c5-32d43c1dc584",
|
||||||
|
"tag": "UNTAGGED",
|
||||||
|
"email": "supinogenesia@gmail.com",
|
||||||
|
"primary": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"updatedDate": "2025-10-07T05:48:44.764Z",
|
||||||
|
"phone": "+393406259979",
|
||||||
|
"createdDate": "2025-10-07T05:48:43.567Z"
|
||||||
|
},
|
||||||
|
"submissionId": "c52702c9-55b9-44e1-b158-ec9544c73cc7",
|
||||||
|
"field:anzahl_kinder": "1",
|
||||||
|
"field:first_name_abae": "Genesia ",
|
||||||
|
"field:utm_content_id": "120238574626400196",
|
||||||
|
"field:utm_campaign": "Conversions_Hotel_Bemelmans_ITA",
|
||||||
|
"field:utm_term": "Cold_Traffic_Conversions_Hotel_Bemelmans_ITA",
|
||||||
|
"contactId": "4d695011-36c1-4480-b225-ae9c6eef9e83",
|
||||||
|
"field:date_picker_a7c8": "2026-01-02",
|
||||||
|
"field:hotelname": "Bemelmans Post",
|
||||||
|
"field:utm_content": "Grafik_AuszeitDezember_9.12_23.12",
|
||||||
|
"field:last_name_d97c": "Supino ",
|
||||||
|
"field:hotelid": "12345",
|
||||||
|
"submissionsLink": "https://manage.wix.app/forms/submissions/1dea821c-8168-4736-96e4-4b92e8b364cf/e084006b-ae83-4e4d-b2f5-074118cdb3b1?d=https%3A%2F%2Fmanage.wix.com%2Fdashboard%2F1dea821c-8168-4736-96e4-4b92e8b364cf%2Fwix-forms%2Fform%2Fe084006b-ae83-4e4d-b2f5-074118cdb3b1%2Fsubmissions&s=true",
|
||||||
|
"field:gbraid": "",
|
||||||
|
"field:fbclid": "IwZXh0bgNhZW0BMABhZGlkAassWPh1b8QBHoRc2S24gMktdNKiPwEvGYMK3rB-mne_0IJQvQRIGH60wLvLfOm0XWP8wJ9s_aem_rbpAFMODwOh4UnF5UVxwWg",
|
||||||
|
"submissionPdf": {
|
||||||
|
"fileName": "c52702c9-55b9-44e1-b158-ec9544c73cc7.pdf",
|
||||||
|
"downloadUrl": "https://manage.wix.com/_api/form-submission-service/v4/submissions/c52702c9-55b9-44e1-b158-ec9544c73cc7/download?accessToken=JWS.eyJraWQiOiJWLVNuLWhwZSIsImFsZyI6IkhTMjU2In0.eyJkYXRhIjoie1wibWV0YVNpdGVJZFwiOlwiMWRlYTgyMWMtODE2OC00NzM2LTk2ZTQtNGI5MmU4YjM2NGNmXCJ9IiwiaWF0IjoxNzU5ODE2MTI0LCJleHAiOjE3NTk4MTY3MjR9.quBfp9UL9Ddqb2CWERXoVkh9OdmHlIBvlLAyhoXElaY"
|
||||||
|
},
|
||||||
|
"field:anrede": "Frau",
|
||||||
|
"formId": "e084006b-ae83-4e4d-b2f5-074118cdb3b1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"origin_header": null,
|
||||||
|
"all_headers": {
|
||||||
|
"host": "localhost:8080",
|
||||||
|
"content-type": "application/json",
|
||||||
|
"user-agent": "insomnia/2023.5.8",
|
||||||
|
"accept": "*/*",
|
||||||
|
"content-length": "7335"
|
||||||
|
}
|
||||||
|
}
|
||||||
257
logs/wix_test_data_20251007_160537.json
Normal file
257
logs/wix_test_data_20251007_160537.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
112
pyproject.toml
112
pyproject.toml
@@ -14,8 +14,11 @@ dependencies = [
|
|||||||
"dotenv>=0.9.9",
|
"dotenv>=0.9.9",
|
||||||
"fastapi>=0.117.1",
|
"fastapi>=0.117.1",
|
||||||
"generateds>=2.44.3",
|
"generateds>=2.44.3",
|
||||||
|
"httpx>=0.28.1",
|
||||||
"lxml>=6.0.1",
|
"lxml>=6.0.1",
|
||||||
|
"pydantic[email]>=2.11.9",
|
||||||
"pytest>=8.4.2",
|
"pytest>=8.4.2",
|
||||||
|
"pytest-asyncio>=1.2.0",
|
||||||
"redis>=6.4.0",
|
"redis>=6.4.0",
|
||||||
"ruff>=0.13.1",
|
"ruff>=0.13.1",
|
||||||
"slowapi>=0.1.9",
|
"slowapi>=0.1.9",
|
||||||
@@ -33,8 +36,115 @@ alpine-bits-server = "alpine_bits_python.main:main"
|
|||||||
packages = ["src/alpine_bits_python"]
|
packages = ["src/alpine_bits_python"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["test"]
|
testpaths = ["tests"]
|
||||||
pythonpath = ["src"]
|
pythonpath = ["src"]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
src = ["src", "test"]
|
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
|
||||||
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +1,40 @@
|
|||||||
from datetime import datetime, timezone
|
import logging
|
||||||
from typing import Union, Optional, Any, TypeVar
|
import traceback
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
from typing import Tuple
|
|
||||||
|
|
||||||
from alpine_bits_python.db import Customer, Reservation
|
from alpine_bits_python.db import Customer, Reservation
|
||||||
|
from alpine_bits_python.schemas import (
|
||||||
|
CommentData,
|
||||||
|
CommentListItemData,
|
||||||
|
CommentsData,
|
||||||
|
CustomerData,
|
||||||
|
HotelReservationIdData,
|
||||||
|
PhoneTechType,
|
||||||
|
)
|
||||||
|
|
||||||
# Import the generated classes
|
# Import the generated classes
|
||||||
from .generated.alpinebits import (
|
from .generated.alpinebits import (
|
||||||
|
CommentName2,
|
||||||
HotelReservationResStatus,
|
HotelReservationResStatus,
|
||||||
OtaHotelResNotifRq,
|
OtaHotelResNotifRq,
|
||||||
OtaResRetrieveRs,
|
OtaResRetrieveRs,
|
||||||
CommentName2,
|
ProfileProfileType,
|
||||||
UniqueIdType2,
|
UniqueIdType2,
|
||||||
)
|
)
|
||||||
import logging
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
_LOGGER.setLevel(logging.INFO)
|
_LOGGER.setLevel(logging.INFO)
|
||||||
|
|
||||||
# Define type aliases for the two Customer types
|
# Define type aliases for the two Customer types
|
||||||
NotifCustomer = OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGuests.ResGuest.Profiles.ProfileInfo.Profile.Customer
|
NotifCustomer = OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGuests.ResGuest.Profiles.ProfileInfo.Profile.Customer # noqa: E501
|
||||||
RetrieveCustomer = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGuests.ResGuest.Profiles.ProfileInfo.Profile.Customer
|
RetrieveCustomer = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGuests.ResGuest.Profiles.ProfileInfo.Profile.Customer # noqa: E501
|
||||||
|
|
||||||
# Define type aliases for HotelReservationId types
|
# Define type aliases for HotelReservationId types
|
||||||
NotifHotelReservationId = OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGlobalInfo.HotelReservationIds.HotelReservationId
|
NotifHotelReservationId = OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGlobalInfo.HotelReservationIds.HotelReservationId # noqa: E501
|
||||||
RetrieveHotelReservationId = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.HotelReservationIds.HotelReservationId
|
RetrieveHotelReservationId = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.HotelReservationIds.HotelReservationId # noqa: E501
|
||||||
|
|
||||||
# Define type aliases for Comments types
|
# Define type aliases for Comments types
|
||||||
NotifComments = (
|
NotifComments = (
|
||||||
@@ -51,12 +58,21 @@ RetrieveGuestCounts = (
|
|||||||
OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays.RoomStay.GuestCounts
|
OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays.RoomStay.GuestCounts
|
||||||
)
|
)
|
||||||
|
|
||||||
|
NotifUniqueId = OtaHotelResNotifRq.HotelReservations.HotelReservation.UniqueId
|
||||||
|
RetrieveUniqueId = OtaResRetrieveRs.ReservationsList.HotelReservation.UniqueId
|
||||||
|
|
||||||
# phonetechtype enum 1,3,5 voice, fax, mobile
|
NotifTimeSpan = (
|
||||||
class PhoneTechType(Enum):
|
OtaHotelResNotifRq.HotelReservations.HotelReservation.RoomStays.RoomStay.TimeSpan
|
||||||
VOICE = "1"
|
)
|
||||||
FAX = "3"
|
RetrieveTimeSpan = (
|
||||||
MOBILE = "5"
|
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
|
# Enum to specify which OTA message type to use
|
||||||
@@ -72,70 +88,37 @@ class KidsAgeData:
|
|||||||
ages: list[int]
|
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:
|
class GuestCountsFactory:
|
||||||
@staticmethod
|
"""Factory class to create GuestCounts instances for both OtaHotelResNotifRq and OtaResRetrieveRs."""
|
||||||
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)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create_retrieve_guest_counts(
|
def create_guest_counts(
|
||||||
adults: int, kids: Optional[list[int]] = None
|
adults: int,
|
||||||
) -> RetrieveGuestCounts:
|
kids: list[int] | None = None,
|
||||||
"""
|
message_type: OtaMessageType = OtaMessageType.RETRIEVE,
|
||||||
Create a GuestCounts object for OtaResRetrieveRs.
|
) -> NotifGuestCounts:
|
||||||
|
"""Create a GuestCounts object for OtaHotelResNotifRq or OtaResRetrieveRs.
|
||||||
|
|
||||||
:param adults: Number of adults
|
:param adults: Number of adults
|
||||||
:param kids: List of ages for each kid (optional)
|
:param kids: List of ages for each kid (optional)
|
||||||
:return: GuestCounts instance
|
:return: GuestCounts instance
|
||||||
"""
|
"""
|
||||||
return GuestCountsFactory._create_guest_counts(
|
if message_type == OtaMessageType.RETRIEVE:
|
||||||
adults, kids, RetrieveGuestCounts
|
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
|
@staticmethod
|
||||||
def _create_guest_counts(
|
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:
|
) -> Any:
|
||||||
"""
|
"""Create a GuestCounts object of the specified type.
|
||||||
Internal method to create a GuestCounts object of the specified type.
|
|
||||||
:param adults: Number of adults
|
:param adults: Number of adults
|
||||||
:param kids: List of ages for each kid (optional)
|
:param kids: List of ages for each kid (optional)
|
||||||
:param guest_counts_class: The GuestCounts class to instantiate
|
:param guest_counts_class: The GuestCounts class to instantiate
|
||||||
@@ -161,7 +144,7 @@ class GuestCountsFactory:
|
|||||||
|
|
||||||
|
|
||||||
class CustomerFactory:
|
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
|
@staticmethod
|
||||||
def create_notif_customer(data: CustomerData) -> NotifCustomer:
|
def create_notif_customer(data: CustomerData) -> NotifCustomer:
|
||||||
@@ -174,9 +157,10 @@ class CustomerFactory:
|
|||||||
return CustomerFactory._create_customer(RetrieveCustomer, data)
|
return CustomerFactory._create_customer(RetrieveCustomer, data)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _create_customer(customer_class: type, data: CustomerData) -> Any:
|
def _create_customer(
|
||||||
"""Internal method to create a customer of the specified type."""
|
customer_class: type[RetrieveCustomer | NotifCustomer], data: CustomerData
|
||||||
|
) -> Any:
|
||||||
|
"""Create a customer of the specified type."""
|
||||||
# Create PersonName
|
# Create PersonName
|
||||||
person_name = customer_class.PersonName(
|
person_name = customer_class.PersonName(
|
||||||
given_name=data.given_name,
|
given_name=data.given_name,
|
||||||
@@ -249,20 +233,21 @@ class CustomerFactory:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _customer_to_data(customer: Any) -> CustomerData:
|
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
|
# Extract phone numbers
|
||||||
phone_numbers = []
|
phone_numbers = []
|
||||||
if customer.telephone:
|
if customer.telephone:
|
||||||
for tel in customer.telephone:
|
phone_numbers.extend(
|
||||||
phone_numbers.append(
|
[
|
||||||
(
|
(
|
||||||
tel.phone_number,
|
tel.phone_number,
|
||||||
PhoneTechType(tel.phone_tech_type)
|
PhoneTechType(tel.phone_tech_type)
|
||||||
if tel.phone_tech_type
|
if tel.phone_tech_type
|
||||||
else None,
|
else None,
|
||||||
)
|
)
|
||||||
)
|
for tel in customer.telephone
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
# Extract email info
|
# Extract email info
|
||||||
email_address = None
|
email_address = None
|
||||||
@@ -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:
|
class HotelReservationIdFactory:
|
||||||
"""Factory class to create HotelReservationId instances for both OtaHotelResNotifRq and OtaResRetrieveRs."""
|
"""Factory class to create HotelReservationId instances for both OtaHotelResNotifRq and OtaResRetrieveRs."""
|
||||||
|
|
||||||
@@ -350,7 +325,7 @@ class HotelReservationIdFactory:
|
|||||||
def _create_hotel_reservation_id(
|
def _create_hotel_reservation_id(
|
||||||
hotel_reservation_id_class: type, data: HotelReservationIdData
|
hotel_reservation_id_class: type, data: HotelReservationIdData
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Internal method to create a hotel reservation id of the specified type."""
|
"""Create a hotel reservation id of the specified type."""
|
||||||
return hotel_reservation_id_class(
|
return hotel_reservation_id_class(
|
||||||
res_id_type=data.res_id_type,
|
res_id_type=data.res_id_type,
|
||||||
res_id_value=data.res_id_value,
|
res_id_value=data.res_id_value,
|
||||||
@@ -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:
|
class CommentFactory:
|
||||||
"""Factory class to create Comment instances for both OtaHotelResNotifRq and OtaResRetrieveRs."""
|
"""Factory class to create Comment instances for both OtaHotelResNotifRq and OtaResRetrieveRs."""
|
||||||
|
|
||||||
@@ -442,7 +384,6 @@ class CommentFactory:
|
|||||||
data: CommentsData,
|
data: CommentsData,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Internal method to create comments of the specified type."""
|
"""Internal method to create comments of the specified type."""
|
||||||
|
|
||||||
comments_list = []
|
comments_list = []
|
||||||
for comment_data in data.comments:
|
for comment_data in data.comments:
|
||||||
# Create list items
|
# Create list items
|
||||||
@@ -481,7 +422,6 @@ class CommentFactory:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _comments_to_data(comments: Any) -> CommentsData:
|
def _comments_to_data(comments: Any) -> CommentsData:
|
||||||
"""Internal method to convert any comments type to CommentsData."""
|
"""Internal method to convert any comments type to CommentsData."""
|
||||||
|
|
||||||
comments_data_list = []
|
comments_data_list = []
|
||||||
for comment in comments.comment:
|
for comment in comments.comment:
|
||||||
# Extract list items
|
# Extract list items
|
||||||
@@ -496,11 +436,7 @@ class CommentFactory:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Extract comment data
|
comments_data_list.append(comment)
|
||||||
comment_data = CommentData(
|
|
||||||
name=comment.name, text=comment.text, list_items=list_items_data
|
|
||||||
)
|
|
||||||
comments_data_list.append(comment_data)
|
|
||||||
|
|
||||||
return CommentsData(comments=comments_data_list)
|
return CommentsData(comments=comments_data_list)
|
||||||
|
|
||||||
@@ -529,10 +465,11 @@ class ResGuestFactory:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _create_res_guests(
|
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:
|
) -> Any:
|
||||||
"""Internal method to create complete ResGuests structure."""
|
"""Create the complete ResGuests structure."""
|
||||||
|
|
||||||
# Create the customer using the existing CustomerFactory
|
# Create the customer using the existing CustomerFactory
|
||||||
customer = CustomerFactory._create_customer(customer_class, customer_data)
|
customer = CustomerFactory._create_customer(customer_class, customer_data)
|
||||||
|
|
||||||
@@ -555,18 +492,16 @@ class ResGuestFactory:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def extract_primary_customer(
|
def extract_primary_customer(
|
||||||
res_guests: Union[NotifResGuests, RetrieveResGuests],
|
res_guests: NotifResGuests | RetrieveResGuests,
|
||||||
) -> CustomerData:
|
) -> CustomerData:
|
||||||
"""Extract the primary customer data from a ResGuests structure."""
|
"""Extract the primary customer data from a ResGuests structure."""
|
||||||
|
|
||||||
# Navigate down the nested structure to get the customer
|
# Navigate down the nested structure to get the customer
|
||||||
customer = res_guests.res_guest.profiles.profile_info.profile.customer
|
customer = res_guests.res_guest.profiles.profile_info.profile.customer
|
||||||
|
|
||||||
# Use the existing CustomerFactory conversion method
|
# Use the existing CustomerFactory conversion method
|
||||||
if isinstance(res_guests, NotifResGuests):
|
if isinstance(res_guests, NotifResGuests):
|
||||||
return CustomerFactory.from_notif_customer(customer)
|
return CustomerFactory.from_notif_customer(customer)
|
||||||
else:
|
return CustomerFactory.from_retrieve_customer(customer)
|
||||||
return CustomerFactory.from_retrieve_customer(customer)
|
|
||||||
|
|
||||||
|
|
||||||
class AlpineBitsFactory:
|
class AlpineBitsFactory:
|
||||||
@@ -574,11 +509,10 @@ class AlpineBitsFactory:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create(
|
def create(
|
||||||
data: Union[CustomerData, HotelReservationIdData, CommentsData],
|
data: CustomerData | HotelReservationIdData | CommentsData,
|
||||||
message_type: OtaMessageType,
|
message_type: OtaMessageType,
|
||||||
) -> Any:
|
) -> 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:
|
Args:
|
||||||
data: The data object (CustomerData, HotelReservationIdData, CommentsData, etc.)
|
data: The data object (CustomerData, HotelReservationIdData, CommentsData, etc.)
|
||||||
@@ -586,36 +520,30 @@ class AlpineBitsFactory:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The appropriate AlpineBits object based on the data type and message type
|
The appropriate AlpineBits object based on the data type and message type
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if isinstance(data, CustomerData):
|
if isinstance(data, CustomerData):
|
||||||
if message_type == OtaMessageType.NOTIF:
|
if message_type == OtaMessageType.NOTIF:
|
||||||
return CustomerFactory.create_notif_customer(data)
|
return CustomerFactory.create_notif_customer(data)
|
||||||
else:
|
return CustomerFactory.create_retrieve_customer(data)
|
||||||
return CustomerFactory.create_retrieve_customer(data)
|
|
||||||
|
|
||||||
elif isinstance(data, HotelReservationIdData):
|
if isinstance(data, HotelReservationIdData):
|
||||||
if message_type == OtaMessageType.NOTIF:
|
if message_type == OtaMessageType.NOTIF:
|
||||||
return HotelReservationIdFactory.create_notif_hotel_reservation_id(data)
|
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:
|
if message_type == OtaMessageType.NOTIF:
|
||||||
return CommentFactory.create_notif_comments(data)
|
return CommentFactory.create_notif_comments(data)
|
||||||
else:
|
return CommentFactory.create_retrieve_comments(data)
|
||||||
return CommentFactory.create_retrieve_comments(data)
|
|
||||||
|
|
||||||
else:
|
raise ValueError(f"Unsupported data type: {type(data)}")
|
||||||
raise ValueError(f"Unsupported data type: {type(data)}")
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create_res_guests(
|
def create_res_guests(
|
||||||
customer_data: CustomerData, message_type: OtaMessageType
|
customer_data: CustomerData, message_type: OtaMessageType
|
||||||
) -> Union[NotifResGuests, RetrieveResGuests]:
|
) -> NotifResGuests | RetrieveResGuests:
|
||||||
"""
|
"""Create a complete ResGuests structure with a primary customer.
|
||||||
Create a complete ResGuests structure with a primary customer.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
customer_data: The customer data
|
customer_data: The customer data
|
||||||
@@ -623,44 +551,44 @@ class AlpineBitsFactory:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The appropriate ResGuests object
|
The appropriate ResGuests object
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if message_type == OtaMessageType.NOTIF:
|
if message_type == OtaMessageType.NOTIF:
|
||||||
return ResGuestFactory.create_notif_res_guests(customer_data)
|
return ResGuestFactory.create_notif_res_guests(customer_data)
|
||||||
else:
|
return ResGuestFactory.create_retrieve_res_guests(customer_data)
|
||||||
return ResGuestFactory.create_retrieve_res_guests(customer_data)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def extract_data(
|
def extract_data(
|
||||||
obj: Any,
|
obj: Any,
|
||||||
) -> Union[CustomerData, HotelReservationIdData, CommentsData]:
|
) -> CustomerData | HotelReservationIdData | CommentsData:
|
||||||
"""
|
"""Extract data from an AlpineBits object back to a simple data class.
|
||||||
Extract data from an AlpineBits object back to a simple data class.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
obj: The AlpineBits object to extract data from
|
obj: The AlpineBits object to extract data from
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The appropriate data object
|
The appropriate data object
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# Check if it's a Customer object
|
# Check if it's a Customer object
|
||||||
if hasattr(obj, "person_name") and hasattr(obj.person_name, "given_name"):
|
if hasattr(obj, "person_name") and hasattr(obj.person_name, "given_name"):
|
||||||
if isinstance(obj, NotifCustomer):
|
if isinstance(obj, NotifCustomer):
|
||||||
return CustomerFactory.from_notif_customer(obj)
|
return CustomerFactory.from_notif_customer(obj)
|
||||||
elif isinstance(obj, RetrieveCustomer):
|
if isinstance(obj, RetrieveCustomer):
|
||||||
return CustomerFactory.from_retrieve_customer(obj)
|
return CustomerFactory.from_retrieve_customer(obj)
|
||||||
|
|
||||||
# Check if it's a HotelReservationId object
|
# Check if it's a HotelReservationId object
|
||||||
elif hasattr(obj, "res_id_type"):
|
elif hasattr(obj, "res_id_type"):
|
||||||
if isinstance(obj, NotifHotelReservationId):
|
if isinstance(obj, NotifHotelReservationId):
|
||||||
return HotelReservationIdFactory.from_notif_hotel_reservation_id(obj)
|
return HotelReservationIdFactory.from_notif_hotel_reservation_id(obj)
|
||||||
elif isinstance(obj, RetrieveHotelReservationId):
|
if isinstance(obj, RetrieveHotelReservationId):
|
||||||
return HotelReservationIdFactory.from_retrieve_hotel_reservation_id(obj)
|
return HotelReservationIdFactory.from_retrieve_hotel_reservation_id(obj)
|
||||||
|
|
||||||
# Check if it's a Comments object
|
# Check if it's a Comments object
|
||||||
elif hasattr(obj, "comment"):
|
elif hasattr(obj, "comment"):
|
||||||
if isinstance(obj, NotifComments):
|
if isinstance(obj, NotifComments):
|
||||||
return CommentFactory.from_notif_comments(obj)
|
return CommentFactory.from_notif_comments(obj)
|
||||||
elif isinstance(obj, RetrieveComments):
|
if isinstance(obj, RetrieveComments):
|
||||||
return CommentFactory.from_retrieve_comments(obj)
|
return CommentFactory.from_retrieve_comments(obj)
|
||||||
|
|
||||||
# Check if it's a ResGuests object
|
# Check if it's a ResGuests object
|
||||||
@@ -669,183 +597,272 @@ class AlpineBitsFactory:
|
|||||||
|
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unsupported object type: {type(obj)}")
|
raise ValueError(f"Unsupported object type: {type(obj)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def create_xml_from_db(list: list[Tuple[Reservation, Customer]]):
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
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_data = CustomerData(
|
||||||
|
given_name=customer.given_name,
|
||||||
|
surname=customer.surname,
|
||||||
|
name_prefix=customer.name_prefix,
|
||||||
|
name_title=customer.name_title,
|
||||||
|
phone_numbers=phone_numbers,
|
||||||
|
email_address=customer.email_address,
|
||||||
|
email_newsletter=customer.email_newsletter,
|
||||||
|
address_line=customer.address_line,
|
||||||
|
city_name=customer.city_name,
|
||||||
|
postal_code=customer.postal_code,
|
||||||
|
country_code=customer.country_code,
|
||||||
|
address_catalog=customer.address_catalog,
|
||||||
|
gender=customer.gender,
|
||||||
|
birth_date=customer.birth_date,
|
||||||
|
language=customer.language,
|
||||||
|
)
|
||||||
|
alpine_bits_factory = AlpineBitsFactory()
|
||||||
|
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_guest_counts(
|
||||||
|
reservation.num_adults, children_ages, message_type
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
unique_id_str = reservation.md5_unique_id
|
||||||
|
|
||||||
|
# UniqueID
|
||||||
|
unique_id = UniqueId(type_value=UniqueIdType2.VALUE_14, id=unique_id_str)
|
||||||
|
|
||||||
|
# TimeSpan
|
||||||
|
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 = RoomStays.RoomStay(
|
||||||
|
time_span=time_span,
|
||||||
|
guest_counts=guest_counts,
|
||||||
|
)
|
||||||
|
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=klick_id,
|
||||||
|
res_id_source=res_id_source,
|
||||||
|
res_id_source_context="99tales",
|
||||||
|
)
|
||||||
|
|
||||||
|
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]
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
offer_comment = None
|
||||||
|
if reservation.offer is not None:
|
||||||
|
offer_comment = CommentData(
|
||||||
|
name=CommentName2.ADDITIONAL_INFO,
|
||||||
|
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",
|
||||||
|
# )
|
||||||
|
# ],
|
||||||
|
)
|
||||||
|
comments = [offer_comment, comment]
|
||||||
|
|
||||||
|
# filter out None comments
|
||||||
|
comments = [c for c in comments if c is not None]
|
||||||
|
|
||||||
|
comments_xml = None
|
||||||
|
if comments:
|
||||||
|
for c in comments:
|
||||||
|
_LOGGER.info(
|
||||||
|
f"Creating comment: name={c.name}, text={c.text}, list_items={len(c.list_items)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
comments_data = CommentsData(comments=comments)
|
||||||
|
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?"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 = HotelReservation(
|
||||||
|
create_date_time=datetime.now(UTC).isoformat(),
|
||||||
|
res_status=HotelReservationResStatus.REQUESTED,
|
||||||
|
room_stay_reservation="true",
|
||||||
|
unique_id=unique_id,
|
||||||
|
room_stays=room_stays,
|
||||||
|
res_guests=res_guests,
|
||||||
|
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.
|
"""Create RetrievedReservation XML from database entries.
|
||||||
|
|
||||||
list of pairs (Reservation, Customer)
|
list of pairs (Reservation, Customer)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
reservations_list = []
|
reservations_list = []
|
||||||
|
|
||||||
for reservation, customer in 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(
|
_LOGGER.info(
|
||||||
f"Creating XML for reservation {reservation.form_id} and customer {customer.given_name}"
|
f"Creating XML for reservation {reservation.unique_id} and customer {customer.given_name}"
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
phone_numbers = (
|
hotel_reservation = _process_single_reservation(reservation, customer, type)
|
||||||
[(customer.phone, PhoneTechType.MOBILE)]
|
|
||||||
if customer.phone is not None
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
customer_data = CustomerData(
|
|
||||||
given_name=customer.given_name,
|
|
||||||
surname=customer.surname,
|
|
||||||
name_prefix=customer.name_prefix,
|
|
||||||
name_title=customer.name_title,
|
|
||||||
phone_numbers=phone_numbers,
|
|
||||||
email_address=customer.email_address,
|
|
||||||
email_newsletter=customer.email_newsletter,
|
|
||||||
address_line=customer.address_line,
|
|
||||||
city_name=customer.city_name,
|
|
||||||
postal_code=customer.postal_code,
|
|
||||||
country_code=customer.country_code,
|
|
||||||
address_catalog=customer.address_catalog,
|
|
||||||
gender=customer.gender,
|
|
||||||
birth_date=customer.birth_date,
|
|
||||||
language=customer.language,
|
|
||||||
)
|
|
||||||
alpine_bits_factory = AlpineBitsFactory()
|
|
||||||
res_guests = alpine_bits_factory.create_res_guests(
|
|
||||||
customer_data, OtaMessageType.RETRIEVE
|
|
||||||
)
|
|
||||||
|
|
||||||
# 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
|
|
||||||
)
|
|
||||||
|
|
||||||
unique_id_string = reservation.form_id
|
|
||||||
|
|
||||||
if len(unique_id_string) > 32:
|
|
||||||
unique_id_string = unique_id_string[:32] # Truncate to 32 characters
|
|
||||||
|
|
||||||
# UniqueID
|
|
||||||
unique_id = OtaResRetrieveRs.ReservationsList.HotelReservation.UniqueId(
|
|
||||||
type_value=UniqueIdType2.VALUE_14, id=unique_id_string
|
|
||||||
)
|
|
||||||
|
|
||||||
# TimeSpan
|
|
||||||
time_span = OtaResRetrieveRs.ReservationsList.HotelReservation.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(
|
|
||||||
time_span=time_span,
|
|
||||||
guest_counts=guest_counts,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
room_stays = OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays(
|
|
||||||
room_stay=[room_stay],
|
|
||||||
)
|
|
||||||
hotel_res_id_data = HotelReservationIdData(
|
|
||||||
res_id_type="13",
|
|
||||||
res_id_value=reservation.fbclid or reservation.gclid,
|
|
||||||
res_id_source=None,
|
|
||||||
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_reservation_id=[hotel_res_id]
|
|
||||||
)
|
|
||||||
basic_property_info = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.BasicPropertyInfo(
|
|
||||||
hotel_code=reservation.hotel_code,
|
|
||||||
hotel_name=reservation.hotel_name,
|
|
||||||
)
|
|
||||||
# Comments
|
|
||||||
|
|
||||||
offer_comment = None
|
|
||||||
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",
|
|
||||||
)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
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",
|
|
||||||
)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
comments = [offer_comment, comment]
|
|
||||||
|
|
||||||
# filter out None comments
|
|
||||||
comments = [c for c in comments if c is not None]
|
|
||||||
|
|
||||||
comments_xml = None
|
|
||||||
if comments:
|
|
||||||
for c in comments:
|
|
||||||
_LOGGER.info(
|
|
||||||
f"Creating comment: name={c.name}, text={c.text}, list_items={len(c.list_items)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
comments_data = CommentsData(comments=comments)
|
|
||||||
comments_xml = alpine_bits_factory.create(
|
|
||||||
comments_data, OtaMessageType.RETRIEVE
|
|
||||||
)
|
|
||||||
|
|
||||||
res_global_info = (
|
|
||||||
OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo(
|
|
||||||
hotel_reservation_ids=hotel_res_ids,
|
|
||||||
basic_property_info=basic_property_info,
|
|
||||||
comments=comments_xml,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
hotel_reservation = OtaResRetrieveRs.ReservationsList.HotelReservation(
|
|
||||||
create_date_time=datetime.now(timezone.utc).isoformat(),
|
|
||||||
res_status=HotelReservationResStatus.REQUESTED,
|
|
||||||
room_stay_reservation="true",
|
|
||||||
unique_id=unique_id,
|
|
||||||
room_stays=room_stays,
|
|
||||||
res_guests=res_guests,
|
|
||||||
res_global_info=res_global_info,
|
|
||||||
)
|
|
||||||
|
|
||||||
reservations_list.append(hotel_reservation)
|
reservations_list.append(hotel_reservation)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_LOGGER.error(
|
_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())
|
||||||
|
|
||||||
retrieved_reservations = OtaResRetrieveRs.ReservationsList(
|
if type == OtaMessageType.NOTIF:
|
||||||
hotel_reservation=reservations_list
|
res_list_obj = OtaHotelResNotifRq.HotelReservations(
|
||||||
)
|
hotel_reservation=reservations_list
|
||||||
|
)
|
||||||
|
|
||||||
ota_res_retrieve_rs = OtaResRetrieveRs(
|
ota_hotel_res_notif_rq = OtaHotelResNotifRq(
|
||||||
version="7.000", success="", reservations_list=retrieved_reservations
|
version="7.000", hotel_reservations=res_list_obj
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ota_res_retrieve_rs.model_validate(ota_res_retrieve_rs.model_dump())
|
ota_hotel_res_notif_rq.model_validate(ota_hotel_res_notif_rq.model_dump())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_LOGGER.error(f"Validation error: {e}")
|
_LOGGER.error(f"Validation error: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
return ota_res_retrieve_rs
|
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=res_list_obj
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ota_res_retrieve_rs.model_validate(ota_res_retrieve_rs.model_dump())
|
||||||
|
except Exception as e:
|
||||||
|
_LOGGER.error(f"Validation error: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
return ota_res_retrieve_rs
|
||||||
|
|
||||||
|
raise ValueError(f"Unsupported message type: {type}")
|
||||||
|
|
||||||
|
|
||||||
# Usage examples
|
# Usage examples
|
||||||
|
|||||||
@@ -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")
|
|
||||||
@@ -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
|
This module provides an asynchronous AlpineBits server that can handle various
|
||||||
OTA (OpenTravel Alliance) actions for hotel data exchange. Currently implements
|
OTA (OpenTravel Alliance) actions for hotel data exchange. Currently implements
|
||||||
handshaking functionality with configurable supported actions and capabilities.
|
handshaking functionality with configurable supported actions and capabilities.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from datetime import datetime
|
|
||||||
import difflib
|
|
||||||
import json
|
|
||||||
import inspect
|
import inspect
|
||||||
import re
|
import json
|
||||||
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 logging
|
import logging
|
||||||
from .db import Reservation, Customer
|
import re
|
||||||
from sqlalchemy import select
|
from abc import ABC
|
||||||
from sqlalchemy.orm import joinedload
|
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
|
# Configure logging
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
@@ -45,6 +49,14 @@ class HttpStatusCode(IntEnum):
|
|||||||
INTERNAL_SERVER_ERROR = 500
|
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):
|
class AlpineBitsActionName(Enum):
|
||||||
"""Enum for AlpineBits action names with capability and request name mappings."""
|
"""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_PING = ("action_OTA_Ping", "OTA_Ping:Handshaking")
|
||||||
OTA_READ = ("action_OTA_Read", "OTA_Read:GuestRequests")
|
OTA_READ = ("action_OTA_Read", "OTA_Read:GuestRequests")
|
||||||
OTA_HOTEL_AVAIL_NOTIF = ("action_OTA_HotelAvailNotif", "OTA_HotelAvailNotif")
|
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",
|
"action_OTA_HotelResNotif_GuestRequests",
|
||||||
"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 = (
|
OTA_HOTEL_DESCRIPTIVE_CONTENT_NOTIF_INVENTORY = (
|
||||||
"action_OTA_HotelDescriptiveContentNotif_Inventory",
|
"action_OTA_HotelDescriptiveContentNotif_Inventory",
|
||||||
"OTA_HotelDescriptiveContentNotif:Inventory",
|
"OTA_HotelDescriptiveContentNotif:Inventory",
|
||||||
@@ -154,8 +170,7 @@ class AlpineBitsAction(ABC):
|
|||||||
dbsession=None,
|
dbsession=None,
|
||||||
server_capabilities=None,
|
server_capabilities=None,
|
||||||
) -> AlpineBitsResponse:
|
) -> 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.
|
Default implementation returns "not implemented" error.
|
||||||
Override this method in subclasses to provide actual functionality.
|
Override this method in subclasses to provide actual functionality.
|
||||||
@@ -167,18 +182,19 @@ class AlpineBitsAction(ABC):
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
AlpineBitsResponse with error or actual response
|
AlpineBitsResponse with error or actual response
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return_string = f"Error: Action {action} not implemented"
|
return_string = f"Error: Action {action} not implemented"
|
||||||
return AlpineBitsResponse(return_string, HttpStatusCode.BAD_REQUEST)
|
return AlpineBitsResponse(return_string, HttpStatusCode.BAD_REQUEST)
|
||||||
|
|
||||||
async def check_version_supported(self, version: Version) -> bool:
|
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:
|
Args:
|
||||||
version: The AlpineBits version to check
|
version: The AlpineBits version to check
|
||||||
Returns:
|
Returns:
|
||||||
True if supported, False otherwise
|
True if supported, False otherwise
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if isinstance(self.version, list):
|
if isinstance(self.version, list):
|
||||||
return version in self.version
|
return version in self.version
|
||||||
@@ -186,12 +202,10 @@ class AlpineBitsAction(ABC):
|
|||||||
|
|
||||||
|
|
||||||
class ServerCapabilities:
|
class ServerCapabilities:
|
||||||
"""
|
"""Automatically discovers AlpineBitsAction implementations and generates capabilities."""
|
||||||
Automatically discovers AlpineBitsAction implementations and generates capabilities.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.action_registry: Dict[str, Type[AlpineBitsAction]] = {}
|
self.action_registry: dict[AlpineBitsActionName, type[AlpineBitsAction]] = {}
|
||||||
self._discover_actions()
|
self._discover_actions()
|
||||||
self.capability_dict = None
|
self.capability_dict = None
|
||||||
|
|
||||||
@@ -209,12 +223,11 @@ class ServerCapabilities:
|
|||||||
if self._is_action_implemented(obj):
|
if self._is_action_implemented(obj):
|
||||||
action_instance = obj()
|
action_instance = obj()
|
||||||
if hasattr(action_instance, "name"):
|
if hasattr(action_instance, "name"):
|
||||||
# Use capability name for the registry key
|
# Use capability attribute as registry key
|
||||||
self.action_registry[action_instance.name.capability_name] = obj
|
self.action_registry[action_instance.name] = obj
|
||||||
|
|
||||||
def _is_action_implemented(self, action_class: Type[AlpineBitsAction]) -> bool:
|
def _is_action_implemented(self, action_class: type[AlpineBitsAction]) -> bool:
|
||||||
"""
|
"""Check if an action is actually implemented or just uses the default behavior.
|
||||||
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.
|
This is a simple check - in practice, you might want more sophisticated detection.
|
||||||
"""
|
"""
|
||||||
# Check if the class has overridden the handle method
|
# Check if the class has overridden the handle method
|
||||||
@@ -223,13 +236,10 @@ class ServerCapabilities:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def create_capabilities_dict(self) -> None:
|
def create_capabilities_dict(self) -> None:
|
||||||
"""
|
"""Generate the capabilities dictionary based on discovered actions."""
|
||||||
Generate the capabilities dictionary based on discovered actions.
|
|
||||||
|
|
||||||
"""
|
|
||||||
versions_dict = {}
|
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()
|
action_instance = action_class()
|
||||||
|
|
||||||
# Get supported versions for this action
|
# Get supported versions for this action
|
||||||
@@ -245,7 +255,7 @@ class ServerCapabilities:
|
|||||||
if version_str not in versions_dict:
|
if version_str not in versions_dict:
|
||||||
versions_dict[version_str] = {"version": version_str, "actions": []}
|
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
|
# Add supports field if the action has custom supports
|
||||||
if hasattr(action_instance, "supports") and action_instance.supports:
|
if hasattr(action_instance, "supports") and action_instance.supports:
|
||||||
@@ -255,22 +265,32 @@ class ServerCapabilities:
|
|||||||
|
|
||||||
self.capability_dict = {"versions": list(versions_dict.values())}
|
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:
|
# remove action_OTA_Ping from version 2024-10
|
||||||
"""
|
for version in self.capability_dict["versions"]:
|
||||||
Get capabilities as a dictionary. Generates if not already created.
|
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:
|
if self.capability_dict is None:
|
||||||
self.create_capabilities_dict()
|
self.create_capabilities_dict()
|
||||||
return self.capability_dict
|
return self.capability_dict
|
||||||
|
|
||||||
def get_capabilities_json(self) -> str:
|
def get_supported_actions(self) -> list[str]:
|
||||||
"""Get capabilities as formatted JSON string."""
|
|
||||||
return json.dumps(self.get_capabilities_dict(), indent=2)
|
|
||||||
|
|
||||||
def get_supported_actions(self) -> List[str]:
|
|
||||||
"""Get list of all supported action names."""
|
"""Get list of all supported action names."""
|
||||||
return list(self.action_registry.keys())
|
return list(self.action_registry.keys())
|
||||||
|
|
||||||
@@ -281,7 +301,7 @@ class ServerCapabilities:
|
|||||||
class PingAction(AlpineBitsAction):
|
class PingAction(AlpineBitsAction):
|
||||||
"""Implementation for OTA_Ping action (handshaking)."""
|
"""Implementation for OTA_Ping action (handshaking)."""
|
||||||
|
|
||||||
def __init__(self, config: Dict = {}):
|
def __init__(self, config: dict = {}):
|
||||||
self.name = AlpineBitsActionName.OTA_PING
|
self.name = AlpineBitsActionName.OTA_PING
|
||||||
self.version = [
|
self.version = [
|
||||||
Version.V2024_10,
|
Version.V2024_10,
|
||||||
@@ -299,10 +319,9 @@ class PingAction(AlpineBitsAction):
|
|||||||
server_capabilities: None | ServerCapabilities = None,
|
server_capabilities: None | ServerCapabilities = None,
|
||||||
) -> AlpineBitsResponse:
|
) -> AlpineBitsResponse:
|
||||||
"""Handle ping requests."""
|
"""Handle ping requests."""
|
||||||
|
|
||||||
if request_xml is None:
|
if request_xml is None:
|
||||||
return AlpineBitsResponse(
|
return AlpineBitsResponse(
|
||||||
f"Error: Xml Request missing", HttpStatusCode.BAD_REQUEST
|
"Error: Xml Request missing", HttpStatusCode.BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
if server_capabilities is None:
|
if server_capabilities is None:
|
||||||
@@ -316,10 +335,10 @@ class PingAction(AlpineBitsAction):
|
|||||||
try:
|
try:
|
||||||
parsed_request = parser.from_string(request_xml, OtaPingRq)
|
parsed_request = parser.from_string(request_xml, OtaPingRq)
|
||||||
|
|
||||||
echo_data = json.loads(parsed_request.echo_data)
|
echo_data_client = json.loads(parsed_request.echo_data)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
return AlpineBitsResponse(
|
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
|
# compare echo data with capabilities, create a dictionary containing the matching capabilities
|
||||||
@@ -329,7 +348,7 @@ class PingAction(AlpineBitsAction):
|
|||||||
matching_capabilities = {"versions": []}
|
matching_capabilities = {"versions": []}
|
||||||
|
|
||||||
# Iterate through client's requested 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", "")
|
client_version_str = client_version.get("version", "")
|
||||||
|
|
||||||
# Find matching server version
|
# Find matching server version
|
||||||
@@ -364,22 +383,22 @@ class PingAction(AlpineBitsAction):
|
|||||||
# Debug print to see what we matched
|
# Debug print to see what we matched
|
||||||
|
|
||||||
# Create successful ping response with matched capabilities
|
# 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(
|
warning = OtaPingRs.Warnings.Warning(
|
||||||
status=WarningStatus.ALPINEBITS_HANDSHAKE,
|
status=WarningStatus.ALPINEBITS_HANDSHAKE,
|
||||||
type_value="11",
|
type_value="11",
|
||||||
content=[capabilities_json],
|
content=[capabilities_json_str],
|
||||||
)
|
)
|
||||||
|
|
||||||
warning_response = OtaPingRs.Warnings(warning=[warning])
|
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(
|
response_ota_ping = OtaPingRs(
|
||||||
version="7.000",
|
version="7.000",
|
||||||
warnings=warning_response,
|
warnings=warning_response,
|
||||||
echo_data=all_capabilities,
|
echo_data=client_response_echo_data,
|
||||||
success="",
|
success="",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -402,7 +421,7 @@ def strip_control_chars(s):
|
|||||||
|
|
||||||
|
|
||||||
def validate_hotel_authentication(
|
def validate_hotel_authentication(
|
||||||
username: str, password: str, hotelid: str, config: Dict
|
username: str, password: str, hotelid: str, config: dict
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Validate hotel authentication based on username, password, and hotel ID.
|
"""Validate hotel authentication based on username, password, and hotel ID.
|
||||||
|
|
||||||
@@ -413,7 +432,6 @@ def validate_hotel_authentication(
|
|||||||
username: "alice"
|
username: "alice"
|
||||||
password: !secret ALICE_PASSWORD
|
password: !secret ALICE_PASSWORD
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not config or "alpine_bits_auth" not in config:
|
if not config or "alpine_bits_auth" not in config:
|
||||||
return False
|
return False
|
||||||
auth_list = config["alpine_bits_auth"]
|
auth_list = config["alpine_bits_auth"]
|
||||||
@@ -432,7 +450,7 @@ def validate_hotel_authentication(
|
|||||||
class ReadAction(AlpineBitsAction):
|
class ReadAction(AlpineBitsAction):
|
||||||
"""Implementation for OTA_Read action."""
|
"""Implementation for OTA_Read action."""
|
||||||
|
|
||||||
def __init__(self, config: Dict = {}):
|
def __init__(self, config: dict = {}):
|
||||||
self.name = AlpineBitsActionName.OTA_READ
|
self.name = AlpineBitsActionName.OTA_READ
|
||||||
self.version = [Version.V2024_10, Version.V2022_10]
|
self.version = [Version.V2024_10, Version.V2022_10]
|
||||||
self.config = config
|
self.config = config
|
||||||
@@ -447,7 +465,6 @@ class ReadAction(AlpineBitsAction):
|
|||||||
server_capabilities=None,
|
server_capabilities=None,
|
||||||
) -> AlpineBitsResponse:
|
) -> AlpineBitsResponse:
|
||||||
"""Handle read requests."""
|
"""Handle read requests."""
|
||||||
|
|
||||||
clean_action = strip_control_chars(str(action)).strip()
|
clean_action = strip_control_chars(str(action)).strip()
|
||||||
clean_expected = strip_control_chars(self.name.value[1]).strip()
|
clean_expected = strip_control_chars(self.name.value[1]).strip()
|
||||||
|
|
||||||
@@ -474,11 +491,13 @@ class ReadAction(AlpineBitsAction):
|
|||||||
|
|
||||||
if hotelid is None:
|
if hotelid is None:
|
||||||
return AlpineBitsResponse(
|
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,
|
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(
|
return AlpineBitsResponse(
|
||||||
f"Error: Unauthorized Read Request for this specific hotel {hotelname}. Check credentials",
|
f"Error: Unauthorized Read Request for this specific hotel {hotelname}. Check credentials",
|
||||||
HttpStatusCode.UNAUTHORIZED,
|
HttpStatusCode.UNAUTHORIZED,
|
||||||
@@ -500,6 +519,17 @@ class ReadAction(AlpineBitsAction):
|
|||||||
)
|
)
|
||||||
if start_date:
|
if start_date:
|
||||||
stmt = stmt.filter(Reservation.start_date >= 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)
|
result = await dbsession.execute(stmt)
|
||||||
reservation_customer_pairs: list[tuple[Reservation, Customer]] = (
|
reservation_customer_pairs: list[tuple[Reservation, Customer]] = (
|
||||||
@@ -514,7 +544,7 @@ class ReadAction(AlpineBitsAction):
|
|||||||
f"Reservation: {reservation.id}, Customer: {customer.given_name}"
|
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(
|
config = SerializerConfig(
|
||||||
pretty_print=True, xml_declaration=True, encoding="UTF-8"
|
pretty_print=True, xml_declaration=True, encoding="UTF-8"
|
||||||
@@ -530,8 +560,8 @@ class ReadAction(AlpineBitsAction):
|
|||||||
class NotifReportReadAction(AlpineBitsAction):
|
class NotifReportReadAction(AlpineBitsAction):
|
||||||
"""Necessary for read action to follow specification. Clients need to report acknowledgements"""
|
"""Necessary for read action to follow specification. Clients need to report acknowledgements"""
|
||||||
|
|
||||||
def __init__(self, config: Dict = {}):
|
def __init__(self, config: dict = {}):
|
||||||
self.name = AlpineBitsActionName.OTA_HOTEL_RES_NOTIF_GUEST_REQUESTS
|
self.name = AlpineBitsActionName.OTA_HOTEL_NOTIF_REPORT
|
||||||
self.version = [Version.V2024_10, Version.V2022_10]
|
self.version = [Version.V2024_10, Version.V2022_10]
|
||||||
self.config = config
|
self.config = config
|
||||||
|
|
||||||
@@ -540,37 +570,102 @@ class NotifReportReadAction(AlpineBitsAction):
|
|||||||
action: str,
|
action: str,
|
||||||
request_xml: str,
|
request_xml: str,
|
||||||
version: Version,
|
version: Version,
|
||||||
|
client_info: AlpineBitsClientInfo,
|
||||||
dbsession=None,
|
dbsession=None,
|
||||||
username=None,
|
server_capabilities=None,
|
||||||
password=None,
|
|
||||||
) -> AlpineBitsResponse:
|
) -> AlpineBitsResponse:
|
||||||
"""Handle read requests."""
|
"""Handle read requests."""
|
||||||
|
notif_report = XmlParser().from_string(request_xml, OtaNotifReportRq)
|
||||||
|
|
||||||
return AlpineBitsResponse(
|
# we can't check hotel auth here, because this action does not contain hotel info
|
||||||
f"Error: Action {action} not implemented", HttpStatusCode.BAD_REQUEST
|
|
||||||
|
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(
|
||||||
|
"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"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
class GuestRequestsAction(AlpineBitsAction):
|
timestamp = datetime.now(ZoneInfo("UTC"))
|
||||||
"""Unimplemented action - will not appear in capabilities."""
|
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)
|
||||||
|
|
||||||
def __init__(self):
|
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.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:
|
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
|
This server handles various OTA actions and implements the AlpineBits protocol
|
||||||
for hotel data exchange. It maintains a registry of supported actions and
|
for hotel data exchange. It maintains a registry of supported actions and
|
||||||
their capabilities, and can respond to handshake requests with its capabilities.
|
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.capabilities = ServerCapabilities()
|
||||||
self._action_instances = {}
|
self._action_instances = {}
|
||||||
self.config = config
|
self.config = config
|
||||||
@@ -579,34 +674,31 @@ class AlpineBitsServer:
|
|||||||
def _initialize_action_instances(self):
|
def _initialize_action_instances(self):
|
||||||
"""Initialize instances of all discovered action classes."""
|
"""Initialize instances of all discovered action classes."""
|
||||||
for capability_name, action_class in self.capabilities.action_registry.items():
|
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)
|
self._action_instances[capability_name] = action_class(config=self.config)
|
||||||
|
|
||||||
def get_capabilities(self) -> Dict:
|
def get_capabilities(self) -> dict:
|
||||||
"""Get server capabilities."""
|
"""Get server capabilities."""
|
||||||
return self.capabilities.get_capabilities_dict()
|
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(
|
async def handle_request(
|
||||||
self,
|
self,
|
||||||
request_action_name: str,
|
request_action_name: str,
|
||||||
request_xml: str,
|
request_xml: str | tuple[Reservation, Customer],
|
||||||
client_info: AlpineBitsClientInfo,
|
client_info: AlpineBitsClientInfo,
|
||||||
version: str = "2024-10",
|
version: str = "2024-10",
|
||||||
dbsession=None,
|
dbsession=None,
|
||||||
) -> AlpineBitsResponse:
|
) -> AlpineBitsResponse:
|
||||||
"""
|
"""Handle an incoming AlpineBits request by routing to appropriate action handler.
|
||||||
Handle an incoming AlpineBits request by routing to appropriate action handler.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request_action_name: The action name from the request (e.g., "OTA_Read:GuestRequests")
|
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")
|
version: The AlpineBits version (defaults to "2024-10")
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
AlpineBitsResponse with the result
|
AlpineBitsResponse with the result
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# Convert string version to enum
|
# Convert string version to enum
|
||||||
try:
|
try:
|
||||||
@@ -618,6 +710,10 @@ class AlpineBitsServer:
|
|||||||
|
|
||||||
# Find the action by request name
|
# Find the action by request name
|
||||||
action_enum = AlpineBitsActionName.get_by_request_name(request_action_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:
|
if not action_enum:
|
||||||
return AlpineBitsResponse(
|
return AlpineBitsResponse(
|
||||||
f"Error: Unknown action {request_action_name}",
|
f"Error: Unknown action {request_action_name}",
|
||||||
@@ -625,14 +721,14 @@ class AlpineBitsServer:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Check if we have an implementation for this action
|
# 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(
|
return AlpineBitsResponse(
|
||||||
f"Error: Action {request_action_name} is not implemented",
|
f"Error: Action {request_action_name} is not implemented",
|
||||||
HttpStatusCode.BAD_REQUEST,
|
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
|
# Check if the action supports the requested version
|
||||||
if not await action_instance.check_version_supported(version_enum):
|
if not await action_instance.check_version_supported(version_enum):
|
||||||
@@ -644,30 +740,48 @@ class AlpineBitsServer:
|
|||||||
# Handle the request
|
# Handle the request
|
||||||
try:
|
try:
|
||||||
# Special case for ping action - pass server capabilities
|
# Special case for ping action - pass server capabilities
|
||||||
if capability_name == "action_OTA_Ping":
|
|
||||||
return await action_instance.handle(
|
if action_enum == AlpineBitsActionName.OTA_HOTEL_RES_NOTIF_GUEST_REQUESTS:
|
||||||
action=request_action_name, request_xml=request_xml, version=version_enum, server_capabilities=self.capabilities, client_info=client_info
|
action_instance: PushAction
|
||||||
)
|
if request_xml is None or not isinstance(request_xml, tuple):
|
||||||
else:
|
return AlpineBitsResponse(
|
||||||
|
"Error: Invalid data for push request",
|
||||||
|
HttpStatusCode.BAD_REQUEST,
|
||||||
|
)
|
||||||
return await action_instance.handle(
|
return await action_instance.handle(
|
||||||
action=request_action_name,
|
action=request_action_name,
|
||||||
request_xml=request_xml,
|
request_xml=request_xml,
|
||||||
version=version_enum,
|
version=version_enum,
|
||||||
dbsession=dbsession,
|
|
||||||
client_info=client_info,
|
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,
|
||||||
|
)
|
||||||
|
return await action_instance.handle(
|
||||||
|
action=request_action_name,
|
||||||
|
request_xml=request_xml,
|
||||||
|
version=version_enum,
|
||||||
|
dbsession=dbsession,
|
||||||
|
client_info=client_info,
|
||||||
|
)
|
||||||
except Exception as e:
|
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
|
# print stack trace for debugging
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
return AlpineBitsResponse(
|
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,
|
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)."""
|
"""Get all supported request names (not capability names)."""
|
||||||
request_names = []
|
request_names = []
|
||||||
for capability_name in self._action_instances.keys():
|
for capability_name in self._action_instances.keys():
|
||||||
@@ -679,8 +793,7 @@ class AlpineBitsServer:
|
|||||||
def is_action_supported(
|
def is_action_supported(
|
||||||
self, request_action_name: str, version: str | None = None
|
self, request_action_name: str, version: str | None = None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""Check if a request action is supported.
|
||||||
Check if a request action is supported.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request_action_name: The request action name (e.g., "OTA_Read:GuestRequests")
|
request_action_name: The request action name (e.g., "OTA_Read:GuestRequests")
|
||||||
@@ -688,6 +801,7 @@ class AlpineBitsServer:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if supported, False otherwise
|
True if supported, False otherwise
|
||||||
|
|
||||||
"""
|
"""
|
||||||
action_enum = AlpineBitsActionName.get_by_request_name(request_action_name)
|
action_enum = AlpineBitsActionName.get_by_request_name(request_action_name)
|
||||||
if not action_enum:
|
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
|
# This would need to be async, but for simplicity we'll just check if version exists
|
||||||
if isinstance(action_instance.version, list):
|
if isinstance(action_instance.version, list):
|
||||||
return version_enum in action_instance.version
|
return version_enum in action_instance.version
|
||||||
else:
|
return action_instance.version == version_enum
|
||||||
return action_instance.version == version_enum
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
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())
|
|
||||||
|
|||||||
@@ -1,49 +1,44 @@
|
|||||||
from fastapi import (
|
import asyncio
|
||||||
FastAPI,
|
import gzip
|
||||||
HTTPException,
|
import json
|
||||||
BackgroundTasks,
|
import logging
|
||||||
Request,
|
import os
|
||||||
Depends,
|
import urllib.parse
|
||||||
APIRouter,
|
from collections import defaultdict
|
||||||
Form,
|
from datetime import UTC, date, datetime
|
||||||
File,
|
from functools import partial
|
||||||
UploadFile,
|
from pathlib import Path
|
||||||
)
|
from typing import Any
|
||||||
from fastapi.concurrency import asynccontextmanager
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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 .config_loader import load_config
|
||||||
from fastapi.responses import HTMLResponse, PlainTextResponse, Response
|
from .db import Base, get_database_url
|
||||||
from .models import WixFormSubmission
|
from .db import Customer as DBCustomer
|
||||||
from datetime import datetime, date, timezone
|
from .db import Reservation as DBReservation
|
||||||
from .auth import validate_api_key, validate_wix_signature, generate_api_key
|
|
||||||
from .rate_limit import (
|
from .rate_limit import (
|
||||||
limiter,
|
BURST_RATE_LIMIT,
|
||||||
webhook_limiter,
|
|
||||||
custom_rate_limit_handler,
|
|
||||||
DEFAULT_RATE_LIMIT,
|
DEFAULT_RATE_LIMIT,
|
||||||
WEBHOOK_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
|
# Configure logging
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
@@ -52,26 +47,154 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
# HTTP Basic auth for AlpineBits
|
# HTTP Basic auth for AlpineBits
|
||||||
security_basic = HTTPBasic()
|
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
|
# 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):
|
async def lifespan(app: FastAPI):
|
||||||
# Setup DB
|
# Setup DB
|
||||||
|
|
||||||
try:
|
try:
|
||||||
config = load_config()
|
config = load_config()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_LOGGER.error(f"Failed to load config: {str(e)}")
|
_LOGGER.error(f"Failed to load config: {e!s}")
|
||||||
config = {}
|
config = {}
|
||||||
|
|
||||||
DATABASE_URL = get_database_url(config)
|
DATABASE_URL = get_database_url(config)
|
||||||
engine = create_async_engine(DATABASE_URL, echo=True)
|
engine = create_async_engine(DATABASE_URL, echo=True)
|
||||||
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
||||||
app.state.engine = engine
|
app.state.engine = engine
|
||||||
app.state.async_sessionmaker = AsyncSessionLocal
|
app.state.async_sessionmaker = AsyncSessionLocal
|
||||||
app.state.config = config
|
app.state.config = config
|
||||||
app.state.alpine_bits_server = AlpineBitsServer(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
|
# Create tables
|
||||||
async with engine.begin() as conn:
|
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("/")
|
@api_router.get("/")
|
||||||
@limiter.limit(DEFAULT_RATE_LIMIT)
|
@limiter.limit(DEFAULT_RATE_LIMIT)
|
||||||
async def root(request: Request):
|
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
|
# Extracted business logic for handling Wix form submissions
|
||||||
async def process_wix_form_submission(request: Request, data: Dict[str, Any], db):
|
async def process_wix_form_submission(request: Request, data: dict[str, Any], db):
|
||||||
"""
|
"""Shared business logic for handling Wix form submissions (test and production)."""
|
||||||
Shared business logic for handling Wix form submissions (test and production).
|
|
||||||
"""
|
|
||||||
timestamp = datetime.now().isoformat()
|
timestamp = datetime.now().isoformat()
|
||||||
|
|
||||||
_LOGGER.info(f"Received Wix form data at {timestamp}")
|
_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")
|
contact_id = contact_info.get("contactId")
|
||||||
|
|
||||||
name_prefix = data.get("field:anrede")
|
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
|
address_line = None
|
||||||
city_name = None
|
city_name = None
|
||||||
postal_code = 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")
|
offer = data.get("field:angebot_auswaehlen")
|
||||||
|
|
||||||
# UTM and offer
|
# get submissionId and ensure max length 35. Generate one if not present
|
||||||
utm_fields = [
|
|
||||||
("utm_Source", "utm_source"),
|
unique_id = data.get("submissionId", generate_unique_id())
|
||||||
("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
|
|
||||||
|
|
||||||
# use database session
|
# use database session
|
||||||
|
|
||||||
@@ -309,20 +400,36 @@ async def process_wix_form_submission(request: Request, data: Dict[str, Any], db
|
|||||||
name_title=None,
|
name_title=None,
|
||||||
)
|
)
|
||||||
db.add(db_customer)
|
db.add(db_customer)
|
||||||
await db.commit()
|
await db.flush() # This assigns db_customer.id without committing
|
||||||
await db.refresh(db_customer)
|
# await db.refresh(db_customer)
|
||||||
|
|
||||||
db_reservation = DBReservation(
|
# Determine hotel_code and hotel_name
|
||||||
customer_id=db_customer.id,
|
# Priority: 1) Form field, 2) Configuration default, 3) Hardcoded fallback
|
||||||
form_id=data.get("submissionId"),
|
hotel_code = (
|
||||||
start_date=date.fromisoformat(start_date) if start_date else None,
|
data.get("field:hotelid")
|
||||||
end_date=date.fromisoformat(end_date) if end_date else None,
|
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_adults=num_adults,
|
||||||
num_children=num_children,
|
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,
|
offer=offer,
|
||||||
utm_comment=utm_comment,
|
created_at=datetime.now(UTC),
|
||||||
created_at=datetime.now(timezone.utc),
|
|
||||||
utm_source=data.get("field:utm_source"),
|
utm_source=data.get("field:utm_source"),
|
||||||
utm_medium=data.get("field:utm_medium"),
|
utm_medium=data.get("field:utm_medium"),
|
||||||
utm_campaign=data.get("field:utm_campaign"),
|
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", ""),
|
user_comment=data.get("field:long_answer_3524", ""),
|
||||||
fbclid=data.get("field:fbclid"),
|
fbclid=data.get("field:fbclid"),
|
||||||
gclid=data.get("field:gclid"),
|
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)
|
db.add(db_reservation)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(db_reservation)
|
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 {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": "Wix form data received successfully",
|
"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(
|
async def validate_basic_auth(
|
||||||
credentials: HTTPBasicCredentials = Depends(security_basic),
|
credentials: HTTPBasicCredentials = Depends(security_basic),
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""Validate basic authentication for AlpineBits protocol.
|
||||||
Validate basic authentication for AlpineBits protocol.
|
|
||||||
Returns username if valid, raises HTTPException if not.
|
Returns username if valid, raises HTTPException if not.
|
||||||
"""
|
"""
|
||||||
# Accept any username/password pair present in config['alpine_bits_auth']
|
# Accept any username/password pair present in config['alpine_bits_auth']
|
||||||
@@ -444,14 +507,151 @@ async def validate_basic_auth(
|
|||||||
headers={"WWW-Authenticate": "Basic"},
|
headers={"WWW-Authenticate": "Basic"},
|
||||||
)
|
)
|
||||||
_LOGGER.info(
|
_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
|
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.
|
This is a simplified parser for the AlpineBits use case.
|
||||||
"""
|
"""
|
||||||
if "multipart/form-data" not in content_type:
|
if "multipart/form-data" not in content_type:
|
||||||
@@ -515,8 +715,7 @@ async def alpinebits_server_handshake(
|
|||||||
credentials_tupel: tuple = Depends(validate_basic_auth),
|
credentials_tupel: tuple = Depends(validate_basic_auth),
|
||||||
dbsession=Depends(get_async_session),
|
dbsession=Depends(get_async_session),
|
||||||
):
|
):
|
||||||
"""
|
"""AlpineBits server endpoint implementing the handshake protocol.
|
||||||
AlpineBits server endpoint implementing the handshake protocol.
|
|
||||||
|
|
||||||
This endpoint handles:
|
This endpoint handles:
|
||||||
- Protocol version negotiation via X-AlpineBits-ClientProtocolVersion header
|
- 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"
|
"No X-AlpineBits-ClientProtocolVersion header found, assuming pre-2013-04"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
_LOGGER.info(f"Client protocol version: {client_protocol_version}")
|
_LOGGER.info("Client protocol version: %s", client_protocol_version)
|
||||||
|
|
||||||
# Optional client ID
|
# Optional client ID
|
||||||
client_id = request.headers.get("X-AlpineBits-ClientID")
|
client_id = request.headers.get("X-AlpineBits-ClientID")
|
||||||
if client_id:
|
if client_id:
|
||||||
_LOGGER.info(f"Client ID: {client_id}")
|
_LOGGER.info("Client ID: %s", client_id)
|
||||||
|
|
||||||
# Check content encoding
|
# Check content encoding
|
||||||
content_encoding = request.headers.get("Content-Encoding")
|
content_encoding = request.headers.get("Content-Encoding")
|
||||||
@@ -559,50 +758,14 @@ async def alpinebits_server_handshake(
|
|||||||
# Get content type before processing
|
# Get content type before processing
|
||||||
content_type = request.headers.get("Content-Type", "")
|
content_type = request.headers.get("Content-Type", "")
|
||||||
|
|
||||||
_LOGGER.info(f"Content-Type: {content_type}")
|
_LOGGER.info("Content-Type: %s", content_type)
|
||||||
_LOGGER.info(f"Content-Encoding: {content_encoding}")
|
_LOGGER.info("Content-Encoding: %s", content_encoding)
|
||||||
|
|
||||||
# Get request body
|
# Get request body
|
||||||
body = await request.body()
|
body = await request.body()
|
||||||
|
|
||||||
# Decompress if needed
|
# Decompress if needed
|
||||||
if is_compressed:
|
form_data = validate_alpinebits_body(is_compressed, content_type, body)
|
||||||
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",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check for required action parameter
|
# Check for required action parameter
|
||||||
action = form_data.get("action")
|
action = form_data.get("action")
|
||||||
@@ -622,7 +785,9 @@ async def alpinebits_server_handshake(
|
|||||||
|
|
||||||
username, password = credentials_tupel
|
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
|
# Create successful handshake response
|
||||||
response = await server.handle_request(
|
response = await server.handle_request(
|
||||||
@@ -642,6 +807,11 @@ async def alpinebits_server_handshake(
|
|||||||
"X-AlpineBits-Server-Version": "2024-10",
|
"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(
|
return Response(
|
||||||
content=response_xml, status_code=response.status_code, headers=headers
|
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.)
|
# Re-raise HTTP exceptions (auth errors, etc.)
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_LOGGER.error(f"Error in AlpineBits handshake: {str(e)}")
|
_LOGGER.error(f"Error in AlpineBits handshake: {e!s}")
|
||||||
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
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")
|
@api_router.get("/admin/stats")
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
async def get_api_stats(request: Request, admin_key: str = Depends(validate_api_key)):
|
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.
|
Requires admin API key.
|
||||||
"""
|
"""
|
||||||
if admin_key != "admin-key":
|
if admin_key != "admin-key":
|
||||||
@@ -683,16 +895,12 @@ app.include_router(api_router)
|
|||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def landing_page():
|
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:
|
try:
|
||||||
# Get the path to the HTML file
|
# Get the path to the HTML file
|
||||||
import os
|
|
||||||
|
|
||||||
html_path = os.path.join(os.path.dirname(__file__), "templates", "index.html")
|
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()
|
html_content = f.read()
|
||||||
|
|
||||||
return HTMLResponse(content=html_content, status_code=200)
|
return HTMLResponse(content=html_content, status_code=200)
|
||||||
|
|||||||
@@ -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 hashlib
|
||||||
import hmac
|
import hmac
|
||||||
from datetime import datetime, timedelta
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
from fastapi import HTTPException, Security, status
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
# Load environment variables from .env file
|
# Load environment variables from .env file
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
@@ -31,6 +30,11 @@ if os.getenv("ADMIN_API_KEY"):
|
|||||||
API_KEYS["admin-key"] = 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:
|
def generate_api_key() -> str:
|
||||||
"""Generate a secure API key"""
|
"""Generate a secure API key"""
|
||||||
return f"sk_live_{secrets.token_urlsafe(32)}"
|
return f"sk_live_{secrets.token_urlsafe(32)}"
|
||||||
@@ -39,8 +43,7 @@ def generate_api_key() -> str:
|
|||||||
def validate_api_key(
|
def validate_api_key(
|
||||||
credentials: HTTPAuthorizationCredentials = Security(security),
|
credentials: HTTPAuthorizationCredentials = Security(security),
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""Validate API key from Authorization header.
|
||||||
Validate API key from Authorization header.
|
|
||||||
Expected format: Authorization: Bearer your_api_key_here
|
Expected format: Authorization: Bearer your_api_key_here
|
||||||
"""
|
"""
|
||||||
token = credentials.credentials
|
token = credentials.credentials
|
||||||
@@ -60,8 +63,7 @@ def validate_api_key(
|
|||||||
|
|
||||||
|
|
||||||
def validate_wix_signature(payload: bytes, signature: str, secret: str) -> bool:
|
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.
|
Wix signs their webhooks with HMAC-SHA256.
|
||||||
"""
|
"""
|
||||||
if not signature or not secret:
|
if not signature or not secret:
|
||||||
@@ -69,8 +71,7 @@ def validate_wix_signature(payload: bytes, signature: str, secret: str) -> bool:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Remove 'sha256=' prefix if present
|
# Remove 'sha256=' prefix if present
|
||||||
if signature.startswith("sha256="):
|
signature = signature.removeprefix("sha256=")
|
||||||
signature = signature[7:]
|
|
||||||
|
|
||||||
# Calculate expected signature
|
# Calculate expected signature
|
||||||
expected_signature = hmac.new(
|
expected_signature = hmac.new(
|
||||||
@@ -90,7 +91,7 @@ class APIKeyAuth:
|
|||||||
def __init__(self, api_keys: dict):
|
def __init__(self, api_keys: dict):
|
||||||
self.api_keys = api_keys
|
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"""
|
"""Authenticate an API key and return the key name if valid"""
|
||||||
for key_name, valid_key in self.api_keys.items():
|
for key_name, valid_key in self.api_keys.items():
|
||||||
if secrets.compare_digest(api_key, valid_key):
|
if secrets.compare_digest(api_key, valid_key):
|
||||||
|
|||||||
@@ -1,25 +1,20 @@
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List
|
|
||||||
from annotatedyaml.loader import (
|
from annotatedyaml.loader import (
|
||||||
HAS_C_LOADER,
|
|
||||||
JSON_TYPE,
|
|
||||||
LoaderType,
|
|
||||||
Secrets,
|
Secrets,
|
||||||
add_constructor,
|
)
|
||||||
|
from annotatedyaml.loader import (
|
||||||
load_yaml as load_annotated_yaml,
|
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 (
|
from voluptuous import (
|
||||||
Schema,
|
PREVENT_EXTRA,
|
||||||
Required,
|
|
||||||
All,
|
All,
|
||||||
Length,
|
Length,
|
||||||
PREVENT_EXTRA,
|
|
||||||
MultipleInvalid,
|
MultipleInvalid,
|
||||||
Optional,
|
Optional,
|
||||||
|
Required,
|
||||||
|
Schema,
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Voluptuous schemas ---
|
# --- Voluptuous schemas ---
|
||||||
@@ -101,7 +96,7 @@ class Config:
|
|||||||
return self.basic_auth["hotel_name"]
|
return self.basic_auth["hotel_name"]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def users(self) -> List[Dict[str, str]]:
|
def users(self) -> list[dict[str, str]]:
|
||||||
return self.basic_auth["users"]
|
return self.basic_auth["users"]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
import os
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Column, Date, DateTime, ForeignKey, Integer, String
|
||||||
|
from sqlalchemy.orm import declarative_base, relationship
|
||||||
|
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
@@ -44,14 +44,14 @@ class Reservation(Base):
|
|||||||
__tablename__ = "reservations"
|
__tablename__ = "reservations"
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
customer_id = Column(Integer, ForeignKey("customers.id"))
|
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)
|
start_date = Column(Date)
|
||||||
end_date = Column(Date)
|
end_date = Column(Date)
|
||||||
num_adults = Column(Integer)
|
num_adults = Column(Integer)
|
||||||
num_children = Column(Integer)
|
num_children = Column(Integer)
|
||||||
children_ages = Column(String) # comma-separated
|
children_ages = Column(String) # comma-separated
|
||||||
offer = Column(String)
|
offer = Column(String)
|
||||||
utm_comment = Column(String)
|
|
||||||
created_at = Column(DateTime)
|
created_at = Column(DateTime)
|
||||||
# Add all UTM fields and user comment for XML
|
# Add all UTM fields and user comment for XML
|
||||||
utm_source = Column(String)
|
utm_source = Column(String)
|
||||||
@@ -68,11 +68,12 @@ class Reservation(Base):
|
|||||||
customer = relationship("Customer", back_populates="reservations")
|
customer = relationship("Customer", back_populates="reservations")
|
||||||
|
|
||||||
|
|
||||||
class HashedCustomer(Base):
|
# Table for tracking acknowledged requests by client
|
||||||
__tablename__ = "hashed_customers"
|
class AckedRequest(Base):
|
||||||
|
__tablename__ = "acked_requests"
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
customer_id = Column(Integer)
|
client_id = Column(String, index=True)
|
||||||
hashed_email = Column(String)
|
unique_id = Column(
|
||||||
hashed_phone = Column(String)
|
String, index=True
|
||||||
hashed_name = Column(String)
|
) # Should match Reservation.form_id or another unique field
|
||||||
redacted_at = Column(DateTime)
|
timestamp = Column(DateTime)
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ __all__ = [
|
|||||||
"CommentName1",
|
"CommentName1",
|
||||||
"CommentName2",
|
"CommentName2",
|
||||||
"ContactInfoLocation",
|
"ContactInfoLocation",
|
||||||
|
"DefSendComplete",
|
||||||
"DescriptionName",
|
"DescriptionName",
|
||||||
"DescriptionTextFormat1",
|
"DescriptionTextFormat1",
|
||||||
"DescriptionTextFormat2",
|
"DescriptionTextFormat2",
|
||||||
@@ -103,6 +104,7 @@ __all__ = [
|
|||||||
"MealsIncludedMealPlanIndicator",
|
"MealsIncludedMealPlanIndicator",
|
||||||
"MultimediaDescriptionInfoCode1",
|
"MultimediaDescriptionInfoCode1",
|
||||||
"MultimediaDescriptionInfoCode2",
|
"MultimediaDescriptionInfoCode2",
|
||||||
|
"OccupancyAgeQualifyingCode",
|
||||||
"OtaHotelDescriptiveContentNotifRq",
|
"OtaHotelDescriptiveContentNotifRq",
|
||||||
"OtaHotelDescriptiveContentNotifRs",
|
"OtaHotelDescriptiveContentNotifRs",
|
||||||
"OtaHotelDescriptiveInfoRq",
|
"OtaHotelDescriptiveInfoRq",
|
||||||
@@ -123,7 +125,6 @@ __all__ = [
|
|||||||
"OtaPingRs",
|
"OtaPingRs",
|
||||||
"OtaReadRq",
|
"OtaReadRq",
|
||||||
"OtaResRetrieveRs",
|
"OtaResRetrieveRs",
|
||||||
"OccupancyAgeQualifyingCode",
|
|
||||||
"PositionAltitudeUnitOfMeasureCode",
|
"PositionAltitudeUnitOfMeasureCode",
|
||||||
"PrerequisiteInventoryInvType",
|
"PrerequisiteInventoryInvType",
|
||||||
"ProfileProfileType",
|
"ProfileProfileType",
|
||||||
@@ -150,12 +151,11 @@ __all__ = [
|
|||||||
"TextTextFormat2",
|
"TextTextFormat2",
|
||||||
"TimeUnitType",
|
"TimeUnitType",
|
||||||
"TypeRoomRoomType",
|
"TypeRoomRoomType",
|
||||||
"UrlType",
|
|
||||||
"UniqueIdInstance",
|
"UniqueIdInstance",
|
||||||
"UniqueIdType1",
|
"UniqueIdType1",
|
||||||
"UniqueIdType2",
|
"UniqueIdType2",
|
||||||
"UniqueIdType3",
|
"UniqueIdType3",
|
||||||
|
"UrlType",
|
||||||
"VideoItemCategory",
|
"VideoItemCategory",
|
||||||
"WarningStatus",
|
"WarningStatus",
|
||||||
"DefSendComplete",
|
|
||||||
]
|
]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,52 +1,36 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from .alpinebits_guestrequests import ResGuest, RoomStay
|
import os
|
||||||
from .generated import alpinebits as ab
|
from datetime import UTC, date, datetime
|
||||||
from io import BytesIO
|
|
||||||
import sys
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
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
|
|
||||||
|
|
||||||
from .alpine_bits_helpers import (
|
from .alpine_bits_helpers import (
|
||||||
|
AlpineBitsFactory,
|
||||||
CommentData,
|
CommentData,
|
||||||
CommentsData,
|
|
||||||
CommentListItemData,
|
CommentListItemData,
|
||||||
|
CommentsData,
|
||||||
CustomerData,
|
CustomerData,
|
||||||
GuestCountsFactory,
|
GuestCountsFactory,
|
||||||
HotelReservationIdData,
|
HotelReservationIdData,
|
||||||
PhoneTechType,
|
|
||||||
AlpineBitsFactory,
|
|
||||||
OtaMessageType,
|
OtaMessageType,
|
||||||
|
PhoneTechType,
|
||||||
)
|
)
|
||||||
|
from .config_loader import load_config
|
||||||
|
|
||||||
# DB and config
|
# DB and config
|
||||||
from .db import (
|
from .db import (
|
||||||
Base,
|
Base,
|
||||||
Customer as DBCustomer,
|
|
||||||
Reservation as DBReservation,
|
|
||||||
HashedCustomer,
|
|
||||||
get_database_url,
|
get_database_url,
|
||||||
)
|
)
|
||||||
from .config_loader import load_config
|
from .db import (
|
||||||
import hashlib
|
Customer as DBCustomer,
|
||||||
import json
|
)
|
||||||
import os
|
from .db import (
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
Reservation as DBReservation,
|
||||||
import asyncio
|
)
|
||||||
|
from .generated import alpinebits as ab
|
||||||
from alpine_bits_python import db
|
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
@@ -101,7 +85,7 @@ async def main():
|
|||||||
os.path.dirname(__file__),
|
os.path.dirname(__file__),
|
||||||
"../../test_data/wix_test_data_20250928_132611.json",
|
"../../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)
|
wix_data = json.load(f)
|
||||||
data = wix_data["data"]["data"]
|
data = wix_data["data"]["data"]
|
||||||
|
|
||||||
@@ -197,7 +181,7 @@ async def main():
|
|||||||
children_ages=",".join(str(a) for a in children_ages),
|
children_ages=",".join(str(a) for a in children_ages),
|
||||||
offer=offer,
|
offer=offer,
|
||||||
utm_comment=utm_comment,
|
utm_comment=utm_comment,
|
||||||
created_at=datetime.now(timezone.utc),
|
created_at=datetime.now(UTC),
|
||||||
utm_source=data.get("field:utm_source"),
|
utm_source=data.get("field:utm_source"),
|
||||||
utm_medium=data.get("field:utm_medium"),
|
utm_medium=data.get("field:utm_medium"),
|
||||||
utm_campaign=data.get("field:utm_campaign"),
|
utm_campaign=data.get("field:utm_campaign"),
|
||||||
@@ -256,7 +240,7 @@ def create_xml_from_db(customer: DBCustomer, reservation: DBReservation):
|
|||||||
|
|
||||||
# UniqueID
|
# UniqueID
|
||||||
unique_id = ab.OtaResRetrieveRs.ReservationsList.HotelReservation.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
|
# TimeSpan
|
||||||
@@ -330,7 +314,7 @@ def create_xml_from_db(customer: DBCustomer, reservation: DBReservation):
|
|||||||
)
|
)
|
||||||
|
|
||||||
hotel_reservation = ab.OtaResRetrieveRs.ReservationsList.HotelReservation(
|
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,
|
res_status=ab.HotelReservationResStatus.REQUESTED,
|
||||||
room_stay_reservation="true",
|
room_stay_reservation="true",
|
||||||
unique_id=unique_id,
|
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:
|
with open("output.xml", "w", encoding="utf-8") as outfile:
|
||||||
outfile.write(xml_string)
|
outfile.write(xml_string)
|
||||||
print("✅ XML serialization successful!")
|
print("✅ XML serialization successful!")
|
||||||
print(f"Generated XML written to output.xml")
|
print("Generated XML written to output.xml")
|
||||||
print("\n📄 Generated XML:")
|
print("\n📄 Generated XML:")
|
||||||
print(xml_string)
|
print(xml_string)
|
||||||
from xsdata_pydantic.bindings import XmlParser
|
from xsdata_pydantic.bindings import XmlParser
|
||||||
|
|
||||||
parser = 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()
|
xml_content = infile.read()
|
||||||
parsed_result = parser.from_string(xml_content, ab.OtaResRetrieveRs)
|
parsed_result = parser.from_string(xml_content, ab.OtaResRetrieveRs)
|
||||||
print("✅ Round-trip validation successful!")
|
print("✅ Round-trip validation successful!")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from typing import Dict, List, Any, Optional
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
|
|
||||||
class AlpineBitsHandshakeRequest(BaseModel):
|
class AlpineBitsHandshakeRequest(BaseModel):
|
||||||
@@ -9,64 +9,64 @@ class AlpineBitsHandshakeRequest(BaseModel):
|
|||||||
action: str = Field(
|
action: str = Field(
|
||||||
..., description="Action parameter, typically 'OTA_Ping:Handshaking'"
|
..., 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):
|
class ContactName(BaseModel):
|
||||||
"""Contact name structure"""
|
"""Contact name structure"""
|
||||||
|
|
||||||
first: Optional[str] = None
|
first: str | None = None
|
||||||
last: Optional[str] = None
|
last: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ContactAddress(BaseModel):
|
class ContactAddress(BaseModel):
|
||||||
"""Contact address structure"""
|
"""Contact address structure"""
|
||||||
|
|
||||||
street: Optional[str] = None
|
street: str | None = None
|
||||||
city: Optional[str] = None
|
city: str | None = None
|
||||||
state: Optional[str] = None
|
state: str | None = None
|
||||||
country: Optional[str] = None
|
country: str | None = None
|
||||||
postalCode: Optional[str] = None
|
postalCode: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class Contact(BaseModel):
|
class Contact(BaseModel):
|
||||||
"""Contact information from Wix form"""
|
"""Contact information from Wix form"""
|
||||||
|
|
||||||
name: Optional[ContactName] = None
|
name: ContactName | None = None
|
||||||
email: Optional[str] = None
|
email: str | None = None
|
||||||
locale: Optional[str] = None
|
locale: str | None = None
|
||||||
company: Optional[str] = None
|
company: str | None = None
|
||||||
birthdate: Optional[str] = None
|
birthdate: str | None = None
|
||||||
labelKeys: Optional[Dict[str, Any]] = None
|
labelKeys: dict[str, Any] | None = None
|
||||||
contactId: Optional[str] = None
|
contactId: str | None = None
|
||||||
address: Optional[ContactAddress] = None
|
address: ContactAddress | None = None
|
||||||
jobTitle: Optional[str] = None
|
jobTitle: str | None = None
|
||||||
imageUrl: Optional[str] = None
|
imageUrl: str | None = None
|
||||||
updatedDate: Optional[str] = None
|
updatedDate: str | None = None
|
||||||
phone: Optional[str] = None
|
phone: str | None = None
|
||||||
createdDate: Optional[str] = None
|
createdDate: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class SubmissionPdf(BaseModel):
|
class SubmissionPdf(BaseModel):
|
||||||
"""PDF submission structure"""
|
"""PDF submission structure"""
|
||||||
|
|
||||||
url: Optional[str] = None
|
url: str | None = None
|
||||||
filename: Optional[str] = None
|
filename: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class WixFormSubmission(BaseModel):
|
class WixFormSubmission(BaseModel):
|
||||||
"""Model for Wix form submission data"""
|
"""Model for Wix form submission data"""
|
||||||
|
|
||||||
formName: str
|
formName: str
|
||||||
submissions: List[Dict[str, Any]] = Field(default_factory=list)
|
submissions: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
submissionTime: str
|
submissionTime: str
|
||||||
formFieldMask: List[str] = Field(default_factory=list)
|
formFieldMask: list[str] = Field(default_factory=list)
|
||||||
submissionId: str
|
submissionId: str
|
||||||
contactId: str
|
contactId: str
|
||||||
submissionsLink: str
|
submissionsLink: str
|
||||||
submissionPdf: Optional[SubmissionPdf] = None
|
submissionPdf: SubmissionPdf | None = None
|
||||||
formId: str
|
formId: str
|
||||||
contact: Optional[Contact] = None
|
contact: Contact | None = None
|
||||||
|
|
||||||
# Dynamic form fields - these will capture all field:* entries
|
# Dynamic form fields - these will capture all field:* entries
|
||||||
class Config:
|
class Config:
|
||||||
|
|||||||
@@ -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 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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -18,8 +19,7 @@ REDIS_URL = os.getenv("REDIS_URL", None)
|
|||||||
|
|
||||||
|
|
||||||
def get_remote_address_with_forwarded(request: Request):
|
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)
|
# Check for forwarded headers (common in production behind proxies)
|
||||||
forwarded_for = request.headers.get("X-Forwarded-For")
|
forwarded_for = request.headers.get("X-Forwarded-For")
|
||||||
@@ -58,8 +58,7 @@ else:
|
|||||||
|
|
||||||
|
|
||||||
def get_api_key_identifier(request: Request) -> str:
|
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
|
This allows different rate limits per API key
|
||||||
"""
|
"""
|
||||||
# Try to get API key from Authorization header
|
# Try to get API key from Authorization header
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""Startup script for the Wix Form Handler API
|
||||||
Startup script for the Wix Form Handler API
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from .api import app
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
db_path = "alpinebits.db" # Adjust path if needed
|
db_path = "alpinebits.db" # Adjust path if needed
|
||||||
|
|||||||
253
src/alpine_bits_python/schemas.py
Normal file
253
src/alpine_bits_python/schemas.py
Normal 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
|
||||||
@@ -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.")
|
|
||||||
@@ -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!")
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from ..generated.alpinebits import OtaPingRq, OtaPingRs
|
|
||||||
from xsdata_pydantic.bindings import XmlParser
|
from xsdata_pydantic.bindings import XmlParser
|
||||||
|
|
||||||
|
from ..generated.alpinebits import OtaPingRs
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
# test parsing a ping request sample
|
# test parsing a ping request sample
|
||||||
@@ -9,7 +10,7 @@ def main():
|
|||||||
"AlpineBits-HotelData-2024-10/files/samples/Handshake/Handshake-OTA_PingRS.xml"
|
"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()
|
xml = f.read()
|
||||||
|
|
||||||
# Parse the XML into the request object
|
# Parse the XML into the request object
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""Convenience launcher for the Wix Form Handler API
|
||||||
Convenience launcher for the Wix Form Handler API
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -11,4 +10,4 @@ src_dir = os.path.join(os.path.dirname(__file__), "src/alpine_bits_python")
|
|||||||
|
|
||||||
# Run the API using uv
|
# Run the API using uv
|
||||||
if __name__ == "__main__":
|
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)
|
||||||
|
|||||||
@@ -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())
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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
|
import asyncio
|
||||||
|
|
||||||
from alpine_bits_python.alpinebits_server import AlpineBitsServer
|
from alpine_bits_python.alpinebits_server import AlpineBitsServer
|
||||||
|
|
||||||
|
|
||||||
@@ -17,7 +17,6 @@ async def main():
|
|||||||
# Read the sample handshake request
|
# Read the sample handshake request
|
||||||
with open(
|
with open(
|
||||||
"AlpineBits-HotelData-2024-10/files/samples/Handshake/Handshake-OTA_PingRQ.xml",
|
"AlpineBits-HotelData-2024-10/files/samples/Handshake/Handshake-OTA_PingRQ.xml",
|
||||||
"r",
|
|
||||||
) as f:
|
) as f:
|
||||||
ping_request_xml = f.read()
|
ping_request_xml = f.read()
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,21 @@
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from typing import Union
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
|
|
||||||
# Add the src directory to the path so we can import our modules
|
from alpine_bits_python.alpine_bits_helpers import (
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
AlpineBitsFactory,
|
||||||
|
|
||||||
from simplified_access import (
|
|
||||||
CustomerData,
|
CustomerData,
|
||||||
CustomerFactory,
|
CustomerFactory,
|
||||||
ResGuestFactory,
|
|
||||||
HotelReservationIdData,
|
HotelReservationIdData,
|
||||||
HotelReservationIdFactory,
|
HotelReservationIdFactory,
|
||||||
AlpineBitsFactory,
|
|
||||||
PhoneTechType,
|
|
||||||
OtaMessageType,
|
|
||||||
NotifCustomer,
|
NotifCustomer,
|
||||||
RetrieveCustomer,
|
|
||||||
NotifResGuests,
|
|
||||||
RetrieveResGuests,
|
|
||||||
NotifHotelReservationId,
|
NotifHotelReservationId,
|
||||||
|
NotifResGuests,
|
||||||
|
OtaMessageType,
|
||||||
|
PhoneTechType,
|
||||||
|
ResGuestFactory,
|
||||||
|
RetrieveCustomer,
|
||||||
RetrieveHotelReservationId,
|
RetrieveHotelReservationId,
|
||||||
|
RetrieveResGuests,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
0
tests/test_alpine_bits_server.py
Normal file
0
tests/test_alpine_bits_server.py
Normal file
455
tests/test_alpine_bits_server_read.py
Normal file
455
tests/test_alpine_bits_server_read.py
Normal 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"])
|
||||||
100
tests/test_alpinebits_server_ping.py
Normal file
100
tests/test_alpinebits_server_ping.py
Normal 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
|
||||||
158
tests/test_data/Handshake-OTA_PingRQ.xml
Normal file
158
tests/test_data/Handshake-OTA_PingRQ.xml
Normal 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>
|
||||||
190
tests/test_data/Handshake-OTA_PingRS.xml
Normal file
190
tests/test_data/Handshake-OTA_PingRS.xml
Normal 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>
|
||||||
241
tests/test_data/test_form1.json
Normal file
241
tests/test_data/test_form1.json
Normal 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"
|
||||||
|
}}
|
||||||
242
tests/test_data/test_form2.json
Normal file
242
tests/test_data/test_form2.json
Normal 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"
|
||||||
|
}}
|
||||||
244
tests/test_data/test_form3.json
Normal file
244
tests/test_data/test_form3.json
Normal 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"
|
||||||
|
}}
|
||||||
241
tests/test_data/test_form4.json
Normal file
241
tests/test_data/test_form4.json
Normal 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"
|
||||||
|
}}
|
||||||
64
tests/test_output/actual_ping_response.xml
Normal file
64
tests/test_output/actual_ping_response.xml
Normal 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>
|
||||||
81
tests/test_output/expected_ping_response.xml
Normal file
81
tests/test_output/expected_ping_response.xml
Normal 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
73
uv.lock
generated
@@ -24,8 +24,11 @@ dependencies = [
|
|||||||
{ name = "dotenv" },
|
{ name = "dotenv" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "generateds" },
|
{ name = "generateds" },
|
||||||
|
{ name = "httpx" },
|
||||||
{ name = "lxml" },
|
{ name = "lxml" },
|
||||||
|
{ name = "pydantic", extra = ["email"] },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-asyncio" },
|
||||||
{ name = "redis" },
|
{ name = "redis" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
{ name = "slowapi" },
|
{ name = "slowapi" },
|
||||||
@@ -43,8 +46,11 @@ requires-dist = [
|
|||||||
{ name = "dotenv", specifier = ">=0.9.9" },
|
{ name = "dotenv", specifier = ">=0.9.9" },
|
||||||
{ name = "fastapi", specifier = ">=0.117.1" },
|
{ name = "fastapi", specifier = ">=0.117.1" },
|
||||||
{ name = "generateds", specifier = ">=2.44.3" },
|
{ name = "generateds", specifier = ">=2.44.3" },
|
||||||
|
{ name = "httpx", specifier = ">=0.28.1" },
|
||||||
{ name = "lxml", specifier = ">=6.0.1" },
|
{ name = "lxml", specifier = ">=6.0.1" },
|
||||||
|
{ name = "pydantic", extras = ["email"], specifier = ">=2.11.9" },
|
||||||
{ name = "pytest", specifier = ">=8.4.2" },
|
{ name = "pytest", specifier = ">=8.4.2" },
|
||||||
|
{ name = "pytest-asyncio", specifier = ">=1.2.0" },
|
||||||
{ name = "redis", specifier = ">=6.4.0" },
|
{ name = "redis", specifier = ">=6.4.0" },
|
||||||
{ name = "ruff", specifier = ">=0.13.1" },
|
{ name = "ruff", specifier = ">=0.13.1" },
|
||||||
{ name = "slowapi", specifier = ">=0.1.9" },
|
{ 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" },
|
{ url = "https://files.pythonhosted.org/packages/6e/c6/ac0b6c1e2d138f1002bcf799d330bd6d85084fece321e662a14223794041/Deprecated-1.2.18-py2.py3-none-any.whl", hash = "sha256:bd5011788200372a32418f888e326a09ff80d0214bd961147cfed01b5c018eec", size = 9998, upload-time = "2025-01-27T10:46:09.186Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dnspython"
|
||||||
|
version = "2.8.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "docformatter"
|
name = "docformatter"
|
||||||
version = "1.7.7"
|
version = "1.7.7"
|
||||||
@@ -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" },
|
{ url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "email-validator"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "dnspython" },
|
||||||
|
{ name = "idna" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.117.1"
|
version = "0.117.1"
|
||||||
@@ -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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "3.10"
|
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" },
|
{ url = "https://files.pythonhosted.org/packages/3e/d3/108f2006987c58e76691d5ae5d200dd3e0f532cb4e5fa3560751c3a1feba/pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2", size = 444855, upload-time = "2025-09-13T11:26:36.909Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
email = [
|
||||||
|
{ name = "email-validator" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic-core"
|
name = "pydantic-core"
|
||||||
version = "2.33.2"
|
version = "2.33.2"
|
||||||
@@ -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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "python-dotenv"
|
name = "python-dotenv"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
|
|||||||
Reference in New Issue
Block a user