Compare commits
18 Commits
f0945ed431
...
1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52f95bd677 | ||
|
|
6701dcd6bf | ||
|
|
9f0a77ca39 | ||
|
|
259243d44b | ||
|
|
84a57f3d98 | ||
|
|
ff25142f62 | ||
|
|
ebbea84a4c | ||
|
|
584def323c | ||
|
|
a8f46016be | ||
|
|
e0c9afe227 | ||
|
|
9094f3e3b7 | ||
|
|
867b2632df | ||
|
|
a69816baa4 | ||
|
|
e605af1231 | ||
|
|
e5a295faba | ||
|
|
5ec47b8332 | ||
|
|
122c7c8be4 | ||
|
|
6102194712 |
139
.github/copilot-instructions.md
vendored
Normal file
139
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
# AlpineBits Python Server - AI Agent Instructions
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
This is an **AlpineBits 2024-10 server** that bridges booking requests from Wix landing pages to hotel partners. It's a dual-purpose system:
|
||||||
|
|
||||||
|
1. **FastAPI webhook receiver** - accepts booking forms from wix.com landing pages via `/api/webhook/wix-form`
|
||||||
|
2. **AlpineBits OTA server** - exposes hotel reservation data at `/api/alpinebits/server-2024-10` using OpenTravel Alliance XML protocol
|
||||||
|
|
||||||
|
Data flows: Wix form → Database → AlpineBits XML → Hotel systems (pull or push)
|
||||||
|
|
||||||
|
## Architecture Patterns
|
||||||
|
|
||||||
|
### XML Generation with xsdata
|
||||||
|
|
||||||
|
- **Never manually construct XML strings**. Use xsdata-generated Pydantic dataclasses from `src/alpine_bits_python/generated/alpinebits.py`
|
||||||
|
- Parse XML: `XmlParser().from_string(xml_string, OtaPingRq)`
|
||||||
|
- Serialize XML: `XmlSerializer(config=SerializerConfig(...)).render(ota_object)`
|
||||||
|
- Factory pattern: Use classes in `alpine_bits_helpers.py` (e.g., `CustomerFactory`, `GuestCountsFactory`) to build complex OTA objects from DB models
|
||||||
|
- Example: `create_res_retrieve_response()` builds OTA_ResRetrieveRS from `(Reservation, Customer)` tuples
|
||||||
|
- **Regenerating XML classes**: Run `xsdata` on `AlpineBits-HotelData-2024-10/files/schema-xsd/alpinebits.xsd` to regenerate `generated/alpinebits.py` (only if XSD spec changes)
|
||||||
|
|
||||||
|
### Configuration System
|
||||||
|
|
||||||
|
- Config loaded from YAML with secret injection via `!secret` tags (see `config_loader.py`)
|
||||||
|
- Default config location: `config/config.yaml` + `config/secrets.yaml`
|
||||||
|
- Override via `ALPINEBITS_CONFIG_DIR` environment variable
|
||||||
|
- Multi-hotel support: Each hotel in `alpine_bits_auth` array gets own credentials and optional `push_endpoint`
|
||||||
|
|
||||||
|
### Database Layer
|
||||||
|
|
||||||
|
- **Async-only SQLAlchemy** with `AsyncSession` (see `db.py`)
|
||||||
|
- Three core tables: `Customer`, `Reservation`, `AckedRequest` (tracks which clients acknowledged which reservations)
|
||||||
|
- DB URL configurable: SQLite for dev (`sqlite+aiosqlite:///alpinebits.db`), PostgreSQL for prod
|
||||||
|
- Database auto-created on startup in `api.py:create_app()`
|
||||||
|
|
||||||
|
### Event-Driven Push System
|
||||||
|
|
||||||
|
- `EventDispatcher` in `api.py` enables hotel-specific listeners: `event_dispatcher.register_hotel_listener("reservation:created", hotel_code, push_listener)`
|
||||||
|
- Push listener sends OTA_HotelResNotif XML to hotel's configured `push_endpoint.url` with Bearer token auth
|
||||||
|
- Push requests logged to `logs/push_requests/` with timestamp and unique ID
|
||||||
|
- **Note**: Push endpoint support is currently dormant - configured but not actively used by partners
|
||||||
|
|
||||||
|
### AlpineBits Action Pattern
|
||||||
|
|
||||||
|
- Each OTA action is a class inheriting `AlpineBitsActionHandler` (see `alpinebits_server.py`)
|
||||||
|
- Actions: `PingAction`, `ReadAction`, `NotifReportAction`, `PushAction`
|
||||||
|
- Request flow: Parse XML → Call `handle()` → Return `AlpineBitsActionResult` with XML response + HTTP status
|
||||||
|
- `AlpineBitsActionName` enum maps capability names to request names (e.g., `OTA_READ` → `"OTA_Read:GuestRequests"`)
|
||||||
|
- Server supports multiple AlpineBits versions (2024-10, 2022-10) when actions are identical across versions
|
||||||
|
|
||||||
|
### Acknowledgment System
|
||||||
|
|
||||||
|
- `AckedRequest` table tracks which clients acknowledged which reservations via `OTA_NotifReport:GuestRequests`
|
||||||
|
- Read requests filter out acknowledged reservations for clients with `client_id`
|
||||||
|
- Prevents duplicate reservation sends: once acknowledged, data won't appear in subsequent reads for that client
|
||||||
|
|
||||||
|
## Critical Workflows
|
||||||
|
|
||||||
|
### Running Locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync # Install dependencies (uses uv, not pip!)
|
||||||
|
uv run python -m alpine_bits_python.run_api # Start server on port 8080, clears DB on startup
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest # Run all tests
|
||||||
|
uv run pytest tests/test_alpine_bits_server_read.py # Specific test file
|
||||||
|
```
|
||||||
|
|
||||||
|
- Tests use in-memory SQLite via `test_db_engine` fixture (see `tests/test_alpine_bits_server_read.py`)
|
||||||
|
- Test data fixtures in `tests/test_data/` directory
|
||||||
|
|
||||||
|
### Building for Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync
|
||||||
|
docker build . -t gitea.linter-home.com/jonas/asa_api:master
|
||||||
|
```
|
||||||
|
|
||||||
|
- Multi-stage Dockerfile: builder stage installs deps with uv, production stage copies `.venv`
|
||||||
|
- Runs as non-root user (UID 1000) for security
|
||||||
|
- Requires `ALPINEBITS_CONFIG_DIR=/config` volume mount for config files
|
||||||
|
- **Deployment**: Docker build pipeline exists and works; can also build manually on target system
|
||||||
|
|
||||||
|
## Project-Specific Conventions
|
||||||
|
|
||||||
|
### Naming Patterns
|
||||||
|
|
||||||
|
- OTA message types use full AlpineBits names: `OtaReadRq`, `OtaResRetrieveRs`, `OtaHotelResNotifRq`
|
||||||
|
- Factory classes suffix with `Factory`: `CustomerFactory`, `HotelReservationIdFactory`
|
||||||
|
- DB models in `db.py`, validation schemas in `schemas.py`, OTA helpers in `alpine_bits_helpers.py`
|
||||||
|
|
||||||
|
### Data Validation Flow
|
||||||
|
|
||||||
|
1. **API Layer** → Pydantic schemas (`schemas.py`) validate incoming data
|
||||||
|
2. **DB Layer** → SQLAlchemy models (`db.py`) persist validated data
|
||||||
|
3. **XML Layer** → xsdata classes (`generated/alpinebits.py`) + factories (`alpine_bits_helpers.py`) generate OTA XML
|
||||||
|
|
||||||
|
This separation prevents mixing concerns (validation ≠ persistence ≠ XML generation).
|
||||||
|
|
||||||
|
### Unique ID Generation
|
||||||
|
|
||||||
|
- Reservation IDs: 35-char max, format `{hotel_code}_{uuid4}_{timestamp}`
|
||||||
|
- Generated via `generate_unique_id()` in `auth.py`
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
|
||||||
|
- Uses `slowapi` with Redis backend
|
||||||
|
- Three tiers: `DEFAULT_RATE_LIMIT` (100/hour), `WEBHOOK_RATE_LIMIT` (300/hour), `BURST_RATE_LIMIT` (10/minute)
|
||||||
|
- Applied via decorators: `@limiter.limit(DEFAULT_RATE_LIMIT)`
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
1. **Don't use synchronous SQLAlchemy calls** - Always `await session.execute()`, never `session.query()`
|
||||||
|
2. **Don't hardcode XML namespaces** - Let xsdata handle them via generated classes
|
||||||
|
3. **Don't skip config validation** - Voluptuous schemas in `config_loader.py` catch config errors early
|
||||||
|
4. **Auth is per-hotel** - HTTP Basic Auth credentials from `alpine_bits_auth` config array
|
||||||
|
5. **AlpineBits version matters** - Server implements 2024-10 spec (see `AlpineBits-HotelData-2024-10/` directory)
|
||||||
|
|
||||||
|
## Key Files Reference
|
||||||
|
|
||||||
|
- `api.py` - FastAPI app, all endpoints, event dispatcher
|
||||||
|
- `alpinebits_server.py` - AlpineBits action handlers (Ping, Read, NotifReport)
|
||||||
|
- `alpine_bits_helpers.py` - Factory classes for building OTA XML from DB models
|
||||||
|
- `config_loader.py` - YAML config loading with secret injection
|
||||||
|
- `db.py` - SQLAlchemy async models (Customer, Reservation, AckedRequest)
|
||||||
|
- `schemas.py` - Pydantic validation schemas
|
||||||
|
- `generated/alpinebits.py` - xsdata-generated OTA XML classes (DO NOT EDIT - regenerate from XSD)
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
- Fixtures create isolated in-memory databases per test
|
||||||
|
- Use `test_config()` fixture for test configuration
|
||||||
|
- XML serialization/parsing tested via xsdata round-trips
|
||||||
|
- Push endpoint mocking via httpx in tests
|
||||||
57
.vscode/settings.json
vendored
57
.vscode/settings.json
vendored
@@ -1,8 +1,53 @@
|
|||||||
{
|
{
|
||||||
"python.testing.pytestArgs": [
|
"editor.formatOnSave": true,
|
||||||
"test"
|
"[python]": {
|
||||||
],
|
"editor.defaultFormatter": "charliermarsh.ruff",
|
||||||
"python.testing.unittestEnabled": false,
|
"editor.codeActionsOnSave": {
|
||||||
"python.testing.pytestEnabled": true,
|
"source.fixAll": "explicit",
|
||||||
"python.analysis.typeCheckingMode": "basic"
|
"source.organizeImports": "explicit"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"[json]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
},
|
||||||
|
"notebook.formatOnSave.enabled": true,
|
||||||
|
"notebook.codeActionsOnSave": {
|
||||||
|
// "notebook.source.fixAll": "explicit",
|
||||||
|
// "notebook.source.organizeImports": "explicit"
|
||||||
|
},
|
||||||
|
"notebook.output.wordWrap": true,
|
||||||
|
"notebook.output.textLineLimit": 200,
|
||||||
|
"jupyter.debugJustMyCode": false,
|
||||||
|
"python.testing.pytestEnabled": true,
|
||||||
|
"files.exclude": {
|
||||||
|
"**/*.egg-info": true,
|
||||||
|
"**/htmlcov": true,
|
||||||
|
"**/~$*": true,
|
||||||
|
"**/.coverage.*": true,
|
||||||
|
"**/.venv": true,
|
||||||
|
"**/__pycache__": true,
|
||||||
|
"**/.mypy_cache": true,
|
||||||
|
"**/.pytest_cache": true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// .vscode/launch.json
|
||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Python: Debug Tests",
|
||||||
|
"type": "debugpy",
|
||||||
|
"request": "launch",
|
||||||
|
"program": "${file}",
|
||||||
|
"purpose": [
|
||||||
|
"debug-test"
|
||||||
|
],
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"justMyCode": false,
|
||||||
|
"env": {
|
||||||
|
"PYTEST_ADDOPTS": "--no-cov"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
24
99Tales_Testexport.xml
Normal file
24
99Tales_Testexport.xml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<reservations>
|
||||||
|
<reservation id="2409" number="191" date="2025-08-28" creationTime="2025-08-28T11:53:45" type="reservation" bookingGroup="" bookingChannel="99TALES" advertisingMedium="99TALES" advertisingPartner="399">
|
||||||
|
<guest id="364" lastName="Busch" firstName="Sebastian" language="de" gender="male" dateOfBirth="" postalCode="58454" city="Witten" countryCode="DE" country="DEUTSCHLAND" email="test@test.com"/>
|
||||||
|
<company/>
|
||||||
|
<roomReservations>
|
||||||
|
<roomReservation arrival="2025-09-03" departure="2025-09-12" status="reserved" roomType="EZ" roomNumber="106" adults="1" children="0" infants="0" ratePlanCode="WEEK" connectedRoomType="0">
|
||||||
|
<connectedRooms/>
|
||||||
|
<dailySales>
|
||||||
|
<dailySale date="2025-09-03" revenueTotal="174" revenueLogis="127.5" revenueBoard="9" revenueFB="10" revenueSpa="1" revenueOther="26.5" revenueResources=""/>
|
||||||
|
<dailySale date="2025-09-04" revenueTotal="164" revenueLogis="127.5" revenueBoard="9" revenueFB="10" revenueSpa="1" revenueOther="16.5" revenueResources=""/>
|
||||||
|
<dailySale date="2025-09-05" revenueTotal="164" revenueLogis="127.5" revenueBoard="9" revenueFB="10" revenueSpa="1" revenueOther="16.5" revenueResources=""/>
|
||||||
|
<dailySale date="2025-09-06" revenueTotal="164" revenueLogis="127.5" revenueBoard="9" revenueFB="10" revenueSpa="1" revenueOther="16.5" revenueResources=""/>
|
||||||
|
<dailySale date="2025-09-07" revenueTotal="164" revenueLogis="127.5" revenueBoard="9" revenueFB="10" revenueSpa="1" revenueOther="16.5" revenueResources=""/>
|
||||||
|
<dailySale date="2025-09-08" revenueTotal="164" revenueLogis="127.5" revenueBoard="9" revenueFB="10" revenueSpa="1" revenueOther="16.5" revenueResources=""/>
|
||||||
|
<dailySale date="2025-09-09" revenueTotal="164" revenueLogis="127.5" revenueBoard="9" revenueFB="10" revenueSpa="1" revenueOther="16.5" revenueResources=""/>
|
||||||
|
<dailySale date="2025-09-10" revenueTotal="164" revenueLogis="127.5" revenueBoard="9" revenueFB="10" revenueSpa="1" revenueOther="16.5" revenueResources=""/>
|
||||||
|
<dailySale date="2025-09-11" revenueTotal="149" revenueLogis="127.5" revenueBoard="9" revenueFB="10" revenueSpa="1" revenueOther="1.5" revenueResources=""/>
|
||||||
|
<dailySale date="2025-09-12" revenueTotal="" revenueLogis="" revenueBoard="" revenueFB="" revenueSpa="" revenueOther="" revenueResources=""/>
|
||||||
|
</dailySales>
|
||||||
|
</roomReservation>
|
||||||
|
</roomReservations>
|
||||||
|
</reservation>
|
||||||
|
</reservations>
|
||||||
@@ -2,19 +2,28 @@
|
|||||||
# Use annotatedyaml for secrets and environment-specific overrides
|
# Use annotatedyaml for secrets and environment-specific overrides
|
||||||
|
|
||||||
database:
|
database:
|
||||||
url: "sqlite+aiosqlite:///alpinebits.db" # For local dev, use SQLite. For prod, override with PostgreSQL URL.
|
url: "sqlite+aiosqlite:///alpinebits.db" # For local dev, use SQLite. For prod, override with PostgreSQL URL.
|
||||||
# url: "postgresql://user:password@host:port/dbname" # Example for Postgres
|
# url: "postgresql://user:password@host:port/dbname" # Example for Postgres
|
||||||
|
|
||||||
|
# AlpineBits Python config
|
||||||
|
# Use annotatedyaml for secrets and environment-specific overrides
|
||||||
|
|
||||||
alpine_bits_auth:
|
alpine_bits_auth:
|
||||||
- hotel_id: "12345"
|
- hotel_id: "39054_001"
|
||||||
hotel_name: "Bemelmans Post"
|
hotel_name: "Bemelmans Post"
|
||||||
username: "alice"
|
username: "bemelman"
|
||||||
password: !secret ALICE_PASSWORD
|
password: !secret BEMELMANS_PASSWORD
|
||||||
push_endpoint:
|
|
||||||
url: "https://example.com/push"
|
|
||||||
token: !secret PUSH_TOKEN_ALICE
|
|
||||||
username: "alice"
|
|
||||||
- hotel_id: "135"
|
- hotel_id: "135"
|
||||||
hotel_name: "Bemelmans"
|
hotel_name: "Testhotel"
|
||||||
username: "sebastian"
|
username: "sebastian"
|
||||||
password: !secret BOB_PASSWORD
|
password: !secret BOB_PASSWORD
|
||||||
|
|
||||||
|
- hotel_id: "39052_001"
|
||||||
|
hotel_name: "Jagthof Kaltern"
|
||||||
|
username: "jagthof"
|
||||||
|
password: !secret JAGTHOF_PASSWORD
|
||||||
|
|
||||||
|
- hotel_id: "39040_001"
|
||||||
|
hotel_name: "Residence Erika"
|
||||||
|
username: "erika"
|
||||||
|
password: !secret ERIKA_PASSWORD
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<OTA_HotelResNotifRQ xmlns="http://www.opentravel.org/OTA/2003/05" Version="7.000">
|
||||||
|
<HotelReservations>
|
||||||
|
<HotelReservation CreateDateTime="2025-10-07T15:13:38.831800+00:00" ResStatus="Requested" RoomStayReservation="true">
|
||||||
|
<UniqueID Type="14" ID="8e68dab6-7c2e-4c67-9471-b8cbfb7b"/>
|
||||||
|
<RoomStays>
|
||||||
|
<RoomStay>
|
||||||
|
<GuestCounts>
|
||||||
|
<GuestCount Count="13"/>
|
||||||
|
</GuestCounts>
|
||||||
|
<TimeSpan Start="2025-10-25" End="2025-10-26"/>
|
||||||
|
</RoomStay>
|
||||||
|
</RoomStays>
|
||||||
|
<ResGuests>
|
||||||
|
<ResGuest>
|
||||||
|
<Profiles>
|
||||||
|
<ProfileInfo>
|
||||||
|
<Profile>
|
||||||
|
<Customer Language="de">
|
||||||
|
<PersonName>
|
||||||
|
<NamePrefix>Frau</NamePrefix>
|
||||||
|
<GivenName>Christine</GivenName>
|
||||||
|
<Surname>Niederkofler</Surname>
|
||||||
|
</PersonName>
|
||||||
|
<Telephone PhoneTechType="5" PhoneNumber="+4953346312"/>
|
||||||
|
<Email Remark="newsletter:yes">info@ledermode.at</Email>
|
||||||
|
</Customer>
|
||||||
|
</Profile>
|
||||||
|
</ProfileInfo>
|
||||||
|
</Profiles>
|
||||||
|
</ResGuest>
|
||||||
|
</ResGuests>
|
||||||
|
<ResGlobalInfo>
|
||||||
|
<Comments>
|
||||||
|
<Comment Name="additional info">
|
||||||
|
<Text>Angebot/Offerta: Törggelewochen - Herbstliche Genüsse & Südtiroler Tradition</Text>
|
||||||
|
</Comment>
|
||||||
|
<Comment Name="customer comment">
|
||||||
|
<Text>Hallo. Wir würden gerne mit unseren Mitarbeitern vom 25.10 - 26.10.25 nach Südtirol fahren.
|
||||||
|
Geplant wäre am Samstagabend Törggelen und am Sonntag nach dem Frühstück mit der Gondel zur Seiser Alm zu fahren.
|
||||||
|
Wir sind ca. 13 Personen (es können gerne auch 3-Bettzimmer dabei sein falls vorhanden. Sonst DZ und wir benötigen 1 EZ).
|
||||||
|
Bitte um ein Angebot für Törggelen, Übernachtung und Frühstück. Vielen lieben Dank! Christine Niederkofler</Text>
|
||||||
|
</Comment>
|
||||||
|
</Comments>
|
||||||
|
<HotelReservationIDs>
|
||||||
|
<HotelReservationID ResID_Type="13" ResID_Value="Cj0KCQjw3OjGBhDYARIsADd-uX65gXKdbOti_3OOA50T-B9Uj-zsOzXJ7g2-8Tz_" ResID_Source="google" ResID_SourceContext="99tales"/>
|
||||||
|
</HotelReservationIDs>
|
||||||
|
<Profiles>
|
||||||
|
<ProfileInfo>
|
||||||
|
<Profile ProfileType="4">
|
||||||
|
<CompanyInfo>
|
||||||
|
<CompanyName Code="who knows?" CodeContext="who knows?">99tales GmbH</CompanyName>
|
||||||
|
</CompanyInfo>
|
||||||
|
</Profile>
|
||||||
|
</ProfileInfo>
|
||||||
|
</Profiles>
|
||||||
|
<BasicPropertyInfo HotelCode="12345" HotelName="Frangart Inn"/>
|
||||||
|
</ResGlobalInfo>
|
||||||
|
</HotelReservation>
|
||||||
|
</HotelReservations>
|
||||||
|
</OTA_HotelResNotifRQ>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<OTA_HotelResNotifRQ xmlns="http://www.opentravel.org/OTA/2003/05" Version="7.000">
|
||||||
|
<HotelReservations/>
|
||||||
|
</OTA_HotelResNotifRQ>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<OTA_HotelResNotifRQ xmlns="http://www.opentravel.org/OTA/2003/05" Version="7.000">
|
||||||
|
<HotelReservations>
|
||||||
|
<HotelReservation CreateDateTime="2025-10-07T14:05:37.563674+00:00" ResStatus="Requested" RoomStayReservation="true">
|
||||||
|
<UniqueID Type="14" ID="c52702c9-55b9-44e1-b158-ec9544c7"/>
|
||||||
|
<RoomStays>
|
||||||
|
<RoomStay>
|
||||||
|
<GuestCounts>
|
||||||
|
<GuestCount Count="3"/>
|
||||||
|
<GuestCount Count="1" Age="12"/>
|
||||||
|
</GuestCounts>
|
||||||
|
<TimeSpan Start="2026-01-02" End="2026-01-07"/>
|
||||||
|
</RoomStay>
|
||||||
|
</RoomStays>
|
||||||
|
<ResGuests>
|
||||||
|
<ResGuest>
|
||||||
|
<Profiles>
|
||||||
|
<ProfileInfo>
|
||||||
|
<Profile>
|
||||||
|
<Customer Language="it">
|
||||||
|
<PersonName>
|
||||||
|
<NamePrefix>Frau</NamePrefix>
|
||||||
|
<GivenName>Genesia</GivenName>
|
||||||
|
<Surname>Supino</Surname>
|
||||||
|
</PersonName>
|
||||||
|
<Telephone PhoneTechType="5" PhoneNumber="+393406259979"/>
|
||||||
|
<Email Remark="newsletter:yes">supinogenesia@gmail.com</Email>
|
||||||
|
</Customer>
|
||||||
|
</Profile>
|
||||||
|
</ProfileInfo>
|
||||||
|
</Profiles>
|
||||||
|
</ResGuest>
|
||||||
|
</ResGuests>
|
||||||
|
<ResGlobalInfo>
|
||||||
|
<HotelReservationIDs>
|
||||||
|
<HotelReservationID ResID_Type="13" ResID_Value="IwZXh0bgNhZW0BMABhZGlkAassWPh1b8QBHoRc2S24gMktdNKiPwEvGYMK3rB-mn" ResID_Source="Facebook_Mobile_Feed" ResID_SourceContext="99tales"/>
|
||||||
|
</HotelReservationIDs>
|
||||||
|
<Profiles>
|
||||||
|
<ProfileInfo>
|
||||||
|
<Profile ProfileType="4">
|
||||||
|
<CompanyInfo>
|
||||||
|
<CompanyName Code="who knows?" CodeContext="who knows?">99tales GmbH</CompanyName>
|
||||||
|
</CompanyInfo>
|
||||||
|
</Profile>
|
||||||
|
</ProfileInfo>
|
||||||
|
</Profiles>
|
||||||
|
<BasicPropertyInfo HotelCode="12345" HotelName="Bemelmans Post"/>
|
||||||
|
</ResGlobalInfo>
|
||||||
|
</HotelReservation>
|
||||||
|
</HotelReservations>
|
||||||
|
</OTA_HotelResNotifRQ>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<OTA_HotelResNotifRQ xmlns="http://www.opentravel.org/OTA/2003/05" Version="7.000">
|
||||||
|
<HotelReservations>
|
||||||
|
<HotelReservation CreateDateTime="2025-10-07T14:24:04.943026+00:00" ResStatus="Requested" RoomStayReservation="true">
|
||||||
|
<UniqueID Type="14" ID="c52702c9-55b9-44e1-b158-ec9544c7"/>
|
||||||
|
<RoomStays>
|
||||||
|
<RoomStay>
|
||||||
|
<GuestCounts>
|
||||||
|
<GuestCount Count="3"/>
|
||||||
|
<GuestCount Count="1" Age="12"/>
|
||||||
|
</GuestCounts>
|
||||||
|
<TimeSpan Start="2026-01-02" End="2026-01-07"/>
|
||||||
|
</RoomStay>
|
||||||
|
</RoomStays>
|
||||||
|
<ResGuests>
|
||||||
|
<ResGuest>
|
||||||
|
<Profiles>
|
||||||
|
<ProfileInfo>
|
||||||
|
<Profile>
|
||||||
|
<Customer Language="it">
|
||||||
|
<PersonName>
|
||||||
|
<NamePrefix>Frau</NamePrefix>
|
||||||
|
<GivenName>Genesia</GivenName>
|
||||||
|
<Surname>Supino</Surname>
|
||||||
|
</PersonName>
|
||||||
|
<Telephone PhoneTechType="5" PhoneNumber="+393406259979"/>
|
||||||
|
<Email Remark="newsletter:yes">supinogenesia@gmail.com</Email>
|
||||||
|
</Customer>
|
||||||
|
</Profile>
|
||||||
|
</ProfileInfo>
|
||||||
|
</Profiles>
|
||||||
|
</ResGuest>
|
||||||
|
</ResGuests>
|
||||||
|
<ResGlobalInfo>
|
||||||
|
<HotelReservationIDs>
|
||||||
|
<HotelReservationID ResID_Type="13" ResID_Value="IwZXh0bgNhZW0BMABhZGlkAassWPh1b8QBHoRc2S24gMktdNKiPwEvGYMK3rB-mn" ResID_Source="Facebook_Mobile_Feed" ResID_SourceContext="99tales"/>
|
||||||
|
</HotelReservationIDs>
|
||||||
|
<Profiles>
|
||||||
|
<ProfileInfo>
|
||||||
|
<Profile ProfileType="4">
|
||||||
|
<CompanyInfo>
|
||||||
|
<CompanyName Code="who knows?" CodeContext="who knows?">99tales GmbH</CompanyName>
|
||||||
|
</CompanyInfo>
|
||||||
|
</Profile>
|
||||||
|
</ProfileInfo>
|
||||||
|
</Profiles>
|
||||||
|
<BasicPropertyInfo HotelCode="12345" HotelName="Bemelmans Post"/>
|
||||||
|
</ResGlobalInfo>
|
||||||
|
</HotelReservation>
|
||||||
|
</HotelReservations>
|
||||||
|
</OTA_HotelResNotifRQ>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<OTA_HotelResNotifRQ xmlns="http://www.opentravel.org/OTA/2003/05" Version="7.000">
|
||||||
|
<HotelReservations>
|
||||||
|
<HotelReservation CreateDateTime="2025-10-07T14:32:52.523968+00:00" ResStatus="Requested" RoomStayReservation="true">
|
||||||
|
<UniqueID Type="14" ID="c52702c9-55b9-44e1-b158-ec9544c7"/>
|
||||||
|
<RoomStays>
|
||||||
|
<RoomStay>
|
||||||
|
<GuestCounts>
|
||||||
|
<GuestCount Count="3"/>
|
||||||
|
<GuestCount Count="1" Age="12"/>
|
||||||
|
</GuestCounts>
|
||||||
|
<TimeSpan Start="2026-01-02" End="2026-01-07"/>
|
||||||
|
</RoomStay>
|
||||||
|
</RoomStays>
|
||||||
|
<ResGuests>
|
||||||
|
<ResGuest>
|
||||||
|
<Profiles>
|
||||||
|
<ProfileInfo>
|
||||||
|
<Profile>
|
||||||
|
<Customer Language="it">
|
||||||
|
<PersonName>
|
||||||
|
<NamePrefix>Frau</NamePrefix>
|
||||||
|
<GivenName>Genesia</GivenName>
|
||||||
|
<Surname>Supino</Surname>
|
||||||
|
</PersonName>
|
||||||
|
<Telephone PhoneTechType="5" PhoneNumber="+393406259979"/>
|
||||||
|
<Email Remark="newsletter:yes">supinogenesia@gmail.com</Email>
|
||||||
|
</Customer>
|
||||||
|
</Profile>
|
||||||
|
</ProfileInfo>
|
||||||
|
</Profiles>
|
||||||
|
</ResGuest>
|
||||||
|
</ResGuests>
|
||||||
|
<ResGlobalInfo>
|
||||||
|
<HotelReservationIDs>
|
||||||
|
<HotelReservationID ResID_Type="13" ResID_Value="IwZXh0bgNhZW0BMABhZGlkAassWPh1b8QBHoRc2S24gMktdNKiPwEvGYMK3rB-mn" ResID_Source="Facebook_Mobile_Feed" ResID_SourceContext="99tales"/>
|
||||||
|
</HotelReservationIDs>
|
||||||
|
<Profiles>
|
||||||
|
<ProfileInfo>
|
||||||
|
<Profile ProfileType="4">
|
||||||
|
<CompanyInfo>
|
||||||
|
<CompanyName Code="who knows?" CodeContext="who knows?">99tales GmbH</CompanyName>
|
||||||
|
</CompanyInfo>
|
||||||
|
</Profile>
|
||||||
|
</ProfileInfo>
|
||||||
|
</Profiles>
|
||||||
|
<BasicPropertyInfo HotelCode="12345" HotelName="Bemelmans Post"/>
|
||||||
|
</ResGlobalInfo>
|
||||||
|
</HotelReservation>
|
||||||
|
</HotelReservations>
|
||||||
|
</OTA_HotelResNotifRQ>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<OTA_HotelResNotifRQ xmlns="http://www.opentravel.org/OTA/2003/05" Version="7.000">
|
||||||
|
<HotelReservations>
|
||||||
|
<HotelReservation CreateDateTime="2025-10-07T15:12:25.274095+00:00" ResStatus="Requested" RoomStayReservation="true">
|
||||||
|
<UniqueID Type="14" ID="c52702c9-55b9-44e1-b158-ec9544c7"/>
|
||||||
|
<RoomStays>
|
||||||
|
<RoomStay>
|
||||||
|
<GuestCounts>
|
||||||
|
<GuestCount Count="3"/>
|
||||||
|
<GuestCount Count="1" Age="12"/>
|
||||||
|
</GuestCounts>
|
||||||
|
<TimeSpan Start="2026-01-02" End="2026-01-07"/>
|
||||||
|
</RoomStay>
|
||||||
|
</RoomStays>
|
||||||
|
<ResGuests>
|
||||||
|
<ResGuest>
|
||||||
|
<Profiles>
|
||||||
|
<ProfileInfo>
|
||||||
|
<Profile>
|
||||||
|
<Customer Language="it">
|
||||||
|
<PersonName>
|
||||||
|
<NamePrefix>Frau</NamePrefix>
|
||||||
|
<GivenName>Genesia</GivenName>
|
||||||
|
<Surname>Supino</Surname>
|
||||||
|
</PersonName>
|
||||||
|
<Telephone PhoneTechType="5" PhoneNumber="+393406259979"/>
|
||||||
|
<Email Remark="newsletter:yes">supinogenesia@gmail.com</Email>
|
||||||
|
</Customer>
|
||||||
|
</Profile>
|
||||||
|
</ProfileInfo>
|
||||||
|
</Profiles>
|
||||||
|
</ResGuest>
|
||||||
|
</ResGuests>
|
||||||
|
<ResGlobalInfo>
|
||||||
|
<HotelReservationIDs>
|
||||||
|
<HotelReservationID ResID_Type="13" ResID_Value="IwZXh0bgNhZW0BMABhZGlkAassWPh1b8QBHoRc2S24gMktdNKiPwEvGYMK3rB-mn" ResID_Source="Facebook_Mobile_Feed" ResID_SourceContext="99tales"/>
|
||||||
|
</HotelReservationIDs>
|
||||||
|
<Profiles>
|
||||||
|
<ProfileInfo>
|
||||||
|
<Profile ProfileType="4">
|
||||||
|
<CompanyInfo>
|
||||||
|
<CompanyName Code="who knows?" CodeContext="who knows?">99tales GmbH</CompanyName>
|
||||||
|
</CompanyInfo>
|
||||||
|
</Profile>
|
||||||
|
</ProfileInfo>
|
||||||
|
</Profiles>
|
||||||
|
<BasicPropertyInfo HotelCode="12345" HotelName="Bemelmans Post"/>
|
||||||
|
</ResGlobalInfo>
|
||||||
|
</HotelReservation>
|
||||||
|
</HotelReservations>
|
||||||
|
</OTA_HotelResNotifRQ>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<OTA_HotelResNotifRQ xmlns="http://www.opentravel.org/OTA/2003/05" Version="7.000">
|
||||||
|
<HotelReservations>
|
||||||
|
<HotelReservation CreateDateTime="2025-10-07T09:38:38.167778+00:00" ResStatus="Requested" RoomStayReservation="true">
|
||||||
|
<UniqueID Type="14" ID="c52702c9-55b9-44e1-b158-ec9544c7"/>
|
||||||
|
<RoomStays>
|
||||||
|
<RoomStay>
|
||||||
|
<GuestCounts>
|
||||||
|
<GuestCount Count="3"/>
|
||||||
|
<GuestCount Count="1" Age="12"/>
|
||||||
|
</GuestCounts>
|
||||||
|
<TimeSpan Start="2026-01-02" End="2026-01-07"/>
|
||||||
|
</RoomStay>
|
||||||
|
</RoomStays>
|
||||||
|
<ResGuests>
|
||||||
|
<ResGuest>
|
||||||
|
<Profiles>
|
||||||
|
<ProfileInfo>
|
||||||
|
<Profile>
|
||||||
|
<Customer Language="it">
|
||||||
|
<PersonName>
|
||||||
|
<NamePrefix>Frau</NamePrefix>
|
||||||
|
<GivenName>Genesia</GivenName>
|
||||||
|
<Surname>Supino</Surname>
|
||||||
|
</PersonName>
|
||||||
|
<Telephone PhoneTechType="5" PhoneNumber="+393406259979"/>
|
||||||
|
<Email Remark="newsletter:yes">supinogenesia@gmail.com</Email>
|
||||||
|
</Customer>
|
||||||
|
</Profile>
|
||||||
|
</ProfileInfo>
|
||||||
|
</Profiles>
|
||||||
|
</ResGuest>
|
||||||
|
</ResGuests>
|
||||||
|
<ResGlobalInfo>
|
||||||
|
<HotelReservationIDs>
|
||||||
|
<HotelReservationID ResID_Type="13" ResID_Value="IwZXh0bgNhZW0BMABhZGlkAassWPh1b8QBHoRc2S24gMktdNKiPwEvGYMK3rB-mn" ResID_Source="Facebook_Mobile_Feed" ResID_SourceContext="99tales"/>
|
||||||
|
</HotelReservationIDs>
|
||||||
|
<Profiles>
|
||||||
|
<ProfileInfo>
|
||||||
|
<Profile ProfileType="4">
|
||||||
|
<CompanyInfo>
|
||||||
|
<CompanyName Code="who knows?" CodeContext="who knows?">99tales GmbH</CompanyName>
|
||||||
|
</CompanyInfo>
|
||||||
|
</Profile>
|
||||||
|
</ProfileInfo>
|
||||||
|
</Profiles>
|
||||||
|
<BasicPropertyInfo HotelCode="12345" HotelName="Bemelmans Post"/>
|
||||||
|
</ResGlobalInfo>
|
||||||
|
</HotelReservation>
|
||||||
|
</HotelReservations>
|
||||||
|
</OTA_HotelResNotifRQ>
|
||||||
@@ -259,4 +259,4 @@
|
|||||||
"accept": "*/*",
|
"accept": "*/*",
|
||||||
"content-length": "7081"
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ dependencies = [
|
|||||||
"generateds>=2.44.3",
|
"generateds>=2.44.3",
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
"lxml>=6.0.1",
|
"lxml>=6.0.1",
|
||||||
|
"pydantic[email]>=2.11.9",
|
||||||
"pytest>=8.4.2",
|
"pytest>=8.4.2",
|
||||||
"pytest-asyncio>=1.2.0",
|
"pytest-asyncio>=1.2.0",
|
||||||
"redis>=6.4.0",
|
"redis>=6.4.0",
|
||||||
@@ -35,7 +36,7 @@ alpine-bits-server = "alpine_bits_python.main:main"
|
|||||||
packages = ["src/alpine_bits_python"]
|
packages = ["src/alpine_bits_python"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["test"]
|
testpaths = ["tests"]
|
||||||
pythonpath = ["src"]
|
pythonpath = ["src"]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ from enum import Enum
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from alpine_bits_python.db import Customer, Reservation
|
from alpine_bits_python.db import Customer, Reservation
|
||||||
|
from alpine_bits_python.schemas import (
|
||||||
|
CommentData,
|
||||||
|
CommentListItemData,
|
||||||
|
CommentsData,
|
||||||
|
CustomerData,
|
||||||
|
HotelReservationIdData,
|
||||||
|
PhoneTechType,
|
||||||
|
)
|
||||||
|
|
||||||
# Import the generated classes
|
# Import the generated classes
|
||||||
from .generated.alpinebits import (
|
from .generated.alpinebits import (
|
||||||
@@ -21,12 +29,12 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
_LOGGER.setLevel(logging.INFO)
|
_LOGGER.setLevel(logging.INFO)
|
||||||
|
|
||||||
# Define type aliases for the two Customer types
|
# Define type aliases for the two Customer types
|
||||||
NotifCustomer = OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGuests.ResGuest.Profiles.ProfileInfo.Profile.Customer
|
NotifCustomer = OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGuests.ResGuest.Profiles.ProfileInfo.Profile.Customer # noqa: E501
|
||||||
RetrieveCustomer = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGuests.ResGuest.Profiles.ProfileInfo.Profile.Customer
|
RetrieveCustomer = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGuests.ResGuest.Profiles.ProfileInfo.Profile.Customer # noqa: E501
|
||||||
|
|
||||||
# Define type aliases for HotelReservationId types
|
# Define type aliases for HotelReservationId types
|
||||||
NotifHotelReservationId = OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGlobalInfo.HotelReservationIds.HotelReservationId
|
NotifHotelReservationId = OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGlobalInfo.HotelReservationIds.HotelReservationId # noqa: E501
|
||||||
RetrieveHotelReservationId = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.HotelReservationIds.HotelReservationId
|
RetrieveHotelReservationId = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.HotelReservationIds.HotelReservationId # noqa: E501
|
||||||
|
|
||||||
# Define type aliases for Comments types
|
# Define type aliases for Comments types
|
||||||
NotifComments = (
|
NotifComments = (
|
||||||
@@ -67,13 +75,6 @@ NotifHotelReservation = OtaHotelResNotifRq.HotelReservations.HotelReservation
|
|||||||
RetrieveHotelReservation = OtaResRetrieveRs.ReservationsList.HotelReservation
|
RetrieveHotelReservation = OtaResRetrieveRs.ReservationsList.HotelReservation
|
||||||
|
|
||||||
|
|
||||||
# phonetechtype enum 1,3,5 voice, fax, mobile
|
|
||||||
class PhoneTechType(Enum):
|
|
||||||
VOICE = "1"
|
|
||||||
FAX = "3"
|
|
||||||
MOBILE = "5"
|
|
||||||
|
|
||||||
|
|
||||||
# Enum to specify which OTA message type to use
|
# Enum to specify which OTA message type to use
|
||||||
class OtaMessageType(Enum):
|
class OtaMessageType(Enum):
|
||||||
NOTIF = "notification" # For OtaHotelResNotifRq
|
NOTIF = "notification" # For OtaHotelResNotifRq
|
||||||
@@ -87,37 +88,6 @@ class KidsAgeData:
|
|||||||
ages: list[int]
|
ages: list[int]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class CustomerData:
|
|
||||||
"""Simple data class to hold customer information without nested type constraints."""
|
|
||||||
|
|
||||||
given_name: str
|
|
||||||
surname: str
|
|
||||||
name_prefix: None | str = None
|
|
||||||
name_title: None | str = None
|
|
||||||
phone_numbers: list[tuple[str, None | PhoneTechType]] = (
|
|
||||||
None # (phone_number, phone_tech_type)
|
|
||||||
)
|
|
||||||
email_address: None | str = None
|
|
||||||
email_newsletter: None | bool = (
|
|
||||||
None # True for "yes", False for "no", None for not specified
|
|
||||||
)
|
|
||||||
address_line: None | str = None
|
|
||||||
city_name: None | str = None
|
|
||||||
postal_code: None | str = None
|
|
||||||
country_code: None | str = None # Two-letter country code
|
|
||||||
address_catalog: None | bool = (
|
|
||||||
None # True for "yes", False for "no", None for not specified
|
|
||||||
)
|
|
||||||
gender: None | str = None # "Unknown", "Male", "Female"
|
|
||||||
birth_date: None | str = None
|
|
||||||
language: None | str = None # Two-letter language code
|
|
||||||
|
|
||||||
def __post_init__(self):
|
|
||||||
if self.phone_numbers is None:
|
|
||||||
self.phone_numbers = []
|
|
||||||
|
|
||||||
|
|
||||||
class GuestCountsFactory:
|
class GuestCountsFactory:
|
||||||
"""Factory class to create GuestCounts instances for both OtaHotelResNotifRq and OtaResRetrieveRs."""
|
"""Factory class to create GuestCounts instances for both OtaHotelResNotifRq and OtaResRetrieveRs."""
|
||||||
|
|
||||||
@@ -128,6 +98,7 @@ class GuestCountsFactory:
|
|||||||
message_type: OtaMessageType = OtaMessageType.RETRIEVE,
|
message_type: OtaMessageType = OtaMessageType.RETRIEVE,
|
||||||
) -> NotifGuestCounts:
|
) -> NotifGuestCounts:
|
||||||
"""Create a GuestCounts object for OtaHotelResNotifRq or OtaResRetrieveRs.
|
"""Create a GuestCounts object for OtaHotelResNotifRq or OtaResRetrieveRs.
|
||||||
|
|
||||||
:param adults: Number of adults
|
:param adults: Number of adults
|
||||||
:param kids: List of ages for each kid (optional)
|
:param kids: List of ages for each kid (optional)
|
||||||
:return: GuestCounts instance
|
:return: GuestCounts instance
|
||||||
@@ -146,7 +117,8 @@ class GuestCountsFactory:
|
|||||||
def _create_guest_counts(
|
def _create_guest_counts(
|
||||||
adults: int, kids: list[int] | None, guest_counts_class: type
|
adults: int, kids: list[int] | None, guest_counts_class: type
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Internal method to create a GuestCounts object of the specified type.
|
"""Create a GuestCounts object of the specified type.
|
||||||
|
|
||||||
:param adults: Number of adults
|
:param adults: Number of adults
|
||||||
:param kids: List of ages for each kid (optional)
|
:param kids: List of ages for each kid (optional)
|
||||||
:param guest_counts_class: The GuestCounts class to instantiate
|
:param guest_counts_class: The GuestCounts class to instantiate
|
||||||
@@ -172,7 +144,7 @@ class GuestCountsFactory:
|
|||||||
|
|
||||||
|
|
||||||
class CustomerFactory:
|
class CustomerFactory:
|
||||||
"""Factory class to create Customer instances for both OtaHotelResNotifRq and OtaResRetrieveRs."""
|
"""Factory class to create Customer instances for both Retrieve and Notif."""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create_notif_customer(data: CustomerData) -> NotifCustomer:
|
def create_notif_customer(data: CustomerData) -> NotifCustomer:
|
||||||
@@ -185,8 +157,10 @@ class CustomerFactory:
|
|||||||
return CustomerFactory._create_customer(RetrieveCustomer, data)
|
return CustomerFactory._create_customer(RetrieveCustomer, data)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _create_customer(customer_class: type, data: CustomerData) -> Any:
|
def _create_customer(
|
||||||
"""Internal method to create a customer of the specified type."""
|
customer_class: type[RetrieveCustomer | NotifCustomer], data: CustomerData
|
||||||
|
) -> Any:
|
||||||
|
"""Create a customer of the specified type."""
|
||||||
# Create PersonName
|
# Create PersonName
|
||||||
person_name = customer_class.PersonName(
|
person_name = customer_class.PersonName(
|
||||||
given_name=data.given_name,
|
given_name=data.given_name,
|
||||||
@@ -259,19 +233,21 @@ class CustomerFactory:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _customer_to_data(customer: Any) -> CustomerData:
|
def _customer_to_data(customer: Any) -> CustomerData:
|
||||||
"""Internal method to convert any customer type to CustomerData."""
|
"""Convert any customer type to CustomerData."""
|
||||||
# Extract phone numbers
|
# Extract phone numbers
|
||||||
phone_numbers = []
|
phone_numbers = []
|
||||||
if customer.telephone:
|
if customer.telephone:
|
||||||
for tel in customer.telephone:
|
phone_numbers.extend(
|
||||||
phone_numbers.append(
|
[
|
||||||
(
|
(
|
||||||
tel.phone_number,
|
tel.phone_number,
|
||||||
PhoneTechType(tel.phone_tech_type)
|
PhoneTechType(tel.phone_tech_type)
|
||||||
if tel.phone_tech_type
|
if tel.phone_tech_type
|
||||||
else None,
|
else None,
|
||||||
)
|
)
|
||||||
)
|
for tel in customer.telephone
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
# Extract email info
|
# Extract email info
|
||||||
email_address = None
|
email_address = None
|
||||||
@@ -324,16 +300,6 @@ class CustomerFactory:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class HotelReservationIdData:
|
|
||||||
"""Simple data class to hold hotel reservation ID information without nested type constraints."""
|
|
||||||
|
|
||||||
res_id_type: str # Required field - pattern: [0-9]+
|
|
||||||
res_id_value: None | str = None # Max 64 characters
|
|
||||||
res_id_source: None | str = None # Max 64 characters
|
|
||||||
res_id_source_context: None | str = None # Max 64 characters
|
|
||||||
|
|
||||||
|
|
||||||
class HotelReservationIdFactory:
|
class HotelReservationIdFactory:
|
||||||
"""Factory class to create HotelReservationId instances for both OtaHotelResNotifRq and OtaResRetrieveRs."""
|
"""Factory class to create HotelReservationId instances for both OtaHotelResNotifRq and OtaResRetrieveRs."""
|
||||||
|
|
||||||
@@ -359,7 +325,7 @@ class HotelReservationIdFactory:
|
|||||||
def _create_hotel_reservation_id(
|
def _create_hotel_reservation_id(
|
||||||
hotel_reservation_id_class: type, data: HotelReservationIdData
|
hotel_reservation_id_class: type, data: HotelReservationIdData
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Internal method to create a hotel reservation id of the specified type."""
|
"""Create a hotel reservation id of the specified type."""
|
||||||
return hotel_reservation_id_class(
|
return hotel_reservation_id_class(
|
||||||
res_id_type=data.res_id_type,
|
res_id_type=data.res_id_type,
|
||||||
res_id_value=data.res_id_value,
|
res_id_value=data.res_id_value,
|
||||||
@@ -398,39 +364,6 @@ class HotelReservationIdFactory:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class CommentListItemData:
|
|
||||||
"""Simple data class to hold comment list item information."""
|
|
||||||
|
|
||||||
value: str # The text content of the list item
|
|
||||||
list_item: str # Numeric identifier (pattern: [0-9]+)
|
|
||||||
language: str # Two-letter language code (pattern: [a-z][a-z])
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class CommentData:
|
|
||||||
"""Simple data class to hold comment information without nested type constraints."""
|
|
||||||
|
|
||||||
name: CommentName2 # Required: "included services", "customer comment", "additional info"
|
|
||||||
text: str | None = None # Optional text content
|
|
||||||
list_items: list[CommentListItemData] = None # Optional list items
|
|
||||||
|
|
||||||
def __post_init__(self):
|
|
||||||
if self.list_items is None:
|
|
||||||
self.list_items = []
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class CommentsData:
|
|
||||||
"""Simple data class to hold multiple comments (1-3 max)."""
|
|
||||||
|
|
||||||
comments: list[CommentData] = None # 1-3 comments maximum
|
|
||||||
|
|
||||||
def __post_init__(self):
|
|
||||||
if self.comments is None:
|
|
||||||
self.comments = []
|
|
||||||
|
|
||||||
|
|
||||||
class CommentFactory:
|
class CommentFactory:
|
||||||
"""Factory class to create Comment instances for both OtaHotelResNotifRq and OtaResRetrieveRs."""
|
"""Factory class to create Comment instances for both OtaHotelResNotifRq and OtaResRetrieveRs."""
|
||||||
|
|
||||||
@@ -503,11 +436,7 @@ class CommentFactory:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Extract comment data
|
comments_data_list.append(comment)
|
||||||
comment_data = CommentData(
|
|
||||||
name=comment.name, text=comment.text, list_items=list_items_data
|
|
||||||
)
|
|
||||||
comments_data_list.append(comment_data)
|
|
||||||
|
|
||||||
return CommentsData(comments=comments_data_list)
|
return CommentsData(comments=comments_data_list)
|
||||||
|
|
||||||
@@ -536,9 +465,11 @@ class ResGuestFactory:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _create_res_guests(
|
def _create_res_guests(
|
||||||
res_guests_class: type, customer_class: type, customer_data: CustomerData
|
res_guests_class: type[RetrieveResGuests] | type[NotifResGuests],
|
||||||
|
customer_class: type[NotifCustomer | RetrieveCustomer],
|
||||||
|
customer_data: CustomerData,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Internal method to create complete ResGuests structure."""
|
"""Create the complete ResGuests structure."""
|
||||||
# Create the customer using the existing CustomerFactory
|
# Create the customer using the existing CustomerFactory
|
||||||
customer = CustomerFactory._create_customer(customer_class, customer_data)
|
customer = CustomerFactory._create_customer(customer_class, customer_data)
|
||||||
|
|
||||||
@@ -599,9 +530,7 @@ class AlpineBitsFactory:
|
|||||||
if isinstance(data, HotelReservationIdData):
|
if isinstance(data, HotelReservationIdData):
|
||||||
if message_type == OtaMessageType.NOTIF:
|
if message_type == OtaMessageType.NOTIF:
|
||||||
return HotelReservationIdFactory.create_notif_hotel_reservation_id(data)
|
return HotelReservationIdFactory.create_notif_hotel_reservation_id(data)
|
||||||
return HotelReservationIdFactory.create_retrieve_hotel_reservation_id(
|
return HotelReservationIdFactory.create_retrieve_hotel_reservation_id(data)
|
||||||
data
|
|
||||||
)
|
|
||||||
|
|
||||||
if isinstance(data, CommentsData):
|
if isinstance(data, CommentsData):
|
||||||
if message_type == OtaMessageType.NOTIF:
|
if message_type == OtaMessageType.NOTIF:
|
||||||
@@ -668,9 +597,12 @@ class AlpineBitsFactory:
|
|||||||
|
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unsupported object type: {type(obj)}")
|
raise ValueError(f"Unsupported object type: {type(obj)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def create_res_retrieve_response(list: list[tuple[Reservation, Customer]]):
|
def create_res_retrieve_response(
|
||||||
|
list: list[tuple[Reservation, Customer]],
|
||||||
|
) -> OtaResRetrieveRs:
|
||||||
"""Create RetrievedReservation XML from database entries."""
|
"""Create RetrievedReservation XML from database entries."""
|
||||||
return _create_xml_from_db(list, OtaMessageType.RETRIEVE)
|
return _create_xml_from_db(list, OtaMessageType.RETRIEVE)
|
||||||
|
|
||||||
@@ -713,8 +645,6 @@ def _process_single_reservation(
|
|||||||
reservation.num_adults, children_ages, message_type
|
reservation.num_adults, children_ages, message_type
|
||||||
)
|
)
|
||||||
|
|
||||||
unique_id_string = reservation.unique_id
|
|
||||||
|
|
||||||
if message_type == OtaMessageType.NOTIF:
|
if message_type == OtaMessageType.NOTIF:
|
||||||
UniqueId = NotifUniqueId
|
UniqueId = NotifUniqueId
|
||||||
RoomStays = NotifRoomStays
|
RoomStays = NotifRoomStays
|
||||||
@@ -726,10 +656,12 @@ def _process_single_reservation(
|
|||||||
HotelReservation = RetrieveHotelReservation
|
HotelReservation = RetrieveHotelReservation
|
||||||
Profile = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.Profiles.ProfileInfo.Profile
|
Profile = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.Profiles.ProfileInfo.Profile
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unsupported message type: {message_type}")
|
raise ValueError("Unsupported message type: %s", message_type.value)
|
||||||
|
|
||||||
|
unique_id_str = reservation.md5_unique_id
|
||||||
|
|
||||||
# UniqueID
|
# UniqueID
|
||||||
unique_id = UniqueId(type_value=UniqueIdType2.VALUE_14, id=unique_id_string)
|
unique_id = UniqueId(type_value=UniqueIdType2.VALUE_14, id=unique_id_str)
|
||||||
|
|
||||||
# TimeSpan
|
# TimeSpan
|
||||||
time_span = RoomStays.RoomStay.TimeSpan(
|
time_span = RoomStays.RoomStay.TimeSpan(
|
||||||
@@ -745,20 +677,24 @@ def _process_single_reservation(
|
|||||||
)
|
)
|
||||||
|
|
||||||
res_id_source = "website"
|
res_id_source = "website"
|
||||||
|
klick_id = None
|
||||||
|
|
||||||
if reservation.fbclid != "":
|
if reservation.fbclid != "":
|
||||||
klick_id = reservation.fbclid
|
klick_id = str(reservation.fbclid)
|
||||||
res_id_source = "meta"
|
res_id_source = "meta"
|
||||||
elif reservation.gclid != "":
|
elif reservation.gclid != "":
|
||||||
klick_id = reservation.gclid
|
klick_id = str(reservation.gclid)
|
||||||
res_id_source = "google"
|
res_id_source = "google"
|
||||||
|
|
||||||
# explicitly set klick_id to None otherwise an empty string will be sent
|
# Get utm_medium if available, otherwise use source
|
||||||
if klick_id in (None, "", "None"):
|
if reservation.utm_medium is not None and str(reservation.utm_medium) != "":
|
||||||
klick_id = None
|
res_id_source = str(reservation.utm_medium)
|
||||||
else: # extract string from Column object
|
|
||||||
klick_id = str(klick_id)
|
|
||||||
|
|
||||||
|
# Use Pydantic model for automatic validation and truncation
|
||||||
|
# It will automatically:
|
||||||
|
# - Trim whitespace
|
||||||
|
# - Truncate to 64 characters if needed
|
||||||
|
# - Convert empty strings to None
|
||||||
hotel_res_id_data = HotelReservationIdData(
|
hotel_res_id_data = HotelReservationIdData(
|
||||||
res_id_type="13",
|
res_id_type="13",
|
||||||
res_id_value=klick_id,
|
res_id_value=klick_id,
|
||||||
@@ -766,32 +702,6 @@ def _process_single_reservation(
|
|||||||
res_id_source_context="99tales",
|
res_id_source_context="99tales",
|
||||||
)
|
)
|
||||||
|
|
||||||
# explicitly set klick_id to None otherwise an empty string will be sent
|
|
||||||
if klick_id in (None, "", "None"):
|
|
||||||
klick_id = None
|
|
||||||
else: # extract string from Column object
|
|
||||||
klick_id = str(klick_id)
|
|
||||||
|
|
||||||
utm_medium = (
|
|
||||||
str(reservation.utm_medium)
|
|
||||||
if reservation.utm_medium is not None and str(reservation.utm_medium) != ""
|
|
||||||
else "website"
|
|
||||||
)
|
|
||||||
|
|
||||||
# shorten klick_id if longer than 64 characters
|
|
||||||
if klick_id is not None and len(klick_id) > 64:
|
|
||||||
klick_id = klick_id[:64]
|
|
||||||
|
|
||||||
if klick_id == "":
|
|
||||||
klick_id = None
|
|
||||||
|
|
||||||
hotel_res_id_data = HotelReservationIdData(
|
|
||||||
res_id_type="13",
|
|
||||||
res_id_value=klick_id,
|
|
||||||
res_id_source=utm_medium,
|
|
||||||
res_id_source_context="99tales",
|
|
||||||
)
|
|
||||||
|
|
||||||
hotel_res_id = alpine_bits_factory.create(hotel_res_id_data, message_type)
|
hotel_res_id = alpine_bits_factory.create(hotel_res_id_data, message_type)
|
||||||
hotel_res_ids = HotelReservation.ResGlobalInfo.HotelReservationIds(
|
hotel_res_ids = HotelReservation.ResGlobalInfo.HotelReservationIds(
|
||||||
hotel_reservation_id=[hotel_res_id]
|
hotel_reservation_id=[hotel_res_id]
|
||||||
@@ -920,12 +830,12 @@ def _create_xml_from_db(
|
|||||||
_LOGGER.debug(traceback.format_exc())
|
_LOGGER.debug(traceback.format_exc())
|
||||||
|
|
||||||
if type == OtaMessageType.NOTIF:
|
if type == OtaMessageType.NOTIF:
|
||||||
retrieved_reservations = OtaHotelResNotifRq.HotelReservations(
|
res_list_obj = OtaHotelResNotifRq.HotelReservations(
|
||||||
hotel_reservation=reservations_list
|
hotel_reservation=reservations_list
|
||||||
)
|
)
|
||||||
|
|
||||||
ota_hotel_res_notif_rq = OtaHotelResNotifRq(
|
ota_hotel_res_notif_rq = OtaHotelResNotifRq(
|
||||||
version="7.000", hotel_reservations=retrieved_reservations
|
version="7.000", hotel_reservations=res_list_obj
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -936,12 +846,12 @@ def _create_xml_from_db(
|
|||||||
|
|
||||||
return ota_hotel_res_notif_rq
|
return ota_hotel_res_notif_rq
|
||||||
if type == OtaMessageType.RETRIEVE:
|
if type == OtaMessageType.RETRIEVE:
|
||||||
retrieved_reservations = OtaResRetrieveRs.ReservationsList(
|
res_list_obj = OtaResRetrieveRs.ReservationsList(
|
||||||
hotel_reservation=reservations_list
|
hotel_reservation=reservations_list
|
||||||
)
|
)
|
||||||
|
|
||||||
ota_res_retrieve_rs = OtaResRetrieveRs(
|
ota_res_retrieve_rs = OtaResRetrieveRs(
|
||||||
version="7.000", success="", reservations_list=retrieved_reservations
|
version="7.000", success="", reservations_list=res_list_obj
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ def dump_json_for_xml(json_content: Any) -> str:
|
|||||||
|
|
||||||
Adds newlines before and after the JSON block for better readability in XML.
|
Adds newlines before and after the JSON block for better readability in XML.
|
||||||
"""
|
"""
|
||||||
return f"\n{json.dumps(json_content, indent=4)}\n"
|
return json.dumps(json_content)
|
||||||
|
|
||||||
|
|
||||||
class AlpineBitsActionName(Enum):
|
class AlpineBitsActionName(Enum):
|
||||||
@@ -202,8 +202,7 @@ class AlpineBitsAction(ABC):
|
|||||||
|
|
||||||
|
|
||||||
class ServerCapabilities:
|
class ServerCapabilities:
|
||||||
"""Automatically discovers AlpineBitsAction implementations and generates capabilities.
|
"""Automatically discovers AlpineBitsAction implementations and generates capabilities."""
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.action_registry: dict[AlpineBitsActionName, type[AlpineBitsAction]] = {}
|
self.action_registry: dict[AlpineBitsActionName, type[AlpineBitsAction]] = {}
|
||||||
@@ -237,9 +236,7 @@ class ServerCapabilities:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def create_capabilities_dict(self) -> None:
|
def create_capabilities_dict(self) -> None:
|
||||||
"""Generate the capabilities dictionary based on discovered actions.
|
"""Generate the capabilities dictionary based on discovered actions."""
|
||||||
|
|
||||||
"""
|
|
||||||
versions_dict = {}
|
versions_dict = {}
|
||||||
|
|
||||||
for action_enum, action_class in self.action_registry.items():
|
for action_enum, action_class in self.action_registry.items():
|
||||||
@@ -287,10 +284,8 @@ class ServerCapabilities:
|
|||||||
if action.get("action") != "action_OTA_Ping"
|
if action.get("action") != "action_OTA_Ping"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def get_capabilities_dict(self) -> dict:
|
def get_capabilities_dict(self) -> dict:
|
||||||
"""Get capabilities as a dictionary. Generates if not already created.
|
"""Get capabilities as a dictionary. Generates if not already created."""
|
||||||
"""
|
|
||||||
if self.capability_dict is None:
|
if self.capability_dict is None:
|
||||||
self.create_capabilities_dict()
|
self.create_capabilities_dict()
|
||||||
return self.capability_dict
|
return self.capability_dict
|
||||||
@@ -398,7 +393,7 @@ class PingAction(AlpineBitsAction):
|
|||||||
|
|
||||||
warning_response = OtaPingRs.Warnings(warning=[warning])
|
warning_response = OtaPingRs.Warnings(warning=[warning])
|
||||||
|
|
||||||
client_response_echo_data = dump_json_for_xml(echo_data_client)
|
client_response_echo_data = parsed_request.echo_data
|
||||||
|
|
||||||
response_ota_ping = OtaPingRs(
|
response_ota_ping = OtaPingRs(
|
||||||
version="7.000",
|
version="7.000",
|
||||||
@@ -530,7 +525,7 @@ class ReadAction(AlpineBitsAction):
|
|||||||
select(Reservation.id)
|
select(Reservation.id)
|
||||||
.join(
|
.join(
|
||||||
AckedRequest,
|
AckedRequest,
|
||||||
AckedRequest.unique_id == Reservation.unique_id,
|
Reservation.md5_unique_id == AckedRequest.unique_id,
|
||||||
)
|
)
|
||||||
.filter(AckedRequest.client_id == client_info.client_id)
|
.filter(AckedRequest.client_id == client_info.client_id)
|
||||||
)
|
)
|
||||||
@@ -615,7 +610,9 @@ class NotifReportReadAction(AlpineBitsAction):
|
|||||||
)
|
)
|
||||||
|
|
||||||
timestamp = datetime.now(ZoneInfo("UTC"))
|
timestamp = datetime.now(ZoneInfo("UTC"))
|
||||||
for entry in notif_report_details.hotel_notif_report.hotel_reservations.hotel_reservation: # type: ignore
|
for entry in (
|
||||||
|
notif_report_details.hotel_notif_report.hotel_reservations.hotel_reservation
|
||||||
|
): # type: ignore
|
||||||
unique_id = entry.unique_id.id
|
unique_id = entry.unique_id.id
|
||||||
acked_request = AckedRequest(
|
acked_request = AckedRequest(
|
||||||
unique_id=unique_id,
|
unique_id=unique_id,
|
||||||
|
|||||||
@@ -4,46 +4,33 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
from collections import defaultdict
|
||||||
from datetime import UTC, date, datetime
|
from datetime import UTC, date, datetime
|
||||||
from functools import partial
|
from functools import partial
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import (
|
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request
|
||||||
APIRouter,
|
|
||||||
Depends,
|
|
||||||
FastAPI,
|
|
||||||
HTTPException,
|
|
||||||
Request,
|
|
||||||
)
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import HTMLResponse, Response
|
from fastapi.responses import HTMLResponse, Response
|
||||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||||
from slowapi.errors import RateLimitExceeded
|
from slowapi.errors import RateLimitExceeded
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from alpine_bits_python.schemas import ReservationData
|
||||||
|
|
||||||
from .alpinebits_server import (
|
from .alpinebits_server import (
|
||||||
AlpineBitsActionName,
|
AlpineBitsActionName,
|
||||||
AlpineBitsClientInfo,
|
AlpineBitsClientInfo,
|
||||||
AlpineBitsServer,
|
AlpineBitsServer,
|
||||||
Version,
|
Version,
|
||||||
)
|
)
|
||||||
from .auth import (
|
from .auth import generate_api_key, generate_unique_id, validate_api_key
|
||||||
generate_api_key,
|
|
||||||
generate_unique_id,
|
|
||||||
validate_api_key,
|
|
||||||
)
|
|
||||||
from .config_loader import load_config
|
from .config_loader import load_config
|
||||||
from .db import (
|
from .db import Base, get_database_url
|
||||||
Base,
|
from .db import Customer as DBCustomer
|
||||||
get_database_url,
|
from .db import Reservation as DBReservation
|
||||||
)
|
|
||||||
from .db import (
|
|
||||||
Customer as DBCustomer,
|
|
||||||
)
|
|
||||||
from .db import (
|
|
||||||
Reservation as DBReservation,
|
|
||||||
)
|
|
||||||
from .rate_limit import (
|
from .rate_limit import (
|
||||||
BURST_RATE_LIMIT,
|
BURST_RATE_LIMIT,
|
||||||
DEFAULT_RATE_LIMIT,
|
DEFAULT_RATE_LIMIT,
|
||||||
@@ -60,8 +47,6 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
# HTTP Basic auth for AlpineBits
|
# HTTP Basic auth for AlpineBits
|
||||||
security_basic = HTTPBasic()
|
security_basic = HTTPBasic()
|
||||||
|
|
||||||
from collections import defaultdict
|
|
||||||
|
|
||||||
|
|
||||||
# --- Enhanced event dispatcher with hotel-specific routing ---
|
# --- Enhanced event dispatcher with hotel-specific routing ---
|
||||||
class EventDispatcher:
|
class EventDispatcher:
|
||||||
@@ -257,42 +242,6 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def process_form_submission(submission_data: dict[str, Any]) -> None:
|
|
||||||
"""Background task to process the form submission.
|
|
||||||
Add your business logic here.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
_LOGGER.info(
|
|
||||||
f"Processing form submission: {submission_data.get('submissionId')}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Example processing - you can replace this with your actual logic
|
|
||||||
form_name = submission_data.get("formName")
|
|
||||||
contact_email = (
|
|
||||||
submission_data.get("contact", {}).get("email")
|
|
||||||
if submission_data.get("contact")
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
# Extract form fields
|
|
||||||
form_fields = {
|
|
||||||
k: v for k, v in submission_data.items() if k.startswith("field:")
|
|
||||||
}
|
|
||||||
|
|
||||||
_LOGGER.info(
|
|
||||||
f"Form: {form_name}, Contact: {contact_email}, Fields: {len(form_fields)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Here you could:
|
|
||||||
# - Save to database
|
|
||||||
# - Send emails
|
|
||||||
# - Call external APIs
|
|
||||||
# - Process the data further
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
_LOGGER.error(f"Error processing form submission: {e!s}")
|
|
||||||
|
|
||||||
|
|
||||||
@api_router.get("/")
|
@api_router.get("/")
|
||||||
@limiter.limit(DEFAULT_RATE_LIMIT)
|
@limiter.limit(DEFAULT_RATE_LIMIT)
|
||||||
async def root(request: Request):
|
async def root(request: Request):
|
||||||
@@ -324,10 +273,25 @@ async def health_check(request: Request):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_db_reservation_from_data(
|
||||||
|
reservation_model: ReservationData, db_customer_id: int
|
||||||
|
) -> DBReservation:
|
||||||
|
"""Convert ReservationData to DBReservation, handling children_ages conversion."""
|
||||||
|
data = reservation_model.model_dump(exclude_none=True)
|
||||||
|
|
||||||
|
children_list = data.pop("children_ages", [])
|
||||||
|
children_csv = ",".join(str(int(a)) for a in children_list) if children_list else ""
|
||||||
|
data["children_ages"] = children_csv
|
||||||
|
|
||||||
|
# Inject FK
|
||||||
|
data["customer_id"] = db_customer_id
|
||||||
|
|
||||||
|
return DBReservation(**data)
|
||||||
|
|
||||||
|
|
||||||
# Extracted business logic for handling Wix form submissions
|
# Extracted business logic for handling Wix form submissions
|
||||||
async def process_wix_form_submission(request: Request, data: dict[str, Any], db):
|
async def process_wix_form_submission(request: Request, data: dict[str, Any], db):
|
||||||
"""Shared business logic for handling Wix form submissions (test and production).
|
"""Shared business logic for handling Wix form submissions (test and production)."""
|
||||||
"""
|
|
||||||
timestamp = datetime.now().isoformat()
|
timestamp = datetime.now().isoformat()
|
||||||
|
|
||||||
_LOGGER.info(f"Received Wix form data at {timestamp}")
|
_LOGGER.info(f"Received Wix form data at {timestamp}")
|
||||||
@@ -410,23 +374,10 @@ async def process_wix_form_submission(request: Request, data: dict[str, Any], db
|
|||||||
|
|
||||||
offer = data.get("field:angebot_auswaehlen")
|
offer = data.get("field:angebot_auswaehlen")
|
||||||
|
|
||||||
# UTM and offer
|
|
||||||
utm_fields = [
|
|
||||||
("utm_Source", "utm_source"),
|
|
||||||
("utm_Medium", "utm_medium"),
|
|
||||||
("utm_Campaign", "utm_campaign"),
|
|
||||||
("utm_Term", "utm_term"),
|
|
||||||
("utm_Content", "utm_content"),
|
|
||||||
]
|
|
||||||
|
|
||||||
# get submissionId and ensure max length 35. Generate one if not present
|
# get submissionId and ensure max length 35. Generate one if not present
|
||||||
|
|
||||||
unique_id = data.get("submissionId", generate_unique_id())
|
unique_id = data.get("submissionId", generate_unique_id())
|
||||||
|
|
||||||
if len(unique_id) > 32:
|
|
||||||
# strip to first 35 chars
|
|
||||||
unique_id = unique_id[:32]
|
|
||||||
|
|
||||||
# use database session
|
# use database session
|
||||||
|
|
||||||
# Save all relevant data to DB (including new fields)
|
# Save all relevant data to DB (including new fields)
|
||||||
@@ -468,14 +419,15 @@ async def process_wix_form_submission(request: Request, data: dict[str, Any], db
|
|||||||
or "Frangart Inn" # fallback
|
or "Frangart Inn" # fallback
|
||||||
)
|
)
|
||||||
|
|
||||||
db_reservation = DBReservation(
|
reservation = ReservationData(
|
||||||
customer_id=db_customer.id,
|
|
||||||
unique_id=unique_id,
|
unique_id=unique_id,
|
||||||
start_date=date.fromisoformat(start_date) if start_date else None,
|
start_date=date.fromisoformat(start_date),
|
||||||
end_date=date.fromisoformat(end_date) if end_date else None,
|
end_date=date.fromisoformat(end_date),
|
||||||
num_adults=num_adults,
|
num_adults=num_adults,
|
||||||
num_children=num_children,
|
num_children=num_children,
|
||||||
children_ages=",".join(str(a) for a in children_ages),
|
children_ages=children_ages,
|
||||||
|
hotel_code=hotel_code,
|
||||||
|
hotel_name=hotel_name,
|
||||||
offer=offer,
|
offer=offer,
|
||||||
created_at=datetime.now(UTC),
|
created_at=datetime.now(UTC),
|
||||||
utm_source=data.get("field:utm_source"),
|
utm_source=data.get("field:utm_source"),
|
||||||
@@ -486,9 +438,12 @@ async def process_wix_form_submission(request: Request, data: dict[str, Any], db
|
|||||||
user_comment=data.get("field:long_answer_3524", ""),
|
user_comment=data.get("field:long_answer_3524", ""),
|
||||||
fbclid=data.get("field:fbclid"),
|
fbclid=data.get("field:fbclid"),
|
||||||
gclid=data.get("field:gclid"),
|
gclid=data.get("field:gclid"),
|
||||||
hotel_code=hotel_code,
|
|
||||||
hotel_name=hotel_name,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if reservation.md5_unique_id is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Failed to generate md5_unique_id")
|
||||||
|
|
||||||
|
db_reservation = create_db_reservation_from_data(reservation, db_customer.id)
|
||||||
db.add(db_reservation)
|
db.add(db_reservation)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(db_reservation)
|
await db.refresh(db_reservation)
|
||||||
@@ -503,7 +458,7 @@ async def process_wix_form_submission(request: Request, data: dict[str, Any], db
|
|||||||
await dispatcher.dispatch_for_hotel(
|
await dispatcher.dispatch_for_hotel(
|
||||||
"form_processed", hotel_code, db_customer, db_reservation
|
"form_processed", hotel_code, db_customer, db_reservation
|
||||||
)
|
)
|
||||||
_LOGGER.info(f"Dispatched form_processed event for hotel {hotel_code}")
|
_LOGGER.info("Dispatched form_processed event for hotel %s", hotel_code)
|
||||||
else:
|
else:
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"No hotel_code in reservation, skipping push notifications"
|
"No hotel_code in reservation, skipping push notifications"
|
||||||
@@ -521,6 +476,43 @@ async def process_wix_form_submission(request: Request, data: dict[str, Any], db
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_basic_auth(
|
||||||
|
credentials: HTTPBasicCredentials = Depends(security_basic),
|
||||||
|
) -> str:
|
||||||
|
"""Validate basic authentication for AlpineBits protocol.
|
||||||
|
|
||||||
|
Returns username if valid, raises HTTPException if not.
|
||||||
|
"""
|
||||||
|
# Accept any username/password pair present in config['alpine_bits_auth']
|
||||||
|
if not credentials.username or not credentials.password:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="ERROR: Authentication required",
|
||||||
|
headers={"WWW-Authenticate": "Basic"},
|
||||||
|
)
|
||||||
|
valid = False
|
||||||
|
config = app.state.config
|
||||||
|
|
||||||
|
for entry in config["alpine_bits_auth"]:
|
||||||
|
if (
|
||||||
|
credentials.username == entry["username"]
|
||||||
|
and credentials.password == entry["password"]
|
||||||
|
):
|
||||||
|
valid = True
|
||||||
|
break
|
||||||
|
if not valid:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="ERROR: Invalid credentials",
|
||||||
|
headers={"WWW-Authenticate": "Basic"},
|
||||||
|
)
|
||||||
|
_LOGGER.info(
|
||||||
|
"AlpineBits authentication successful for user: %s (from config)",
|
||||||
|
credentials.username,
|
||||||
|
)
|
||||||
|
return credentials.username, credentials.password
|
||||||
|
|
||||||
|
|
||||||
@api_router.post("/webhook/wix-form")
|
@api_router.post("/webhook/wix-form")
|
||||||
@webhook_limiter.limit(WEBHOOK_RATE_LIMIT)
|
@webhook_limiter.limit(WEBHOOK_RATE_LIMIT)
|
||||||
async def handle_wix_form(
|
async def handle_wix_form(
|
||||||
@@ -556,6 +548,84 @@ async def handle_wix_form_test(
|
|||||||
raise HTTPException(status_code=500, detail="Error processing test data")
|
raise HTTPException(status_code=500, detail="Error processing test data")
|
||||||
|
|
||||||
|
|
||||||
|
@api_router.post("/hoteldata/conversions_import")
|
||||||
|
@limiter.limit(DEFAULT_RATE_LIMIT)
|
||||||
|
async def handle_xml_upload(
|
||||||
|
request: Request, credentials_tupel: tuple = Depends(validate_basic_auth)
|
||||||
|
):
|
||||||
|
"""Endpoint for receiving XML files for conversion processing.
|
||||||
|
Requires basic authentication and saves XML files to log directory.
|
||||||
|
Supports gzip compression via Content-Encoding header.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get the raw body content
|
||||||
|
body = await request.body()
|
||||||
|
|
||||||
|
if not body:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="ERROR: No XML content provided"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if content is gzip compressed
|
||||||
|
content_encoding = request.headers.get("content-encoding", "").lower()
|
||||||
|
is_gzipped = content_encoding == "gzip"
|
||||||
|
|
||||||
|
# Decompress if gzipped
|
||||||
|
if is_gzipped:
|
||||||
|
try:
|
||||||
|
body = gzip.decompress(body)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"ERROR: Failed to decompress gzip content: {e}",
|
||||||
|
) from e
|
||||||
|
|
||||||
|
# Try to decode as UTF-8
|
||||||
|
try:
|
||||||
|
xml_content = body.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
# If UTF-8 fails, try with latin-1 as fallback
|
||||||
|
xml_content = body.decode("latin-1")
|
||||||
|
|
||||||
|
# Basic validation that it's XML-like
|
||||||
|
if not xml_content.strip().startswith("<"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="ERROR: Content does not appear to be XML"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create logs directory for XML conversions
|
||||||
|
logs_dir = Path("logs/conversions_import")
|
||||||
|
if not logs_dir.exists():
|
||||||
|
logs_dir.mkdir(parents=True, mode=0o755, exist_ok=True)
|
||||||
|
_LOGGER.info("Created directory: %s", logs_dir)
|
||||||
|
|
||||||
|
# Generate filename with timestamp and authenticated user
|
||||||
|
username, _ = credentials_tupel
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
log_filename = logs_dir / f"xml_import_{username}_{timestamp}.xml"
|
||||||
|
|
||||||
|
# Save XML content to file
|
||||||
|
log_filename.write_text(xml_content, encoding="utf-8")
|
||||||
|
|
||||||
|
_LOGGER.info("XML file saved to %s by user %s", log_filename, username)
|
||||||
|
|
||||||
|
response_headers = {
|
||||||
|
"Content-Type": "application/xml; charset=utf-8",
|
||||||
|
"X-AlpineBits-Server-Accept-Encoding": "gzip",
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content="Xml received", headers=response_headers, status_code=200
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.exception("Error in handle_xml_upload")
|
||||||
|
raise HTTPException(status_code=500, detail="Error processing XML upload")
|
||||||
|
|
||||||
|
|
||||||
|
# UNUSED
|
||||||
@api_router.post("/admin/generate-api-key")
|
@api_router.post("/admin/generate-api-key")
|
||||||
@limiter.limit("5/hour") # Very restrictive for admin operations
|
@limiter.limit("5/hour") # Very restrictive for admin operations
|
||||||
async def generate_new_api_key(
|
async def generate_new_api_key(
|
||||||
@@ -579,41 +649,7 @@ async def generate_new_api_key(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def validate_basic_auth(
|
# TODO Bit sketchy. May need requests-toolkit in the future
|
||||||
credentials: HTTPBasicCredentials = Depends(security_basic),
|
|
||||||
) -> str:
|
|
||||||
"""Validate basic authentication for AlpineBits protocol.
|
|
||||||
Returns username if valid, raises HTTPException if not.
|
|
||||||
"""
|
|
||||||
# Accept any username/password pair present in config['alpine_bits_auth']
|
|
||||||
if not credentials.username or not credentials.password:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=401,
|
|
||||||
detail="ERROR: Authentication required",
|
|
||||||
headers={"WWW-Authenticate": "Basic"},
|
|
||||||
)
|
|
||||||
valid = False
|
|
||||||
config = app.state.config
|
|
||||||
|
|
||||||
for entry in config["alpine_bits_auth"]:
|
|
||||||
if (
|
|
||||||
credentials.username == entry["username"]
|
|
||||||
and credentials.password == entry["password"]
|
|
||||||
):
|
|
||||||
valid = True
|
|
||||||
break
|
|
||||||
if not valid:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=401,
|
|
||||||
detail="ERROR: Invalid credentials",
|
|
||||||
headers={"WWW-Authenticate": "Basic"},
|
|
||||||
)
|
|
||||||
_LOGGER.info(
|
|
||||||
f"AlpineBits authentication successful for user: {credentials.username} (from config)"
|
|
||||||
)
|
|
||||||
return credentials.username, credentials.password
|
|
||||||
|
|
||||||
|
|
||||||
def parse_multipart_data(content_type: str, body: bytes) -> dict[str, Any]:
|
def parse_multipart_data(content_type: str, body: bytes) -> dict[str, Any]:
|
||||||
"""Parse multipart/form-data from raw request body.
|
"""Parse multipart/form-data from raw request body.
|
||||||
This is a simplified parser for the AlpineBits use case.
|
This is a simplified parser for the AlpineBits use case.
|
||||||
@@ -705,12 +741,12 @@ async def alpinebits_server_handshake(
|
|||||||
"No X-AlpineBits-ClientProtocolVersion header found, assuming pre-2013-04"
|
"No X-AlpineBits-ClientProtocolVersion header found, assuming pre-2013-04"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
_LOGGER.info(f"Client protocol version: {client_protocol_version}")
|
_LOGGER.info("Client protocol version: %s", client_protocol_version)
|
||||||
|
|
||||||
# Optional client ID
|
# Optional client ID
|
||||||
client_id = request.headers.get("X-AlpineBits-ClientID")
|
client_id = request.headers.get("X-AlpineBits-ClientID")
|
||||||
if client_id:
|
if client_id:
|
||||||
_LOGGER.info(f"Client ID: {client_id}")
|
_LOGGER.info("Client ID: %s", client_id)
|
||||||
|
|
||||||
# Check content encoding
|
# Check content encoding
|
||||||
content_encoding = request.headers.get("Content-Encoding")
|
content_encoding = request.headers.get("Content-Encoding")
|
||||||
@@ -722,50 +758,14 @@ async def alpinebits_server_handshake(
|
|||||||
# Get content type before processing
|
# Get content type before processing
|
||||||
content_type = request.headers.get("Content-Type", "")
|
content_type = request.headers.get("Content-Type", "")
|
||||||
|
|
||||||
_LOGGER.info(f"Content-Type: {content_type}")
|
_LOGGER.info("Content-Type: %s", content_type)
|
||||||
_LOGGER.info(f"Content-Encoding: {content_encoding}")
|
_LOGGER.info("Content-Encoding: %s", content_encoding)
|
||||||
|
|
||||||
# Get request body
|
# Get request body
|
||||||
body = await request.body()
|
body = await request.body()
|
||||||
|
|
||||||
# Decompress if needed
|
# Decompress if needed
|
||||||
if is_compressed:
|
form_data = validate_alpinebits_body(is_compressed, content_type, body)
|
||||||
try:
|
|
||||||
body = gzip.decompress(body)
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="ERROR: Failed to decompress gzip content",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check content type (after decompression)
|
|
||||||
if (
|
|
||||||
"multipart/form-data" not in content_type
|
|
||||||
and "application/x-www-form-urlencoded" not in content_type
|
|
||||||
):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="ERROR: Content-Type must be multipart/form-data or application/x-www-form-urlencoded",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse multipart data
|
|
||||||
if "multipart/form-data" in content_type:
|
|
||||||
try:
|
|
||||||
form_data = parse_multipart_data(content_type, body)
|
|
||||||
except Exception:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="ERROR: Failed to parse multipart/form-data",
|
|
||||||
)
|
|
||||||
elif "application/x-www-form-urlencoded" in content_type:
|
|
||||||
# Parse as urlencoded
|
|
||||||
form_data = dict(urllib.parse.parse_qsl(body.decode("utf-8")))
|
|
||||||
else:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="ERROR: Content-Type must be multipart/form-data or application/x-www-form-urlencoded",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check for required action parameter
|
# Check for required action parameter
|
||||||
action = form_data.get("action")
|
action = form_data.get("action")
|
||||||
@@ -807,6 +807,11 @@ async def alpinebits_server_handshake(
|
|||||||
"X-AlpineBits-Server-Version": "2024-10",
|
"X-AlpineBits-Server-Version": "2024-10",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if is_compressed:
|
||||||
|
# Compress response if client sent compressed request
|
||||||
|
response_xml = gzip.compress(response_xml.encode("utf-8"))
|
||||||
|
headers["Content-Encoding"] = "gzip"
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
content=response_xml, status_code=response.status_code, headers=headers
|
content=response_xml, status_code=response.status_code, headers=headers
|
||||||
)
|
)
|
||||||
@@ -819,6 +824,49 @@ async def alpinebits_server_handshake(
|
|||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_alpinebits_body(is_compressed, content_type, body):
|
||||||
|
"""Check if the body conforms to AlpineBits expectations."""
|
||||||
|
if is_compressed:
|
||||||
|
try:
|
||||||
|
body = gzip.decompress(body)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="ERROR: Failed to decompress gzip content",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check content type (after decompression)
|
||||||
|
if (
|
||||||
|
"multipart/form-data" not in content_type
|
||||||
|
and "application/x-www-form-urlencoded" not in content_type
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="ERROR: Content-Type must be multipart/form-data or application/x-www-form-urlencoded",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse multipart data
|
||||||
|
if "multipart/form-data" in content_type:
|
||||||
|
try:
|
||||||
|
form_data = parse_multipart_data(content_type, body)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="ERROR: Failed to parse multipart/form-data",
|
||||||
|
)
|
||||||
|
elif "application/x-www-form-urlencoded" in content_type:
|
||||||
|
# Parse as urlencoded
|
||||||
|
form_data = dict(urllib.parse.parse_qsl(body.decode("utf-8")))
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="ERROR: Content-Type must be multipart/form-data or application/x-www-form-urlencoded",
|
||||||
|
)
|
||||||
|
|
||||||
|
return form_data
|
||||||
|
|
||||||
|
|
||||||
@api_router.get("/admin/stats")
|
@api_router.get("/admin/stats")
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
async def get_api_stats(request: Request, admin_key: str = Depends(validate_api_key)):
|
async def get_api_stats(request: Request, admin_key: str = Depends(validate_api_key)):
|
||||||
@@ -847,12 +895,9 @@ app.include_router(api_router)
|
|||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def landing_page():
|
async def landing_page():
|
||||||
"""Serve the under construction landing page at the root route
|
"""Serve the under construction landing page at the root route."""
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
# Get the path to the HTML file
|
# Get the path to the HTML file
|
||||||
import os
|
|
||||||
|
|
||||||
html_path = os.path.join(os.path.dirname(__file__), "templates", "index.html")
|
html_path = os.path.join(os.path.dirname(__file__), "templates", "index.html")
|
||||||
|
|
||||||
with open(html_path, encoding="utf-8") as f:
|
with open(html_path, encoding="utf-8") as f:
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ class Reservation(Base):
|
|||||||
__tablename__ = "reservations"
|
__tablename__ = "reservations"
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
customer_id = Column(Integer, ForeignKey("customers.id"))
|
customer_id = Column(Integer, ForeignKey("customers.id"))
|
||||||
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)
|
start_date = Column(Date)
|
||||||
end_date = Column(Date)
|
end_date = Column(Date)
|
||||||
num_adults = Column(Integer)
|
num_adults = Column(Integer)
|
||||||
|
|||||||
253
src/alpine_bits_python/schemas.py
Normal file
253
src/alpine_bits_python/schemas.py
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
"""Pydantic models for data validation in AlpineBits.
|
||||||
|
|
||||||
|
These models provide validation for data before it's passed to:
|
||||||
|
- SQLAlchemy database models
|
||||||
|
- AlpineBits XML generation
|
||||||
|
- API endpoints
|
||||||
|
|
||||||
|
Separating validation (Pydantic) from persistence (SQLAlchemy) and
|
||||||
|
from XML generation (xsdata) follows clean architecture principles.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from datetime import date
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from pydantic import BaseModel, EmailStr, Field, field_validator, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
# phonetechtype enum 1,3,5 voice, fax, mobile
|
||||||
|
class PhoneTechType(Enum):
|
||||||
|
VOICE = "1"
|
||||||
|
FAX = "3"
|
||||||
|
MOBILE = "5"
|
||||||
|
|
||||||
|
|
||||||
|
class PhoneNumber(BaseModel):
|
||||||
|
"""Phone number with optional type."""
|
||||||
|
|
||||||
|
number: str = Field(..., min_length=1, max_length=50, pattern=r"^\+?[0-9\s\-()]+$")
|
||||||
|
tech_type: str | None = Field(None, pattern="^[135]$") # 1=voice, 3=fax, 5=mobile
|
||||||
|
|
||||||
|
@field_validator("number")
|
||||||
|
@classmethod
|
||||||
|
def clean_phone_number(cls, v: str) -> str:
|
||||||
|
"""Remove extra spaces from phone number."""
|
||||||
|
return " ".join(v.split())
|
||||||
|
|
||||||
|
|
||||||
|
class ReservationData(BaseModel):
|
||||||
|
"""Validated reservation data."""
|
||||||
|
|
||||||
|
unique_id: str = Field(..., min_length=1, max_length=200)
|
||||||
|
md5_unique_id: str | None = Field(None, min_length=1, max_length=32)
|
||||||
|
start_date: date
|
||||||
|
end_date: date
|
||||||
|
num_adults: int = Field(..., ge=1)
|
||||||
|
num_children: int = Field(0, ge=0, le=10)
|
||||||
|
children_ages: list[int] = Field(default_factory=list)
|
||||||
|
hotel_code: str = Field(..., min_length=1, max_length=50)
|
||||||
|
hotel_name: str | None = Field(None, max_length=200)
|
||||||
|
offer: str | None = Field(None, max_length=500)
|
||||||
|
user_comment: str | None = Field(None, max_length=2000)
|
||||||
|
fbclid: str | None = Field(None, max_length=100)
|
||||||
|
gclid: str | None = Field(None, max_length=100)
|
||||||
|
utm_source: str | None = Field(None, max_length=100)
|
||||||
|
utm_medium: str | None = Field(None, max_length=100)
|
||||||
|
utm_campaign: str | None = Field(None, max_length=100)
|
||||||
|
utm_term: str | None = Field(None, max_length=100)
|
||||||
|
utm_content: str | None = Field(None, max_length=100)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def ensure_md5(self) -> "ReservationData":
|
||||||
|
"""Ensure md5_unique_id is set after model validation.
|
||||||
|
|
||||||
|
Using a model_validator in 'after' mode lets us access all fields via
|
||||||
|
the instance and set md5_unique_id in-place when it wasn't provided.
|
||||||
|
"""
|
||||||
|
if not getattr(self, "md5_unique_id", None) and getattr(
|
||||||
|
self, "unique_id", None
|
||||||
|
):
|
||||||
|
self.md5_unique_id = hashlib.md5(self.unique_id.encode("utf-8")).hexdigest()
|
||||||
|
return self
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_children_ages(self) -> "ReservationData":
|
||||||
|
"""Ensure children_ages matches num_children."""
|
||||||
|
if len(self.children_ages) != self.num_children:
|
||||||
|
raise ValueError(
|
||||||
|
f"Number of children ages ({len(self.children_ages)}) "
|
||||||
|
f"must match num_children ({self.num_children})"
|
||||||
|
)
|
||||||
|
for age in self.children_ages:
|
||||||
|
if age < 0 or age > 17:
|
||||||
|
raise ValueError(f"Child age {age} must be between 0 and 17")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerData(BaseModel):
|
||||||
|
"""Validated customer data for creating reservations and guests."""
|
||||||
|
|
||||||
|
given_name: str = Field(..., min_length=1, max_length=100)
|
||||||
|
surname: str = Field(..., min_length=1, max_length=100)
|
||||||
|
name_prefix: str | None = Field(None, max_length=20)
|
||||||
|
name_title: str | None = Field(None, max_length=20)
|
||||||
|
phone_numbers: list[tuple[str, None | PhoneTechType]] = Field(default_factory=list)
|
||||||
|
email_address: EmailStr | None = None
|
||||||
|
email_newsletter: bool | None = None
|
||||||
|
address_line: str | None = Field(None, max_length=255)
|
||||||
|
city_name: str | None = Field(None, max_length=100)
|
||||||
|
postal_code: str | None = Field(None, max_length=20)
|
||||||
|
country_code: str | None = Field(
|
||||||
|
None, min_length=2, max_length=2, pattern="^[A-Z]{2}$"
|
||||||
|
)
|
||||||
|
address_catalog: bool | None = None
|
||||||
|
gender: str | None = Field(None, pattern="^(Male|Female|Unknown)$")
|
||||||
|
birth_date: str | None = Field(None, pattern=r"^\d{4}-\d{2}-\d{2}$") # ISO format
|
||||||
|
language: str | None = Field(None, min_length=2, max_length=2, pattern="^[a-z]{2}$")
|
||||||
|
|
||||||
|
@field_validator("given_name", "surname")
|
||||||
|
@classmethod
|
||||||
|
def name_must_not_be_empty(cls, v: str) -> str:
|
||||||
|
"""Ensure names are not just whitespace."""
|
||||||
|
if not v.strip():
|
||||||
|
raise ValueError("Name cannot be empty or whitespace")
|
||||||
|
return v.strip()
|
||||||
|
|
||||||
|
@field_validator("country_code")
|
||||||
|
@classmethod
|
||||||
|
def normalize_country_code(cls, v: str | None) -> str | None:
|
||||||
|
"""Normalize country code to uppercase."""
|
||||||
|
return v.upper() if v else None
|
||||||
|
|
||||||
|
@field_validator("language")
|
||||||
|
@classmethod
|
||||||
|
def normalize_language(cls, v: str | None) -> str | None:
|
||||||
|
"""Normalize language code to lowercase."""
|
||||||
|
return v.lower() if v else None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True} # Allow creation from ORM models
|
||||||
|
|
||||||
|
|
||||||
|
class HotelReservationIdData(BaseModel):
|
||||||
|
"""Validated hotel reservation ID data."""
|
||||||
|
|
||||||
|
res_id_type: str = Field(..., pattern=r"^[0-9]+$") # Must be numeric string
|
||||||
|
res_id_value: str | None = Field(None, min_length=1, max_length=64)
|
||||||
|
res_id_source: str | None = Field(None, min_length=1, max_length=64)
|
||||||
|
res_id_source_context: str | None = Field(None, min_length=1, max_length=64)
|
||||||
|
|
||||||
|
@field_validator(
|
||||||
|
"res_id_value", "res_id_source", "res_id_source_context", mode="before"
|
||||||
|
)
|
||||||
|
@classmethod
|
||||||
|
def trim_and_truncate(cls, v: str | None) -> str | None:
|
||||||
|
"""Trim whitespace and truncate to max length if needed.
|
||||||
|
|
||||||
|
Runs BEFORE field validation to ensure values are cleaned and truncated
|
||||||
|
before max_length constraints are checked.
|
||||||
|
"""
|
||||||
|
if not v:
|
||||||
|
return None
|
||||||
|
# Convert to string if needed
|
||||||
|
v = str(v)
|
||||||
|
# Strip whitespace
|
||||||
|
v = v.strip()
|
||||||
|
# Convert empty strings to None
|
||||||
|
if not v:
|
||||||
|
return None
|
||||||
|
# Truncate to 64 characters if needed
|
||||||
|
if len(v) > 64:
|
||||||
|
v = v[:64]
|
||||||
|
return v
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class CommentListItemData(BaseModel):
|
||||||
|
"""Validated comment list item."""
|
||||||
|
|
||||||
|
value: str = Field(..., min_length=1, max_length=1000)
|
||||||
|
list_item: str = Field(..., pattern=r"^[0-9]+$") # Numeric identifier
|
||||||
|
language: str = Field(..., min_length=2, max_length=2, pattern=r"^[a-z]{2}$")
|
||||||
|
|
||||||
|
@field_validator("language")
|
||||||
|
@classmethod
|
||||||
|
def normalize_language(cls, v: str) -> str:
|
||||||
|
"""Normalize language to lowercase."""
|
||||||
|
return v.lower()
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class CommentData(BaseModel):
|
||||||
|
"""Validated comment data."""
|
||||||
|
|
||||||
|
name: str # Should be validated against CommentName2 enum
|
||||||
|
text: str | None = Field(None, max_length=4000)
|
||||||
|
list_items: list[CommentListItemData] = Field(default_factory=list)
|
||||||
|
|
||||||
|
@field_validator("list_items")
|
||||||
|
@classmethod
|
||||||
|
def validate_list_items(
|
||||||
|
cls, v: list[CommentListItemData]
|
||||||
|
) -> list[CommentListItemData]:
|
||||||
|
"""Ensure list items have unique identifiers."""
|
||||||
|
if v:
|
||||||
|
item_ids = [item.list_item for item in v]
|
||||||
|
if len(item_ids) != len(set(item_ids)):
|
||||||
|
raise ValueError("List items must have unique identifiers")
|
||||||
|
return v
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class CommentsData(BaseModel):
|
||||||
|
"""Validated comments collection."""
|
||||||
|
|
||||||
|
comments: list[CommentData] = Field(default_factory=list, max_length=3)
|
||||||
|
|
||||||
|
@field_validator("comments")
|
||||||
|
@classmethod
|
||||||
|
def validate_comment_count(cls, v: list[CommentData]) -> list[CommentData]:
|
||||||
|
"""Ensure maximum 3 comments."""
|
||||||
|
if len(v) > 3:
|
||||||
|
raise ValueError("Maximum 3 comments allowed")
|
||||||
|
return v
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
# Example usage in a service layer
|
||||||
|
class ReservationService:
|
||||||
|
"""Example service showing how to use Pydantic models with SQLAlchemy."""
|
||||||
|
|
||||||
|
def __init__(self, db_session):
|
||||||
|
self.db_session = db_session
|
||||||
|
|
||||||
|
async def create_reservation(
|
||||||
|
self, reservation_data: ReservationData, customer_data: CustomerData
|
||||||
|
):
|
||||||
|
"""Create a reservation with validated data.
|
||||||
|
|
||||||
|
The data has already been validated by Pydantic before reaching here.
|
||||||
|
"""
|
||||||
|
from alpine_bits_python.db import Customer, Reservation
|
||||||
|
|
||||||
|
# Convert validated Pydantic model to SQLAlchemy model
|
||||||
|
db_customer = Customer(**customer_data.model_dump(exclude_none=True))
|
||||||
|
self.db_session.add(db_customer)
|
||||||
|
await self.db_session.flush() # Get the customer ID
|
||||||
|
|
||||||
|
# Create reservation linked to customer
|
||||||
|
db_reservation = Reservation(
|
||||||
|
customer_id=db_customer.id,
|
||||||
|
**reservation_data.model_dump(
|
||||||
|
exclude={"children_ages"}
|
||||||
|
), # Handle separately
|
||||||
|
children_ages=",".join(map(str, reservation_data.children_ages)),
|
||||||
|
)
|
||||||
|
self.db_session.add(db_reservation)
|
||||||
|
await self.db_session.commit()
|
||||||
|
|
||||||
|
return db_reservation, db_customer
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Configuration and setup script for the Wix Form Handler API
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import secrets
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# 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("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")
|
|
||||||
elif 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,219 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Test script for the Secure Wix Form Handler API
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
import aiohttp
|
|
||||||
|
|
||||||
# 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,2 +0,0 @@
|
|||||||
|
|
||||||
|
|
||||||
455
tests/test_alpine_bits_server_read.py
Normal file
455
tests/test_alpine_bits_server_read.py
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
"""Tests for AlpineBits server read action.
|
||||||
|
|
||||||
|
This module tests the ReadAction handler which retrieves reservations
|
||||||
|
from the database and returns them as OTA_ResRetrieveRS XML.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import UTC, date, datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
from xsdata.formats.dataclass.serializers.config import SerializerConfig
|
||||||
|
from xsdata_pydantic.bindings import XmlParser, XmlSerializer
|
||||||
|
|
||||||
|
from alpine_bits_python.alpine_bits_helpers import create_res_retrieve_response
|
||||||
|
from alpine_bits_python.alpinebits_server import AlpineBitsClientInfo
|
||||||
|
from alpine_bits_python.db import Base, Customer, Reservation
|
||||||
|
from alpine_bits_python.generated import OtaReadRq
|
||||||
|
from alpine_bits_python.generated.alpinebits import OtaResRetrieveRs
|
||||||
|
from alpine_bits_python.schemas import ReservationData
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def test_db_engine():
|
||||||
|
"""Create an in-memory SQLite database for testing."""
|
||||||
|
engine = create_async_engine(
|
||||||
|
"sqlite+aiosqlite:///:memory:",
|
||||||
|
echo=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create tables
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
yield engine
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def test_db_session(test_db_engine):
|
||||||
|
"""Create a test database session."""
|
||||||
|
async_session = async_sessionmaker(
|
||||||
|
test_db_engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_customer():
|
||||||
|
"""Create a sample customer for testing."""
|
||||||
|
return Customer(
|
||||||
|
id=1,
|
||||||
|
given_name="John",
|
||||||
|
surname="Doe",
|
||||||
|
contact_id="CONTACT-12345",
|
||||||
|
name_prefix="Mr.",
|
||||||
|
name_title="Jr.",
|
||||||
|
email_address="john.doe@example.com",
|
||||||
|
phone="+1234567890",
|
||||||
|
email_newsletter=True,
|
||||||
|
address_line="123 Main Street",
|
||||||
|
city_name="Anytown",
|
||||||
|
postal_code="12345",
|
||||||
|
country_code="US",
|
||||||
|
gender="Male",
|
||||||
|
birth_date="1980-01-01",
|
||||||
|
language="en",
|
||||||
|
address_catalog=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_reservation(sample_customer):
|
||||||
|
"""Create a sample reservation for testing."""
|
||||||
|
reservation = ReservationData(
|
||||||
|
unique_id="RES-2024-001",
|
||||||
|
start_date=date(2024, 12, 25),
|
||||||
|
end_date=date(2024, 12, 31),
|
||||||
|
num_adults=2,
|
||||||
|
num_children=1,
|
||||||
|
children_ages=[8],
|
||||||
|
offer="Christmas Special",
|
||||||
|
created_at=datetime.now(UTC),
|
||||||
|
utm_source="google",
|
||||||
|
utm_medium="cpc",
|
||||||
|
utm_campaign="winter2024",
|
||||||
|
utm_term="ski resort",
|
||||||
|
utm_content="ad1",
|
||||||
|
user_comment="Late check-in requested",
|
||||||
|
fbclid="",
|
||||||
|
gclid="abc123xyz",
|
||||||
|
hotel_code="HOTEL123",
|
||||||
|
hotel_name="Alpine Paradise Resort",
|
||||||
|
)
|
||||||
|
data = reservation.model_dump(exclude_none=True)
|
||||||
|
|
||||||
|
children_list = data.pop("children_ages", [])
|
||||||
|
children_csv = ",".join(str(int(a)) for a in children_list) if children_list else ""
|
||||||
|
data["children_ages"] = children_csv
|
||||||
|
|
||||||
|
print(data)
|
||||||
|
|
||||||
|
return Reservation(
|
||||||
|
id=1,
|
||||||
|
customer_id=1,
|
||||||
|
**data,
|
||||||
|
customer=sample_customer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def minimal_customer():
|
||||||
|
"""Create a minimal customer with only required fields."""
|
||||||
|
return Customer(
|
||||||
|
id=2,
|
||||||
|
given_name="Jane",
|
||||||
|
surname="Smith",
|
||||||
|
contact_id="CONTACT-67890",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def minimal_reservation(minimal_customer):
|
||||||
|
"""Create a minimal reservation with only required fields."""
|
||||||
|
reservation = ReservationData(
|
||||||
|
unique_id="RES-2024-002",
|
||||||
|
start_date=date(2025, 1, 15),
|
||||||
|
end_date=date(2025, 1, 20),
|
||||||
|
num_adults=1,
|
||||||
|
num_children=0,
|
||||||
|
children_ages=[],
|
||||||
|
hotel_code="HOTEL123",
|
||||||
|
created_at=datetime.now(UTC),
|
||||||
|
hotel_name="Alpine Paradise Resort",
|
||||||
|
)
|
||||||
|
|
||||||
|
data = reservation.model_dump(exclude_none=True)
|
||||||
|
|
||||||
|
children_list = data.pop("children_ages", [])
|
||||||
|
children_csv = ",".join(str(int(a)) for a in children_list) if children_list else ""
|
||||||
|
data["children_ages"] = children_csv
|
||||||
|
|
||||||
|
return Reservation(
|
||||||
|
id=2,
|
||||||
|
customer_id=2,
|
||||||
|
**data,
|
||||||
|
customer=minimal_customer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def read_request_xml():
|
||||||
|
"""Sample OTA_ReadRQ XML request."""
|
||||||
|
return """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<OTA_ReadRQ xmlns="http://www.opentravel.org/OTA/2003/05"
|
||||||
|
EchoToken="12345"
|
||||||
|
TimeStamp="2024-10-07T10:00:00"
|
||||||
|
Version="8.000">
|
||||||
|
<ReadRequests>
|
||||||
|
<HotelReadRequest HotelCode="HOTEL123" HotelName="Alpine Paradise Resort">
|
||||||
|
<SelectionCriteria Start="2024-12-01" End="2025-01-31"/>
|
||||||
|
</HotelReadRequest>
|
||||||
|
</ReadRequests>
|
||||||
|
</OTA_ReadRQ>"""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def read_request_xml_no_date_filter():
|
||||||
|
"""Sample OTA_ReadRQ XML request without date filter."""
|
||||||
|
return """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<OTA_ReadRQ xmlns="http://www.opentravel.org/OTA/2003/05"
|
||||||
|
EchoToken="12345"
|
||||||
|
TimeStamp="2024-10-07T10:00:00"
|
||||||
|
Version="8.000">
|
||||||
|
<ReadRequests>
|
||||||
|
<HotelReadRequest HotelCode="HOTEL123" HotelName="Alpine Paradise Resort"/>
|
||||||
|
</ReadRequests>
|
||||||
|
</OTA_ReadRQ>"""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_config():
|
||||||
|
"""Test configuration with hotel credentials."""
|
||||||
|
return {
|
||||||
|
"hotels": [
|
||||||
|
{
|
||||||
|
"hotel_id": "HOTEL123",
|
||||||
|
"username": "testuser",
|
||||||
|
"password": "testpass",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client_info():
|
||||||
|
"""Sample client info for testing."""
|
||||||
|
return AlpineBitsClientInfo(
|
||||||
|
username="testuser",
|
||||||
|
password="testpass",
|
||||||
|
client_id="CLIENT-001",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateResRetrieveResponse:
|
||||||
|
"""Test the create_res_retrieve_response function."""
|
||||||
|
|
||||||
|
def test_empty_list(self):
|
||||||
|
"""Test creating response with empty reservation list."""
|
||||||
|
response = create_res_retrieve_response([])
|
||||||
|
|
||||||
|
assert response is not None, "Response should not be None"
|
||||||
|
|
||||||
|
# check that response is of correct type
|
||||||
|
assert isinstance(response, OtaResRetrieveRs), (
|
||||||
|
"Response should be of type OtaResRetrieveRs"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert hasattr(response, "success"), "Response should have success attribute"
|
||||||
|
|
||||||
|
assert hasattr(response, "reservations_list"), (
|
||||||
|
"Response should have reservations_list attribute"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_single_reservation(self, sample_reservation, sample_customer):
|
||||||
|
"""Test creating response with single reservation."""
|
||||||
|
reservation_pairs = [(sample_reservation, sample_customer)]
|
||||||
|
response = create_res_retrieve_response(reservation_pairs)
|
||||||
|
|
||||||
|
assert response is not None
|
||||||
|
assert hasattr(response, "reservations_list"), (
|
||||||
|
"Response should have reservations_list attribute"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert hasattr(response.reservations_list, "hotel_reservation"), (
|
||||||
|
"reservations_list should have reservation attribute"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(response.reservations_list.hotel_reservation) == 1
|
||||||
|
res: OtaResRetrieveRs.ReservationsList.HotelReservation = (
|
||||||
|
response.reservations_list.hotel_reservation[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res.unique_id is not None, "Reservation should have unique_id"
|
||||||
|
# Verify the response can be serialized to XML
|
||||||
|
config = SerializerConfig(
|
||||||
|
pretty_print=True, xml_declaration=True, encoding="UTF-8"
|
||||||
|
)
|
||||||
|
serializer = XmlSerializer(config=config)
|
||||||
|
xml_output = serializer.render(
|
||||||
|
response, ns_map={None: "http://www.opentravel.org/OTA/2003/05"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert xml_output is not None
|
||||||
|
# assert "RES-2024-001" in xml_output does not work due to hashing
|
||||||
|
assert "John" in xml_output
|
||||||
|
assert "Doe" in xml_output
|
||||||
|
assert "HOTEL123" in xml_output
|
||||||
|
|
||||||
|
def test_multiple_reservations(
|
||||||
|
self,
|
||||||
|
sample_reservation,
|
||||||
|
sample_customer,
|
||||||
|
minimal_reservation,
|
||||||
|
minimal_customer,
|
||||||
|
):
|
||||||
|
"""Test creating response with multiple reservations."""
|
||||||
|
reservation_pairs = [
|
||||||
|
(sample_reservation, sample_customer),
|
||||||
|
(minimal_reservation, minimal_customer),
|
||||||
|
]
|
||||||
|
response = create_res_retrieve_response(reservation_pairs)
|
||||||
|
|
||||||
|
assert response is not None
|
||||||
|
|
||||||
|
# Serialize to XML and verify both reservations are present
|
||||||
|
config = SerializerConfig(
|
||||||
|
pretty_print=True, xml_declaration=True, encoding="UTF-8"
|
||||||
|
)
|
||||||
|
serializer = XmlSerializer(config=config)
|
||||||
|
xml_output = serializer.render(
|
||||||
|
response, ns_map={None: "http://www.opentravel.org/OTA/2003/05"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# assert "RES-2024-001" in xml_output
|
||||||
|
# assert "RES-2024-002" in xml_output
|
||||||
|
assert "John" in xml_output
|
||||||
|
assert "Jane" in xml_output
|
||||||
|
|
||||||
|
def test_reservation_with_children(self, sample_reservation, sample_customer):
|
||||||
|
"""Test reservation with children ages."""
|
||||||
|
sample_reservation.num_children = 2
|
||||||
|
sample_reservation.children_ages = "8,5"
|
||||||
|
|
||||||
|
reservation_pairs = [(sample_reservation, sample_customer)]
|
||||||
|
response = create_res_retrieve_response(reservation_pairs)
|
||||||
|
|
||||||
|
config = SerializerConfig(pretty_print=True)
|
||||||
|
serializer = XmlSerializer(config=config)
|
||||||
|
xml_output = serializer.render(
|
||||||
|
response, ns_map={None: "http://www.opentravel.org/OTA/2003/05"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response is not None
|
||||||
|
# Children should be represented in guest counts
|
||||||
|
assert "GuestCount" in xml_output or "Child" in xml_output
|
||||||
|
|
||||||
|
|
||||||
|
class TestXMLParsing:
|
||||||
|
"""Test XML parsing and generation."""
|
||||||
|
|
||||||
|
def test_parse_read_request(self, read_request_xml):
|
||||||
|
"""Test parsing of OTA_ReadRQ XML."""
|
||||||
|
parser = XmlParser()
|
||||||
|
read_request = parser.from_string(read_request_xml, OtaReadRq)
|
||||||
|
|
||||||
|
assert read_request is not None
|
||||||
|
assert read_request.read_requests is not None
|
||||||
|
assert read_request.read_requests.hotel_read_request is not None
|
||||||
|
|
||||||
|
hotel_req = read_request.read_requests.hotel_read_request
|
||||||
|
assert hotel_req.hotel_code == "HOTEL123"
|
||||||
|
assert hotel_req.hotel_name == "Alpine Paradise Resort"
|
||||||
|
assert hotel_req.selection_criteria is not None
|
||||||
|
assert hotel_req.selection_criteria.start == "2024-12-01"
|
||||||
|
|
||||||
|
def test_parse_read_request_no_date(self, read_request_xml_no_date_filter):
|
||||||
|
"""Test parsing of OTA_ReadRQ without date filter."""
|
||||||
|
parser = XmlParser()
|
||||||
|
read_request = parser.from_string(read_request_xml_no_date_filter, OtaReadRq)
|
||||||
|
|
||||||
|
assert read_request is not None
|
||||||
|
hotel_req = read_request.read_requests.hotel_read_request
|
||||||
|
assert hotel_req.hotel_code == "HOTEL123"
|
||||||
|
assert hotel_req.selection_criteria is None
|
||||||
|
|
||||||
|
def test_serialize_retrieve_response(
|
||||||
|
self,
|
||||||
|
sample_reservation,
|
||||||
|
sample_customer,
|
||||||
|
):
|
||||||
|
"""Test serialization of retrieve response to XML."""
|
||||||
|
reservation_pairs = [(sample_reservation, sample_customer)]
|
||||||
|
response = create_res_retrieve_response(reservation_pairs)
|
||||||
|
|
||||||
|
config = SerializerConfig(
|
||||||
|
pretty_print=True, xml_declaration=True, encoding="UTF-8"
|
||||||
|
)
|
||||||
|
serializer = XmlSerializer(config=config)
|
||||||
|
xml_output = serializer.render(
|
||||||
|
response, ns_map={None: "http://www.opentravel.org/OTA/2003/05"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify it's valid XML
|
||||||
|
assert xml_output.startswith('<?xml version="1.0" encoding="UTF-8"?>')
|
||||||
|
assert "OTA_ResRetrieveRS" in xml_output
|
||||||
|
|
||||||
|
# Verify customer data is present
|
||||||
|
assert "John" in xml_output
|
||||||
|
assert "Doe" in xml_output
|
||||||
|
assert "john.doe@example.com" in xml_output
|
||||||
|
|
||||||
|
# Verify reservation data is present
|
||||||
|
# assert "RES-2024-001" in xml_output
|
||||||
|
assert "HOTEL123" in xml_output
|
||||||
|
|
||||||
|
|
||||||
|
class TestEdgeCases:
|
||||||
|
"""Test edge cases and error conditions."""
|
||||||
|
|
||||||
|
def test_customer_with_special_characters(self):
|
||||||
|
"""Test customer with special characters in name."""
|
||||||
|
customer = Customer(
|
||||||
|
id=99,
|
||||||
|
given_name="François",
|
||||||
|
surname="O'Brien-Smith",
|
||||||
|
contact_id="CONTACT-SPECIAL",
|
||||||
|
)
|
||||||
|
reservation = Reservation(
|
||||||
|
id=99,
|
||||||
|
customer_id=99,
|
||||||
|
unique_id="RES-SPECIAL",
|
||||||
|
start_date=date(2025, 1, 1),
|
||||||
|
end_date=date(2025, 1, 5),
|
||||||
|
num_adults=1,
|
||||||
|
num_children=0,
|
||||||
|
children_ages="",
|
||||||
|
hotel_code="HOTEL123",
|
||||||
|
created_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
reservation_pairs = [(reservation, customer)]
|
||||||
|
response = create_res_retrieve_response(reservation_pairs)
|
||||||
|
|
||||||
|
config = SerializerConfig(pretty_print=True, encoding="UTF-8")
|
||||||
|
serializer = XmlSerializer(config=config)
|
||||||
|
xml_output = serializer.render(
|
||||||
|
response, ns_map={None: "http://www.opentravel.org/OTA/2003/05"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response is not None
|
||||||
|
assert xml_output is not None
|
||||||
|
|
||||||
|
def test_reservation_with_all_utm_parameters(self):
|
||||||
|
"""Test reservation with all UTM tracking parameters."""
|
||||||
|
customer = Customer(
|
||||||
|
id=97,
|
||||||
|
given_name="Marketing",
|
||||||
|
surname="Test",
|
||||||
|
contact_id="CONTACT-97",
|
||||||
|
)
|
||||||
|
reservation = ReservationData(
|
||||||
|
unique_id="RES-UTM-TEST",
|
||||||
|
start_date=date(2025, 2, 1),
|
||||||
|
end_date=date(2025, 2, 7),
|
||||||
|
num_adults=2,
|
||||||
|
num_children=0,
|
||||||
|
children_ages=[],
|
||||||
|
hotel_code="HOTEL123",
|
||||||
|
created_at=datetime.now(UTC),
|
||||||
|
utm_source="facebook",
|
||||||
|
utm_medium="social",
|
||||||
|
utm_campaign="spring2025",
|
||||||
|
utm_term="luxury resort",
|
||||||
|
utm_content="carousel_ad",
|
||||||
|
fbclid="IwAR1234567890",
|
||||||
|
gclid="",
|
||||||
|
)
|
||||||
|
|
||||||
|
reservation_db = Reservation(
|
||||||
|
id=97,
|
||||||
|
customer_id=97,
|
||||||
|
**reservation.model_dump(exclude_none=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
reservation_pairs = [(reservation_db, customer)]
|
||||||
|
response = create_res_retrieve_response(reservation_pairs)
|
||||||
|
|
||||||
|
config = SerializerConfig(pretty_print=True)
|
||||||
|
serializer = XmlSerializer(config=config)
|
||||||
|
xml_output = serializer.render(
|
||||||
|
response, ns_map={None: "http://www.opentravel.org/OTA/2003/05"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response is not None
|
||||||
|
# UTM parameters should be in comments or other fields
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
pytest.main([__file__, "-v"])
|
||||||
@@ -17,15 +17,11 @@ def extract_relevant_sections(xml_string):
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_ping_action_response_matches_expected():
|
async def test_ping_action_response_matches_expected():
|
||||||
with open("test/test_data/Handshake-OTA_PingRQ.xml", encoding="utf-8") as f:
|
with open("tests/test_data/Handshake-OTA_PingRQ.xml", encoding="utf-8") as f:
|
||||||
server = AlpineBitsServer()
|
server = AlpineBitsServer()
|
||||||
with open(
|
with open("tests/test_data/Handshake-OTA_PingRQ.xml", encoding="utf-8") as f:
|
||||||
"test/test_data/Handshake-OTA_PingRQ.xml", encoding="utf-8"
|
|
||||||
) as f:
|
|
||||||
request_xml = f.read()
|
request_xml = f.read()
|
||||||
with open(
|
with open("tests/test_data/Handshake-OTA_PingRS.xml", encoding="utf-8") as f:
|
||||||
"test/test_data/Handshake-OTA_PingRS.xml", encoding="utf-8"
|
|
||||||
) as f:
|
|
||||||
expected_xml = f.read()
|
expected_xml = f.read()
|
||||||
client_info = AlpineBitsClientInfo(username="irrelevant", password="irrelevant")
|
client_info = AlpineBitsClientInfo(username="irrelevant", password="irrelevant")
|
||||||
response = await server.handle_request(
|
response = await server.handle_request(
|
||||||
@@ -56,7 +52,7 @@ async def test_ping_action_response_matches_expected():
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_ping_action_response_success():
|
async def test_ping_action_response_success():
|
||||||
server = AlpineBitsServer()
|
server = AlpineBitsServer()
|
||||||
with open("test/test_data/Handshake-OTA_PingRQ.xml", encoding="utf-8") as f:
|
with open("tests/test_data/Handshake-OTA_PingRQ.xml", encoding="utf-8") as f:
|
||||||
request_xml = f.read()
|
request_xml = f.read()
|
||||||
client_info = AlpineBitsClientInfo(username="irrelevant", password="irrelevant")
|
client_info = AlpineBitsClientInfo(username="irrelevant", password="irrelevant")
|
||||||
response = await server.handle_request(
|
response = await server.handle_request(
|
||||||
@@ -74,7 +70,7 @@ async def test_ping_action_response_success():
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_ping_action_response_version_arbitrary():
|
async def test_ping_action_response_version_arbitrary():
|
||||||
server = AlpineBitsServer()
|
server = AlpineBitsServer()
|
||||||
with open("test/test_data/Handshake-OTA_PingRQ.xml", encoding="utf-8") as f:
|
with open("tests/test_data/Handshake-OTA_PingRQ.xml", encoding="utf-8") as f:
|
||||||
request_xml = f.read()
|
request_xml = f.read()
|
||||||
client_info = AlpineBitsClientInfo(username="irrelevant", password="irrelevant")
|
client_info = AlpineBitsClientInfo(username="irrelevant", password="irrelevant")
|
||||||
response = await server.handle_request(
|
response = await server.handle_request(
|
||||||
@@ -91,7 +87,7 @@ async def test_ping_action_response_version_arbitrary():
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_ping_action_response_invalid_action():
|
async def test_ping_action_response_invalid_action():
|
||||||
server = AlpineBitsServer()
|
server = AlpineBitsServer()
|
||||||
with open("test/test_data/Handshake-OTA_PingRQ.xml", encoding="utf-8") as f:
|
with open("tests/test_data/Handshake-OTA_PingRQ.xml", encoding="utf-8") as f:
|
||||||
request_xml = f.read()
|
request_xml = f.read()
|
||||||
client_info = AlpineBitsClientInfo(username="irrelevant", password="irrelevant")
|
client_info = AlpineBitsClientInfo(username="irrelevant", password="irrelevant")
|
||||||
response = await server.handle_request(
|
response = await server.handle_request(
|
||||||
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>
|
||||||
29
uv.lock
generated
29
uv.lock
generated
@@ -26,6 +26,7 @@ dependencies = [
|
|||||||
{ name = "generateds" },
|
{ name = "generateds" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "lxml" },
|
{ name = "lxml" },
|
||||||
|
{ name = "pydantic", extra = ["email"] },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-asyncio" },
|
{ name = "pytest-asyncio" },
|
||||||
{ name = "redis" },
|
{ name = "redis" },
|
||||||
@@ -47,6 +48,7 @@ requires-dist = [
|
|||||||
{ name = "generateds", specifier = ">=2.44.3" },
|
{ name = "generateds", specifier = ">=2.44.3" },
|
||||||
{ name = "httpx", specifier = ">=0.28.1" },
|
{ name = "httpx", specifier = ">=0.28.1" },
|
||||||
{ name = "lxml", specifier = ">=6.0.1" },
|
{ name = "lxml", specifier = ">=6.0.1" },
|
||||||
|
{ name = "pydantic", extras = ["email"], specifier = ">=2.11.9" },
|
||||||
{ name = "pytest", specifier = ">=8.4.2" },
|
{ name = "pytest", specifier = ">=8.4.2" },
|
||||||
{ name = "pytest-asyncio", specifier = ">=1.2.0" },
|
{ name = "pytest-asyncio", specifier = ">=1.2.0" },
|
||||||
{ name = "redis", specifier = ">=6.4.0" },
|
{ name = "redis", specifier = ">=6.4.0" },
|
||||||
@@ -206,6 +208,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/6e/c6/ac0b6c1e2d138f1002bcf799d330bd6d85084fece321e662a14223794041/Deprecated-1.2.18-py2.py3-none-any.whl", hash = "sha256:bd5011788200372a32418f888e326a09ff80d0214bd961147cfed01b5c018eec", size = 9998, upload-time = "2025-01-27T10:46:09.186Z" },
|
{ url = "https://files.pythonhosted.org/packages/6e/c6/ac0b6c1e2d138f1002bcf799d330bd6d85084fece321e662a14223794041/Deprecated-1.2.18-py2.py3-none-any.whl", hash = "sha256:bd5011788200372a32418f888e326a09ff80d0214bd961147cfed01b5c018eec", size = 9998, upload-time = "2025-01-27T10:46:09.186Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dnspython"
|
||||||
|
version = "2.8.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "docformatter"
|
name = "docformatter"
|
||||||
version = "1.7.7"
|
version = "1.7.7"
|
||||||
@@ -230,6 +241,19 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" },
|
{ url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "email-validator"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "dnspython" },
|
||||||
|
{ name = "idna" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.117.1"
|
version = "0.117.1"
|
||||||
@@ -508,6 +532,11 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/3e/d3/108f2006987c58e76691d5ae5d200dd3e0f532cb4e5fa3560751c3a1feba/pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2", size = 444855, upload-time = "2025-09-13T11:26:36.909Z" },
|
{ url = "https://files.pythonhosted.org/packages/3e/d3/108f2006987c58e76691d5ae5d200dd3e0f532cb4e5fa3560751c3a1feba/pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2", size = 444855, upload-time = "2025-09-13T11:26:36.909Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
email = [
|
||||||
|
{ name = "email-validator" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic-core"
|
name = "pydantic-core"
|
||||||
version = "2.33.2"
|
version = "2.33.2"
|
||||||
|
|||||||
Reference in New Issue
Block a user