Compare commits
70 Commits
dbfbd53ad9
...
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 |
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>
|
||||
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"
|
||||
```
|
||||
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,40 +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
|
||||
from zoneinfo import ZoneInfo
|
||||
import difflib
|
||||
import json
|
||||
import inspect
|
||||
import json
|
||||
import re
|
||||
from typing import Dict, List, Optional, Any, Union, Tuple, Type, override
|
||||
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 OtaNotifReportRq, OtaNotifReportRs, 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 AckedRequest, 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):
|
||||
@@ -46,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."""
|
||||
|
||||
@@ -53,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,7 +128,7 @@ class Version(str, Enum):
|
||||
|
||||
|
||||
class AlpineBitsClientInfo:
|
||||
"""Wrapper for username, password, client_id"""
|
||||
"""Wrapper for username, password, client_id."""
|
||||
|
||||
def __init__(self, username: str, password: str, client_id: str | None = None):
|
||||
self.username = username
|
||||
@@ -155,8 +169,7 @@ class AlpineBitsAction(ABC):
|
||||
dbsession=None,
|
||||
server_capabilities=None,
|
||||
) -> AlpineBitsResponse:
|
||||
"""
|
||||
Handle the incoming request XML and return response XML.
|
||||
"""Handle the incoming request XML and return response XML.
|
||||
|
||||
Default implementation returns "not implemented" error.
|
||||
Override this method in subclasses to provide actual functionality.
|
||||
@@ -168,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
|
||||
@@ -187,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
|
||||
|
||||
@@ -200,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)
|
||||
@@ -210,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
|
||||
@@ -246,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:
|
||||
@@ -256,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())
|
||||
|
||||
@@ -282,7 +298,7 @@ class ServerCapabilities:
|
||||
class PingAction(AlpineBitsAction):
|
||||
"""Implementation for OTA_Ping action (handshaking)."""
|
||||
|
||||
def __init__(self, config: Dict = {}):
|
||||
def __init__(self, config: dict = {}):
|
||||
self.name = AlpineBitsActionName.OTA_PING
|
||||
self.version = [
|
||||
Version.V2024_10,
|
||||
@@ -300,10 +316,9 @@ class PingAction(AlpineBitsAction):
|
||||
server_capabilities: None | ServerCapabilities = None,
|
||||
) -> AlpineBitsResponse:
|
||||
"""Handle ping requests."""
|
||||
|
||||
if request_xml is None:
|
||||
return AlpineBitsResponse(
|
||||
f"Error: Xml Request missing", HttpStatusCode.BAD_REQUEST
|
||||
"Error: Xml Request missing", HttpStatusCode.BAD_REQUEST
|
||||
)
|
||||
|
||||
if server_capabilities is None:
|
||||
@@ -317,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
|
||||
@@ -365,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,
|
||||
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="",
|
||||
)
|
||||
|
||||
@@ -403,7 +418,7 @@ def strip_control_chars(s):
|
||||
|
||||
|
||||
def validate_hotel_authentication(
|
||||
username: str, password: str, hotelid: str, config: Dict
|
||||
username: str, password: str, hotelid: str, config: dict
|
||||
) -> bool:
|
||||
"""Validate hotel authentication based on username, password, and hotel ID.
|
||||
|
||||
@@ -414,7 +429,6 @@ def validate_hotel_authentication(
|
||||
username: "alice"
|
||||
password: !secret ALICE_PASSWORD
|
||||
"""
|
||||
|
||||
if not config or "alpine_bits_auth" not in config:
|
||||
return False
|
||||
auth_list = config["alpine_bits_auth"]
|
||||
@@ -433,7 +447,7 @@ def validate_hotel_authentication(
|
||||
class ReadAction(AlpineBitsAction):
|
||||
"""Implementation for OTA_Read action."""
|
||||
|
||||
def __init__(self, config: Dict = {}):
|
||||
def __init__(self, config: dict = {}):
|
||||
self.name = AlpineBitsActionName.OTA_READ
|
||||
self.version = [Version.V2024_10, Version.V2022_10]
|
||||
self.config = config
|
||||
@@ -448,7 +462,6 @@ class ReadAction(AlpineBitsAction):
|
||||
server_capabilities=None,
|
||||
) -> AlpineBitsResponse:
|
||||
"""Handle read requests."""
|
||||
|
||||
clean_action = strip_control_chars(str(action)).strip()
|
||||
clean_expected = strip_control_chars(self.name.value[1]).strip()
|
||||
|
||||
@@ -475,11 +488,13 @@ class ReadAction(AlpineBitsAction):
|
||||
|
||||
if hotelid is None:
|
||||
return AlpineBitsResponse(
|
||||
f"Error: Unauthorized Read Request. No target hotel specified. Check credentials",
|
||||
"Error: Unauthorized Read Request. No target hotel specified. Check credentials",
|
||||
HttpStatusCode.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
if not validate_hotel_authentication(client_info.username, client_info.password, hotelid, self.config):
|
||||
if not validate_hotel_authentication(
|
||||
client_info.username, client_info.password, hotelid, self.config
|
||||
):
|
||||
return AlpineBitsResponse(
|
||||
f"Error: Unauthorized Read Request for this specific hotel {hotelname}. Check credentials",
|
||||
HttpStatusCode.UNAUTHORIZED,
|
||||
@@ -487,6 +502,9 @@ class ReadAction(AlpineBitsAction):
|
||||
|
||||
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
|
||||
@@ -494,29 +512,25 @@ class ReadAction(AlpineBitsAction):
|
||||
|
||||
# query all reservations for this hotel from the database, where start_date is greater than or equal to the given start_date
|
||||
|
||||
|
||||
|
||||
stmt = (
|
||||
select(Reservation, Customer)
|
||||
.join(Customer, Reservation.customer_id == Customer.id)
|
||||
.filter(Reservation.hotel_code == hotelid)
|
||||
)
|
||||
if start_date:
|
||||
stmt = stmt.filter(Reservation.start_date >= start_date)
|
||||
else:
|
||||
# remove reservations that have been acknowledged via client_id
|
||||
if client_info.client_id:
|
||||
subquery = (
|
||||
select(Reservation.id)
|
||||
.join(
|
||||
AckedRequest,
|
||||
AckedRequest.unique_id == Reservation.unique_id,
|
||||
)
|
||||
.filter(AckedRequest.client_id == client_info.client_id)
|
||||
_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,
|
||||
)
|
||||
stmt = stmt.filter(~Reservation.id.in_(subquery))
|
||||
|
||||
|
||||
.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]] = (
|
||||
@@ -524,14 +538,20 @@ class ReadAction(AlpineBitsAction):
|
||||
) # List of (Reservation, Customer) tuples
|
||||
|
||||
_LOGGER.info(
|
||||
f"Querying reservations and customers for hotel {hotelid} from database"
|
||||
"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}"
|
||||
"Retrieving reservation %s for customer %s %s",
|
||||
reservation.id,
|
||||
customer.given_name,
|
||||
customer.surname,
|
||||
)
|
||||
|
||||
res_retrive_rs = create_xml_from_db(reservation_customer_pairs)
|
||||
res_retrive_rs = create_res_retrieve_response(
|
||||
reservation_customer_pairs, config=self.config
|
||||
)
|
||||
|
||||
config = SerializerConfig(
|
||||
pretty_print=True, xml_declaration=True, encoding="UTF-8"
|
||||
@@ -545,10 +565,10 @@ class ReadAction(AlpineBitsAction):
|
||||
|
||||
|
||||
class NotifReportReadAction(AlpineBitsAction):
|
||||
"""Necessary for read action to follow specification. Clients need to report acknowledgements"""
|
||||
"""Necessary for read action to follow specification. Clients need to report acknowledgements."""
|
||||
|
||||
def __init__(self, config: Dict = {}):
|
||||
self.name = AlpineBitsActionName.OTA_HOTEL_RES_NOTIF_GUEST_REQUESTS
|
||||
def __init__(self, config: dict = {}):
|
||||
self.name = AlpineBitsActionName.OTA_HOTEL_NOTIF_REPORT
|
||||
self.version = [Version.V2024_10, Version.V2022_10]
|
||||
self.config = config
|
||||
|
||||
@@ -562,7 +582,6 @@ class NotifReportReadAction(AlpineBitsAction):
|
||||
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
|
||||
@@ -570,9 +589,7 @@ class NotifReportReadAction(AlpineBitsAction):
|
||||
warnings = notif_report.warnings
|
||||
notif_report_details = notif_report.notif_details
|
||||
|
||||
success_message = OtaNotifReportRs(
|
||||
version="7.000", success=""
|
||||
)
|
||||
success_message = OtaNotifReportRs(version="7.000", success="")
|
||||
|
||||
if client_info.client_id is None:
|
||||
return AlpineBitsResponse(
|
||||
@@ -587,55 +604,77 @@ class NotifReportReadAction(AlpineBitsAction):
|
||||
success_message, ns_map={None: "http://www.opentravel.org/OTA/2003/05"}
|
||||
)
|
||||
|
||||
if warnings is None and notif_report_details is None:
|
||||
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
|
||||
elif notif_report_details is not None and notif_report_details.hotel_notif_report is None:
|
||||
if dbsession is None:
|
||||
return AlpineBitsResponse(
|
||||
response_xml, HttpStatusCode.OK
|
||||
) # Nothing to process
|
||||
else:
|
||||
"Error: Something went wrong", HttpStatusCode.INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
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()
|
||||
|
||||
await dbsession.commit()
|
||||
|
||||
|
||||
return AlpineBitsResponse(response_xml, HttpStatusCode.OK)
|
||||
|
||||
|
||||
|
||||
|
||||
class PushAction(AlpineBitsAction):
|
||||
"""Creates the necessary xml for OTA_HotelResNotif:GuestRequests."""
|
||||
|
||||
def __init__(self, config: dict = {}):
|
||||
self.name = AlpineBitsActionName.OTA_HOTEL_RES_NOTIF_GUEST_REQUESTS
|
||||
self.version = [Version.V2024_10, Version.V2022_10]
|
||||
self.config = config
|
||||
|
||||
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)
|
||||
|
||||
return AlpineBitsResponse(
|
||||
response_xml, 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
|
||||
@@ -644,34 +683,31 @@ class AlpineBitsServer:
|
||||
def _initialize_action_instances(self):
|
||||
"""Initialize instances of all discovered action classes."""
|
||||
for capability_name, action_class in self.capabilities.action_registry.items():
|
||||
_LOGGER.info(f"Initializing action instance for {capability_name}")
|
||||
self._action_instances[capability_name] = action_class(config=self.config)
|
||||
|
||||
def get_capabilities(self) -> Dict:
|
||||
def get_capabilities(self) -> dict:
|
||||
"""Get server capabilities."""
|
||||
return self.capabilities.get_capabilities_dict()
|
||||
|
||||
def get_capabilities_json(self) -> str:
|
||||
"""Get server capabilities as JSON."""
|
||||
return self.capabilities.get_capabilities_json()
|
||||
|
||||
async def handle_request(
|
||||
self,
|
||||
request_action_name: str,
|
||||
request_xml: str,
|
||||
request_xml: str | tuple[Reservation, Customer],
|
||||
client_info: AlpineBitsClientInfo,
|
||||
version: str = "2024-10",
|
||||
dbsession=None,
|
||||
) -> AlpineBitsResponse:
|
||||
"""
|
||||
Handle an incoming AlpineBits request by routing to appropriate action handler.
|
||||
"""Handle an incoming AlpineBits request by routing to appropriate action handler.
|
||||
|
||||
Args:
|
||||
request_action_name: The action name from the request (e.g., "OTA_Read:GuestRequests")
|
||||
request_xml: The XML request body
|
||||
request_xml: The XML request body. Gets passed to the action handler. In case of PushRequest can be the data to be pushed
|
||||
version: The AlpineBits version (defaults to "2024-10")
|
||||
|
||||
Returns:
|
||||
AlpineBitsResponse with the result
|
||||
|
||||
"""
|
||||
# Convert string version to enum
|
||||
try:
|
||||
@@ -683,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}",
|
||||
@@ -690,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):
|
||||
@@ -709,33 +749,50 @@ class AlpineBitsServer:
|
||||
# Handle the request
|
||||
try:
|
||||
# Special case for ping action - pass server capabilities
|
||||
if capability_name == "action_OTA_Ping":
|
||||
return await action_instance.handle(
|
||||
action=request_action_name, request_xml=request_xml, version=version_enum, server_capabilities=self.capabilities, client_info=client_info
|
||||
)
|
||||
else:
|
||||
|
||||
if action_enum == AlpineBitsActionName.OTA_HOTEL_RES_NOTIF_GUEST_REQUESTS:
|
||||
action_instance: PushAction
|
||||
if request_xml is None or not isinstance(request_xml, tuple):
|
||||
return AlpineBitsResponse(
|
||||
"Error: Invalid data for push request",
|
||||
HttpStatusCode.BAD_REQUEST,
|
||||
)
|
||||
return await action_instance.handle(
|
||||
action=request_action_name,
|
||||
request_xml=request_xml,
|
||||
version=version_enum,
|
||||
dbsession=dbsession,
|
||||
client_info=client_info,
|
||||
)
|
||||
|
||||
if action_enum == AlpineBitsActionName.OTA_PING:
|
||||
return await action_instance.handle(
|
||||
action=request_action_name,
|
||||
request_xml=request_xml,
|
||||
version=version_enum,
|
||||
server_capabilities=self.capabilities,
|
||||
client_info=client_info,
|
||||
)
|
||||
return await action_instance.handle(
|
||||
action=request_action_name,
|
||||
request_xml=request_xml,
|
||||
version=version_enum,
|
||||
dbsession=dbsession,
|
||||
client_info=client_info,
|
||||
)
|
||||
except Exception as e:
|
||||
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)
|
||||
@@ -744,8 +801,7 @@ class AlpineBitsServer:
|
||||
def is_action_supported(
|
||||
self, request_action_name: str, version: str | None = None
|
||||
) -> bool:
|
||||
"""
|
||||
Check if a request action is supported.
|
||||
"""Check if a request action is supported.
|
||||
|
||||
Args:
|
||||
request_action_name: The request action name (e.g., "OTA_Read:GuestRequests")
|
||||
@@ -753,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:
|
||||
@@ -769,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()
|
||||
@@ -30,21 +29,22 @@ if os.getenv("WIX_API_KEY"):
|
||||
if os.getenv("ADMIN_API_KEY"):
|
||||
API_KEYS["admin-key"] = os.getenv("ADMIN_API_KEY")
|
||||
|
||||
|
||||
def generate_unique_id() -> str:
|
||||
"""Generate a unique ID with max length 35 characters"""
|
||||
return secrets.token_urlsafe(26)[:35] # 26 bytes -> 35 chars in base64url
|
||||
"""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
|
||||
@@ -64,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:
|
||||
@@ -73,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(
|
||||
@@ -84,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,41 +1,80 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
from annotatedyaml.loader import (
|
||||
HAS_C_LOADER,
|
||||
JSON_TYPE,
|
||||
LoaderType,
|
||||
Secrets,
|
||||
add_constructor,
|
||||
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 (
|
||||
Schema,
|
||||
Required,
|
||||
PREVENT_EXTRA,
|
||||
All,
|
||||
Length,
|
||||
PREVENT_EXTRA,
|
||||
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,
|
||||
)
|
||||
|
||||
# --- 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,
|
||||
Optional("push_endpoint"): {
|
||||
Required("url"): str,
|
||||
Required("token"): str,
|
||||
Optional("username"): 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,
|
||||
@@ -45,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,
|
||||
)
|
||||
@@ -57,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()
|
||||
@@ -101,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"]
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -44,7 +44,8 @@ class Reservation(Base):
|
||||
__tablename__ = "reservations"
|
||||
id = Column(Integer, primary_key=True)
|
||||
customer_id = Column(Integer, ForeignKey("customers.id"))
|
||||
unique_id = Column(String(35), unique=True) # max length 35
|
||||
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)
|
||||
@@ -67,11 +68,12 @@ class Reservation(Base):
|
||||
customer = relationship("Customer", back_populates="reservations")
|
||||
|
||||
|
||||
|
||||
# Table for tracking acknowledged requests by client
|
||||
class AckedRequest(Base):
|
||||
__tablename__ = 'acked_requests'
|
||||
__tablename__ = "acked_requests"
|
||||
id = Column(Integer, primary_key=True)
|
||||
client_id = Column(String, index=True)
|
||||
unique_id = Column(String, index=True) # Should match Reservation.form_id or another unique field
|
||||
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,382 +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.unique_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