Compare commits
98 Commits
v1.1.3
...
52f95bd677
| 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 | ||
|
|
a343013eed | ||
|
|
7380fa4378 | ||
|
|
642b6cb7a5 | ||
|
|
ebcf2c22dd | ||
|
|
373cf0882a | ||
|
|
6c2ce2dc08 | ||
|
|
7f25fb2b02 | ||
|
|
54c002ac96 | ||
|
|
382bf2334a | ||
|
|
06739ebea9 | ||
|
|
384fb2b558 | ||
|
|
8d4ccc4041 | ||
|
|
6688a9a465 | ||
|
|
12f245ae06 | ||
| 679785dd1c | |||
| 9eb993cba5 | |||
|
|
b79288f6b6 | ||
|
|
52114a7443 | ||
|
|
784ff0e5da | ||
| 0a6c4f64e8 | |||
|
|
958e48b40a | ||
|
|
76176f8a79 | ||
|
|
4416397a69 | ||
|
|
ff00edf35d | ||
|
|
7b539ea42f | ||
|
|
5fb313d4cc | ||
|
|
553fcc7a24 | ||
|
|
0f7f1532a0 | ||
|
|
4cfc00abb1 | ||
|
|
9f80f49693 | ||
|
|
e95b680ff0 | ||
|
|
4ceb50f9ed | ||
|
|
eda3bf505e | ||
|
|
d9f814ca64 | ||
|
|
06ea9caded | ||
|
|
2af9558b35 | ||
|
|
e9cf606dde | ||
|
|
7ed6ceecc5 | ||
|
|
31b6c7eceb | ||
|
|
85214344ef |
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 }}
|
||||
10
.github/workflows/publish.yaml
vendored
10
.github/workflows/publish.yaml
vendored
@@ -13,15 +13,11 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
uses: astral-sh/setup-uv@v5
|
||||
- name: Install Python 3.13
|
||||
run: uv python install 3.13
|
||||
- name: Build
|
||||
run: uv build
|
||||
run: uv auth login gitea.linter-home.com --username jonas --password ${{ secrets.CI_TOKEN }} && uv build
|
||||
# Check that basic features work and we didn't miss to include crucial files
|
||||
- name: Smoke test (wheel)
|
||||
run: uv run --isolated --no-project --with dist/*.whl tests/smoke_test.py
|
||||
- name: Smoke test (source distribution)
|
||||
run: uv run --isolated --no-project --with dist/*.tar.gz tests/smoke_test.py
|
||||
- name: Publish
|
||||
run: uv publish --publish-url https://gitea.linter-home.com.com/api/packages/jonas/pypi --username jonas --password ${{ secrets.GITEA_TOKEN }}
|
||||
run: uv publish --publish-url https://gitea.linter-home.com/api/packages/jonas/pypi --username jonas --password ${{ secrets.CI_TOKEN }}
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -13,3 +13,15 @@ wheels/
|
||||
|
||||
# exclude ruff cache
|
||||
.ruff_cache/
|
||||
|
||||
# ignore test_data content but keep the folder
|
||||
test_data/*
|
||||
|
||||
test/test_output/*
|
||||
|
||||
|
||||
# ignore secrets
|
||||
secrets.yaml
|
||||
|
||||
# ignore db
|
||||
alpinebits.db
|
||||
|
||||
56
.vscode/settings.json
vendored
56
.vscode/settings.json
vendored
@@ -1,7 +1,53 @@
|
||||
{
|
||||
"python.testing.pytestArgs": [
|
||||
"test"
|
||||
],
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.pytestEnabled": true
|
||||
"editor.formatOnSave": true,
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "charliermarsh.ruff",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit",
|
||||
"source.organizeImports": "explicit"
|
||||
}
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"notebook.formatOnSave.enabled": true,
|
||||
"notebook.codeActionsOnSave": {
|
||||
// "notebook.source.fixAll": "explicit",
|
||||
// "notebook.source.organizeImports": "explicit"
|
||||
},
|
||||
"notebook.output.wordWrap": true,
|
||||
"notebook.output.textLineLimit": 200,
|
||||
"jupyter.debugJustMyCode": false,
|
||||
"python.testing.pytestEnabled": true,
|
||||
"files.exclude": {
|
||||
"**/*.egg-info": true,
|
||||
"**/htmlcov": true,
|
||||
"**/~$*": true,
|
||||
"**/.coverage.*": true,
|
||||
"**/.venv": true,
|
||||
"**/__pycache__": true,
|
||||
"**/.mypy_cache": true,
|
||||
"**/.pytest_cache": true,
|
||||
}
|
||||
}
|
||||
|
||||
// .vscode/launch.json
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Python: Debug Tests",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"program": "${file}",
|
||||
"purpose": [
|
||||
"debug-test"
|
||||
],
|
||||
"console": "integratedTerminal",
|
||||
"justMyCode": false,
|
||||
"env": {
|
||||
"PYTEST_ADDOPTS": "--no-cov"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
24
99Tales_Testexport.xml
Normal file
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.
@@ -1,93 +0,0 @@
|
||||
class GivenNameType(GeneratedsSuper):
|
||||
__hash__ = GeneratedsSuper.__hash__
|
||||
subclass = None
|
||||
superclass = None
|
||||
def __init__(self, valueOf_=None, gds_collector_=None, **kwargs_):
|
||||
self.gds_collector_ = gds_collector_
|
||||
self.gds_elementtree_node_ = None
|
||||
self.original_tagname_ = None
|
||||
self.parent_object_ = kwargs_.get('parent_object_')
|
||||
self.ns_prefix_ = None
|
||||
self.valueOf_ = valueOf_
|
||||
def factory(*args_, **kwargs_):
|
||||
if CurrentSubclassModule_ is not None:
|
||||
subclass = getSubclassFromModule_(
|
||||
CurrentSubclassModule_, GivenNameType)
|
||||
if subclass is not None:
|
||||
return subclass(*args_, **kwargs_)
|
||||
if GivenNameType.subclass:
|
||||
return GivenNameType.subclass(*args_, **kwargs_)
|
||||
else:
|
||||
return GivenNameType(*args_, **kwargs_)
|
||||
factory = staticmethod(factory)
|
||||
def get_ns_prefix_(self):
|
||||
return self.ns_prefix_
|
||||
def set_ns_prefix_(self, ns_prefix):
|
||||
self.ns_prefix_ = ns_prefix
|
||||
def get_valueOf_(self): return self.valueOf_
|
||||
def set_valueOf_(self, valueOf_): self.valueOf_ = valueOf_
|
||||
def validate_StringLength1to64(self, value):
|
||||
result = True
|
||||
# Validate type StringLength1to64, a restriction on xs:string.
|
||||
if value is not None and Validate_simpletypes_ and self.gds_collector_ is not None:
|
||||
if not isinstance(value, str):
|
||||
lineno = self.gds_get_node_lineno_()
|
||||
self.gds_collector_.add_message('Value "%(value)s"%(lineno)s is not of the correct base simple type (str)' % {"value": value, "lineno": lineno, })
|
||||
return False
|
||||
if len(value) > 64:
|
||||
lineno = self.gds_get_node_lineno_()
|
||||
self.gds_collector_.add_message('Value "%(value)s"%(lineno)s does not match xsd maxLength restriction on StringLength1to64' % {"value" : encode_str_2_3(value), "lineno": lineno} )
|
||||
result = False
|
||||
if len(value) < 1:
|
||||
lineno = self.gds_get_node_lineno_()
|
||||
self.gds_collector_.add_message('Value "%(value)s"%(lineno)s does not match xsd minLength restriction on StringLength1to64' % {"value" : encode_str_2_3(value), "lineno": lineno} )
|
||||
result = False
|
||||
return result
|
||||
def has__content(self):
|
||||
if (
|
||||
(1 if type(self.valueOf_) in [int,float] else self.valueOf_)
|
||||
):
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
def export(self, outfile, level, namespaceprefix_='', namespacedef_='', name_='GivenNameType', pretty_print=True):
|
||||
imported_ns_def_ = GenerateDSNamespaceDefs_.get('GivenNameType')
|
||||
if imported_ns_def_ is not None:
|
||||
namespacedef_ = imported_ns_def_
|
||||
if pretty_print:
|
||||
eol_ = '\n'
|
||||
else:
|
||||
eol_ = ''
|
||||
if self.original_tagname_ is not None and name_ == 'GivenNameType':
|
||||
name_ = self.original_tagname_
|
||||
if UseCapturedNS_ and self.ns_prefix_:
|
||||
namespaceprefix_ = self.ns_prefix_ + ':'
|
||||
showIndent(outfile, level, pretty_print)
|
||||
outfile.write('<%s%s%s' % (namespaceprefix_, name_, namespacedef_ and ' ' + namespacedef_ or '', ))
|
||||
already_processed = set()
|
||||
self._exportAttributes(outfile, level, already_processed, namespaceprefix_, name_='GivenNameType')
|
||||
outfile.write('>')
|
||||
self._exportChildren(outfile, level + 1, namespaceprefix_, namespacedef_, name_, pretty_print=pretty_print)
|
||||
outfile.write(self.convert_unicode(self.valueOf_))
|
||||
outfile.write('</%s%s>%s' % (namespaceprefix_, name_, eol_))
|
||||
def _exportAttributes(self, outfile, level, already_processed, namespaceprefix_='', name_='GivenNameType'):
|
||||
pass
|
||||
def _exportChildren(self, outfile, level, namespaceprefix_='', namespacedef_='', name_='GivenNameType', fromsubclass_=False, pretty_print=True):
|
||||
pass
|
||||
def build(self, node, gds_collector_=None):
|
||||
self.gds_collector_ = gds_collector_
|
||||
if SaveElementTreeNode:
|
||||
self.gds_elementtree_node_ = node
|
||||
already_processed = set()
|
||||
self.ns_prefix_ = node.prefix
|
||||
self._buildAttributes(node, node.attrib, already_processed)
|
||||
self.valueOf_ = get_all_text_(node)
|
||||
for child in node:
|
||||
nodeName_ = Tag_pattern_.match(child.tag).groups()[-1]
|
||||
self._buildChildren(child, node, nodeName_, gds_collector_=gds_collector_)
|
||||
return self
|
||||
def _buildAttributes(self, node, attrs, already_processed):
|
||||
pass
|
||||
def _buildChildren(self, child_, node, nodeName_, fromsubclass_=False, gds_collector_=None):
|
||||
pass
|
||||
# end class GivenNameType
|
||||
@@ -1,93 +0,0 @@
|
||||
class SurnameType(GeneratedsSuper):
|
||||
__hash__ = GeneratedsSuper.__hash__
|
||||
subclass = None
|
||||
superclass = None
|
||||
def __init__(self, valueOf_=None, gds_collector_=None, **kwargs_):
|
||||
self.gds_collector_ = gds_collector_
|
||||
self.gds_elementtree_node_ = None
|
||||
self.original_tagname_ = None
|
||||
self.parent_object_ = kwargs_.get('parent_object_')
|
||||
self.ns_prefix_ = None
|
||||
self.valueOf_ = valueOf_
|
||||
def factory(*args_, **kwargs_):
|
||||
if CurrentSubclassModule_ is not None:
|
||||
subclass = getSubclassFromModule_(
|
||||
CurrentSubclassModule_, SurnameType)
|
||||
if subclass is not None:
|
||||
return subclass(*args_, **kwargs_)
|
||||
if SurnameType.subclass:
|
||||
return SurnameType.subclass(*args_, **kwargs_)
|
||||
else:
|
||||
return SurnameType(*args_, **kwargs_)
|
||||
factory = staticmethod(factory)
|
||||
def get_ns_prefix_(self):
|
||||
return self.ns_prefix_
|
||||
def set_ns_prefix_(self, ns_prefix):
|
||||
self.ns_prefix_ = ns_prefix
|
||||
def get_valueOf_(self): return self.valueOf_
|
||||
def set_valueOf_(self, valueOf_): self.valueOf_ = valueOf_
|
||||
def validate_StringLength1to64(self, value):
|
||||
result = True
|
||||
# Validate type StringLength1to64, a restriction on xs:string.
|
||||
if value is not None and Validate_simpletypes_ and self.gds_collector_ is not None:
|
||||
if not isinstance(value, str):
|
||||
lineno = self.gds_get_node_lineno_()
|
||||
self.gds_collector_.add_message('Value "%(value)s"%(lineno)s is not of the correct base simple type (str)' % {"value": value, "lineno": lineno, })
|
||||
return False
|
||||
if len(value) > 64:
|
||||
lineno = self.gds_get_node_lineno_()
|
||||
self.gds_collector_.add_message('Value "%(value)s"%(lineno)s does not match xsd maxLength restriction on StringLength1to64' % {"value" : encode_str_2_3(value), "lineno": lineno} )
|
||||
result = False
|
||||
if len(value) < 1:
|
||||
lineno = self.gds_get_node_lineno_()
|
||||
self.gds_collector_.add_message('Value "%(value)s"%(lineno)s does not match xsd minLength restriction on StringLength1to64' % {"value" : encode_str_2_3(value), "lineno": lineno} )
|
||||
result = False
|
||||
return result
|
||||
def has__content(self):
|
||||
if (
|
||||
(1 if type(self.valueOf_) in [int,float] else self.valueOf_)
|
||||
):
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
def export(self, outfile, level, namespaceprefix_='', namespacedef_='', name_='SurnameType', pretty_print=True):
|
||||
imported_ns_def_ = GenerateDSNamespaceDefs_.get('SurnameType')
|
||||
if imported_ns_def_ is not None:
|
||||
namespacedef_ = imported_ns_def_
|
||||
if pretty_print:
|
||||
eol_ = '\n'
|
||||
else:
|
||||
eol_ = ''
|
||||
if self.original_tagname_ is not None and name_ == 'SurnameType':
|
||||
name_ = self.original_tagname_
|
||||
if UseCapturedNS_ and self.ns_prefix_:
|
||||
namespaceprefix_ = self.ns_prefix_ + ':'
|
||||
showIndent(outfile, level, pretty_print)
|
||||
outfile.write('<%s%s%s' % (namespaceprefix_, name_, namespacedef_ and ' ' + namespacedef_ or '', ))
|
||||
already_processed = set()
|
||||
self._exportAttributes(outfile, level, already_processed, namespaceprefix_, name_='SurnameType')
|
||||
outfile.write('>')
|
||||
self._exportChildren(outfile, level + 1, namespaceprefix_, namespacedef_, name_, pretty_print=pretty_print)
|
||||
outfile.write(self.convert_unicode(self.valueOf_))
|
||||
outfile.write('</%s%s>%s' % (namespaceprefix_, name_, eol_))
|
||||
def _exportAttributes(self, outfile, level, already_processed, namespaceprefix_='', name_='SurnameType'):
|
||||
pass
|
||||
def _exportChildren(self, outfile, level, namespaceprefix_='', namespacedef_='', name_='SurnameType', fromsubclass_=False, pretty_print=True):
|
||||
pass
|
||||
def build(self, node, gds_collector_=None):
|
||||
self.gds_collector_ = gds_collector_
|
||||
if SaveElementTreeNode:
|
||||
self.gds_elementtree_node_ = node
|
||||
already_processed = set()
|
||||
self.ns_prefix_ = node.prefix
|
||||
self._buildAttributes(node, node.attrib, already_processed)
|
||||
self.valueOf_ = get_all_text_(node)
|
||||
for child in node:
|
||||
nodeName_ = Tag_pattern_.match(child.tag).groups()[-1]
|
||||
self._buildChildren(child, node, nodeName_, gds_collector_=gds_collector_)
|
||||
return self
|
||||
def _buildAttributes(self, node, attrs, already_processed):
|
||||
pass
|
||||
def _buildChildren(self, child_, node, nodeName_, fromsubclass_=False, gds_collector_=None):
|
||||
pass
|
||||
# end class SurnameType
|
||||
66
Dockerfile
Normal file
66
Dockerfile
Normal file
@@ -0,0 +1,66 @@
|
||||
# Multi-stage build for smaller final image
|
||||
FROM python:3.13-slim AS builder
|
||||
|
||||
# Install uv for fast dependency management
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy dependency files
|
||||
COPY pyproject.toml uv.lock README.md ./
|
||||
|
||||
ARG CI_TOKEN
|
||||
|
||||
# Install dependencies in a virtual environment
|
||||
RUN uv sync --frozen --no-cache
|
||||
|
||||
# Production stage
|
||||
FROM python:3.13-slim
|
||||
|
||||
# Create non-root user for security
|
||||
RUN groupadd -r appuser && useradd -r -g appuser -u 1000 appuser
|
||||
|
||||
# Install uv in production image
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy virtual environment from builder stage
|
||||
COPY --from=builder /app/.venv /app/.venv
|
||||
|
||||
# Copy application code
|
||||
COPY src/ ./src/
|
||||
|
||||
# Create directories and set permissions
|
||||
RUN mkdir -p /app/logs && \
|
||||
chown -R appuser:appuser /app
|
||||
|
||||
# Switch to non-root user
|
||||
USER appuser
|
||||
|
||||
# Set environment variables
|
||||
ENV PATH="/app/.venv/bin:$PATH" \
|
||||
PYTHONPATH="/app/src" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
# Expose port (non-privileged port)
|
||||
EXPOSE 8000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=120s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD python -c "import requests; requests.get('http://localhost:8000/api/health', timeout=5)"
|
||||
|
||||
# Run the application with uvicorn
|
||||
WORKDIR /app/src
|
||||
CMD uvicorn alpine_bits_python.api:app \
|
||||
--host 0.0.0.0 \
|
||||
--port 8000 \
|
||||
--workers 4 \
|
||||
--log-level info \
|
||||
--access-log \
|
||||
--forwarded-allow-ips "${FORWARDED_ALLOW_IPS:-127.0.0.1}" \
|
||||
--proxy-headers \
|
||||
--no-server-header
|
||||
182
README.md
182
README.md
@@ -1,10 +1,182 @@
|
||||
# Alpine bits
|
||||
# Übersicht
|
||||
|
||||
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.
|
||||
|
||||
|
||||
## Entwicklung
|
||||
|
||||
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.
|
||||
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
Just for GuestRequests this should be fine however.
|
||||
|
||||
|
||||
29
config/config.yaml
Normal file
29
config/config.yaml
Normal file
@@ -0,0 +1,29 @@
|
||||
# AlpineBits Python config
|
||||
# Use annotatedyaml for secrets and environment-specific overrides
|
||||
|
||||
database:
|
||||
url: "sqlite+aiosqlite:///alpinebits.db" # For local dev, use SQLite. For prod, override with PostgreSQL URL.
|
||||
# url: "postgresql://user:password@host:port/dbname" # Example for Postgres
|
||||
|
||||
# AlpineBits Python config
|
||||
# Use annotatedyaml for secrets and environment-specific overrides
|
||||
|
||||
alpine_bits_auth:
|
||||
- hotel_id: "39054_001"
|
||||
hotel_name: "Bemelmans Post"
|
||||
username: "bemelman"
|
||||
password: !secret BEMELMANS_PASSWORD
|
||||
- hotel_id: "135"
|
||||
hotel_name: "Testhotel"
|
||||
username: "sebastian"
|
||||
password: !secret BOB_PASSWORD
|
||||
|
||||
- hotel_id: "39052_001"
|
||||
hotel_name: "Jagthof Kaltern"
|
||||
username: "jagthof"
|
||||
password: !secret JAGTHOF_PASSWORD
|
||||
|
||||
- hotel_id: "39040_001"
|
||||
hotel_name: "Residence Erika"
|
||||
username: "erika"
|
||||
password: !secret ERIKA_PASSWORD
|
||||
@@ -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>
|
||||
250
logs/wix_test_data_20250929_154411.json
Normal file
250
logs/wix_test_data_20250929_154411.json
Normal file
@@ -0,0 +1,250 @@
|
||||
{
|
||||
"timestamp": "2025-09-29T15:44:11.839852",
|
||||
"client_ip": "127.0.0.1",
|
||||
"headers": {
|
||||
"host": "localhost:8080",
|
||||
"content-type": "application/json",
|
||||
"user-agent": "insomnia/2023.5.8",
|
||||
"accept": "*/*",
|
||||
"content-length": "6920"
|
||||
},
|
||||
"data": {
|
||||
"data": {
|
||||
"formName": "Contact us",
|
||||
"submissions": [
|
||||
{
|
||||
"label": "Angebot auswählen",
|
||||
"value": "Herbstferien - Familienzeit mit Dolomitenblick"
|
||||
},
|
||||
{
|
||||
"label": "Anreisedatum",
|
||||
"value": "2025-10-31"
|
||||
},
|
||||
{
|
||||
"label": "Abreisedatum",
|
||||
"value": "2025-11-02"
|
||||
},
|
||||
{
|
||||
"label": "Anzahl Erwachsene",
|
||||
"value": "2"
|
||||
},
|
||||
{
|
||||
"label": "Anzahl Kinder",
|
||||
"value": "3"
|
||||
},
|
||||
{
|
||||
"label": "Alter Kind 1",
|
||||
"value": "3"
|
||||
},
|
||||
{
|
||||
"label": "Alter Kind 2",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"label": "Alter Kind 3",
|
||||
"value": "0"
|
||||
},
|
||||
{
|
||||
"label": "Anrede",
|
||||
"value": "Frau"
|
||||
},
|
||||
{
|
||||
"label": "Vorname",
|
||||
"value": "Elena"
|
||||
},
|
||||
{
|
||||
"label": "Nachname",
|
||||
"value": "Battiloro"
|
||||
},
|
||||
{
|
||||
"label": "Email",
|
||||
"value": "e.battiloro1@gmail.com"
|
||||
},
|
||||
{
|
||||
"label": "Phone",
|
||||
"value": "+39 333 767 3262"
|
||||
},
|
||||
{
|
||||
"label": "Einwilligung Marketing",
|
||||
"value": "Non selezionato"
|
||||
},
|
||||
{
|
||||
"label": "utm_Source",
|
||||
"value": "ig"
|
||||
},
|
||||
{
|
||||
"label": "utm_Medium",
|
||||
"value": "Instagram_Stories"
|
||||
},
|
||||
{
|
||||
"label": "utm_Campaign",
|
||||
"value": "Conversions_Hotel_Bemelmans_ITA"
|
||||
},
|
||||
{
|
||||
"label": "utm_Term",
|
||||
"value": "Cold_Traffic_Conversions_Hotel_Bemelmans_ITA"
|
||||
},
|
||||
{
|
||||
"label": "utm_Content",
|
||||
"value": "Grafik_4_Spätsommer_23.08-07.09_Landingpage_ITA"
|
||||
},
|
||||
{
|
||||
"label": "utm_term_id",
|
||||
"value": "120232007764490196"
|
||||
},
|
||||
{
|
||||
"label": "utm_content_id",
|
||||
"value": "120232007764490196"
|
||||
},
|
||||
{
|
||||
"label": "gad_source",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "gad_campaignid",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "gbraid",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "gclid",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "fbclid",
|
||||
"value": "PAZXh0bgNhZW0BMABhZGlkAasmYBhk4DQBp02L46Rl1jAuccxsOaeFSv7WSFnP-MQCsOrz9yDnKRH4hwZ7GEgxF9gy0_OF_aem_qSvrs6xsBkvTaI_Y9_hfnQ"
|
||||
}
|
||||
],
|
||||
"field:date_picker_7e65": "2025-11-02",
|
||||
"field:number_7cf5": "2",
|
||||
"field:utm_source": "ig",
|
||||
"submissionTime": "2025-09-28T13:26:07.938Z",
|
||||
"field:alter_kind_3": "3",
|
||||
"field:gad_source": "",
|
||||
"field:form_field_5a7b": "Non selezionato",
|
||||
"field:gad_campaignid": "",
|
||||
"field:utm_medium": "Instagram_Stories",
|
||||
"field:utm_term_id": "120232007764490196",
|
||||
"context": {
|
||||
"metaSiteId": "1dea821c-8168-4736-96e4-4b92e8b364cf",
|
||||
"activationId": "3fd865e1-f44a-49d2-ae29-19cf77ee488a"
|
||||
},
|
||||
"field:email_5139": "e.battiloro1@gmail.com",
|
||||
"field:phone_4c77": "+39 333 767 3262",
|
||||
"_context": {
|
||||
"activation": {
|
||||
"id": "3fd865e1-f44a-49d2-ae29-19cf77ee488a"
|
||||
},
|
||||
"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:angebot_auswaehlen",
|
||||
"field:date_picker_a7c8",
|
||||
"field:date_picker_7e65",
|
||||
"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:anrede",
|
||||
"field:first_name_abae",
|
||||
"field:last_name_d97c",
|
||||
"field:email_5139",
|
||||
"field:phone_4c77",
|
||||
"field:long_answer_3524",
|
||||
"field:form_field_5a7b",
|
||||
"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",
|
||||
"metaSiteId"
|
||||
],
|
||||
"field:alter_kind_4": "0",
|
||||
"contact": {
|
||||
"name": {
|
||||
"first": "Elena",
|
||||
"last": "Battiloro"
|
||||
},
|
||||
"email": "e.battiloro1@gmail.com",
|
||||
"locale": "it-it",
|
||||
"phones": [
|
||||
{
|
||||
"tag": "UNTAGGED",
|
||||
"formattedPhone": "+39 333 767 3262",
|
||||
"id": "7e5c8512-b88e-4cf0-8d0c-9ebe6b210924",
|
||||
"countryCode": "IT",
|
||||
"e164Phone": "+393337673262",
|
||||
"primary": true,
|
||||
"phone": "333 767 3262"
|
||||
}
|
||||
],
|
||||
"contactId": "b9d47825-9f84-4ae7-873c-d169851b5888",
|
||||
"emails": [
|
||||
{
|
||||
"id": "c5609c67-5eba-4068-ab21-8a2ab9a09a27",
|
||||
"tag": "UNTAGGED",
|
||||
"email": "e.battiloro1@gmail.com",
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"updatedDate": "2025-09-28T13:26:09.916Z",
|
||||
"phone": "+393337673262",
|
||||
"createdDate": "2025-08-08T13:05:23.733Z"
|
||||
},
|
||||
"submissionId": "02fbc71c-745b-4c73-9cba-827d0958117a",
|
||||
"field:anzahl_kinder": "3",
|
||||
"field:alter_kind_25": "1",
|
||||
"field:first_name_abae": "Elena",
|
||||
"field:utm_content_id": "120232007764490196",
|
||||
"field:utm_campaign": "Conversions_Hotel_Bemelmans_ITA",
|
||||
"field:utm_term": "Cold_Traffic_Conversions_Hotel_Bemelmans_ITA",
|
||||
"contactId": "b9d47825-9f84-4ae7-873c-d169851b5888",
|
||||
"field:date_picker_a7c8": "2025-10-31",
|
||||
"field:angebot_auswaehlen": "Herbstferien - Familienzeit mit Dolomitenblick",
|
||||
"field:utm_content": "Grafik_4_Spätsommer_23.08-07.09_Landingpage_ITA",
|
||||
"field:last_name_d97c": "Battiloro",
|
||||
"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": "PAZXh0bgNhZW0BMABhZGlkAasmYBhk4DQBp02L46Rl1jAuccxsOaeFSv7WSFnP-MQCsOrz9yDnKRH4hwZ7GEgxF9gy0_OF_aem_qSvrs6xsBkvTaI_Y9_hfnQ",
|
||||
"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": "6920"
|
||||
}
|
||||
}
|
||||
250
logs/wix_test_data_20250929_154454.json
Normal file
250
logs/wix_test_data_20250929_154454.json
Normal file
@@ -0,0 +1,250 @@
|
||||
{
|
||||
"timestamp": "2025-09-29T15:44:54.746579",
|
||||
"client_ip": "127.0.0.1",
|
||||
"headers": {
|
||||
"host": "localhost:8080",
|
||||
"content-type": "application/json",
|
||||
"user-agent": "insomnia/2023.5.8",
|
||||
"accept": "*/*",
|
||||
"content-length": "6920"
|
||||
},
|
||||
"data": {
|
||||
"data": {
|
||||
"formName": "Contact us",
|
||||
"submissions": [
|
||||
{
|
||||
"label": "Angebot auswählen",
|
||||
"value": "Herbstferien - Familienzeit mit Dolomitenblick"
|
||||
},
|
||||
{
|
||||
"label": "Anreisedatum",
|
||||
"value": "2025-10-31"
|
||||
},
|
||||
{
|
||||
"label": "Abreisedatum",
|
||||
"value": "2025-11-02"
|
||||
},
|
||||
{
|
||||
"label": "Anzahl Erwachsene",
|
||||
"value": "2"
|
||||
},
|
||||
{
|
||||
"label": "Anzahl Kinder",
|
||||
"value": "3"
|
||||
},
|
||||
{
|
||||
"label": "Alter Kind 1",
|
||||
"value": "3"
|
||||
},
|
||||
{
|
||||
"label": "Alter Kind 2",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"label": "Alter Kind 3",
|
||||
"value": "0"
|
||||
},
|
||||
{
|
||||
"label": "Anrede",
|
||||
"value": "Frau"
|
||||
},
|
||||
{
|
||||
"label": "Vorname",
|
||||
"value": "Elena"
|
||||
},
|
||||
{
|
||||
"label": "Nachname",
|
||||
"value": "Battiloro"
|
||||
},
|
||||
{
|
||||
"label": "Email",
|
||||
"value": "e.battiloro1@gmail.com"
|
||||
},
|
||||
{
|
||||
"label": "Phone",
|
||||
"value": "+39 333 767 3262"
|
||||
},
|
||||
{
|
||||
"label": "Einwilligung Marketing",
|
||||
"value": "Non selezionato"
|
||||
},
|
||||
{
|
||||
"label": "utm_Source",
|
||||
"value": "ig"
|
||||
},
|
||||
{
|
||||
"label": "utm_Medium",
|
||||
"value": "Instagram_Stories"
|
||||
},
|
||||
{
|
||||
"label": "utm_Campaign",
|
||||
"value": "Conversions_Hotel_Bemelmans_ITA"
|
||||
},
|
||||
{
|
||||
"label": "utm_Term",
|
||||
"value": "Cold_Traffic_Conversions_Hotel_Bemelmans_ITA"
|
||||
},
|
||||
{
|
||||
"label": "utm_Content",
|
||||
"value": "Grafik_4_Spätsommer_23.08-07.09_Landingpage_ITA"
|
||||
},
|
||||
{
|
||||
"label": "utm_term_id",
|
||||
"value": "120232007764490196"
|
||||
},
|
||||
{
|
||||
"label": "utm_content_id",
|
||||
"value": "120232007764490196"
|
||||
},
|
||||
{
|
||||
"label": "gad_source",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "gad_campaignid",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "gbraid",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "gclid",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "fbclid",
|
||||
"value": "PAZXh0bgNhZW0BMABhZGlkAasmYBhk4DQBp02L46Rl1jAuccxsOaeFSv7WSFnP-MQCsOrz9yDnKRH4hwZ7GEgxF9gy0_OF_aem_qSvrs6xsBkvTaI_Y9_hfnQ"
|
||||
}
|
||||
],
|
||||
"field:date_picker_7e65": "2025-11-02",
|
||||
"field:number_7cf5": "2",
|
||||
"field:utm_source": "ig",
|
||||
"submissionTime": "2025-09-28T13:26:07.938Z",
|
||||
"field:alter_kind_3": "3",
|
||||
"field:gad_source": "",
|
||||
"field:form_field_5a7b": "Non selezionato",
|
||||
"field:gad_campaignid": "",
|
||||
"field:utm_medium": "Instagram_Stories",
|
||||
"field:utm_term_id": "120232007764490196",
|
||||
"context": {
|
||||
"metaSiteId": "1dea821c-8168-4736-96e4-4b92e8b364cf",
|
||||
"activationId": "3fd865e1-f44a-49d2-ae29-19cf77ee488a"
|
||||
},
|
||||
"field:email_5139": "e.battiloro1@gmail.com",
|
||||
"field:phone_4c77": "+39 333 767 3262",
|
||||
"_context": {
|
||||
"activation": {
|
||||
"id": "3fd865e1-f44a-49d2-ae29-19cf77ee488a"
|
||||
},
|
||||
"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:angebot_auswaehlen",
|
||||
"field:date_picker_a7c8",
|
||||
"field:date_picker_7e65",
|
||||
"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:anrede",
|
||||
"field:first_name_abae",
|
||||
"field:last_name_d97c",
|
||||
"field:email_5139",
|
||||
"field:phone_4c77",
|
||||
"field:long_answer_3524",
|
||||
"field:form_field_5a7b",
|
||||
"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",
|
||||
"metaSiteId"
|
||||
],
|
||||
"field:alter_kind_4": "0",
|
||||
"contact": {
|
||||
"name": {
|
||||
"first": "Elena",
|
||||
"last": "Battiloro"
|
||||
},
|
||||
"email": "e.battiloro1@gmail.com",
|
||||
"locale": "it-it",
|
||||
"phones": [
|
||||
{
|
||||
"tag": "UNTAGGED",
|
||||
"formattedPhone": "+39 333 767 3262",
|
||||
"id": "7e5c8512-b88e-4cf0-8d0c-9ebe6b210924",
|
||||
"countryCode": "IT",
|
||||
"e164Phone": "+393337673262",
|
||||
"primary": true,
|
||||
"phone": "333 767 3262"
|
||||
}
|
||||
],
|
||||
"contactId": "b9d47825-9f84-4ae7-873c-d169851b5888",
|
||||
"emails": [
|
||||
{
|
||||
"id": "c5609c67-5eba-4068-ab21-8a2ab9a09a27",
|
||||
"tag": "UNTAGGED",
|
||||
"email": "e.battiloro1@gmail.com",
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"updatedDate": "2025-09-28T13:26:09.916Z",
|
||||
"phone": "+393337673262",
|
||||
"createdDate": "2025-08-08T13:05:23.733Z"
|
||||
},
|
||||
"submissionId": "02fbc71c-745b-4c73-9cba-827d0958117a",
|
||||
"field:anzahl_kinder": "3",
|
||||
"field:alter_kind_25": "1",
|
||||
"field:first_name_abae": "Elena",
|
||||
"field:utm_content_id": "120232007764490196",
|
||||
"field:utm_campaign": "Conversions_Hotel_Bemelmans_ITA",
|
||||
"field:utm_term": "Cold_Traffic_Conversions_Hotel_Bemelmans_ITA",
|
||||
"contactId": "b9d47825-9f84-4ae7-873c-d169851b5888",
|
||||
"field:date_picker_a7c8": "2025-10-31",
|
||||
"field:angebot_auswaehlen": "Herbstferien - Familienzeit mit Dolomitenblick",
|
||||
"field:utm_content": "Grafik_4_Spätsommer_23.08-07.09_Landingpage_ITA",
|
||||
"field:last_name_d97c": "Battiloro",
|
||||
"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": "PAZXh0bgNhZW0BMABhZGlkAasmYBhk4DQBp02L46Rl1jAuccxsOaeFSv7WSFnP-MQCsOrz9yDnKRH4hwZ7GEgxF9gy0_OF_aem_qSvrs6xsBkvTaI_Y9_hfnQ",
|
||||
"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": "6920"
|
||||
}
|
||||
}
|
||||
170
logs/wix_test_data_20250929_160843.json
Normal file
170
logs/wix_test_data_20250929_160843.json
Normal file
@@ -0,0 +1,170 @@
|
||||
{
|
||||
"timestamp": "2025-09-29T16:08:43.177480",
|
||||
"client_ip": "127.0.0.1",
|
||||
"headers": {
|
||||
"host": "localhost:8080",
|
||||
"content-type": "application/json",
|
||||
"user-agent": "insomnia/2023.5.8",
|
||||
"accept": "*/*",
|
||||
"content-length": "4518"
|
||||
},
|
||||
"data": {
|
||||
"data": {
|
||||
"formName": "Contact us",
|
||||
"submissions": [
|
||||
{
|
||||
"label": "Anreisedatum",
|
||||
"value": "2026-01-17"
|
||||
},
|
||||
{
|
||||
"label": "Abreisedatum",
|
||||
"value": "2026-01-24"
|
||||
},
|
||||
{
|
||||
"label": "Anzahl Erwachsene",
|
||||
"value": "2"
|
||||
},
|
||||
{
|
||||
"label": "Anzahl Kinder",
|
||||
"value": "0"
|
||||
},
|
||||
{
|
||||
"label": "Anrede",
|
||||
"value": "Herr"
|
||||
},
|
||||
{
|
||||
"label": "Vorname",
|
||||
"value": "Weislinger "
|
||||
},
|
||||
{
|
||||
"label": "Nachname",
|
||||
"value": "Alain "
|
||||
},
|
||||
{
|
||||
"label": "Email",
|
||||
"value": "alain-et-evelyne@hotmail.fr"
|
||||
},
|
||||
{
|
||||
"label": "Phone",
|
||||
"value": "+33 6 41 77 99 09"
|
||||
},
|
||||
{
|
||||
"label": "Einwilligung Marketing",
|
||||
"value": "Cochée"
|
||||
}
|
||||
],
|
||||
"field:date_picker_7e65": "2026-01-24",
|
||||
"field:number_7cf5": "2",
|
||||
"submissionTime": "2025-09-27T19:36:39.137Z",
|
||||
"field:form_field_5a7b": "Cochée",
|
||||
"context": {
|
||||
"metaSiteId": "7b28c2ce-1e20-4d07-9e86-73d822007e18",
|
||||
"activationId": "d59c463c-96e0-4742-b4f7-70b8f0431168"
|
||||
},
|
||||
"field:email_5139": "alain-et-evelyne@hotmail.fr",
|
||||
"field:phone_4c77": "+33 6 41 77 99 09",
|
||||
"_context": {
|
||||
"activation": {
|
||||
"id": "d59c463c-96e0-4742-b4f7-70b8f0431168"
|
||||
},
|
||||
"configuration": {
|
||||
"id": "483806f6-24ba-413f-9431-6b1ad9379f5c"
|
||||
},
|
||||
"app": {
|
||||
"id": "225dd912-7dea-4738-8688-4b8c6955ffc2"
|
||||
},
|
||||
"action": {
|
||||
"id": "a85d9873-f8ed-426a-90b0-fb64a8e50406"
|
||||
},
|
||||
"trigger": {
|
||||
"key": "wix_form_app-form_submitted"
|
||||
}
|
||||
},
|
||||
"formFieldMask": [
|
||||
"field:angebot_auswaehlen",
|
||||
"field:date_picker_a7c8",
|
||||
"field:date_picker_7e65",
|
||||
"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:anrede",
|
||||
"field:first_name_abae",
|
||||
"field:last_name_d97c",
|
||||
"field:email_5139",
|
||||
"field:phone_4c77",
|
||||
"field:long_answer_3524",
|
||||
"field:form_field_5a7b",
|
||||
"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",
|
||||
"metaSiteId"
|
||||
],
|
||||
"contact": {
|
||||
"name": {
|
||||
"first": "Weislinger",
|
||||
"last": "Alain"
|
||||
},
|
||||
"email": "alain-et-evelyne@hotmail.fr",
|
||||
"locale": "de-de",
|
||||
"phones": [
|
||||
{
|
||||
"tag": "UNTAGGED",
|
||||
"formattedPhone": "+33 6 41 77 99 09",
|
||||
"id": "90ffc824-1fd7-4167-b29f-24a4b62a0773",
|
||||
"countryCode": "FR",
|
||||
"e164Phone": "+33641779909",
|
||||
"primary": true,
|
||||
"phone": "6 41 77 99 09"
|
||||
}
|
||||
],
|
||||
"contactId": "250e24db-d41e-4f6e-835d-75acdf2ef2b7",
|
||||
"emails": [
|
||||
{
|
||||
"id": "2c071108-2410-4db8-99fa-b50b75a02493",
|
||||
"tag": "UNTAGGED",
|
||||
"email": "alain-et-evelyne@hotmail.fr",
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"updatedDate": "2025-09-27T19:36:41.908Z",
|
||||
"phone": "+33641779909",
|
||||
"createdDate": "2025-09-27T19:36:41.054Z"
|
||||
},
|
||||
"submissionId": "6cfee967-69a8-454a-a10e-0aa03868ba6d",
|
||||
"field:anzahl_kinder": "0",
|
||||
"field:first_name_abae": "Weislinger ",
|
||||
"contactId": "250e24db-d41e-4f6e-835d-75acdf2ef2b7",
|
||||
"field:date_picker_a7c8": "2026-01-17",
|
||||
"field:last_name_d97c": "Alain ",
|
||||
"submissionsLink": "https://manage.wix.app/forms/submissions/7b28c2ce-1e20-4d07-9e86-73d822007e18/e084006b-ae83-4e4d-b2f5-074118cdb3b1?d=https%3A%2F%2Fmanage.wix.com%2Fdashboard%2F7b28c2ce-1e20-4d07-9e86-73d822007e18%2Fwix-forms%2Fform%2Fe084006b-ae83-4e4d-b2f5-074118cdb3b1%2Fsubmissions&s=true",
|
||||
"field:anrede": "Herr",
|
||||
"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": "4518"
|
||||
}
|
||||
}
|
||||
170
logs/wix_test_data_20250929_162447.json
Normal file
170
logs/wix_test_data_20250929_162447.json
Normal file
@@ -0,0 +1,170 @@
|
||||
{
|
||||
"timestamp": "2025-09-29T16:24:47.833595",
|
||||
"client_ip": "127.0.0.1",
|
||||
"headers": {
|
||||
"host": "localhost:8080",
|
||||
"content-type": "application/json",
|
||||
"user-agent": "insomnia/2023.5.8",
|
||||
"accept": "*/*",
|
||||
"content-length": "4518"
|
||||
},
|
||||
"data": {
|
||||
"data": {
|
||||
"formName": "Contact us",
|
||||
"submissions": [
|
||||
{
|
||||
"label": "Anreisedatum",
|
||||
"value": "2026-01-17"
|
||||
},
|
||||
{
|
||||
"label": "Abreisedatum",
|
||||
"value": "2026-01-24"
|
||||
},
|
||||
{
|
||||
"label": "Anzahl Erwachsene",
|
||||
"value": "2"
|
||||
},
|
||||
{
|
||||
"label": "Anzahl Kinder",
|
||||
"value": "0"
|
||||
},
|
||||
{
|
||||
"label": "Anrede",
|
||||
"value": "Herr"
|
||||
},
|
||||
{
|
||||
"label": "Vorname",
|
||||
"value": "Weislinger "
|
||||
},
|
||||
{
|
||||
"label": "Nachname",
|
||||
"value": "Alain "
|
||||
},
|
||||
{
|
||||
"label": "Email",
|
||||
"value": "alain-et-evelyne@hotmail.fr"
|
||||
},
|
||||
{
|
||||
"label": "Phone",
|
||||
"value": "+33 6 41 77 99 09"
|
||||
},
|
||||
{
|
||||
"label": "Einwilligung Marketing",
|
||||
"value": "Cochée"
|
||||
}
|
||||
],
|
||||
"field:date_picker_7e65": "2026-01-24",
|
||||
"field:number_7cf5": "2",
|
||||
"submissionTime": "2025-09-27T19:36:39.137Z",
|
||||
"field:form_field_5a7b": "Cochée",
|
||||
"context": {
|
||||
"metaSiteId": "7b28c2ce-1e20-4d07-9e86-73d822007e18",
|
||||
"activationId": "d59c463c-96e0-4742-b4f7-70b8f0431168"
|
||||
},
|
||||
"field:email_5139": "alain-et-evelyne@hotmail.fr",
|
||||
"field:phone_4c77": "+33 6 41 77 99 09",
|
||||
"_context": {
|
||||
"activation": {
|
||||
"id": "d59c463c-96e0-4742-b4f7-70b8f0431168"
|
||||
},
|
||||
"configuration": {
|
||||
"id": "483806f6-24ba-413f-9431-6b1ad9379f5c"
|
||||
},
|
||||
"app": {
|
||||
"id": "225dd912-7dea-4738-8688-4b8c6955ffc2"
|
||||
},
|
||||
"action": {
|
||||
"id": "a85d9873-f8ed-426a-90b0-fb64a8e50406"
|
||||
},
|
||||
"trigger": {
|
||||
"key": "wix_form_app-form_submitted"
|
||||
}
|
||||
},
|
||||
"formFieldMask": [
|
||||
"field:angebot_auswaehlen",
|
||||
"field:date_picker_a7c8",
|
||||
"field:date_picker_7e65",
|
||||
"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:anrede",
|
||||
"field:first_name_abae",
|
||||
"field:last_name_d97c",
|
||||
"field:email_5139",
|
||||
"field:phone_4c77",
|
||||
"field:long_answer_3524",
|
||||
"field:form_field_5a7b",
|
||||
"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",
|
||||
"metaSiteId"
|
||||
],
|
||||
"contact": {
|
||||
"name": {
|
||||
"first": "Weislinger",
|
||||
"last": "Alain"
|
||||
},
|
||||
"email": "alain-et-evelyne@hotmail.fr",
|
||||
"locale": "de-de",
|
||||
"phones": [
|
||||
{
|
||||
"tag": "UNTAGGED",
|
||||
"formattedPhone": "+33 6 41 77 99 09",
|
||||
"id": "90ffc824-1fd7-4167-b29f-24a4b62a0773",
|
||||
"countryCode": "FR",
|
||||
"e164Phone": "+33641779909",
|
||||
"primary": true,
|
||||
"phone": "6 41 77 99 09"
|
||||
}
|
||||
],
|
||||
"contactId": "250e24db-d41e-4f6e-835d-75acdf2ef2b7",
|
||||
"emails": [
|
||||
{
|
||||
"id": "2c071108-2410-4db8-99fa-b50b75a02493",
|
||||
"tag": "UNTAGGED",
|
||||
"email": "alain-et-evelyne@hotmail.fr",
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"updatedDate": "2025-09-27T19:36:41.908Z",
|
||||
"phone": "+33641779909",
|
||||
"createdDate": "2025-09-27T19:36:41.054Z"
|
||||
},
|
||||
"submissionId": "6cfee967-69a8-454a-a10e-0aa03868ba6d",
|
||||
"field:anzahl_kinder": "0",
|
||||
"field:first_name_abae": "Weislinger ",
|
||||
"contactId": "250e24db-d41e-4f6e-835d-75acdf2ef2b7",
|
||||
"field:date_picker_a7c8": "2026-01-17",
|
||||
"field:last_name_d97c": "Alain ",
|
||||
"submissionsLink": "https://manage.wix.app/forms/submissions/7b28c2ce-1e20-4d07-9e86-73d822007e18/e084006b-ae83-4e4d-b2f5-074118cdb3b1?d=https%3A%2F%2Fmanage.wix.com%2Fdashboard%2F7b28c2ce-1e20-4d07-9e86-73d822007e18%2Fwix-forms%2Fform%2Fe084006b-ae83-4e4d-b2f5-074118cdb3b1%2Fsubmissions&s=true",
|
||||
"field:anrede": "Herr",
|
||||
"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": "4518"
|
||||
}
|
||||
}
|
||||
170
logs/wix_test_data_20250929_163212.json
Normal file
170
logs/wix_test_data_20250929_163212.json
Normal file
@@ -0,0 +1,170 @@
|
||||
{
|
||||
"timestamp": "2025-09-29T16:32:12.776585",
|
||||
"client_ip": "127.0.0.1",
|
||||
"headers": {
|
||||
"host": "localhost:8080",
|
||||
"content-type": "application/json",
|
||||
"user-agent": "insomnia/2023.5.8",
|
||||
"accept": "*/*",
|
||||
"content-length": "4518"
|
||||
},
|
||||
"data": {
|
||||
"data": {
|
||||
"formName": "Contact us",
|
||||
"submissions": [
|
||||
{
|
||||
"label": "Anreisedatum",
|
||||
"value": "2026-01-17"
|
||||
},
|
||||
{
|
||||
"label": "Abreisedatum",
|
||||
"value": "2026-01-24"
|
||||
},
|
||||
{
|
||||
"label": "Anzahl Erwachsene",
|
||||
"value": "2"
|
||||
},
|
||||
{
|
||||
"label": "Anzahl Kinder",
|
||||
"value": "0"
|
||||
},
|
||||
{
|
||||
"label": "Anrede",
|
||||
"value": "Herr"
|
||||
},
|
||||
{
|
||||
"label": "Vorname",
|
||||
"value": "Weislinger "
|
||||
},
|
||||
{
|
||||
"label": "Nachname",
|
||||
"value": "Alain "
|
||||
},
|
||||
{
|
||||
"label": "Email",
|
||||
"value": "alain-et-evelyne@hotmail.fr"
|
||||
},
|
||||
{
|
||||
"label": "Phone",
|
||||
"value": "+33 6 41 77 99 09"
|
||||
},
|
||||
{
|
||||
"label": "Einwilligung Marketing",
|
||||
"value": "Cochée"
|
||||
}
|
||||
],
|
||||
"field:date_picker_7e65": "2026-01-24",
|
||||
"field:number_7cf5": "2",
|
||||
"submissionTime": "2025-09-27T19:36:39.137Z",
|
||||
"field:form_field_5a7b": "Cochée",
|
||||
"context": {
|
||||
"metaSiteId": "7b28c2ce-1e20-4d07-9e86-73d822007e18",
|
||||
"activationId": "d59c463c-96e0-4742-b4f7-70b8f0431168"
|
||||
},
|
||||
"field:email_5139": "alain-et-evelyne@hotmail.fr",
|
||||
"field:phone_4c77": "+33 6 41 77 99 09",
|
||||
"_context": {
|
||||
"activation": {
|
||||
"id": "d59c463c-96e0-4742-b4f7-70b8f0431168"
|
||||
},
|
||||
"configuration": {
|
||||
"id": "483806f6-24ba-413f-9431-6b1ad9379f5c"
|
||||
},
|
||||
"app": {
|
||||
"id": "225dd912-7dea-4738-8688-4b8c6955ffc2"
|
||||
},
|
||||
"action": {
|
||||
"id": "a85d9873-f8ed-426a-90b0-fb64a8e50406"
|
||||
},
|
||||
"trigger": {
|
||||
"key": "wix_form_app-form_submitted"
|
||||
}
|
||||
},
|
||||
"formFieldMask": [
|
||||
"field:angebot_auswaehlen",
|
||||
"field:date_picker_a7c8",
|
||||
"field:date_picker_7e65",
|
||||
"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:anrede",
|
||||
"field:first_name_abae",
|
||||
"field:last_name_d97c",
|
||||
"field:email_5139",
|
||||
"field:phone_4c77",
|
||||
"field:long_answer_3524",
|
||||
"field:form_field_5a7b",
|
||||
"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",
|
||||
"metaSiteId"
|
||||
],
|
||||
"contact": {
|
||||
"name": {
|
||||
"first": "Weislinger",
|
||||
"last": "Alain"
|
||||
},
|
||||
"email": "alain-et-evelyne@hotmail.fr",
|
||||
"locale": "de-de",
|
||||
"phones": [
|
||||
{
|
||||
"tag": "UNTAGGED",
|
||||
"formattedPhone": "+33 6 41 77 99 09",
|
||||
"id": "90ffc824-1fd7-4167-b29f-24a4b62a0773",
|
||||
"countryCode": "FR",
|
||||
"e164Phone": "+33641779909",
|
||||
"primary": true,
|
||||
"phone": "6 41 77 99 09"
|
||||
}
|
||||
],
|
||||
"contactId": "250e24db-d41e-4f6e-835d-75acdf2ef2b7",
|
||||
"emails": [
|
||||
{
|
||||
"id": "2c071108-2410-4db8-99fa-b50b75a02493",
|
||||
"tag": "UNTAGGED",
|
||||
"email": "alain-et-evelyne@hotmail.fr",
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"updatedDate": "2025-09-27T19:36:41.908Z",
|
||||
"phone": "+33641779909",
|
||||
"createdDate": "2025-09-27T19:36:41.054Z"
|
||||
},
|
||||
"submissionId": "6cfee967-69a8-454a-a10e-0aa03868ba6d",
|
||||
"field:anzahl_kinder": "0",
|
||||
"field:first_name_abae": "Weislinger ",
|
||||
"contactId": "250e24db-d41e-4f6e-835d-75acdf2ef2b7",
|
||||
"field:date_picker_a7c8": "2026-01-17",
|
||||
"field:last_name_d97c": "Alain ",
|
||||
"submissionsLink": "https://manage.wix.app/forms/submissions/7b28c2ce-1e20-4d07-9e86-73d822007e18/e084006b-ae83-4e4d-b2f5-074118cdb3b1?d=https%3A%2F%2Fmanage.wix.com%2Fdashboard%2F7b28c2ce-1e20-4d07-9e86-73d822007e18%2Fwix-forms%2Fform%2Fe084006b-ae83-4e4d-b2f5-074118cdb3b1%2Fsubmissions&s=true",
|
||||
"field:anrede": "Herr",
|
||||
"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": "4518"
|
||||
}
|
||||
}
|
||||
240
logs/wix_test_data_20250929_163449.json
Normal file
240
logs/wix_test_data_20250929_163449.json
Normal file
@@ -0,0 +1,240 @@
|
||||
{
|
||||
"timestamp": "2025-09-29T16:34:49.785457",
|
||||
"client_ip": "127.0.0.1",
|
||||
"headers": {
|
||||
"host": "localhost:8080",
|
||||
"content-type": "application/json",
|
||||
"user-agent": "insomnia/2023.5.8",
|
||||
"accept": "*/*",
|
||||
"content-length": "6638"
|
||||
},
|
||||
"data": {
|
||||
"data": {
|
||||
"formName": "Contact us",
|
||||
"submissions": [
|
||||
{
|
||||
"label": "Angebot auswählen",
|
||||
"value": "Zimmer: Doppelzimmer"
|
||||
},
|
||||
{
|
||||
"label": "Anreisedatum",
|
||||
"value": "2025-10-03"
|
||||
},
|
||||
{
|
||||
"label": "Abreisedatum",
|
||||
"value": "2025-10-05"
|
||||
},
|
||||
{
|
||||
"label": "Anzahl Erwachsene",
|
||||
"value": "2"
|
||||
},
|
||||
{
|
||||
"label": "Anzahl Kinder",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"label": "Alter Kind 1",
|
||||
"value": "3"
|
||||
},
|
||||
{
|
||||
"label": "Anrede",
|
||||
"value": "Familie"
|
||||
},
|
||||
{
|
||||
"label": "Vorname",
|
||||
"value": "Miriana"
|
||||
},
|
||||
{
|
||||
"label": "Nachname",
|
||||
"value": "Darman"
|
||||
},
|
||||
{
|
||||
"label": "Email",
|
||||
"value": "miriana.m9@gmail.com"
|
||||
},
|
||||
{
|
||||
"label": "Phone",
|
||||
"value": "+39 348 443 0969"
|
||||
},
|
||||
{
|
||||
"label": "Einwilligung Marketing",
|
||||
"value": "Non selezionato"
|
||||
},
|
||||
{
|
||||
"label": "utm_Source",
|
||||
"value": "ig"
|
||||
},
|
||||
{
|
||||
"label": "utm_Medium",
|
||||
"value": "Instagram_Stories"
|
||||
},
|
||||
{
|
||||
"label": "utm_Campaign",
|
||||
"value": "Conversions_Hotel_Bemelmans_ITA"
|
||||
},
|
||||
{
|
||||
"label": "utm_Term",
|
||||
"value": "Cold_Traffic_Conversions_Hotel_Bemelmans_ITA"
|
||||
},
|
||||
{
|
||||
"label": "utm_Content",
|
||||
"value": "Grafik_4_Spätsommer_23.08-07.09_Landingpage_ITA"
|
||||
},
|
||||
{
|
||||
"label": "utm_term_id",
|
||||
"value": "120232007764490196"
|
||||
},
|
||||
{
|
||||
"label": "utm_content_id",
|
||||
"value": "120232007764490196"
|
||||
},
|
||||
{
|
||||
"label": "gad_source",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "gad_campaignid",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "gbraid",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "gclid",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "fbclid",
|
||||
"value": "PAZXh0bgNhZW0BMABhZGlkAasmYBTNE3QBp1jWuJ9zIpfEGRJMP63fMAMI405yvG5EtH-OT0PxSkAbBJaudFHR6cMtkdHu_aem_fopaFtECyVPNW9fmWfEkyA"
|
||||
}
|
||||
],
|
||||
"field:date_picker_7e65": "2025-10-05",
|
||||
"field:number_7cf5": "2",
|
||||
"field:utm_source": "ig",
|
||||
"submissionTime": "2025-09-27T07:04:55.843Z",
|
||||
"field:alter_kind_3": "3",
|
||||
"field:gad_source": "",
|
||||
"field:form_field_5a7b": "Non selezionato",
|
||||
"field:gad_campaignid": "",
|
||||
"field:utm_medium": "Instagram_Stories",
|
||||
"field:utm_term_id": "120232007764490196",
|
||||
"context": {
|
||||
"metaSiteId": "1dea821c-8168-4736-96e4-4b92e8b364cf",
|
||||
"activationId": "d41b7796-dca2-40f1-8245-c2f26a096f19"
|
||||
},
|
||||
"field:email_5139": "miriana.m9@gmail.com",
|
||||
"field:phone_4c77": "+39 348 443 0969",
|
||||
"_context": {
|
||||
"activation": {
|
||||
"id": "d41b7796-dca2-40f1-8245-c2f26a096f19"
|
||||
},
|
||||
"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:angebot_auswaehlen",
|
||||
"field:date_picker_a7c8",
|
||||
"field:date_picker_7e65",
|
||||
"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:anrede",
|
||||
"field:first_name_abae",
|
||||
"field:last_name_d97c",
|
||||
"field:email_5139",
|
||||
"field:phone_4c77",
|
||||
"field:long_answer_3524",
|
||||
"field:form_field_5a7b",
|
||||
"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",
|
||||
"metaSiteId"
|
||||
],
|
||||
"contact": {
|
||||
"name": {
|
||||
"first": "Miriana",
|
||||
"last": "Darman"
|
||||
},
|
||||
"email": "miriana.m9@gmail.com",
|
||||
"locale": "it-it",
|
||||
"phones": [
|
||||
{
|
||||
"tag": "UNTAGGED",
|
||||
"formattedPhone": "+39 348 443 0969",
|
||||
"id": "ac9d623e-6aaa-4022-856a-0dd64d0ff3fb",
|
||||
"countryCode": "IT",
|
||||
"e164Phone": "+393484430969",
|
||||
"primary": true,
|
||||
"phone": "348 443 0969"
|
||||
}
|
||||
],
|
||||
"contactId": "bcc29403-82ac-445a-be52-90a67180f16f",
|
||||
"emails": [
|
||||
{
|
||||
"id": "448de804-7353-46ed-9ae3-9c13ca521917",
|
||||
"tag": "UNTAGGED",
|
||||
"email": "miriana.m9@gmail.com",
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"updatedDate": "2025-09-27T07:04:58.724Z",
|
||||
"phone": "+393484430969",
|
||||
"createdDate": "2025-09-27T07:04:57.752Z"
|
||||
},
|
||||
"submissionId": "3150614e-1b0a-47ba-a774-b0a0c71d8110",
|
||||
"field:anzahl_kinder": "1",
|
||||
"field:first_name_abae": "Miriana",
|
||||
"field:utm_content_id": "120232007764490196",
|
||||
"field:utm_campaign": "Conversions_Hotel_Bemelmans_ITA",
|
||||
"field:utm_term": "Cold_Traffic_Conversions_Hotel_Bemelmans_ITA",
|
||||
"contactId": "bcc29403-82ac-445a-be52-90a67180f16f",
|
||||
"field:date_picker_a7c8": "2025-10-03",
|
||||
"field:angebot_auswaehlen": "Zimmer: Doppelzimmer",
|
||||
"field:utm_content": "Grafik_4_Spätsommer_23.08-07.09_Landingpage_ITA",
|
||||
"field:last_name_d97c": "Darman",
|
||||
"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": "PAZXh0bgNhZW0BMABhZGlkAasmYBTNE3QBp1jWuJ9zIpfEGRJMP63fMAMI405yvG5EtH-OT0PxSkAbBJaudFHR6cMtkdHu_aem_fopaFtECyVPNW9fmWfEkyA",
|
||||
"field:anrede": "Familie",
|
||||
"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": "6638"
|
||||
}
|
||||
}
|
||||
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"
|
||||
}
|
||||
}
|
||||
42
output.xml
42
output.xml
@@ -1,13 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<OTA_ResRetrieveRS xmlns="http://www.opentravel.org/OTA/2003/05" Version="7.000">
|
||||
<ReservationsList>
|
||||
<HotelReservation CreateDateTime="2025-09-25T13:33:19.275224+00:00" ResStatus="Requested" RoomStayReservation="true">
|
||||
<UniqueID Type="14" ID="6b34fe24ac2ff811"/>
|
||||
<HotelReservation CreateDateTime="2025-09-29T12:08:55.313540+00:00" ResStatus="Requested" RoomStayReservation="true">
|
||||
<UniqueID Type="14" ID="e084006b-ae83-4e4d-b2f5-074118cdb3b1"/>
|
||||
<RoomStays>
|
||||
<RoomStay>
|
||||
<TimeSpan>
|
||||
<StartDateWindow EarliestDate="2024-10-01" LatestDate="2024-10-02"/>
|
||||
</TimeSpan>
|
||||
<GuestCounts>
|
||||
<GuestCount Count="2"/>
|
||||
<GuestCount Count="1" Age="3"/>
|
||||
<GuestCount Count="1" Age="0"/>
|
||||
<GuestCount Count="1" Age="1"/>
|
||||
</GuestCounts>
|
||||
<TimeSpan Start="2025-10-31" End="2025-11-02"/>
|
||||
</RoomStay>
|
||||
</RoomStays>
|
||||
<ResGuests>
|
||||
@@ -15,21 +19,14 @@
|
||||
<Profiles>
|
||||
<ProfileInfo>
|
||||
<Profile>
|
||||
<Customer Gender="Male" BirthDate="1980-01-01" Language="en">
|
||||
<Customer Language="it">
|
||||
<PersonName>
|
||||
<NamePrefix>Mr.</NamePrefix>
|
||||
<GivenName>John</GivenName>
|
||||
<Surname>Doe</Surname>
|
||||
<NamePrefix>Frau</NamePrefix>
|
||||
<GivenName>Elena</GivenName>
|
||||
<Surname>Battiloro</Surname>
|
||||
</PersonName>
|
||||
<Telephone PhoneTechType="5" PhoneNumber="+1234567890"/>
|
||||
<Telephone PhoneNumber="+0987654321"/>
|
||||
<Email Remark="newsletter:yes">john.doe@example.com</Email>
|
||||
<Address Remark="catalog:no">
|
||||
<AddressLine>123 Main Street</AddressLine>
|
||||
<CityName>Anytown</CityName>
|
||||
<PostalCode>12345</PostalCode>
|
||||
<CountryName Code="US"/>
|
||||
</Address>
|
||||
<Telephone PhoneTechType="5" PhoneNumber="+393337673262"/>
|
||||
<Email Remark="newsletter:no">e.battiloro1@gmail.com</Email>
|
||||
</Customer>
|
||||
</Profile>
|
||||
</ProfileInfo>
|
||||
@@ -38,16 +35,13 @@
|
||||
</ResGuests>
|
||||
<ResGlobalInfo>
|
||||
<Comments>
|
||||
<Comment Name="customer comment">
|
||||
<ListItem ListItem="1" Language="en">Landing page comment</ListItem>
|
||||
<Text>This is a sample comment.</Text>
|
||||
</Comment>
|
||||
<Comment Name="additional info">
|
||||
<Text>This is a special request comment.</Text>
|
||||
<ListItem ListItem="1" Language="it">Herbstferien - Familienzeit mit Dolomitenblick</ListItem>
|
||||
<Text>Angebot/Offerta</Text>
|
||||
</Comment>
|
||||
</Comments>
|
||||
<HotelReservationIDs>
|
||||
<HotelReservationID ResID_Type="13" ResID_SourceContext="99tales"/>
|
||||
<HotelReservationID ResID_Type="13" ResID_Value="PAZXh0bgNhZW0BMABhZGlkAasmYBhk4DQBp02L46Rl1jAuccxsOaeFSv7WSFnP-MQCsOrz9yDnKRH4hwZ7GEgxF9gy0_OF_aem_qSvrs6xsBkvTaI_Y9_hfnQ" ResID_SourceContext="99tales"/>
|
||||
</HotelReservationIDs>
|
||||
<BasicPropertyInfo HotelCode="123" HotelName="Frangart Inn"/>
|
||||
</ResGlobalInfo>
|
||||
|
||||
123
pyproject.toml
123
pyproject.toml
@@ -4,15 +4,27 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "alpine-bits-python-server"
|
||||
version = "0.1.0"
|
||||
version = "0.1.2"
|
||||
description = "Alpine Bits Python Server implementation"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"aiosqlite>=0.21.0",
|
||||
"annotatedyaml>=1.0.0",
|
||||
"dotenv>=0.9.9",
|
||||
"fastapi>=0.117.1",
|
||||
"generateds>=2.44.3",
|
||||
"httpx>=0.28.1",
|
||||
"lxml>=6.0.1",
|
||||
"pydantic[email]>=2.11.9",
|
||||
"pytest>=8.4.2",
|
||||
"pytest-asyncio>=1.2.0",
|
||||
"redis>=6.4.0",
|
||||
"ruff>=0.13.1",
|
||||
"slowapi>=0.1.9",
|
||||
"sqlalchemy>=2.0.43",
|
||||
"uvicorn>=0.37.0",
|
||||
"voluptuous>=0.15.2",
|
||||
"xsdata-pydantic[cli,lxml,soap]>=24.5",
|
||||
"xsdata[cli,lxml,soap]>=25.7",
|
||||
]
|
||||
@@ -24,8 +36,115 @@ alpine-bits-server = "alpine_bits_python.main:main"
|
||||
packages = ["src/alpine_bits_python"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["test"]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["src"]
|
||||
|
||||
[tool.ruff]
|
||||
src = ["src", "test"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"A001", # Variable {name} is shadowing a Python builtin
|
||||
"ASYNC210", # Async functions should not call blocking HTTP methods
|
||||
"ASYNC220", # Async functions should not create subprocesses with blocking methods
|
||||
"ASYNC221", # Async functions should not run processes with blocking methods
|
||||
"ASYNC222", # Async functions should not wait on processes with blocking methods
|
||||
"ASYNC230", # Async functions should not open files with blocking methods like open
|
||||
"ASYNC251", # Async functions should not call time.sleep
|
||||
"B002", # Python does not support the unary prefix increment
|
||||
"B005", # Using .strip() with multi-character strings is misleading
|
||||
"B007", # Loop control variable {name} not used within loop body
|
||||
"B014", # Exception handler with duplicate exception
|
||||
"B015", # Pointless comparison. Did you mean to assign a value? Otherwise, prepend assert or remove it.
|
||||
"B017", # pytest.raises(BaseException) should be considered evil
|
||||
"B018", # Found useless attribute access. Either assign it to a variable or remove it.
|
||||
"B023", # Function definition does not bind loop variable {name}
|
||||
"B024", # `{name}` is an abstract base class, but it has no abstract methods or properties
|
||||
"B026", # Star-arg unpacking after a keyword argument is strongly discouraged
|
||||
"B032", # Possible unintentional type annotation (using :). Did you mean to assign (using =)?
|
||||
"B035", # Dictionary comprehension uses static key
|
||||
"B904", # Use raise from to specify exception cause
|
||||
"B905", # zip() without an explicit strict= parameter
|
||||
"BLE",
|
||||
"C", # complexity
|
||||
"COM818", # Trailing comma on bare tuple prohibited
|
||||
"D", # docstrings
|
||||
"DTZ003", # Use datetime.now(tz=) instead of datetime.utcnow()
|
||||
"DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts)
|
||||
"E", # pycodestyle
|
||||
"F", # pyflakes/autoflake
|
||||
"F541", # f-string without any placeholders
|
||||
"FLY", # flynt
|
||||
"FURB", # refurb
|
||||
"G", # flake8-logging-format
|
||||
"I", # isort
|
||||
"INP", # flake8-no-pep420
|
||||
"ISC", # flake8-implicit-str-concat
|
||||
"ICN001", # import concentions; {name} should be imported as {asname}
|
||||
"LOG", # flake8-logging
|
||||
"N804", # First argument of a class method should be named cls
|
||||
"N805", # First argument of a method should be named self
|
||||
"N815", # Variable {name} in class scope should not be mixedCase
|
||||
"PERF", # Perflint
|
||||
"PGH", # pygrep-hooks
|
||||
"PIE", # flake8-pie
|
||||
"PL", # pylint
|
||||
"PT", # flake8-pytest-style
|
||||
"PTH", # flake8-pathlib
|
||||
"PYI", # flake8-pyi
|
||||
"RET", # flake8-return
|
||||
"RSE", # flake8-raise
|
||||
"RUF005", # Consider iterable unpacking instead of concatenation
|
||||
"RUF006", # Store a reference to the return value of asyncio.create_task
|
||||
"RUF007", # Prefer itertools.pairwise() over zip() when iterating over successive pairs
|
||||
"RUF008", # Do not use mutable default values for dataclass attributes
|
||||
"RUF010", # Use explicit conversion flag
|
||||
"RUF013", # PEP 484 prohibits implicit Optional
|
||||
"RUF016", # Slice in indexed access to type {value_type} uses type {index_type} instead of an integer
|
||||
"RUF017", # Avoid quadratic list summation
|
||||
"RUF018", # Avoid assignment expressions in assert statements
|
||||
"RUF019", # Unnecessary key check before dictionary access
|
||||
"RUF020", # {never_like} | T is equivalent to T
|
||||
"RUF021", # Parenthesize a and b expressions when chaining and and or together, to make the precedence clear
|
||||
"RUF022", # Sort __all__
|
||||
"RUF023", # Sort __slots__
|
||||
"RUF024", # Do not pass mutable objects as values to dict.fromkeys
|
||||
"RUF026", # default_factory is a positional-only argument to defaultdict
|
||||
"RUF030", # print() call in assert statement is likely unintentional
|
||||
"RUF032", # Decimal() called with float literal argument
|
||||
"RUF033", # __post_init__ method with argument defaults
|
||||
"RUF034", # Useless if-else condition
|
||||
"RUF100", # Unused `noqa` directive
|
||||
"RUF101", # noqa directives that use redirected rule codes
|
||||
"RUF200", # Failed to parse pyproject.toml: {message}
|
||||
"S102", # Use of exec detected
|
||||
"S103", # bad-file-permissions
|
||||
"S108", # hardcoded-temp-file
|
||||
"S306", # suspicious-mktemp-usage
|
||||
"S307", # suspicious-eval-usage
|
||||
"S313", # suspicious-xmlc-element-tree-usage
|
||||
"S314", # suspicious-xml-element-tree-usage
|
||||
"S315", # suspicious-xml-expat-reader-usage
|
||||
"S316", # suspicious-xml-expat-builder-usage
|
||||
"S317", # suspicious-xml-sax-usage
|
||||
"S318", # suspicious-xml-mini-dom-usage
|
||||
"S319", # suspicious-xml-pull-dom-usage
|
||||
"S601", # paramiko-call
|
||||
"S602", # subprocess-popen-with-shell-equals-true
|
||||
"S604", # call-with-shell-equals-true
|
||||
"S608", # hardcoded-sql-expression
|
||||
"S609", # unix-command-wildcard-injection
|
||||
"SIM", # flake8-simplify
|
||||
"SLF", # flake8-self
|
||||
"SLOT", # flake8-slots
|
||||
"T100", # Trace found: {name} used
|
||||
"T20", # flake8-print
|
||||
"TC", # flake8-type-checking
|
||||
"TID", # Tidy imports
|
||||
"TRY", # tryceratops
|
||||
"UP", # pyupgrade
|
||||
"UP031", # Use format specifiers instead of percent format
|
||||
"UP032", # Use f-string instead of `format` call
|
||||
"W", # pycodestyle
|
||||
]
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Entry point for alpine_bits_python package."""
|
||||
|
||||
from .main import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("running test main")
|
||||
main()
|
||||
@@ -1,31 +1,78 @@
|
||||
from typing import Union, Optional, Any, TypeVar
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
import logging
|
||||
import traceback
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from alpine_bits_python.db import Customer, Reservation
|
||||
from alpine_bits_python.schemas import (
|
||||
CommentData,
|
||||
CommentListItemData,
|
||||
CommentsData,
|
||||
CustomerData,
|
||||
HotelReservationIdData,
|
||||
PhoneTechType,
|
||||
)
|
||||
|
||||
# Import the generated classes
|
||||
from .generated.alpinebits import OtaHotelResNotifRq, OtaResRetrieveRs, CommentName2
|
||||
from .generated.alpinebits import (
|
||||
CommentName2,
|
||||
HotelReservationResStatus,
|
||||
OtaHotelResNotifRq,
|
||||
OtaResRetrieveRs,
|
||||
ProfileProfileType,
|
||||
UniqueIdType2,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_LOGGER.setLevel(logging.INFO)
|
||||
|
||||
# Define type aliases for the two Customer types
|
||||
NotifCustomer = OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGuests.ResGuest.Profiles.ProfileInfo.Profile.Customer
|
||||
RetrieveCustomer = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGuests.ResGuest.Profiles.ProfileInfo.Profile.Customer
|
||||
NotifCustomer = OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGuests.ResGuest.Profiles.ProfileInfo.Profile.Customer # noqa: E501
|
||||
RetrieveCustomer = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGuests.ResGuest.Profiles.ProfileInfo.Profile.Customer # noqa: E501
|
||||
|
||||
# Define type aliases for HotelReservationId types
|
||||
NotifHotelReservationId = OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGlobalInfo.HotelReservationIds.HotelReservationId
|
||||
RetrieveHotelReservationId = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.HotelReservationIds.HotelReservationId
|
||||
NotifHotelReservationId = OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGlobalInfo.HotelReservationIds.HotelReservationId # noqa: E501
|
||||
RetrieveHotelReservationId = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.HotelReservationIds.HotelReservationId # noqa: E501
|
||||
|
||||
# Define type aliases for Comments types
|
||||
NotifComments = OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGlobalInfo.Comments
|
||||
RetrieveComments = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.Comments
|
||||
NotifComment = OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGlobalInfo.Comments.Comment
|
||||
RetrieveComment = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.Comments.Comment
|
||||
NotifComments = (
|
||||
OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGlobalInfo.Comments
|
||||
)
|
||||
RetrieveComments = (
|
||||
OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.Comments
|
||||
)
|
||||
NotifComment = (
|
||||
OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGlobalInfo.Comments.Comment
|
||||
)
|
||||
RetrieveComment = (
|
||||
OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.Comments.Comment
|
||||
)
|
||||
|
||||
# type aliases for GuestCounts
|
||||
NotifGuestCounts = (
|
||||
OtaHotelResNotifRq.HotelReservations.HotelReservation.RoomStays.RoomStay.GuestCounts
|
||||
)
|
||||
RetrieveGuestCounts = (
|
||||
OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays.RoomStay.GuestCounts
|
||||
)
|
||||
|
||||
# phonetechtype enum 1,3,5 voice, fax, mobile
|
||||
class PhoneTechType(Enum):
|
||||
VOICE = "1"
|
||||
FAX = "3"
|
||||
MOBILE = "5"
|
||||
NotifUniqueId = OtaHotelResNotifRq.HotelReservations.HotelReservation.UniqueId
|
||||
RetrieveUniqueId = OtaResRetrieveRs.ReservationsList.HotelReservation.UniqueId
|
||||
|
||||
NotifTimeSpan = (
|
||||
OtaHotelResNotifRq.HotelReservations.HotelReservation.RoomStays.RoomStay.TimeSpan
|
||||
)
|
||||
RetrieveTimeSpan = (
|
||||
OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays.RoomStay.TimeSpan
|
||||
)
|
||||
|
||||
NotifRoomStays = OtaHotelResNotifRq.HotelReservations.HotelReservation.RoomStays
|
||||
RetrieveRoomStays = OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays
|
||||
|
||||
NotifHotelReservation = OtaHotelResNotifRq.HotelReservations.HotelReservation
|
||||
RetrieveHotelReservation = OtaResRetrieveRs.ReservationsList.HotelReservation
|
||||
|
||||
|
||||
# Enum to specify which OTA message type to use
|
||||
@@ -35,38 +82,69 @@ class OtaMessageType(Enum):
|
||||
|
||||
|
||||
@dataclass
|
||||
class CustomerData:
|
||||
"""Simple data class to hold customer information without nested type constraints."""
|
||||
class KidsAgeData:
|
||||
"""Data class to hold information about children's ages."""
|
||||
|
||||
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
|
||||
ages: list[int]
|
||||
|
||||
def __post_init__(self):
|
||||
if self.phone_numbers is None:
|
||||
self.phone_numbers = []
|
||||
|
||||
class GuestCountsFactory:
|
||||
"""Factory class to create GuestCounts instances for both OtaHotelResNotifRq and OtaResRetrieveRs."""
|
||||
|
||||
@staticmethod
|
||||
def create_guest_counts(
|
||||
adults: int,
|
||||
kids: list[int] | None = None,
|
||||
message_type: OtaMessageType = OtaMessageType.RETRIEVE,
|
||||
) -> NotifGuestCounts:
|
||||
"""Create a GuestCounts object for OtaHotelResNotifRq or OtaResRetrieveRs.
|
||||
|
||||
:param adults: Number of adults
|
||||
:param kids: List of ages for each kid (optional)
|
||||
:return: GuestCounts instance
|
||||
"""
|
||||
if message_type == OtaMessageType.RETRIEVE:
|
||||
return GuestCountsFactory._create_guest_counts(
|
||||
adults, kids, RetrieveGuestCounts
|
||||
)
|
||||
if message_type == OtaMessageType.NOTIF:
|
||||
return GuestCountsFactory._create_guest_counts(
|
||||
adults, kids, NotifGuestCounts
|
||||
)
|
||||
raise ValueError(f"Unsupported message type: {message_type}")
|
||||
|
||||
@staticmethod
|
||||
def _create_guest_counts(
|
||||
adults: int, kids: list[int] | None, guest_counts_class: type
|
||||
) -> Any:
|
||||
"""Create a GuestCounts object of the specified type.
|
||||
|
||||
:param adults: Number of adults
|
||||
:param kids: List of ages for each kid (optional)
|
||||
:param guest_counts_class: The GuestCounts class to instantiate
|
||||
:return: GuestCounts instance
|
||||
"""
|
||||
GuestCount = guest_counts_class.GuestCount
|
||||
guest_count_list = []
|
||||
if adults > 0:
|
||||
guest_count_list.append(GuestCount(count=str(adults)))
|
||||
if kids:
|
||||
# create a dict with amount of kids for each age
|
||||
age_count = {}
|
||||
|
||||
for age in kids:
|
||||
if age in age_count:
|
||||
age_count[age] += 1
|
||||
else:
|
||||
age_count[age] = 1
|
||||
|
||||
for age, count in age_count.items():
|
||||
guest_count_list.append(GuestCount(count=str(count), age=str(age)))
|
||||
return guest_counts_class(guest_count=guest_count_list)
|
||||
|
||||
|
||||
class CustomerFactory:
|
||||
"""Factory class to create Customer instances for both OtaHotelResNotifRq and OtaResRetrieveRs."""
|
||||
"""Factory class to create Customer instances for both Retrieve and Notif."""
|
||||
|
||||
@staticmethod
|
||||
def create_notif_customer(data: CustomerData) -> NotifCustomer:
|
||||
@@ -79,9 +157,10 @@ class CustomerFactory:
|
||||
return CustomerFactory._create_customer(RetrieveCustomer, data)
|
||||
|
||||
@staticmethod
|
||||
def _create_customer(customer_class: type, data: CustomerData) -> Any:
|
||||
"""Internal method to create a customer of the specified type."""
|
||||
|
||||
def _create_customer(
|
||||
customer_class: type[RetrieveCustomer | NotifCustomer], data: CustomerData
|
||||
) -> Any:
|
||||
"""Create a customer of the specified type."""
|
||||
# Create PersonName
|
||||
person_name = customer_class.PersonName(
|
||||
given_name=data.given_name,
|
||||
@@ -154,19 +233,20 @@ class CustomerFactory:
|
||||
|
||||
@staticmethod
|
||||
def _customer_to_data(customer: Any) -> CustomerData:
|
||||
"""Internal method to convert any customer type to CustomerData."""
|
||||
|
||||
"""Convert any customer type to CustomerData."""
|
||||
# Extract phone numbers
|
||||
phone_numbers = []
|
||||
if customer.telephone:
|
||||
for tel in customer.telephone:
|
||||
phone_numbers.append(
|
||||
phone_numbers.extend(
|
||||
[
|
||||
(
|
||||
tel.phone_number,
|
||||
PhoneTechType(tel.phone_tech_type)
|
||||
if tel.phone_tech_type
|
||||
else None,
|
||||
)
|
||||
for tel in customer.telephone
|
||||
]
|
||||
)
|
||||
|
||||
# Extract email info
|
||||
@@ -220,16 +300,6 @@ class CustomerFactory:
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HotelReservationIdData:
|
||||
"""Simple data class to hold hotel reservation ID information without nested type constraints."""
|
||||
|
||||
res_id_type: str # Required field - pattern: [0-9]+
|
||||
res_id_value: None | str = None # Max 64 characters
|
||||
res_id_source: None | str = None # Max 64 characters
|
||||
res_id_source_context: None | str = None # Max 64 characters
|
||||
|
||||
|
||||
class HotelReservationIdFactory:
|
||||
"""Factory class to create HotelReservationId instances for both OtaHotelResNotifRq and OtaResRetrieveRs."""
|
||||
|
||||
@@ -255,7 +325,7 @@ class HotelReservationIdFactory:
|
||||
def _create_hotel_reservation_id(
|
||||
hotel_reservation_id_class: type, data: HotelReservationIdData
|
||||
) -> Any:
|
||||
"""Internal method to create a hotel reservation id of the specified type."""
|
||||
"""Create a hotel reservation id of the specified type."""
|
||||
return hotel_reservation_id_class(
|
||||
res_id_type=data.res_id_type,
|
||||
res_id_value=data.res_id_value,
|
||||
@@ -294,36 +364,6 @@ class HotelReservationIdFactory:
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommentListItemData:
|
||||
"""Simple data class to hold comment list item information."""
|
||||
value: str # The text content of the list item
|
||||
list_item: str # Numeric identifier (pattern: [0-9]+)
|
||||
language: str # Two-letter language code (pattern: [a-z][a-z])
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommentData:
|
||||
"""Simple data class to hold comment information without nested type constraints."""
|
||||
name: CommentName2 # Required: "included services", "customer comment", "additional info"
|
||||
text: Optional[str] = None # Optional text content
|
||||
list_items: list[CommentListItemData] = None # Optional list items
|
||||
|
||||
def __post_init__(self):
|
||||
if self.list_items is None:
|
||||
self.list_items = []
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommentsData:
|
||||
"""Simple data class to hold multiple comments (1-3 max)."""
|
||||
comments: list[CommentData] = None # 1-3 comments maximum
|
||||
|
||||
def __post_init__(self):
|
||||
if self.comments is None:
|
||||
self.comments = []
|
||||
|
||||
|
||||
class CommentFactory:
|
||||
"""Factory class to create Comment instances for both OtaHotelResNotifRq and OtaResRetrieveRs."""
|
||||
|
||||
@@ -338,26 +378,31 @@ class CommentFactory:
|
||||
return CommentFactory._create_comments(RetrieveComments, RetrieveComment, data)
|
||||
|
||||
@staticmethod
|
||||
def _create_comments(comments_class: type, comment_class: type, data: CommentsData) -> Any:
|
||||
def _create_comments(
|
||||
comments_class: type[RetrieveComments] | type[NotifComments],
|
||||
comment_class: type[RetrieveComment] | type[NotifComment],
|
||||
data: CommentsData,
|
||||
) -> Any:
|
||||
"""Internal method to create comments of the specified type."""
|
||||
|
||||
comments_list = []
|
||||
for comment_data in data.comments:
|
||||
# Create list items
|
||||
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}"
|
||||
)
|
||||
|
||||
list_item = comment_class.ListItem(
|
||||
value=item_data.value,
|
||||
list_item=item_data.list_item,
|
||||
language=item_data.language
|
||||
language=item_data.language,
|
||||
)
|
||||
list_items.append(list_item)
|
||||
|
||||
# Create comment
|
||||
comment = comment_class(
|
||||
name=comment_data.name,
|
||||
text=comment_data.text,
|
||||
list_item=list_items
|
||||
name=comment_data.name, text=comment_data.text, list_item=list_items
|
||||
)
|
||||
comments_list.append(comment)
|
||||
|
||||
@@ -377,26 +422,21 @@ class CommentFactory:
|
||||
@staticmethod
|
||||
def _comments_to_data(comments: Any) -> CommentsData:
|
||||
"""Internal method to convert any comments type to CommentsData."""
|
||||
|
||||
comments_data_list = []
|
||||
for comment in comments.comment:
|
||||
# Extract list items
|
||||
list_items_data = []
|
||||
if comment.list_item:
|
||||
for list_item in comment.list_item:
|
||||
list_items_data.append(CommentListItemData(
|
||||
list_items_data.append(
|
||||
CommentListItemData(
|
||||
value=list_item.value,
|
||||
list_item=list_item.list_item,
|
||||
language=list_item.language
|
||||
))
|
||||
|
||||
# Extract comment data
|
||||
comment_data = CommentData(
|
||||
name=comment.name,
|
||||
text=comment.text,
|
||||
list_items=list_items_data
|
||||
language=list_item.language,
|
||||
)
|
||||
comments_data_list.append(comment_data)
|
||||
)
|
||||
|
||||
comments_data_list.append(comment)
|
||||
|
||||
return CommentsData(comments=comments_data_list)
|
||||
|
||||
@@ -425,10 +465,11 @@ class ResGuestFactory:
|
||||
|
||||
@staticmethod
|
||||
def _create_res_guests(
|
||||
res_guests_class: type, customer_class: type, customer_data: CustomerData
|
||||
res_guests_class: type[RetrieveResGuests] | type[NotifResGuests],
|
||||
customer_class: type[NotifCustomer | RetrieveCustomer],
|
||||
customer_data: CustomerData,
|
||||
) -> Any:
|
||||
"""Internal method to create complete ResGuests structure."""
|
||||
|
||||
"""Create the complete ResGuests structure."""
|
||||
# Create the customer using the existing CustomerFactory
|
||||
customer = CustomerFactory._create_customer(customer_class, customer_data)
|
||||
|
||||
@@ -451,17 +492,15 @@ class ResGuestFactory:
|
||||
|
||||
@staticmethod
|
||||
def extract_primary_customer(
|
||||
res_guests: Union[NotifResGuests, RetrieveResGuests],
|
||||
res_guests: NotifResGuests | RetrieveResGuests,
|
||||
) -> CustomerData:
|
||||
"""Extract the primary customer data from a ResGuests structure."""
|
||||
|
||||
# Navigate down the nested structure to get the customer
|
||||
customer = res_guests.res_guest.profiles.profile_info.profile.customer
|
||||
|
||||
# Use the existing CustomerFactory conversion method
|
||||
if isinstance(res_guests, NotifResGuests):
|
||||
return CustomerFactory.from_notif_customer(customer)
|
||||
else:
|
||||
return CustomerFactory.from_retrieve_customer(customer)
|
||||
|
||||
|
||||
@@ -469,9 +508,11 @@ class AlpineBitsFactory:
|
||||
"""Unified factory class for creating AlpineBits objects with a simple interface."""
|
||||
|
||||
@staticmethod
|
||||
def create(data: Union[CustomerData, HotelReservationIdData, CommentsData], message_type: OtaMessageType) -> Any:
|
||||
"""
|
||||
Create an AlpineBits object based on the data type and message type.
|
||||
def create(
|
||||
data: CustomerData | HotelReservationIdData | CommentsData,
|
||||
message_type: OtaMessageType,
|
||||
) -> Any:
|
||||
"""Create an AlpineBits object based on the data type and message type.
|
||||
|
||||
Args:
|
||||
data: The data object (CustomerData, HotelReservationIdData, CommentsData, etc.)
|
||||
@@ -479,32 +520,30 @@ class AlpineBitsFactory:
|
||||
|
||||
Returns:
|
||||
The appropriate AlpineBits object based on the data type and message type
|
||||
|
||||
"""
|
||||
if isinstance(data, CustomerData):
|
||||
if message_type == OtaMessageType.NOTIF:
|
||||
return CustomerFactory.create_notif_customer(data)
|
||||
else:
|
||||
return CustomerFactory.create_retrieve_customer(data)
|
||||
|
||||
elif isinstance(data, HotelReservationIdData):
|
||||
if isinstance(data, HotelReservationIdData):
|
||||
if message_type == OtaMessageType.NOTIF:
|
||||
return HotelReservationIdFactory.create_notif_hotel_reservation_id(data)
|
||||
else:
|
||||
return HotelReservationIdFactory.create_retrieve_hotel_reservation_id(data)
|
||||
|
||||
elif isinstance(data, CommentsData):
|
||||
if isinstance(data, CommentsData):
|
||||
if message_type == OtaMessageType.NOTIF:
|
||||
return CommentFactory.create_notif_comments(data)
|
||||
else:
|
||||
return CommentFactory.create_retrieve_comments(data)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unsupported data type: {type(data)}")
|
||||
|
||||
@staticmethod
|
||||
def create_res_guests(customer_data: CustomerData, message_type: OtaMessageType) -> Union[NotifResGuests, RetrieveResGuests]:
|
||||
"""
|
||||
Create a complete ResGuests structure with a primary customer.
|
||||
def create_res_guests(
|
||||
customer_data: CustomerData, message_type: OtaMessageType
|
||||
) -> NotifResGuests | RetrieveResGuests:
|
||||
"""Create a complete ResGuests structure with a primary customer.
|
||||
|
||||
Args:
|
||||
customer_data: The customer data
|
||||
@@ -512,50 +551,318 @@ class AlpineBitsFactory:
|
||||
|
||||
Returns:
|
||||
The appropriate ResGuests object
|
||||
|
||||
"""
|
||||
if message_type == OtaMessageType.NOTIF:
|
||||
return ResGuestFactory.create_notif_res_guests(customer_data)
|
||||
else:
|
||||
return ResGuestFactory.create_retrieve_res_guests(customer_data)
|
||||
|
||||
@staticmethod
|
||||
def extract_data(obj: Any) -> Union[CustomerData, HotelReservationIdData, CommentsData]:
|
||||
"""
|
||||
Extract data from an AlpineBits object back to a simple data class.
|
||||
def extract_data(
|
||||
obj: Any,
|
||||
) -> CustomerData | HotelReservationIdData | CommentsData:
|
||||
"""Extract data from an AlpineBits object back to a simple data class.
|
||||
|
||||
Args:
|
||||
obj: The AlpineBits object to extract data from
|
||||
|
||||
Returns:
|
||||
The appropriate data object
|
||||
|
||||
"""
|
||||
# Check if it's a Customer object
|
||||
if hasattr(obj, 'person_name') and hasattr(obj.person_name, 'given_name'):
|
||||
if hasattr(obj, "person_name") and hasattr(obj.person_name, "given_name"):
|
||||
if isinstance(obj, NotifCustomer):
|
||||
return CustomerFactory.from_notif_customer(obj)
|
||||
elif isinstance(obj, RetrieveCustomer):
|
||||
if isinstance(obj, RetrieveCustomer):
|
||||
return CustomerFactory.from_retrieve_customer(obj)
|
||||
|
||||
# Check if it's a HotelReservationId object
|
||||
elif hasattr(obj, 'res_id_type'):
|
||||
elif hasattr(obj, "res_id_type"):
|
||||
if isinstance(obj, NotifHotelReservationId):
|
||||
return HotelReservationIdFactory.from_notif_hotel_reservation_id(obj)
|
||||
elif isinstance(obj, RetrieveHotelReservationId):
|
||||
if isinstance(obj, RetrieveHotelReservationId):
|
||||
return HotelReservationIdFactory.from_retrieve_hotel_reservation_id(obj)
|
||||
|
||||
# Check if it's a Comments object
|
||||
elif hasattr(obj, 'comment'):
|
||||
elif hasattr(obj, "comment"):
|
||||
if isinstance(obj, NotifComments):
|
||||
return CommentFactory.from_notif_comments(obj)
|
||||
elif isinstance(obj, RetrieveComments):
|
||||
if isinstance(obj, RetrieveComments):
|
||||
return CommentFactory.from_retrieve_comments(obj)
|
||||
|
||||
# Check if it's a ResGuests object
|
||||
elif hasattr(obj, 'res_guest'):
|
||||
elif hasattr(obj, "res_guest"):
|
||||
return ResGuestFactory.extract_primary_customer(obj)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unsupported object type: {type(obj)}")
|
||||
return None
|
||||
|
||||
|
||||
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)
|
||||
"""
|
||||
reservations_list = []
|
||||
|
||||
# if entries isn't a list wrap the element in a list
|
||||
|
||||
if not isinstance(entries, list):
|
||||
entries = [entries]
|
||||
|
||||
for reservation, customer in entries:
|
||||
_LOGGER.info(
|
||||
f"Creating XML for reservation {reservation.unique_id} and customer {customer.given_name}"
|
||||
)
|
||||
|
||||
try:
|
||||
hotel_reservation = _process_single_reservation(reservation, customer, type)
|
||||
|
||||
reservations_list.append(hotel_reservation)
|
||||
|
||||
except Exception as e:
|
||||
_LOGGER.error(
|
||||
f"Error creating XML for reservation {reservation.unique_id} and customer {customer.given_name}: {e}"
|
||||
)
|
||||
_LOGGER.debug(traceback.format_exc())
|
||||
|
||||
if type == OtaMessageType.NOTIF:
|
||||
res_list_obj = OtaHotelResNotifRq.HotelReservations(
|
||||
hotel_reservation=reservations_list
|
||||
)
|
||||
|
||||
ota_hotel_res_notif_rq = OtaHotelResNotifRq(
|
||||
version="7.000", hotel_reservations=res_list_obj
|
||||
)
|
||||
|
||||
try:
|
||||
ota_hotel_res_notif_rq.model_validate(ota_hotel_res_notif_rq.model_dump())
|
||||
except Exception as e:
|
||||
_LOGGER.error(f"Validation error: {e}")
|
||||
raise
|
||||
|
||||
return ota_hotel_res_notif_rq
|
||||
if type == OtaMessageType.RETRIEVE:
|
||||
res_list_obj = OtaResRetrieveRs.ReservationsList(
|
||||
hotel_reservation=reservations_list
|
||||
)
|
||||
|
||||
ota_res_retrieve_rs = OtaResRetrieveRs(
|
||||
version="7.000", success="", reservations_list=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
|
||||
@@ -682,16 +989,17 @@ if __name__ == "__main__":
|
||||
|
||||
print("=== HotelReservationId Creation ===")
|
||||
reservation_id_data = HotelReservationIdData(
|
||||
res_id_type="123",
|
||||
res_id_value="RESERVATION-456",
|
||||
res_id_source="HOTEL_SYSTEM"
|
||||
res_id_type="123", res_id_value="RESERVATION-456", res_id_source="HOTEL_SYSTEM"
|
||||
)
|
||||
notif_res_id = AlpineBitsFactory.create(reservation_id_data, OtaMessageType.NOTIF)
|
||||
retrieve_res_id = AlpineBitsFactory.create(reservation_id_data, OtaMessageType.RETRIEVE)
|
||||
retrieve_res_id = AlpineBitsFactory.create(
|
||||
reservation_id_data, OtaMessageType.RETRIEVE
|
||||
)
|
||||
print("Created reservation IDs using unified factory")
|
||||
|
||||
print("=== Comments Creation ===")
|
||||
comments_data = CommentsData(comments=[
|
||||
comments_data = CommentsData(
|
||||
comments=[
|
||||
CommentData(
|
||||
name=CommentName2.CUSTOMER_COMMENT,
|
||||
text="This is a customer comment about the reservation",
|
||||
@@ -699,27 +1007,30 @@ if __name__ == "__main__":
|
||||
CommentListItemData(
|
||||
value="Special dietary requirements: vegetarian",
|
||||
list_item="1",
|
||||
language="en"
|
||||
language="en",
|
||||
),
|
||||
CommentListItemData(
|
||||
value="Late arrival expected",
|
||||
list_item="2",
|
||||
language="en"
|
||||
)
|
||||
]
|
||||
value="Late arrival expected", list_item="2", language="en"
|
||||
),
|
||||
],
|
||||
),
|
||||
CommentData(
|
||||
name=CommentName2.ADDITIONAL_INFO,
|
||||
text="Additional information about the stay"
|
||||
text="Additional information about the stay",
|
||||
),
|
||||
]
|
||||
)
|
||||
])
|
||||
notif_comments = AlpineBitsFactory.create(comments_data, OtaMessageType.NOTIF)
|
||||
retrieve_comments = AlpineBitsFactory.create(comments_data, OtaMessageType.RETRIEVE)
|
||||
print("Created comments using unified factory")
|
||||
|
||||
print("=== ResGuests Creation ===")
|
||||
notif_res_guests = AlpineBitsFactory.create_res_guests(customer_data, OtaMessageType.NOTIF)
|
||||
retrieve_res_guests = AlpineBitsFactory.create_res_guests(customer_data, OtaMessageType.RETRIEVE)
|
||||
notif_res_guests = AlpineBitsFactory.create_res_guests(
|
||||
customer_data, OtaMessageType.NOTIF
|
||||
)
|
||||
retrieve_res_guests = AlpineBitsFactory.create_res_guests(
|
||||
customer_data, OtaMessageType.RETRIEVE
|
||||
)
|
||||
print("Created ResGuests using unified factory")
|
||||
|
||||
print("=== Data Extraction ===")
|
||||
@@ -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")
|
||||
File diff suppressed because it is too large
Load Diff
933
src/alpine_bits_python/api.py
Normal file
933
src/alpine_bits_python/api.py
Normal file
@@ -0,0 +1,933 @@
|
||||
import asyncio
|
||||
import gzip
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import urllib.parse
|
||||
from collections import defaultdict
|
||||
from datetime import UTC, date, datetime
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.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 .db import Base, get_database_url
|
||||
from .db import Customer as DBCustomer
|
||||
from .db import Reservation as DBReservation
|
||||
from .rate_limit import (
|
||||
BURST_RATE_LIMIT,
|
||||
DEFAULT_RATE_LIMIT,
|
||||
WEBHOOK_RATE_LIMIT,
|
||||
custom_rate_limit_handler,
|
||||
limiter,
|
||||
webhook_limiter,
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# HTTP Basic auth for AlpineBits
|
||||
security_basic = HTTPBasic()
|
||||
|
||||
|
||||
# --- Enhanced event dispatcher with hotel-specific routing ---
|
||||
class EventDispatcher:
|
||||
def __init__(self):
|
||||
self.listeners = defaultdict(list)
|
||||
self.hotel_listeners = defaultdict(list) # hotel_code -> list of listeners
|
||||
|
||||
def register(self, event_name, func):
|
||||
self.listeners[event_name].append(func)
|
||||
|
||||
def register_hotel_listener(self, event_name, hotel_code, func):
|
||||
"""Register a listener for a specific hotel"""
|
||||
self.hotel_listeners[f"{event_name}:{hotel_code}"].append(func)
|
||||
|
||||
async def dispatch(self, event_name, *args, **kwargs):
|
||||
for func in self.listeners[event_name]:
|
||||
await func(*args, **kwargs)
|
||||
|
||||
async def dispatch_for_hotel(self, event_name, hotel_code, *args, **kwargs):
|
||||
"""Dispatch event only to listeners registered for specific hotel"""
|
||||
key = f"{event_name}:{hotel_code}"
|
||||
for func in self.hotel_listeners[key]:
|
||||
await func(*args, **kwargs)
|
||||
|
||||
|
||||
event_dispatcher = EventDispatcher()
|
||||
|
||||
# Load config at startup
|
||||
|
||||
|
||||
async def push_listener(customer: DBCustomer, reservation: DBReservation, hotel):
|
||||
"""Push listener that sends reservation data to hotel's push endpoint.
|
||||
Only called for reservations that match this hotel's hotel_id.
|
||||
"""
|
||||
push_endpoint = hotel.get("push_endpoint")
|
||||
if not push_endpoint:
|
||||
_LOGGER.warning(
|
||||
f"No push endpoint configured for hotel {hotel.get('hotel_id')}"
|
||||
)
|
||||
return
|
||||
|
||||
server: AlpineBitsServer = app.state.alpine_bits_server
|
||||
hotel_id = hotel["hotel_id"]
|
||||
reservation_hotel_id = reservation.hotel_code
|
||||
|
||||
# Double-check hotel matching (should be guaranteed by dispatcher)
|
||||
if hotel_id != reservation_hotel_id:
|
||||
_LOGGER.warning(
|
||||
f"Hotel ID mismatch: listener for {hotel_id}, reservation for {reservation_hotel_id}"
|
||||
)
|
||||
return
|
||||
|
||||
_LOGGER.info(
|
||||
f"Processing push notification for hotel {hotel_id}, reservation {reservation.unique_id}"
|
||||
)
|
||||
|
||||
# Prepare payload for push notification
|
||||
|
||||
request = await server.handle_request(
|
||||
request_action_name=AlpineBitsActionName.OTA_HOTEL_RES_NOTIF_GUEST_REQUESTS.request_name,
|
||||
request_xml=(reservation, customer),
|
||||
client_info=None,
|
||||
version=Version.V2024_10,
|
||||
)
|
||||
|
||||
if request.status_code != 200:
|
||||
_LOGGER.error(
|
||||
f"Failed to generate push request for hotel {hotel_id}, reservation {reservation.unique_id}: {request.xml_content}"
|
||||
)
|
||||
return
|
||||
|
||||
# save push request to file
|
||||
|
||||
logs_dir = "logs/push_requests"
|
||||
if not os.path.exists(logs_dir):
|
||||
os.makedirs(logs_dir, mode=0o755, exist_ok=True)
|
||||
stat_info = os.stat(logs_dir)
|
||||
_LOGGER.info(
|
||||
f"Created directory owner: uid:{stat_info.st_uid}, gid:{stat_info.st_gid}"
|
||||
)
|
||||
_LOGGER.info(f"Directory mode: {oct(stat_info.st_mode)[-3:]}")
|
||||
log_filename = f"{logs_dir}/alpinebits_push_{hotel_id}_{reservation.unique_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xml"
|
||||
|
||||
with open(log_filename, "w", encoding="utf-8") as f:
|
||||
f.write(request.xml_content)
|
||||
return
|
||||
|
||||
headers = (
|
||||
{"Authorization": f"Bearer {push_endpoint.get('token', '')}"}
|
||||
if push_endpoint.get("token")
|
||||
else {}
|
||||
)
|
||||
""
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
push_endpoint["url"], json=payload, headers=headers, timeout=10
|
||||
)
|
||||
_LOGGER.info(
|
||||
f"Push event fired to {push_endpoint['url']} for hotel {hotel['hotel_id']}, status: {resp.status_code}"
|
||||
)
|
||||
|
||||
if resp.status_code not in [200, 201, 202]:
|
||||
_LOGGER.warning(
|
||||
f"Push endpoint returned non-success status {resp.status_code}: {resp.text}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
_LOGGER.error(f"Push event failed for hotel {hotel['hotel_id']}: {e}")
|
||||
# Optionally implement retry logic here@asynccontextmanager
|
||||
|
||||
|
||||
async def lifespan(app: FastAPI):
|
||||
# Setup DB
|
||||
|
||||
try:
|
||||
config = load_config()
|
||||
except Exception as e:
|
||||
_LOGGER.error(f"Failed to load config: {e!s}")
|
||||
config = {}
|
||||
|
||||
DATABASE_URL = get_database_url(config)
|
||||
engine = create_async_engine(DATABASE_URL, echo=True)
|
||||
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
app.state.engine = engine
|
||||
app.state.async_sessionmaker = AsyncSessionLocal
|
||||
app.state.config = config
|
||||
app.state.alpine_bits_server = AlpineBitsServer(config)
|
||||
app.state.event_dispatcher = event_dispatcher
|
||||
|
||||
# Register push listeners for hotels with push_endpoint
|
||||
for hotel in config.get("alpine_bits_auth", []):
|
||||
push_endpoint = hotel.get("push_endpoint")
|
||||
hotel_id = hotel.get("hotel_id")
|
||||
|
||||
if push_endpoint and hotel_id:
|
||||
# Register hotel-specific listener
|
||||
event_dispatcher.register_hotel_listener(
|
||||
"form_processed", hotel_id, partial(push_listener, hotel=hotel)
|
||||
)
|
||||
_LOGGER.info(
|
||||
f"Registered push listener for hotel {hotel_id} with endpoint {push_endpoint.get('url')}"
|
||||
)
|
||||
elif push_endpoint and not hotel_id:
|
||||
_LOGGER.warning(f"Hotel has push_endpoint but no hotel_id: {hotel}")
|
||||
elif hotel_id and not push_endpoint:
|
||||
_LOGGER.info(f"Hotel {hotel_id} has no push_endpoint configured")
|
||||
|
||||
# Create tables
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
_LOGGER.info("Database tables checked/created at startup.")
|
||||
|
||||
yield
|
||||
|
||||
# Optional: Dispose engine on shutdown
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def get_async_session(request: Request):
|
||||
async_sessionmaker = request.app.state.async_sessionmaker
|
||||
async with async_sessionmaker() as session:
|
||||
yield session
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Wix Form Handler API",
|
||||
description="Secure API endpoint to receive and process Wix form submissions with authentication and rate limiting",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Create API router with /api prefix
|
||||
api_router = APIRouter(prefix="/api", tags=["api"])
|
||||
|
||||
# Add rate limiting
|
||||
app.state.limiter = limiter
|
||||
app.add_exception_handler(RateLimitExceeded, custom_rate_limit_handler)
|
||||
|
||||
# Add CORS middleware to allow requests from Wix
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"https://*.wix.com",
|
||||
"https://*.wixstatic.com",
|
||||
"http://localhost:3000", # For development
|
||||
"http://localhost:8000", # For local testing
|
||||
],
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@api_router.get("/")
|
||||
@limiter.limit(DEFAULT_RATE_LIMIT)
|
||||
async def root(request: Request):
|
||||
"""Health check endpoint"""
|
||||
return {
|
||||
"message": "Wix Form Handler API is running",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"status": "healthy",
|
||||
"authentication": "required",
|
||||
"rate_limits": {
|
||||
"default": DEFAULT_RATE_LIMIT,
|
||||
"webhook": WEBHOOK_RATE_LIMIT,
|
||||
"burst": BURST_RATE_LIMIT,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@api_router.get("/health")
|
||||
@limiter.limit(DEFAULT_RATE_LIMIT)
|
||||
async def health_check(request: Request):
|
||||
"""Detailed health check"""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"service": "wix-form-handler",
|
||||
"version": "1.0.0",
|
||||
"authentication": "enabled",
|
||||
"rate_limiting": "enabled",
|
||||
}
|
||||
|
||||
|
||||
def create_db_reservation_from_data(
|
||||
reservation_model: ReservationData, db_customer_id: int
|
||||
) -> DBReservation:
|
||||
"""Convert ReservationData to DBReservation, handling children_ages conversion."""
|
||||
data = reservation_model.model_dump(exclude_none=True)
|
||||
|
||||
children_list = data.pop("children_ages", [])
|
||||
children_csv = ",".join(str(int(a)) for a in children_list) if children_list else ""
|
||||
data["children_ages"] = children_csv
|
||||
|
||||
# Inject FK
|
||||
data["customer_id"] = db_customer_id
|
||||
|
||||
return DBReservation(**data)
|
||||
|
||||
|
||||
# Extracted business logic for handling Wix form submissions
|
||||
async def process_wix_form_submission(request: Request, data: dict[str, Any], db):
|
||||
"""Shared business logic for handling Wix form submissions (test and production)."""
|
||||
timestamp = datetime.now().isoformat()
|
||||
|
||||
_LOGGER.info(f"Received Wix form data at {timestamp}")
|
||||
# _LOGGER.info(f"Data keys: {list(data.keys())}")
|
||||
# _LOGGER.info(f"Full data: {json.dumps(data, indent=2)}")
|
||||
log_entry = {
|
||||
"timestamp": timestamp,
|
||||
"client_ip": request.client.host if request.client else "unknown",
|
||||
"headers": dict(request.headers),
|
||||
"data": data,
|
||||
"origin_header": request.headers.get("origin"),
|
||||
"all_headers": dict(request.headers),
|
||||
}
|
||||
logs_dir = "logs"
|
||||
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}/wix_test_data_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
||||
)
|
||||
with open(log_filename, "w", encoding="utf-8") as f:
|
||||
json.dump(log_entry, f, indent=2, default=str, ensure_ascii=False)
|
||||
file_stat = os.stat(log_filename)
|
||||
_LOGGER.info(f"Created file owner: uid:{file_stat.st_uid}, gid:{file_stat.st_gid}")
|
||||
_LOGGER.info(f"File mode: {oct(file_stat.st_mode)[-3:]}")
|
||||
_LOGGER.info(f"Data logged to: {log_filename}")
|
||||
|
||||
data = data.get("data") # Handle nested "data" key if present
|
||||
|
||||
# save customer and reservation to DB
|
||||
|
||||
contact_info = data.get("contact", {})
|
||||
first_name = contact_info.get("name", {}).get("first")
|
||||
last_name = contact_info.get("name", {}).get("last")
|
||||
email = contact_info.get("email")
|
||||
phone_number = contact_info.get("phones", [{}])[0].get("e164Phone")
|
||||
locale = contact_info.get("locale", "de-de")
|
||||
contact_id = contact_info.get("contactId")
|
||||
|
||||
name_prefix = data.get("field:anrede")
|
||||
email_newsletter_string = data.get("field:form_field_5a7b", "")
|
||||
yes_values = {"Selezionato", "Angekreuzt", "Checked"}
|
||||
email_newsletter = email_newsletter_string in yes_values
|
||||
address_line = None
|
||||
city_name = None
|
||||
postal_code = None
|
||||
country_code = None
|
||||
gender = None
|
||||
birth_date = None
|
||||
language = data.get("contact", {}).get("locale", "en")[:2]
|
||||
|
||||
# Dates
|
||||
start_date = (
|
||||
data.get("field:date_picker_a7c8")
|
||||
or data.get("Anreisedatum")
|
||||
or data.get("submissions", [{}])[1].get("value")
|
||||
)
|
||||
end_date = (
|
||||
data.get("field:date_picker_7e65")
|
||||
or data.get("Abreisedatum")
|
||||
or data.get("submissions", [{}])[2].get("value")
|
||||
)
|
||||
|
||||
# Room/guest info
|
||||
num_adults = int(data.get("field:number_7cf5") or 2)
|
||||
num_children = int(data.get("field:anzahl_kinder") or 0)
|
||||
children_ages = []
|
||||
if num_children > 0:
|
||||
for k in data.keys():
|
||||
if k.startswith("field:alter_kind_"):
|
||||
try:
|
||||
age = int(data[k])
|
||||
children_ages.append(age)
|
||||
except ValueError:
|
||||
_LOGGER.warning(f"Invalid age value for {k}: {data[k]}")
|
||||
|
||||
offer = data.get("field:angebot_auswaehlen")
|
||||
|
||||
# get submissionId and ensure max length 35. Generate one if not present
|
||||
|
||||
unique_id = data.get("submissionId", generate_unique_id())
|
||||
|
||||
# use database session
|
||||
|
||||
# Save all relevant data to DB (including new fields)
|
||||
db_customer = DBCustomer(
|
||||
given_name=first_name,
|
||||
surname=last_name,
|
||||
contact_id=contact_id,
|
||||
name_prefix=name_prefix,
|
||||
email_address=email,
|
||||
phone=phone_number,
|
||||
email_newsletter=email_newsletter,
|
||||
address_line=address_line,
|
||||
city_name=city_name,
|
||||
postal_code=postal_code,
|
||||
country_code=country_code,
|
||||
gender=gender,
|
||||
birth_date=birth_date,
|
||||
language=language,
|
||||
address_catalog=False,
|
||||
name_title=None,
|
||||
)
|
||||
db.add(db_customer)
|
||||
await db.flush() # This assigns db_customer.id without committing
|
||||
# await db.refresh(db_customer)
|
||||
|
||||
# Determine hotel_code and hotel_name
|
||||
# Priority: 1) Form field, 2) Configuration default, 3) Hardcoded fallback
|
||||
hotel_code = (
|
||||
data.get("field:hotelid")
|
||||
or data.get("hotelid")
|
||||
or request.app.state.config.get("default_hotel_code")
|
||||
or "123" # fallback
|
||||
)
|
||||
|
||||
hotel_name = (
|
||||
data.get("field:hotelname")
|
||||
or data.get("hotelname")
|
||||
or request.app.state.config.get("default_hotel_name")
|
||||
or "Frangart Inn" # fallback
|
||||
)
|
||||
|
||||
reservation = ReservationData(
|
||||
unique_id=unique_id,
|
||||
start_date=date.fromisoformat(start_date),
|
||||
end_date=date.fromisoformat(end_date),
|
||||
num_adults=num_adults,
|
||||
num_children=num_children,
|
||||
children_ages=children_ages,
|
||||
hotel_code=hotel_code,
|
||||
hotel_name=hotel_name,
|
||||
offer=offer,
|
||||
created_at=datetime.now(UTC),
|
||||
utm_source=data.get("field:utm_source"),
|
||||
utm_medium=data.get("field:utm_medium"),
|
||||
utm_campaign=data.get("field:utm_campaign"),
|
||||
utm_term=data.get("field:utm_term"),
|
||||
utm_content=data.get("field:utm_content"),
|
||||
user_comment=data.get("field:long_answer_3524", ""),
|
||||
fbclid=data.get("field:fbclid"),
|
||||
gclid=data.get("field:gclid"),
|
||||
)
|
||||
|
||||
if reservation.md5_unique_id is None:
|
||||
raise HTTPException(status_code=400, detail="Failed to generate md5_unique_id")
|
||||
|
||||
db_reservation = create_db_reservation_from_data(reservation, db_customer.id)
|
||||
db.add(db_reservation)
|
||||
await db.commit()
|
||||
await db.refresh(db_reservation)
|
||||
|
||||
async def push_event():
|
||||
# Fire event for listeners (push, etc.) - hotel-specific dispatch
|
||||
dispatcher = getattr(request.app.state, "event_dispatcher", None)
|
||||
if dispatcher:
|
||||
# Get hotel_code from reservation to target the right listeners
|
||||
hotel_code = getattr(db_reservation, "hotel_code", None)
|
||||
if hotel_code and hotel_code.strip():
|
||||
await dispatcher.dispatch_for_hotel(
|
||||
"form_processed", hotel_code, db_customer, db_reservation
|
||||
)
|
||||
_LOGGER.info("Dispatched form_processed event for hotel %s", hotel_code)
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"No hotel_code in reservation, skipping push notifications"
|
||||
)
|
||||
|
||||
asyncio.create_task(push_event())
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Wix form data received successfully",
|
||||
"received_keys": list(data.keys()),
|
||||
"data_logged_to": log_filename,
|
||||
"timestamp": timestamp,
|
||||
"note": "No authentication required for this endpoint",
|
||||
}
|
||||
|
||||
|
||||
async def validate_basic_auth(
|
||||
credentials: HTTPBasicCredentials = Depends(security_basic),
|
||||
) -> str:
|
||||
"""Validate basic authentication for AlpineBits protocol.
|
||||
|
||||
Returns username if valid, raises HTTPException if not.
|
||||
"""
|
||||
# Accept any username/password pair present in config['alpine_bits_auth']
|
||||
if not credentials.username or not credentials.password:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="ERROR: Authentication required",
|
||||
headers={"WWW-Authenticate": "Basic"},
|
||||
)
|
||||
valid = False
|
||||
config = app.state.config
|
||||
|
||||
for entry in config["alpine_bits_auth"]:
|
||||
if (
|
||||
credentials.username == entry["username"]
|
||||
and credentials.password == entry["password"]
|
||||
):
|
||||
valid = True
|
||||
break
|
||||
if not valid:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="ERROR: Invalid credentials",
|
||||
headers={"WWW-Authenticate": "Basic"},
|
||||
)
|
||||
_LOGGER.info(
|
||||
"AlpineBits authentication successful for user: %s (from config)",
|
||||
credentials.username,
|
||||
)
|
||||
return credentials.username, credentials.password
|
||||
|
||||
|
||||
@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: {e!s}")
|
||||
# log stacktrace
|
||||
import traceback
|
||||
|
||||
traceback_str = traceback.format_exc()
|
||||
_LOGGER.error(f"Stack trace for handle_wix_form: {traceback_str}")
|
||||
raise HTTPException(status_code=500, detail="Error processing Wix form data")
|
||||
|
||||
|
||||
@api_router.post("/webhook/wix-form/test")
|
||||
@limiter.limit(DEFAULT_RATE_LIMIT)
|
||||
async def handle_wix_form_test(
|
||||
request: Request, data: dict[str, Any], db_session=Depends(get_async_session)
|
||||
):
|
||||
"""Test endpoint to verify the API is working with raw JSON data.
|
||||
No authentication required for testing purposes.
|
||||
"""
|
||||
try:
|
||||
return await process_wix_form_submission(request, data, db_session)
|
||||
except Exception as e:
|
||||
_LOGGER.error(f"Error in handle_wix_form_test: {e!s}")
|
||||
raise HTTPException(status_code=500, detail="Error processing test data")
|
||||
|
||||
|
||||
@api_router.post("/hoteldata/conversions_import")
|
||||
@limiter.limit(DEFAULT_RATE_LIMIT)
|
||||
async def handle_xml_upload(
|
||||
request: Request, credentials_tupel: tuple = Depends(validate_basic_auth)
|
||||
):
|
||||
"""Endpoint for receiving XML files for conversion processing.
|
||||
Requires basic authentication and saves XML files to log directory.
|
||||
Supports gzip compression via Content-Encoding header.
|
||||
"""
|
||||
try:
|
||||
# Get the raw body content
|
||||
body = await request.body()
|
||||
|
||||
if not body:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="ERROR: No XML content provided"
|
||||
)
|
||||
|
||||
# Check if content is gzip compressed
|
||||
content_encoding = request.headers.get("content-encoding", "").lower()
|
||||
is_gzipped = content_encoding == "gzip"
|
||||
|
||||
# Decompress if gzipped
|
||||
if is_gzipped:
|
||||
try:
|
||||
body = gzip.decompress(body)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"ERROR: Failed to decompress gzip content: {e}",
|
||||
) from e
|
||||
|
||||
# Try to decode as UTF-8
|
||||
try:
|
||||
xml_content = body.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
# If UTF-8 fails, try with latin-1 as fallback
|
||||
xml_content = body.decode("latin-1")
|
||||
|
||||
# Basic validation that it's XML-like
|
||||
if not xml_content.strip().startswith("<"):
|
||||
raise HTTPException(
|
||||
status_code=400, detail="ERROR: Content does not appear to be XML"
|
||||
)
|
||||
|
||||
# Create logs directory for XML conversions
|
||||
logs_dir = Path("logs/conversions_import")
|
||||
if not logs_dir.exists():
|
||||
logs_dir.mkdir(parents=True, mode=0o755, exist_ok=True)
|
||||
_LOGGER.info("Created directory: %s", logs_dir)
|
||||
|
||||
# Generate filename with timestamp and authenticated user
|
||||
username, _ = credentials_tupel
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
log_filename = logs_dir / f"xml_import_{username}_{timestamp}.xml"
|
||||
|
||||
# Save XML content to file
|
||||
log_filename.write_text(xml_content, encoding="utf-8")
|
||||
|
||||
_LOGGER.info("XML file saved to %s by user %s", log_filename, username)
|
||||
|
||||
response_headers = {
|
||||
"Content-Type": "application/xml; charset=utf-8",
|
||||
"X-AlpineBits-Server-Accept-Encoding": "gzip",
|
||||
}
|
||||
|
||||
return Response(
|
||||
content="Xml received", headers=response_headers, status_code=200
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
_LOGGER.exception("Error in handle_xml_upload")
|
||||
raise HTTPException(status_code=500, detail="Error processing XML upload")
|
||||
|
||||
|
||||
# UNUSED
|
||||
@api_router.post("/admin/generate-api-key")
|
||||
@limiter.limit("5/hour") # Very restrictive for admin operations
|
||||
async def generate_new_api_key(
|
||||
request: Request, admin_key: str = Depends(validate_api_key)
|
||||
):
|
||||
"""Admin endpoint to generate new API keys.
|
||||
Requires admin API key and is heavily rate limited.
|
||||
"""
|
||||
if admin_key != "admin-key":
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
new_key = generate_api_key()
|
||||
_LOGGER.info(f"Generated new API key (requested by: {admin_key})")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "New API key generated",
|
||||
"api_key": new_key,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"note": "Store this key securely - it won't be shown again",
|
||||
}
|
||||
|
||||
|
||||
# TODO Bit sketchy. May need requests-toolkit in the future
|
||||
def parse_multipart_data(content_type: str, body: bytes) -> dict[str, Any]:
|
||||
"""Parse multipart/form-data from raw request body.
|
||||
This is a simplified parser for the AlpineBits use case.
|
||||
"""
|
||||
if "multipart/form-data" not in content_type:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="ERROR: Content-Type must be multipart/form-data"
|
||||
)
|
||||
|
||||
# Extract boundary
|
||||
boundary = None
|
||||
for part in content_type.split(";"):
|
||||
part = part.strip()
|
||||
if part.startswith("boundary="):
|
||||
boundary = part.split("=", 1)[1].strip('"')
|
||||
break
|
||||
|
||||
if not boundary:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="ERROR: Missing boundary in multipart/form-data"
|
||||
)
|
||||
|
||||
# Simple multipart parsing
|
||||
parts = body.split(f"--{boundary}".encode())
|
||||
data = {}
|
||||
|
||||
for part in parts:
|
||||
if not part.strip() or part.strip() == b"--":
|
||||
continue
|
||||
|
||||
# Split headers and content
|
||||
if b"\r\n\r\n" in part:
|
||||
headers_section, content = part.split(b"\r\n\r\n", 1)
|
||||
content = content.rstrip(b"\r\n")
|
||||
|
||||
# Parse Content-Disposition header
|
||||
headers = headers_section.decode("utf-8", errors="ignore")
|
||||
name = None
|
||||
for line in headers.split("\n"):
|
||||
if "Content-Disposition" in line and "name=" in line:
|
||||
# Extract name parameter
|
||||
for param in line.split(";"):
|
||||
param = param.strip()
|
||||
if param.startswith("name="):
|
||||
name = param.split("=", 1)[1].strip('"')
|
||||
break
|
||||
|
||||
if name:
|
||||
# Handle file uploads or text content
|
||||
if content.startswith(b"<"):
|
||||
# Likely XML content
|
||||
data[name] = content.decode("utf-8", errors="ignore")
|
||||
else:
|
||||
data[name] = content.decode("utf-8", errors="ignore")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@api_router.post("/alpinebits/server-2024-10")
|
||||
@limiter.limit("60/minute")
|
||||
async def alpinebits_server_handshake(
|
||||
request: Request,
|
||||
credentials_tupel: tuple = Depends(validate_basic_auth),
|
||||
dbsession=Depends(get_async_session),
|
||||
):
|
||||
"""AlpineBits server endpoint implementing the handshake protocol.
|
||||
|
||||
This endpoint handles:
|
||||
- Protocol version negotiation via X-AlpineBits-ClientProtocolVersion header
|
||||
- Client identification via X-AlpineBits-ClientID header (optional)
|
||||
- Multipart/form-data parsing for action and request parameters
|
||||
- Gzip compression support
|
||||
- Proper error handling with HTTP status codes
|
||||
- Handshaking action processing
|
||||
|
||||
Authentication: HTTP Basic Auth required
|
||||
Content-Type: multipart/form-data
|
||||
Compression: gzip supported (check X-AlpineBits-Server-Accept-Encoding)
|
||||
"""
|
||||
try:
|
||||
# Check required headers
|
||||
client_protocol_version = request.headers.get(
|
||||
"X-AlpineBits-ClientProtocolVersion"
|
||||
)
|
||||
if not client_protocol_version:
|
||||
# Server concludes client speaks a protocol version preceding 2013-04
|
||||
client_protocol_version = "pre-2013-04"
|
||||
_LOGGER.info(
|
||||
"No X-AlpineBits-ClientProtocolVersion header found, assuming pre-2013-04"
|
||||
)
|
||||
else:
|
||||
_LOGGER.info("Client protocol version: %s", client_protocol_version)
|
||||
|
||||
# Optional client ID
|
||||
client_id = request.headers.get("X-AlpineBits-ClientID")
|
||||
if client_id:
|
||||
_LOGGER.info("Client ID: %s", client_id)
|
||||
|
||||
# Check content encoding
|
||||
content_encoding = request.headers.get("Content-Encoding")
|
||||
is_compressed = content_encoding == "gzip"
|
||||
|
||||
if is_compressed:
|
||||
_LOGGER.info("Request is gzip compressed")
|
||||
|
||||
# Get content type before processing
|
||||
content_type = request.headers.get("Content-Type", "")
|
||||
|
||||
_LOGGER.info("Content-Type: %s", content_type)
|
||||
_LOGGER.info("Content-Encoding: %s", content_encoding)
|
||||
|
||||
# Get request body
|
||||
body = await request.body()
|
||||
|
||||
# Decompress if needed
|
||||
form_data = validate_alpinebits_body(is_compressed, content_type, body)
|
||||
|
||||
# Check for required action parameter
|
||||
action = form_data.get("action")
|
||||
if not action:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="ERROR: Missing required 'action' parameter"
|
||||
)
|
||||
|
||||
_LOGGER.info(f"AlpineBits action: {action}")
|
||||
|
||||
# Get optional request XML
|
||||
request_xml = form_data.get("request")
|
||||
|
||||
server: AlpineBitsServer = app.state.alpine_bits_server
|
||||
|
||||
version = Version.V2024_10
|
||||
|
||||
username, password = credentials_tupel
|
||||
|
||||
client_info = AlpineBitsClientInfo(
|
||||
username=username, password=password, client_id=client_id
|
||||
)
|
||||
|
||||
# Create successful handshake response
|
||||
response = await server.handle_request(
|
||||
action,
|
||||
request_xml,
|
||||
client_info=client_info,
|
||||
version=version,
|
||||
dbsession=dbsession,
|
||||
)
|
||||
|
||||
response_xml = response.xml_content
|
||||
|
||||
# Set response headers indicating server capabilities
|
||||
headers = {
|
||||
"Content-Type": "application/xml; charset=utf-8",
|
||||
"X-AlpineBits-Server-Accept-Encoding": "gzip", # Indicate gzip support
|
||||
"X-AlpineBits-Server-Version": "2024-10",
|
||||
}
|
||||
|
||||
if is_compressed:
|
||||
# Compress response if client sent compressed request
|
||||
response_xml = gzip.compress(response_xml.encode("utf-8"))
|
||||
headers["Content-Encoding"] = "gzip"
|
||||
|
||||
return Response(
|
||||
content=response_xml, status_code=response.status_code, headers=headers
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
# Re-raise HTTP exceptions (auth errors, etc.)
|
||||
raise
|
||||
except Exception as e:
|
||||
_LOGGER.error(f"Error in AlpineBits handshake: {e!s}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
def validate_alpinebits_body(is_compressed, content_type, body):
|
||||
"""Check if the body conforms to AlpineBits expectations."""
|
||||
if is_compressed:
|
||||
try:
|
||||
body = gzip.decompress(body)
|
||||
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="ERROR: Failed to decompress gzip content",
|
||||
)
|
||||
|
||||
# Check content type (after decompression)
|
||||
if (
|
||||
"multipart/form-data" not in content_type
|
||||
and "application/x-www-form-urlencoded" not in content_type
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="ERROR: Content-Type must be multipart/form-data or application/x-www-form-urlencoded",
|
||||
)
|
||||
|
||||
# Parse multipart data
|
||||
if "multipart/form-data" in content_type:
|
||||
try:
|
||||
form_data = parse_multipart_data(content_type, body)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="ERROR: Failed to parse multipart/form-data",
|
||||
)
|
||||
elif "application/x-www-form-urlencoded" in content_type:
|
||||
# Parse as urlencoded
|
||||
form_data = dict(urllib.parse.parse_qsl(body.decode("utf-8")))
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="ERROR: Content-Type must be multipart/form-data or application/x-www-form-urlencoded",
|
||||
)
|
||||
|
||||
return form_data
|
||||
|
||||
|
||||
@api_router.get("/admin/stats")
|
||||
@limiter.limit("10/minute")
|
||||
async def get_api_stats(request: Request, admin_key: str = Depends(validate_api_key)):
|
||||
"""Admin endpoint to get API usage statistics.
|
||||
Requires admin API key.
|
||||
"""
|
||||
if admin_key != "admin-key":
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
# In a real application, you'd fetch this from your database/monitoring system
|
||||
return {
|
||||
"status": "success",
|
||||
"stats": {
|
||||
"uptime": "Available in production deployment",
|
||||
"total_requests": "Available with monitoring setup",
|
||||
"active_api_keys": len([k for k in ["wix-webhook-key", "admin-key"] if k]),
|
||||
"rate_limit_backend": "redis" if os.getenv("REDIS_URL") else "memory",
|
||||
},
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# Include the API router in the main app
|
||||
app.include_router(api_router)
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def landing_page():
|
||||
"""Serve the under construction landing page at the root route."""
|
||||
try:
|
||||
# Get the path to the HTML file
|
||||
html_path = os.path.join(os.path.dirname(__file__), "templates", "index.html")
|
||||
|
||||
with open(html_path, encoding="utf-8") as f:
|
||||
html_content = f.read()
|
||||
|
||||
return HTMLResponse(content=html_content, status_code=200)
|
||||
except FileNotFoundError:
|
||||
# Fallback if HTML file is not found
|
||||
html_content = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>99tales - Under Construction</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
|
||||
h1 { color: #333; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🏗️ 99tales</h1>
|
||||
<h2>Under Construction</h2>
|
||||
<p>We're working hard to bring you something amazing!</p>
|
||||
<p><a href="/api">API Documentation</a></p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return HTMLResponse(content=html_content, status_code=200)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
112
src/alpine_bits_python/auth.py
Normal file
112
src/alpine_bits_python/auth.py
Normal file
@@ -0,0 +1,112 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import HTTPException, Security, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Security scheme
|
||||
security = HTTPBearer()
|
||||
|
||||
# API Keys - In production, store these in environment variables or a secure database
|
||||
API_KEYS = {
|
||||
# Example API keys - replace with your own secure keys
|
||||
"wix-webhook-key": "sk_live_your_secure_api_key_here",
|
||||
"admin-key": "sk_admin_your_admin_key_here",
|
||||
}
|
||||
|
||||
# Load API keys from environment if available
|
||||
if os.getenv("WIX_API_KEY"):
|
||||
API_KEYS["wix-webhook-key"] = os.getenv("WIX_API_KEY")
|
||||
if os.getenv("ADMIN_API_KEY"):
|
||||
API_KEYS["admin-key"] = os.getenv("ADMIN_API_KEY")
|
||||
|
||||
|
||||
def generate_unique_id() -> str:
|
||||
"""Generate a unique ID with max length 35 characters"""
|
||||
return secrets.token_urlsafe(26)[:35] # 26 bytes -> 35 chars in base64url
|
||||
|
||||
|
||||
def generate_api_key() -> str:
|
||||
"""Generate a secure API key"""
|
||||
return f"sk_live_{secrets.token_urlsafe(32)}"
|
||||
|
||||
|
||||
def validate_api_key(
|
||||
credentials: HTTPAuthorizationCredentials = Security(security),
|
||||
) -> str:
|
||||
"""Validate API key from Authorization header.
|
||||
Expected format: Authorization: Bearer your_api_key_here
|
||||
"""
|
||||
token = credentials.credentials
|
||||
|
||||
# Check if the token is in our valid API keys
|
||||
for key_name, valid_key in API_KEYS.items():
|
||||
if secrets.compare_digest(token, valid_key):
|
||||
logger.info(f"Valid API key used: {key_name}")
|
||||
return key_name
|
||||
|
||||
logger.warning(f"Invalid API key attempted: {token[:10]}...")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid API key",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
def validate_wix_signature(payload: bytes, signature: str, secret: str) -> bool:
|
||||
"""Validate Wix webhook signature for additional security.
|
||||
Wix signs their webhooks with HMAC-SHA256.
|
||||
"""
|
||||
if not signature or not secret:
|
||||
return False
|
||||
|
||||
try:
|
||||
# Remove 'sha256=' prefix if present
|
||||
signature = signature.removeprefix("sha256=")
|
||||
|
||||
# Calculate expected signature
|
||||
expected_signature = hmac.new(
|
||||
secret.encode("utf-8"), payload, hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
# Compare signatures securely
|
||||
return secrets.compare_digest(signature, expected_signature)
|
||||
except Exception as e:
|
||||
logger.error(f"Error validating signature: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class APIKeyAuth:
|
||||
"""Simple API key authentication class"""
|
||||
|
||||
def __init__(self, api_keys: dict):
|
||||
self.api_keys = api_keys
|
||||
|
||||
def authenticate(self, api_key: str) -> str | None:
|
||||
"""Authenticate an API key and return the key name if valid"""
|
||||
for key_name, valid_key in self.api_keys.items():
|
||||
if secrets.compare_digest(api_key, valid_key):
|
||||
return key_name
|
||||
return None
|
||||
|
||||
def add_key(self, name: str, key: str):
|
||||
"""Add a new API key"""
|
||||
self.api_keys[name] = key
|
||||
|
||||
def remove_key(self, name: str):
|
||||
"""Remove an API key"""
|
||||
if name in self.api_keys:
|
||||
del self.api_keys[name]
|
||||
|
||||
|
||||
# Initialize auth system
|
||||
auth_system = APIKeyAuth(API_KEYS)
|
||||
105
src/alpine_bits_python/config_loader.py
Normal file
105
src/alpine_bits_python/config_loader.py
Normal file
@@ -0,0 +1,105 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from annotatedyaml.loader import (
|
||||
Secrets,
|
||||
)
|
||||
from annotatedyaml.loader import (
|
||||
load_yaml as load_annotated_yaml,
|
||||
)
|
||||
from voluptuous import (
|
||||
PREVENT_EXTRA,
|
||||
All,
|
||||
Length,
|
||||
MultipleInvalid,
|
||||
Optional,
|
||||
Required,
|
||||
Schema,
|
||||
)
|
||||
|
||||
# --- Voluptuous schemas ---
|
||||
database_schema = Schema({Required("url"): str}, extra=PREVENT_EXTRA)
|
||||
|
||||
|
||||
hotel_auth_schema = Schema(
|
||||
{
|
||||
Required("hotel_id"): str,
|
||||
Required("hotel_name"): str,
|
||||
Required("username"): str,
|
||||
Required("password"): str,
|
||||
Optional("push_endpoint"): {
|
||||
Required("url"): str,
|
||||
Required("token"): str,
|
||||
Optional("username"): str,
|
||||
},
|
||||
},
|
||||
extra=PREVENT_EXTRA,
|
||||
)
|
||||
|
||||
basic_auth_schema = Schema(All([hotel_auth_schema], Length(min=1)))
|
||||
|
||||
config_schema = Schema(
|
||||
{
|
||||
Required("database"): database_schema,
|
||||
Required("alpine_bits_auth"): basic_auth_schema,
|
||||
},
|
||||
extra=PREVENT_EXTRA,
|
||||
)
|
||||
|
||||
DEFAULT_CONFIG_FILE = "config.yaml"
|
||||
|
||||
|
||||
class Config:
|
||||
def __init__(
|
||||
self,
|
||||
config_folder: str | Path = None,
|
||||
config_name: str = DEFAULT_CONFIG_FILE,
|
||||
testing_mode: bool = False,
|
||||
):
|
||||
if config_folder is None:
|
||||
config_folder = os.environ.get("ALPINEBITS_CONFIG_DIR")
|
||||
if not config_folder:
|
||||
config_folder = os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "../../config")
|
||||
)
|
||||
if isinstance(config_folder, str):
|
||||
config_folder = Path(config_folder)
|
||||
self.config_folder = config_folder
|
||||
self.config_path = os.path.join(config_folder, config_name)
|
||||
self.secrets = Secrets(config_folder)
|
||||
self.testing_mode = testing_mode
|
||||
self._load_config()
|
||||
|
||||
def _load_config(self):
|
||||
stuff = load_annotated_yaml(self.config_path, secrets=self.secrets)
|
||||
try:
|
||||
validated = config_schema(stuff)
|
||||
except MultipleInvalid as e:
|
||||
raise ValueError(f"Config validation error: {e}")
|
||||
self.database = validated["database"]
|
||||
self.basic_auth = validated["alpine_bits_auth"]
|
||||
self.config = validated
|
||||
|
||||
def get(self, key, default=None):
|
||||
return self.config.get(key, default)
|
||||
|
||||
@property
|
||||
def db_url(self) -> str:
|
||||
return self.database["url"]
|
||||
|
||||
@property
|
||||
def hotel_id(self) -> str:
|
||||
return self.basic_auth["hotel_id"]
|
||||
|
||||
@property
|
||||
def hotel_name(self) -> str:
|
||||
return self.basic_auth["hotel_name"]
|
||||
|
||||
@property
|
||||
def users(self) -> list[dict[str, str]]:
|
||||
return self.basic_auth["users"]
|
||||
|
||||
|
||||
# For backward compatibility
|
||||
def load_config():
|
||||
return Config().config
|
||||
0
src/alpine_bits_python/const.py
Normal file
0
src/alpine_bits_python/const.py
Normal file
79
src/alpine_bits_python/db.py
Normal file
79
src/alpine_bits_python/db.py
Normal file
@@ -0,0 +1,79 @@
|
||||
import os
|
||||
|
||||
from sqlalchemy import Boolean, Column, Date, DateTime, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import declarative_base, relationship
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
# Async SQLAlchemy setup
|
||||
def get_database_url(config=None):
|
||||
db_url = None
|
||||
if config and "database" in config and "url" in config["database"]:
|
||||
db_url = config["database"]["url"]
|
||||
if not db_url:
|
||||
db_url = os.environ.get("DATABASE_URL")
|
||||
if not db_url:
|
||||
db_url = "sqlite+aiosqlite:///alpinebits.db"
|
||||
return db_url
|
||||
|
||||
|
||||
class Customer(Base):
|
||||
__tablename__ = "customers"
|
||||
id = Column(Integer, primary_key=True)
|
||||
given_name = Column(String)
|
||||
contact_id = Column(String, unique=True)
|
||||
surname = Column(String)
|
||||
name_prefix = Column(String)
|
||||
email_address = Column(String)
|
||||
phone = Column(String)
|
||||
email_newsletter = Column(Boolean)
|
||||
address_line = Column(String)
|
||||
city_name = Column(String)
|
||||
postal_code = Column(String)
|
||||
country_code = Column(String)
|
||||
gender = Column(String)
|
||||
birth_date = Column(String)
|
||||
language = Column(String)
|
||||
address_catalog = Column(Boolean) # Added for XML
|
||||
name_title = Column(String) # Added for XML
|
||||
reservations = relationship("Reservation", back_populates="customer")
|
||||
|
||||
|
||||
class Reservation(Base):
|
||||
__tablename__ = "reservations"
|
||||
id = Column(Integer, primary_key=True)
|
||||
customer_id = Column(Integer, ForeignKey("customers.id"))
|
||||
unique_id = Column(String, unique=True)
|
||||
md5_unique_id = Column(String(32), unique=True) # max length 32 guaranteed
|
||||
start_date = Column(Date)
|
||||
end_date = Column(Date)
|
||||
num_adults = Column(Integer)
|
||||
num_children = Column(Integer)
|
||||
children_ages = Column(String) # comma-separated
|
||||
offer = Column(String)
|
||||
created_at = Column(DateTime)
|
||||
# Add all UTM fields and user comment for XML
|
||||
utm_source = Column(String)
|
||||
utm_medium = Column(String)
|
||||
utm_campaign = Column(String)
|
||||
utm_term = Column(String)
|
||||
utm_content = Column(String)
|
||||
user_comment = Column(String)
|
||||
fbclid = Column(String)
|
||||
gclid = Column(String)
|
||||
# Add hotel_code and hotel_name for XML
|
||||
hotel_code = Column(String)
|
||||
hotel_name = Column(String)
|
||||
customer = relationship("Customer", back_populates="reservations")
|
||||
|
||||
|
||||
# Table for tracking acknowledged requests by client
|
||||
class AckedRequest(Base):
|
||||
__tablename__ = "acked_requests"
|
||||
id = Column(Integer, primary_key=True)
|
||||
client_id = Column(String, index=True)
|
||||
unique_id = Column(
|
||||
String, index=True
|
||||
) # Should match Reservation.form_id or another unique field
|
||||
timestamp = Column(DateTime)
|
||||
@@ -85,6 +85,7 @@ __all__ = [
|
||||
"CommentName1",
|
||||
"CommentName2",
|
||||
"ContactInfoLocation",
|
||||
"DefSendComplete",
|
||||
"DescriptionName",
|
||||
"DescriptionTextFormat1",
|
||||
"DescriptionTextFormat2",
|
||||
@@ -103,6 +104,7 @@ __all__ = [
|
||||
"MealsIncludedMealPlanIndicator",
|
||||
"MultimediaDescriptionInfoCode1",
|
||||
"MultimediaDescriptionInfoCode2",
|
||||
"OccupancyAgeQualifyingCode",
|
||||
"OtaHotelDescriptiveContentNotifRq",
|
||||
"OtaHotelDescriptiveContentNotifRs",
|
||||
"OtaHotelDescriptiveInfoRq",
|
||||
@@ -123,7 +125,6 @@ __all__ = [
|
||||
"OtaPingRs",
|
||||
"OtaReadRq",
|
||||
"OtaResRetrieveRs",
|
||||
"OccupancyAgeQualifyingCode",
|
||||
"PositionAltitudeUnitOfMeasureCode",
|
||||
"PrerequisiteInventoryInvType",
|
||||
"ProfileProfileType",
|
||||
@@ -150,12 +151,11 @@ __all__ = [
|
||||
"TextTextFormat2",
|
||||
"TimeUnitType",
|
||||
"TypeRoomRoomType",
|
||||
"UrlType",
|
||||
"UniqueIdInstance",
|
||||
"UniqueIdType1",
|
||||
"UniqueIdType2",
|
||||
"UniqueIdType3",
|
||||
"UrlType",
|
||||
"VideoItemCategory",
|
||||
"WarningStatus",
|
||||
"DefSendComplete",
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,131 +1,320 @@
|
||||
from .alpinebits_guestrequests import ResGuest, RoomStay
|
||||
from .generated import alpinebits as ab
|
||||
from io import BytesIO
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
import re
|
||||
from xsdata_pydantic.bindings import XmlSerializer
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import UTC, date, datetime
|
||||
|
||||
from .simplified_access import (
|
||||
CommentData,
|
||||
CommentsData,
|
||||
CommentListItemData,
|
||||
CustomerData,
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
HotelReservationIdData,
|
||||
PhoneTechType,
|
||||
from .alpine_bits_helpers import (
|
||||
AlpineBitsFactory,
|
||||
OtaMessageType
|
||||
CommentData,
|
||||
CommentListItemData,
|
||||
CommentsData,
|
||||
CustomerData,
|
||||
GuestCountsFactory,
|
||||
HotelReservationIdData,
|
||||
OtaMessageType,
|
||||
PhoneTechType,
|
||||
)
|
||||
from .config_loader import load_config
|
||||
|
||||
# DB and config
|
||||
from .db import (
|
||||
Base,
|
||||
get_database_url,
|
||||
)
|
||||
from .db import (
|
||||
Customer as DBCustomer,
|
||||
)
|
||||
from .db import (
|
||||
Reservation as DBReservation,
|
||||
)
|
||||
from .generated import alpinebits as ab
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def setup_db(config):
|
||||
DATABASE_URL = get_database_url(config)
|
||||
engine = create_async_engine(DATABASE_URL, echo=True)
|
||||
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
# Create tables
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
_LOGGER.info("Database tables checked/created at startup.")
|
||||
|
||||
return engine, AsyncSessionLocal
|
||||
|
||||
|
||||
async def main():
|
||||
print("🚀 Starting AlpineBits XML generation script...")
|
||||
# Load config (yaml, annotatedyaml)
|
||||
config = load_config()
|
||||
|
||||
# print config for debugging
|
||||
print("Loaded configuration:")
|
||||
print(json.dumps(config, indent=2))
|
||||
|
||||
# Ensure SQLite DB file exists if using SQLite
|
||||
db_url = config.get("database", {}).get("url", "")
|
||||
if db_url.startswith("sqlite+aiosqlite:///"):
|
||||
db_path = db_url.replace("sqlite+aiosqlite:///", "")
|
||||
db_path = os.path.abspath(db_path)
|
||||
db_dir = os.path.dirname(db_path)
|
||||
if not os.path.exists(db_dir):
|
||||
os.makedirs(db_dir, exist_ok=True)
|
||||
# for now we delete the existing DB for clean testing
|
||||
if os.path.exists(db_path):
|
||||
os.remove(db_path)
|
||||
print(f"Deleted existing SQLite DB at {db_path} for clean testing.")
|
||||
|
||||
# # Ensure DB schema is created (async)
|
||||
|
||||
engine, AsyncSessionLocal = await setup_db(config)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
# Load data from JSON file
|
||||
json_path = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"../../test_data/wix_test_data_20250928_132611.json",
|
||||
)
|
||||
with open(json_path, encoding="utf-8") as f:
|
||||
wix_data = json.load(f)
|
||||
data = wix_data["data"]["data"]
|
||||
|
||||
contact_info = data.get("contact", {})
|
||||
first_name = contact_info.get("name", {}).get("first")
|
||||
last_name = contact_info.get("name", {}).get("last")
|
||||
email = contact_info.get("email")
|
||||
phone_number = contact_info.get("phones", [{}])[0].get("e164Phone")
|
||||
locale = contact_info.get("locale", "de-de")
|
||||
contact_id = contact_info.get("contactId")
|
||||
|
||||
name_prefix = data.get("field:anrede")
|
||||
email_newsletter = data.get("field:form_field_5a7b", "") != "Non selezionato"
|
||||
address_line = None
|
||||
city_name = None
|
||||
postal_code = None
|
||||
country_code = None
|
||||
gender = None
|
||||
birth_date = None
|
||||
language = data.get("contact", {}).get("locale", "en")[:2]
|
||||
|
||||
# Dates
|
||||
start_date = (
|
||||
data.get("field:date_picker_a7c8")
|
||||
or data.get("Anreisedatum")
|
||||
or data.get("submissions", [{}])[1].get("value")
|
||||
)
|
||||
end_date = (
|
||||
data.get("field:date_picker_7e65")
|
||||
or data.get("Abreisedatum")
|
||||
or data.get("submissions", [{}])[2].get("value")
|
||||
)
|
||||
|
||||
# Room/guest info
|
||||
num_adults = int(data.get("field:number_7cf5") or 2)
|
||||
num_children = int(data.get("field:anzahl_kinder") or 0)
|
||||
children_ages = []
|
||||
if num_children > 0:
|
||||
for k in data.keys():
|
||||
if k.startswith("field:alter_kind_"):
|
||||
try:
|
||||
age = int(data[k])
|
||||
children_ages.append(age)
|
||||
except ValueError:
|
||||
logging.warning(f"Invalid age value for {k}: {data[k]}")
|
||||
|
||||
def main():
|
||||
# Success - use None instead of object() for cleaner XML output
|
||||
success = None
|
||||
# UTM and offer
|
||||
utm_fields = [
|
||||
("utm_Source", "utm_source"),
|
||||
("utm_Medium", "utm_medium"),
|
||||
("utm_Campaign", "utm_campaign"),
|
||||
("utm_Term", "utm_term"),
|
||||
("utm_Content", "utm_content"),
|
||||
]
|
||||
utm_comment_text = []
|
||||
for label, field in utm_fields:
|
||||
val = data.get(f"field:{field}") or data.get(label)
|
||||
if val:
|
||||
utm_comment_text.append(f"{label}: {val}")
|
||||
utm_comment = " | ".join(utm_comment_text) if utm_comment_text else None
|
||||
offer = data.get("field:angebot_auswaehlen")
|
||||
|
||||
# Save all relevant data to DB (including new fields)
|
||||
db_customer = DBCustomer(
|
||||
given_name=first_name,
|
||||
surname=last_name,
|
||||
contact_id=contact_id,
|
||||
name_prefix=name_prefix,
|
||||
email_address=email,
|
||||
phone=phone_number,
|
||||
email_newsletter=email_newsletter,
|
||||
address_line=address_line,
|
||||
city_name=city_name,
|
||||
postal_code=postal_code,
|
||||
country_code=country_code,
|
||||
gender=gender,
|
||||
birth_date=birth_date,
|
||||
language=language,
|
||||
address_catalog=False,
|
||||
name_title=None,
|
||||
)
|
||||
db.add(db_customer)
|
||||
await db.commit()
|
||||
await db.refresh(db_customer)
|
||||
|
||||
db_reservation = DBReservation(
|
||||
customer_id=db_customer.id,
|
||||
form_id=data.get("submissionId"),
|
||||
start_date=date.fromisoformat(start_date) if start_date else None,
|
||||
end_date=date.fromisoformat(end_date) if end_date else None,
|
||||
num_adults=num_adults,
|
||||
num_children=num_children,
|
||||
children_ages=",".join(str(a) for a in children_ages),
|
||||
offer=offer,
|
||||
utm_comment=utm_comment,
|
||||
created_at=datetime.now(UTC),
|
||||
utm_source=data.get("field:utm_source"),
|
||||
utm_medium=data.get("field:utm_medium"),
|
||||
utm_campaign=data.get("field:utm_campaign"),
|
||||
utm_term=data.get("field:utm_term"),
|
||||
utm_content=data.get("field:utm_content"),
|
||||
user_comment=data.get("field:long_answer_3524", ""),
|
||||
fbclid=data.get("field:fbclid"),
|
||||
gclid=data.get("field:gclid"),
|
||||
hotel_code="123",
|
||||
hotel_name="Frangart Inn",
|
||||
)
|
||||
db.add(db_reservation)
|
||||
await db.commit()
|
||||
await db.refresh(db_reservation)
|
||||
|
||||
# Now read back from DB
|
||||
customer = await db.get(DBCustomer, db_reservation.customer_id)
|
||||
reservation = await db.get(DBReservation, db_reservation.id)
|
||||
|
||||
# Generate XML from DB data
|
||||
create_xml_from_db(customer, reservation)
|
||||
|
||||
await db.close()
|
||||
|
||||
|
||||
def create_xml_from_db(customer: DBCustomer, reservation: DBReservation):
|
||||
# Prepare data for XML
|
||||
phone_numbers = [(customer.phone, PhoneTechType.MOBILE)] if customer.phone 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
|
||||
)
|
||||
|
||||
# UniqueID
|
||||
unique_id = ab.OtaResRetrieveRs.ReservationsList.HotelReservation.UniqueId(
|
||||
type_value=ab.UniqueIdType2.VALUE_14, id="6b34fe24ac2ff811"
|
||||
)
|
||||
|
||||
# TimeSpan - use the actual nested class
|
||||
|
||||
start_date_window = ab.OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays.RoomStay.TimeSpan.StartDateWindow(
|
||||
earliest_date="2024-10-01", latest_date="2024-10-02"
|
||||
type_value=ab.UniqueIdType2.VALUE_14, id=reservation.unique_id
|
||||
)
|
||||
|
||||
# TimeSpan
|
||||
time_span = ab.OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays.RoomStay.TimeSpan(
|
||||
start_date_window=start_date_window
|
||||
start=reservation.start_date.isoformat() if reservation.start_date else None,
|
||||
end=reservation.end_date.isoformat() if reservation.end_date else None,
|
||||
)
|
||||
|
||||
# RoomStay with TimeSpan
|
||||
room_stay = (
|
||||
ab.OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays.RoomStay(
|
||||
time_span=time_span
|
||||
time_span=time_span,
|
||||
guest_counts=guest_counts,
|
||||
)
|
||||
)
|
||||
room_stays = ab.OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays(
|
||||
room_stay=[room_stay]
|
||||
room_stay=[room_stay],
|
||||
)
|
||||
|
||||
customer_data = CustomerData(
|
||||
given_name="John",
|
||||
surname="Doe",
|
||||
name_prefix="Mr.",
|
||||
phone_numbers=[
|
||||
("+1234567890", PhoneTechType.MOBILE), # Phone number with type
|
||||
("+0987654321", None), # Phone number without type
|
||||
],
|
||||
email_address="john.doe@example.com",
|
||||
email_newsletter=True,
|
||||
address_line="123 Main Street",
|
||||
city_name="Anytown",
|
||||
postal_code="12345",
|
||||
country_code="US",
|
||||
address_catalog=False,
|
||||
gender="Male",
|
||||
birth_date="1980-01-01",
|
||||
language="en",
|
||||
)
|
||||
|
||||
alpine_bits_factory = AlpineBitsFactory()
|
||||
|
||||
res_guests = alpine_bits_factory.create_res_guests(customer_data, OtaMessageType.RETRIEVE)
|
||||
|
||||
# HotelReservationId
|
||||
hotel_res_id_data = HotelReservationIdData(
|
||||
res_id_type="13",
|
||||
res_id_value=None,
|
||||
res_id_value=reservation.fbclid or reservation.gclid,
|
||||
res_id_source=None,
|
||||
res_id_source_context="99tales",
|
||||
)
|
||||
# Create HotelReservationId using the factory
|
||||
hotel_res_id = alpine_bits_factory.create(hotel_res_id_data, OtaMessageType.RETRIEVE)
|
||||
|
||||
# Use the actual nested HotelReservationIds class
|
||||
hotel_res_id = alpine_bits_factory.create(
|
||||
hotel_res_id_data, OtaMessageType.RETRIEVE
|
||||
)
|
||||
hotel_res_ids = ab.OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.HotelReservationIds(
|
||||
hotel_reservation_id=[hotel_res_id]
|
||||
)
|
||||
|
||||
# Basic property info
|
||||
basic_property_info = ab.OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.BasicPropertyInfo(
|
||||
hotel_code="123", hotel_name="Frangart Inn"
|
||||
hotel_code=reservation.hotel_code,
|
||||
hotel_name=reservation.hotel_name,
|
||||
)
|
||||
|
||||
# Comments
|
||||
offer_comment = CommentData(
|
||||
name=ab.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=ab.CommentName2.CUSTOMER_COMMENT,
|
||||
text="This is a sample comment.",
|
||||
list_items=[CommentListItemData(
|
||||
text=reservation.user_comment,
|
||||
list_items=[
|
||||
CommentListItemData(
|
||||
value="Landing page comment",
|
||||
language="en",
|
||||
language=customer.language,
|
||||
list_item="1",
|
||||
)],
|
||||
|
||||
|
||||
)
|
||||
|
||||
comment2 = CommentData(
|
||||
name= ab.CommentName2.ADDITIONAL_INFO,
|
||||
text="This is a special request comment.",
|
||||
|
||||
],
|
||||
)
|
||||
comments = [offer_comment, comment] if comment else [offer_comment]
|
||||
comments_data = CommentsData(comments=comments)
|
||||
comments_xml = alpine_bits_factory.create(comments_data, OtaMessageType.RETRIEVE)
|
||||
|
||||
comments_data = CommentsData(comments=[comment, comment2])
|
||||
|
||||
|
||||
comments = alpine_bits_factory.create(comments_data, OtaMessageType.RETRIEVE)
|
||||
|
||||
|
||||
|
||||
# ResGlobalInfo
|
||||
res_global_info = (
|
||||
ab.OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo(
|
||||
hotel_reservation_ids=hotel_res_ids, basic_property_info=basic_property_info, comments=comments
|
||||
hotel_reservation_ids=hotel_res_ids,
|
||||
basic_property_info=basic_property_info,
|
||||
comments=comments_xml,
|
||||
)
|
||||
)
|
||||
|
||||
# Hotel Reservation
|
||||
hotel_reservation = ab.OtaResRetrieveRs.ReservationsList.HotelReservation(
|
||||
create_date_time=datetime.now(timezone.utc).isoformat(),
|
||||
create_date_time=datetime.now(UTC).isoformat(),
|
||||
res_status=ab.HotelReservationResStatus.REQUESTED,
|
||||
room_stay_reservation="true",
|
||||
unique_id=unique_id,
|
||||
@@ -133,63 +322,45 @@ def main():
|
||||
res_guests=res_guests,
|
||||
res_global_info=res_global_info,
|
||||
)
|
||||
|
||||
reservations_list = ab.OtaResRetrieveRs.ReservationsList(
|
||||
hotel_reservation=[hotel_reservation]
|
||||
)
|
||||
|
||||
# Root element
|
||||
ota_res_retrieve_rs = ab.OtaResRetrieveRs(
|
||||
version="7.000", success=success, reservations_list=reservations_list
|
||||
version="7.000", success=None, reservations_list=reservations_list
|
||||
)
|
||||
|
||||
# Serialize using Pydantic's model_dump and convert to XML
|
||||
# Serialize to XML
|
||||
try:
|
||||
# First validate the model
|
||||
ota_res_retrieve_rs.model_validate(ota_res_retrieve_rs.model_dump())
|
||||
print("✅ Pydantic validation successful!")
|
||||
|
||||
# For XML serialization with Pydantic models, we need to use xsdata-pydantic serializer
|
||||
from xsdata.formats.dataclass.serializers.config import SerializerConfig
|
||||
from xsdata_pydantic.bindings import XmlSerializer
|
||||
|
||||
config = SerializerConfig(
|
||||
pretty_print=True, xml_declaration=True, encoding="UTF-8"
|
||||
)
|
||||
|
||||
serializer = XmlSerializer(config=config)
|
||||
|
||||
# Use ns_map to control namespace prefixes - set default namespace
|
||||
ns_map = {None: "http://www.opentravel.org/OTA/2003/05"}
|
||||
xml_string = serializer.render(ota_res_retrieve_rs, ns_map=ns_map)
|
||||
|
||||
with open("output.xml", "w", encoding="utf-8") as outfile:
|
||||
outfile.write(xml_string)
|
||||
|
||||
print("✅ XML serialization successful!")
|
||||
print(f"Generated XML written to output.xml")
|
||||
|
||||
# Also print the pretty formatted XML to console
|
||||
print("Generated XML written to output.xml")
|
||||
print("\n📄 Generated XML:")
|
||||
print(xml_string)
|
||||
|
||||
# Test parsing back
|
||||
from xsdata_pydantic.bindings import XmlParser
|
||||
|
||||
parser = XmlParser()
|
||||
|
||||
with open("output.xml", "r", encoding="utf-8") as infile:
|
||||
with open("output.xml", encoding="utf-8") as infile:
|
||||
xml_content = infile.read()
|
||||
|
||||
parsed_result = parser.from_string(xml_content, ab.OtaResRetrieveRs)
|
||||
|
||||
print("✅ Round-trip validation successful!")
|
||||
print(
|
||||
f"Parsed reservation status: {parsed_result.reservations_list.hotel_reservation[0].res_status}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Validation/Serialization failed: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
asyncio.run(main())
|
||||
|
||||
73
src/alpine_bits_python/models.py
Normal file
73
src/alpine_bits_python/models.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AlpineBitsHandshakeRequest(BaseModel):
|
||||
"""Model for AlpineBits handshake request data"""
|
||||
|
||||
action: str = Field(
|
||||
..., description="Action parameter, typically 'OTA_Ping:Handshaking'"
|
||||
)
|
||||
request_xml: str | None = Field(None, description="XML request document")
|
||||
|
||||
|
||||
class ContactName(BaseModel):
|
||||
"""Contact name structure"""
|
||||
|
||||
first: str | None = None
|
||||
last: str | None = None
|
||||
|
||||
|
||||
class ContactAddress(BaseModel):
|
||||
"""Contact address structure"""
|
||||
|
||||
street: str | None = None
|
||||
city: str | None = None
|
||||
state: str | None = None
|
||||
country: str | None = None
|
||||
postalCode: str | None = None
|
||||
|
||||
|
||||
class Contact(BaseModel):
|
||||
"""Contact information from Wix form"""
|
||||
|
||||
name: ContactName | None = None
|
||||
email: str | None = None
|
||||
locale: str | None = None
|
||||
company: str | None = None
|
||||
birthdate: str | None = None
|
||||
labelKeys: dict[str, Any] | None = None
|
||||
contactId: str | None = None
|
||||
address: ContactAddress | None = None
|
||||
jobTitle: str | None = None
|
||||
imageUrl: str | None = None
|
||||
updatedDate: str | None = None
|
||||
phone: str | None = None
|
||||
createdDate: str | None = None
|
||||
|
||||
|
||||
class SubmissionPdf(BaseModel):
|
||||
"""PDF submission structure"""
|
||||
|
||||
url: str | None = None
|
||||
filename: str | None = None
|
||||
|
||||
|
||||
class WixFormSubmission(BaseModel):
|
||||
"""Model for Wix form submission data"""
|
||||
|
||||
formName: str
|
||||
submissions: list[dict[str, Any]] = Field(default_factory=list)
|
||||
submissionTime: str
|
||||
formFieldMask: list[str] = Field(default_factory=list)
|
||||
submissionId: str
|
||||
contactId: str
|
||||
submissionsLink: str
|
||||
submissionPdf: SubmissionPdf | None = None
|
||||
formId: str
|
||||
contact: Contact | None = None
|
||||
|
||||
# Dynamic form fields - these will capture all field:* entries
|
||||
class Config:
|
||||
extra = "allow" # Allow additional fields not defined in the model
|
||||
100
src/alpine_bits_python/rate_limit.py
Normal file
100
src/alpine_bits_python/rate_limit.py
Normal file
@@ -0,0 +1,100 @@
|
||||
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__)
|
||||
|
||||
# Rate limiting configuration
|
||||
DEFAULT_RATE_LIMIT = "10/minute" # 10 requests per minute per IP
|
||||
WEBHOOK_RATE_LIMIT = "60/minute" # 60 webhook requests per minute per IP
|
||||
BURST_RATE_LIMIT = "3/second" # Max 3 requests per second per IP
|
||||
|
||||
# Redis configuration for distributed rate limiting (optional)
|
||||
REDIS_URL = os.getenv("REDIS_URL", None)
|
||||
|
||||
|
||||
def get_remote_address_with_forwarded(request: Request):
|
||||
"""Get client IP address, considering forwarded headers from proxies/load balancers
|
||||
"""
|
||||
# Check for forwarded headers (common in production behind proxies)
|
||||
forwarded_for = request.headers.get("X-Forwarded-For")
|
||||
if forwarded_for:
|
||||
# Take the first IP in the chain
|
||||
return forwarded_for.split(",")[0].strip()
|
||||
|
||||
real_ip = request.headers.get("X-Real-IP")
|
||||
if real_ip:
|
||||
return real_ip
|
||||
|
||||
# Fallback to direct connection IP
|
||||
return get_remote_address(request)
|
||||
|
||||
|
||||
# Initialize limiter
|
||||
if REDIS_URL:
|
||||
# Use Redis for distributed rate limiting (recommended for production)
|
||||
try:
|
||||
import redis
|
||||
|
||||
redis_client = redis.from_url(REDIS_URL)
|
||||
limiter = Limiter(
|
||||
key_func=get_remote_address_with_forwarded, storage_uri=REDIS_URL
|
||||
)
|
||||
logger.info("Rate limiting initialized with Redis backend")
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to connect to Redis: {e}. Using in-memory rate limiting."
|
||||
)
|
||||
limiter = Limiter(key_func=get_remote_address_with_forwarded)
|
||||
else:
|
||||
# Use in-memory rate limiting (fine for single instance)
|
||||
limiter = Limiter(key_func=get_remote_address_with_forwarded)
|
||||
logger.info("Rate limiting initialized with in-memory backend")
|
||||
|
||||
|
||||
def get_api_key_identifier(request: Request) -> str:
|
||||
"""Get identifier for rate limiting based on API key if available, otherwise IP
|
||||
This allows different rate limits per API key
|
||||
"""
|
||||
# Try to get API key from Authorization header
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if auth_header and auth_header.startswith("Bearer "):
|
||||
api_key = auth_header[7:] # Remove "Bearer " prefix
|
||||
# Use first 10 chars of API key as identifier (don't log full key)
|
||||
return f"api_key:{api_key[:10]}"
|
||||
|
||||
# Fallback to IP address
|
||||
return f"ip:{get_remote_address_with_forwarded(request)}"
|
||||
|
||||
|
||||
# Custom rate limit key function for API key based limiting
|
||||
def api_key_rate_limit_key(request: Request):
|
||||
return get_api_key_identifier(request)
|
||||
|
||||
|
||||
# Rate limiting decorators for different endpoint types
|
||||
webhook_limiter = Limiter(
|
||||
key_func=api_key_rate_limit_key, storage_uri=REDIS_URL if REDIS_URL else None
|
||||
)
|
||||
|
||||
|
||||
# Custom rate limit exceeded handler
|
||||
def custom_rate_limit_handler(request: Request, exc: RateLimitExceeded):
|
||||
"""Custom handler for rate limit exceeded"""
|
||||
logger.warning(
|
||||
f"Rate limit exceeded for {get_remote_address_with_forwarded(request)}: "
|
||||
f"{exc.detail}"
|
||||
)
|
||||
|
||||
response = _rate_limit_exceeded_handler(request, exc)
|
||||
|
||||
# Add custom headers
|
||||
response.headers["X-RateLimit-Limit"] = str(exc.retry_after)
|
||||
response.headers["X-RateLimit-Retry-After"] = str(exc.retry_after)
|
||||
|
||||
return response
|
||||
2
src/alpine_bits_python/reservations.py
Normal file
2
src/alpine_bits_python/reservations.py
Normal file
@@ -0,0 +1,2 @@
|
||||
def parse_form(form: dict):
|
||||
pass
|
||||
21
src/alpine_bits_python/run_api.py
Normal file
21
src/alpine_bits_python/run_api.py
Normal file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Startup script for the Wix Form Handler API
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import uvicorn
|
||||
|
||||
if __name__ == "__main__":
|
||||
db_path = "alpinebits.db" # Adjust path if needed
|
||||
if os.path.exists(db_path):
|
||||
os.remove(db_path)
|
||||
print(f"Deleted database file: {db_path}")
|
||||
|
||||
uvicorn.run(
|
||||
"alpine_bits_python.api:app",
|
||||
host="0.0.0.0",
|
||||
port=8080,
|
||||
reload=True, # Enable auto-reload during development
|
||||
log_level="info",
|
||||
)
|
||||
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
|
||||
108
src/alpine_bits_python/templates/index.html
Normal file
108
src/alpine_bits_python/templates/index.html
Normal file
@@ -0,0 +1,108 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>99 Tales - Under Construction</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'Arial', sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 600px;
|
||||
padding: 2rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 20px;
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 2rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.construction-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
animation: bounce 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 20%, 50%, 80%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
40% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
60% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
|
||||
.contact-info {
|
||||
margin-top: 2rem;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.api-link {
|
||||
display: inline-block;
|
||||
margin-top: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 10px;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.api-link:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="construction-icon">🏗️</div>
|
||||
<h1>99 Tales</h1>
|
||||
<div class="subtitle">Coming Soon</div>
|
||||
<div class="description">
|
||||
We're working hard to bring you something amazing. Our team is putting the finishing touches on an exciting new experience.
|
||||
</div>
|
||||
<div class="description">
|
||||
Thank you for your patience while we build something special for you.
|
||||
</div>
|
||||
<a href="/api" class="api-link">API Documentation</a>
|
||||
<div class="contact-info">
|
||||
Check back soon for updates!
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Entry point for util package."""
|
||||
|
||||
from .handshake_util import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,27 +1,24 @@
|
||||
from ..generated.alpinebits import OtaPingRq, OtaPingRs
|
||||
from xsdata_pydantic.bindings import XmlParser
|
||||
|
||||
|
||||
from ..generated.alpinebits import OtaPingRs
|
||||
|
||||
|
||||
def main():
|
||||
# test parsing a ping request sample
|
||||
|
||||
path = "AlpineBits-HotelData-2024-10/files/samples/Handshake/Handshake-OTA_PingRS.xml"
|
||||
path = (
|
||||
"AlpineBits-HotelData-2024-10/files/samples/Handshake/Handshake-OTA_PingRS.xml"
|
||||
)
|
||||
|
||||
with open(
|
||||
path, "r", encoding="utf-8") as f:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
xml = f.read()
|
||||
|
||||
# Parse the XML into the request object
|
||||
|
||||
# Test parsing back
|
||||
|
||||
|
||||
parser = XmlParser()
|
||||
|
||||
|
||||
|
||||
parsed_result = parser.from_string(xml, OtaPingRs)
|
||||
|
||||
print(parsed_result.echo_data)
|
||||
@@ -34,19 +31,14 @@ def main():
|
||||
|
||||
print(warning.content[0])
|
||||
|
||||
|
||||
|
||||
|
||||
# save json in echo_data to file with indents
|
||||
output_path = "echo_data_response.json"
|
||||
with open(output_path, "w", encoding="utf-8") as out_f:
|
||||
import json
|
||||
|
||||
json.dump(json.loads(parsed_result.echo_data), out_f, indent=4)
|
||||
print(f"Saved echo_data json to {output_path}")
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
|
||||
main()
|
||||
13
start_api.py
Normal file
13
start_api.py
Normal file
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Convenience launcher for the Wix Form Handler API
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
# Change to src directory
|
||||
src_dir = os.path.join(os.path.dirname(__file__), "src/alpine_bits_python")
|
||||
|
||||
# Run the API using uv
|
||||
if __name__ == "__main__":
|
||||
subprocess.run(["uv", "run", "python", os.path.join(src_dir, "run_api.py")], check=False)
|
||||
@@ -1,61 +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,11 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test the handshake functionality with the real AlpineBits sample file.
|
||||
"""Test the handshake functionality with the real AlpineBits sample file.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from alpine_bits_python.alpinebits_server import AlpineBitsServer
|
||||
|
||||
|
||||
async def main():
|
||||
print("🔄 Testing AlpineBits Handshake with Sample File")
|
||||
print("=" * 60)
|
||||
@@ -14,16 +15,21 @@ async def main():
|
||||
server = AlpineBitsServer()
|
||||
|
||||
# Read the sample handshake request
|
||||
with open("AlpineBits-HotelData-2024-10/files/samples/Handshake/Handshake-OTA_PingRQ.xml", "r") as f:
|
||||
with open(
|
||||
"AlpineBits-HotelData-2024-10/files/samples/Handshake/Handshake-OTA_PingRQ.xml",
|
||||
) as f:
|
||||
ping_request_xml = f.read()
|
||||
|
||||
print("📤 Sending handshake request...")
|
||||
|
||||
# Handle the ping request
|
||||
response = await server.handle_request("OTA_Ping:Handshaking", ping_request_xml, "2024-10")
|
||||
response = await server.handle_request(
|
||||
"OTA_Ping:Handshaking", ping_request_xml, "2024-10"
|
||||
)
|
||||
|
||||
print(f"\n📥 Response Status: {response.status_code}")
|
||||
print(f"📄 Response XML:\n{response.xml_content}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1,26 +1,21 @@
|
||||
|
||||
import pytest
|
||||
from typing import Union
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add the src directory to the path so we can import our modules
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
|
||||
|
||||
from simplified_access import (
|
||||
from alpine_bits_python.alpine_bits_helpers import (
|
||||
AlpineBitsFactory,
|
||||
CustomerData,
|
||||
CustomerFactory,
|
||||
ResGuestFactory,
|
||||
HotelReservationIdData,
|
||||
HotelReservationIdFactory,
|
||||
AlpineBitsFactory,
|
||||
PhoneTechType,
|
||||
OtaMessageType,
|
||||
NotifCustomer,
|
||||
RetrieveCustomer,
|
||||
NotifResGuests,
|
||||
RetrieveResGuests,
|
||||
NotifHotelReservationId,
|
||||
RetrieveHotelReservationId
|
||||
NotifResGuests,
|
||||
OtaMessageType,
|
||||
PhoneTechType,
|
||||
ResGuestFactory,
|
||||
RetrieveCustomer,
|
||||
RetrieveHotelReservationId,
|
||||
RetrieveResGuests,
|
||||
)
|
||||
|
||||
|
||||
@@ -35,7 +30,7 @@ def sample_customer_data():
|
||||
phone_numbers=[
|
||||
("+1234567890", PhoneTechType.MOBILE),
|
||||
("+0987654321", PhoneTechType.VOICE),
|
||||
("+1111111111", None)
|
||||
("+1111111111", None),
|
||||
],
|
||||
email_address="john.doe@example.com",
|
||||
email_newsletter=True,
|
||||
@@ -46,17 +41,14 @@ def sample_customer_data():
|
||||
address_catalog=False,
|
||||
gender="Male",
|
||||
birth_date="1980-01-01",
|
||||
language="en"
|
||||
language="en",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def minimal_customer_data():
|
||||
"""Fixture providing minimal customer data (only required fields)."""
|
||||
return CustomerData(
|
||||
given_name="Jane",
|
||||
surname="Smith"
|
||||
)
|
||||
return CustomerData(given_name="Jane", surname="Smith")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -66,16 +58,14 @@ def sample_hotel_reservation_id_data():
|
||||
res_id_type="123",
|
||||
res_id_value="RESERVATION-456",
|
||||
res_id_source="HOTEL_SYSTEM",
|
||||
res_id_source_context="BOOKING_ENGINE"
|
||||
res_id_source_context="BOOKING_ENGINE",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def minimal_hotel_reservation_id_data():
|
||||
"""Fixture providing minimal hotel reservation ID data (only required fields)."""
|
||||
return HotelReservationIdData(
|
||||
res_id_type="999"
|
||||
)
|
||||
return HotelReservationIdData(res_id_type="999")
|
||||
|
||||
|
||||
class TestCustomerData:
|
||||
@@ -152,7 +142,9 @@ class TestCustomerFactory:
|
||||
def test_create_customer_minimal(self, minimal_customer_data):
|
||||
"""Test creating customers with minimal data."""
|
||||
notif_customer = CustomerFactory.create_notif_customer(minimal_customer_data)
|
||||
retrieve_customer = CustomerFactory.create_retrieve_customer(minimal_customer_data)
|
||||
retrieve_customer = CustomerFactory.create_retrieve_customer(
|
||||
minimal_customer_data
|
||||
)
|
||||
|
||||
for customer in [notif_customer, retrieve_customer]:
|
||||
assert customer.person_name.given_name == "Jane"
|
||||
@@ -169,40 +161,64 @@ class TestCustomerFactory:
|
||||
def test_email_newsletter_options(self):
|
||||
"""Test different email newsletter options."""
|
||||
# Newsletter yes
|
||||
data_yes = CustomerData(given_name="Test", surname="User",
|
||||
email_address="test@example.com", email_newsletter=True)
|
||||
data_yes = CustomerData(
|
||||
given_name="Test",
|
||||
surname="User",
|
||||
email_address="test@example.com",
|
||||
email_newsletter=True,
|
||||
)
|
||||
customer = CustomerFactory.create_notif_customer(data_yes)
|
||||
assert customer.email.remark == "newsletter:yes"
|
||||
|
||||
# Newsletter no
|
||||
data_no = CustomerData(given_name="Test", surname="User",
|
||||
email_address="test@example.com", email_newsletter=False)
|
||||
data_no = CustomerData(
|
||||
given_name="Test",
|
||||
surname="User",
|
||||
email_address="test@example.com",
|
||||
email_newsletter=False,
|
||||
)
|
||||
customer = CustomerFactory.create_notif_customer(data_no)
|
||||
assert customer.email.remark == "newsletter:no"
|
||||
|
||||
# Newsletter not specified
|
||||
data_none = CustomerData(given_name="Test", surname="User",
|
||||
email_address="test@example.com", email_newsletter=None)
|
||||
data_none = CustomerData(
|
||||
given_name="Test",
|
||||
surname="User",
|
||||
email_address="test@example.com",
|
||||
email_newsletter=None,
|
||||
)
|
||||
customer = CustomerFactory.create_notif_customer(data_none)
|
||||
assert customer.email.remark is None
|
||||
|
||||
def test_address_catalog_options(self):
|
||||
"""Test different address catalog options."""
|
||||
# Catalog no
|
||||
data_no = CustomerData(given_name="Test", surname="User",
|
||||
address_line="123 Street", address_catalog=False)
|
||||
data_no = CustomerData(
|
||||
given_name="Test",
|
||||
surname="User",
|
||||
address_line="123 Street",
|
||||
address_catalog=False,
|
||||
)
|
||||
customer = CustomerFactory.create_notif_customer(data_no)
|
||||
assert customer.address.remark == "catalog:no"
|
||||
|
||||
# Catalog yes
|
||||
data_yes = CustomerData(given_name="Test", surname="User",
|
||||
address_line="123 Street", address_catalog=True)
|
||||
data_yes = CustomerData(
|
||||
given_name="Test",
|
||||
surname="User",
|
||||
address_line="123 Street",
|
||||
address_catalog=True,
|
||||
)
|
||||
customer = CustomerFactory.create_notif_customer(data_yes)
|
||||
assert customer.address.remark == "catalog:yes"
|
||||
|
||||
# Catalog not specified
|
||||
data_none = CustomerData(given_name="Test", surname="User",
|
||||
address_line="123 Street", address_catalog=None)
|
||||
data_none = CustomerData(
|
||||
given_name="Test",
|
||||
surname="User",
|
||||
address_line="123 Street",
|
||||
address_catalog=None,
|
||||
)
|
||||
customer = CustomerFactory.create_notif_customer(data_none)
|
||||
assert customer.address.remark is None
|
||||
|
||||
@@ -228,8 +244,8 @@ class TestCustomerFactory:
|
||||
phone_numbers=[
|
||||
("+1111111111", PhoneTechType.VOICE),
|
||||
("+2222222222", PhoneTechType.FAX),
|
||||
("+3333333333", PhoneTechType.MOBILE)
|
||||
]
|
||||
("+3333333333", PhoneTechType.MOBILE),
|
||||
],
|
||||
)
|
||||
|
||||
customer = CustomerFactory.create_notif_customer(data)
|
||||
@@ -241,14 +257,20 @@ class TestCustomerFactory:
|
||||
class TestHotelReservationIdData:
|
||||
"""Test the HotelReservationIdData dataclass."""
|
||||
|
||||
def test_hotel_reservation_id_data_creation_full(self, sample_hotel_reservation_id_data):
|
||||
def test_hotel_reservation_id_data_creation_full(
|
||||
self, sample_hotel_reservation_id_data
|
||||
):
|
||||
"""Test creating HotelReservationIdData with all fields."""
|
||||
assert sample_hotel_reservation_id_data.res_id_type == "123"
|
||||
assert sample_hotel_reservation_id_data.res_id_value == "RESERVATION-456"
|
||||
assert sample_hotel_reservation_id_data.res_id_source == "HOTEL_SYSTEM"
|
||||
assert sample_hotel_reservation_id_data.res_id_source_context == "BOOKING_ENGINE"
|
||||
assert (
|
||||
sample_hotel_reservation_id_data.res_id_source_context == "BOOKING_ENGINE"
|
||||
)
|
||||
|
||||
def test_hotel_reservation_id_data_creation_minimal(self, minimal_hotel_reservation_id_data):
|
||||
def test_hotel_reservation_id_data_creation_minimal(
|
||||
self, minimal_hotel_reservation_id_data
|
||||
):
|
||||
"""Test creating HotelReservationIdData with only required fields."""
|
||||
assert minimal_hotel_reservation_id_data.res_id_type == "999"
|
||||
assert minimal_hotel_reservation_id_data.res_id_value is None
|
||||
@@ -259,9 +281,13 @@ class TestHotelReservationIdData:
|
||||
class TestHotelReservationIdFactory:
|
||||
"""Test the HotelReservationIdFactory class."""
|
||||
|
||||
def test_create_notif_hotel_reservation_id_full(self, sample_hotel_reservation_id_data):
|
||||
def test_create_notif_hotel_reservation_id_full(
|
||||
self, sample_hotel_reservation_id_data
|
||||
):
|
||||
"""Test creating a NotifHotelReservationId with full data."""
|
||||
reservation_id = HotelReservationIdFactory.create_notif_hotel_reservation_id(sample_hotel_reservation_id_data)
|
||||
reservation_id = HotelReservationIdFactory.create_notif_hotel_reservation_id(
|
||||
sample_hotel_reservation_id_data
|
||||
)
|
||||
|
||||
assert isinstance(reservation_id, NotifHotelReservationId)
|
||||
assert reservation_id.res_id_type == "123"
|
||||
@@ -269,9 +295,13 @@ class TestHotelReservationIdFactory:
|
||||
assert reservation_id.res_id_source == "HOTEL_SYSTEM"
|
||||
assert reservation_id.res_id_source_context == "BOOKING_ENGINE"
|
||||
|
||||
def test_create_retrieve_hotel_reservation_id_full(self, sample_hotel_reservation_id_data):
|
||||
def test_create_retrieve_hotel_reservation_id_full(
|
||||
self, sample_hotel_reservation_id_data
|
||||
):
|
||||
"""Test creating a RetrieveHotelReservationId with full data."""
|
||||
reservation_id = HotelReservationIdFactory.create_retrieve_hotel_reservation_id(sample_hotel_reservation_id_data)
|
||||
reservation_id = HotelReservationIdFactory.create_retrieve_hotel_reservation_id(
|
||||
sample_hotel_reservation_id_data
|
||||
)
|
||||
|
||||
assert isinstance(reservation_id, RetrieveHotelReservationId)
|
||||
assert reservation_id.res_id_type == "123"
|
||||
@@ -279,10 +309,20 @@ class TestHotelReservationIdFactory:
|
||||
assert reservation_id.res_id_source == "HOTEL_SYSTEM"
|
||||
assert reservation_id.res_id_source_context == "BOOKING_ENGINE"
|
||||
|
||||
def test_create_hotel_reservation_id_minimal(self, minimal_hotel_reservation_id_data):
|
||||
def test_create_hotel_reservation_id_minimal(
|
||||
self, minimal_hotel_reservation_id_data
|
||||
):
|
||||
"""Test creating hotel reservation IDs with minimal data."""
|
||||
notif_reservation_id = HotelReservationIdFactory.create_notif_hotel_reservation_id(minimal_hotel_reservation_id_data)
|
||||
retrieve_reservation_id = HotelReservationIdFactory.create_retrieve_hotel_reservation_id(minimal_hotel_reservation_id_data)
|
||||
notif_reservation_id = (
|
||||
HotelReservationIdFactory.create_notif_hotel_reservation_id(
|
||||
minimal_hotel_reservation_id_data
|
||||
)
|
||||
)
|
||||
retrieve_reservation_id = (
|
||||
HotelReservationIdFactory.create_retrieve_hotel_reservation_id(
|
||||
minimal_hotel_reservation_id_data
|
||||
)
|
||||
)
|
||||
|
||||
for reservation_id in [notif_reservation_id, retrieve_reservation_id]:
|
||||
assert reservation_id.res_id_type == "999"
|
||||
@@ -290,17 +330,29 @@ class TestHotelReservationIdFactory:
|
||||
assert reservation_id.res_id_source is None
|
||||
assert reservation_id.res_id_source_context is None
|
||||
|
||||
def test_from_notif_hotel_reservation_id_roundtrip(self, sample_hotel_reservation_id_data):
|
||||
def test_from_notif_hotel_reservation_id_roundtrip(
|
||||
self, sample_hotel_reservation_id_data
|
||||
):
|
||||
"""Test converting NotifHotelReservationId back to HotelReservationIdData."""
|
||||
reservation_id = HotelReservationIdFactory.create_notif_hotel_reservation_id(sample_hotel_reservation_id_data)
|
||||
converted_data = HotelReservationIdFactory.from_notif_hotel_reservation_id(reservation_id)
|
||||
reservation_id = HotelReservationIdFactory.create_notif_hotel_reservation_id(
|
||||
sample_hotel_reservation_id_data
|
||||
)
|
||||
converted_data = HotelReservationIdFactory.from_notif_hotel_reservation_id(
|
||||
reservation_id
|
||||
)
|
||||
|
||||
assert converted_data == sample_hotel_reservation_id_data
|
||||
|
||||
def test_from_retrieve_hotel_reservation_id_roundtrip(self, sample_hotel_reservation_id_data):
|
||||
def test_from_retrieve_hotel_reservation_id_roundtrip(
|
||||
self, sample_hotel_reservation_id_data
|
||||
):
|
||||
"""Test converting RetrieveHotelReservationId back to HotelReservationIdData."""
|
||||
reservation_id = HotelReservationIdFactory.create_retrieve_hotel_reservation_id(sample_hotel_reservation_id_data)
|
||||
converted_data = HotelReservationIdFactory.from_retrieve_hotel_reservation_id(reservation_id)
|
||||
reservation_id = HotelReservationIdFactory.create_retrieve_hotel_reservation_id(
|
||||
sample_hotel_reservation_id_data
|
||||
)
|
||||
converted_data = HotelReservationIdFactory.from_retrieve_hotel_reservation_id(
|
||||
reservation_id
|
||||
)
|
||||
|
||||
assert converted_data == sample_hotel_reservation_id_data
|
||||
|
||||
@@ -334,8 +386,12 @@ class TestResGuestFactory:
|
||||
|
||||
def test_create_res_guests_minimal(self, minimal_customer_data):
|
||||
"""Test creating ResGuests with minimal customer data."""
|
||||
notif_res_guests = ResGuestFactory.create_notif_res_guests(minimal_customer_data)
|
||||
retrieve_res_guests = ResGuestFactory.create_retrieve_res_guests(minimal_customer_data)
|
||||
notif_res_guests = ResGuestFactory.create_notif_res_guests(
|
||||
minimal_customer_data
|
||||
)
|
||||
retrieve_res_guests = ResGuestFactory.create_retrieve_res_guests(
|
||||
minimal_customer_data
|
||||
)
|
||||
|
||||
for res_guests in [notif_res_guests, retrieve_res_guests]:
|
||||
customer = res_guests.res_guest.profiles.profile_info.profile.customer
|
||||
@@ -395,35 +451,47 @@ class TestAlpineBitsFactory:
|
||||
|
||||
def test_create_customer_retrieve(self, sample_customer_data):
|
||||
"""Test creating customer using unified factory for RETRIEVE."""
|
||||
customer = AlpineBitsFactory.create(sample_customer_data, OtaMessageType.RETRIEVE)
|
||||
customer = AlpineBitsFactory.create(
|
||||
sample_customer_data, OtaMessageType.RETRIEVE
|
||||
)
|
||||
assert isinstance(customer, RetrieveCustomer)
|
||||
assert customer.person_name.given_name == "John"
|
||||
assert customer.person_name.surname == "Doe"
|
||||
|
||||
def test_create_hotel_reservation_id_notif(self, sample_hotel_reservation_id_data):
|
||||
"""Test creating hotel reservation ID using unified factory for NOTIF."""
|
||||
reservation_id = AlpineBitsFactory.create(sample_hotel_reservation_id_data, OtaMessageType.NOTIF)
|
||||
reservation_id = AlpineBitsFactory.create(
|
||||
sample_hotel_reservation_id_data, OtaMessageType.NOTIF
|
||||
)
|
||||
assert isinstance(reservation_id, NotifHotelReservationId)
|
||||
assert reservation_id.res_id_type == "123"
|
||||
assert reservation_id.res_id_value == "RESERVATION-456"
|
||||
|
||||
def test_create_hotel_reservation_id_retrieve(self, sample_hotel_reservation_id_data):
|
||||
def test_create_hotel_reservation_id_retrieve(
|
||||
self, sample_hotel_reservation_id_data
|
||||
):
|
||||
"""Test creating hotel reservation ID using unified factory for RETRIEVE."""
|
||||
reservation_id = AlpineBitsFactory.create(sample_hotel_reservation_id_data, OtaMessageType.RETRIEVE)
|
||||
reservation_id = AlpineBitsFactory.create(
|
||||
sample_hotel_reservation_id_data, OtaMessageType.RETRIEVE
|
||||
)
|
||||
assert isinstance(reservation_id, RetrieveHotelReservationId)
|
||||
assert reservation_id.res_id_type == "123"
|
||||
assert reservation_id.res_id_value == "RESERVATION-456"
|
||||
|
||||
def test_create_res_guests_notif(self, sample_customer_data):
|
||||
"""Test creating ResGuests using unified factory for NOTIF."""
|
||||
res_guests = AlpineBitsFactory.create_res_guests(sample_customer_data, OtaMessageType.NOTIF)
|
||||
res_guests = AlpineBitsFactory.create_res_guests(
|
||||
sample_customer_data, OtaMessageType.NOTIF
|
||||
)
|
||||
assert isinstance(res_guests, NotifResGuests)
|
||||
customer = res_guests.res_guest.profiles.profile_info.profile.customer
|
||||
assert customer.person_name.given_name == "John"
|
||||
|
||||
def test_create_res_guests_retrieve(self, sample_customer_data):
|
||||
"""Test creating ResGuests using unified factory for RETRIEVE."""
|
||||
res_guests = AlpineBitsFactory.create_res_guests(sample_customer_data, OtaMessageType.RETRIEVE)
|
||||
res_guests = AlpineBitsFactory.create_res_guests(
|
||||
sample_customer_data, OtaMessageType.RETRIEVE
|
||||
)
|
||||
assert isinstance(res_guests, RetrieveResGuests)
|
||||
customer = res_guests.res_guest.profiles.profile_info.profile.customer
|
||||
assert customer.person_name.given_name == "John"
|
||||
@@ -431,8 +499,12 @@ class TestAlpineBitsFactory:
|
||||
def test_extract_data_from_customer(self, sample_customer_data):
|
||||
"""Test extracting data from customer objects."""
|
||||
# Create both types and extract data back
|
||||
notif_customer = AlpineBitsFactory.create(sample_customer_data, OtaMessageType.NOTIF)
|
||||
retrieve_customer = AlpineBitsFactory.create(sample_customer_data, OtaMessageType.RETRIEVE)
|
||||
notif_customer = AlpineBitsFactory.create(
|
||||
sample_customer_data, OtaMessageType.NOTIF
|
||||
)
|
||||
retrieve_customer = AlpineBitsFactory.create(
|
||||
sample_customer_data, OtaMessageType.RETRIEVE
|
||||
)
|
||||
|
||||
notif_extracted = AlpineBitsFactory.extract_data(notif_customer)
|
||||
retrieve_extracted = AlpineBitsFactory.extract_data(retrieve_customer)
|
||||
@@ -440,11 +512,17 @@ class TestAlpineBitsFactory:
|
||||
assert notif_extracted == sample_customer_data
|
||||
assert retrieve_extracted == sample_customer_data
|
||||
|
||||
def test_extract_data_from_hotel_reservation_id(self, sample_hotel_reservation_id_data):
|
||||
def test_extract_data_from_hotel_reservation_id(
|
||||
self, sample_hotel_reservation_id_data
|
||||
):
|
||||
"""Test extracting data from hotel reservation ID objects."""
|
||||
# Create both types and extract data back
|
||||
notif_res_id = AlpineBitsFactory.create(sample_hotel_reservation_id_data, OtaMessageType.NOTIF)
|
||||
retrieve_res_id = AlpineBitsFactory.create(sample_hotel_reservation_id_data, OtaMessageType.RETRIEVE)
|
||||
notif_res_id = AlpineBitsFactory.create(
|
||||
sample_hotel_reservation_id_data, OtaMessageType.NOTIF
|
||||
)
|
||||
retrieve_res_id = AlpineBitsFactory.create(
|
||||
sample_hotel_reservation_id_data, OtaMessageType.RETRIEVE
|
||||
)
|
||||
|
||||
notif_extracted = AlpineBitsFactory.extract_data(notif_res_id)
|
||||
retrieve_extracted = AlpineBitsFactory.extract_data(retrieve_res_id)
|
||||
@@ -455,8 +533,12 @@ class TestAlpineBitsFactory:
|
||||
def test_extract_data_from_res_guests(self, sample_customer_data):
|
||||
"""Test extracting data from ResGuests objects."""
|
||||
# Create both types and extract data back
|
||||
notif_res_guests = AlpineBitsFactory.create_res_guests(sample_customer_data, OtaMessageType.NOTIF)
|
||||
retrieve_res_guests = AlpineBitsFactory.create_res_guests(sample_customer_data, OtaMessageType.RETRIEVE)
|
||||
notif_res_guests = AlpineBitsFactory.create_res_guests(
|
||||
sample_customer_data, OtaMessageType.NOTIF
|
||||
)
|
||||
retrieve_res_guests = AlpineBitsFactory.create_res_guests(
|
||||
sample_customer_data, OtaMessageType.RETRIEVE
|
||||
)
|
||||
|
||||
notif_extracted = AlpineBitsFactory.extract_data(notif_res_guests)
|
||||
retrieve_extracted = AlpineBitsFactory.extract_data(retrieve_res_guests)
|
||||
@@ -481,33 +563,46 @@ class TestAlpineBitsFactory:
|
||||
given_name="Unified",
|
||||
surname="Factory",
|
||||
email_address="unified@factory.com",
|
||||
phone_numbers=[("+1234567890", PhoneTechType.MOBILE)]
|
||||
phone_numbers=[("+1234567890", PhoneTechType.MOBILE)],
|
||||
)
|
||||
|
||||
reservation_data = HotelReservationIdData(
|
||||
res_id_type="999",
|
||||
res_id_value="UNIFIED-TEST"
|
||||
res_id_type="999", res_id_value="UNIFIED-TEST"
|
||||
)
|
||||
|
||||
# Create using unified factory
|
||||
customer_notif = AlpineBitsFactory.create(customer_data, OtaMessageType.NOTIF)
|
||||
customer_retrieve = AlpineBitsFactory.create(customer_data, OtaMessageType.RETRIEVE)
|
||||
customer_retrieve = AlpineBitsFactory.create(
|
||||
customer_data, OtaMessageType.RETRIEVE
|
||||
)
|
||||
|
||||
res_id_notif = AlpineBitsFactory.create(reservation_data, OtaMessageType.NOTIF)
|
||||
res_id_retrieve = AlpineBitsFactory.create(reservation_data, OtaMessageType.RETRIEVE)
|
||||
res_id_retrieve = AlpineBitsFactory.create(
|
||||
reservation_data, OtaMessageType.RETRIEVE
|
||||
)
|
||||
|
||||
res_guests_notif = AlpineBitsFactory.create_res_guests(customer_data, OtaMessageType.NOTIF)
|
||||
res_guests_retrieve = AlpineBitsFactory.create_res_guests(customer_data, OtaMessageType.RETRIEVE)
|
||||
res_guests_notif = AlpineBitsFactory.create_res_guests(
|
||||
customer_data, OtaMessageType.NOTIF
|
||||
)
|
||||
res_guests_retrieve = AlpineBitsFactory.create_res_guests(
|
||||
customer_data, OtaMessageType.RETRIEVE
|
||||
)
|
||||
|
||||
# Extract everything back
|
||||
extracted_customer_from_notif = AlpineBitsFactory.extract_data(customer_notif)
|
||||
extracted_customer_from_retrieve = AlpineBitsFactory.extract_data(customer_retrieve)
|
||||
extracted_customer_from_retrieve = AlpineBitsFactory.extract_data(
|
||||
customer_retrieve
|
||||
)
|
||||
|
||||
extracted_res_id_from_notif = AlpineBitsFactory.extract_data(res_id_notif)
|
||||
extracted_res_id_from_retrieve = AlpineBitsFactory.extract_data(res_id_retrieve)
|
||||
|
||||
extracted_from_res_guests_notif = AlpineBitsFactory.extract_data(res_guests_notif)
|
||||
extracted_from_res_guests_retrieve = AlpineBitsFactory.extract_data(res_guests_retrieve)
|
||||
extracted_from_res_guests_notif = AlpineBitsFactory.extract_data(
|
||||
res_guests_notif
|
||||
)
|
||||
extracted_from_res_guests_retrieve = AlpineBitsFactory.extract_data(
|
||||
res_guests_retrieve
|
||||
)
|
||||
|
||||
# Verify everything matches
|
||||
assert extracted_customer_from_notif == customer_data
|
||||
@@ -525,31 +620,66 @@ class TestIntegration:
|
||||
"""Test that both factories can work with the same customer data."""
|
||||
# Create using CustomerFactory
|
||||
notif_customer = CustomerFactory.create_notif_customer(sample_customer_data)
|
||||
retrieve_customer = CustomerFactory.create_retrieve_customer(sample_customer_data)
|
||||
retrieve_customer = CustomerFactory.create_retrieve_customer(
|
||||
sample_customer_data
|
||||
)
|
||||
|
||||
# Create using ResGuestFactory and extract customers
|
||||
notif_res_guests = ResGuestFactory.create_notif_res_guests(sample_customer_data)
|
||||
retrieve_res_guests = ResGuestFactory.create_retrieve_res_guests(sample_customer_data)
|
||||
retrieve_res_guests = ResGuestFactory.create_retrieve_res_guests(
|
||||
sample_customer_data
|
||||
)
|
||||
|
||||
notif_from_res_guests = notif_res_guests.res_guest.profiles.profile_info.profile.customer
|
||||
retrieve_from_res_guests = retrieve_res_guests.res_guest.profiles.profile_info.profile.customer
|
||||
notif_from_res_guests = (
|
||||
notif_res_guests.res_guest.profiles.profile_info.profile.customer
|
||||
)
|
||||
retrieve_from_res_guests = (
|
||||
retrieve_res_guests.res_guest.profiles.profile_info.profile.customer
|
||||
)
|
||||
|
||||
# Compare customer names (structure should be identical)
|
||||
assert notif_customer.person_name.given_name == notif_from_res_guests.person_name.given_name
|
||||
assert notif_customer.person_name.surname == notif_from_res_guests.person_name.surname
|
||||
assert retrieve_customer.person_name.given_name == retrieve_from_res_guests.person_name.given_name
|
||||
assert retrieve_customer.person_name.surname == retrieve_from_res_guests.person_name.surname
|
||||
assert (
|
||||
notif_customer.person_name.given_name
|
||||
== notif_from_res_guests.person_name.given_name
|
||||
)
|
||||
assert (
|
||||
notif_customer.person_name.surname
|
||||
== notif_from_res_guests.person_name.surname
|
||||
)
|
||||
assert (
|
||||
retrieve_customer.person_name.given_name
|
||||
== retrieve_from_res_guests.person_name.given_name
|
||||
)
|
||||
assert (
|
||||
retrieve_customer.person_name.surname
|
||||
== retrieve_from_res_guests.person_name.surname
|
||||
)
|
||||
|
||||
def test_hotel_reservation_id_factories_produce_same_data(self, sample_hotel_reservation_id_data):
|
||||
def test_hotel_reservation_id_factories_produce_same_data(
|
||||
self, sample_hotel_reservation_id_data
|
||||
):
|
||||
"""Test that both HotelReservationId factories produce equivalent results."""
|
||||
notif_reservation_id = HotelReservationIdFactory.create_notif_hotel_reservation_id(sample_hotel_reservation_id_data)
|
||||
retrieve_reservation_id = HotelReservationIdFactory.create_retrieve_hotel_reservation_id(sample_hotel_reservation_id_data)
|
||||
notif_reservation_id = (
|
||||
HotelReservationIdFactory.create_notif_hotel_reservation_id(
|
||||
sample_hotel_reservation_id_data
|
||||
)
|
||||
)
|
||||
retrieve_reservation_id = (
|
||||
HotelReservationIdFactory.create_retrieve_hotel_reservation_id(
|
||||
sample_hotel_reservation_id_data
|
||||
)
|
||||
)
|
||||
|
||||
# Both should have the same field values
|
||||
assert notif_reservation_id.res_id_type == retrieve_reservation_id.res_id_type
|
||||
assert notif_reservation_id.res_id_value == retrieve_reservation_id.res_id_value
|
||||
assert notif_reservation_id.res_id_source == retrieve_reservation_id.res_id_source
|
||||
assert notif_reservation_id.res_id_source_context == retrieve_reservation_id.res_id_source_context
|
||||
assert (
|
||||
notif_reservation_id.res_id_source == retrieve_reservation_id.res_id_source
|
||||
)
|
||||
assert (
|
||||
notif_reservation_id.res_id_source_context
|
||||
== retrieve_reservation_id.res_id_source_context
|
||||
)
|
||||
|
||||
def test_complex_customer_workflow(self):
|
||||
"""Test a complex workflow with multiple operations."""
|
||||
@@ -559,7 +689,7 @@ class TestIntegration:
|
||||
surname="Johnson",
|
||||
phone_numbers=[
|
||||
("+1555123456", PhoneTechType.MOBILE),
|
||||
("+1555654321", PhoneTechType.VOICE)
|
||||
("+1555654321", PhoneTechType.VOICE),
|
||||
],
|
||||
email_address="alice.johnson@company.com",
|
||||
email_newsletter=False,
|
||||
@@ -569,7 +699,7 @@ class TestIntegration:
|
||||
country_code="CA",
|
||||
address_catalog=True,
|
||||
gender="Female",
|
||||
language="fr"
|
||||
language="fr",
|
||||
)
|
||||
|
||||
# Create ResGuests for both types
|
||||
@@ -578,7 +708,9 @@ class TestIntegration:
|
||||
|
||||
# Extract data back from both
|
||||
notif_extracted = ResGuestFactory.extract_primary_customer(notif_res_guests)
|
||||
retrieve_extracted = ResGuestFactory.extract_primary_customer(retrieve_res_guests)
|
||||
retrieve_extracted = ResGuestFactory.extract_primary_customer(
|
||||
retrieve_res_guests
|
||||
)
|
||||
|
||||
# All should be equal
|
||||
assert original_data == notif_extracted
|
||||
@@ -592,16 +724,28 @@ class TestIntegration:
|
||||
res_id_type="456",
|
||||
res_id_value="COMPLEX-RESERVATION-789",
|
||||
res_id_source="INTEGRATION_SYSTEM",
|
||||
res_id_source_context="API_CALL"
|
||||
res_id_source_context="API_CALL",
|
||||
)
|
||||
|
||||
# Create HotelReservationId for both types
|
||||
notif_reservation_id = HotelReservationIdFactory.create_notif_hotel_reservation_id(original_data)
|
||||
retrieve_reservation_id = HotelReservationIdFactory.create_retrieve_hotel_reservation_id(original_data)
|
||||
notif_reservation_id = (
|
||||
HotelReservationIdFactory.create_notif_hotel_reservation_id(original_data)
|
||||
)
|
||||
retrieve_reservation_id = (
|
||||
HotelReservationIdFactory.create_retrieve_hotel_reservation_id(
|
||||
original_data
|
||||
)
|
||||
)
|
||||
|
||||
# Extract data back from both
|
||||
notif_extracted = HotelReservationIdFactory.from_notif_hotel_reservation_id(notif_reservation_id)
|
||||
retrieve_extracted = HotelReservationIdFactory.from_retrieve_hotel_reservation_id(retrieve_reservation_id)
|
||||
notif_extracted = HotelReservationIdFactory.from_notif_hotel_reservation_id(
|
||||
notif_reservation_id
|
||||
)
|
||||
retrieve_extracted = (
|
||||
HotelReservationIdFactory.from_retrieve_hotel_reservation_id(
|
||||
retrieve_reservation_id
|
||||
)
|
||||
)
|
||||
|
||||
# All should be equal
|
||||
assert original_data == notif_extracted
|
||||
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>
|
||||
Reference in New Issue
Block a user