Compare commits
58 Commits
a343013eed
...
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 | ||
|
|
59347f504f | ||
|
|
13df12afc6 | ||
|
|
228aed6d58 | ||
|
|
c3a5d3bdbb | ||
|
|
b1be81023c | ||
|
|
6750a3d8a0 | ||
|
|
8a52765f87 | ||
|
|
eea25930ff |
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>
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
## AlpineBits Action Mapping System
|
|
||||||
|
|
||||||
### Problem Solved
|
|
||||||
The AlpineBits specification uses different names for the same action:
|
|
||||||
- **Capability JSON**: `"action_OTA_Read"` (advertised in handshake)
|
|
||||||
- **Request Action**: `"OTA_Read:GuestRequests"` (actual request parameter)
|
|
||||||
|
|
||||||
### Solution Architecture
|
|
||||||
|
|
||||||
#### 1. Enhanced AlpineBitsActionName Enum
|
|
||||||
```python
|
|
||||||
# Maps capability names to request names
|
|
||||||
OTA_READ = ("action_OTA_Read", ["OTA_Read:GuestRequests", "OTA_Read"])
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. Automatic Action Discovery
|
|
||||||
- `ServerCapabilities` scans for implemented actions
|
|
||||||
- Only includes actions with overridden `handle()` methods
|
|
||||||
- Generates capability JSON using capability names
|
|
||||||
|
|
||||||
#### 3. Request Routing
|
|
||||||
- `AlpineBitsServer.handle_request()` accepts request action names
|
|
||||||
- Maps request names back to capability names
|
|
||||||
- Routes to appropriate action handler
|
|
||||||
- Validates version support
|
|
||||||
|
|
||||||
### Key Features
|
|
||||||
|
|
||||||
✅ **Automatic Discovery**: New action implementations are automatically detected
|
|
||||||
✅ **Name Mapping**: Handles capability vs request name differences
|
|
||||||
✅ **Version Support**: Actions can support multiple versions
|
|
||||||
✅ **Error Handling**: Proper HTTP status codes (200, 400, 401, 500)
|
|
||||||
✅ **Capability Generation**: Dynamic JSON generation for handshakes
|
|
||||||
|
|
||||||
### Usage Example
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Server automatically discovers implemented actions
|
|
||||||
server = AlpineBitsServer()
|
|
||||||
|
|
||||||
# Handle request with different name format
|
|
||||||
response = await server.handle_request(
|
|
||||||
"OTA_Read:GuestRequests", # Request name
|
|
||||||
xml_content,
|
|
||||||
"2024-10"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Capability JSON uses "action_OTA_Read" automatically
|
|
||||||
capabilities = server.get_capabilities_json()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Adding New Actions
|
|
||||||
|
|
||||||
1. Create action class inheriting from `AlpineBitsAction`
|
|
||||||
2. Add mapping to `AlpineBitsActionName` enum
|
|
||||||
3. Implement `handle()` method
|
|
||||||
4. Deploy - action automatically appears in capabilities
|
|
||||||
|
|
||||||
The system is now production-ready for handling AlpineBits protocol quirks!
|
|
||||||
Binary file not shown.
180
README.md
180
README.md
@@ -1,28 +1,182 @@
|
|||||||
# Alpine bits
|
# Übersicht
|
||||||
|
|
||||||
Hour alpine bits application needs to mostly act as a server. It needs to provide room reservation information to the ASA alpinebits client at the hotel.
|
Enthält einen in Python geschriebenen Alpine Bits Server zur Übertragung von Buchungsanfragen von Landingpages an Partnerhotels. Ein Fastapi Endpoint empfängt Anfrageformulare von den wix.com landingpages, und speichert sie in die Datenbank ab. Der Alpine Bits Server stellt diese dann Hotels auf dem Endpoint `www.99tales.net/api/alpinebits/server-2024-10` zu Verfügung.
|
||||||
|
|
||||||
However in other things we act could potentially act as the client with ASA acting as the alpinebits server. Basically according to the documentation the whole thing depends on who is requesting information and who has it.
|
|
||||||
|
|
||||||
When ASA wants to know our GuestRequests from the Landing page then they are the client and we the server. This causes some problems because our system actually knows less than the hotel system. We can't easiliy add Room Rate information and publish a reservation to ASA because we don't actually know the rooms.
|
## Entwicklung
|
||||||
|
|
||||||
Just for GuestRequests this should be fine however.
|
Auf dem Entwicklungsystem muss git und der uv python package manager installiert sein.
|
||||||
|
|
||||||
|
### Git Authentification
|
||||||
|
|
||||||
|
Wenn über http geklont wird muss lokal der [git-credential-oauth](https://github.com/hickford/git-credential-oauth) helper installiert sein. Besser gehts über ssh. Da muss ein ssh-key in gitea für den eigenen Benutzer angelegt sein.
|
||||||
|
|
||||||
|
1. Repo klonen.
|
||||||
|
2. `uv sync` ausführen
|
||||||
|
3. `uv run python -m alpine_bits_python.run_api` führt die API lokal auf Port 8080 aus. Datenbank wird automatisch erstellt und bei jedem start geleert.
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
Erfolgt über zwei yaml files. Zu konfigurieren ist die Verbindung zur Datenbank und die Konfiguration der einzelnen Hotels. In zukunft kommt vermutlich auch noch die Push URL hinzu.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
database:
|
||||||
|
url: "sqlite+aiosqlite:///alpinebits.db" # For local dev, use SQLite. For prod, override with PostgreSQL URL.
|
||||||
|
# url: "postgresql://user:password@host:port/dbname" # Example for Postgres
|
||||||
|
|
||||||
|
alpine_bits_auth:
|
||||||
|
- hotel_id: "123"
|
||||||
|
hotel_name: "Frangart Inn"
|
||||||
|
username: "alice"
|
||||||
|
password: !secret ALICE_PASSWORD
|
||||||
|
- hotel_id: "456"
|
||||||
|
hotel_name: "Bemelmans"
|
||||||
|
username: "bob"
|
||||||
|
password: !secret BOB_PASSWORD
|
||||||
|
```
|
||||||
|
|
||||||
|
!secret verweist auf einen Eintrag in secrets.yaml. Diese Datei wird aus Sicherheitsgründen nicht auf die Repository hochgeladen. In secrets.yaml können passwörter folgendermaßen angegeben werden
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ALICE_PASSWORD: "supersecretpassword123"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Die Applikation wird in einem Dockercontainer deployed. Um das Container Image zu erstellen ist folgender Befehl notwendig
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync
|
||||||
|
docker build . -t gitea.linter-home.com/jonas/asa_api:master
|
||||||
|
```
|
||||||
|
Dieser Befehl muss im Wurzelverzeichnis der Repository ausgeführt werden. `pwd` sollte irgendwas/alpinebits_python ergeben. Der Punkt hinter dem docker build befehl verweißt nämlich auf das lokale Dockerfile. "-t" steht für tag. In diesem Beispiel wird das Image mit dem Tag `gitea.linter-home.com/jonas/asa_api:master` versehen.
|
||||||
|
|
||||||
|
Ideal wäre eine Build Pipeline in Gitea selbst aber dies aufzusetzen ist etwas schwierig und es ist gut möglich das die Hetzner VM das nicht herhat. Lokal bei mir zuhause ist dies aufgesetzt. War alles andere als leicht.
|
||||||
|
|
||||||
|
Am besten einfach direkt auf dem Zielsystem den Container bauen und im Docker Compose File dann auf dieses Image referenzieren.
|
||||||
|
|
||||||
|
|
||||||
|
### Docker Compose Beispiel mit Traefik Reverse Proxy
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
asa_connector:
|
||||||
|
image: gitea.linter-home.com/jonas/asa_api:master
|
||||||
|
container_name: asa_connector
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Environment variables via .env file
|
||||||
|
env_file:
|
||||||
|
- asa_connector.env
|
||||||
|
|
||||||
|
networks:
|
||||||
|
- external
|
||||||
|
|
||||||
|
# Only expose internally - Traefik will handle external access
|
||||||
|
expose:
|
||||||
|
- "8000"
|
||||||
|
|
||||||
|
user: "1000:1000" # Run as user with UID 1000 and GID 1000
|
||||||
|
|
||||||
|
environment:
|
||||||
|
- ALPINEBITS_CONFIG_DIR=/config
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- /home/jonas/asa_connector_logs:/app/src/logs
|
||||||
|
- /home/jonas/alpinebits_config:/config
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Traefik labels for automatic service discovery
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
# API router - handles /api/* paths on 99tales.net
|
||||||
|
- "traefik.http.routers.asa_connector.rule=Host(`99tales.net`) && PathPrefix(`/api`)"
|
||||||
|
- "traefik.http.routers.asa_connector.entrypoints=https"
|
||||||
|
- "traefik.http.routers.asa_connector.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.asa_connector.loadbalancer.server.port=8000"
|
||||||
|
- "traefik.http.routers.asa_connector.priority=100"
|
||||||
|
|
||||||
|
# Redirect middleware for non-API paths
|
||||||
|
- "traefik.http.middlewares.redirect-to-99tales-it.redirectregex.regex=^https://99tales\\.net/(.*)$$"
|
||||||
|
- "traefik.http.middlewares.redirect-to-99tales-it.redirectregex.replacement=https://99tales.it/$${1}"
|
||||||
|
- "traefik.http.middlewares.redirect-to-99tales-it.redirectregex.permanent=true"
|
||||||
|
|
||||||
|
# Catch-all router for non-API paths on 99tales.net (lower priority)
|
||||||
|
- "traefik.http.routers.redirect-router.rule=Host(`99tales.net`)"
|
||||||
|
- "traefik.http.routers.redirect-router.entrypoints=https"
|
||||||
|
- "traefik.http.routers.redirect-router.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.routers.redirect-router.middlewares=redirect-to-99tales-it"
|
||||||
|
- "traefik.http.routers.redirect-router.service=noop@internal"
|
||||||
|
- "traefik.http.routers.redirect-router.priority=1"
|
||||||
|
|
||||||
|
dockerproxy:
|
||||||
|
image: ghcr.io/tecnativa/docker-socket-proxy:latest
|
||||||
|
container_name: dockerproxy
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
CONTAINERS: 1 # read only
|
||||||
|
POST: 0
|
||||||
|
|
||||||
|
networks:
|
||||||
|
- external
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
|
||||||
|
|
||||||
|
traefik:
|
||||||
|
image: traefik:latest
|
||||||
|
container_name: traefik
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
|
||||||
|
environment:
|
||||||
|
- DOCKER_HOST=dockerproxy
|
||||||
|
|
||||||
|
networks:
|
||||||
|
- external
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- "80:80" # HTTP
|
||||||
|
- "443:443" # HTTPS
|
||||||
|
- "22:22" # SSH for Gitea
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- /home/jonas/traefik:/etc/traefik # Traefik configuration files
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import requests; requests.get('http://localhost:8000/health', timeout=5)"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 60s
|
||||||
|
|
||||||
|
networks: # custom bridge network named 'external'
|
||||||
|
external:
|
||||||
|
name: external
|
||||||
|
driver: bridge
|
||||||
|
```
|
||||||
|
|
||||||
|
Damit das ganze auch funktioniert müssen dns Einträge auf die Virtuelle Machine zeigen in der das ganze läuft. Wurde bei Hostinger für 99tales.net eingerichtet.
|
||||||
|
|
||||||
|
Wie in dem Beispiel ersichtlich wird sowohl ein Log Ordner als auch ein Config ordner in den Container gemapped. Diesen am besten auf dem Host vor Erstellung des Containers erstellen.
|
||||||
|
|
||||||
|
Die Umgebungsvariable `ALPINEBITS_CONFIG_DIR` sagt dann dem Programm wo es die Config finden soll. In dem Ordner kann man die obens erwähnten Konfigurationsdateien speichern. Falls sqlite als Datenbank verwendet wird, findet man dort auch die Datenbank nach erstem ausführen.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Wix formular parsing Notizen
|
# TODO Liste
|
||||||
|
|
||||||
|
Need a table in the database that stores requests that have already been acknowledged by the client. Should contain client_id + a list of all acked unique_ids
|
||||||
|
|
||||||
|
|
||||||
### Kontaktinformationen
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#### Werbeparameter
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
field:angebot_auswaehlen -> Kommentar mit Angebot
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,27 +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 HotelReservationResStatus, OtaHotelResNotifRq, OtaResRetrieveRs, CommentName2, UniqueIdType2
|
from .generated.alpinebits import (
|
||||||
import logging
|
CommentName2,
|
||||||
|
HotelReservationResStatus,
|
||||||
|
OtaHotelResNotifRq,
|
||||||
|
OtaResRetrieveRs,
|
||||||
|
ProfileProfileType,
|
||||||
|
UniqueIdType2,
|
||||||
|
)
|
||||||
|
|
||||||
_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 = (
|
||||||
@@ -45,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
|
||||||
@@ -66,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
|
||||||
@@ -155,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:
|
||||||
@@ -168,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,
|
||||||
@@ -243,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
|
||||||
@@ -309,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."""
|
||||||
|
|
||||||
@@ -344,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,
|
||||||
@@ -383,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."""
|
||||||
|
|
||||||
@@ -431,16 +379,19 @@ class CommentFactory:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _create_comments(
|
def _create_comments(
|
||||||
comments_class: type[RetrieveComments] | type[NotifComments], comment_class: type[RetrieveComment] | type[NotifComment], data: CommentsData
|
comments_class: type[RetrieveComments] | type[NotifComments],
|
||||||
|
comment_class: type[RetrieveComment] | type[NotifComment],
|
||||||
|
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
|
||||||
list_items = []
|
list_items = []
|
||||||
for item_data in comment_data.list_items:
|
for item_data in comment_data.list_items:
|
||||||
_LOGGER.info(f"Creating list item: value={item_data.value}, list_item={item_data.list_item}, language={item_data.language}")
|
_LOGGER.info(
|
||||||
|
f"Creating list item: value={item_data.value}, list_item={item_data.list_item}, language={item_data.language}"
|
||||||
|
)
|
||||||
|
|
||||||
list_item = comment_class.ListItem(
|
list_item = comment_class.ListItem(
|
||||||
value=item_data.value,
|
value=item_data.value,
|
||||||
@@ -471,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
|
||||||
@@ -486,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)
|
||||||
|
|
||||||
@@ -519,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)
|
||||||
|
|
||||||
@@ -545,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:
|
||||||
@@ -564,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.)
|
||||||
@@ -576,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
|
||||||
@@ -613,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
|
||||||
@@ -659,176 +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]]):
|
|
||||||
""" Create RetrievedReservation XML from database entries.
|
def create_res_retrieve_response(
|
||||||
|
list: list[tuple[Reservation, Customer]],
|
||||||
|
) -> OtaResRetrieveRs:
|
||||||
|
"""Create RetrievedReservation XML from database entries."""
|
||||||
|
return _create_xml_from_db(list, OtaMessageType.RETRIEVE)
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
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
|
||||||
_LOGGER.info(f"Creating XML for reservation {reservation.form_id} and customer {customer.given_name}")
|
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
entries = [entries]
|
||||||
|
|
||||||
|
for reservation, customer in entries:
|
||||||
|
_LOGGER.info(
|
||||||
|
f"Creating XML for reservation {reservation.unique_id} and customer {customer.given_name}"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
hotel_reservation = _process_single_reservation(reservation, customer, type)
|
||||||
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, 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(f"Error creating XML for reservation {reservation.form_id} and customer {customer.given_name}: {e}")
|
_LOGGER.error(
|
||||||
|
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,178 +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
|
|
||||||
|
|
||||||
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 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,43 +1,45 @@
|
|||||||
"""
|
"""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
|
|
||||||
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)
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class HttpStatusCode(IntEnum):
|
class HttpStatusCode(IntEnum):
|
||||||
"""Allowed HTTP status codes for AlpineBits responses."""
|
"""Allowed HTTP status codes for AlpineBits responses."""
|
||||||
|
|
||||||
@@ -47,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."""
|
||||||
|
|
||||||
@@ -54,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",
|
||||||
@@ -114,6 +128,15 @@ class Version(str, Enum):
|
|||||||
# Add other versions as needed
|
# Add other versions as needed
|
||||||
|
|
||||||
|
|
||||||
|
class AlpineBitsClientInfo:
|
||||||
|
"""Wrapper for username, password, client_id"""
|
||||||
|
|
||||||
|
def __init__(self, username: str, password: str, client_id: str | None = None):
|
||||||
|
self.username = username
|
||||||
|
self.password = password
|
||||||
|
self.client_id = client_id
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AlpineBitsResponse:
|
class AlpineBitsResponse:
|
||||||
"""Response data structure for AlpineBits actions."""
|
"""Response data structure for AlpineBits actions."""
|
||||||
@@ -139,10 +162,15 @@ class AlpineBitsAction(ABC):
|
|||||||
) # list of versions in case action supports multiple versions
|
) # list of versions in case action supports multiple versions
|
||||||
|
|
||||||
async def handle(
|
async def handle(
|
||||||
self, action: str, request_xml: str, version: Version, dbsession=None, server_capabilities=None, username=None, password=None, config: Dict = None
|
self,
|
||||||
|
action: str,
|
||||||
|
request_xml: str,
|
||||||
|
version: Version,
|
||||||
|
client_info: AlpineBitsClientInfo,
|
||||||
|
dbsession=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.
|
||||||
@@ -154,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
|
||||||
@@ -173,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
|
||||||
|
|
||||||
@@ -196,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
|
||||||
@@ -210,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
|
||||||
@@ -232,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:
|
||||||
@@ -242,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())
|
||||||
|
|
||||||
@@ -268,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 = None):
|
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,
|
||||||
@@ -276,18 +309,19 @@ class PingAction(AlpineBitsAction):
|
|||||||
] # Supports multiple versions
|
] # Supports multiple versions
|
||||||
self.config = config
|
self.config = config
|
||||||
|
|
||||||
|
@override
|
||||||
async def handle(
|
async def handle(
|
||||||
self,
|
self,
|
||||||
action: str,
|
action: str,
|
||||||
request_xml: str,
|
request_xml: str,
|
||||||
version: Version,
|
version: Version,
|
||||||
|
client_info: AlpineBitsClientInfo,
|
||||||
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:
|
||||||
@@ -301,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
|
||||||
@@ -314,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
|
||||||
@@ -349,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.value,
|
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="",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -379,21 +413,25 @@ class PingAction(AlpineBitsAction):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return AlpineBitsResponse(response_xml, HttpStatusCode.OK)
|
return AlpineBitsResponse(response_xml, HttpStatusCode.OK)
|
||||||
|
|
||||||
|
|
||||||
def strip_control_chars(s):
|
def strip_control_chars(s):
|
||||||
# Remove all control characters (ASCII < 32 and DEL)
|
# Remove all control characters (ASCII < 32 and DEL)
|
||||||
return re.sub(r'[\x00-\x1F\x7F]', '', s)
|
return re.sub(r"[\x00-\x1F\x7F]", "", s)
|
||||||
|
|
||||||
def validate_hotel_authentication(username: str, password: str, hotelid: str, config: Dict) -> bool:
|
|
||||||
""" Validate hotel authentication based on username, password, and hotel ID.
|
|
||||||
|
|
||||||
Example config
|
def validate_hotel_authentication(
|
||||||
alpine_bits_auth:
|
username: str, password: str, hotelid: str, config: dict
|
||||||
- hotel_id: "123"
|
) -> bool:
|
||||||
hotel_name: "Frangart Inn"
|
"""Validate hotel authentication based on username, password, and hotel ID.
|
||||||
username: "alice"
|
|
||||||
password: !secret ALICE_PASSWORD
|
Example config
|
||||||
|
alpine_bits_auth:
|
||||||
|
- hotel_id: "123"
|
||||||
|
hotel_name: "Frangart Inn"
|
||||||
|
username: "alice"
|
||||||
|
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"]
|
||||||
@@ -409,37 +447,38 @@ def validate_hotel_authentication(username: str, password: str, hotelid: str, co
|
|||||||
# look for hotelid in config
|
# look for hotelid in config
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class ReadAction(AlpineBitsAction):
|
class ReadAction(AlpineBitsAction):
|
||||||
"""Implementation for OTA_Read action."""
|
"""Implementation for OTA_Read action."""
|
||||||
|
|
||||||
def __init__(self, config: Dict = None):
|
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
|
||||||
|
|
||||||
async def handle(
|
async def handle(
|
||||||
self, action: str, request_xml: str, version: Version, dbsession=None, username=None, password=None
|
self,
|
||||||
|
action: str,
|
||||||
|
request_xml: str,
|
||||||
|
version: Version,
|
||||||
|
client_info: AlpineBitsClientInfo,
|
||||||
|
dbsession=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()
|
||||||
|
|
||||||
if clean_action != clean_expected:
|
if clean_action != clean_expected:
|
||||||
|
|
||||||
return AlpineBitsResponse(
|
return AlpineBitsResponse(
|
||||||
f"Error: Invalid action {action}, expected {self.name.value[1]}", HttpStatusCode.BAD_REQUEST
|
f"Error: Invalid action {action}, expected {self.name.value[1]}",
|
||||||
|
HttpStatusCode.BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
if dbsession is None:
|
if dbsession is None:
|
||||||
return AlpineBitsResponse(
|
return AlpineBitsResponse(
|
||||||
"Error: Something went wrong", HttpStatusCode.INTERNAL_SERVER_ERROR
|
"Error: Something went wrong", HttpStatusCode.INTERNAL_SERVER_ERROR
|
||||||
)
|
)
|
||||||
|
|
||||||
read_request = XmlParser().from_string(request_xml, OtaReadRq)
|
read_request = XmlParser().from_string(request_xml, OtaReadRq)
|
||||||
|
|
||||||
hotel_read_request = read_request.read_requests.hotel_read_request
|
hotel_read_request = read_request.read_requests.hotel_read_request
|
||||||
@@ -450,22 +489,26 @@ class ReadAction(AlpineBitsAction):
|
|||||||
if hotelname is None:
|
if hotelname is None:
|
||||||
hotelname = "unknown"
|
hotelname = "unknown"
|
||||||
|
|
||||||
if username is None or password is None or hotelid is None:
|
if hotelid is None:
|
||||||
return AlpineBitsResponse(
|
return AlpineBitsResponse(
|
||||||
f"Error: Unauthorized Read Request for this specific hotel {hotelname}. Check credentials", HttpStatusCode.UNAUTHORIZED
|
"Error: Unauthorized Read Request. No target hotel specified. Check credentials",
|
||||||
|
HttpStatusCode.UNAUTHORIZED,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not validate_hotel_authentication(username, 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", HttpStatusCode.UNAUTHORIZED
|
f"Error: Unauthorized Read Request for this specific hotel {hotelname}. Check credentials",
|
||||||
|
HttpStatusCode.UNAUTHORIZED,
|
||||||
)
|
)
|
||||||
|
|
||||||
start_date = None
|
start_date = None
|
||||||
|
|
||||||
if hotel_read_request.selection_criteria is not None:
|
if hotel_read_request.selection_criteria is not None:
|
||||||
start_date = datetime.fromisoformat(hotel_read_request.selection_criteria.start)
|
start_date = datetime.fromisoformat(
|
||||||
|
hotel_read_request.selection_criteria.start
|
||||||
|
)
|
||||||
|
|
||||||
# query all reservations for this hotel from the database, where start_date is greater than or equal to the given start_date
|
# query all reservations for this hotel from the database, where start_date is greater than or equal to the given start_date
|
||||||
|
|
||||||
@@ -476,15 +519,32 @@ 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]] = result.all() # List of (Reservation, Customer) tuples
|
reservation_customer_pairs: list[tuple[Reservation, Customer]] = (
|
||||||
|
result.all()
|
||||||
|
) # List of (Reservation, Customer) tuples
|
||||||
|
|
||||||
_LOGGER.info(f"Querying reservations and customers for hotel {hotelid} from database")
|
_LOGGER.info(
|
||||||
|
f"Querying reservations and customers for hotel {hotelid} from database"
|
||||||
|
)
|
||||||
for reservation, customer in reservation_customer_pairs:
|
for reservation, customer in reservation_customer_pairs:
|
||||||
_LOGGER.info(f"Reservation: {reservation.id}, Customer: {customer.given_name}")
|
_LOGGER.info(
|
||||||
|
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"
|
||||||
@@ -495,114 +555,150 @@ class ReadAction(AlpineBitsAction):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return AlpineBitsResponse(response_xml, HttpStatusCode.OK)
|
return AlpineBitsResponse(response_xml, HttpStatusCode.OK)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class NotifReportReadAction(AlpineBitsAction):
|
||||||
|
"""Necessary for read action to follow specification. Clients need to report acknowledgements"""
|
||||||
|
|
||||||
|
def __init__(self, config: dict = {}):
|
||||||
|
self.name = AlpineBitsActionName.OTA_HOTEL_NOTIF_REPORT
|
||||||
|
self.version = [Version.V2024_10, Version.V2022_10]
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
async def handle(
|
||||||
|
self,
|
||||||
|
action: str,
|
||||||
|
request_xml: str,
|
||||||
|
version: Version,
|
||||||
|
client_info: AlpineBitsClientInfo,
|
||||||
|
dbsession=None,
|
||||||
|
server_capabilities=None,
|
||||||
|
) -> AlpineBitsResponse:
|
||||||
|
"""Handle read requests."""
|
||||||
|
notif_report = XmlParser().from_string(request_xml, OtaNotifReportRq)
|
||||||
|
|
||||||
|
# we can't check hotel auth here, because this action does not contain hotel info
|
||||||
|
|
||||||
|
warnings = notif_report.warnings
|
||||||
|
notif_report_details = notif_report.notif_details
|
||||||
|
|
||||||
|
success_message = OtaNotifReportRs(version="7.000", success="")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# For demonstration, just echo back a simple XML response
|
|
||||||
response_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<OTA_ReadRS xmlns="http://www.opentravel.org/OTA/2003/
|
|
||||||
05" Version="8.000">
|
|
||||||
<Success/>
|
|
||||||
</OTA_ReadRS>"""
|
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
timestamp = datetime.now(ZoneInfo("UTC"))
|
||||||
|
for entry in (
|
||||||
|
notif_report_details.hotel_notif_report.hotel_reservations.hotel_reservation
|
||||||
|
): # type: ignore
|
||||||
|
unique_id = entry.unique_id.id
|
||||||
|
acked_request = AckedRequest(
|
||||||
|
unique_id=unique_id,
|
||||||
|
client_id=client_info.client_id,
|
||||||
|
timestamp=timestamp,
|
||||||
|
)
|
||||||
|
dbsession.add(acked_request)
|
||||||
|
|
||||||
|
await dbsession.commit()
|
||||||
|
|
||||||
return AlpineBitsResponse(response_xml, HttpStatusCode.OK)
|
return AlpineBitsResponse(response_xml, HttpStatusCode.OK)
|
||||||
|
|
||||||
|
|
||||||
# class HotelAvailNotifAction(AlpineBitsAction):
|
class PushAction(AlpineBitsAction):
|
||||||
# """Implementation for Hotel Availability Notification action with supports."""
|
"""Creates the necessary xml for OTA_HotelResNotif:GuestRequests"""
|
||||||
|
|
||||||
# def __init__(self):
|
def __init__(self, config: dict = {}):
|
||||||
# self.name = AlpineBitsActionName.OTA_HOTEL_AVAIL_NOTIF
|
|
||||||
# self.version = Version.V2022_10
|
|
||||||
# self.supports = [
|
|
||||||
# "OTA_HotelAvailNotif_accept_rooms",
|
|
||||||
# "OTA_HotelAvailNotif_accept_categories",
|
|
||||||
# "OTA_HotelAvailNotif_accept_deltas",
|
|
||||||
# "OTA_HotelAvailNotif_accept_BookingThreshold",
|
|
||||||
# ]
|
|
||||||
|
|
||||||
# async def handle(
|
|
||||||
# self, action: str, request_xml: str, version: Version
|
|
||||||
# ) -> AlpineBitsResponse:
|
|
||||||
# """Handle hotel availability notifications."""
|
|
||||||
# response_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
# <OTA_HotelAvailNotifRS xmlns="http://www.opentravel.org/OTA/2003/05" Version="8.000">
|
|
||||||
# <Success/>
|
|
||||||
# </OTA_HotelAvailNotifRS>"""
|
|
||||||
# return AlpineBitsResponse(response_xml, HttpStatusCode.OK)
|
|
||||||
|
|
||||||
|
|
||||||
class GuestRequestsAction(AlpineBitsAction):
|
|
||||||
"""Unimplemented action - will not appear in capabilities."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
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
|
||||||
self._initialize_action_instances()
|
self._initialize_action_instances()
|
||||||
|
|
||||||
|
|
||||||
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, request_action_name: str, request_xml: str, version: str = "2024-10", dbsession=None, username=None, password=None
|
self,
|
||||||
|
request_action_name: str,
|
||||||
|
request_xml: str | tuple[Reservation, Customer],
|
||||||
|
client_info: AlpineBitsClientInfo,
|
||||||
|
version: str = "2024-10",
|
||||||
|
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:
|
||||||
@@ -614,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}",
|
||||||
@@ -621,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):
|
||||||
@@ -640,26 +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":
|
|
||||||
|
if action_enum == AlpineBitsActionName.OTA_HOTEL_RES_NOTIF_GUEST_REQUESTS:
|
||||||
|
action_instance: PushAction
|
||||||
|
if request_xml is None or not isinstance(request_xml, tuple):
|
||||||
|
return AlpineBitsResponse(
|
||||||
|
"Error: Invalid data for push request",
|
||||||
|
HttpStatusCode.BAD_REQUEST,
|
||||||
|
)
|
||||||
return await action_instance.handle(
|
return await action_instance.handle(
|
||||||
request_action_name, request_xml, version_enum, self.capabilities
|
action=request_action_name,
|
||||||
|
request_xml=request_xml,
|
||||||
|
version=version_enum,
|
||||||
|
client_info=client_info,
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
|
if action_enum == AlpineBitsActionName.OTA_PING:
|
||||||
return await action_instance.handle(
|
return await action_instance.handle(
|
||||||
request_action_name, request_xml, version_enum, dbsession=dbsession, username=username, password=password
|
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():
|
||||||
@@ -669,10 +791,9 @@ class AlpineBitsServer:
|
|||||||
return sorted(request_names)
|
return sorted(request_names)
|
||||||
|
|
||||||
def is_action_supported(
|
def is_action_supported(
|
||||||
self, request_action_name: str, version: str = 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")
|
||||||
@@ -680,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:
|
||||||
@@ -696,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 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:
|
||||||
@@ -83,6 +206,7 @@ async def lifespan(app: FastAPI):
|
|||||||
# Optional: Dispose engine on shutdown
|
# Optional: Dispose engine on shutdown
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
async def get_async_session(request: Request):
|
async def get_async_session(request: Request):
|
||||||
async_sessionmaker = request.app.state.async_sessionmaker
|
async_sessionmaker = request.app.state.async_sessionmaker
|
||||||
async with async_sessionmaker() as session:
|
async with async_sessionmaker() as session:
|
||||||
@@ -93,7 +217,7 @@ app = FastAPI(
|
|||||||
title="Wix Form Handler API",
|
title="Wix Form Handler API",
|
||||||
description="Secure API endpoint to receive and process Wix form submissions with authentication and rate limiting",
|
description="Secure API endpoint to receive and process Wix form submissions with authentication and rate limiting",
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
lifespan=lifespan
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create API router with /api prefix
|
# Create API router with /api prefix
|
||||||
@@ -118,45 +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):
|
||||||
@@ -188,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}")
|
||||||
@@ -237,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
|
||||||
@@ -273,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
|
||||||
|
|
||||||
@@ -310,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"),
|
||||||
@@ -332,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",
|
||||||
@@ -349,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']
|
||||||
@@ -445,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:
|
||||||
@@ -512,10 +711,11 @@ def parse_multipart_data(content_type: str, body: bytes) -> Dict[str, Any]:
|
|||||||
@api_router.post("/alpinebits/server-2024-10")
|
@api_router.post("/alpinebits/server-2024-10")
|
||||||
@limiter.limit("60/minute")
|
@limiter.limit("60/minute")
|
||||||
async def alpinebits_server_handshake(
|
async def alpinebits_server_handshake(
|
||||||
request: Request, credentials_tupel: tuple = Depends(validate_basic_auth), dbsession=Depends(get_async_session)
|
request: Request,
|
||||||
|
credentials_tupel: tuple = Depends(validate_basic_auth),
|
||||||
|
dbsession=Depends(get_async_session),
|
||||||
):
|
):
|
||||||
"""
|
"""AlpineBits server endpoint implementing the handshake protocol.
|
||||||
AlpineBits server endpoint implementing the handshake protocol.
|
|
||||||
|
|
||||||
This endpoint handles:
|
This endpoint handles:
|
||||||
- Protocol version negotiation via X-AlpineBits-ClientProtocolVersion header
|
- Protocol version negotiation via X-AlpineBits-ClientProtocolVersion header
|
||||||
@@ -541,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")
|
||||||
@@ -558,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")
|
||||||
@@ -615,14 +779,24 @@ async def alpinebits_server_handshake(
|
|||||||
# Get optional request XML
|
# Get optional request XML
|
||||||
request_xml = form_data.get("request")
|
request_xml = form_data.get("request")
|
||||||
|
|
||||||
server = app.state.alpine_bits_server
|
server: AlpineBitsServer = app.state.alpine_bits_server
|
||||||
|
|
||||||
version = Version.V2024_10
|
version = Version.V2024_10
|
||||||
|
|
||||||
username, password = credentials_tupel
|
username, password = credentials_tupel
|
||||||
|
|
||||||
|
client_info = AlpineBitsClientInfo(
|
||||||
|
username=username, password=password, client_id=client_id
|
||||||
|
)
|
||||||
|
|
||||||
# Create successful handshake response
|
# Create successful handshake response
|
||||||
response = await server.handle_request(action, request_xml, version, dbsession=dbsession, username=username, password=password)
|
response = await server.handle_request(
|
||||||
|
action,
|
||||||
|
request_xml,
|
||||||
|
client_info=client_info,
|
||||||
|
version=version,
|
||||||
|
dbsession=dbsession,
|
||||||
|
)
|
||||||
|
|
||||||
response_xml = response.xml_content
|
response_xml = response.xml_content
|
||||||
|
|
||||||
@@ -633,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
|
||||||
)
|
)
|
||||||
@@ -641,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":
|
||||||
@@ -674,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,18 +1,21 @@
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
from annotatedyaml.loader import (
|
from annotatedyaml.loader import (
|
||||||
HAS_C_LOADER,
|
|
||||||
JSON_TYPE,
|
|
||||||
LoaderType,
|
|
||||||
Secrets,
|
Secrets,
|
||||||
add_constructor,
|
|
||||||
load_yaml as load_annotated_yaml,
|
|
||||||
load_yaml_dict as load_annotated_yaml_dict,
|
|
||||||
parse_yaml as parse_annotated_yaml,
|
|
||||||
secret_yaml as annotated_secret_yaml,
|
|
||||||
)
|
)
|
||||||
from voluptuous import Schema, Required, All, Length, PREVENT_EXTRA, MultipleInvalid
|
from annotatedyaml.loader import (
|
||||||
|
load_yaml as load_annotated_yaml,
|
||||||
|
)
|
||||||
|
from voluptuous import (
|
||||||
|
PREVENT_EXTRA,
|
||||||
|
All,
|
||||||
|
Length,
|
||||||
|
MultipleInvalid,
|
||||||
|
Optional,
|
||||||
|
Required,
|
||||||
|
Schema,
|
||||||
|
)
|
||||||
|
|
||||||
# --- Voluptuous schemas ---
|
# --- Voluptuous schemas ---
|
||||||
database_schema = Schema({Required("url"): str}, extra=PREVENT_EXTRA)
|
database_schema = Schema({Required("url"): str}, extra=PREVENT_EXTRA)
|
||||||
@@ -24,6 +27,11 @@ hotel_auth_schema = Schema(
|
|||||||
Required("hotel_name"): str,
|
Required("hotel_name"): str,
|
||||||
Required("username"): str,
|
Required("username"): str,
|
||||||
Required("password"): str,
|
Required("password"): str,
|
||||||
|
Optional("push_endpoint"): {
|
||||||
|
Required("url"): str,
|
||||||
|
Required("token"): str,
|
||||||
|
Optional("username"): str,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
extra=PREVENT_EXTRA,
|
extra=PREVENT_EXTRA,
|
||||||
)
|
)
|
||||||
@@ -88,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"]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
0
src/alpine_bits_python/const.py
Normal file
0
src/alpine_bits_python/const.py
Normal file
@@ -1,8 +1,8 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Date, Boolean, ForeignKey, DateTime
|
|
||||||
from sqlalchemy.orm import declarative_base, relationship
|
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
|
||||||
import os
|
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()
|
||||||
|
|
||||||
|
|
||||||
@@ -18,9 +18,6 @@ def get_database_url(config=None):
|
|||||||
return db_url
|
return db_url
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class Customer(Base):
|
class Customer(Base):
|
||||||
__tablename__ = "customers"
|
__tablename__ = "customers"
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
@@ -47,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)
|
||||||
@@ -71,12 +68,12 @@ class Reservation(Base):
|
|||||||
customer = relationship("Customer", back_populates="reservations")
|
customer = relationship("Customer", back_populates="reservations")
|
||||||
|
|
||||||
|
|
||||||
|
# Table for tracking acknowledged requests by client
|
||||||
class HashedCustomer(Base):
|
class AckedRequest(Base):
|
||||||
__tablename__ = "hashed_customers"
|
__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,59 +1,42 @@
|
|||||||
|
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)
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def setup_db(config):
|
async def setup_db(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)
|
||||||
@@ -67,7 +50,6 @@ async def setup_db(config):
|
|||||||
return engine, AsyncSessionLocal
|
return engine, AsyncSessionLocal
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
print("🚀 Starting AlpineBits XML generation script...")
|
print("🚀 Starting AlpineBits XML generation script...")
|
||||||
# Load config (yaml, annotatedyaml)
|
# Load config (yaml, annotatedyaml)
|
||||||
@@ -92,7 +74,6 @@ async def main():
|
|||||||
|
|
||||||
# # Ensure DB schema is created (async)
|
# # Ensure DB schema is created (async)
|
||||||
|
|
||||||
|
|
||||||
engine, AsyncSessionLocal = await setup_db(config)
|
engine, AsyncSessionLocal = await setup_db(config)
|
||||||
|
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
@@ -104,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"]
|
||||||
|
|
||||||
@@ -200,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"),
|
||||||
@@ -227,8 +208,6 @@ async def main():
|
|||||||
|
|
||||||
|
|
||||||
def create_xml_from_db(customer: DBCustomer, reservation: DBReservation):
|
def create_xml_from_db(customer: DBCustomer, reservation: DBReservation):
|
||||||
|
|
||||||
|
|
||||||
# Prepare data for XML
|
# Prepare data for XML
|
||||||
phone_numbers = [(customer.phone, PhoneTechType.MOBILE)] if customer.phone else []
|
phone_numbers = [(customer.phone, PhoneTechType.MOBILE)] if customer.phone else []
|
||||||
customer_data = CustomerData(
|
customer_data = CustomerData(
|
||||||
@@ -261,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
|
||||||
@@ -335,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,
|
||||||
@@ -366,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