Compare commits
81 Commits
a343013eed
...
1.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cec464ac2 | ||
|
|
1248772f60 | ||
|
|
165914d686 | ||
|
|
dbbdb3694b | ||
|
|
6ab5212a0f | ||
|
|
4ac5a148b6 | ||
|
|
5b91608577 | ||
|
|
2c54303189 | ||
|
|
123bd19e3c | ||
|
|
f0beb294ee | ||
|
|
a325a443f7 | ||
|
|
f05cc9215e | ||
|
|
162ef39013 | ||
|
|
ac57999a85 | ||
|
|
7d3d63db56 | ||
|
|
b9adb8c7d9 | ||
|
|
95b17b8776 | ||
|
|
1b3ebb3cad | ||
|
|
18d30a140f | ||
|
|
69fb1374b2 | ||
|
|
bbac8060b9 | ||
|
|
dba07fc5ff | ||
|
|
44abe3ed35 | ||
|
|
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 |
6
.env
Normal file
6
.env
Normal file
@@ -0,0 +1,6 @@
|
||||
# Environment variables for development
|
||||
# You can add project-specific environment variables here
|
||||
|
||||
# Example:
|
||||
# ALPINEBITS_CONFIG_DIR=./config
|
||||
# PYTHONPATH=./src
|
||||
143
.github/copilot-instructions.md
vendored
Normal file
143
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,143 @@
|
||||
# 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`
|
||||
- **Logging**: Centralized logging configured via `logger` section (see `logging_config.py` and `LOGGING.md`)
|
||||
- Use `from logging_config import get_logger; _LOGGER = get_logger(__name__)` in any module
|
||||
- Logs to console always; optionally to file if `logger.file` is set
|
||||
- Format includes timestamp: `%(asctime)s - %(name)s - %(levelname)s - %(message)s`
|
||||
|
||||
### 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 }}
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -17,9 +17,17 @@ wheels/
|
||||
# ignore test_data content but keep the folder
|
||||
test_data/*
|
||||
|
||||
test/test_output/*
|
||||
|
||||
logs/*
|
||||
|
||||
|
||||
# ignore secrets
|
||||
secrets.yaml
|
||||
|
||||
# ignore db
|
||||
alpinebits.db
|
||||
|
||||
# test output files
|
||||
test_output.txt
|
||||
output.xml
|
||||
|
||||
38
.vscode/launch.json
vendored
Normal file
38
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Python: Current File",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"program": "${file}",
|
||||
"console": "integratedTerminal",
|
||||
"justMyCode": true
|
||||
},
|
||||
{
|
||||
"name": "Python: API Server",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "alpine_bits_python.run_api",
|
||||
"console": "integratedTerminal",
|
||||
"justMyCode": true,
|
||||
"env": {
|
||||
"ALPINEBITS_CONFIG_DIR": "${workspaceFolder}/config"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
60
.vscode/settings.json
vendored
60
.vscode/settings.json
vendored
@@ -1,8 +1,56 @@
|
||||
{
|
||||
"python.testing.pytestArgs": [
|
||||
"test"
|
||||
],
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.pytestEnabled": true,
|
||||
"python.analysis.typeCheckingMode": "basic"
|
||||
"editor.formatOnSave": true,
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "charliermarsh.ruff",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit",
|
||||
"source.organizeImports": "explicit"
|
||||
}
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"notebook.formatOnSave.enabled": true,
|
||||
"notebook.codeActionsOnSave": {
|
||||
// "notebook.source.fixAll": "explicit",
|
||||
// "notebook.source.organizeImports": "explicit"
|
||||
},
|
||||
"notebook.output.wordWrap": true,
|
||||
"notebook.output.textLineLimit": 200,
|
||||
"jupyter.debugJustMyCode": false,
|
||||
"python.defaultInterpreterPath": "./.venv/bin/python",
|
||||
"python.terminal.activateEnvironment": true,
|
||||
"python.terminal.activateEnvInCurrentTerminal": true,
|
||||
"python.envFile": "${workspaceFolder}/.env",
|
||||
"terminal.integrated.env.linux": {
|
||||
"VIRTUAL_ENV": "${workspaceFolder}/.venv",
|
||||
"PATH": "${workspaceFolder}/.venv/bin:${env:PATH}"
|
||||
},
|
||||
"terminal.integrated.defaultProfile.linux": "bash",
|
||||
"terminal.integrated.profiles.linux": {
|
||||
"bash": {
|
||||
"path": "bash",
|
||||
"args": ["-c", "source ${workspaceFolder}/.venv/bin/activate && exec bash"]
|
||||
}
|
||||
},
|
||||
"python.testing.pytestEnabled": true,
|
||||
"python.testing.pytestArgs": [
|
||||
"tests",
|
||||
"-v",
|
||||
"--tb=short"
|
||||
],
|
||||
"python.testing.pytestPath": "./.venv/bin/pytest",
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.autoTestDiscoverOnSaveEnabled": true,
|
||||
"python.testing.cwd": "${workspaceFolder}",
|
||||
"files.exclude": {
|
||||
"**/*.egg-info": true,
|
||||
"**/htmlcov": true,
|
||||
"**/~$*": true,
|
||||
"**/.coverage.*": true,
|
||||
"**/.venv": true,
|
||||
"**/__pycache__": true,
|
||||
"**/.mypy_cache": true,
|
||||
"**/.pytest_cache": true
|
||||
}
|
||||
}
|
||||
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
CLAUDE.md
Normal file
1
CLAUDE.md
Normal file
@@ -0,0 +1 @@
|
||||
This python project is managed by uv. Use uv run to execute app and tests.
|
||||
118
LOGGING.md
Normal file
118
LOGGING.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# Logging Configuration
|
||||
|
||||
The AlpineBits Python server uses a centralized logging system that can be configured via the `config.yaml` file.
|
||||
|
||||
## Configuration
|
||||
|
||||
Add the following section to your `config/config.yaml`:
|
||||
|
||||
```yaml
|
||||
logger:
|
||||
level: "INFO" # Options: DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||
file: "logs/alpinebits.log" # Optional: path to log file (omit or set to null for console-only)
|
||||
```
|
||||
|
||||
### Log Levels
|
||||
|
||||
- **DEBUG**: Detailed diagnostic information (very verbose)
|
||||
- **INFO**: General informational messages about application progress
|
||||
- **WARNING**: Warning messages about potential issues
|
||||
- **ERROR**: Error messages when something goes wrong
|
||||
- **CRITICAL**: Critical errors that may cause application failure
|
||||
|
||||
### Log Output
|
||||
|
||||
- **Console**: Logs are always written to console (stdout)
|
||||
- **File**: Optionally write logs to a file by specifying the `file` parameter
|
||||
- File logs include the same timestamp and formatting as console logs
|
||||
- Log directory will be created automatically if it doesn't exist
|
||||
|
||||
## Usage in Code
|
||||
|
||||
To use logging in any module:
|
||||
|
||||
```python
|
||||
from alpine_bits_python.logging_config import get_logger
|
||||
|
||||
_LOGGER = get_logger(__name__)
|
||||
|
||||
# Then use the logger
|
||||
_LOGGER.info("Application started")
|
||||
_LOGGER.debug("Detailed debug information: %s", some_variable)
|
||||
_LOGGER.warning("Something unusual happened")
|
||||
_LOGGER.error("An error occurred: %s", error_message)
|
||||
_LOGGER.exception("Critical error with stack trace")
|
||||
```
|
||||
|
||||
## Log Format
|
||||
|
||||
All log entries include:
|
||||
|
||||
- Timestamp (YYYY-MM-DD HH:MM:SS)
|
||||
- Module name (logger name)
|
||||
- Log level
|
||||
- Message
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
2025-10-09 14:23:45 - alpine_bits_python.api - INFO - Application startup initiated
|
||||
2025-10-09 14:23:45 - alpine_bits_python.api - INFO - Logging configured at INFO level
|
||||
2025-10-09 14:23:46 - alpine_bits_python.api - INFO - Database tables checked/created at startup.
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use structured logging**: Pass variables as arguments, not f-strings
|
||||
|
||||
```python
|
||||
# Good
|
||||
_LOGGER.info("Processing reservation %s for hotel %s", reservation_id, hotel_code)
|
||||
|
||||
# Avoid (performance overhead, linting warnings)
|
||||
_LOGGER.info(f"Processing reservation {reservation_id} for hotel {hotel_code}")
|
||||
```
|
||||
|
||||
2. **Use appropriate log levels**:
|
||||
|
||||
- `DEBUG`: Detailed tracing for development
|
||||
- `INFO`: Normal application flow events
|
||||
- `WARNING`: Unexpected but handled situations
|
||||
- `ERROR`: Errors that need attention
|
||||
- `CRITICAL`: Severe errors requiring immediate action
|
||||
|
||||
3. **Use `exception()` for error handling**:
|
||||
|
||||
```python
|
||||
try:
|
||||
risky_operation()
|
||||
except Exception:
|
||||
_LOGGER.exception("Operation failed") # Automatically includes stack trace
|
||||
```
|
||||
|
||||
4. **Don't log sensitive data**: Avoid logging passwords, tokens, or personal data
|
||||
|
||||
## Examples
|
||||
|
||||
### Console-only logging (development)
|
||||
|
||||
```yaml
|
||||
logger:
|
||||
level: "DEBUG"
|
||||
```
|
||||
|
||||
### File logging (production)
|
||||
|
||||
```yaml
|
||||
logger:
|
||||
level: "INFO"
|
||||
file: "/var/log/alpinebits/app.log"
|
||||
```
|
||||
|
||||
### Minimal logging
|
||||
|
||||
```yaml
|
||||
logger:
|
||||
level: "WARNING"
|
||||
file: "logs/warnings.log"
|
||||
```
|
||||
180
README.md
180
README.md
@@ -1,28 +1,182 @@
|
||||
# Alpine bits
|
||||
# Übersicht
|
||||
|
||||
Hour alpine bits application needs to mostly act as a server. It needs to provide room reservation information to the ASA alpinebits client at the hotel.
|
||||
Enthält einen in Python geschriebenen Alpine Bits Server zur Übertragung von Buchungsanfragen von Landingpages an Partnerhotels. Ein Fastapi Endpoint empfängt Anfrageformulare von den wix.com landingpages, und speichert sie in die Datenbank ab. Der Alpine Bits Server stellt diese dann Hotels auf dem Endpoint `www.99tales.net/api/alpinebits/server-2024-10` zu Verfügung.
|
||||
|
||||
However in other things we act could potentially act as the client with ASA acting as the alpinebits server. Basically according to the documentation the whole thing depends on who is requesting information and who has it.
|
||||
|
||||
When ASA wants to know our GuestRequests from the Landing page then they are the client and we the server. This causes some problems because our system actually knows less than the hotel system. We can't easiliy add Room Rate information and publish a reservation to ASA because we don't actually know the rooms.
|
||||
## Entwicklung
|
||||
|
||||
Just for GuestRequests this should be fine however.
|
||||
Auf dem Entwicklungsystem muss git und der uv python package manager installiert sein.
|
||||
|
||||
### Git Authentification
|
||||
|
||||
Wenn über http geklont wird muss lokal der [git-credential-oauth](https://github.com/hickford/git-credential-oauth) helper installiert sein. Besser gehts über ssh. Da muss ein ssh-key in gitea für den eigenen Benutzer angelegt sein.
|
||||
|
||||
1. Repo klonen.
|
||||
2. `uv sync` ausführen
|
||||
3. `uv run python -m alpine_bits_python.run_api` führt die API lokal auf Port 8080 aus. Datenbank wird automatisch erstellt und bei jedem start geleert.
|
||||
|
||||
## Konfiguration
|
||||
|
||||
Erfolgt über zwei yaml files. Zu konfigurieren ist die Verbindung zur Datenbank und die Konfiguration der einzelnen Hotels. In zukunft kommt vermutlich auch noch die Push URL hinzu.
|
||||
|
||||
```yaml
|
||||
database:
|
||||
url: "sqlite+aiosqlite:///alpinebits.db" # For local dev, use SQLite. For prod, override with PostgreSQL URL.
|
||||
# url: "postgresql://user:password@host:port/dbname" # Example for Postgres
|
||||
|
||||
alpine_bits_auth:
|
||||
- hotel_id: "123"
|
||||
hotel_name: "Frangart Inn"
|
||||
username: "alice"
|
||||
password: !secret ALICE_PASSWORD
|
||||
- hotel_id: "456"
|
||||
hotel_name: "Bemelmans"
|
||||
username: "bob"
|
||||
password: !secret BOB_PASSWORD
|
||||
```
|
||||
|
||||
!secret verweist auf einen Eintrag in secrets.yaml. Diese Datei wird aus Sicherheitsgründen nicht auf die Repository hochgeladen. In secrets.yaml können passwörter folgendermaßen angegeben werden
|
||||
|
||||
```yaml
|
||||
ALICE_PASSWORD: "supersecretpassword123"
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
Die Applikation wird in einem Dockercontainer deployed. Um das Container Image zu erstellen ist folgender Befehl notwendig
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
docker build . -t gitea.linter-home.com/jonas/asa_api:master
|
||||
```
|
||||
Dieser Befehl muss im Wurzelverzeichnis der Repository ausgeführt werden. `pwd` sollte irgendwas/alpinebits_python ergeben. Der Punkt hinter dem docker build befehl verweißt nämlich auf das lokale Dockerfile. "-t" steht für tag. In diesem Beispiel wird das Image mit dem Tag `gitea.linter-home.com/jonas/asa_api:master` versehen.
|
||||
|
||||
Ideal wäre eine Build Pipeline in Gitea selbst aber dies aufzusetzen ist etwas schwierig und es ist gut möglich das die Hetzner VM das nicht herhat. Lokal bei mir zuhause ist dies aufgesetzt. War alles andere als leicht.
|
||||
|
||||
Am besten einfach direkt auf dem Zielsystem den Container bauen und im Docker Compose File dann auf dieses Image referenzieren.
|
||||
|
||||
|
||||
### Docker Compose Beispiel mit Traefik Reverse Proxy
|
||||
|
||||
```yaml
|
||||
services:
|
||||
asa_connector:
|
||||
image: gitea.linter-home.com/jonas/asa_api:master
|
||||
container_name: asa_connector
|
||||
restart: unless-stopped
|
||||
|
||||
# Environment variables via .env file
|
||||
env_file:
|
||||
- asa_connector.env
|
||||
|
||||
networks:
|
||||
- external
|
||||
|
||||
# Only expose internally - Traefik will handle external access
|
||||
expose:
|
||||
- "8000"
|
||||
|
||||
user: "1000:1000" # Run as user with UID 1000 and GID 1000
|
||||
|
||||
environment:
|
||||
- ALPINEBITS_CONFIG_DIR=/config
|
||||
|
||||
volumes:
|
||||
- /home/jonas/asa_connector_logs:/app/src/logs
|
||||
- /home/jonas/alpinebits_config:/config
|
||||
|
||||
|
||||
|
||||
# Traefik labels for automatic service discovery
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# API router - handles /api/* paths on 99tales.net
|
||||
- "traefik.http.routers.asa_connector.rule=Host(`99tales.net`) && PathPrefix(`/api`)"
|
||||
- "traefik.http.routers.asa_connector.entrypoints=https"
|
||||
- "traefik.http.routers.asa_connector.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.asa_connector.loadbalancer.server.port=8000"
|
||||
- "traefik.http.routers.asa_connector.priority=100"
|
||||
|
||||
# Redirect middleware for non-API paths
|
||||
- "traefik.http.middlewares.redirect-to-99tales-it.redirectregex.regex=^https://99tales\\.net/(.*)$$"
|
||||
- "traefik.http.middlewares.redirect-to-99tales-it.redirectregex.replacement=https://99tales.it/$${1}"
|
||||
- "traefik.http.middlewares.redirect-to-99tales-it.redirectregex.permanent=true"
|
||||
|
||||
# Catch-all router for non-API paths on 99tales.net (lower priority)
|
||||
- "traefik.http.routers.redirect-router.rule=Host(`99tales.net`)"
|
||||
- "traefik.http.routers.redirect-router.entrypoints=https"
|
||||
- "traefik.http.routers.redirect-router.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.redirect-router.middlewares=redirect-to-99tales-it"
|
||||
- "traefik.http.routers.redirect-router.service=noop@internal"
|
||||
- "traefik.http.routers.redirect-router.priority=1"
|
||||
|
||||
dockerproxy:
|
||||
image: ghcr.io/tecnativa/docker-socket-proxy:latest
|
||||
container_name: dockerproxy
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
CONTAINERS: 1 # read only
|
||||
POST: 0
|
||||
|
||||
networks:
|
||||
- external
|
||||
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
|
||||
traefik:
|
||||
image: traefik:latest
|
||||
container_name: traefik
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
environment:
|
||||
- DOCKER_HOST=dockerproxy
|
||||
|
||||
networks:
|
||||
- external
|
||||
|
||||
ports:
|
||||
- "80:80" # HTTP
|
||||
- "443:443" # HTTPS
|
||||
- "22:22" # SSH for Gitea
|
||||
|
||||
volumes:
|
||||
- /home/jonas/traefik:/etc/traefik # Traefik configuration files
|
||||
|
||||
|
||||
|
||||
# Health check
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import requests; requests.get('http://localhost:8000/health', timeout=5)"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
networks: # custom bridge network named 'external'
|
||||
external:
|
||||
name: external
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
Damit das ganze auch funktioniert müssen dns Einträge auf die Virtuelle Machine zeigen in der das ganze läuft. Wurde bei Hostinger für 99tales.net eingerichtet.
|
||||
|
||||
Wie in dem Beispiel ersichtlich wird sowohl ein Log Ordner als auch ein Config ordner in den Container gemapped. Diesen am besten auf dem Host vor Erstellung des Containers erstellen.
|
||||
|
||||
Die Umgebungsvariable `ALPINEBITS_CONFIG_DIR` sagt dann dem Programm wo es die Config finden soll. In dem Ordner kann man die obens erwähnten Konfigurationsdateien speichern. Falls sqlite als Datenbank verwendet wird, findet man dort auch die Datenbank nach erstem ausführen.
|
||||
|
||||
|
||||
|
||||
# Wix formular parsing Notizen
|
||||
# TODO Liste
|
||||
|
||||
Need a table in the database that stores requests that have already been acknowledged by the client. Should contain client_id + a list of all acked unique_ids
|
||||
|
||||
|
||||
### Kontaktinformationen
|
||||
|
||||
|
||||
|
||||
#### Werbeparameter
|
||||
|
||||
|
||||
|
||||
|
||||
field:angebot_auswaehlen -> Kommentar mit Angebot
|
||||
|
||||
|
||||
|
||||
|
||||
14061
alpinebits.log
Normal file
14061
alpinebits.log
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,15 +2,40 @@
|
||||
# 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: "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
|
||||
|
||||
server:
|
||||
codecontext: "ADVERTISING"
|
||||
code: 70597314
|
||||
companyname: "99tales Gmbh"
|
||||
res_id_source_context: "99tales"
|
||||
|
||||
|
||||
|
||||
logger:
|
||||
level: "INFO" # Set to DEBUG for more verbose output
|
||||
file: "alpinebits.log" # Log file path, or null for console only
|
||||
|
||||
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
|
||||
- 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
|
||||
|
||||
13
conftest.py
Normal file
13
conftest.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Pytest configuration and path setup for VS Code.
|
||||
|
||||
This configuration file ensures that VS Code can properly discover and run tests
|
||||
by setting up the Python path to include the src directory.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add the src directory to Python path for VS Code test discovery
|
||||
src_path = Path(__file__).parent / "src"
|
||||
if str(src_path) not in sys.path:
|
||||
sys.path.insert(0, str(src_path))
|
||||
1
coverage.json
Normal file
1
coverage.json
Normal file
File diff suppressed because one or more lines are too long
@@ -0,0 +1,61 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<OTA_HotelResNotifRQ xmlns="http://www.opentravel.org/OTA/2003/05" Version="7.000">
|
||||
<HotelReservations>
|
||||
<HotelReservation CreateDateTime="2025-10-07T15:13:38.831800+00:00" ResStatus="Requested" RoomStayReservation="true">
|
||||
<UniqueID Type="14" ID="8e68dab6-7c2e-4c67-9471-b8cbfb7b"/>
|
||||
<RoomStays>
|
||||
<RoomStay>
|
||||
<GuestCounts>
|
||||
<GuestCount Count="13"/>
|
||||
</GuestCounts>
|
||||
<TimeSpan Start="2025-10-25" End="2025-10-26"/>
|
||||
</RoomStay>
|
||||
</RoomStays>
|
||||
<ResGuests>
|
||||
<ResGuest>
|
||||
<Profiles>
|
||||
<ProfileInfo>
|
||||
<Profile>
|
||||
<Customer Language="de">
|
||||
<PersonName>
|
||||
<NamePrefix>Frau</NamePrefix>
|
||||
<GivenName>Christine</GivenName>
|
||||
<Surname>Niederkofler</Surname>
|
||||
</PersonName>
|
||||
<Telephone PhoneTechType="5" PhoneNumber="+4953346312"/>
|
||||
<Email Remark="newsletter:yes">info@ledermode.at</Email>
|
||||
</Customer>
|
||||
</Profile>
|
||||
</ProfileInfo>
|
||||
</Profiles>
|
||||
</ResGuest>
|
||||
</ResGuests>
|
||||
<ResGlobalInfo>
|
||||
<Comments>
|
||||
<Comment Name="additional info">
|
||||
<Text>Angebot/Offerta: Törggelewochen - Herbstliche Genüsse & Südtiroler Tradition</Text>
|
||||
</Comment>
|
||||
<Comment Name="customer comment">
|
||||
<Text>Hallo. Wir würden gerne mit unseren Mitarbeitern vom 25.10 - 26.10.25 nach Südtirol fahren.
|
||||
Geplant wäre am Samstagabend Törggelen und am Sonntag nach dem Frühstück mit der Gondel zur Seiser Alm zu fahren.
|
||||
Wir sind ca. 13 Personen (es können gerne auch 3-Bettzimmer dabei sein falls vorhanden. Sonst DZ und wir benötigen 1 EZ).
|
||||
Bitte um ein Angebot für Törggelen, Übernachtung und Frühstück. Vielen lieben Dank! Christine Niederkofler</Text>
|
||||
</Comment>
|
||||
</Comments>
|
||||
<HotelReservationIDs>
|
||||
<HotelReservationID ResID_Type="13" ResID_Value="Cj0KCQjw3OjGBhDYARIsADd-uX65gXKdbOti_3OOA50T-B9Uj-zsOzXJ7g2-8Tz_" ResID_Source="google" ResID_SourceContext="99tales"/>
|
||||
</HotelReservationIDs>
|
||||
<Profiles>
|
||||
<ProfileInfo>
|
||||
<Profile ProfileType="4">
|
||||
<CompanyInfo>
|
||||
<CompanyName Code="who knows?" CodeContext="who knows?">99tales GmbH</CompanyName>
|
||||
</CompanyInfo>
|
||||
</Profile>
|
||||
</ProfileInfo>
|
||||
</Profiles>
|
||||
<BasicPropertyInfo HotelCode="12345" HotelName="Frangart Inn"/>
|
||||
</ResGlobalInfo>
|
||||
</HotelReservation>
|
||||
</HotelReservations>
|
||||
</OTA_HotelResNotifRQ>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<OTA_HotelResNotifRQ xmlns="http://www.opentravel.org/OTA/2003/05" Version="7.000">
|
||||
<HotelReservations/>
|
||||
</OTA_HotelResNotifRQ>
|
||||
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<OTA_HotelResNotifRQ xmlns="http://www.opentravel.org/OTA/2003/05" Version="7.000">
|
||||
<HotelReservations>
|
||||
<HotelReservation CreateDateTime="2025-10-07T14:05:37.563674+00:00" ResStatus="Requested" RoomStayReservation="true">
|
||||
<UniqueID Type="14" ID="c52702c9-55b9-44e1-b158-ec9544c7"/>
|
||||
<RoomStays>
|
||||
<RoomStay>
|
||||
<GuestCounts>
|
||||
<GuestCount Count="3"/>
|
||||
<GuestCount Count="1" Age="12"/>
|
||||
</GuestCounts>
|
||||
<TimeSpan Start="2026-01-02" End="2026-01-07"/>
|
||||
</RoomStay>
|
||||
</RoomStays>
|
||||
<ResGuests>
|
||||
<ResGuest>
|
||||
<Profiles>
|
||||
<ProfileInfo>
|
||||
<Profile>
|
||||
<Customer Language="it">
|
||||
<PersonName>
|
||||
<NamePrefix>Frau</NamePrefix>
|
||||
<GivenName>Genesia</GivenName>
|
||||
<Surname>Supino</Surname>
|
||||
</PersonName>
|
||||
<Telephone PhoneTechType="5" PhoneNumber="+393406259979"/>
|
||||
<Email Remark="newsletter:yes">supinogenesia@gmail.com</Email>
|
||||
</Customer>
|
||||
</Profile>
|
||||
</ProfileInfo>
|
||||
</Profiles>
|
||||
</ResGuest>
|
||||
</ResGuests>
|
||||
<ResGlobalInfo>
|
||||
<HotelReservationIDs>
|
||||
<HotelReservationID ResID_Type="13" ResID_Value="IwZXh0bgNhZW0BMABhZGlkAassWPh1b8QBHoRc2S24gMktdNKiPwEvGYMK3rB-mn" ResID_Source="Facebook_Mobile_Feed" ResID_SourceContext="99tales"/>
|
||||
</HotelReservationIDs>
|
||||
<Profiles>
|
||||
<ProfileInfo>
|
||||
<Profile ProfileType="4">
|
||||
<CompanyInfo>
|
||||
<CompanyName Code="who knows?" CodeContext="who knows?">99tales GmbH</CompanyName>
|
||||
</CompanyInfo>
|
||||
</Profile>
|
||||
</ProfileInfo>
|
||||
</Profiles>
|
||||
<BasicPropertyInfo HotelCode="12345" HotelName="Bemelmans Post"/>
|
||||
</ResGlobalInfo>
|
||||
</HotelReservation>
|
||||
</HotelReservations>
|
||||
</OTA_HotelResNotifRQ>
|
||||
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<OTA_HotelResNotifRQ xmlns="http://www.opentravel.org/OTA/2003/05" Version="7.000">
|
||||
<HotelReservations>
|
||||
<HotelReservation CreateDateTime="2025-10-07T14:24:04.943026+00:00" ResStatus="Requested" RoomStayReservation="true">
|
||||
<UniqueID Type="14" ID="c52702c9-55b9-44e1-b158-ec9544c7"/>
|
||||
<RoomStays>
|
||||
<RoomStay>
|
||||
<GuestCounts>
|
||||
<GuestCount Count="3"/>
|
||||
<GuestCount Count="1" Age="12"/>
|
||||
</GuestCounts>
|
||||
<TimeSpan Start="2026-01-02" End="2026-01-07"/>
|
||||
</RoomStay>
|
||||
</RoomStays>
|
||||
<ResGuests>
|
||||
<ResGuest>
|
||||
<Profiles>
|
||||
<ProfileInfo>
|
||||
<Profile>
|
||||
<Customer Language="it">
|
||||
<PersonName>
|
||||
<NamePrefix>Frau</NamePrefix>
|
||||
<GivenName>Genesia</GivenName>
|
||||
<Surname>Supino</Surname>
|
||||
</PersonName>
|
||||
<Telephone PhoneTechType="5" PhoneNumber="+393406259979"/>
|
||||
<Email Remark="newsletter:yes">supinogenesia@gmail.com</Email>
|
||||
</Customer>
|
||||
</Profile>
|
||||
</ProfileInfo>
|
||||
</Profiles>
|
||||
</ResGuest>
|
||||
</ResGuests>
|
||||
<ResGlobalInfo>
|
||||
<HotelReservationIDs>
|
||||
<HotelReservationID ResID_Type="13" ResID_Value="IwZXh0bgNhZW0BMABhZGlkAassWPh1b8QBHoRc2S24gMktdNKiPwEvGYMK3rB-mn" ResID_Source="Facebook_Mobile_Feed" ResID_SourceContext="99tales"/>
|
||||
</HotelReservationIDs>
|
||||
<Profiles>
|
||||
<ProfileInfo>
|
||||
<Profile ProfileType="4">
|
||||
<CompanyInfo>
|
||||
<CompanyName Code="who knows?" CodeContext="who knows?">99tales GmbH</CompanyName>
|
||||
</CompanyInfo>
|
||||
</Profile>
|
||||
</ProfileInfo>
|
||||
</Profiles>
|
||||
<BasicPropertyInfo HotelCode="12345" HotelName="Bemelmans Post"/>
|
||||
</ResGlobalInfo>
|
||||
</HotelReservation>
|
||||
</HotelReservations>
|
||||
</OTA_HotelResNotifRQ>
|
||||
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<OTA_HotelResNotifRQ xmlns="http://www.opentravel.org/OTA/2003/05" Version="7.000">
|
||||
<HotelReservations>
|
||||
<HotelReservation CreateDateTime="2025-10-07T14:32:52.523968+00:00" ResStatus="Requested" RoomStayReservation="true">
|
||||
<UniqueID Type="14" ID="c52702c9-55b9-44e1-b158-ec9544c7"/>
|
||||
<RoomStays>
|
||||
<RoomStay>
|
||||
<GuestCounts>
|
||||
<GuestCount Count="3"/>
|
||||
<GuestCount Count="1" Age="12"/>
|
||||
</GuestCounts>
|
||||
<TimeSpan Start="2026-01-02" End="2026-01-07"/>
|
||||
</RoomStay>
|
||||
</RoomStays>
|
||||
<ResGuests>
|
||||
<ResGuest>
|
||||
<Profiles>
|
||||
<ProfileInfo>
|
||||
<Profile>
|
||||
<Customer Language="it">
|
||||
<PersonName>
|
||||
<NamePrefix>Frau</NamePrefix>
|
||||
<GivenName>Genesia</GivenName>
|
||||
<Surname>Supino</Surname>
|
||||
</PersonName>
|
||||
<Telephone PhoneTechType="5" PhoneNumber="+393406259979"/>
|
||||
<Email Remark="newsletter:yes">supinogenesia@gmail.com</Email>
|
||||
</Customer>
|
||||
</Profile>
|
||||
</ProfileInfo>
|
||||
</Profiles>
|
||||
</ResGuest>
|
||||
</ResGuests>
|
||||
<ResGlobalInfo>
|
||||
<HotelReservationIDs>
|
||||
<HotelReservationID ResID_Type="13" ResID_Value="IwZXh0bgNhZW0BMABhZGlkAassWPh1b8QBHoRc2S24gMktdNKiPwEvGYMK3rB-mn" ResID_Source="Facebook_Mobile_Feed" ResID_SourceContext="99tales"/>
|
||||
</HotelReservationIDs>
|
||||
<Profiles>
|
||||
<ProfileInfo>
|
||||
<Profile ProfileType="4">
|
||||
<CompanyInfo>
|
||||
<CompanyName Code="who knows?" CodeContext="who knows?">99tales GmbH</CompanyName>
|
||||
</CompanyInfo>
|
||||
</Profile>
|
||||
</ProfileInfo>
|
||||
</Profiles>
|
||||
<BasicPropertyInfo HotelCode="12345" HotelName="Bemelmans Post"/>
|
||||
</ResGlobalInfo>
|
||||
</HotelReservation>
|
||||
</HotelReservations>
|
||||
</OTA_HotelResNotifRQ>
|
||||
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<OTA_HotelResNotifRQ xmlns="http://www.opentravel.org/OTA/2003/05" Version="7.000">
|
||||
<HotelReservations>
|
||||
<HotelReservation CreateDateTime="2025-10-07T15:12:25.274095+00:00" ResStatus="Requested" RoomStayReservation="true">
|
||||
<UniqueID Type="14" ID="c52702c9-55b9-44e1-b158-ec9544c7"/>
|
||||
<RoomStays>
|
||||
<RoomStay>
|
||||
<GuestCounts>
|
||||
<GuestCount Count="3"/>
|
||||
<GuestCount Count="1" Age="12"/>
|
||||
</GuestCounts>
|
||||
<TimeSpan Start="2026-01-02" End="2026-01-07"/>
|
||||
</RoomStay>
|
||||
</RoomStays>
|
||||
<ResGuests>
|
||||
<ResGuest>
|
||||
<Profiles>
|
||||
<ProfileInfo>
|
||||
<Profile>
|
||||
<Customer Language="it">
|
||||
<PersonName>
|
||||
<NamePrefix>Frau</NamePrefix>
|
||||
<GivenName>Genesia</GivenName>
|
||||
<Surname>Supino</Surname>
|
||||
</PersonName>
|
||||
<Telephone PhoneTechType="5" PhoneNumber="+393406259979"/>
|
||||
<Email Remark="newsletter:yes">supinogenesia@gmail.com</Email>
|
||||
</Customer>
|
||||
</Profile>
|
||||
</ProfileInfo>
|
||||
</Profiles>
|
||||
</ResGuest>
|
||||
</ResGuests>
|
||||
<ResGlobalInfo>
|
||||
<HotelReservationIDs>
|
||||
<HotelReservationID ResID_Type="13" ResID_Value="IwZXh0bgNhZW0BMABhZGlkAassWPh1b8QBHoRc2S24gMktdNKiPwEvGYMK3rB-mn" ResID_Source="Facebook_Mobile_Feed" ResID_SourceContext="99tales"/>
|
||||
</HotelReservationIDs>
|
||||
<Profiles>
|
||||
<ProfileInfo>
|
||||
<Profile ProfileType="4">
|
||||
<CompanyInfo>
|
||||
<CompanyName Code="who knows?" CodeContext="who knows?">99tales GmbH</CompanyName>
|
||||
</CompanyInfo>
|
||||
</Profile>
|
||||
</ProfileInfo>
|
||||
</Profiles>
|
||||
<BasicPropertyInfo HotelCode="12345" HotelName="Bemelmans Post"/>
|
||||
</ResGlobalInfo>
|
||||
</HotelReservation>
|
||||
</HotelReservations>
|
||||
</OTA_HotelResNotifRQ>
|
||||
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<OTA_HotelResNotifRQ xmlns="http://www.opentravel.org/OTA/2003/05" Version="7.000">
|
||||
<HotelReservations>
|
||||
<HotelReservation CreateDateTime="2025-10-07T09:38:38.167778+00:00" ResStatus="Requested" RoomStayReservation="true">
|
||||
<UniqueID Type="14" ID="c52702c9-55b9-44e1-b158-ec9544c7"/>
|
||||
<RoomStays>
|
||||
<RoomStay>
|
||||
<GuestCounts>
|
||||
<GuestCount Count="3"/>
|
||||
<GuestCount Count="1" Age="12"/>
|
||||
</GuestCounts>
|
||||
<TimeSpan Start="2026-01-02" End="2026-01-07"/>
|
||||
</RoomStay>
|
||||
</RoomStays>
|
||||
<ResGuests>
|
||||
<ResGuest>
|
||||
<Profiles>
|
||||
<ProfileInfo>
|
||||
<Profile>
|
||||
<Customer Language="it">
|
||||
<PersonName>
|
||||
<NamePrefix>Frau</NamePrefix>
|
||||
<GivenName>Genesia</GivenName>
|
||||
<Surname>Supino</Surname>
|
||||
</PersonName>
|
||||
<Telephone PhoneTechType="5" PhoneNumber="+393406259979"/>
|
||||
<Email Remark="newsletter:yes">supinogenesia@gmail.com</Email>
|
||||
</Customer>
|
||||
</Profile>
|
||||
</ProfileInfo>
|
||||
</Profiles>
|
||||
</ResGuest>
|
||||
</ResGuests>
|
||||
<ResGlobalInfo>
|
||||
<HotelReservationIDs>
|
||||
<HotelReservationID ResID_Type="13" ResID_Value="IwZXh0bgNhZW0BMABhZGlkAassWPh1b8QBHoRc2S24gMktdNKiPwEvGYMK3rB-mn" ResID_Source="Facebook_Mobile_Feed" ResID_SourceContext="99tales"/>
|
||||
</HotelReservationIDs>
|
||||
<Profiles>
|
||||
<ProfileInfo>
|
||||
<Profile ProfileType="4">
|
||||
<CompanyInfo>
|
||||
<CompanyName Code="who knows?" CodeContext="who knows?">99tales GmbH</CompanyName>
|
||||
</CompanyInfo>
|
||||
</Profile>
|
||||
</ProfileInfo>
|
||||
</Profiles>
|
||||
<BasicPropertyInfo HotelCode="12345" HotelName="Bemelmans Post"/>
|
||||
</ResGlobalInfo>
|
||||
</HotelReservation>
|
||||
</HotelReservations>
|
||||
</OTA_HotelResNotifRQ>
|
||||
262
logs/wix_test_data_20251006_104642.json
Normal file
262
logs/wix_test_data_20251006_104642.json
Normal file
@@ -0,0 +1,262 @@
|
||||
{
|
||||
"timestamp": "2025-10-06T10:46:42.527300",
|
||||
"client_ip": "127.0.0.1",
|
||||
"headers": {
|
||||
"host": "localhost:8080",
|
||||
"content-type": "application/json",
|
||||
"user-agent": "insomnia/2023.5.8",
|
||||
"accept": "*/*",
|
||||
"content-length": "7499"
|
||||
},
|
||||
"data": {
|
||||
"data": {
|
||||
"formName": "Contact us",
|
||||
"submissions": [
|
||||
{
|
||||
"label": "Angebot auswählen",
|
||||
"value": "Zimmer: Doppelzimmer"
|
||||
},
|
||||
{
|
||||
"label": "Anreisedatum",
|
||||
"value": "2025-12-21"
|
||||
},
|
||||
{
|
||||
"label": "Abreisedatum",
|
||||
"value": "2025-10-28"
|
||||
},
|
||||
{
|
||||
"label": "Anzahl Erwachsene",
|
||||
"value": "2"
|
||||
},
|
||||
{
|
||||
"label": "Anzahl Kinder",
|
||||
"value": "0"
|
||||
},
|
||||
{
|
||||
"label": "Anrede",
|
||||
"value": "Herr"
|
||||
},
|
||||
{
|
||||
"label": "Vorname",
|
||||
"value": "Ernst-Dieter"
|
||||
},
|
||||
{
|
||||
"label": "Nachname",
|
||||
"value": "Koepper"
|
||||
},
|
||||
{
|
||||
"label": "Email",
|
||||
"value": "koepper-ed@t-online.de"
|
||||
},
|
||||
{
|
||||
"label": "Phone",
|
||||
"value": "+49 175 8555456"
|
||||
},
|
||||
{
|
||||
"label": "Message",
|
||||
"value": "Guten Morgen,\nwir sind nicht gebau an die Reisedaten gebunden: Anreise ist möglich ab 20. Dezember, Aufenthalt mindestens eine Woche, gern auch 8 oder 9 Tage. Natürlich mit Halbpension. Mit freundlichem Gruß D. Köpper"
|
||||
},
|
||||
{
|
||||
"label": "Einwilligung Marketing",
|
||||
"value": "Angekreuzt"
|
||||
},
|
||||
{
|
||||
"label": "utm_Source",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "utm_Medium",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "utm_Campaign",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "utm_Term",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "utm_Content",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "utm_term_id",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "utm_content_id",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "gad_source",
|
||||
"value": "5"
|
||||
},
|
||||
{
|
||||
"label": "gad_campaignid",
|
||||
"value": "23065043477"
|
||||
},
|
||||
{
|
||||
"label": "gbraid",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "gclid",
|
||||
"value": "EAIaIQobChMI-d7Bn_-OkAMVuZJQBh09uD0vEAAYASAAEgKR8_D_BwE"
|
||||
},
|
||||
{
|
||||
"label": "fbclid",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "hotelid",
|
||||
"value": "12345"
|
||||
},
|
||||
{
|
||||
"label": "hotelname",
|
||||
"value": "Bemelmans Post"
|
||||
}
|
||||
],
|
||||
"field:date_picker_7e65": "2025-10-28",
|
||||
"field:number_7cf5": "2",
|
||||
"field:utm_source": "",
|
||||
"submissionTime": "2025-10-06T07:05:34.001Z",
|
||||
"field:gad_source": "5",
|
||||
"field:form_field_5a7b": "Angekreuzt",
|
||||
"field:gad_campaignid": "23065043477",
|
||||
"field:utm_medium": "",
|
||||
"field:utm_term_id": "",
|
||||
"context": {
|
||||
"metaSiteId": "1dea821c-8168-4736-96e4-4b92e8b364cf",
|
||||
"activationId": "fd8e9c90-0335-4fd2-976d-985f065f3f80"
|
||||
},
|
||||
"field:email_5139": "koepper-ed@t-online.de",
|
||||
"field:phone_4c77": "+49 175 8555456",
|
||||
"_context": {
|
||||
"activation": {
|
||||
"id": "fd8e9c90-0335-4fd2-976d-985f065f3f80"
|
||||
},
|
||||
"configuration": {
|
||||
"id": "a976f18c-fa86-495d-be1e-676df188eeae"
|
||||
},
|
||||
"app": {
|
||||
"id": "225dd912-7dea-4738-8688-4b8c6955ffc2"
|
||||
},
|
||||
"action": {
|
||||
"id": "152db4d7-5263-40c4-be2b-1c81476318b7"
|
||||
},
|
||||
"trigger": {
|
||||
"key": "wix_form_app-form_submitted"
|
||||
}
|
||||
},
|
||||
"field:gclid": "EAIaIQobChMI-d7Bn_-OkAMVuZJQBh09uD0vEAAYASAAEgKR8_D_BwE",
|
||||
"formFieldMask": [
|
||||
"field:",
|
||||
"field:",
|
||||
"field:angebot_auswaehlen",
|
||||
"field:date_picker_a7c8",
|
||||
"field:date_picker_7e65",
|
||||
"field:",
|
||||
"field:number_7cf5",
|
||||
"field:anzahl_kinder",
|
||||
"field:alter_kind_3",
|
||||
"field:alter_kind_25",
|
||||
"field:alter_kind_4",
|
||||
"field:alter_kind_5",
|
||||
"field:alter_kind_6",
|
||||
"field:alter_kind_7",
|
||||
"field:alter_kind_8",
|
||||
"field:alter_kind_9",
|
||||
"field:alter_kind_10",
|
||||
"field:alter_kind_11",
|
||||
"field:",
|
||||
"field:anrede",
|
||||
"field:first_name_abae",
|
||||
"field:last_name_d97c",
|
||||
"field:email_5139",
|
||||
"field:phone_4c77",
|
||||
"field:long_answer_3524",
|
||||
"field:form_field_5a7b",
|
||||
"field:",
|
||||
"field:utm_source",
|
||||
"field:utm_medium",
|
||||
"field:utm_campaign",
|
||||
"field:utm_term",
|
||||
"field:utm_content",
|
||||
"field:utm_term_id",
|
||||
"field:utm_content_id",
|
||||
"field:gad_source",
|
||||
"field:gad_campaignid",
|
||||
"field:gbraid",
|
||||
"field:gclid",
|
||||
"field:fbclid",
|
||||
"field:hotelid",
|
||||
"field:hotelname",
|
||||
"field:",
|
||||
"metaSiteId"
|
||||
],
|
||||
"contact": {
|
||||
"name": {
|
||||
"first": "Ernst-Dieter",
|
||||
"last": "Koepper"
|
||||
},
|
||||
"email": "koepper-ed@t-online.de",
|
||||
"locale": "de-de",
|
||||
"phones": [
|
||||
{
|
||||
"tag": "UNTAGGED",
|
||||
"formattedPhone": "+49 175 8555456",
|
||||
"id": "530a3bf4-6dbe-4611-8963-a50df805785d",
|
||||
"countryCode": "DE",
|
||||
"e164Phone": "+491758555456",
|
||||
"primary": true,
|
||||
"phone": "175 8555456"
|
||||
}
|
||||
],
|
||||
"contactId": "13659da8-4035-47fe-a66b-6ce461ad290f",
|
||||
"emails": [
|
||||
{
|
||||
"id": "e1d2168e-ca3c-4844-8f93-f2e1b0ae70e3",
|
||||
"tag": "UNTAGGED",
|
||||
"email": "koepper-ed@t-online.de",
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"updatedDate": "2025-10-06T07:05:35.675Z",
|
||||
"phone": "+491758555456",
|
||||
"createdDate": "2025-10-06T07:05:35.675Z"
|
||||
},
|
||||
"submissionId": "86d247dc-9d5a-4eb7-87a7-677bf64645ad",
|
||||
"field:anzahl_kinder": "0",
|
||||
"field:first_name_abae": "Ernst-Dieter",
|
||||
"field:utm_content_id": "",
|
||||
"field:utm_campaign": "",
|
||||
"field:utm_term": "",
|
||||
"contactId": "13659da8-4035-47fe-a66b-6ce461ad290f",
|
||||
"field:date_picker_a7c8": "2025-12-21",
|
||||
"field:hotelname": "Bemelmans Post",
|
||||
"field:angebot_auswaehlen": "Zimmer: Doppelzimmer",
|
||||
"field:utm_content": "",
|
||||
"field:last_name_d97c": "Koepper",
|
||||
"field:hotelid": "12345",
|
||||
"submissionsLink": "https://manage.wix.app/forms/submissions/1dea821c-8168-4736-96e4-4b92e8b364cf/e084006b-ae83-4e4d-b2f5-074118cdb3b1?d=https%3A%2F%2Fmanage.wix.com%2Fdashboard%2F1dea821c-8168-4736-96e4-4b92e8b364cf%2Fwix-forms%2Fform%2Fe084006b-ae83-4e4d-b2f5-074118cdb3b1%2Fsubmissions&s=true",
|
||||
"field:gbraid": "",
|
||||
"field:fbclid": "",
|
||||
"submissionPdf": {
|
||||
"fileName": "86d247dc-9d5a-4eb7-87a7-677bf64645ad.pdf",
|
||||
"downloadUrl": "https://manage.wix.com/_api/form-submission-service/v4/submissions/86d247dc-9d5a-4eb7-87a7-677bf64645ad/download?accessToken=JWS.eyJraWQiOiJWLVNuLWhwZSIsImFsZyI6IkhTMjU2In0.eyJkYXRhIjoie1wibWV0YVNpdGVJZFwiOlwiMWRlYTgyMWMtODE2OC00NzM2LTk2ZTQtNGI5MmU4YjM2NGNmXCJ9IiwiaWF0IjoxNzU5NzM0MzM1LCJleHAiOjE3NTk3MzQ5MzV9.9koy-O_ptm0dRspjh01Yefkt2rCHiUlRCFtE_S3auYw"
|
||||
},
|
||||
"field:anrede": "Herr",
|
||||
"field:long_answer_3524": "Guten Morgen,\nwir sind nicht gebau an die Reisedaten gebunden: Anreise ist möglich ab 20. Dezember, Aufenthalt mindestens eine Woche, gern auch 8 oder 9 Tage. Natürlich mit Halbpension. Mit freundlichem Gruß D. Köpper",
|
||||
"formId": "e084006b-ae83-4e4d-b2f5-074118cdb3b1"
|
||||
}
|
||||
},
|
||||
"origin_header": null,
|
||||
"all_headers": {
|
||||
"host": "localhost:8080",
|
||||
"content-type": "application/json",
|
||||
"user-agent": "insomnia/2023.5.8",
|
||||
"accept": "*/*",
|
||||
"content-length": "7499"
|
||||
}
|
||||
}
|
||||
262
logs/wix_test_data_20251006_105732.json
Normal file
262
logs/wix_test_data_20251006_105732.json
Normal file
@@ -0,0 +1,262 @@
|
||||
{
|
||||
"timestamp": "2025-10-06T10:57:32.973217",
|
||||
"client_ip": "127.0.0.1",
|
||||
"headers": {
|
||||
"host": "localhost:8080",
|
||||
"content-type": "application/json",
|
||||
"user-agent": "insomnia/2023.5.8",
|
||||
"accept": "*/*",
|
||||
"content-length": "7499"
|
||||
},
|
||||
"data": {
|
||||
"data": {
|
||||
"formName": "Contact us",
|
||||
"submissions": [
|
||||
{
|
||||
"label": "Angebot auswählen",
|
||||
"value": "Zimmer: Doppelzimmer"
|
||||
},
|
||||
{
|
||||
"label": "Anreisedatum",
|
||||
"value": "2025-12-21"
|
||||
},
|
||||
{
|
||||
"label": "Abreisedatum",
|
||||
"value": "2025-10-28"
|
||||
},
|
||||
{
|
||||
"label": "Anzahl Erwachsene",
|
||||
"value": "2"
|
||||
},
|
||||
{
|
||||
"label": "Anzahl Kinder",
|
||||
"value": "0"
|
||||
},
|
||||
{
|
||||
"label": "Anrede",
|
||||
"value": "Herr"
|
||||
},
|
||||
{
|
||||
"label": "Vorname",
|
||||
"value": "Ernst-Dieter"
|
||||
},
|
||||
{
|
||||
"label": "Nachname",
|
||||
"value": "Koepper"
|
||||
},
|
||||
{
|
||||
"label": "Email",
|
||||
"value": "koepper-ed@t-online.de"
|
||||
},
|
||||
{
|
||||
"label": "Phone",
|
||||
"value": "+49 175 8555456"
|
||||
},
|
||||
{
|
||||
"label": "Message",
|
||||
"value": "Guten Morgen,\nwir sind nicht gebau an die Reisedaten gebunden: Anreise ist möglich ab 20. Dezember, Aufenthalt mindestens eine Woche, gern auch 8 oder 9 Tage. Natürlich mit Halbpension. Mit freundlichem Gruß D. Köpper"
|
||||
},
|
||||
{
|
||||
"label": "Einwilligung Marketing",
|
||||
"value": "Angekreuzt"
|
||||
},
|
||||
{
|
||||
"label": "utm_Source",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "utm_Medium",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "utm_Campaign",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "utm_Term",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "utm_Content",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "utm_term_id",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "utm_content_id",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "gad_source",
|
||||
"value": "5"
|
||||
},
|
||||
{
|
||||
"label": "gad_campaignid",
|
||||
"value": "23065043477"
|
||||
},
|
||||
{
|
||||
"label": "gbraid",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "gclid",
|
||||
"value": "EAIaIQobChMI-d7Bn_-OkAMVuZJQBh09uD0vEAAYASAAEgKR8_D_BwE"
|
||||
},
|
||||
{
|
||||
"label": "fbclid",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "hotelid",
|
||||
"value": "12345"
|
||||
},
|
||||
{
|
||||
"label": "hotelname",
|
||||
"value": "Bemelmans Post"
|
||||
}
|
||||
],
|
||||
"field:date_picker_7e65": "2025-10-28",
|
||||
"field:number_7cf5": "2",
|
||||
"field:utm_source": "",
|
||||
"submissionTime": "2025-10-06T07:05:34.001Z",
|
||||
"field:gad_source": "5",
|
||||
"field:form_field_5a7b": "Angekreuzt",
|
||||
"field:gad_campaignid": "23065043477",
|
||||
"field:utm_medium": "",
|
||||
"field:utm_term_id": "",
|
||||
"context": {
|
||||
"metaSiteId": "1dea821c-8168-4736-96e4-4b92e8b364cf",
|
||||
"activationId": "fd8e9c90-0335-4fd2-976d-985f065f3f80"
|
||||
},
|
||||
"field:email_5139": "koepper-ed@t-online.de",
|
||||
"field:phone_4c77": "+49 175 8555456",
|
||||
"_context": {
|
||||
"activation": {
|
||||
"id": "fd8e9c90-0335-4fd2-976d-985f065f3f80"
|
||||
},
|
||||
"configuration": {
|
||||
"id": "a976f18c-fa86-495d-be1e-676df188eeae"
|
||||
},
|
||||
"app": {
|
||||
"id": "225dd912-7dea-4738-8688-4b8c6955ffc2"
|
||||
},
|
||||
"action": {
|
||||
"id": "152db4d7-5263-40c4-be2b-1c81476318b7"
|
||||
},
|
||||
"trigger": {
|
||||
"key": "wix_form_app-form_submitted"
|
||||
}
|
||||
},
|
||||
"field:gclid": "EAIaIQobChMI-d7Bn_-OkAMVuZJQBh09uD0vEAAYASAAEgKR8_D_BwE",
|
||||
"formFieldMask": [
|
||||
"field:",
|
||||
"field:",
|
||||
"field:angebot_auswaehlen",
|
||||
"field:date_picker_a7c8",
|
||||
"field:date_picker_7e65",
|
||||
"field:",
|
||||
"field:number_7cf5",
|
||||
"field:anzahl_kinder",
|
||||
"field:alter_kind_3",
|
||||
"field:alter_kind_25",
|
||||
"field:alter_kind_4",
|
||||
"field:alter_kind_5",
|
||||
"field:alter_kind_6",
|
||||
"field:alter_kind_7",
|
||||
"field:alter_kind_8",
|
||||
"field:alter_kind_9",
|
||||
"field:alter_kind_10",
|
||||
"field:alter_kind_11",
|
||||
"field:",
|
||||
"field:anrede",
|
||||
"field:first_name_abae",
|
||||
"field:last_name_d97c",
|
||||
"field:email_5139",
|
||||
"field:phone_4c77",
|
||||
"field:long_answer_3524",
|
||||
"field:form_field_5a7b",
|
||||
"field:",
|
||||
"field:utm_source",
|
||||
"field:utm_medium",
|
||||
"field:utm_campaign",
|
||||
"field:utm_term",
|
||||
"field:utm_content",
|
||||
"field:utm_term_id",
|
||||
"field:utm_content_id",
|
||||
"field:gad_source",
|
||||
"field:gad_campaignid",
|
||||
"field:gbraid",
|
||||
"field:gclid",
|
||||
"field:fbclid",
|
||||
"field:hotelid",
|
||||
"field:hotelname",
|
||||
"field:",
|
||||
"metaSiteId"
|
||||
],
|
||||
"contact": {
|
||||
"name": {
|
||||
"first": "Ernst-Dieter",
|
||||
"last": "Koepper"
|
||||
},
|
||||
"email": "koepper-ed@t-online.de",
|
||||
"locale": "de-de",
|
||||
"phones": [
|
||||
{
|
||||
"tag": "UNTAGGED",
|
||||
"formattedPhone": "+49 175 8555456",
|
||||
"id": "530a3bf4-6dbe-4611-8963-a50df805785d",
|
||||
"countryCode": "DE",
|
||||
"e164Phone": "+491758555456",
|
||||
"primary": true,
|
||||
"phone": "175 8555456"
|
||||
}
|
||||
],
|
||||
"contactId": "13659da8-4035-47fe-a66b-6ce461ad290f",
|
||||
"emails": [
|
||||
{
|
||||
"id": "e1d2168e-ca3c-4844-8f93-f2e1b0ae70e3",
|
||||
"tag": "UNTAGGED",
|
||||
"email": "koepper-ed@t-online.de",
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"updatedDate": "2025-10-06T07:05:35.675Z",
|
||||
"phone": "+491758555456",
|
||||
"createdDate": "2025-10-06T07:05:35.675Z"
|
||||
},
|
||||
"submissionId": "86d247dc-9d5a-4eb7-87a7-677bf64645ad",
|
||||
"field:anzahl_kinder": "0",
|
||||
"field:first_name_abae": "Ernst-Dieter",
|
||||
"field:utm_content_id": "",
|
||||
"field:utm_campaign": "",
|
||||
"field:utm_term": "",
|
||||
"contactId": "13659da8-4035-47fe-a66b-6ce461ad290f",
|
||||
"field:date_picker_a7c8": "2025-12-21",
|
||||
"field:hotelname": "Bemelmans Post",
|
||||
"field:angebot_auswaehlen": "Zimmer: Doppelzimmer",
|
||||
"field:utm_content": "",
|
||||
"field:last_name_d97c": "Koepper",
|
||||
"field:hotelid": "12345",
|
||||
"submissionsLink": "https://manage.wix.app/forms/submissions/1dea821c-8168-4736-96e4-4b92e8b364cf/e084006b-ae83-4e4d-b2f5-074118cdb3b1?d=https%3A%2F%2Fmanage.wix.com%2Fdashboard%2F1dea821c-8168-4736-96e4-4b92e8b364cf%2Fwix-forms%2Fform%2Fe084006b-ae83-4e4d-b2f5-074118cdb3b1%2Fsubmissions&s=true",
|
||||
"field:gbraid": "",
|
||||
"field:fbclid": "",
|
||||
"submissionPdf": {
|
||||
"fileName": "86d247dc-9d5a-4eb7-87a7-677bf64645ad.pdf",
|
||||
"downloadUrl": "https://manage.wix.com/_api/form-submission-service/v4/submissions/86d247dc-9d5a-4eb7-87a7-677bf64645ad/download?accessToken=JWS.eyJraWQiOiJWLVNuLWhwZSIsImFsZyI6IkhTMjU2In0.eyJkYXRhIjoie1wibWV0YVNpdGVJZFwiOlwiMWRlYTgyMWMtODE2OC00NzM2LTk2ZTQtNGI5MmU4YjM2NGNmXCJ9IiwiaWF0IjoxNzU5NzM0MzM1LCJleHAiOjE3NTk3MzQ5MzV9.9koy-O_ptm0dRspjh01Yefkt2rCHiUlRCFtE_S3auYw"
|
||||
},
|
||||
"field:anrede": "Herr",
|
||||
"field:long_answer_3524": "Guten Morgen,\nwir sind nicht gebau an die Reisedaten gebunden: Anreise ist möglich ab 20. Dezember, Aufenthalt mindestens eine Woche, gern auch 8 oder 9 Tage. Natürlich mit Halbpension. Mit freundlichem Gruß D. Köpper",
|
||||
"formId": "e084006b-ae83-4e4d-b2f5-074118cdb3b1"
|
||||
}
|
||||
},
|
||||
"origin_header": null,
|
||||
"all_headers": {
|
||||
"host": "localhost:8080",
|
||||
"content-type": "application/json",
|
||||
"user-agent": "insomnia/2023.5.8",
|
||||
"accept": "*/*",
|
||||
"content-length": "7499"
|
||||
}
|
||||
}
|
||||
262
logs/wix_test_data_20251006_154306.json
Normal file
262
logs/wix_test_data_20251006_154306.json
Normal file
@@ -0,0 +1,262 @@
|
||||
{
|
||||
"timestamp": "2025-10-06T15:43:06.732884",
|
||||
"client_ip": "127.0.0.1",
|
||||
"headers": {
|
||||
"host": "localhost:8080",
|
||||
"content-type": "application/json",
|
||||
"user-agent": "insomnia/2023.5.8",
|
||||
"accept": "*/*",
|
||||
"content-length": "7081"
|
||||
},
|
||||
"data": {
|
||||
"data": {
|
||||
"formName": "Contact us",
|
||||
"submissions": [
|
||||
{
|
||||
"label": "Angebot auswählen",
|
||||
"value": "Zimmer: Doppelzimmer"
|
||||
},
|
||||
{
|
||||
"label": "Anreisedatum",
|
||||
"value": "2025-10-21"
|
||||
},
|
||||
{
|
||||
"label": "Abreisedatum",
|
||||
"value": "2025-12-28"
|
||||
},
|
||||
{
|
||||
"label": "Anzahl Erwachsene",
|
||||
"value": "4"
|
||||
},
|
||||
{
|
||||
"label": "Anzahl Kinder",
|
||||
"value": "0"
|
||||
},
|
||||
{
|
||||
"label": "Anrede",
|
||||
"value": "Herr"
|
||||
},
|
||||
{
|
||||
"label": "Vorname",
|
||||
"value": "Jonas"
|
||||
},
|
||||
{
|
||||
"label": "Nachname",
|
||||
"value": "Linter"
|
||||
},
|
||||
{
|
||||
"label": "Email",
|
||||
"value": "jonas@vaius.ai"
|
||||
},
|
||||
{
|
||||
"label": "Phone",
|
||||
"value": "+39 392 007 6982"
|
||||
},
|
||||
{
|
||||
"label": "Message",
|
||||
"value": "Hallo nachricht in der Kommentarsection"
|
||||
},
|
||||
{
|
||||
"label": "Einwilligung Marketing",
|
||||
"value": "Angekreuzt"
|
||||
},
|
||||
{
|
||||
"label": "utm_Source",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "utm_Medium",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "utm_Campaign",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "utm_Term",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "utm_Content",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "utm_term_id",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "utm_content_id",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "gad_source",
|
||||
"value": "5"
|
||||
},
|
||||
{
|
||||
"label": "gad_campaignid",
|
||||
"value": "23065043477"
|
||||
},
|
||||
{
|
||||
"label": "gbraid",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "gclid",
|
||||
"value": "EAIaIQobChMI-d7Bn_-OkAMVuZJQBh09uD0vEAAYASAAEgKR8_D_BwE"
|
||||
},
|
||||
{
|
||||
"label": "fbclid",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "hotelid",
|
||||
"value": "12345"
|
||||
},
|
||||
{
|
||||
"label": "hotelname",
|
||||
"value": "Bemelmans Post"
|
||||
}
|
||||
],
|
||||
"field:date_picker_7e65": "2025-10-28",
|
||||
"field:number_7cf5": "2",
|
||||
"field:utm_source": "",
|
||||
"submissionTime": "2025-10-06T07:05:34.001Z",
|
||||
"field:gad_source": "5",
|
||||
"field:form_field_5a7b": "Angekreuzt",
|
||||
"field:gad_campaignid": "23065043477",
|
||||
"field:utm_medium": "",
|
||||
"field:utm_term_id": "",
|
||||
"context": {
|
||||
"metaSiteId": "1dea821c-8168-4736-96e4-4b92e8b364cf",
|
||||
"activationId": "fd8e9c90-0335-4fd2-976d-985f065f3f80"
|
||||
},
|
||||
"field:email_5139": "jonas@vaius.ai",
|
||||
"field:phone_4c77": "+39 392 007 6982",
|
||||
"_context": {
|
||||
"activation": {
|
||||
"id": "fd8e9c90-0335-4fd2-976d-985f065f3f80"
|
||||
},
|
||||
"configuration": {
|
||||
"id": "a976f18c-fa86-495d-be1e-676df188eeae"
|
||||
},
|
||||
"app": {
|
||||
"id": "225dd912-7dea-4738-8688-4b8c6955ffc2"
|
||||
},
|
||||
"action": {
|
||||
"id": "152db4d7-5263-40c4-be2b-1c81476318b7"
|
||||
},
|
||||
"trigger": {
|
||||
"key": "wix_form_app-form_submitted"
|
||||
}
|
||||
},
|
||||
"field:gclid": "EAIaIQobChMI-d7Bn_-OkAMVuZJQBh09uD0vEAAYASAAEgKR8_D_BwE",
|
||||
"formFieldMask": [
|
||||
"field:",
|
||||
"field:",
|
||||
"field:angebot_auswaehlen",
|
||||
"field:date_picker_a7c8",
|
||||
"field:date_picker_7e65",
|
||||
"field:",
|
||||
"field:number_7cf5",
|
||||
"field:anzahl_kinder",
|
||||
"field:alter_kind_3",
|
||||
"field:alter_kind_25",
|
||||
"field:alter_kind_4",
|
||||
"field:alter_kind_5",
|
||||
"field:alter_kind_6",
|
||||
"field:alter_kind_7",
|
||||
"field:alter_kind_8",
|
||||
"field:alter_kind_9",
|
||||
"field:alter_kind_10",
|
||||
"field:alter_kind_11",
|
||||
"field:",
|
||||
"field:anrede",
|
||||
"field:first_name_abae",
|
||||
"field:last_name_d97c",
|
||||
"field:email_5139",
|
||||
"field:phone_4c77",
|
||||
"field:long_answer_3524",
|
||||
"field:form_field_5a7b",
|
||||
"field:",
|
||||
"field:utm_source",
|
||||
"field:utm_medium",
|
||||
"field:utm_campaign",
|
||||
"field:utm_term",
|
||||
"field:utm_content",
|
||||
"field:utm_term_id",
|
||||
"field:utm_content_id",
|
||||
"field:gad_source",
|
||||
"field:gad_campaignid",
|
||||
"field:gbraid",
|
||||
"field:gclid",
|
||||
"field:fbclid",
|
||||
"field:hotelid",
|
||||
"field:hotelname",
|
||||
"field:",
|
||||
"metaSiteId"
|
||||
],
|
||||
"contact": {
|
||||
"name": {
|
||||
"first": "Jonas",
|
||||
"last": "Linter"
|
||||
},
|
||||
"email": "jonas@vaius.ai",
|
||||
"locale": "de-de",
|
||||
"phones": [
|
||||
{
|
||||
"tag": "UNTAGGED",
|
||||
"formattedPhone": "+39 392 007 6982",
|
||||
"id": "530a3bf4-6dbe-4611-8963-a50df805785d",
|
||||
"countryCode": "DE",
|
||||
"e164Phone": "+493920076982",
|
||||
"primary": true,
|
||||
"phone": "392 0076982"
|
||||
}
|
||||
],
|
||||
"contactId": "66659da8-4035-47fe-a66b-6ce461ad290f",
|
||||
"emails": [
|
||||
{
|
||||
"id": "e1d2168e-ca3c-4844-8f93-f2e1b0ae70e3",
|
||||
"tag": "UNTAGGED",
|
||||
"email": "koepper-ed@t-online.de",
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"updatedDate": "2025-10-06T07:05:35.675Z",
|
||||
"phone": "+491758555456",
|
||||
"createdDate": "2025-10-06T07:05:35.675Z"
|
||||
},
|
||||
"submissionId": "666247dc-9d5a-4eb7-87a7-677bf64645ad",
|
||||
"field:anzahl_kinder": "0",
|
||||
"field:first_name_abae": "Ernst-Dieter",
|
||||
"field:utm_content_id": "",
|
||||
"field:utm_campaign": "",
|
||||
"field:utm_term": "",
|
||||
"contactId": "66659da8-4035-47fe-a66b-6ce461ad290f",
|
||||
"field:date_picker_a7c8": "2025-12-21",
|
||||
"field:hotelname": "Testhotel",
|
||||
"field:angebot_auswaehlen": "Zimmer: Doppelzimmer",
|
||||
"field:utm_content": "",
|
||||
"field:last_name_d97c": "Linter",
|
||||
"field:hotelid": "135",
|
||||
"submissionsLink": "https://manage.wix.app/forms/submissions/1dea821c-8168-4736-96e4-4b92e8b364cf/e084006b-ae83-4e4d-b2f5-074118cdb3b1?d=https%3A%2F%2Fmanage.wix.com%2Fdashboard%2F1dea821c-8168-4736-96e4-4b92e8b364cf%2Fwix-forms%2Fform%2Fe084006b-ae83-4e4d-b2f5-074118cdb3b1%2Fsubmissions&s=true",
|
||||
"field:gbraid": "",
|
||||
"field:fbclid": "",
|
||||
"submissionPdf": {
|
||||
"fileName": "86d247dc-9d5a-4eb7-87a7-677bf64645ad.pdf",
|
||||
"downloadUrl": "https://manage.wix.com/_api/form-submission-service/v4/submissions/86d247dc-9d5a-4eb7-87a7-677bf64645ad/download?accessToken=JWS.eyJraWQiOiJWLVNuLWhwZSIsImFsZyI6IkhTMjU2In0.eyJkYXRhIjoie1wibWV0YVNpdGVJZFwiOlwiMWRlYTgyMWMtODE2OC00NzM2LTk2ZTQtNGI5MmU4YjM2NGNmXCJ9IiwiaWF0IjoxNzU5NzM0MzM1LCJleHAiOjE3NTk3MzQ5MzV9.9koy-O_ptm0dRspjh01Yefkt2rCHiUlRCFtE_S3auYw"
|
||||
},
|
||||
"field:anrede": "Herr",
|
||||
"field:long_answer_3524": "Kommentarsektion vermutlich",
|
||||
"formId": "e084006b-ae83-4e4d-b2f5-074118cdb3b1"
|
||||
}
|
||||
},
|
||||
"origin_header": null,
|
||||
"all_headers": {
|
||||
"host": "localhost:8080",
|
||||
"content-type": "application/json",
|
||||
"user-agent": "insomnia/2023.5.8",
|
||||
"accept": "*/*",
|
||||
"content-length": "7081"
|
||||
}
|
||||
}
|
||||
262
logs/wix_test_data_20251006_154435.json
Normal file
262
logs/wix_test_data_20251006_154435.json
Normal file
@@ -0,0 +1,262 @@
|
||||
{
|
||||
"timestamp": "2025-10-06T15:44:35.341703",
|
||||
"client_ip": "127.0.0.1",
|
||||
"headers": {
|
||||
"host": "localhost:8080",
|
||||
"content-type": "application/json",
|
||||
"user-agent": "insomnia/2023.5.8",
|
||||
"accept": "*/*",
|
||||
"content-length": "7081"
|
||||
},
|
||||
"data": {
|
||||
"data": {
|
||||
"formName": "Contact us",
|
||||
"submissions": [
|
||||
{
|
||||
"label": "Angebot auswählen",
|
||||
"value": "Zimmer: Doppelzimmer"
|
||||
},
|
||||
{
|
||||
"label": "Anreisedatum",
|
||||
"value": "2025-10-21"
|
||||
},
|
||||
{
|
||||
"label": "Abreisedatum",
|
||||
"value": "2025-12-28"
|
||||
},
|
||||
{
|
||||
"label": "Anzahl Erwachsene",
|
||||
"value": "4"
|
||||
},
|
||||
{
|
||||
"label": "Anzahl Kinder",
|
||||
"value": "0"
|
||||
},
|
||||
{
|
||||
"label": "Anrede",
|
||||
"value": "Herr"
|
||||
},
|
||||
{
|
||||
"label": "Vorname",
|
||||
"value": "Jonas"
|
||||
},
|
||||
{
|
||||
"label": "Nachname",
|
||||
"value": "Linter"
|
||||
},
|
||||
{
|
||||
"label": "Email",
|
||||
"value": "jonas@vaius.ai"
|
||||
},
|
||||
{
|
||||
"label": "Phone",
|
||||
"value": "+39 392 007 6982"
|
||||
},
|
||||
{
|
||||
"label": "Message",
|
||||
"value": "Hallo nachricht in der Kommentarsection"
|
||||
},
|
||||
{
|
||||
"label": "Einwilligung Marketing",
|
||||
"value": "Angekreuzt"
|
||||
},
|
||||
{
|
||||
"label": "utm_Source",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "utm_Medium",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "utm_Campaign",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "utm_Term",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "utm_Content",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "utm_term_id",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "utm_content_id",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "gad_source",
|
||||
"value": "5"
|
||||
},
|
||||
{
|
||||
"label": "gad_campaignid",
|
||||
"value": "23065043477"
|
||||
},
|
||||
{
|
||||
"label": "gbraid",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "gclid",
|
||||
"value": "EAIaIQobChMI-d7Bn_-OkAMVuZJQBh09uD0vEAAYASAAEgKR8_D_BwE"
|
||||
},
|
||||
{
|
||||
"label": "fbclid",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "hotelid",
|
||||
"value": "12345"
|
||||
},
|
||||
{
|
||||
"label": "hotelname",
|
||||
"value": "Bemelmans Post"
|
||||
}
|
||||
],
|
||||
"field:date_picker_7e65": "2025-10-28",
|
||||
"field:number_7cf5": "2",
|
||||
"field:utm_source": "",
|
||||
"submissionTime": "2025-10-06T07:05:34.001Z",
|
||||
"field:gad_source": "5",
|
||||
"field:form_field_5a7b": "Angekreuzt",
|
||||
"field:gad_campaignid": "23065043477",
|
||||
"field:utm_medium": "",
|
||||
"field:utm_term_id": "",
|
||||
"context": {
|
||||
"metaSiteId": "1dea821c-8168-4736-96e4-4b92e8b364cf",
|
||||
"activationId": "fd8e9c90-0335-4fd2-976d-985f065f3f80"
|
||||
},
|
||||
"field:email_5139": "jonas@vaius.ai",
|
||||
"field:phone_4c77": "+39 392 007 6982",
|
||||
"_context": {
|
||||
"activation": {
|
||||
"id": "fd8e9c90-0335-4fd2-976d-985f065f3f80"
|
||||
},
|
||||
"configuration": {
|
||||
"id": "a976f18c-fa86-495d-be1e-676df188eeae"
|
||||
},
|
||||
"app": {
|
||||
"id": "225dd912-7dea-4738-8688-4b8c6955ffc2"
|
||||
},
|
||||
"action": {
|
||||
"id": "152db4d7-5263-40c4-be2b-1c81476318b7"
|
||||
},
|
||||
"trigger": {
|
||||
"key": "wix_form_app-form_submitted"
|
||||
}
|
||||
},
|
||||
"field:gclid": "EAIaIQobChMI-d7Bn_-OkAMVuZJQBh09uD0vEAAYASAAEgKR8_D_BwE",
|
||||
"formFieldMask": [
|
||||
"field:",
|
||||
"field:",
|
||||
"field:angebot_auswaehlen",
|
||||
"field:date_picker_a7c8",
|
||||
"field:date_picker_7e65",
|
||||
"field:",
|
||||
"field:number_7cf5",
|
||||
"field:anzahl_kinder",
|
||||
"field:alter_kind_3",
|
||||
"field:alter_kind_25",
|
||||
"field:alter_kind_4",
|
||||
"field:alter_kind_5",
|
||||
"field:alter_kind_6",
|
||||
"field:alter_kind_7",
|
||||
"field:alter_kind_8",
|
||||
"field:alter_kind_9",
|
||||
"field:alter_kind_10",
|
||||
"field:alter_kind_11",
|
||||
"field:",
|
||||
"field:anrede",
|
||||
"field:first_name_abae",
|
||||
"field:last_name_d97c",
|
||||
"field:email_5139",
|
||||
"field:phone_4c77",
|
||||
"field:long_answer_3524",
|
||||
"field:form_field_5a7b",
|
||||
"field:",
|
||||
"field:utm_source",
|
||||
"field:utm_medium",
|
||||
"field:utm_campaign",
|
||||
"field:utm_term",
|
||||
"field:utm_content",
|
||||
"field:utm_term_id",
|
||||
"field:utm_content_id",
|
||||
"field:gad_source",
|
||||
"field:gad_campaignid",
|
||||
"field:gbraid",
|
||||
"field:gclid",
|
||||
"field:fbclid",
|
||||
"field:hotelid",
|
||||
"field:hotelname",
|
||||
"field:",
|
||||
"metaSiteId"
|
||||
],
|
||||
"contact": {
|
||||
"name": {
|
||||
"first": "Jonas",
|
||||
"last": "Linter"
|
||||
},
|
||||
"email": "jonas@vaius.ai",
|
||||
"locale": "de-de",
|
||||
"phones": [
|
||||
{
|
||||
"tag": "UNTAGGED",
|
||||
"formattedPhone": "+39 392 007 6982",
|
||||
"id": "530a3bf4-6dbe-4611-8963-a50df805785d",
|
||||
"countryCode": "DE",
|
||||
"e164Phone": "+493920076982",
|
||||
"primary": true,
|
||||
"phone": "392 0076982"
|
||||
}
|
||||
],
|
||||
"contactId": "66659da8-4035-47fe-a66b-6ce461ad290f",
|
||||
"emails": [
|
||||
{
|
||||
"id": "e1d2168e-ca3c-4844-8f93-f2e1b0ae70e3",
|
||||
"tag": "UNTAGGED",
|
||||
"email": "koepper-ed@t-online.de",
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"updatedDate": "2025-10-06T07:05:35.675Z",
|
||||
"phone": "+491758555456",
|
||||
"createdDate": "2025-10-06T07:05:35.675Z"
|
||||
},
|
||||
"submissionId": "666247dc-9d5a-4eb7-87a7-677bf64645ad",
|
||||
"field:anzahl_kinder": "0",
|
||||
"field:first_name_abae": "Ernst-Dieter",
|
||||
"field:utm_content_id": "",
|
||||
"field:utm_campaign": "",
|
||||
"field:utm_term": "",
|
||||
"contactId": "66659da8-4035-47fe-a66b-6ce461ad290f",
|
||||
"field:date_picker_a7c8": "2025-12-21",
|
||||
"field:hotelname": "Testhotel",
|
||||
"field:angebot_auswaehlen": "Zimmer: Doppelzimmer",
|
||||
"field:utm_content": "",
|
||||
"field:last_name_d97c": "Linter",
|
||||
"field:hotelid": "135",
|
||||
"submissionsLink": "https://manage.wix.app/forms/submissions/1dea821c-8168-4736-96e4-4b92e8b364cf/e084006b-ae83-4e4d-b2f5-074118cdb3b1?d=https%3A%2F%2Fmanage.wix.com%2Fdashboard%2F1dea821c-8168-4736-96e4-4b92e8b364cf%2Fwix-forms%2Fform%2Fe084006b-ae83-4e4d-b2f5-074118cdb3b1%2Fsubmissions&s=true",
|
||||
"field:gbraid": "",
|
||||
"field:fbclid": "",
|
||||
"submissionPdf": {
|
||||
"fileName": "86d247dc-9d5a-4eb7-87a7-677bf64645ad.pdf",
|
||||
"downloadUrl": "https://manage.wix.com/_api/form-submission-service/v4/submissions/86d247dc-9d5a-4eb7-87a7-677bf64645ad/download?accessToken=JWS.eyJraWQiOiJWLVNuLWhwZSIsImFsZyI6IkhTMjU2In0.eyJkYXRhIjoie1wibWV0YVNpdGVJZFwiOlwiMWRlYTgyMWMtODE2OC00NzM2LTk2ZTQtNGI5MmU4YjM2NGNmXCJ9IiwiaWF0IjoxNzU5NzM0MzM1LCJleHAiOjE3NTk3MzQ5MzV9.9koy-O_ptm0dRspjh01Yefkt2rCHiUlRCFtE_S3auYw"
|
||||
},
|
||||
"field:anrede": "Herr",
|
||||
"field:long_answer_3524": "Kommentarsektion vermutlich",
|
||||
"formId": "e084006b-ae83-4e4d-b2f5-074118cdb3b1"
|
||||
}
|
||||
},
|
||||
"origin_header": null,
|
||||
"all_headers": {
|
||||
"host": "localhost:8080",
|
||||
"content-type": "application/json",
|
||||
"user-agent": "insomnia/2023.5.8",
|
||||
"accept": "*/*",
|
||||
"content-length": "7081"
|
||||
}
|
||||
}
|
||||
257
logs/wix_test_data_20251007_155426.json
Normal file
257
logs/wix_test_data_20251007_155426.json
Normal file
@@ -0,0 +1,257 @@
|
||||
{
|
||||
"timestamp": "2025-10-07T15:54:26.898008",
|
||||
"client_ip": "127.0.0.1",
|
||||
"headers": {
|
||||
"host": "localhost:8080",
|
||||
"content-type": "application/json",
|
||||
"user-agent": "insomnia/2023.5.8",
|
||||
"accept": "*/*",
|
||||
"content-length": "7335"
|
||||
},
|
||||
"data": {
|
||||
"data": {
|
||||
"formName": "Contact us",
|
||||
"submissions": [
|
||||
{
|
||||
"label": "Anreisedatum",
|
||||
"value": "2026-01-02"
|
||||
},
|
||||
{
|
||||
"label": "Abreisedatum",
|
||||
"value": "2026-01-07"
|
||||
},
|
||||
{
|
||||
"label": "Anzahl Erwachsene",
|
||||
"value": "3"
|
||||
},
|
||||
{
|
||||
"label": "Anzahl Kinder",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"label": "Alter Kind 1",
|
||||
"value": "12"
|
||||
},
|
||||
{
|
||||
"label": "Anrede",
|
||||
"value": "Frau"
|
||||
},
|
||||
{
|
||||
"label": "Vorname",
|
||||
"value": "Genesia "
|
||||
},
|
||||
{
|
||||
"label": "Nachname",
|
||||
"value": "Supino "
|
||||
},
|
||||
{
|
||||
"label": "Email",
|
||||
"value": "supinogenesia@gmail.com"
|
||||
},
|
||||
{
|
||||
"label": "Phone",
|
||||
"value": "+39 340 625 9979"
|
||||
},
|
||||
{
|
||||
"label": "Einwilligung Marketing",
|
||||
"value": "Selezionato"
|
||||
},
|
||||
{
|
||||
"label": "utm_Source",
|
||||
"value": "fb"
|
||||
},
|
||||
{
|
||||
"label": "utm_Medium",
|
||||
"value": "Facebook_Mobile_Feed"
|
||||
},
|
||||
{
|
||||
"label": "utm_Campaign",
|
||||
"value": "Conversions_Hotel_Bemelmans_ITA"
|
||||
},
|
||||
{
|
||||
"label": "utm_Term",
|
||||
"value": "Cold_Traffic_Conversions_Hotel_Bemelmans_ITA"
|
||||
},
|
||||
{
|
||||
"label": "utm_Content",
|
||||
"value": "Grafik_AuszeitDezember_9.12_23.12"
|
||||
},
|
||||
{
|
||||
"label": "utm_term_id",
|
||||
"value": "120238574626400196"
|
||||
},
|
||||
{
|
||||
"label": "utm_content_id",
|
||||
"value": "120238574626400196"
|
||||
},
|
||||
{
|
||||
"label": "gad_source",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "gad_campaignid",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "gbraid",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "gclid",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "fbclid",
|
||||
"value": "IwZXh0bgNhZW0BMABhZGlkAassWPh1b8QBHoRc2S24gMktdNKiPwEvGYMK3rB-mne_0IJQvQRIGH60wLvLfOm0XWP8wJ9s_aem_rbpAFMODwOh4UnF5UVxwWg"
|
||||
},
|
||||
{
|
||||
"label": "hotelid",
|
||||
"value": "12345"
|
||||
},
|
||||
{
|
||||
"label": "hotelname",
|
||||
"value": "Bemelmans Post"
|
||||
}
|
||||
],
|
||||
"field:date_picker_7e65": "2026-01-07",
|
||||
"field:number_7cf5": "3",
|
||||
"field:utm_source": "fb",
|
||||
"submissionTime": "2025-10-07T05:48:41.855Z",
|
||||
"field:alter_kind_3": "12",
|
||||
"field:gad_source": "",
|
||||
"field:form_field_5a7b": "Selezionato",
|
||||
"field:gad_campaignid": "",
|
||||
"field:utm_medium": "Facebook_Mobile_Feed",
|
||||
"field:utm_term_id": "120238574626400196",
|
||||
"context": {
|
||||
"metaSiteId": "1dea821c-8168-4736-96e4-4b92e8b364cf",
|
||||
"activationId": "2421c9cd-6565-49ba-b60f-165d3dacccba"
|
||||
},
|
||||
"field:email_5139": "supinogenesia@gmail.com",
|
||||
"field:phone_4c77": "+39 340 625 9979",
|
||||
"_context": {
|
||||
"activation": {
|
||||
"id": "2421c9cd-6565-49ba-b60f-165d3dacccba"
|
||||
},
|
||||
"configuration": {
|
||||
"id": "a976f18c-fa86-495d-be1e-676df188eeae"
|
||||
},
|
||||
"app": {
|
||||
"id": "225dd912-7dea-4738-8688-4b8c6955ffc2"
|
||||
},
|
||||
"action": {
|
||||
"id": "152db4d7-5263-40c4-be2b-1c81476318b7"
|
||||
},
|
||||
"trigger": {
|
||||
"key": "wix_form_app-form_submitted"
|
||||
}
|
||||
},
|
||||
"field:gclid": "",
|
||||
"formFieldMask": [
|
||||
"field:",
|
||||
"field:",
|
||||
"field:angebot_auswaehlen",
|
||||
"field:date_picker_a7c8",
|
||||
"field:date_picker_7e65",
|
||||
"field:",
|
||||
"field:number_7cf5",
|
||||
"field:anzahl_kinder",
|
||||
"field:alter_kind_3",
|
||||
"field:alter_kind_25",
|
||||
"field:alter_kind_4",
|
||||
"field:alter_kind_5",
|
||||
"field:alter_kind_6",
|
||||
"field:alter_kind_7",
|
||||
"field:alter_kind_8",
|
||||
"field:alter_kind_9",
|
||||
"field:alter_kind_10",
|
||||
"field:alter_kind_11",
|
||||
"field:",
|
||||
"field:anrede",
|
||||
"field:first_name_abae",
|
||||
"field:last_name_d97c",
|
||||
"field:email_5139",
|
||||
"field:phone_4c77",
|
||||
"field:long_answer_3524",
|
||||
"field:form_field_5a7b",
|
||||
"field:",
|
||||
"field:utm_source",
|
||||
"field:utm_medium",
|
||||
"field:utm_campaign",
|
||||
"field:utm_term",
|
||||
"field:utm_content",
|
||||
"field:utm_term_id",
|
||||
"field:utm_content_id",
|
||||
"field:gad_source",
|
||||
"field:gad_campaignid",
|
||||
"field:gbraid",
|
||||
"field:gclid",
|
||||
"field:fbclid",
|
||||
"field:hotelid",
|
||||
"field:hotelname",
|
||||
"field:",
|
||||
"metaSiteId"
|
||||
],
|
||||
"contact": {
|
||||
"name": {
|
||||
"first": "Genesia",
|
||||
"last": "Supino"
|
||||
},
|
||||
"email": "supinogenesia@gmail.com",
|
||||
"locale": "it-it",
|
||||
"phones": [
|
||||
{
|
||||
"tag": "UNTAGGED",
|
||||
"formattedPhone": "+39 340 625 9979",
|
||||
"id": "198f04fb-5b2c-4a7b-b7ea-adc150ec4212",
|
||||
"countryCode": "IT",
|
||||
"e164Phone": "+393406259979",
|
||||
"primary": true,
|
||||
"phone": "340 625 9979"
|
||||
}
|
||||
],
|
||||
"contactId": "4d695011-36c1-4480-b225-ae9c6eef9e83",
|
||||
"emails": [
|
||||
{
|
||||
"id": "e09d7bab-1f11-4b5d-b3c5-32d43c1dc584",
|
||||
"tag": "UNTAGGED",
|
||||
"email": "supinogenesia@gmail.com",
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"updatedDate": "2025-10-07T05:48:44.764Z",
|
||||
"phone": "+393406259979",
|
||||
"createdDate": "2025-10-07T05:48:43.567Z"
|
||||
},
|
||||
"submissionId": "c52702c9-55b9-44e1-b158-ec9544c73cc7",
|
||||
"field:anzahl_kinder": "1",
|
||||
"field:first_name_abae": "Genesia ",
|
||||
"field:utm_content_id": "120238574626400196",
|
||||
"field:utm_campaign": "Conversions_Hotel_Bemelmans_ITA",
|
||||
"field:utm_term": "Cold_Traffic_Conversions_Hotel_Bemelmans_ITA",
|
||||
"contactId": "4d695011-36c1-4480-b225-ae9c6eef9e83",
|
||||
"field:date_picker_a7c8": "2026-01-02",
|
||||
"field:hotelname": "Bemelmans Post",
|
||||
"field:utm_content": "Grafik_AuszeitDezember_9.12_23.12",
|
||||
"field:last_name_d97c": "Supino ",
|
||||
"field:hotelid": "12345",
|
||||
"submissionsLink": "https://manage.wix.app/forms/submissions/1dea821c-8168-4736-96e4-4b92e8b364cf/e084006b-ae83-4e4d-b2f5-074118cdb3b1?d=https%3A%2F%2Fmanage.wix.com%2Fdashboard%2F1dea821c-8168-4736-96e4-4b92e8b364cf%2Fwix-forms%2Fform%2Fe084006b-ae83-4e4d-b2f5-074118cdb3b1%2Fsubmissions&s=true",
|
||||
"field:gbraid": "",
|
||||
"field:fbclid": "IwZXh0bgNhZW0BMABhZGlkAassWPh1b8QBHoRc2S24gMktdNKiPwEvGYMK3rB-mne_0IJQvQRIGH60wLvLfOm0XWP8wJ9s_aem_rbpAFMODwOh4UnF5UVxwWg",
|
||||
"submissionPdf": {
|
||||
"fileName": "c52702c9-55b9-44e1-b158-ec9544c73cc7.pdf",
|
||||
"downloadUrl": "https://manage.wix.com/_api/form-submission-service/v4/submissions/c52702c9-55b9-44e1-b158-ec9544c73cc7/download?accessToken=JWS.eyJraWQiOiJWLVNuLWhwZSIsImFsZyI6IkhTMjU2In0.eyJkYXRhIjoie1wibWV0YVNpdGVJZFwiOlwiMWRlYTgyMWMtODE2OC00NzM2LTk2ZTQtNGI5MmU4YjM2NGNmXCJ9IiwiaWF0IjoxNzU5ODE2MTI0LCJleHAiOjE3NTk4MTY3MjR9.quBfp9UL9Ddqb2CWERXoVkh9OdmHlIBvlLAyhoXElaY"
|
||||
},
|
||||
"field:anrede": "Frau",
|
||||
"formId": "e084006b-ae83-4e4d-b2f5-074118cdb3b1"
|
||||
}
|
||||
},
|
||||
"origin_header": null,
|
||||
"all_headers": {
|
||||
"host": "localhost:8080",
|
||||
"content-type": "application/json",
|
||||
"user-agent": "insomnia/2023.5.8",
|
||||
"accept": "*/*",
|
||||
"content-length": "7335"
|
||||
}
|
||||
}
|
||||
257
logs/wix_test_data_20251007_160537.json
Normal file
257
logs/wix_test_data_20251007_160537.json
Normal file
@@ -0,0 +1,257 @@
|
||||
{
|
||||
"timestamp": "2025-10-07T16:05:37.531417",
|
||||
"client_ip": "127.0.0.1",
|
||||
"headers": {
|
||||
"host": "localhost:8080",
|
||||
"content-type": "application/json",
|
||||
"user-agent": "insomnia/2023.5.8",
|
||||
"accept": "*/*",
|
||||
"content-length": "7335"
|
||||
},
|
||||
"data": {
|
||||
"data": {
|
||||
"formName": "Contact us",
|
||||
"submissions": [
|
||||
{
|
||||
"label": "Anreisedatum",
|
||||
"value": "2026-01-02"
|
||||
},
|
||||
{
|
||||
"label": "Abreisedatum",
|
||||
"value": "2026-01-07"
|
||||
},
|
||||
{
|
||||
"label": "Anzahl Erwachsene",
|
||||
"value": "3"
|
||||
},
|
||||
{
|
||||
"label": "Anzahl Kinder",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"label": "Alter Kind 1",
|
||||
"value": "12"
|
||||
},
|
||||
{
|
||||
"label": "Anrede",
|
||||
"value": "Frau"
|
||||
},
|
||||
{
|
||||
"label": "Vorname",
|
||||
"value": "Genesia "
|
||||
},
|
||||
{
|
||||
"label": "Nachname",
|
||||
"value": "Supino "
|
||||
},
|
||||
{
|
||||
"label": "Email",
|
||||
"value": "supinogenesia@gmail.com"
|
||||
},
|
||||
{
|
||||
"label": "Phone",
|
||||
"value": "+39 340 625 9979"
|
||||
},
|
||||
{
|
||||
"label": "Einwilligung Marketing",
|
||||
"value": "Selezionato"
|
||||
},
|
||||
{
|
||||
"label": "utm_Source",
|
||||
"value": "fb"
|
||||
},
|
||||
{
|
||||
"label": "utm_Medium",
|
||||
"value": "Facebook_Mobile_Feed"
|
||||
},
|
||||
{
|
||||
"label": "utm_Campaign",
|
||||
"value": "Conversions_Hotel_Bemelmans_ITA"
|
||||
},
|
||||
{
|
||||
"label": "utm_Term",
|
||||
"value": "Cold_Traffic_Conversions_Hotel_Bemelmans_ITA"
|
||||
},
|
||||
{
|
||||
"label": "utm_Content",
|
||||
"value": "Grafik_AuszeitDezember_9.12_23.12"
|
||||
},
|
||||
{
|
||||
"label": "utm_term_id",
|
||||
"value": "120238574626400196"
|
||||
},
|
||||
{
|
||||
"label": "utm_content_id",
|
||||
"value": "120238574626400196"
|
||||
},
|
||||
{
|
||||
"label": "gad_source",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "gad_campaignid",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "gbraid",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "gclid",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "fbclid",
|
||||
"value": "IwZXh0bgNhZW0BMABhZGlkAassWPh1b8QBHoRc2S24gMktdNKiPwEvGYMK3rB-mne_0IJQvQRIGH60wLvLfOm0XWP8wJ9s_aem_rbpAFMODwOh4UnF5UVxwWg"
|
||||
},
|
||||
{
|
||||
"label": "hotelid",
|
||||
"value": "12345"
|
||||
},
|
||||
{
|
||||
"label": "hotelname",
|
||||
"value": "Bemelmans Post"
|
||||
}
|
||||
],
|
||||
"field:date_picker_7e65": "2026-01-07",
|
||||
"field:number_7cf5": "3",
|
||||
"field:utm_source": "fb",
|
||||
"submissionTime": "2025-10-07T05:48:41.855Z",
|
||||
"field:alter_kind_3": "12",
|
||||
"field:gad_source": "",
|
||||
"field:form_field_5a7b": "Selezionato",
|
||||
"field:gad_campaignid": "",
|
||||
"field:utm_medium": "Facebook_Mobile_Feed",
|
||||
"field:utm_term_id": "120238574626400196",
|
||||
"context": {
|
||||
"metaSiteId": "1dea821c-8168-4736-96e4-4b92e8b364cf",
|
||||
"activationId": "2421c9cd-6565-49ba-b60f-165d3dacccba"
|
||||
},
|
||||
"field:email_5139": "supinogenesia@gmail.com",
|
||||
"field:phone_4c77": "+39 340 625 9979",
|
||||
"_context": {
|
||||
"activation": {
|
||||
"id": "2421c9cd-6565-49ba-b60f-165d3dacccba"
|
||||
},
|
||||
"configuration": {
|
||||
"id": "a976f18c-fa86-495d-be1e-676df188eeae"
|
||||
},
|
||||
"app": {
|
||||
"id": "225dd912-7dea-4738-8688-4b8c6955ffc2"
|
||||
},
|
||||
"action": {
|
||||
"id": "152db4d7-5263-40c4-be2b-1c81476318b7"
|
||||
},
|
||||
"trigger": {
|
||||
"key": "wix_form_app-form_submitted"
|
||||
}
|
||||
},
|
||||
"field:gclid": "",
|
||||
"formFieldMask": [
|
||||
"field:",
|
||||
"field:",
|
||||
"field:angebot_auswaehlen",
|
||||
"field:date_picker_a7c8",
|
||||
"field:date_picker_7e65",
|
||||
"field:",
|
||||
"field:number_7cf5",
|
||||
"field:anzahl_kinder",
|
||||
"field:alter_kind_3",
|
||||
"field:alter_kind_25",
|
||||
"field:alter_kind_4",
|
||||
"field:alter_kind_5",
|
||||
"field:alter_kind_6",
|
||||
"field:alter_kind_7",
|
||||
"field:alter_kind_8",
|
||||
"field:alter_kind_9",
|
||||
"field:alter_kind_10",
|
||||
"field:alter_kind_11",
|
||||
"field:",
|
||||
"field:anrede",
|
||||
"field:first_name_abae",
|
||||
"field:last_name_d97c",
|
||||
"field:email_5139",
|
||||
"field:phone_4c77",
|
||||
"field:long_answer_3524",
|
||||
"field:form_field_5a7b",
|
||||
"field:",
|
||||
"field:utm_source",
|
||||
"field:utm_medium",
|
||||
"field:utm_campaign",
|
||||
"field:utm_term",
|
||||
"field:utm_content",
|
||||
"field:utm_term_id",
|
||||
"field:utm_content_id",
|
||||
"field:gad_source",
|
||||
"field:gad_campaignid",
|
||||
"field:gbraid",
|
||||
"field:gclid",
|
||||
"field:fbclid",
|
||||
"field:hotelid",
|
||||
"field:hotelname",
|
||||
"field:",
|
||||
"metaSiteId"
|
||||
],
|
||||
"contact": {
|
||||
"name": {
|
||||
"first": "Genesia",
|
||||
"last": "Supino"
|
||||
},
|
||||
"email": "supinogenesia@gmail.com",
|
||||
"locale": "it-it",
|
||||
"phones": [
|
||||
{
|
||||
"tag": "UNTAGGED",
|
||||
"formattedPhone": "+39 340 625 9979",
|
||||
"id": "198f04fb-5b2c-4a7b-b7ea-adc150ec4212",
|
||||
"countryCode": "IT",
|
||||
"e164Phone": "+393406259979",
|
||||
"primary": true,
|
||||
"phone": "340 625 9979"
|
||||
}
|
||||
],
|
||||
"contactId": "4d695011-36c1-4480-b225-ae9c6eef9e83",
|
||||
"emails": [
|
||||
{
|
||||
"id": "e09d7bab-1f11-4b5d-b3c5-32d43c1dc584",
|
||||
"tag": "UNTAGGED",
|
||||
"email": "supinogenesia@gmail.com",
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"updatedDate": "2025-10-07T05:48:44.764Z",
|
||||
"phone": "+393406259979",
|
||||
"createdDate": "2025-10-07T05:48:43.567Z"
|
||||
},
|
||||
"submissionId": "c52702c9-55b9-44e1-b158-ec9544c73cc7",
|
||||
"field:anzahl_kinder": "1",
|
||||
"field:first_name_abae": "Genesia ",
|
||||
"field:utm_content_id": "120238574626400196",
|
||||
"field:utm_campaign": "Conversions_Hotel_Bemelmans_ITA",
|
||||
"field:utm_term": "Cold_Traffic_Conversions_Hotel_Bemelmans_ITA",
|
||||
"contactId": "4d695011-36c1-4480-b225-ae9c6eef9e83",
|
||||
"field:date_picker_a7c8": "2026-01-02",
|
||||
"field:hotelname": "Bemelmans Post",
|
||||
"field:utm_content": "Grafik_AuszeitDezember_9.12_23.12",
|
||||
"field:last_name_d97c": "Supino ",
|
||||
"field:hotelid": "12345",
|
||||
"submissionsLink": "https://manage.wix.app/forms/submissions/1dea821c-8168-4736-96e4-4b92e8b364cf/e084006b-ae83-4e4d-b2f5-074118cdb3b1?d=https%3A%2F%2Fmanage.wix.com%2Fdashboard%2F1dea821c-8168-4736-96e4-4b92e8b364cf%2Fwix-forms%2Fform%2Fe084006b-ae83-4e4d-b2f5-074118cdb3b1%2Fsubmissions&s=true",
|
||||
"field:gbraid": "",
|
||||
"field:fbclid": "IwZXh0bgNhZW0BMABhZGlkAassWPh1b8QBHoRc2S24gMktdNKiPwEvGYMK3rB-mne_0IJQvQRIGH60wLvLfOm0XWP8wJ9s_aem_rbpAFMODwOh4UnF5UVxwWg",
|
||||
"submissionPdf": {
|
||||
"fileName": "c52702c9-55b9-44e1-b158-ec9544c73cc7.pdf",
|
||||
"downloadUrl": "https://manage.wix.com/_api/form-submission-service/v4/submissions/c52702c9-55b9-44e1-b158-ec9544c73cc7/download?accessToken=JWS.eyJraWQiOiJWLVNuLWhwZSIsImFsZyI6IkhTMjU2In0.eyJkYXRhIjoie1wibWV0YVNpdGVJZFwiOlwiMWRlYTgyMWMtODE2OC00NzM2LTk2ZTQtNGI5MmU4YjM2NGNmXCJ9IiwiaWF0IjoxNzU5ODE2MTI0LCJleHAiOjE3NTk4MTY3MjR9.quBfp9UL9Ddqb2CWERXoVkh9OdmHlIBvlLAyhoXElaY"
|
||||
},
|
||||
"field:anrede": "Frau",
|
||||
"formId": "e084006b-ae83-4e4d-b2f5-074118cdb3b1"
|
||||
}
|
||||
},
|
||||
"origin_header": null,
|
||||
"all_headers": {
|
||||
"host": "localhost:8080",
|
||||
"content-type": "application/json",
|
||||
"user-agent": "insomnia/2023.5.8",
|
||||
"accept": "*/*",
|
||||
"content-length": "7335"
|
||||
}
|
||||
}
|
||||
118
pyproject.toml
118
pyproject.toml
@@ -14,8 +14,11 @@ dependencies = [
|
||||
"dotenv>=0.9.9",
|
||||
"fastapi>=0.117.1",
|
||||
"generateds>=2.44.3",
|
||||
"httpx>=0.28.1",
|
||||
"lxml>=6.0.1",
|
||||
"pydantic[email]>=2.11.9",
|
||||
"pytest>=8.4.2",
|
||||
"pytest-asyncio>=1.2.0",
|
||||
"redis>=6.4.0",
|
||||
"ruff>=0.13.1",
|
||||
"slowapi>=0.1.9",
|
||||
@@ -33,8 +36,119 @@ 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"]
|
||||
src = ["src", "tests"]
|
||||
|
||||
[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
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest-cov>=7.0.0",
|
||||
]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""AlpineBits Python Server package."""
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
"""Entry point for alpine_bits_python package."""
|
||||
|
||||
from .main import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("running test main")
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,178 +0,0 @@
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
# TimeSpan class according to XSD: <TimeSpan Start="..." End="..." Duration="..." StartWindow="..." EndWindow="..."/>
|
||||
class TimeSpan:
|
||||
def __init__(
|
||||
self,
|
||||
start: str,
|
||||
end: str = None,
|
||||
duration: str = None,
|
||||
start_window: str = None,
|
||||
end_window: str = None,
|
||||
):
|
||||
self.start = start
|
||||
self.end = end
|
||||
self.duration = duration
|
||||
self.start_window = start_window
|
||||
self.end_window = end_window
|
||||
|
||||
def to_xml(self):
|
||||
attrib = {"Start": self.start}
|
||||
if self.end:
|
||||
attrib["End"] = self.end
|
||||
if self.duration:
|
||||
attrib["Duration"] = self.duration
|
||||
if self.start_window:
|
||||
attrib["StartWindow"] = self.start_window
|
||||
if self.end_window:
|
||||
attrib["EndWindow"] = self.end_window
|
||||
return ET.Element(_ns("TimeSpan"), attrib)
|
||||
|
||||
|
||||
NAMESPACE = "http://www.opentravel.org/OTA/2003/05"
|
||||
ET.register_namespace("", NAMESPACE)
|
||||
|
||||
|
||||
def _ns(tag):
|
||||
return f"{{{NAMESPACE}}}{tag}"
|
||||
|
||||
|
||||
class ResGuest:
|
||||
def __init__(
|
||||
self,
|
||||
given_name: str,
|
||||
surname: str,
|
||||
gender: Optional[str] = None,
|
||||
birth_date: Optional[str] = None,
|
||||
language: Optional[str] = None,
|
||||
name_prefix: Optional[str] = None,
|
||||
name_title: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
address: Optional[dict] = None,
|
||||
telephones: Optional[list] = None,
|
||||
):
|
||||
self.given_name = given_name
|
||||
self.surname = surname
|
||||
self.gender = gender
|
||||
self.birth_date = birth_date
|
||||
self.language = language
|
||||
self.name_prefix = name_prefix
|
||||
self.name_title = name_title
|
||||
self.email = email
|
||||
self.address = address or {}
|
||||
self.telephones = telephones or []
|
||||
|
||||
def to_xml(self):
|
||||
resguest_elem = ET.Element(_ns("ResGuest"))
|
||||
profiles_elem = ET.SubElement(resguest_elem, _ns("Profiles"))
|
||||
profileinfo_elem = ET.SubElement(profiles_elem, _ns("ProfileInfo"))
|
||||
profile_elem = ET.SubElement(profileinfo_elem, _ns("Profile"))
|
||||
customer_elem = ET.SubElement(profile_elem, _ns("Customer"))
|
||||
if self.gender:
|
||||
customer_elem.set("Gender", self.gender)
|
||||
if self.birth_date:
|
||||
customer_elem.set("BirthDate", self.birth_date)
|
||||
if self.language:
|
||||
customer_elem.set("Language", self.language)
|
||||
personname_elem = ET.SubElement(customer_elem, _ns("PersonName"))
|
||||
if self.name_prefix:
|
||||
ET.SubElement(personname_elem, _ns("NamePrefix")).text = self.name_prefix
|
||||
ET.SubElement(personname_elem, _ns("GivenName")).text = self.given_name
|
||||
ET.SubElement(personname_elem, _ns("Surname")).text = self.surname
|
||||
if self.name_title:
|
||||
ET.SubElement(personname_elem, _ns("NameTitle")).text = self.name_title
|
||||
for tel in self.telephones:
|
||||
tel_elem = ET.SubElement(customer_elem, _ns("Telephone"))
|
||||
for k, v in tel.items():
|
||||
tel_elem.set(k, v)
|
||||
if self.email:
|
||||
ET.SubElement(customer_elem, _ns("Email")).text = self.email
|
||||
if self.address:
|
||||
address_elem = ET.SubElement(customer_elem, _ns("Address"))
|
||||
for k, v in self.address.items():
|
||||
if k == "CountryName":
|
||||
country_elem = ET.SubElement(address_elem, _ns("CountryName"))
|
||||
if isinstance(v, dict):
|
||||
for ck, cv in v.items():
|
||||
country_elem.set(ck, cv)
|
||||
else:
|
||||
country_elem.text = v
|
||||
else:
|
||||
ET.SubElement(address_elem, _ns(k)).text = v
|
||||
return resguest_elem
|
||||
|
||||
def __str__(self):
|
||||
from lxml import etree
|
||||
|
||||
elem = self.to_xml()
|
||||
xml_bytes = ET.tostring(elem, encoding="utf-8")
|
||||
parser = etree.XMLParser(remove_blank_text=True)
|
||||
lxml_elem = etree.fromstring(xml_bytes, parser)
|
||||
return etree.tostring(lxml_elem, pretty_print=True, encoding="unicode")
|
||||
|
||||
|
||||
class RoomStay:
|
||||
def __init__(self, room_type: str, timespan: TimeSpan, guests: List[ResGuest]):
|
||||
self.room_type = room_type
|
||||
self.timespan = timespan
|
||||
self.guests = guests
|
||||
|
||||
def to_xml(self):
|
||||
roomstay_elem = ET.Element(_ns("RoomStay"))
|
||||
ET.SubElement(roomstay_elem, _ns("RoomType")).set(
|
||||
"RoomTypeCode", self.room_type
|
||||
)
|
||||
roomstay_elem.append(self.timespan.to_xml())
|
||||
guests_elem = ET.SubElement(roomstay_elem, _ns("Guests"))
|
||||
for guest in self.guests:
|
||||
guests_elem.append(guest.to_xml())
|
||||
return roomstay_elem
|
||||
|
||||
def __str__(self):
|
||||
from lxml import etree
|
||||
|
||||
elem = self.to_xml()
|
||||
xml_bytes = ET.tostring(elem, encoding="utf-8")
|
||||
parser = etree.XMLParser(remove_blank_text=True)
|
||||
lxml_elem = etree.fromstring(xml_bytes, parser)
|
||||
return etree.tostring(lxml_elem, pretty_print=True, encoding="unicode")
|
||||
|
||||
|
||||
class Reservation:
|
||||
def __init__(
|
||||
self,
|
||||
reservation_id: str,
|
||||
hotel_code: str,
|
||||
roomstays: List[RoomStay],
|
||||
create_time: Optional[str] = None,
|
||||
):
|
||||
self.reservation_id = reservation_id
|
||||
self.hotel_code = hotel_code
|
||||
self.roomstays = roomstays
|
||||
self.create_time = create_time or datetime.now(timezone.utc).isoformat()
|
||||
|
||||
def to_xml(self):
|
||||
res_elem = ET.Element(_ns("HotelReservation"))
|
||||
uniqueid_elem = ET.SubElement(res_elem, _ns("UniqueID"))
|
||||
uniqueid_elem.set("Type", "14")
|
||||
uniqueid_elem.set("ID", self.reservation_id)
|
||||
hotel_elem = ET.SubElement(res_elem, _ns("Hotel"))
|
||||
hotel_elem.set("HotelCode", self.hotel_code)
|
||||
roomstays_elem = ET.SubElement(res_elem, _ns("RoomStays"))
|
||||
for rs in self.roomstays:
|
||||
roomstays_elem.append(rs.to_xml())
|
||||
res_elem.set("CreateDateTime", self.create_time)
|
||||
return res_elem
|
||||
|
||||
def to_xml_string(self):
|
||||
root = ET.Element(
|
||||
_ns("OTA_ResRetrieveRS"),
|
||||
{"Version": "2024-10", "TimeStamp": datetime.now(timezone.utc).isoformat()},
|
||||
)
|
||||
success_elem = ET.SubElement(root, _ns("Success"))
|
||||
reservations_list = ET.SubElement(root, _ns("ReservationsList"))
|
||||
reservations_list.append(self.to_xml())
|
||||
return ET.tostring(root, encoding="utf-8", xml_declaration=True).decode("utf-8")
|
||||
@@ -1,41 +1,42 @@
|
||||
"""
|
||||
AlpineBits Server for handling hotel data exchange.
|
||||
"""AlpineBits Server for handling hotel data exchange.
|
||||
|
||||
This module provides an asynchronous AlpineBits server that can handle various
|
||||
OTA (OpenTravel Alliance) actions for hotel data exchange. Currently implements
|
||||
handshaking functionality with configurable supported actions and capabilities.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
import difflib
|
||||
import json
|
||||
import inspect
|
||||
import json
|
||||
import re
|
||||
from typing import Dict, List, Optional, Any, Union, Tuple, Type
|
||||
from xml.etree import ElementTree as ET
|
||||
from abc import ABC
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import Enum, IntEnum
|
||||
from typing import Any, Optional, override
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from alpine_bits_python.alpine_bits_helpers import PhoneTechType, create_xml_from_db
|
||||
|
||||
|
||||
|
||||
from .generated.alpinebits import OtaPingRq, OtaPingRs, WarningStatus, OtaReadRq
|
||||
from xsdata_pydantic.bindings import XmlSerializer
|
||||
from xsdata.formats.dataclass.serializers.config import SerializerConfig
|
||||
from abc import ABC, abstractmethod
|
||||
from xsdata_pydantic.bindings import XmlParser
|
||||
import logging
|
||||
from .db import Reservation, Customer
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import joinedload
|
||||
from xsdata.formats.dataclass.serializers.config import SerializerConfig
|
||||
from xsdata_pydantic.bindings import XmlParser, XmlSerializer
|
||||
|
||||
from alpine_bits_python.alpine_bits_helpers import (
|
||||
create_res_notif_push_message,
|
||||
create_res_retrieve_response,
|
||||
)
|
||||
from alpine_bits_python.logging_config import get_logger
|
||||
|
||||
from .db import AckedRequest, Customer, Reservation
|
||||
from .generated.alpinebits import (
|
||||
OtaNotifReportRq,
|
||||
OtaNotifReportRs,
|
||||
OtaPingRq,
|
||||
OtaPingRs,
|
||||
OtaReadRq,
|
||||
WarningStatus,
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_LOGGER = get_logger(__name__)
|
||||
|
||||
|
||||
class HttpStatusCode(IntEnum):
|
||||
@@ -47,6 +48,14 @@ class HttpStatusCode(IntEnum):
|
||||
INTERNAL_SERVER_ERROR = 500
|
||||
|
||||
|
||||
def dump_json_for_xml(json_content: Any) -> str:
|
||||
"""Dump JSON content as a pretty-printed string for embedding in XML.
|
||||
|
||||
Adds newlines before and after the JSON block for better readability in XML.
|
||||
"""
|
||||
return json.dumps(json_content)
|
||||
|
||||
|
||||
class AlpineBitsActionName(Enum):
|
||||
"""Enum for AlpineBits action names with capability and request name mappings."""
|
||||
|
||||
@@ -54,10 +63,14 @@ class AlpineBitsActionName(Enum):
|
||||
OTA_PING = ("action_OTA_Ping", "OTA_Ping:Handshaking")
|
||||
OTA_READ = ("action_OTA_Read", "OTA_Read:GuestRequests")
|
||||
OTA_HOTEL_AVAIL_NOTIF = ("action_OTA_HotelAvailNotif", "OTA_HotelAvailNotif")
|
||||
OTA_HOTEL_RES_NOTIF_GUEST_REQUESTS = (
|
||||
OTA_HOTEL_RES_NOTIF_GUEST_REQUESTS = ( ## Push Action for Guest Requests
|
||||
"action_OTA_HotelResNotif_GuestRequests",
|
||||
"OTA_HotelResNotif:GuestRequests",
|
||||
)
|
||||
OTA_HOTEL_NOTIF_REPORT = (
|
||||
"action_OTA_Read", # if read is supported this is also supported
|
||||
"OTA_NotifReport:GuestRequests",
|
||||
)
|
||||
OTA_HOTEL_DESCRIPTIVE_CONTENT_NOTIF_INVENTORY = (
|
||||
"action_OTA_HotelDescriptiveContentNotif_Inventory",
|
||||
"OTA_HotelDescriptiveContentNotif:Inventory",
|
||||
@@ -114,6 +127,15 @@ class Version(str, Enum):
|
||||
# Add other versions as needed
|
||||
|
||||
|
||||
class AlpineBitsClientInfo:
|
||||
"""Wrapper for username, password, client_id."""
|
||||
|
||||
def __init__(self, username: str, password: str, client_id: str | None = None):
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.client_id = client_id
|
||||
|
||||
|
||||
@dataclass
|
||||
class AlpineBitsResponse:
|
||||
"""Response data structure for AlpineBits actions."""
|
||||
@@ -139,10 +161,15 @@ class AlpineBitsAction(ABC):
|
||||
) # list of versions in case action supports multiple versions
|
||||
|
||||
async def handle(
|
||||
self, action: str, request_xml: str, version: Version, dbsession=None, server_capabilities=None, username=None, password=None, config: Dict = None
|
||||
self,
|
||||
action: str,
|
||||
request_xml: str,
|
||||
version: Version,
|
||||
client_info: AlpineBitsClientInfo,
|
||||
dbsession=None,
|
||||
server_capabilities=None,
|
||||
) -> AlpineBitsResponse:
|
||||
"""
|
||||
Handle the incoming request XML and return response XML.
|
||||
"""Handle the incoming request XML and return response XML.
|
||||
|
||||
Default implementation returns "not implemented" error.
|
||||
Override this method in subclasses to provide actual functionality.
|
||||
@@ -154,18 +181,19 @@ class AlpineBitsAction(ABC):
|
||||
|
||||
Returns:
|
||||
AlpineBitsResponse with error or actual response
|
||||
|
||||
"""
|
||||
return_string = f"Error: Action {action} not implemented"
|
||||
return AlpineBitsResponse(return_string, HttpStatusCode.BAD_REQUEST)
|
||||
|
||||
async def check_version_supported(self, version: Version) -> bool:
|
||||
"""
|
||||
Check if the action supports the given version.
|
||||
"""Check if the action supports the given version.
|
||||
|
||||
Args:
|
||||
version: The AlpineBits version to check
|
||||
Returns:
|
||||
True if supported, False otherwise
|
||||
|
||||
"""
|
||||
if isinstance(self.version, list):
|
||||
return version in self.version
|
||||
@@ -173,12 +201,10 @@ class AlpineBitsAction(ABC):
|
||||
|
||||
|
||||
class ServerCapabilities:
|
||||
"""
|
||||
Automatically discovers AlpineBitsAction implementations and generates capabilities.
|
||||
"""
|
||||
"""Automatically discovers AlpineBitsAction implementations and generates capabilities."""
|
||||
|
||||
def __init__(self):
|
||||
self.action_registry: Dict[str, Type[AlpineBitsAction]] = {}
|
||||
self.action_registry: dict[AlpineBitsActionName, type[AlpineBitsAction]] = {}
|
||||
self._discover_actions()
|
||||
self.capability_dict = None
|
||||
|
||||
@@ -186,7 +212,7 @@ class ServerCapabilities:
|
||||
"""Discover all AlpineBitsAction implementations in the current module."""
|
||||
current_module = inspect.getmodule(self)
|
||||
|
||||
for name, obj in inspect.getmembers(current_module):
|
||||
for _name, obj in inspect.getmembers(current_module):
|
||||
if (
|
||||
inspect.isclass(obj)
|
||||
and issubclass(obj, AlpineBitsAction)
|
||||
@@ -196,27 +222,21 @@ class ServerCapabilities:
|
||||
if self._is_action_implemented(obj):
|
||||
action_instance = obj()
|
||||
if hasattr(action_instance, "name"):
|
||||
# Use capability name for the registry key
|
||||
self.action_registry[action_instance.name.capability_name] = obj
|
||||
# Use capability attribute as registry key
|
||||
self.action_registry[action_instance.name] = obj
|
||||
|
||||
def _is_action_implemented(self, action_class: Type[AlpineBitsAction]) -> bool:
|
||||
"""
|
||||
Check if an action is actually implemented or just uses the default behavior.
|
||||
def _is_action_implemented(self, action_class: type[AlpineBitsAction]) -> bool:
|
||||
"""Check if an action is actually implemented or just uses the default behavior.
|
||||
This is a simple check - in practice, you might want more sophisticated detection.
|
||||
"""
|
||||
# Check if the class has overridden the handle method
|
||||
if "handle" in action_class.__dict__:
|
||||
return True
|
||||
return False
|
||||
return "handle" in action_class.__dict__
|
||||
|
||||
def create_capabilities_dict(self) -> None:
|
||||
"""
|
||||
Generate the capabilities dictionary based on discovered actions.
|
||||
|
||||
"""
|
||||
"""Generate the capabilities dictionary based on discovered actions."""
|
||||
versions_dict = {}
|
||||
|
||||
for action_name, action_class in self.action_registry.items():
|
||||
for action_enum, action_class in self.action_registry.items():
|
||||
action_instance = action_class()
|
||||
|
||||
# Get supported versions for this action
|
||||
@@ -232,7 +252,7 @@ class ServerCapabilities:
|
||||
if version_str not in versions_dict:
|
||||
versions_dict[version_str] = {"version": version_str, "actions": []}
|
||||
|
||||
action_dict = {"action": action_name}
|
||||
action_dict = {"action": action_enum.capability_name}
|
||||
|
||||
# Add supports field if the action has custom supports
|
||||
if hasattr(action_instance, "supports") and action_instance.supports:
|
||||
@@ -242,22 +262,32 @@ class ServerCapabilities:
|
||||
|
||||
self.capability_dict = {"versions": list(versions_dict.values())}
|
||||
|
||||
return None
|
||||
# filter duplicates in actions for each version
|
||||
for version in self.capability_dict["versions"]:
|
||||
seen_actions = set()
|
||||
unique_actions = []
|
||||
for action in version["actions"]:
|
||||
if action["action"] not in seen_actions:
|
||||
seen_actions.add(action["action"])
|
||||
unique_actions.append(action)
|
||||
version["actions"] = unique_actions
|
||||
|
||||
def get_capabilities_dict(self) -> Dict:
|
||||
"""
|
||||
Get capabilities as a dictionary. Generates if not already created.
|
||||
"""
|
||||
# remove action_OTA_Ping from version 2024-10
|
||||
for version in self.capability_dict["versions"]:
|
||||
if version["version"] == "2024-10":
|
||||
version["actions"] = [
|
||||
action
|
||||
for action in version["actions"]
|
||||
if action.get("action") != "action_OTA_Ping"
|
||||
]
|
||||
|
||||
def get_capabilities_dict(self) -> dict:
|
||||
"""Get capabilities as a dictionary. Generates if not already created."""
|
||||
if self.capability_dict is None:
|
||||
self.create_capabilities_dict()
|
||||
return self.capability_dict
|
||||
|
||||
def get_capabilities_json(self) -> str:
|
||||
"""Get capabilities as formatted JSON string."""
|
||||
return json.dumps(self.get_capabilities_dict(), indent=2)
|
||||
|
||||
def get_supported_actions(self) -> List[str]:
|
||||
def get_supported_actions(self) -> list[str]:
|
||||
"""Get list of all supported action names."""
|
||||
return list(self.action_registry.keys())
|
||||
|
||||
@@ -268,7 +298,7 @@ class ServerCapabilities:
|
||||
class PingAction(AlpineBitsAction):
|
||||
"""Implementation for OTA_Ping action (handshaking)."""
|
||||
|
||||
def __init__(self, config: Dict = None):
|
||||
def __init__(self, config: dict = {}):
|
||||
self.name = AlpineBitsActionName.OTA_PING
|
||||
self.version = [
|
||||
Version.V2024_10,
|
||||
@@ -276,18 +306,19 @@ class PingAction(AlpineBitsAction):
|
||||
] # Supports multiple versions
|
||||
self.config = config
|
||||
|
||||
@override
|
||||
async def handle(
|
||||
self,
|
||||
action: str,
|
||||
request_xml: str,
|
||||
version: Version,
|
||||
client_info: AlpineBitsClientInfo,
|
||||
server_capabilities: None | ServerCapabilities = None,
|
||||
) -> AlpineBitsResponse:
|
||||
"""Handle ping requests."""
|
||||
|
||||
if request_xml is None:
|
||||
return AlpineBitsResponse(
|
||||
f"Error: Xml Request missing", HttpStatusCode.BAD_REQUEST
|
||||
"Error: Xml Request missing", HttpStatusCode.BAD_REQUEST
|
||||
)
|
||||
|
||||
if server_capabilities is None:
|
||||
@@ -301,20 +332,20 @@ class PingAction(AlpineBitsAction):
|
||||
try:
|
||||
parsed_request = parser.from_string(request_xml, OtaPingRq)
|
||||
|
||||
echo_data = json.loads(parsed_request.echo_data)
|
||||
except Exception as e:
|
||||
echo_data_client = json.loads(parsed_request.echo_data)
|
||||
except Exception:
|
||||
return AlpineBitsResponse(
|
||||
f"Error: Invalid XML request", HttpStatusCode.BAD_REQUEST
|
||||
"Error: Invalid XML request", HttpStatusCode.BAD_REQUEST
|
||||
)
|
||||
|
||||
# compare echo data with capabilities, create a dictionary containing the matching capabilities
|
||||
capabilities_dict = server_capabilities.get_capabilities_dict()
|
||||
|
||||
_LOGGER.info(f"Capabilities Dict: {capabilities_dict}")
|
||||
_LOGGER.debug("Capabilities of Server: %s", capabilities_dict)
|
||||
matching_capabilities = {"versions": []}
|
||||
|
||||
# Iterate through client's requested versions
|
||||
for client_version in echo_data.get("versions", []):
|
||||
for client_version in echo_data_client.get("versions", []):
|
||||
client_version_str = client_version.get("version", "")
|
||||
|
||||
# Find matching server version
|
||||
@@ -349,22 +380,22 @@ class PingAction(AlpineBitsAction):
|
||||
# Debug print to see what we matched
|
||||
|
||||
# Create successful ping response with matched capabilities
|
||||
capabilities_json = json.dumps(matching_capabilities, indent=2)
|
||||
capabilities_json_str = dump_json_for_xml(matching_capabilities)
|
||||
|
||||
warning = OtaPingRs.Warnings.Warning(
|
||||
status=WarningStatus.ALPINEBITS_HANDSHAKE.value,
|
||||
status=WarningStatus.ALPINEBITS_HANDSHAKE,
|
||||
type_value="11",
|
||||
content=[capabilities_json],
|
||||
content=[capabilities_json_str],
|
||||
)
|
||||
|
||||
warning_response = OtaPingRs.Warnings(warning=[warning])
|
||||
|
||||
all_capabilities = server_capabilities.get_capabilities_json()
|
||||
client_response_echo_data = parsed_request.echo_data
|
||||
|
||||
response_ota_ping = OtaPingRs(
|
||||
version="7.000",
|
||||
warnings=warning_response,
|
||||
echo_data=all_capabilities,
|
||||
echo_data=client_response_echo_data,
|
||||
success="",
|
||||
)
|
||||
|
||||
@@ -379,21 +410,25 @@ class PingAction(AlpineBitsAction):
|
||||
)
|
||||
|
||||
return AlpineBitsResponse(response_xml, HttpStatusCode.OK)
|
||||
|
||||
|
||||
def strip_control_chars(s):
|
||||
# Remove all control characters (ASCII < 32 and DEL)
|
||||
return re.sub(r'[\x00-\x1F\x7F]', '', s)
|
||||
return re.sub(r"[\x00-\x1F\x7F]", "", s)
|
||||
|
||||
def validate_hotel_authentication(username: str, password: str, hotelid: str, config: Dict) -> bool:
|
||||
""" Validate hotel authentication based on username, password, and hotel ID.
|
||||
|
||||
Example config
|
||||
alpine_bits_auth:
|
||||
- hotel_id: "123"
|
||||
hotel_name: "Frangart Inn"
|
||||
username: "alice"
|
||||
password: !secret ALICE_PASSWORD
|
||||
def validate_hotel_authentication(
|
||||
username: str, password: str, hotelid: str, config: dict
|
||||
) -> bool:
|
||||
"""Validate hotel authentication based on username, password, and hotel ID.
|
||||
|
||||
Example config
|
||||
alpine_bits_auth:
|
||||
- hotel_id: "123"
|
||||
hotel_name: "Frangart Inn"
|
||||
username: "alice"
|
||||
password: !secret ALICE_PASSWORD
|
||||
"""
|
||||
|
||||
if not config or "alpine_bits_auth" not in config:
|
||||
return False
|
||||
auth_list = config["alpine_bits_auth"]
|
||||
@@ -409,37 +444,38 @@ def validate_hotel_authentication(username: str, password: str, hotelid: str, co
|
||||
# look for hotelid in config
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class ReadAction(AlpineBitsAction):
|
||||
"""Implementation for OTA_Read action."""
|
||||
|
||||
def __init__(self, config: Dict = None):
|
||||
def __init__(self, config: dict = {}):
|
||||
self.name = AlpineBitsActionName.OTA_READ
|
||||
self.version = [Version.V2024_10, Version.V2022_10]
|
||||
self.config = config
|
||||
|
||||
async def handle(
|
||||
self, action: str, request_xml: str, version: Version, dbsession=None, username=None, password=None
|
||||
self,
|
||||
action: str,
|
||||
request_xml: str,
|
||||
version: Version,
|
||||
client_info: AlpineBitsClientInfo,
|
||||
dbsession=None,
|
||||
server_capabilities=None,
|
||||
) -> AlpineBitsResponse:
|
||||
"""Handle read requests."""
|
||||
|
||||
clean_action = strip_control_chars(str(action)).strip()
|
||||
clean_expected = strip_control_chars(self.name.value[1]).strip()
|
||||
|
||||
if clean_action != clean_expected:
|
||||
|
||||
return AlpineBitsResponse(
|
||||
f"Error: Invalid action {action}, expected {self.name.value[1]}", HttpStatusCode.BAD_REQUEST
|
||||
f"Error: Invalid action {action}, expected {self.name.value[1]}",
|
||||
HttpStatusCode.BAD_REQUEST,
|
||||
)
|
||||
|
||||
if dbsession is None:
|
||||
return AlpineBitsResponse(
|
||||
"Error: Something went wrong", HttpStatusCode.INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
read_request = XmlParser().from_string(request_xml, OtaReadRq)
|
||||
|
||||
hotel_read_request = read_request.read_requests.hotel_read_request
|
||||
@@ -450,22 +486,29 @@ class ReadAction(AlpineBitsAction):
|
||||
if hotelname is None:
|
||||
hotelname = "unknown"
|
||||
|
||||
if username is None or password is None or hotelid is None:
|
||||
if hotelid is None:
|
||||
return AlpineBitsResponse(
|
||||
f"Error: Unauthorized Read Request for this specific hotel {hotelname}. Check credentials", HttpStatusCode.UNAUTHORIZED
|
||||
"Error: Unauthorized Read Request. No target hotel specified. Check credentials",
|
||||
HttpStatusCode.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
if not validate_hotel_authentication(username, password, hotelid, self.config):
|
||||
if not validate_hotel_authentication(
|
||||
client_info.username, client_info.password, hotelid, self.config
|
||||
):
|
||||
return AlpineBitsResponse(
|
||||
f"Error: Unauthorized Read Request for this specific hotel {hotelname}. Check credentials", HttpStatusCode.UNAUTHORIZED
|
||||
f"Error: Unauthorized Read Request for this specific hotel {hotelname}. Check credentials",
|
||||
HttpStatusCode.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
|
||||
start_date = None
|
||||
|
||||
|
||||
"""When given, the server will send only inquiries generated after the Start timestamp, regardless
|
||||
whether the client has retrieved them before or not."""
|
||||
|
||||
if hotel_read_request.selection_criteria is not None:
|
||||
start_date = datetime.fromisoformat(hotel_read_request.selection_criteria.start)
|
||||
|
||||
|
||||
start_date = datetime.fromisoformat(
|
||||
hotel_read_request.selection_criteria.start
|
||||
)
|
||||
|
||||
# query all reservations for this hotel from the database, where start_date is greater than or equal to the given start_date
|
||||
|
||||
@@ -475,16 +518,40 @@ class ReadAction(AlpineBitsAction):
|
||||
.filter(Reservation.hotel_code == hotelid)
|
||||
)
|
||||
if start_date:
|
||||
stmt = stmt.filter(Reservation.start_date >= start_date)
|
||||
_LOGGER.info("Filtering reservations from start date %s", start_date)
|
||||
stmt = stmt.filter(Reservation.created_at >= start_date)
|
||||
# remove reservations that have been acknowledged via client_id
|
||||
elif client_info.client_id:
|
||||
subquery = (
|
||||
select(Reservation.id)
|
||||
.join(
|
||||
AckedRequest,
|
||||
Reservation.md5_unique_id == AckedRequest.unique_id,
|
||||
)
|
||||
.filter(AckedRequest.client_id == client_info.client_id)
|
||||
)
|
||||
stmt = stmt.filter(~Reservation.id.in_(subquery))
|
||||
|
||||
result = await dbsession.execute(stmt)
|
||||
reservation_customer_pairs: list[tuple[Reservation, Customer]] = result.all() # List of (Reservation, Customer) tuples
|
||||
reservation_customer_pairs: list[tuple[Reservation, Customer]] = (
|
||||
result.all()
|
||||
) # List of (Reservation, Customer) tuples
|
||||
|
||||
_LOGGER.info(f"Querying reservations and customers for hotel {hotelid} from database")
|
||||
_LOGGER.info(
|
||||
"Querying reservations and customers for hotel %s from database",
|
||||
hotelid,
|
||||
)
|
||||
for reservation, customer in reservation_customer_pairs:
|
||||
_LOGGER.info(f"Reservation: {reservation.id}, Customer: {customer.given_name}")
|
||||
|
||||
res_retrive_rs = create_xml_from_db(reservation_customer_pairs)
|
||||
_LOGGER.info(
|
||||
"Retrieving reservation %s for customer %s %s",
|
||||
reservation.id,
|
||||
customer.given_name,
|
||||
customer.surname,
|
||||
)
|
||||
|
||||
res_retrive_rs = create_res_retrieve_response(
|
||||
reservation_customer_pairs, config=self.config
|
||||
)
|
||||
|
||||
config = SerializerConfig(
|
||||
pretty_print=True, xml_declaration=True, encoding="UTF-8"
|
||||
@@ -495,114 +562,152 @@ class ReadAction(AlpineBitsAction):
|
||||
)
|
||||
|
||||
return AlpineBitsResponse(response_xml, HttpStatusCode.OK)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class NotifReportReadAction(AlpineBitsAction):
|
||||
"""Necessary for read action to follow specification. Clients need to report acknowledgements."""
|
||||
|
||||
|
||||
def __init__(self, config: dict = {}):
|
||||
self.name = AlpineBitsActionName.OTA_HOTEL_NOTIF_REPORT
|
||||
self.version = [Version.V2024_10, Version.V2022_10]
|
||||
self.config = config
|
||||
|
||||
|
||||
async def handle(
|
||||
self,
|
||||
action: str,
|
||||
request_xml: str,
|
||||
version: Version,
|
||||
client_info: AlpineBitsClientInfo,
|
||||
dbsession=None,
|
||||
server_capabilities=None,
|
||||
) -> AlpineBitsResponse:
|
||||
"""Handle read requests."""
|
||||
notif_report = XmlParser().from_string(request_xml, OtaNotifReportRq)
|
||||
|
||||
|
||||
# we can't check hotel auth here, because this action does not contain hotel info
|
||||
|
||||
warnings = notif_report.warnings
|
||||
notif_report_details = notif_report.notif_details
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# For demonstration, just echo back a simple XML response
|
||||
response_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<OTA_ReadRS xmlns="http://www.opentravel.org/OTA/2003/
|
||||
05" Version="8.000">
|
||||
<Success/>
|
||||
</OTA_ReadRS>"""
|
||||
success_message = OtaNotifReportRs(version="7.000", success="")
|
||||
|
||||
if client_info.client_id is None:
|
||||
return AlpineBitsResponse(
|
||||
"ERROR:no valid client id provided", HttpStatusCode.BAD_REQUEST
|
||||
)
|
||||
|
||||
config = SerializerConfig(
|
||||
pretty_print=True, xml_declaration=True, encoding="UTF-8"
|
||||
)
|
||||
serializer = XmlSerializer(config=config)
|
||||
response_xml = serializer.render(
|
||||
success_message, ns_map={None: "http://www.opentravel.org/OTA/2003/05"}
|
||||
)
|
||||
|
||||
if (warnings is None and notif_report_details is None) or (
|
||||
notif_report_details is not None
|
||||
and notif_report_details.hotel_notif_report is None
|
||||
):
|
||||
return AlpineBitsResponse(
|
||||
response_xml, HttpStatusCode.OK
|
||||
) # Nothing to process
|
||||
if dbsession is None:
|
||||
return AlpineBitsResponse(
|
||||
"Error: Something went wrong", HttpStatusCode.INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
timestamp = datetime.now(ZoneInfo("UTC"))
|
||||
for entry in (
|
||||
notif_report_details.hotel_notif_report.hotel_reservations.hotel_reservation
|
||||
): # type: ignore
|
||||
unique_id = entry.unique_id.id
|
||||
acked_request = AckedRequest(
|
||||
unique_id=unique_id,
|
||||
client_id=client_info.client_id,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
dbsession.add(acked_request)
|
||||
|
||||
await dbsession.commit()
|
||||
|
||||
return AlpineBitsResponse(response_xml, HttpStatusCode.OK)
|
||||
|
||||
|
||||
# class HotelAvailNotifAction(AlpineBitsAction):
|
||||
# """Implementation for Hotel Availability Notification action with supports."""
|
||||
class PushAction(AlpineBitsAction):
|
||||
"""Creates the necessary xml for OTA_HotelResNotif:GuestRequests."""
|
||||
|
||||
# def __init__(self):
|
||||
# self.name = AlpineBitsActionName.OTA_HOTEL_AVAIL_NOTIF
|
||||
# self.version = Version.V2022_10
|
||||
# self.supports = [
|
||||
# "OTA_HotelAvailNotif_accept_rooms",
|
||||
# "OTA_HotelAvailNotif_accept_categories",
|
||||
# "OTA_HotelAvailNotif_accept_deltas",
|
||||
# "OTA_HotelAvailNotif_accept_BookingThreshold",
|
||||
# ]
|
||||
|
||||
# async def handle(
|
||||
# self, action: str, request_xml: str, version: Version
|
||||
# ) -> AlpineBitsResponse:
|
||||
# """Handle hotel availability notifications."""
|
||||
# response_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
# <OTA_HotelAvailNotifRS xmlns="http://www.opentravel.org/OTA/2003/05" Version="8.000">
|
||||
# <Success/>
|
||||
# </OTA_HotelAvailNotifRS>"""
|
||||
# return AlpineBitsResponse(response_xml, HttpStatusCode.OK)
|
||||
|
||||
|
||||
class GuestRequestsAction(AlpineBitsAction):
|
||||
"""Unimplemented action - will not appear in capabilities."""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, config: dict = {}):
|
||||
self.name = AlpineBitsActionName.OTA_HOTEL_RES_NOTIF_GUEST_REQUESTS
|
||||
self.version = Version.V2024_10
|
||||
self.version = [Version.V2024_10, Version.V2022_10]
|
||||
self.config = config
|
||||
|
||||
# Note: This class doesn't override the handle method, so it won't be discovered
|
||||
async def handle(
|
||||
self,
|
||||
action: str,
|
||||
request_xml: tuple[Reservation, Customer],
|
||||
version: Version,
|
||||
client_info: AlpineBitsClientInfo,
|
||||
dbsession=None,
|
||||
server_capabilities=None,
|
||||
) -> AlpineBitsResponse:
|
||||
"""Create push request XML."""
|
||||
xml_push_request = create_res_notif_push_message(
|
||||
request_xml, config=self.config
|
||||
)
|
||||
|
||||
config = SerializerConfig(
|
||||
pretty_print=True, xml_declaration=True, encoding="UTF-8"
|
||||
)
|
||||
serializer = XmlSerializer(config=config)
|
||||
xml_push_request = serializer.render(
|
||||
xml_push_request, ns_map={None: "http://www.opentravel.org/OTA/2003/05"}
|
||||
)
|
||||
|
||||
return AlpineBitsResponse(xml_push_request, HttpStatusCode.OK)
|
||||
|
||||
|
||||
class AlpineBitsServer:
|
||||
"""
|
||||
Asynchronous AlpineBits server for handling hotel data exchange requests.
|
||||
"""Asynchronous AlpineBits server for handling hotel data exchange requests.
|
||||
|
||||
This server handles various OTA actions and implements the AlpineBits protocol
|
||||
for hotel data exchange. It maintains a registry of supported actions and
|
||||
their capabilities, and can respond to handshake requests with its capabilities.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict = None):
|
||||
def __init__(self, config: dict | None = None):
|
||||
self.capabilities = ServerCapabilities()
|
||||
self._action_instances = {}
|
||||
self.config = config
|
||||
self._initialize_action_instances()
|
||||
|
||||
|
||||
def _initialize_action_instances(self):
|
||||
"""Initialize instances of all discovered action classes."""
|
||||
for capability_name, action_class in self.capabilities.action_registry.items():
|
||||
_LOGGER.info(f"Initializing action instance for {capability_name}")
|
||||
self._action_instances[capability_name] = action_class(config=self.config)
|
||||
|
||||
def get_capabilities(self) -> Dict:
|
||||
def get_capabilities(self) -> dict:
|
||||
"""Get server capabilities."""
|
||||
return self.capabilities.get_capabilities_dict()
|
||||
|
||||
def get_capabilities_json(self) -> str:
|
||||
"""Get server capabilities as JSON."""
|
||||
return self.capabilities.get_capabilities_json()
|
||||
|
||||
async def handle_request(
|
||||
self, request_action_name: str, request_xml: str, version: str = "2024-10", dbsession=None, username=None, password=None
|
||||
self,
|
||||
request_action_name: str,
|
||||
request_xml: str | tuple[Reservation, Customer],
|
||||
client_info: AlpineBitsClientInfo,
|
||||
version: str = "2024-10",
|
||||
dbsession=None,
|
||||
) -> AlpineBitsResponse:
|
||||
"""
|
||||
Handle an incoming AlpineBits request by routing to appropriate action handler.
|
||||
"""Handle an incoming AlpineBits request by routing to appropriate action handler.
|
||||
|
||||
Args:
|
||||
request_action_name: The action name from the request (e.g., "OTA_Read:GuestRequests")
|
||||
request_xml: The XML request body
|
||||
request_xml: The XML request body. Gets passed to the action handler. In case of PushRequest can be the data to be pushed
|
||||
version: The AlpineBits version (defaults to "2024-10")
|
||||
|
||||
Returns:
|
||||
AlpineBitsResponse with the result
|
||||
|
||||
"""
|
||||
# Convert string version to enum
|
||||
try:
|
||||
@@ -614,6 +719,10 @@ class AlpineBitsServer:
|
||||
|
||||
# Find the action by request name
|
||||
action_enum = AlpineBitsActionName.get_by_request_name(request_action_name)
|
||||
|
||||
_LOGGER.info(
|
||||
f"Handling request for action: {request_action_name} with action enum: {action_enum}"
|
||||
)
|
||||
if not action_enum:
|
||||
return AlpineBitsResponse(
|
||||
f"Error: Unknown action {request_action_name}",
|
||||
@@ -621,14 +730,14 @@ class AlpineBitsServer:
|
||||
)
|
||||
|
||||
# Check if we have an implementation for this action
|
||||
capability_name = action_enum.capability_name
|
||||
if capability_name not in self._action_instances:
|
||||
|
||||
if action_enum not in self._action_instances:
|
||||
return AlpineBitsResponse(
|
||||
f"Error: Action {request_action_name} is not implemented",
|
||||
HttpStatusCode.BAD_REQUEST,
|
||||
)
|
||||
|
||||
action_instance: AlpineBitsAction = self._action_instances[capability_name]
|
||||
action_instance: AlpineBitsAction = self._action_instances[action_enum]
|
||||
|
||||
# Check if the action supports the requested version
|
||||
if not await action_instance.check_version_supported(version_enum):
|
||||
@@ -640,39 +749,59 @@ class AlpineBitsServer:
|
||||
# Handle the request
|
||||
try:
|
||||
# Special case for ping action - pass server capabilities
|
||||
if capability_name == "action_OTA_Ping":
|
||||
|
||||
if action_enum == AlpineBitsActionName.OTA_HOTEL_RES_NOTIF_GUEST_REQUESTS:
|
||||
action_instance: PushAction
|
||||
if request_xml is None or not isinstance(request_xml, tuple):
|
||||
return AlpineBitsResponse(
|
||||
"Error: Invalid data for push request",
|
||||
HttpStatusCode.BAD_REQUEST,
|
||||
)
|
||||
return await action_instance.handle(
|
||||
request_action_name, request_xml, version_enum, self.capabilities
|
||||
action=request_action_name,
|
||||
request_xml=request_xml,
|
||||
version=version_enum,
|
||||
client_info=client_info,
|
||||
)
|
||||
else:
|
||||
|
||||
if action_enum == AlpineBitsActionName.OTA_PING:
|
||||
return await action_instance.handle(
|
||||
request_action_name, request_xml, version_enum, dbsession=dbsession, username=username, password=password
|
||||
action=request_action_name,
|
||||
request_xml=request_xml,
|
||||
version=version_enum,
|
||||
server_capabilities=self.capabilities,
|
||||
client_info=client_info,
|
||||
)
|
||||
return await action_instance.handle(
|
||||
action=request_action_name,
|
||||
request_xml=request_xml,
|
||||
version=version_enum,
|
||||
dbsession=dbsession,
|
||||
client_info=client_info,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error handling request {request_action_name}: {str(e)}")
|
||||
# print stack trace for debugging
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return AlpineBitsResponse(
|
||||
f"Error: Internal server error while processing {request_action_name}: {str(e)}",
|
||||
f"Error: Internal server error while processing {request_action_name}: {e!s}",
|
||||
HttpStatusCode.INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
def get_supported_request_names(self) -> List[str]:
|
||||
def get_supported_request_names(self) -> list[str]:
|
||||
"""Get all supported request names (not capability names)."""
|
||||
request_names = []
|
||||
for capability_name in self._action_instances.keys():
|
||||
for capability_name in self._action_instances:
|
||||
action_enum = AlpineBitsActionName.get_by_capability_name(capability_name)
|
||||
if action_enum:
|
||||
request_names.append(action_enum.request_name)
|
||||
return sorted(request_names)
|
||||
|
||||
def is_action_supported(
|
||||
self, request_action_name: str, version: str = None
|
||||
self, request_action_name: str, version: str | None = None
|
||||
) -> bool:
|
||||
"""
|
||||
Check if a request action is supported.
|
||||
"""Check if a request action is supported.
|
||||
|
||||
Args:
|
||||
request_action_name: The request action name (e.g., "OTA_Read:GuestRequests")
|
||||
@@ -680,6 +809,7 @@ class AlpineBitsServer:
|
||||
|
||||
Returns:
|
||||
True if supported, False otherwise
|
||||
|
||||
"""
|
||||
action_enum = AlpineBitsActionName.get_by_request_name(request_action_name)
|
||||
if not action_enum:
|
||||
@@ -696,76 +826,8 @@ class AlpineBitsServer:
|
||||
# This would need to be async, but for simplicity we'll just check if version exists
|
||||
if isinstance(action_instance.version, list):
|
||||
return version_enum in action_instance.version
|
||||
else:
|
||||
return action_instance.version == version_enum
|
||||
return action_instance.version == version_enum
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def main():
|
||||
"""Demonstrate the automatic capabilities discovery and request handling."""
|
||||
print("🚀 AlpineBits Server Capabilities Discovery & Request Handling Demo")
|
||||
print("=" * 70)
|
||||
|
||||
# Create server instance
|
||||
server = AlpineBitsServer()
|
||||
|
||||
print("\n📋 Discovered Action Classes:")
|
||||
print("-" * 30)
|
||||
for capability_name, action_class in server.capabilities.action_registry.items():
|
||||
action_enum = AlpineBitsActionName.get_by_capability_name(capability_name)
|
||||
request_name = action_enum.request_name if action_enum else "unknown"
|
||||
print(f"✅ {capability_name} -> {action_class.__name__}")
|
||||
print(f" Request name: {request_name}")
|
||||
|
||||
print(
|
||||
f"\n📊 Total Implemented Actions: {len(server.capabilities.get_supported_actions())}"
|
||||
)
|
||||
|
||||
print("\n🔍 Generated Capabilities JSON:")
|
||||
print("-" * 30)
|
||||
capabilities_json = server.get_capabilities_json()
|
||||
print(capabilities_json)
|
||||
|
||||
print("\n🎯 Supported Request Names:")
|
||||
print("-" * 30)
|
||||
for request_name in server.get_supported_request_names():
|
||||
print(f" • {request_name}")
|
||||
|
||||
print("\n🧪 Testing Request Handling:")
|
||||
print("-" * 30)
|
||||
|
||||
test_xml = "<test>sample request</test>"
|
||||
|
||||
# Test different request formats
|
||||
test_cases = [
|
||||
("OTA_Ping:Handshaking", "2024-10"),
|
||||
("OTA_Read:GuestRequests", "2024-10"),
|
||||
("OTA_Read:GuestRequests", "2022-10"),
|
||||
("OTA_HotelAvailNotif", "2024-10"),
|
||||
("UnknownAction", "2024-10"),
|
||||
("OTA_Ping:Handshaking", "unsupported-version"),
|
||||
]
|
||||
|
||||
for request_name, version in test_cases:
|
||||
print(f"\n<EFBFBD> Testing: {request_name} (v{version})")
|
||||
|
||||
# Check if supported first
|
||||
is_supported = server.is_action_supported(request_name, version)
|
||||
print(f" Supported: {is_supported}")
|
||||
|
||||
# Handle the request
|
||||
response = await server.handle_request(request_name, test_xml, version)
|
||||
print(f" Status: {response.status_code}")
|
||||
if len(response.xml_content) > 100:
|
||||
print(f" Response: {response.xml_content[:100]}...")
|
||||
else:
|
||||
print(f" Response: {response.xml_content}")
|
||||
|
||||
print("\n✅ Demo completed successfully!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,17 @@
|
||||
import os
|
||||
import secrets
|
||||
from typing import Optional
|
||||
from fastapi import HTTPException, Security, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
import hashlib
|
||||
import hmac
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import HTTPException, Security, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
from .logging_config import get_logger
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Security scheme
|
||||
security = HTTPBearer()
|
||||
@@ -31,16 +30,21 @@ 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 32 characters."""
|
||||
return secrets.token_urlsafe(26)[:32] # 26 bytes -> 32 chars in base64url
|
||||
|
||||
|
||||
def generate_api_key() -> str:
|
||||
"""Generate a secure API key"""
|
||||
"""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.
|
||||
"""Validate API key from Authorization header.
|
||||
|
||||
Expected format: Authorization: Bearer your_api_key_here
|
||||
"""
|
||||
token = credentials.credentials
|
||||
@@ -60,8 +64,8 @@ def validate_api_key(
|
||||
|
||||
|
||||
def validate_wix_signature(payload: bytes, signature: str, secret: str) -> bool:
|
||||
"""
|
||||
Validate Wix webhook signature for additional security.
|
||||
"""Validate Wix webhook signature for additional security.
|
||||
|
||||
Wix signs their webhooks with HMAC-SHA256.
|
||||
"""
|
||||
if not signature or not secret:
|
||||
@@ -69,8 +73,7 @@ def validate_wix_signature(payload: bytes, signature: str, secret: str) -> bool:
|
||||
|
||||
try:
|
||||
# Remove 'sha256=' prefix if present
|
||||
if signature.startswith("sha256="):
|
||||
signature = signature[7:]
|
||||
signature = signature.removeprefix("sha256=")
|
||||
|
||||
# Calculate expected signature
|
||||
expected_signature = hmac.new(
|
||||
@@ -80,29 +83,29 @@ def validate_wix_signature(payload: bytes, signature: str, secret: str) -> bool:
|
||||
# Compare signatures securely
|
||||
return secrets.compare_digest(signature, expected_signature)
|
||||
except Exception as e:
|
||||
logger.error(f"Error validating signature: {e}")
|
||||
logger.exception(f"Error validating signature: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class APIKeyAuth:
|
||||
"""Simple API key authentication class"""
|
||||
"""Simple API key authentication class."""
|
||||
|
||||
def __init__(self, api_keys: dict):
|
||||
self.api_keys = api_keys
|
||||
|
||||
def authenticate(self, api_key: str) -> Optional[str]:
|
||||
"""Authenticate an API key and return the key name if valid"""
|
||||
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"""
|
||||
"""Add a new API key."""
|
||||
self.api_keys[name] = key
|
||||
|
||||
def remove_key(self, name: str):
|
||||
"""Remove an API key"""
|
||||
"""Remove an API key."""
|
||||
if name in self.api_keys:
|
||||
del self.api_keys[name]
|
||||
|
||||
|
||||
@@ -1,29 +1,81 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from annotatedyaml.loader import (
|
||||
HAS_C_LOADER,
|
||||
JSON_TYPE,
|
||||
LoaderType,
|
||||
Secrets,
|
||||
add_constructor,
|
||||
load_yaml as load_annotated_yaml,
|
||||
load_yaml_dict as load_annotated_yaml_dict,
|
||||
parse_yaml as parse_annotated_yaml,
|
||||
secret_yaml as annotated_secret_yaml,
|
||||
|
||||
from 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,
|
||||
)
|
||||
|
||||
from alpine_bits_python.const import (
|
||||
CONF_ALPINE_BITS_AUTH,
|
||||
CONF_DATABASE,
|
||||
CONF_HOTEL_ID,
|
||||
CONF_HOTEL_NAME,
|
||||
CONF_LOGGING,
|
||||
CONF_LOGGING_FILE,
|
||||
CONF_LOGGING_LEVEL,
|
||||
CONF_PASSWORD,
|
||||
CONF_PUSH_ENDPOINT,
|
||||
CONF_PUSH_TOKEN,
|
||||
CONF_PUSH_URL,
|
||||
CONF_PUSH_USERNAME,
|
||||
CONF_SERVER,
|
||||
CONF_SERVER_CODE,
|
||||
CONF_SERVER_CODECONTEXT,
|
||||
CONF_SERVER_COMPANYNAME,
|
||||
CONF_SERVER_RES_ID_SOURCE_CONTEXT,
|
||||
CONF_USERNAME,
|
||||
ENV_ALPINE_BITS_CONFIG_PATH,
|
||||
)
|
||||
from voluptuous import Schema, Required, All, Length, PREVENT_EXTRA, MultipleInvalid
|
||||
|
||||
# --- Voluptuous schemas ---
|
||||
database_schema = Schema({Required("url"): str}, extra=PREVENT_EXTRA)
|
||||
|
||||
|
||||
logger_schema = Schema(
|
||||
{
|
||||
Required(CONF_LOGGING_LEVEL, default="INFO"): str,
|
||||
Optional(CONF_LOGGING_FILE): str, # If not provided, log to console
|
||||
},
|
||||
extra=PREVENT_EXTRA,
|
||||
)
|
||||
|
||||
|
||||
def ensure_string(value):
|
||||
"""Ensure the value is a string."""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return str(value)
|
||||
|
||||
|
||||
server_info = Schema(
|
||||
{
|
||||
Required(CONF_SERVER_CODECONTEXT, default="ADVERTISING"): ensure_string,
|
||||
Required(CONF_SERVER_CODE, default="70597314"): ensure_string,
|
||||
Required(CONF_SERVER_COMPANYNAME, default="99tales Gmbh"): ensure_string,
|
||||
Required(CONF_SERVER_RES_ID_SOURCE_CONTEXT, default="99tales"): ensure_string,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
hotel_auth_schema = Schema(
|
||||
{
|
||||
Required("hotel_id"): str,
|
||||
Required("hotel_name"): str,
|
||||
Required("username"): str,
|
||||
Required("password"): str,
|
||||
Required(CONF_HOTEL_ID): ensure_string,
|
||||
Required(CONF_HOTEL_NAME): str,
|
||||
Required(CONF_USERNAME): str,
|
||||
Required(CONF_PASSWORD): str,
|
||||
Optional(CONF_PUSH_ENDPOINT): {
|
||||
Required(CONF_PUSH_URL): str,
|
||||
Required(CONF_PUSH_TOKEN): str,
|
||||
Optional(CONF_PUSH_USERNAME): str,
|
||||
},
|
||||
},
|
||||
extra=PREVENT_EXTRA,
|
||||
)
|
||||
@@ -32,8 +84,10 @@ 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,
|
||||
Required(CONF_DATABASE): database_schema,
|
||||
Required(CONF_ALPINE_BITS_AUTH): basic_auth_schema,
|
||||
Required(CONF_SERVER): server_info,
|
||||
Required(CONF_LOGGING): logger_schema,
|
||||
},
|
||||
extra=PREVENT_EXTRA,
|
||||
)
|
||||
@@ -44,20 +98,18 @@ DEFAULT_CONFIG_FILE = "config.yaml"
|
||||
class Config:
|
||||
def __init__(
|
||||
self,
|
||||
config_folder: str | Path = None,
|
||||
config_folder: str | Path | None = None,
|
||||
config_name: str = DEFAULT_CONFIG_FILE,
|
||||
testing_mode: bool = False,
|
||||
):
|
||||
if config_folder is None:
|
||||
config_folder = os.environ.get("ALPINEBITS_CONFIG_DIR")
|
||||
config_folder = os.environ.get(ENV_ALPINE_BITS_CONFIG_PATH)
|
||||
if not config_folder:
|
||||
config_folder = os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "../../config")
|
||||
)
|
||||
config_folder = Path(__file__).parent.joinpath("../../config").resolve()
|
||||
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.config_path = config_folder / config_name
|
||||
self.secrets = Secrets(config_folder)
|
||||
self.testing_mode = testing_mode
|
||||
self._load_config()
|
||||
@@ -88,7 +140,7 @@ class Config:
|
||||
return self.basic_auth["hotel_name"]
|
||||
|
||||
@property
|
||||
def users(self) -> List[Dict[str, str]]:
|
||||
def users(self) -> list[dict[str, str]]:
|
||||
return self.basic_auth["users"]
|
||||
|
||||
|
||||
|
||||
34
src/alpine_bits_python/const.py
Normal file
34
src/alpine_bits_python/const.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from typing import Final
|
||||
|
||||
RESERVATION_ID_TYPE: str = (
|
||||
"13" # Default reservation ID type for Reservation. 14 would be cancellation
|
||||
)
|
||||
|
||||
|
||||
CONF_LOGGING: Final[str] = "logger"
|
||||
|
||||
CONF_LOGGING_LEVEL: Final[str] = "level"
|
||||
CONF_LOGGING_FILE: Final[str] = "file"
|
||||
|
||||
|
||||
CONF_DATABASE: Final[str] = "database"
|
||||
|
||||
|
||||
CONF_SERVER: Final[str] = "server"
|
||||
CONF_SERVER_CODECONTEXT: Final[str] = "codecontext"
|
||||
CONF_SERVER_CODE: Final[str] = "code"
|
||||
CONF_SERVER_COMPANYNAME: Final[str] = "companyname"
|
||||
CONF_SERVER_RES_ID_SOURCE_CONTEXT: Final[str] = "res_id_source_context"
|
||||
|
||||
|
||||
CONF_ALPINE_BITS_AUTH: Final[str] = "alpine_bits_auth"
|
||||
CONF_HOTEL_ID: Final[str] = "hotel_id"
|
||||
CONF_HOTEL_NAME: Final[str] = "hotel_name"
|
||||
CONF_USERNAME: Final[str] = "username"
|
||||
CONF_PASSWORD: Final[str] = "password"
|
||||
CONF_PUSH_ENDPOINT: Final[str] = "push_endpoint"
|
||||
CONF_PUSH_URL: Final[str] = "url"
|
||||
CONF_PUSH_TOKEN: Final[str] = "token"
|
||||
CONF_PUSH_USERNAME: Final[str] = "username"
|
||||
|
||||
ENV_ALPINE_BITS_CONFIG_PATH: Final[str] = "ALPINE_BITS_CONFIG_DIR"
|
||||
@@ -1,8 +1,8 @@
|
||||
from sqlalchemy import Column, Integer, String, Date, Boolean, ForeignKey, DateTime
|
||||
from sqlalchemy.orm import declarative_base, relationship
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
import os
|
||||
|
||||
from sqlalchemy import Boolean, Column, Date, DateTime, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import declarative_base, relationship
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
@@ -18,9 +18,6 @@ def get_database_url(config=None):
|
||||
return db_url
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class Customer(Base):
|
||||
__tablename__ = "customers"
|
||||
id = Column(Integer, primary_key=True)
|
||||
@@ -47,14 +44,14 @@ class Reservation(Base):
|
||||
__tablename__ = "reservations"
|
||||
id = Column(Integer, primary_key=True)
|
||||
customer_id = Column(Integer, ForeignKey("customers.id"))
|
||||
form_id = Column(String, unique=True)
|
||||
unique_id = Column(String, unique=True)
|
||||
md5_unique_id = Column(String(32), unique=True) # max length 32 guaranteed
|
||||
start_date = Column(Date)
|
||||
end_date = Column(Date)
|
||||
num_adults = Column(Integer)
|
||||
num_children = Column(Integer)
|
||||
children_ages = Column(String) # comma-separated
|
||||
offer = Column(String)
|
||||
utm_comment = Column(String)
|
||||
created_at = Column(DateTime)
|
||||
# Add all UTM fields and user comment for XML
|
||||
utm_source = Column(String)
|
||||
@@ -71,12 +68,12 @@ class Reservation(Base):
|
||||
customer = relationship("Customer", back_populates="reservations")
|
||||
|
||||
|
||||
|
||||
class HashedCustomer(Base):
|
||||
__tablename__ = "hashed_customers"
|
||||
# Table for tracking acknowledged requests by client
|
||||
class AckedRequest(Base):
|
||||
__tablename__ = "acked_requests"
|
||||
id = Column(Integer, primary_key=True)
|
||||
customer_id = Column(Integer)
|
||||
hashed_email = Column(String)
|
||||
hashed_phone = Column(String)
|
||||
hashed_name = Column(String)
|
||||
redacted_at = Column(DateTime)
|
||||
client_id = Column(String, index=True)
|
||||
unique_id = Column(
|
||||
String, index=True
|
||||
) # Should match Reservation.form_id or another unique field
|
||||
timestamp = Column(DateTime)
|
||||
|
||||
@@ -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
87
src/alpine_bits_python/logging_config.py
Normal file
87
src/alpine_bits_python/logging_config.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Centralized logging configuration for AlpineBits application.
|
||||
|
||||
This module sets up logging based on config and provides a function to get
|
||||
loggers from anywhere in the application.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def setup_logging(config: dict | None = None):
|
||||
"""Configure logging based on application config.
|
||||
|
||||
Args:
|
||||
config: Application configuration dict with optional 'logger' section
|
||||
|
||||
Logger config format:
|
||||
logger:
|
||||
level: "INFO" # DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||
file: "alpinebits.log" # Optional, logs to console if not provided
|
||||
|
||||
"""
|
||||
if config is None:
|
||||
config = {}
|
||||
|
||||
logger_config = config.get("logger", {})
|
||||
level = logger_config.get("level", "INFO").upper()
|
||||
log_file = logger_config.get("file")
|
||||
|
||||
# Convert string level to logging constant
|
||||
numeric_level = getattr(logging, level, logging.INFO)
|
||||
|
||||
# Create formatter with timestamp
|
||||
formatter = logging.Formatter(
|
||||
fmt="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
# Get root logger
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(numeric_level)
|
||||
|
||||
# Remove existing handlers to avoid duplicates
|
||||
root_logger.handlers.clear()
|
||||
|
||||
# Console handler (always add this)
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(numeric_level)
|
||||
console_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(console_handler)
|
||||
|
||||
# File handler (optional)
|
||||
if log_file:
|
||||
log_path = Path(log_file)
|
||||
|
||||
# Create logs directory if it doesn't exist
|
||||
if log_path.parent != Path():
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
file_handler = logging.FileHandler(log_file, encoding="utf-8")
|
||||
file_handler.setLevel(numeric_level)
|
||||
file_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
root_logger.info("Logging to file: %s", log_file)
|
||||
|
||||
root_logger.info("Logging configured at %s level", level)
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""Get a logger instance for the given module name.
|
||||
|
||||
Usage:
|
||||
from alpine_bits_python.logging_config import get_logger
|
||||
|
||||
_LOGGER = get_logger(__name__)
|
||||
_LOGGER.info("Something happened")
|
||||
|
||||
Args:
|
||||
name: Usually __name__ from the calling module
|
||||
|
||||
Returns:
|
||||
Configured logger instance
|
||||
|
||||
"""
|
||||
return logging.getLogger(name)
|
||||
@@ -1,387 +0,0 @@
|
||||
import logging
|
||||
from .alpinebits_guestrequests import ResGuest, RoomStay
|
||||
from .generated import alpinebits as ab
|
||||
from io import BytesIO
|
||||
import sys
|
||||
from datetime import datetime, timezone, date
|
||||
import re
|
||||
from xsdata_pydantic.bindings import XmlSerializer
|
||||
from .alpine_bits_helpers import (
|
||||
CustomerData,
|
||||
GuestCountsFactory,
|
||||
HotelReservationIdData,
|
||||
AlpineBitsFactory,
|
||||
OtaMessageType,
|
||||
CommentData,
|
||||
CommentsData,
|
||||
CommentListItemData,
|
||||
)
|
||||
from .generated import alpinebits as ab
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .alpine_bits_helpers import (
|
||||
CommentData,
|
||||
CommentsData,
|
||||
CommentListItemData,
|
||||
CustomerData,
|
||||
GuestCountsFactory,
|
||||
HotelReservationIdData,
|
||||
PhoneTechType,
|
||||
AlpineBitsFactory,
|
||||
OtaMessageType,
|
||||
)
|
||||
|
||||
# DB and config
|
||||
from .db import (
|
||||
Base,
|
||||
Customer as DBCustomer,
|
||||
Reservation as DBReservation,
|
||||
HashedCustomer,
|
||||
get_database_url,
|
||||
)
|
||||
from .config_loader import load_config
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
import asyncio
|
||||
|
||||
from alpine_bits_python import db
|
||||
|
||||
# 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, "r", 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]}")
|
||||
|
||||
# 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(timezone.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=reservation.form_id
|
||||
)
|
||||
|
||||
# TimeSpan
|
||||
time_span = ab.OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays.RoomStay.TimeSpan(
|
||||
start=reservation.start_date.isoformat() if reservation.start_date else None,
|
||||
end=reservation.end_date.isoformat() if reservation.end_date else None,
|
||||
)
|
||||
room_stay = (
|
||||
ab.OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays.RoomStay(
|
||||
time_span=time_span,
|
||||
guest_counts=guest_counts,
|
||||
)
|
||||
)
|
||||
room_stays = ab.OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays(
|
||||
room_stay=[room_stay],
|
||||
)
|
||||
|
||||
# HotelReservationId
|
||||
hotel_res_id_data = HotelReservationIdData(
|
||||
res_id_type="13",
|
||||
res_id_value=reservation.fbclid or reservation.gclid,
|
||||
res_id_source=None,
|
||||
res_id_source_context="99tales",
|
||||
)
|
||||
hotel_res_id = alpine_bits_factory.create(
|
||||
hotel_res_id_data, OtaMessageType.RETRIEVE
|
||||
)
|
||||
hotel_res_ids = ab.OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.HotelReservationIds(
|
||||
hotel_reservation_id=[hotel_res_id]
|
||||
)
|
||||
basic_property_info = ab.OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.BasicPropertyInfo(
|
||||
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=reservation.user_comment,
|
||||
list_items=[
|
||||
CommentListItemData(
|
||||
value="Landing page comment",
|
||||
language=customer.language,
|
||||
list_item="1",
|
||||
)
|
||||
],
|
||||
)
|
||||
comments = [offer_comment, comment] if comment else [offer_comment]
|
||||
comments_data = CommentsData(comments=comments)
|
||||
comments_xml = alpine_bits_factory.create(comments_data, OtaMessageType.RETRIEVE)
|
||||
|
||||
res_global_info = (
|
||||
ab.OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo(
|
||||
hotel_reservation_ids=hotel_res_ids,
|
||||
basic_property_info=basic_property_info,
|
||||
comments=comments_xml,
|
||||
)
|
||||
)
|
||||
|
||||
hotel_reservation = ab.OtaResRetrieveRs.ReservationsList.HotelReservation(
|
||||
create_date_time=datetime.now(timezone.utc).isoformat(),
|
||||
res_status=ab.HotelReservationResStatus.REQUESTED,
|
||||
room_stay_reservation="true",
|
||||
unique_id=unique_id,
|
||||
room_stays=room_stays,
|
||||
res_guests=res_guests,
|
||||
res_global_info=res_global_info,
|
||||
)
|
||||
reservations_list = ab.OtaResRetrieveRs.ReservationsList(
|
||||
hotel_reservation=[hotel_reservation]
|
||||
)
|
||||
ota_res_retrieve_rs = ab.OtaResRetrieveRs(
|
||||
version="7.000", success=None, reservations_list=reservations_list
|
||||
)
|
||||
|
||||
# Serialize to XML
|
||||
try:
|
||||
ota_res_retrieve_rs.model_validate(ota_res_retrieve_rs.model_dump())
|
||||
print("✅ Pydantic validation successful!")
|
||||
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)
|
||||
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")
|
||||
print("\n📄 Generated XML:")
|
||||
print(xml_string)
|
||||
from xsdata_pydantic.bindings import XmlParser
|
||||
|
||||
parser = XmlParser()
|
||||
with open("output.xml", "r", encoding="utf-8") as infile:
|
||||
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__":
|
||||
asyncio.run(main())
|
||||
@@ -1,73 +0,0 @@
|
||||
from typing import Dict, List, Any, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class AlpineBitsHandshakeRequest(BaseModel):
|
||||
"""Model for AlpineBits handshake request data"""
|
||||
|
||||
action: str = Field(
|
||||
..., description="Action parameter, typically 'OTA_Ping:Handshaking'"
|
||||
)
|
||||
request_xml: Optional[str] = Field(None, description="XML request document")
|
||||
|
||||
|
||||
class ContactName(BaseModel):
|
||||
"""Contact name structure"""
|
||||
|
||||
first: Optional[str] = None
|
||||
last: Optional[str] = None
|
||||
|
||||
|
||||
class ContactAddress(BaseModel):
|
||||
"""Contact address structure"""
|
||||
|
||||
street: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
state: Optional[str] = None
|
||||
country: Optional[str] = None
|
||||
postalCode: Optional[str] = None
|
||||
|
||||
|
||||
class Contact(BaseModel):
|
||||
"""Contact information from Wix form"""
|
||||
|
||||
name: Optional[ContactName] = None
|
||||
email: Optional[str] = None
|
||||
locale: Optional[str] = None
|
||||
company: Optional[str] = None
|
||||
birthdate: Optional[str] = None
|
||||
labelKeys: Optional[Dict[str, Any]] = None
|
||||
contactId: Optional[str] = None
|
||||
address: Optional[ContactAddress] = None
|
||||
jobTitle: Optional[str] = None
|
||||
imageUrl: Optional[str] = None
|
||||
updatedDate: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
createdDate: Optional[str] = None
|
||||
|
||||
|
||||
class SubmissionPdf(BaseModel):
|
||||
"""PDF submission structure"""
|
||||
|
||||
url: Optional[str] = None
|
||||
filename: Optional[str] = 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: Optional[SubmissionPdf] = None
|
||||
formId: str
|
||||
contact: Optional[Contact] = None
|
||||
|
||||
# Dynamic form fields - these will capture all field:* entries
|
||||
class Config:
|
||||
extra = "allow" # Allow additional fields not defined in the model
|
||||
@@ -1,10 +1,11 @@
|
||||
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||
from slowapi.util import get_remote_address
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from fastapi import Request
|
||||
import redis
|
||||
import os
|
||||
import logging
|
||||
import os
|
||||
|
||||
import redis
|
||||
from fastapi import Request
|
||||
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -18,9 +19,7 @@ REDIS_URL = os.getenv("REDIS_URL", None)
|
||||
|
||||
|
||||
def get_remote_address_with_forwarded(request: Request):
|
||||
"""
|
||||
Get client IP address, considering forwarded headers from proxies/load balancers
|
||||
"""
|
||||
"""Get client IP address, considering forwarded headers from proxies/load balancers."""
|
||||
# Check for forwarded headers (common in production behind proxies)
|
||||
forwarded_for = request.headers.get("X-Forwarded-For")
|
||||
if forwarded_for:
|
||||
@@ -58,9 +57,8 @@ else:
|
||||
|
||||
|
||||
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
|
||||
"""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")
|
||||
@@ -86,7 +84,7 @@ webhook_limiter = Limiter(
|
||||
|
||||
# Custom rate limit exceeded handler
|
||||
def custom_rate_limit_handler(request: Request, exc: RateLimitExceeded):
|
||||
"""Custom handler for rate limit exceeded"""
|
||||
"""Custom handler for rate limit exceeded."""
|
||||
logger.warning(
|
||||
f"Rate limit exceeded for {get_remote_address_with_forwarded(request)}: "
|
||||
f"{exc.detail}"
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
def parse_form(form: dict):
|
||||
pass
|
||||
@@ -1,17 +1,14 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Startup script for the Wix Form Handler API
|
||||
"""
|
||||
"""Startup script for the Wix Form Handler API."""
|
||||
|
||||
import os
|
||||
|
||||
import uvicorn
|
||||
from .api import app
|
||||
|
||||
if __name__ == "__main__":
|
||||
db_path = "alpinebits.db" # Adjust path if needed
|
||||
if os.path.exists(db_path):
|
||||
os.remove(db_path)
|
||||
print(f"Deleted database file: {db_path}")
|
||||
|
||||
uvicorn.run(
|
||||
"alpine_bits_python.api:app",
|
||||
|
||||
254
src/alpine_bits_python/schemas.py
Normal file
254
src/alpine_bits_python/schemas.py
Normal file
@@ -0,0 +1,254 @@
|
||||
"""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, datetime
|
||||
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
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
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=300)
|
||||
gclid: str | None = Field(None, max_length=300)
|
||||
utm_source: str | None = Field(None, max_length=150)
|
||||
utm_medium: str | None = Field(None, max_length=150)
|
||||
utm_campaign: str | None = Field(None, max_length=150)
|
||||
utm_term: str | None = Field(None, max_length=150)
|
||||
utm_content: str | None = Field(None, max_length=150)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def ensure_md5(self) -> "ReservationData":
|
||||
"""Ensure md5_unique_id is set after model validation.
|
||||
|
||||
Using a model_validator in 'after' mode lets us access all fields via
|
||||
the instance and set md5_unique_id in-place when it wasn't provided.
|
||||
"""
|
||||
if not getattr(self, "md5_unique_id", None) and getattr(
|
||||
self, "unique_id", None
|
||||
):
|
||||
self.md5_unique_id = hashlib.md5(self.unique_id.encode("utf-8")).hexdigest()
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_children_ages(self) -> "ReservationData":
|
||||
"""Ensure children_ages matches num_children."""
|
||||
if len(self.children_ages) != self.num_children:
|
||||
raise ValueError(
|
||||
f"Number of children ages ({len(self.children_ages)}) "
|
||||
f"must match num_children ({self.num_children})"
|
||||
)
|
||||
for age in self.children_ages:
|
||||
if age < 0 or age > 17:
|
||||
raise ValueError(f"Child age {age} must be between 0 and 17")
|
||||
return self
|
||||
|
||||
|
||||
class CustomerData(BaseModel):
|
||||
"""Validated customer data for creating reservations and guests."""
|
||||
|
||||
given_name: str = Field(..., min_length=1, max_length=100)
|
||||
surname: str = Field(..., min_length=1, max_length=100)
|
||||
name_prefix: str | None = Field(None, max_length=20)
|
||||
name_title: str | None = Field(None, max_length=20)
|
||||
phone_numbers: list[tuple[str, None | PhoneTechType]] = Field(default_factory=list)
|
||||
email_address: EmailStr | None = None
|
||||
email_newsletter: bool | None = None
|
||||
address_line: str | None = Field(None, max_length=255)
|
||||
city_name: str | None = Field(None, max_length=100)
|
||||
postal_code: str | None = Field(None, max_length=20)
|
||||
country_code: str | None = Field(
|
||||
None, min_length=2, max_length=2, pattern="^[A-Z]{2}$"
|
||||
)
|
||||
address_catalog: bool | None = None
|
||||
gender: str | None = Field(None, pattern="^(Male|Female|Unknown)$")
|
||||
birth_date: str | None = Field(None, pattern=r"^\d{4}-\d{2}-\d{2}$") # ISO format
|
||||
language: str | None = Field(None, min_length=2, max_length=2, pattern="^[a-z]{2}$")
|
||||
|
||||
@field_validator("given_name", "surname")
|
||||
@classmethod
|
||||
def name_must_not_be_empty(cls, v: str) -> str:
|
||||
"""Ensure names are not just whitespace."""
|
||||
if not v.strip():
|
||||
raise ValueError("Name cannot be empty or whitespace")
|
||||
return v.strip()
|
||||
|
||||
@field_validator("country_code")
|
||||
@classmethod
|
||||
def normalize_country_code(cls, v: str | None) -> str | None:
|
||||
"""Normalize country code to uppercase."""
|
||||
return v.upper() if v else None
|
||||
|
||||
@field_validator("language")
|
||||
@classmethod
|
||||
def normalize_language(cls, v: str | None) -> str | None:
|
||||
"""Normalize language code to lowercase."""
|
||||
return v.lower() if v else None
|
||||
|
||||
model_config = {"from_attributes": True} # Allow creation from ORM models
|
||||
|
||||
|
||||
class HotelReservationIdData(BaseModel):
|
||||
"""Validated hotel reservation ID data."""
|
||||
|
||||
res_id_type: str = Field(..., pattern=r"^[0-9]+$") # Must be numeric string
|
||||
res_id_value: str | None = Field(None, min_length=1, max_length=64)
|
||||
res_id_source: str | None = Field(None, min_length=1, max_length=64)
|
||||
res_id_source_context: str | None = Field(None, min_length=1, max_length=64)
|
||||
|
||||
@field_validator(
|
||||
"res_id_value", "res_id_source", "res_id_source_context", mode="before"
|
||||
)
|
||||
@classmethod
|
||||
def trim_and_truncate(cls, v: str | None) -> str | None:
|
||||
"""Trim whitespace and truncate to max length if needed.
|
||||
|
||||
Runs BEFORE field validation to ensure values are cleaned and truncated
|
||||
before max_length constraints are checked.
|
||||
"""
|
||||
if not v:
|
||||
return None
|
||||
# Convert to string if needed
|
||||
v = str(v)
|
||||
# Strip whitespace
|
||||
v = v.strip()
|
||||
# Convert empty strings to None
|
||||
if not v:
|
||||
return None
|
||||
# Truncate to 64 characters if needed
|
||||
if len(v) > 64:
|
||||
v = v[:64]
|
||||
return v
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class CommentListItemData(BaseModel):
|
||||
"""Validated comment list item."""
|
||||
|
||||
value: str = Field(..., min_length=1, max_length=1000)
|
||||
list_item: str = Field(..., pattern=r"^[0-9]+$") # Numeric identifier
|
||||
language: str = Field(..., min_length=2, max_length=2, pattern=r"^[a-z]{2}$")
|
||||
|
||||
@field_validator("language")
|
||||
@classmethod
|
||||
def normalize_language(cls, v: str) -> str:
|
||||
"""Normalize language to lowercase."""
|
||||
return v.lower()
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class CommentData(BaseModel):
|
||||
"""Validated comment data."""
|
||||
|
||||
name: str # Should be validated against CommentName2 enum
|
||||
text: str | None = Field(None, max_length=4000)
|
||||
list_items: list[CommentListItemData] = Field(default_factory=list)
|
||||
|
||||
@field_validator("list_items")
|
||||
@classmethod
|
||||
def validate_list_items(
|
||||
cls, v: list[CommentListItemData]
|
||||
) -> list[CommentListItemData]:
|
||||
"""Ensure list items have unique identifiers."""
|
||||
if v:
|
||||
item_ids = [item.list_item for item in v]
|
||||
if len(item_ids) != len(set(item_ids)):
|
||||
raise ValueError("List items must have unique identifiers")
|
||||
return v
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class CommentsData(BaseModel):
|
||||
"""Validated comments collection."""
|
||||
|
||||
comments: list[CommentData] = Field(default_factory=list, max_length=3)
|
||||
|
||||
@field_validator("comments")
|
||||
@classmethod
|
||||
def validate_comment_count(cls, v: list[CommentData]) -> list[CommentData]:
|
||||
"""Ensure maximum 3 comments."""
|
||||
if len(v) > 3:
|
||||
raise ValueError("Maximum 3 comments allowed")
|
||||
return v
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# Example usage in a service layer
|
||||
class ReservationService:
|
||||
"""Example service showing how to use Pydantic models with SQLAlchemy."""
|
||||
|
||||
def __init__(self, db_session):
|
||||
self.db_session = db_session
|
||||
|
||||
async def create_reservation(
|
||||
self, reservation_data: ReservationData, customer_data: CustomerData
|
||||
):
|
||||
"""Create a reservation with validated data.
|
||||
|
||||
The data has already been validated by Pydantic before reaching here.
|
||||
"""
|
||||
from alpine_bits_python.db import Customer, Reservation
|
||||
|
||||
# Convert validated Pydantic model to SQLAlchemy model
|
||||
db_customer = Customer(**customer_data.model_dump(exclude_none=True))
|
||||
self.db_session.add(db_customer)
|
||||
await self.db_session.flush() # Get the customer ID
|
||||
|
||||
# Create reservation linked to customer
|
||||
db_reservation = Reservation(
|
||||
customer_id=db_customer.id,
|
||||
**reservation_data.model_dump(
|
||||
exclude={"children_ages"}
|
||||
), # Handle separately
|
||||
children_ages=",".join(map(str, reservation_data.children_ages)),
|
||||
)
|
||||
self.db_session.add(db_reservation)
|
||||
await self.db_session.commit()
|
||||
|
||||
return db_reservation, db_customer
|
||||
@@ -1,135 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Configuration and setup script for the Wix Form Handler API
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import secrets
|
||||
|
||||
# Add parent directory to path to import from src
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from alpine_bits_python.auth import generate_api_key
|
||||
|
||||
|
||||
def generate_secure_keys():
|
||||
"""Generate secure API keys for the application"""
|
||||
|
||||
print("🔐 Generating Secure API Keys")
|
||||
print("=" * 50)
|
||||
|
||||
# Generate API keys
|
||||
wix_api_key = generate_api_key()
|
||||
admin_api_key = generate_api_key()
|
||||
webhook_secret = secrets.token_urlsafe(32)
|
||||
|
||||
print(f"🔑 Wix Webhook API Key: {wix_api_key}")
|
||||
print(f"🔐 Admin API Key: {admin_api_key}")
|
||||
print(f"🔒 Webhook Secret: {webhook_secret}")
|
||||
|
||||
print("\n📋 Environment Variables")
|
||||
print("-" * 30)
|
||||
print(f"export WIX_API_KEY='{wix_api_key}'")
|
||||
print(f"export ADMIN_API_KEY='{admin_api_key}'")
|
||||
print(f"export WIX_WEBHOOK_SECRET='{webhook_secret}'")
|
||||
print(f"export REDIS_URL='redis://localhost:6379' # Optional for production")
|
||||
|
||||
print("\n🔧 .env File Content")
|
||||
print("-" * 20)
|
||||
print(f"WIX_API_KEY={wix_api_key}")
|
||||
print(f"ADMIN_API_KEY={admin_api_key}")
|
||||
print(f"WIX_WEBHOOK_SECRET={webhook_secret}")
|
||||
print("REDIS_URL=redis://localhost:6379")
|
||||
|
||||
# Optionally write to .env file
|
||||
create_env = input("\n❓ Create .env file? (y/n): ").lower().strip()
|
||||
if create_env == "y":
|
||||
# Create .env in the project root (two levels up from scripts)
|
||||
env_path = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env"
|
||||
)
|
||||
with open(env_path, "w") as f:
|
||||
f.write(f"WIX_API_KEY={wix_api_key}\n")
|
||||
f.write(f"ADMIN_API_KEY={admin_api_key}\n")
|
||||
f.write(f"WIX_WEBHOOK_SECRET={webhook_secret}\n")
|
||||
f.write("REDIS_URL=redis://localhost:6379\n")
|
||||
print(f"✅ .env file created at {env_path}!")
|
||||
print("⚠️ Add .env to your .gitignore file!")
|
||||
|
||||
print("\n🌐 Wix Configuration")
|
||||
print("-" * 20)
|
||||
print("1. In your Wix site, go to Settings > Webhooks")
|
||||
print("2. Add webhook URL: https://yourdomain.com/webhook/wix-form")
|
||||
print("3. Add custom header: Authorization: Bearer " + wix_api_key)
|
||||
print("4. Optionally configure webhook signature with the secret above")
|
||||
|
||||
return {
|
||||
"wix_api_key": wix_api_key,
|
||||
"admin_api_key": admin_api_key,
|
||||
"webhook_secret": webhook_secret,
|
||||
}
|
||||
|
||||
|
||||
def check_security_setup():
|
||||
"""Check current security configuration"""
|
||||
|
||||
print("🔍 Security Configuration Check")
|
||||
print("=" * 40)
|
||||
|
||||
# Check environment variables
|
||||
wix_key = os.getenv("WIX_API_KEY")
|
||||
admin_key = os.getenv("ADMIN_API_KEY")
|
||||
webhook_secret = os.getenv("WIX_WEBHOOK_SECRET")
|
||||
redis_url = os.getenv("REDIS_URL")
|
||||
|
||||
print("Environment Variables:")
|
||||
print(f" WIX_API_KEY: {'✅ Set' if wix_key else '❌ Not set'}")
|
||||
print(f" ADMIN_API_KEY: {'✅ Set' if admin_key else '❌ Not set'}")
|
||||
print(f" WIX_WEBHOOK_SECRET: {'✅ Set' if webhook_secret else '❌ Not set'}")
|
||||
print(f" REDIS_URL: {'✅ Set' if redis_url else '⚠️ Optional (using in-memory)'}")
|
||||
|
||||
# Security recommendations
|
||||
print("\n🛡️ Security Recommendations:")
|
||||
if not wix_key:
|
||||
print(" ❌ Set WIX_API_KEY environment variable")
|
||||
else:
|
||||
if len(wix_key) < 32:
|
||||
print(" ⚠️ WIX_API_KEY should be longer for better security")
|
||||
else:
|
||||
print(" ✅ WIX_API_KEY looks secure")
|
||||
|
||||
if not admin_key:
|
||||
print(" ❌ Set ADMIN_API_KEY environment variable")
|
||||
elif wix_key and admin_key == wix_key:
|
||||
print(" ❌ Admin and Wix keys should be different")
|
||||
else:
|
||||
print(" ✅ ADMIN_API_KEY configured")
|
||||
|
||||
if not webhook_secret:
|
||||
print(" ⚠️ Consider setting WIX_WEBHOOK_SECRET for signature validation")
|
||||
else:
|
||||
print(" ✅ Webhook signature validation enabled")
|
||||
|
||||
print("\n🚀 Production Checklist:")
|
||||
print(" - Use HTTPS in production")
|
||||
print(" - Set up Redis for distributed rate limiting")
|
||||
print(" - Configure proper CORS origins")
|
||||
print(" - Set up monitoring and logging")
|
||||
print(" - Regular key rotation")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("🔐 Wix Form Handler API - Security Setup")
|
||||
print("=" * 50)
|
||||
|
||||
choice = input(
|
||||
"Choose an option:\n1. Generate new API keys\n2. Check current setup\n\nEnter choice (1 or 2): "
|
||||
).strip()
|
||||
|
||||
if choice == "1":
|
||||
generate_secure_keys()
|
||||
elif choice == "2":
|
||||
check_security_setup()
|
||||
else:
|
||||
print("Invalid choice. Please run again and choose 1 or 2.")
|
||||
@@ -1,221 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script for the Secure Wix Form Handler API
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import aiohttp
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
# Add parent directory to path to import from src
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# API Configuration
|
||||
BASE_URL = "http://localhost:8000"
|
||||
|
||||
# API Keys for testing - replace with your actual keys
|
||||
TEST_API_KEY = os.getenv("WIX_API_KEY", "sk_live_your_secure_api_key_here")
|
||||
ADMIN_API_KEY = os.getenv("ADMIN_API_KEY", "sk_admin_your_admin_key_here")
|
||||
|
||||
# Sample Wix form data based on your example
|
||||
SAMPLE_WIX_DATA = {
|
||||
"formName": "Contact Form",
|
||||
"submissions": [],
|
||||
"submissionTime": "2024-03-20T10:30:00+00:00",
|
||||
"formFieldMask": ["email", "name", "phone"],
|
||||
"submissionId": "test-submission-123",
|
||||
"contactId": "test-contact-456",
|
||||
"submissionsLink": "https://www.wix.app/forms/test-form/submissions",
|
||||
"submissionPdf": {
|
||||
"url": "https://example.com/submission.pdf",
|
||||
"filename": "submission.pdf",
|
||||
},
|
||||
"formId": "test-form-789",
|
||||
"field:email_5139": "test@example.com",
|
||||
"field:first_name_abae": "John",
|
||||
"field:last_name_d97c": "Doe",
|
||||
"field:phone_4c77": "+1234567890",
|
||||
"field:anrede": "Herr",
|
||||
"field:anzahl_kinder": "2",
|
||||
"field:alter_kind_3": "8",
|
||||
"field:alter_kind_4": "12",
|
||||
"field:long_answer_3524": "This is a long answer field with more details about the inquiry.",
|
||||
"contact": {
|
||||
"name": {"first": "John", "last": "Doe"},
|
||||
"email": "test@example.com",
|
||||
"locale": "de",
|
||||
"company": "Test Company",
|
||||
"birthdate": "1985-05-15",
|
||||
"labelKeys": {},
|
||||
"contactId": "test-contact-456",
|
||||
"address": {
|
||||
"street": "Test Street 123",
|
||||
"city": "Test City",
|
||||
"country": "Germany",
|
||||
"postalCode": "12345",
|
||||
},
|
||||
"jobTitle": "Manager",
|
||||
"phone": "+1234567890",
|
||||
"createdDate": "2024-03-20T10:00:00.000Z",
|
||||
"updatedDate": "2024-03-20T10:30:00.000Z",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def test_api():
|
||||
"""Test the API endpoints with authentication"""
|
||||
|
||||
headers_with_auth = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {TEST_API_KEY}",
|
||||
}
|
||||
|
||||
admin_headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {ADMIN_API_KEY}",
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
# Test health endpoint (no auth required)
|
||||
print("1. Testing health endpoint (no auth)...")
|
||||
try:
|
||||
async with session.get(f"{BASE_URL}/api/health") as response:
|
||||
result = await response.json()
|
||||
print(f" ✅ Health check: {response.status} - {result.get('status')}")
|
||||
except Exception as e:
|
||||
print(f" ❌ Health check failed: {e}")
|
||||
|
||||
# Test root endpoint (no auth required)
|
||||
print("\n2. Testing root endpoint (no auth)...")
|
||||
try:
|
||||
async with session.get(f"{BASE_URL}/api/") as response:
|
||||
result = await response.json()
|
||||
print(f" ✅ Root: {response.status} - {result.get('message')}")
|
||||
except Exception as e:
|
||||
print(f" ❌ Root endpoint failed: {e}")
|
||||
|
||||
# Test webhook endpoint without auth (should fail)
|
||||
print("\n3. Testing webhook endpoint WITHOUT auth (should fail)...")
|
||||
try:
|
||||
async with session.post(
|
||||
f"{BASE_URL}/api/webhook/wix-form",
|
||||
json=SAMPLE_WIX_DATA,
|
||||
headers={"Content-Type": "application/json"},
|
||||
) as response:
|
||||
result = await response.json()
|
||||
if response.status == 401:
|
||||
print(
|
||||
f" ✅ Correctly rejected: {response.status} - {result.get('detail')}"
|
||||
)
|
||||
else:
|
||||
print(f" ❌ Unexpected response: {response.status} - {result}")
|
||||
except Exception as e:
|
||||
print(f" ❌ Test failed: {e}")
|
||||
|
||||
# Test webhook endpoint with valid auth
|
||||
print("\n4. Testing webhook endpoint WITH valid auth...")
|
||||
try:
|
||||
async with session.post(
|
||||
f"{BASE_URL}/api/webhook/wix-form",
|
||||
json=SAMPLE_WIX_DATA,
|
||||
headers=headers_with_auth,
|
||||
) as response:
|
||||
result = await response.json()
|
||||
if response.status == 200:
|
||||
print(
|
||||
f" ✅ Webhook success: {response.status} - {result.get('status')}"
|
||||
)
|
||||
else:
|
||||
print(f" ❌ Webhook failed: {response.status} - {result}")
|
||||
except Exception as e:
|
||||
print(f" ❌ Webhook test failed: {e}")
|
||||
|
||||
# Test test endpoint with auth
|
||||
print("\n5. Testing simple test endpoint WITH auth...")
|
||||
try:
|
||||
async with session.post(
|
||||
f"{BASE_URL}/api/webhook/wix-form/test",
|
||||
json={"test": "data", "timestamp": datetime.now().isoformat()},
|
||||
headers=headers_with_auth,
|
||||
) as response:
|
||||
result = await response.json()
|
||||
if response.status == 200:
|
||||
print(
|
||||
f" ✅ Test endpoint: {response.status} - {result.get('status')}"
|
||||
)
|
||||
else:
|
||||
print(f" ❌ Test endpoint failed: {response.status} - {result}")
|
||||
except Exception as e:
|
||||
print(f" ❌ Test endpoint failed: {e}")
|
||||
|
||||
# Test rate limiting by making multiple rapid requests
|
||||
print("\n6. Testing rate limiting (making 5 rapid requests)...")
|
||||
rate_limit_test_count = 0
|
||||
for i in range(5):
|
||||
try:
|
||||
async with session.get(f"{BASE_URL}/api/health") as response:
|
||||
if response.status == 200:
|
||||
rate_limit_test_count += 1
|
||||
elif response.status == 429:
|
||||
print(f" ✅ Rate limit triggered on request {i + 1}")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f" ❌ Rate limit test failed: {e}")
|
||||
break
|
||||
|
||||
if rate_limit_test_count == 5:
|
||||
print(" ℹ️ No rate limit reached (normal for low request volume)")
|
||||
|
||||
# Test admin endpoint (if admin key is configured)
|
||||
print("\n7. Testing admin stats endpoint...")
|
||||
try:
|
||||
async with session.get(
|
||||
f"{BASE_URL}/api/admin/stats", headers=admin_headers
|
||||
) as response:
|
||||
result = await response.json()
|
||||
if response.status == 200:
|
||||
print(
|
||||
f" ✅ Admin stats: {response.status} - {result.get('status')}"
|
||||
)
|
||||
elif response.status == 401:
|
||||
print(
|
||||
f" ⚠️ Admin access denied (API key not configured): {result.get('detail')}"
|
||||
)
|
||||
else:
|
||||
print(f" ❌ Admin endpoint failed: {response.status} - {result}")
|
||||
except Exception as e:
|
||||
print(f" ❌ Admin test failed: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("🔒 Testing Secure Wix Form Handler API...")
|
||||
print("=" * 60)
|
||||
print("📍 API URL:", BASE_URL)
|
||||
print(
|
||||
"🔑 Using API Key:",
|
||||
TEST_API_KEY[:20] + "..." if len(TEST_API_KEY) > 20 else TEST_API_KEY,
|
||||
)
|
||||
print(
|
||||
"🔐 Using Admin Key:",
|
||||
ADMIN_API_KEY[:20] + "..." if len(ADMIN_API_KEY) > 20 else ADMIN_API_KEY,
|
||||
)
|
||||
print("=" * 60)
|
||||
print("Make sure the API is running with: python3 run_api.py")
|
||||
print("-" * 60)
|
||||
|
||||
try:
|
||||
asyncio.run(test_api())
|
||||
print("\n" + "=" * 60)
|
||||
print("✅ Testing completed!")
|
||||
print("\n📋 Quick Setup Reminder:")
|
||||
print("1. Set environment variables:")
|
||||
print(" export WIX_API_KEY='your_secure_api_key'")
|
||||
print(" export ADMIN_API_KEY='your_admin_key'")
|
||||
print("2. Configure Wix webhook URL: https://yourdomain.com/webhook/wix-form")
|
||||
print("3. Add Authorization header: Bearer your_api_key")
|
||||
except Exception as e:
|
||||
print(f"\n❌ Error testing API: {e}")
|
||||
print("Make sure the API server is running!")
|
||||
@@ -1,43 +0,0 @@
|
||||
from ..generated.alpinebits import OtaPingRq, OtaPingRs
|
||||
from xsdata_pydantic.bindings import XmlParser
|
||||
|
||||
|
||||
def main():
|
||||
# test parsing a ping request sample
|
||||
|
||||
path = (
|
||||
"AlpineBits-HotelData-2024-10/files/samples/Handshake/Handshake-OTA_PingRS.xml"
|
||||
)
|
||||
|
||||
with open(path, "r", 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)
|
||||
|
||||
warning = parsed_result.warnings.warning[0]
|
||||
|
||||
print(warning.type_value)
|
||||
|
||||
print(type(warning.content))
|
||||
|
||||
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()
|
||||
@@ -1,7 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Convenience launcher for the Wix Form Handler API
|
||||
"""
|
||||
"""Convenience launcher for the Wix Form Handler API."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
@@ -11,4 +9,4 @@ src_dir = os.path.join(os.path.dirname(__file__), "src/alpine_bits_python")
|
||||
|
||||
# Run the API using uv
|
||||
if __name__ == "__main__":
|
||||
subprocess.run(["uv", "run", "python", os.path.join(src_dir, "run_api.py")])
|
||||
subprocess.run(["uv", "run", "python", os.path.join(src_dir, "run_api.py")], check=False)
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Quick test to demonstrate how the ServerCapabilities automatically
|
||||
discovers implemented vs unimplemented actions.
|
||||
"""
|
||||
|
||||
from alpine_bits_python.alpinebits_server import (
|
||||
ServerCapabilities,
|
||||
AlpineBitsAction,
|
||||
AlpineBitsActionName,
|
||||
Version,
|
||||
AlpineBitsResponse,
|
||||
HttpStatusCode,
|
||||
)
|
||||
import asyncio
|
||||
|
||||
|
||||
class NewImplementedAction(AlpineBitsAction):
|
||||
"""A new action that IS implemented."""
|
||||
|
||||
def __init__(self):
|
||||
self.name = AlpineBitsActionName.OTA_HOTEL_DESCRIPTIVE_INFO_INFO
|
||||
self.version = Version.V2024_10
|
||||
|
||||
async def handle(
|
||||
self, action: str, request_xml: str, version: Version
|
||||
) -> AlpineBitsResponse:
|
||||
"""This action is implemented."""
|
||||
return AlpineBitsResponse("Implemented!", HttpStatusCode.OK)
|
||||
|
||||
|
||||
class NewUnimplementedAction(AlpineBitsAction):
|
||||
"""A new action that is NOT implemented (no handle override)."""
|
||||
|
||||
def __init__(self):
|
||||
self.name = AlpineBitsActionName.OTA_HOTEL_DESCRIPTIVE_CONTENT_NOTIF_INFO
|
||||
self.version = Version.V2024_10
|
||||
|
||||
# Notice: No handle method override - will use default "not implemented"
|
||||
|
||||
|
||||
async def main():
|
||||
print("🔍 Testing Action Discovery Logic")
|
||||
print("=" * 50)
|
||||
|
||||
# Create capabilities and see what gets discovered
|
||||
capabilities = ServerCapabilities()
|
||||
|
||||
print("📋 Actions found by discovery:")
|
||||
for action_name in capabilities.get_supported_actions():
|
||||
print(f" ✅ {action_name}")
|
||||
|
||||
print(f"\n📊 Total discovered: {len(capabilities.get_supported_actions())}")
|
||||
|
||||
# Test the new implemented action
|
||||
implemented_action = NewImplementedAction()
|
||||
result = await implemented_action.handle("test", "<xml/>", Version.V2024_10)
|
||||
print(f"\n🟢 NewImplementedAction result: {result.xml_content}")
|
||||
|
||||
# Test the unimplemented action (should use default behavior)
|
||||
unimplemented_action = NewUnimplementedAction()
|
||||
result = await unimplemented_action.handle("test", "<xml/>", Version.V2024_10)
|
||||
print(f"🔴 NewUnimplementedAction result: {result.xml_content}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1,15 +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)
|
||||
|
||||
# Create server instance
|
||||
server = AlpineBitsServer()
|
||||
@@ -17,19 +14,15 @@ async def main():
|
||||
# Read the sample handshake request
|
||||
with open(
|
||||
"AlpineBits-HotelData-2024-10/files/samples/Handshake/Handshake-OTA_PingRQ.xml",
|
||||
"r",
|
||||
) as f:
|
||||
ping_request_xml = f.read()
|
||||
|
||||
print("📤 Sending handshake request...")
|
||||
|
||||
# Handle the ping request
|
||||
response = await server.handle_request(
|
||||
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__":
|
||||
|
||||
@@ -1,26 +1,20 @@
|
||||
import pytest
|
||||
from typing import Union
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add the src directory to the path so we can import our modules
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from simplified_access import (
|
||||
from alpine_bits_python.alpine_bits_helpers import (
|
||||
AlpineBitsFactory,
|
||||
CustomerData,
|
||||
CustomerFactory,
|
||||
ResGuestFactory,
|
||||
HotelReservationIdData,
|
||||
HotelReservationIdFactory,
|
||||
AlpineBitsFactory,
|
||||
PhoneTechType,
|
||||
OtaMessageType,
|
||||
NotifCustomer,
|
||||
RetrieveCustomer,
|
||||
NotifResGuests,
|
||||
RetrieveResGuests,
|
||||
NotifHotelReservationId,
|
||||
NotifResGuests,
|
||||
OtaMessageType,
|
||||
PhoneTechType,
|
||||
ResGuestFactory,
|
||||
RetrieveCustomer,
|
||||
RetrieveHotelReservationId,
|
||||
RetrieveResGuests,
|
||||
)
|
||||
|
||||
|
||||
0
tests/test_alpine_bits_server.py
Normal file
0
tests/test_alpine_bits_server.py
Normal file
952
tests/test_alpine_bits_server_read.py
Normal file
952
tests/test_alpine_bits_server_read.py
Normal file
@@ -0,0 +1,952 @@
|
||||
"""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.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from datetime import UTC, date, datetime
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import select
|
||||
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, AlpineBitsServer
|
||||
from alpine_bits_python.db import AckedRequest, 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
|
||||
|
||||
# HTTP status code constants
|
||||
HTTP_OK = 200
|
||||
|
||||
|
||||
@pytest_asyncio.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_asyncio.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(2024, 11, 1, 12, 0, 0, tzinfo=UTC),
|
||||
utm_source="google",
|
||||
utm_medium="cpc",
|
||||
utm_campaign="winter2024",
|
||||
utm_term="ski resort",
|
||||
utm_content="ad1",
|
||||
user_comment="Late check-in requested",
|
||||
fbclid="PAZXh0bgNhZW0BMABhZGlkAasmYBTNE3QBp1jWuJ9zIpfEGRJMP63fMAMI405yvG5EtH-OT0PxSkAbBJaudFHR6cMtkdHu_aem_fopaFtECyVPNW9fmWfEkyA",
|
||||
gclid="",
|
||||
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
|
||||
|
||||
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(2024, 12, 2, 12, 0, 0, tzinfo=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-10-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 {
|
||||
"server": {
|
||||
"codecontext": "ADVERTISING",
|
||||
"code": "70597314",
|
||||
"companyname": "99tales Gmbh",
|
||||
"res_id_source_context": "99tales",
|
||||
},
|
||||
"alpine_bits_auth": [
|
||||
{
|
||||
"hotel_id": "HOTEL123",
|
||||
"hotel_name": "Alpine Paradise Resort",
|
||||
"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_config):
|
||||
"""Test creating response with empty reservation list."""
|
||||
response = create_res_retrieve_response([], config=test_config)
|
||||
|
||||
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_config):
|
||||
"""Test creating response with single reservation."""
|
||||
reservation_pairs = [(sample_reservation, sample_customer)]
|
||||
response = create_res_retrieve_response(reservation_pairs, config=test_config)
|
||||
|
||||
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_config,
|
||||
):
|
||||
"""Test creating response with multiple reservations."""
|
||||
reservation_pairs = [
|
||||
(sample_reservation, sample_customer),
|
||||
(minimal_reservation, minimal_customer),
|
||||
]
|
||||
response = create_res_retrieve_response(reservation_pairs, config=test_config)
|
||||
|
||||
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_config
|
||||
):
|
||||
"""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=test_config)
|
||||
|
||||
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-10-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_config,
|
||||
):
|
||||
"""Test serialization of retrieve response to XML."""
|
||||
reservation_pairs = [(sample_reservation, sample_customer)]
|
||||
response = create_res_retrieve_response(reservation_pairs, config=test_config)
|
||||
|
||||
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_config):
|
||||
"""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=test_config)
|
||||
|
||||
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_config):
|
||||
"""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=test_config)
|
||||
|
||||
config = SerializerConfig(pretty_print=True)
|
||||
serializer = XmlSerializer(config=config)
|
||||
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
|
||||
|
||||
|
||||
class TestAcknowledgments:
|
||||
"""Test acknowledgments.
|
||||
|
||||
1. Setup AlpineBitsServer so that it can respond to sample read requests.
|
||||
2. Send acknowledgment requests and verify responses.
|
||||
3. Verify that acknowledgments are recorded in the database.
|
||||
4. Verify that Read Requests no longer return already acknowledged reservations.
|
||||
5. Verify that that still happens when SelectionCriteria date filters are applied.
|
||||
|
||||
"""
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def populated_db_session(
|
||||
self,
|
||||
test_db_session,
|
||||
sample_reservation,
|
||||
sample_customer,
|
||||
minimal_reservation,
|
||||
minimal_customer,
|
||||
):
|
||||
"""Create a database session with sample data."""
|
||||
# Add customers
|
||||
test_db_session.add(sample_customer)
|
||||
test_db_session.add(minimal_customer)
|
||||
await test_db_session.commit()
|
||||
|
||||
# Add reservations
|
||||
test_db_session.add(sample_reservation)
|
||||
test_db_session.add(minimal_reservation)
|
||||
await test_db_session.commit()
|
||||
|
||||
return test_db_session
|
||||
|
||||
@pytest.fixture
|
||||
def alpinebits_server(self, test_config):
|
||||
"""Create AlpineBitsServer instance for testing."""
|
||||
return AlpineBitsServer(config=test_config)
|
||||
|
||||
@pytest.fixture
|
||||
def notif_report_xml_template(self):
|
||||
"""Template for OTA_NotifReportRQ XML request."""
|
||||
return """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<OTA_NotifReportRQ xmlns="http://www.opentravel.org/OTA/2003/05"
|
||||
EchoToken="ACK-12345"
|
||||
TimeStamp="2024-10-07T10:00:00"
|
||||
Version="7.000">
|
||||
<NotifDetails>
|
||||
<HotelNotifReport>
|
||||
<HotelReservations>
|
||||
{reservations}
|
||||
</HotelReservations>
|
||||
</HotelNotifReport>
|
||||
</NotifDetails>
|
||||
</OTA_NotifReportRQ>"""
|
||||
|
||||
def create_notif_report_xml(self, unique_ids):
|
||||
"""Create a notification report XML with given unique IDs."""
|
||||
template = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<OTA_NotifReportRQ xmlns="http://www.opentravel.org/OTA/2003/05"
|
||||
EchoToken="ACK-12345"
|
||||
TimeStamp="2024-10-07T10:00:00"
|
||||
Version="7.000">
|
||||
<NotifDetails>
|
||||
<HotelNotifReport>
|
||||
<HotelReservations>
|
||||
{reservations}
|
||||
</HotelReservations>
|
||||
</HotelNotifReport>
|
||||
</NotifDetails>
|
||||
</OTA_NotifReportRQ>"""
|
||||
|
||||
reservations = ""
|
||||
for unique_id in unique_ids:
|
||||
reservations += f'<HotelReservation><UniqueID Type="14" ID="{unique_id}"/></HotelReservation>'
|
||||
|
||||
return template.format(reservations=reservations)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_server_responds_to_read_requests(
|
||||
self, alpinebits_server, populated_db_session, client_info, read_request_xml
|
||||
):
|
||||
"""Test 1: Setup AlpineBitsServer so that it can respond to sample read requests."""
|
||||
# Send a read request and verify we get a response
|
||||
response = await alpinebits_server.handle_request(
|
||||
request_action_name="OTA_Read:GuestRequests",
|
||||
request_xml=read_request_xml,
|
||||
client_info=client_info,
|
||||
version="2024-10",
|
||||
dbsession=populated_db_session,
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.status_code == HTTP_OK
|
||||
assert response.xml_content is not None
|
||||
|
||||
# Verify response contains reservation data
|
||||
assert "OTA_ResRetrieveRS" in response.xml_content
|
||||
assert "HOTEL123" in response.xml_content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_acknowledgment_and_verify_response(
|
||||
self, alpinebits_server, populated_db_session, client_info
|
||||
):
|
||||
"""Test 2: Send acknowledgment requests and verify responses."""
|
||||
# First, get the unique IDs from a read request
|
||||
read_xml = """<?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>"""
|
||||
|
||||
# Get reservations first
|
||||
_read_response = await alpinebits_server.handle_request(
|
||||
request_action_name="OTA_Read:GuestRequests",
|
||||
request_xml=read_xml,
|
||||
client_info=client_info,
|
||||
version="2024-10",
|
||||
dbsession=populated_db_session,
|
||||
)
|
||||
|
||||
# Extract unique IDs from the response (we'll use test unique IDs)
|
||||
test_unique_ids = [
|
||||
"RES-2024-001",
|
||||
"RES-2024-002",
|
||||
] # In reality, these would be extracted from read response
|
||||
|
||||
# Create acknowledgment request
|
||||
notif_report_xml = self.create_notif_report_xml(test_unique_ids)
|
||||
|
||||
# Send acknowledgment
|
||||
ack_response = await alpinebits_server.handle_request(
|
||||
request_action_name="OTA_NotifReport:GuestRequests",
|
||||
request_xml=notif_report_xml,
|
||||
client_info=client_info,
|
||||
version="2024-10",
|
||||
dbsession=populated_db_session,
|
||||
)
|
||||
|
||||
assert ack_response is not None
|
||||
assert ack_response.status_code == HTTP_OK
|
||||
assert "OTA_NotifReportRS" in ack_response.xml_content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acknowledgments_recorded_in_database(
|
||||
self, alpinebits_server, populated_db_session, client_info
|
||||
):
|
||||
"""Test 3: Verify that acknowledgments are recorded in the database."""
|
||||
# Create acknowledgment request
|
||||
test_unique_ids = ["test-ack-id-1", "test-ack-id-2"]
|
||||
notif_report_xml = self.create_notif_report_xml(test_unique_ids)
|
||||
|
||||
# Count existing acked requests
|
||||
result = await populated_db_session.execute(select(AckedRequest))
|
||||
initial_count = len(result.all())
|
||||
|
||||
# Send acknowledgment
|
||||
await alpinebits_server.handle_request(
|
||||
request_action_name="OTA_NotifReport:GuestRequests",
|
||||
request_xml=notif_report_xml,
|
||||
client_info=client_info,
|
||||
version="2024-10",
|
||||
dbsession=populated_db_session,
|
||||
)
|
||||
|
||||
# Verify acknowledgments were recorded
|
||||
result = await populated_db_session.execute(select(AckedRequest))
|
||||
acked_requests = result.all()
|
||||
assert len(acked_requests) == initial_count + 2
|
||||
|
||||
# Verify the specific acknowledgments
|
||||
acked_ids = [req[0].unique_id for req in acked_requests]
|
||||
assert "test-ack-id-1" in acked_ids
|
||||
assert "test-ack-id-2" in acked_ids
|
||||
|
||||
# Verify client ID is recorded
|
||||
for req in acked_requests[-2:]: # Last 2 requests
|
||||
assert req[0].client_id == client_info.client_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_excludes_acknowledged_reservations(
|
||||
self, alpinebits_server, populated_db_session, client_info
|
||||
):
|
||||
"""Test 4: Verify that Read Requests no longer return already acknowledged reservations."""
|
||||
# First read request - should return all reservations
|
||||
read_xml = """<?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>"""
|
||||
|
||||
initial_response = await alpinebits_server.handle_request(
|
||||
request_action_name="OTA_Read:GuestRequests",
|
||||
request_xml=read_xml,
|
||||
client_info=client_info,
|
||||
version="2024-10",
|
||||
dbsession=populated_db_session,
|
||||
)
|
||||
|
||||
# Parse response to count initial reservations
|
||||
parser = XmlParser()
|
||||
initial_parsed = parser.from_string(
|
||||
initial_response.xml_content, OtaResRetrieveRs
|
||||
)
|
||||
initial_count = 0
|
||||
if (
|
||||
initial_parsed.reservations_list
|
||||
and initial_parsed.reservations_list.hotel_reservation
|
||||
):
|
||||
initial_count = len(initial_parsed.reservations_list.hotel_reservation)
|
||||
|
||||
# Acknowledge one reservation by using its MD5 hash
|
||||
# Get the unique_id from sample reservation and create its MD5
|
||||
sample_unique_id = "RES-2024-001"
|
||||
md5_hash = hashlib.md5(sample_unique_id.encode()).hexdigest()
|
||||
|
||||
# Manually insert acknowledgment
|
||||
acked_request = AckedRequest(
|
||||
unique_id=md5_hash,
|
||||
client_id=client_info.client_id,
|
||||
timestamp=datetime.now(UTC),
|
||||
)
|
||||
populated_db_session.add(acked_request)
|
||||
await populated_db_session.commit()
|
||||
|
||||
# Second read request - should return fewer reservations
|
||||
second_response = await alpinebits_server.handle_request(
|
||||
request_action_name="OTA_Read:GuestRequests",
|
||||
request_xml=read_xml,
|
||||
client_info=client_info,
|
||||
version="2024-10",
|
||||
dbsession=populated_db_session,
|
||||
)
|
||||
|
||||
# Parse second response
|
||||
second_parsed = parser.from_string(
|
||||
second_response.xml_content, OtaResRetrieveRs
|
||||
)
|
||||
second_count = 0
|
||||
if (
|
||||
second_parsed.reservations_list
|
||||
and second_parsed.reservations_list.hotel_reservation
|
||||
):
|
||||
second_count = len(second_parsed.reservations_list.hotel_reservation)
|
||||
|
||||
# Should have one fewer reservation
|
||||
assert second_count == initial_count - 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acknowledgments_work_with_date_filters(
|
||||
self,
|
||||
alpinebits_server,
|
||||
populated_db_session,
|
||||
client_info,
|
||||
read_request_xml_no_date_filter,
|
||||
):
|
||||
"""Test 5: Verify acknowledgments still work when SelectionCriteria date filters are applied."""
|
||||
# Read request with date filter
|
||||
read_xml_with_date = """<?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"/>
|
||||
</HotelReadRequest>
|
||||
</ReadRequests>
|
||||
</OTA_ReadRQ>"""
|
||||
|
||||
# First read with date filter
|
||||
initial_response = await alpinebits_server.handle_request(
|
||||
request_action_name="OTA_Read:GuestRequests",
|
||||
request_xml=read_xml_with_date,
|
||||
client_info=client_info,
|
||||
version="2024-10",
|
||||
dbsession=populated_db_session,
|
||||
)
|
||||
|
||||
parser = XmlParser()
|
||||
initial_parsed = parser.from_string(
|
||||
initial_response.xml_content, OtaResRetrieveRs
|
||||
)
|
||||
initial_count = 0
|
||||
if (
|
||||
initial_parsed.reservations_list
|
||||
and initial_parsed.reservations_list.hotel_reservation
|
||||
):
|
||||
initial_count = len(initial_parsed.reservations_list.hotel_reservation)
|
||||
|
||||
assert initial_count > 0, "Initial count with date filter should be > 0"
|
||||
assert initial_count == 1, (
|
||||
"Should only return one reservation with this date filter"
|
||||
)
|
||||
|
||||
# Acknowledge one reservation that falls within the date range
|
||||
# The sample_reservation was created at 2024-11-01 and thus falls out of range
|
||||
sample_unique_id = "RES-2024-002"
|
||||
md5_hash = hashlib.md5(sample_unique_id.encode()).hexdigest()
|
||||
|
||||
acked_request = AckedRequest(
|
||||
unique_id=md5_hash,
|
||||
client_id=client_info.client_id,
|
||||
timestamp=datetime.now(UTC),
|
||||
)
|
||||
populated_db_session.add(acked_request)
|
||||
await populated_db_session.commit()
|
||||
|
||||
without_filter_read = await alpinebits_server.handle_request(
|
||||
request_action_name="OTA_Read:GuestRequests",
|
||||
request_xml=read_request_xml_no_date_filter,
|
||||
client_info=client_info,
|
||||
version="2024-10",
|
||||
dbsession=populated_db_session,
|
||||
)
|
||||
|
||||
without_filter_parsed = parser.from_string(
|
||||
without_filter_read.xml_content, OtaResRetrieveRs
|
||||
)
|
||||
|
||||
without_filter_count = 0
|
||||
if (
|
||||
without_filter_parsed.reservations_list
|
||||
and without_filter_parsed.reservations_list.hotel_reservation
|
||||
):
|
||||
without_filter_count = len(
|
||||
without_filter_parsed.reservations_list.hotel_reservation
|
||||
)
|
||||
|
||||
assert without_filter_count == 1, (
|
||||
"Without date filter, should return one reservation after acknowledgment"
|
||||
)
|
||||
|
||||
# Second read with same date filter
|
||||
second_response = await alpinebits_server.handle_request(
|
||||
request_action_name="OTA_Read:GuestRequests",
|
||||
request_xml=read_xml_with_date,
|
||||
client_info=client_info,
|
||||
version="2024-10",
|
||||
dbsession=populated_db_session,
|
||||
)
|
||||
|
||||
second_parsed = parser.from_string(
|
||||
second_response.xml_content, OtaResRetrieveRs
|
||||
)
|
||||
second_count = 0
|
||||
if (
|
||||
second_parsed.reservations_list
|
||||
and second_parsed.reservations_list.hotel_reservation
|
||||
):
|
||||
second_count = len(second_parsed.reservations_list.hotel_reservation)
|
||||
|
||||
# Should have exactly the same amount of reservations
|
||||
assert second_count == initial_count, (
|
||||
"Acknowledgment should not affect count when date filter is applied"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_same_customer_multiple_reservations(
|
||||
self,
|
||||
alpinebits_server,
|
||||
test_db_session,
|
||||
client_info,
|
||||
sample_customer,
|
||||
):
|
||||
"""Test same customer with multiple reservations returns all."""
|
||||
# Add the customer to the database
|
||||
test_db_session.add(sample_customer)
|
||||
await test_db_session.commit()
|
||||
|
||||
# Create two reservations for the same customer
|
||||
first_reservation = ReservationData(
|
||||
unique_id="RES-2024-MULTI-001",
|
||||
start_date=date(2024, 12, 25),
|
||||
end_date=date(2024, 12, 31),
|
||||
num_adults=2,
|
||||
num_children=0,
|
||||
children_ages=[],
|
||||
hotel_code="HOTEL123",
|
||||
hotel_name="Alpine Paradise Resort",
|
||||
created_at=datetime(2024, 11, 1, 12, 0, 0, tzinfo=UTC),
|
||||
)
|
||||
|
||||
second_reservation = ReservationData(
|
||||
unique_id="RES-2024-MULTI-002",
|
||||
start_date=date(2025, 3, 15),
|
||||
end_date=date(2025, 3, 20),
|
||||
num_adults=2,
|
||||
num_children=1,
|
||||
children_ages=[10],
|
||||
hotel_code="HOTEL123",
|
||||
hotel_name="Alpine Paradise Resort",
|
||||
created_at=datetime(2024, 11, 15, 10, 0, 0, tzinfo=UTC),
|
||||
)
|
||||
|
||||
# Convert to DB reservations
|
||||
first_data = first_reservation.model_dump(exclude_none=True)
|
||||
children_list = first_data.pop("children_ages", [])
|
||||
children_csv = (
|
||||
",".join(str(int(a)) for a in children_list) if children_list else ""
|
||||
)
|
||||
first_data["children_ages"] = children_csv
|
||||
|
||||
db_first_reservation = Reservation(
|
||||
id=100,
|
||||
customer_id=sample_customer.id,
|
||||
**first_data,
|
||||
)
|
||||
|
||||
second_data = second_reservation.model_dump(exclude_none=True)
|
||||
children_list = second_data.pop("children_ages", [])
|
||||
children_csv = (
|
||||
",".join(str(int(a)) for a in children_list) if children_list else ""
|
||||
)
|
||||
second_data["children_ages"] = children_csv
|
||||
|
||||
db_second_reservation = Reservation(
|
||||
id=101,
|
||||
customer_id=sample_customer.id,
|
||||
**second_data,
|
||||
)
|
||||
|
||||
# Add both reservations to the database
|
||||
test_db_session.add(db_first_reservation)
|
||||
test_db_session.add(db_second_reservation)
|
||||
await test_db_session.commit()
|
||||
|
||||
# Send read request
|
||||
read_xml = """<?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>"""
|
||||
|
||||
response = await alpinebits_server.handle_request(
|
||||
request_action_name="OTA_Read:GuestRequests",
|
||||
request_xml=read_xml,
|
||||
client_info=client_info,
|
||||
version="2024-10",
|
||||
dbsession=test_db_session,
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.status_code == HTTP_OK
|
||||
|
||||
# Parse response to verify both reservations are returned
|
||||
parser = XmlParser()
|
||||
parsed_response = parser.from_string(response.xml_content, OtaResRetrieveRs)
|
||||
|
||||
assert parsed_response.reservations_list is not None
|
||||
assert parsed_response.reservations_list.hotel_reservation is not None
|
||||
|
||||
reservation_count = len(parsed_response.reservations_list.hotel_reservation)
|
||||
expected_reservations = 2
|
||||
assert reservation_count == expected_reservations, (
|
||||
"Should return 2 reservations for the same customer"
|
||||
)
|
||||
|
||||
# Verify both reservations are present in the response
|
||||
xml_content = response.xml_content
|
||||
assert "John" in xml_content # Customer first name
|
||||
assert "Doe" in xml_content # Customer last name
|
||||
|
||||
# Both reservations should be linked to the same customer
|
||||
# Verify this by checking that customer appears in both reservation contexts
|
||||
min_customer_name_occurrences = 2
|
||||
assert xml_content.count("John") >= min_customer_name_occurrences, (
|
||||
"Customer name should appear for each reservation"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
99
tests/test_alpinebits_server_ping.py
Normal file
99
tests/test_alpinebits_server_ping.py
Normal file
@@ -0,0 +1,99 @@
|
||||
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()
|
||||
return parser.from_string(xml_string, OtaPingRs)
|
||||
|
||||
|
||||
@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
|
||||
723
tests/test_api.py
Normal file
723
tests/test_api.py
Normal file
@@ -0,0 +1,723 @@
|
||||
"""Tests for API endpoints using FastAPI TestClient.
|
||||
|
||||
This module tests all FastAPI endpoints including:
|
||||
- Health check endpoints
|
||||
- Wix webhook endpoints
|
||||
- AlpineBits server endpoint
|
||||
- XML upload endpoint
|
||||
- Authentication
|
||||
- Rate limiting
|
||||
"""
|
||||
|
||||
import base64
|
||||
import gzip
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from alpine_bits_python.api import app
|
||||
from alpine_bits_python.db import Base, Customer, Reservation
|
||||
|
||||
|
||||
@pytest_asyncio.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_asyncio.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 test_config():
|
||||
"""Test configuration."""
|
||||
return {
|
||||
"server": {
|
||||
"codecontext": "ADVERTISING",
|
||||
"code": "70597314",
|
||||
"companyname": "99tales Gmbh",
|
||||
"res_id_source_context": "99tales",
|
||||
},
|
||||
"alpine_bits_auth": [
|
||||
{
|
||||
"hotel_id": "HOTEL123",
|
||||
"hotel_name": "Test Hotel",
|
||||
"username": "testuser",
|
||||
"password": "testpass",
|
||||
}
|
||||
],
|
||||
"default_hotel_code": "HOTEL123",
|
||||
"default_hotel_name": "Test Hotel",
|
||||
"database": {"url": "sqlite+aiosqlite:///:memory:"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(test_config):
|
||||
"""Create a test client with mocked dependencies.
|
||||
|
||||
Each test gets a fresh TestClient instance to avoid database conflicts.
|
||||
Mocks load_config to return test_config instead of production config.
|
||||
"""
|
||||
# Import locally to avoid circular imports
|
||||
from alpine_bits_python.alpinebits_server import AlpineBitsServer # noqa: PLC0415
|
||||
|
||||
# Mock load_config to return test_config instead of production config
|
||||
with patch("alpine_bits_python.api.load_config", return_value=test_config):
|
||||
# Create a new in-memory database for each test
|
||||
engine = create_async_engine(
|
||||
"sqlite+aiosqlite:///:memory:",
|
||||
echo=False,
|
||||
)
|
||||
|
||||
# Setup app state (will be overridden by lifespan but we set it anyway)
|
||||
app.state.engine = engine
|
||||
app.state.async_sessionmaker = async_sessionmaker(
|
||||
engine, expire_on_commit=False
|
||||
)
|
||||
app.state.config = test_config
|
||||
app.state.alpine_bits_server = AlpineBitsServer(test_config)
|
||||
|
||||
# TestClient will trigger lifespan events which create the tables
|
||||
# The mocked load_config will ensure test_config is used
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_wix_form_data():
|
||||
"""Sample Wix form submission data.
|
||||
|
||||
Each call generates unique IDs to avoid database conflicts.
|
||||
"""
|
||||
unique_id = uuid.uuid4().hex[:8]
|
||||
return {
|
||||
"data": {
|
||||
"submissionId": f"test-submission-{unique_id}",
|
||||
"submissionTime": "2025-10-07T05:48:41.855Z",
|
||||
"contact": {
|
||||
"name": {"first": "John", "last": "Doe"},
|
||||
"email": f"john.doe.{unique_id}@example.com",
|
||||
"phones": [{"e164Phone": "+1234567890"}],
|
||||
"locale": "en-US",
|
||||
"contactId": f"contact-{unique_id}",
|
||||
},
|
||||
"field:anrede": "Mr.",
|
||||
"field:form_field_5a7b": "Checked",
|
||||
"field:date_picker_a7c8": "2024-12-25",
|
||||
"field:date_picker_7e65": "2024-12-31",
|
||||
"field:number_7cf5": "2",
|
||||
"field:anzahl_kinder": "1",
|
||||
"field:alter_kind_1": "8",
|
||||
"field:angebot_auswaehlen": "Christmas Special",
|
||||
"field:utm_source": "google",
|
||||
"field:utm_medium": "cpc",
|
||||
"field:utm_campaign": "winter2024",
|
||||
"field:fbclid": "test_fbclid_123",
|
||||
"field:long_answer_3524": "Late check-in please",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def basic_auth_headers():
|
||||
"""Create Basic Auth headers for testing."""
|
||||
credentials = base64.b64encode(b"testuser:testpass").decode("utf-8")
|
||||
return {"Authorization": f"Basic {credentials}"}
|
||||
|
||||
|
||||
class TestHealthEndpoints:
|
||||
"""Test health check and root endpoints."""
|
||||
|
||||
def test_root_endpoint(self, client):
|
||||
"""Test GET / returns health status."""
|
||||
response = client.get("/api/")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["message"] == "Wix Form Handler API is running"
|
||||
assert "timestamp" in data
|
||||
assert data["status"] == "healthy"
|
||||
assert "rate_limits" in data
|
||||
|
||||
def test_health_check_endpoint(self, client):
|
||||
"""Test GET /api/health returns healthy status."""
|
||||
response = client.get("/api/health")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "healthy"
|
||||
assert data["service"] == "wix-form-handler"
|
||||
assert data["version"] == "1.0.0"
|
||||
assert "timestamp" in data
|
||||
|
||||
def test_landing_page(self, client):
|
||||
"""Test GET / (landing page) returns HTML."""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
assert "99tales" in response.text or "Construction" in response.text
|
||||
|
||||
|
||||
class TestWixWebhookEndpoint:
|
||||
"""Test Wix form webhook endpoint."""
|
||||
|
||||
def test_wix_webhook_success(self, client, sample_wix_form_data):
|
||||
"""Test successful Wix form submission."""
|
||||
response = client.post("/api/webhook/wix-form", json=sample_wix_form_data)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "success"
|
||||
assert "timestamp" in data
|
||||
assert "data_logged_to" in data
|
||||
|
||||
def test_wix_webhook_creates_customer_and_reservation(
|
||||
self, client, sample_wix_form_data
|
||||
):
|
||||
"""Test that webhook creates customer and reservation in database."""
|
||||
response = client.post("/api/webhook/wix-form", json=sample_wix_form_data)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify data was saved to database
|
||||
# Use the client's app state engine, not a separate test_db_engine
|
||||
async def check_db():
|
||||
engine = client.app.state.engine
|
||||
async_session = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with async_session() as session:
|
||||
from sqlalchemy import select
|
||||
|
||||
# Check customer was created
|
||||
result = await session.execute(select(Customer))
|
||||
customers = result.scalars().all()
|
||||
assert len(customers) == 1
|
||||
customer = customers[0]
|
||||
assert customer.given_name == "John"
|
||||
assert customer.surname == "Doe"
|
||||
# Email address in sample_wix_form_data has unique ID appended
|
||||
assert customer.email_address.startswith("john.doe.")
|
||||
assert "@example.com" in customer.email_address
|
||||
|
||||
# Check reservation was created
|
||||
result = await session.execute(select(Reservation))
|
||||
reservations = result.scalars().all()
|
||||
assert len(reservations) == 1
|
||||
reservation = reservations[0]
|
||||
assert reservation.customer_id == customer.id
|
||||
assert reservation.num_adults == 2
|
||||
assert reservation.num_children == 1
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.run(check_db())
|
||||
|
||||
def test_wix_webhook_minimal_data(self, client):
|
||||
"""Test webhook with minimal required data."""
|
||||
minimal_data = {
|
||||
"data": {
|
||||
"submissionId": "minimal-123",
|
||||
"submissionTime": "2025-01-10T12:00:00.000Z",
|
||||
"contact": {
|
||||
"name": {"first": "Jane", "last": "Smith"},
|
||||
"email": "jane@example.com",
|
||||
},
|
||||
"field:date_picker_a7c8": "2025-01-15",
|
||||
"field:date_picker_7e65": "2025-01-20",
|
||||
}
|
||||
}
|
||||
|
||||
response = client.post("/api/webhook/wix-form", json=minimal_data)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "success"
|
||||
|
||||
def test_wix_webhook_test_endpoint(self, client, sample_wix_form_data):
|
||||
"""Test the test endpoint works identically."""
|
||||
response = client.post("/api/webhook/wix-form/test", json=sample_wix_form_data)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "success"
|
||||
|
||||
def test_wix_webhook_updates_existing_customer(self, client):
|
||||
"""Test that same contact_id updates customer instead of duplicate."""
|
||||
# First submission
|
||||
first_submission = {
|
||||
"data": {
|
||||
"submissionId": "test-submission-001",
|
||||
"submissionTime": "2025-10-07T05:48:41.855Z",
|
||||
"contact": {
|
||||
"name": {"first": "John", "last": "Doe"},
|
||||
"email": "john.doe@example.com",
|
||||
"phones": [{"e164Phone": "+1234567890"}],
|
||||
"locale": "en-US",
|
||||
"contactId": "fixed-contact-id-123",
|
||||
},
|
||||
"field:anrede": "Mr.",
|
||||
"field:date_picker_a7c8": "2024-12-25",
|
||||
"field:date_picker_7e65": "2024-12-31",
|
||||
"field:number_7cf5": "2",
|
||||
"field:anzahl_kinder": "0",
|
||||
}
|
||||
}
|
||||
|
||||
response = client.post("/api/webhook/wix-form", json=first_submission)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Second submission with same contact_id but different data
|
||||
second_submission = {
|
||||
"data": {
|
||||
"submissionId": "test-submission-002",
|
||||
"submissionTime": "2025-10-08T10:30:00.000Z",
|
||||
"contact": {
|
||||
"name": {"first": "John", "last": "Smith"}, # Changed last name
|
||||
"email": "john.smith@example.com", # Changed email
|
||||
"phones": [{"e164Phone": "+9876543210"}], # Changed phone
|
||||
"locale": "de-DE", # Changed locale
|
||||
"contactId": "fixed-contact-id-123", # Same contact_id
|
||||
},
|
||||
"field:anrede": "Dr.", # Changed prefix
|
||||
"field:date_picker_a7c8": "2025-01-10",
|
||||
"field:date_picker_7e65": "2025-01-15",
|
||||
"field:number_7cf5": "4",
|
||||
"field:anzahl_kinder": "2",
|
||||
"field:alter_kind_1": "5",
|
||||
"field:alter_kind_2": "10",
|
||||
}
|
||||
}
|
||||
|
||||
response = client.post("/api/webhook/wix-form", json=second_submission)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify only one customer exists with updated information
|
||||
async def check_db():
|
||||
from sqlalchemy import select # noqa: PLC0415
|
||||
|
||||
engine = client.app.state.engine
|
||||
async_session = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with async_session() as session:
|
||||
|
||||
# Check only one customer exists
|
||||
result = await session.execute(select(Customer))
|
||||
customers = result.scalars().all()
|
||||
assert len(customers) == 1, "Should have exactly one customer"
|
||||
|
||||
customer = customers[0]
|
||||
# Verify customer was updated with new information
|
||||
assert customer.given_name == "John"
|
||||
assert customer.surname == "Smith", "Last name updated"
|
||||
assert (
|
||||
customer.email_address == "john.smith@example.com"
|
||||
), "Email updated"
|
||||
assert customer.phone == "+9876543210", "Phone updated"
|
||||
assert customer.name_prefix == "Dr.", "Prefix updated"
|
||||
assert customer.language == "de", "Language updated"
|
||||
assert customer.contact_id == "fixed-contact-id-123"
|
||||
|
||||
# Check both reservations were created
|
||||
result = await session.execute(select(Reservation))
|
||||
reservations = result.scalars().all()
|
||||
expected_reservations = 2
|
||||
assert len(reservations) == expected_reservations
|
||||
# Both reservations should be linked to the same customer
|
||||
assert all(r.customer_id == customer.id for r in reservations)
|
||||
|
||||
import asyncio # noqa: PLC0415
|
||||
|
||||
asyncio.run(check_db())
|
||||
|
||||
|
||||
class TestGenericWebhookEndpoint:
|
||||
"""Test generic webhook endpoint."""
|
||||
|
||||
def test_generic_webhook_success(self, client):
|
||||
"""Test successful generic webhook submission."""
|
||||
test_data = {
|
||||
"event_type": "test_event",
|
||||
"data": {
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
"nested": {"foo": "bar"},
|
||||
},
|
||||
"metadata": {"source": "test_system"},
|
||||
}
|
||||
|
||||
response = client.post("/api/webhook/generic", json=test_data)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "success"
|
||||
assert "timestamp" in data
|
||||
assert "data_logged_to" in data
|
||||
assert "generic_webhooks" in data["data_logged_to"]
|
||||
assert data["note"] == "Data logged for later analysis"
|
||||
|
||||
def test_generic_webhook_empty_payload(self, client):
|
||||
"""Test generic webhook with empty payload."""
|
||||
response = client.post("/api/webhook/generic", json={})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "success"
|
||||
|
||||
def test_generic_webhook_complex_nested_data(self, client):
|
||||
"""Test generic webhook with complex nested data structures."""
|
||||
complex_data = {
|
||||
"arrays": [1, 2, 3],
|
||||
"nested": {"level1": {"level2": {"level3": "deep"}}},
|
||||
"mixed": [{"a": 1}, {"b": 2}],
|
||||
}
|
||||
|
||||
response = client.post("/api/webhook/generic", json=complex_data)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "success"
|
||||
|
||||
|
||||
class TestAlpineBitsServerEndpoint:
|
||||
"""Test AlpineBits server endpoint."""
|
||||
|
||||
def test_alpinebits_handshake_ping_success(self, client, basic_auth_headers):
|
||||
"""Test AlpineBits handshake with OTA_Ping action using real test data."""
|
||||
# Use the actual test data file with proper AlpineBits handshake format
|
||||
with Path("tests/test_data/Handshake-OTA_PingRQ.xml").open(
|
||||
encoding="utf-8"
|
||||
) as f:
|
||||
ping_xml = f.read()
|
||||
|
||||
# Prepare multipart form data
|
||||
form_data = {"action": "OTA_Ping:Handshaking", "request": ping_xml}
|
||||
|
||||
headers = {
|
||||
**basic_auth_headers,
|
||||
"X-AlpineBits-ClientProtocolVersion": "2024-10",
|
||||
"X-AlpineBits-ClientID": "TEST-CLIENT-001",
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
"/api/alpinebits/server-2024-10",
|
||||
data=form_data,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "OTA_PingRS" in response.text
|
||||
assert "application/xml" in response.headers["content-type"]
|
||||
assert "X-AlpineBits-Server-Version" in response.headers
|
||||
|
||||
def test_alpinebits_missing_auth(self, client):
|
||||
"""Test AlpineBits endpoint without authentication."""
|
||||
form_data = {"action": "OTA_Ping:Handshaking", "request": "<xml/>"}
|
||||
|
||||
response = client.post("/api/alpinebits/server-2024-10", data=form_data)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_alpinebits_invalid_credentials(self, client):
|
||||
"""Test AlpineBits endpoint with invalid credentials."""
|
||||
credentials = base64.b64encode(b"wrong:credentials").decode("utf-8")
|
||||
headers = {"Authorization": f"Basic {credentials}"}
|
||||
|
||||
form_data = {"action": "OTA_Ping:Handshaking", "request": "<xml/>"}
|
||||
|
||||
response = client.post(
|
||||
"/api/alpinebits/server-2024-10", data=form_data, headers=headers
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_alpinebits_missing_action(self, client, basic_auth_headers):
|
||||
"""Test AlpineBits endpoint without action parameter."""
|
||||
headers = {
|
||||
**basic_auth_headers,
|
||||
"X-AlpineBits-ClientProtocolVersion": "2024-10",
|
||||
}
|
||||
|
||||
form_data = {"request": "<xml/>"}
|
||||
|
||||
response = client.post(
|
||||
"/api/alpinebits/server-2024-10", data=form_data, headers=headers
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_alpinebits_gzip_compression(self, client, basic_auth_headers):
|
||||
"""Test AlpineBits endpoint with gzip compressed request."""
|
||||
# Use real test data
|
||||
with open("tests/test_data/Handshake-OTA_PingRQ.xml", encoding="utf-8") as f:
|
||||
ping_xml = f.read()
|
||||
|
||||
form_data = f"action=OTA_Ping:Handshaking&request={ping_xml}"
|
||||
compressed_data = gzip.compress(form_data.encode("utf-8"))
|
||||
|
||||
headers = {
|
||||
**basic_auth_headers,
|
||||
"X-AlpineBits-ClientProtocolVersion": "2024-10",
|
||||
"Content-Encoding": "gzip",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
"/api/alpinebits/server-2024-10",
|
||||
content=compressed_data,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "OTA_PingRS" in response.text
|
||||
|
||||
|
||||
class TestXMLUploadEndpoint:
|
||||
"""Test XML upload endpoint for conversions."""
|
||||
|
||||
def test_xml_upload_success(self, client, basic_auth_headers):
|
||||
"""Test successful XML upload."""
|
||||
xml_content = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<OTA_HotelResNotifRQ xmlns="http://www.opentravel.org/OTA/2003/05">
|
||||
<HotelReservations>
|
||||
<HotelReservation>
|
||||
<UniqueID Type="14" ID="TEST-123"/>
|
||||
</HotelReservation>
|
||||
</HotelReservations>
|
||||
</OTA_HotelResNotifRQ>"""
|
||||
|
||||
response = client.put(
|
||||
"/api/hoteldata/conversions_import/test_reservation.xml",
|
||||
content=xml_content.encode("utf-8"),
|
||||
headers={**basic_auth_headers, "Content-Type": "application/xml"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Xml received" in response.text
|
||||
|
||||
def test_xml_upload_gzip_compressed(self, client, basic_auth_headers):
|
||||
"""Test XML upload with gzip compression."""
|
||||
xml_content = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<OTA_HotelResNotifRQ xmlns="http://www.opentravel.org/OTA/2003/05">
|
||||
<HotelReservations/>
|
||||
</OTA_HotelResNotifRQ>"""
|
||||
|
||||
compressed = gzip.compress(xml_content.encode("utf-8"))
|
||||
|
||||
headers = {
|
||||
**basic_auth_headers,
|
||||
"Content-Type": "application/xml",
|
||||
"Content-Encoding": "gzip",
|
||||
}
|
||||
|
||||
response = client.put(
|
||||
"/api/hoteldata/conversions_import/compressed.xml",
|
||||
content=compressed,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_xml_upload_missing_auth(self, client):
|
||||
"""Test XML upload without authentication."""
|
||||
response = client.put(
|
||||
"/api/hoteldata/conversions_import/test.xml",
|
||||
content=b"<xml/>",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_xml_upload_invalid_path(self, client, basic_auth_headers):
|
||||
"""Test XML upload with path traversal attempt.
|
||||
|
||||
Path traversal is blocked by the server, resulting in 404 Not Found.
|
||||
"""
|
||||
response = client.put(
|
||||
"/api/hoteldata/conversions_import/../../../etc/passwd",
|
||||
content=b"<xml/>",
|
||||
headers=basic_auth_headers,
|
||||
)
|
||||
|
||||
# Path traversal results in 404 as the normalized path doesn't match the route
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_xml_upload_empty_content(self, client, basic_auth_headers):
|
||||
"""Test XML upload with empty content."""
|
||||
response = client.put(
|
||||
"/api/hoteldata/conversions_import/empty.xml",
|
||||
content=b"",
|
||||
headers=basic_auth_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_xml_upload_non_xml_content(self, client, basic_auth_headers):
|
||||
"""Test XML upload with non-XML content."""
|
||||
response = client.put(
|
||||
"/api/hoteldata/conversions_import/notxml.xml",
|
||||
content=b"This is not XML content",
|
||||
headers=basic_auth_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
class TestAuthentication:
|
||||
"""Test authentication and authorization."""
|
||||
|
||||
def test_basic_auth_success(self, client):
|
||||
"""Test successful basic authentication."""
|
||||
credentials = base64.b64encode(b"testuser:testpass").decode("utf-8")
|
||||
headers = {"Authorization": f"Basic {credentials}"}
|
||||
|
||||
form_data = {"action": "OTA_Ping:Handshaking", "request": "<xml/>"}
|
||||
|
||||
response = client.post(
|
||||
"/api/alpinebits/server-2024-10",
|
||||
data=form_data,
|
||||
headers={
|
||||
**headers,
|
||||
"X-AlpineBits-ClientProtocolVersion": "2024-10",
|
||||
},
|
||||
)
|
||||
|
||||
# Should not be 401
|
||||
assert response.status_code != 401
|
||||
|
||||
def test_basic_auth_missing_credentials(self, client):
|
||||
"""Test basic auth with missing credentials."""
|
||||
response = client.post(
|
||||
"/api/alpinebits/server-2024-10",
|
||||
data={"action": "OTA_Ping:Handshaking"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_basic_auth_malformed_header(self, client):
|
||||
"""Test basic auth with malformed Authorization header."""
|
||||
headers = {"Authorization": "Basic malformed"}
|
||||
|
||||
response = client.post(
|
||||
"/api/alpinebits/server-2024-10",
|
||||
data={"action": "OTA_Ping:Handshaking"},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
# FastAPI should handle this gracefully
|
||||
assert response.status_code in [401, 422]
|
||||
|
||||
|
||||
class TestEventDispatcher:
|
||||
"""Test event dispatcher and push notifications."""
|
||||
|
||||
def test_form_submission_triggers_event(
|
||||
self, client, sample_wix_form_data
|
||||
):
|
||||
"""Test that form submission triggers event dispatcher."""
|
||||
# Just verify the endpoint works with the event dispatcher
|
||||
# The async task runs in background and doesn't affect response
|
||||
response = client.post("/api/webhook/wix-form", json=sample_wix_form_data)
|
||||
|
||||
assert response.status_code == 200
|
||||
# Event dispatcher is tested separately in its own test suite
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
"""Test error handling across endpoints."""
|
||||
|
||||
def test_wix_webhook_invalid_json(self, client):
|
||||
"""Test webhook with invalid JSON."""
|
||||
response = client.post(
|
||||
"/api/webhook/wix-form",
|
||||
content=b"invalid json {{{",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_wix_webhook_missing_required_fields(self, client):
|
||||
"""Test webhook with missing required fields."""
|
||||
invalid_data = {"data": {}}
|
||||
|
||||
response = client.post("/api/webhook/wix-form", json=invalid_data)
|
||||
|
||||
# Should handle gracefully - may be 500 or 400 depending on validation
|
||||
assert response.status_code in [400, 500]
|
||||
|
||||
def test_alpinebits_invalid_xml(self, client, basic_auth_headers):
|
||||
"""Test AlpineBits endpoint with invalid XML."""
|
||||
form_data = {
|
||||
"action": "OTA_Ping:Handshaking",
|
||||
"request": "<<invalid xml>>",
|
||||
}
|
||||
|
||||
headers = {
|
||||
**basic_auth_headers,
|
||||
"X-AlpineBits-ClientProtocolVersion": "2024-10",
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
"/api/alpinebits/server-2024-10",
|
||||
data=form_data,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
# Should return error response
|
||||
assert response.status_code in [400, 500]
|
||||
|
||||
|
||||
class TestCORS:
|
||||
"""Test CORS configuration."""
|
||||
|
||||
def test_cors_preflight_request(self, client):
|
||||
"""Test CORS preflight request."""
|
||||
response = client.options(
|
||||
"/api/health",
|
||||
headers={
|
||||
"Origin": "https://example.wix.com",
|
||||
"Access-Control-Request-Method": "POST",
|
||||
},
|
||||
)
|
||||
|
||||
# TestClient returns 400 for OPTIONS requests
|
||||
# In production, CORS middleware handles preflight correctly
|
||||
assert response.status_code in [200, 400, 405]
|
||||
|
||||
|
||||
class TestRateLimiting:
|
||||
"""Test rate limiting (requires actual rate limiter to be active)."""
|
||||
|
||||
def test_health_endpoint_rate_limit(self, client):
|
||||
"""Test that health endpoint has rate limiting configured."""
|
||||
# Make multiple requests
|
||||
responses = []
|
||||
for _ in range(5):
|
||||
response = client.get("/api/health")
|
||||
responses.append(response.status_code)
|
||||
|
||||
# All should succeed if under limit
|
||||
assert all(status == 200 for status in responses)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
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>
|
||||
158
uv.lock
generated
158
uv.lock
generated
@@ -1,5 +1,5 @@
|
||||
version = 1
|
||||
revision = 2
|
||||
revision = 3
|
||||
requires-python = ">=3.13"
|
||||
|
||||
[[package]]
|
||||
@@ -24,8 +24,11 @@ dependencies = [
|
||||
{ name = "dotenv" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "generateds" },
|
||||
{ name = "httpx" },
|
||||
{ name = "lxml" },
|
||||
{ name = "pydantic", extra = ["email"] },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "redis" },
|
||||
{ name = "ruff" },
|
||||
{ name = "slowapi" },
|
||||
@@ -36,6 +39,11 @@ dependencies = [
|
||||
{ name = "xsdata-pydantic", extra = ["cli", "lxml", "soap"] },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest-cov" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiosqlite", specifier = ">=0.21.0" },
|
||||
@@ -43,8 +51,11 @@ requires-dist = [
|
||||
{ name = "dotenv", specifier = ">=0.9.9" },
|
||||
{ name = "fastapi", specifier = ">=0.117.1" },
|
||||
{ name = "generateds", specifier = ">=2.44.3" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "lxml", specifier = ">=6.0.1" },
|
||||
{ name = "pydantic", extras = ["email"], specifier = ">=2.11.9" },
|
||||
{ name = "pytest", specifier = ">=8.4.2" },
|
||||
{ name = "pytest-asyncio", specifier = ">=1.2.0" },
|
||||
{ name = "redis", specifier = ">=6.4.0" },
|
||||
{ name = "ruff", specifier = ">=0.13.1" },
|
||||
{ name = "slowapi", specifier = ">=0.1.9" },
|
||||
@@ -55,6 +66,9 @@ requires-dist = [
|
||||
{ name = "xsdata-pydantic", extras = ["cli", "lxml", "soap"], specifier = ">=24.5" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "pytest-cov", specifier = ">=7.0.0" }]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.7.0"
|
||||
@@ -190,6 +204,67 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.10.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320, upload-time = "2025-09-21T20:01:56.629Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575, upload-time = "2025-09-21T20:01:58.203Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568, upload-time = "2025-09-21T20:01:59.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174, upload-time = "2025-09-21T20:02:01.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447, upload-time = "2025-09-21T20:02:02.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779, upload-time = "2025-09-21T20:02:04.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604, upload-time = "2025-09-21T20:02:06.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497, upload-time = "2025-09-21T20:02:07.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350, upload-time = "2025-09-21T20:02:10.34Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111, upload-time = "2025-09-21T20:02:12.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746, upload-time = "2025-09-21T20:02:13.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541, upload-time = "2025-09-21T20:02:15.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170, upload-time = "2025-09-21T20:02:17.395Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029, upload-time = "2025-09-21T20:02:18.936Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259, upload-time = "2025-09-21T20:02:20.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592, upload-time = "2025-09-21T20:02:22.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768, upload-time = "2025-09-21T20:02:24.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995, upload-time = "2025-09-21T20:02:26.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546, upload-time = "2025-09-21T20:02:27.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544, upload-time = "2025-09-21T20:02:29.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308, upload-time = "2025-09-21T20:02:31.226Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920, upload-time = "2025-09-21T20:02:32.823Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434, upload-time = "2025-09-21T20:02:34.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/9c/5844ab4ca6a4dd97a1850e030a15ec7d292b5c5cb93082979225126e35dd/coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520", size = 218302, upload-time = "2025-09-21T20:02:42.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/89/673f6514b0961d1f0e20ddc242e9342f6da21eaba3489901b565c0689f34/coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32", size = 218578, upload-time = "2025-09-21T20:02:44.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/e8/261cae479e85232828fb17ad536765c88dd818c8470aca690b0ac6feeaa3/coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f", size = 249629, upload-time = "2025-09-21T20:02:46.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/62/14ed6546d0207e6eda876434e3e8475a3e9adbe32110ce896c9e0c06bb9a/coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a", size = 252162, upload-time = "2025-09-21T20:02:48.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/49/07f00db9ac6478e4358165a08fb41b469a1b053212e8a00cb02f0d27a05f/coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360", size = 253517, upload-time = "2025-09-21T20:02:50.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/59/c5201c62dbf165dfbc91460f6dbbaa85a8b82cfa6131ac45d6c1bfb52deb/coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69", size = 249632, upload-time = "2025-09-21T20:02:51.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/ae/5920097195291a51fb00b3a70b9bbd2edbfe3c84876a1762bd1ef1565ebc/coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14", size = 251520, upload-time = "2025-09-21T20:02:53.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455, upload-time = "2025-09-21T20:02:55.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287, upload-time = "2025-09-21T20:02:57.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946, upload-time = "2025-09-21T20:02:59.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009, upload-time = "2025-09-21T20:03:01.324Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804, upload-time = "2025-09-21T20:03:03.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384, upload-time = "2025-09-21T20:03:05.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047, upload-time = "2025-09-21T20:03:06.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266, upload-time = "2025-09-21T20:03:08.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767, upload-time = "2025-09-21T20:03:10.172Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931, upload-time = "2025-09-21T20:03:11.861Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186, upload-time = "2025-09-21T20:03:13.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470, upload-time = "2025-09-21T20:03:15.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626, upload-time = "2025-09-21T20:03:17.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386, upload-time = "2025-09-21T20:03:19.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852, upload-time = "2025-09-21T20:03:21.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534, upload-time = "2025-09-21T20:03:23.12Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784, upload-time = "2025-09-21T20:03:24.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905, upload-time = "2025-09-21T20:03:26.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922, upload-time = "2025-09-21T20:03:28.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deprecated"
|
||||
version = "1.2.18"
|
||||
@@ -202,6 +277,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/c6/ac0b6c1e2d138f1002bcf799d330bd6d85084fece321e662a14223794041/Deprecated-1.2.18-py2.py3-none-any.whl", hash = "sha256:bd5011788200372a32418f888e326a09ff80d0214bd961147cfed01b5c018eec", size = 9998, upload-time = "2025-01-27T10:46:09.186Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dnspython"
|
||||
version = "2.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "docformatter"
|
||||
version = "1.7.7"
|
||||
@@ -226,6 +310,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email-validator"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "dnspython" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.117.1"
|
||||
@@ -286,6 +383,34 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "certifi" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.10"
|
||||
@@ -476,6 +601,11 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/d3/108f2006987c58e76691d5ae5d200dd3e0f532cb4e5fa3560751c3a1feba/pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2", size = 444855, upload-time = "2025-09-13T11:26:36.909Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
email = [
|
||||
{ name = "email-validator" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.33.2"
|
||||
@@ -529,6 +659,32 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
version = "7.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "coverage" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.1.1"
|
||||
|
||||
Reference in New Issue
Block a user