186 Commits

Author SHA1 Message Date
Jonas Linter
f3978381df Matching guests works nicely 2025-11-17 14:25:53 +01:00
Jonas Linter
24067847b4 Done but not really complete 2025-11-17 10:32:26 +01:00
Jonas Linter
0c37254317 Adding guests to conversion_import 2025-11-17 09:22:35 +01:00
Jonas Linter
9b82be9a6e Fixed export 2025-11-17 09:13:33 +01:00
Jonas Linter
26c6d3ffbc Fixed migration 2025-11-13 16:25:56 +01:00
Jonas Linter
0ba70550c9 New system for acknowledgments 2025-11-13 16:23:09 +01:00
Jonas Linter
189e44a7ff Hotfix for NoneType kid ages 2025-11-13 11:39:57 +01:00
Jonas Linter
e161508a61 Wrong imports whupps 2025-11-13 09:15:56 +01:00
Jonas Linter
0d13f903a0 Updated db handling 2025-11-13 09:08:02 +01:00
Jonas Linter
12072dcbc8 Small fixes 2025-11-12 18:40:37 +01:00
Jonas Linter
f9139d82d7 Missing entry in vol schema 2025-11-04 10:05:21 +01:00
Jonas Linter
0106702f41 Fixed yaml loading 2025-11-04 10:01:06 +01:00
Jonas Linter
1f7649fffe Updated fix sequences scripts 2025-11-04 09:36:22 +01:00
Jonas Linter
eb10e070b1 Refactored db logic. Can now specify schema in config 2025-11-04 09:20:02 +01:00
Jonas Linter
e7b789fcac Fixed generic_webhook error and made the duplicate submission thing a bit cleaner 2025-10-27 09:02:50 +01:00
Jonas Linter
90d79a71fb Added account_ids to the config 2025-10-22 17:32:28 +02:00
Jonas Linter
81074d839a Started setting up conversion_imports. Not entirely sure how it ultimatly works. Need to grab some real data for a while first 2025-10-22 15:19:17 +02:00
Jonas Linter
76ab37f097 Fixed checkbox nonsense 2025-10-22 09:22:39 +02:00
Jonas Linter
12385f685b Testing hashing existing customers 2025-10-20 16:24:57 +02:00
Jonas Linter
9f36997166 Country code fixes. Hopefully 2025-10-20 09:46:20 +02:00
Jonas Linter
8e6049e210 Added docstrings 2025-10-20 09:15:08 +02:00
Jonas Linter
6f377b1ea1 Fixed some linting stuff 2025-10-20 08:56:14 +02:00
Jonas Linter
7bcbe70392 Database migrations incorrectly ran before the tables where created. This didn't cause problems when the database was already set up but would absolutely bork a fresh install. 2025-10-20 08:39:26 +02:00
Jonas Linter
b0cb4e555c Removed erroneus type hint 2025-10-17 22:39:51 +02:00
Jonas Linter
27ed8dcd1f Switched to timezone aware schema for database 2025-10-17 22:38:57 +02:00
Jonas Linter
bd54fc72ad Passing parameters from config 2025-10-17 22:29:44 +02:00
Jonas Linter
a5006b2faf Fix autoincrement 2025-10-17 22:27:10 +02:00
Jonas Linter
27cf040f45 Worker coordination cleanup 2025-10-17 19:56:04 +02:00
Jonas Linter
f30632df29 Updated reporting scheme. Should work better now 2025-10-17 19:47:15 +02:00
Jonas Linter
75f32234e0 Fixed config loading in migration script 2025-10-17 19:25:20 +02:00
Jonas Linter
e479381374 Migration script in place 2025-10-16 16:56:27 +02:00
Jonas Linter
38f3686948 Added async postgres to dependencies 2025-10-16 16:22:42 +02:00
Jonas Linter
c43782c664 Migration should work now 2025-10-16 16:16:36 +02:00
Jonas Linter
48113f6592 Merge remote-tracking branch 'origin/main' into schema_extension 2025-10-16 16:03:24 +02:00
Jonas Linter
063ae3277f Added addittonal section to the schema. Can now add RoomTypes but they are optional 2025-10-16 16:01:58 +02:00
Jonas Linter
6e963cec51 Fixed formatting for the pushover serivice 2025-10-16 11:27:08 +02:00
Jonas Linter
c07d025873 Pushover startup fix 2025-10-16 11:24:20 +02:00
Jonas Linter
d834ec2d4b Changed pushover test script to be more api friendly 2025-10-16 11:15:15 +02:00
Jonas Linter
eef70516a9 Added pushover support 2025-10-16 11:08:39 +02:00
Jonas Linter
6ad4df6990 Fixed startup email 2025-10-16 10:53:03 +02:00
Jonas Linter
90e253b950 Potentially fixed daily report 2025-10-16 10:39:43 +02:00
Jonas Linter
0753d1fc1d Removed useless test that worked but threw a bunch of warnings 2025-10-16 09:00:17 +02:00
Jonas Linter
716e5066e1 Changed return value 2025-10-15 14:38:33 +02:00
Jonas Linter
9104c60956 More logging 2025-10-15 14:31:16 +02:00
Jonas Linter
76e3b53a4e Added token to config 2025-10-15 14:27:30 +02:00
Jonas Linter
f58332221b Created lang-detect endpoint 2025-10-15 14:24:38 +02:00
Jonas Linter
d9e45fed36 Langdetect for n8n 2025-10-15 14:12:57 +02:00
Jonas Linter
361611ae1b Worker coordination with file locks 2025-10-15 10:07:42 +02:00
Jonas Linter
0d04a546cf Fixed functions in lifespan running multiple times even though once would be enough 2025-10-15 09:21:52 +02:00
Jonas Linter
a8c441ea6f Stats collector for email monitoring 2025-10-15 09:09:07 +02:00
Jonas Linter
5a0ae44a45 fixed test warning 2025-10-15 08:55:51 +02:00
Jonas Linter
3669d0ca00 Generated doc for email monitoring 2025-10-15 08:48:15 +02:00
Jonas Linter
f22684d592 Added email monitoring 2025-10-15 08:46:25 +02:00
Jonas Linter
bb900ab1ee Finally fixed vscode test discovery got dammit 2025-10-14 15:17:34 +02:00
Jonas Linter
c16848a809 Better docstrings 2025-10-14 15:06:32 +02:00
Jonas Linter
3714226b08 Log generic webhook for now 2025-10-14 14:43:59 +02:00
Jonas Linter
8f2565b5a9 Generic webhook now gets saved to database 2025-10-14 14:28:47 +02:00
Jonas Linter
669cf00bbc Fixed testcase due to email validation 2025-10-14 09:27:39 +02:00
Jonas Linter
99d1ed1732 Email validation no longer breaks customer retrieval 2025-10-14 08:46:16 +02:00
Jonas Linter
0e659072c0 Added capi test. Data is received in facebook 2025-10-13 13:45:19 +02:00
Jonas Linter
592a9d7ce7 Added reservation_service aswell 2025-10-13 10:59:05 +02:00
Jonas Linter
b045c62cee Created hashed customers. migrated to service instead of using db logic directly 2025-10-13 10:51:56 +02:00
Jonas Linter
2560f61ee8 Updated how wix forms are logged. 2025-10-13 10:03:53 +02:00
Jonas Linter
4b61921e7a Fixed missing part of the docs in readme 2025-10-10 16:59:45 +02:00
Jonas Linter
fed8cb5653 Readme update 2025-10-10 16:58:49 +02:00
Jonas Linter
5cec464ac2 Added tests for double reservation by one customer 2025-10-10 16:50:43 +02:00
Jonas Linter
1248772f60 Updateinsert customers 2025-10-10 16:47:19 +02:00
Jonas Linter
165914d686 Added generic endpoint 2025-10-10 16:21:25 +02:00
Jonas Linter
dbbdb3694b More tests 2025-10-10 16:17:01 +02:00
Jonas Linter
6ab5212a0f MORE Tests 2025-10-10 11:28:25 +02:00
Jonas Linter
4ac5a148b6 Cleanup 2025-10-10 10:45:47 +02:00
Jonas Linter
5b91608577 Linting fixies 2025-10-10 10:21:13 +02:00
Jonas Linter
2c54303189 Replace with post for xml with put 2025-10-09 16:47:54 +02:00
Jonas Linter
123bd19e3c More cleanup. Fixing linting issues and stuff like that 2025-10-09 15:41:43 +02:00
Jonas Linter
f0beb294ee Cleanup. Removed some unneccessary files 2025-10-09 15:16:28 +02:00
Jonas Linter
a325a443f7 Better logger 2025-10-09 14:29:44 +02:00
Jonas Linter
f05cc9215e Updated config 2025-10-09 14:16:11 +02:00
Jonas Linter
162ef39013 added logging config. Not active yet 2025-10-09 11:06:22 +02:00
Jonas Linter
ac57999a85 Changed some logging statements 2025-10-09 10:59:24 +02:00
Jonas Linter
7d3d63db56 Fixing some Linter mistakes 2025-10-09 10:54:33 +02:00
Jonas Linter
b9adb8c7d9 submission as creation time for reservations 2025-10-09 10:04:34 +02:00
Jonas Linter
95b17b8776 I think acknowledgments work just fine now 2025-10-09 09:38:54 +02:00
Jonas Linter
1b3ebb3cad Mucking around with the tests 2025-10-09 09:29:01 +02:00
Jonas Linter
18d30a140f Fixed SelectionCriteria Filtering. Date wasn't added to pydantic model 2025-10-09 09:22:52 +02:00
Jonas Linter
69fb1374b2 Updated sizes of certain string fields 2025-10-09 08:45:06 +02:00
Jonas Linter
bbac8060b9 Created new tests for acknowlegments. One fails atm 2025-10-08 16:48:38 +02:00
Jonas Linter
dba07fc5ff Python env now autoopens 2025-10-08 16:18:20 +02:00
Jonas Linter
44abe3ed35 VScode can now test hurray 2025-10-08 16:14:00 +02:00
Jonas Linter
52f95bd677 Updated config 2025-10-08 15:28:36 +02:00
Jonas Linter
6701dcd6bf Probably added gzip 2025-10-08 14:36:21 +02:00
Jonas Linter
9f0a77ca39 Removed unneccessary scripts 2025-10-08 14:26:11 +02:00
Jonas Linter
259243d44b updated db 2025-10-08 13:53:44 +02:00
Jonas Linter
84a57f3d98 Created endpoint for export 2025-10-08 13:28:38 +02:00
Jonas Linter
ff25142f62 All tests pass again. Handeling the children is difficult 2025-10-08 11:23:18 +02:00
Jonas Linter
ebbea84a4c Fixed acknowledgments 2025-10-08 10:47:18 +02:00
Jonas Linter
584def323c Starting unique_id migration 2025-10-08 10:45:00 +02:00
Jonas Linter
a8f46016be Merge branch 'main' into db_modeling_for_capi 2025-10-08 08:48:51 +02:00
Jonas Linter
e0c9afe227 Hotfix. Echodata unverändert zurückgeben 2025-10-08 08:33:54 +02:00
Jonas Linter
9094f3e3b7 More tests. Hard to say how useful they are though. Need further work 2025-10-07 17:25:27 +02:00
Jonas Linter
867b2632df Created copilot instructions and testing readRequests 2025-10-07 17:16:41 +02:00
Jonas Linter
a69816baa4 Additonal validation and better type hints 2025-10-07 16:28:43 +02:00
Jonas Linter
e605af1231 Using pydantic instead of dataclasses 2025-10-07 16:06:53 +02:00
Jonas Linter
e5a295faba Experimenting with pydantic 2025-10-07 15:59:00 +02:00
Jonas Linter
5ec47b8332 More cleanup. 2025-10-07 15:12:46 +02:00
Jonas Linter
122c7c8be4 Trying to gzip compress response 2025-10-07 11:34:33 +02:00
Jonas Linter
6102194712 moved tests 2025-10-07 10:24:01 +02:00
Jonas Linter
f0945ed431 Linting and formatting 2025-10-07 09:46:44 +02:00
Jonas Linter
b4b7a537e1 Formatting 2025-10-07 09:40:23 +02:00
Jonas Linter
2d9e90c9a4 Fixed test. Now compares json contents instead of strings 2025-10-07 09:33:13 +02:00
Jonas Linter
4e03d1e089 Don't send exceptions back as responses 2025-10-07 09:04:10 +02:00
Jonas Linter
1f9c969e69 formatting changes 2025-10-07 09:00:01 +02:00
Jonas Linter
106316dc6d Probably fixed handshake 2025-10-07 08:31:22 +02:00
Jonas Linter
951d3a2a26 Test stub 2025-10-07 08:28:25 +02:00
Jonas Linter
1248ba3f3a Updated example test files 2025-10-06 16:01:36 +02:00
Jonas Linter
3b33e552a9 Config an prod angepasst 2025-10-06 15:45:14 +02:00
Jonas Linter
35531ff925 Simplified comments 2025-10-06 15:41:12 +02:00
Jonas Linter
c4fa774a86 Added test forms 2025-10-06 15:38:19 +02:00
Jonas Linter
4b37d8c52c Unique_ids waren zu lange 2025-10-06 15:22:56 +02:00
Jonas Linter
c320fe866d == for string compare whupps 2025-10-06 15:15:14 +02:00
Jonas Linter
201f218c23 Really ensuring res_id isn't "" 2025-10-06 15:13:56 +02:00
Jonas Linter
808f0eccc8 Added build file 2025-10-06 14:48:16 +02:00
Jonas Linter
b8e4f4fd01 Merging to main 2025-10-06 14:46:58 +02:00
Jonas Linter
17c3fc57b2 Push requests should be mostly done 2025-10-06 11:47:28 +02:00
Jonas Linter
87668e6dc0 Unhappy with push_listener 2025-10-06 11:09:08 +02:00
Jonas Linter
68e49aab34 Made helper methods more userfriendly. Guest requests still works as expected 2025-10-06 10:58:05 +02:00
Jonas Linter
2944b52d43 Super simple email newsletter parsing. Better safe then sorry 2025-10-06 10:21:41 +02:00
Jonas Linter
325965bb10 Fixed up ping test 2025-10-02 15:44:52 +02:00
Jonas Linter
48aec92794 Fixed a small handshaking bug thanks to tests 2025-10-02 15:34:23 +02:00
Jonas Linter
82118a1fa8 Added some tests for Handshakes 2025-10-02 14:26:06 +02:00
Jonas Linter
233a682e35 Fixed OTA_NotifReport by matching on entire ActionEnum and not just one action string. Now OTA_NotifReport:GuestRequests is distinct even if its corresponding capability action is technically identical OTA_Read:GuestRequests 2025-10-02 13:43:15 +02:00
Jonas Linter
9c292a9897 FFS notifReport is another special case 2025-10-02 11:58:30 +02:00
Jonas Linter
277bd1934e Fixed empty klick_ids 2025-10-01 16:44:47 +02:00
Jonas Linter
b7afe4f528 Fixed some shoddy typing 2025-10-01 16:43:50 +02:00
Jonas Linter
36c32c44d8 Created a listener for wix-form to do push actions with but unsure how to best handle it 2025-10-01 16:32:15 +02:00
Jonas Linter
ea9b6c72e4 fixed config 2025-10-01 15:38:23 +02:00
Jonas Linter
dbfbd53ad9 Removed unused old experiments 2025-10-01 12:02:40 +02:00
Jonas Linter
579db2231f Barebones notif works. Doing nothing with warnings at the moment. Not sure what I can do exept log the things 2025-10-01 11:23:54 +02:00
Jonas Linter
9f289e4750 Fixed unique_id issue in reservation table 2025-10-01 10:15:27 +02:00
Jonas Linter
59347f504f Updated readme 2025-10-01 09:38:50 +02:00
Jonas Linter
13df12afc6 Starting to implement action_OTA_HotelResNotif_GuestRequests. Necessary to fully comply with spec 2025-10-01 09:31:11 +02:00
Jonas Linter
228aed6d58 removed unneccessary __str__ methods 2025-09-30 18:58:30 +02:00
Jonas Linter
c3a5d3bdbb Removed hosting api token from example. Token is not necessary for traefik and is not valid anyways 2025-09-30 15:28:16 +02:00
Jonas Linter
b1be81023c Setup instructions 2025-09-30 15:27:18 +02:00
Jonas Linter
6750a3d8a0 Readme ergänzt mit setup beispiel 2025-09-30 15:24:47 +02:00
Jonas Linter
8a52765f87 Added more instructions 2025-09-30 15:10:50 +02:00
Jonas Linter
eea25930ff Updated readme 2025-09-30 14:40:22 +02:00
Jonas Linter
a343013eed Added a try block so that one guestrequest failing doesn't blow up the entire thing 2025-09-30 14:29:37 +02:00
Jonas Linter
7380fa4378 Fixed missing offer comment causing validation error 2025-09-30 14:24:45 +02:00
Jonas Linter
642b6cb7a5 Fixed comments error 2025-09-29 17:36:35 +02:00
Jonas Linter
ebcf2c22dd Shorten unique id 2025-09-29 17:02:05 +02:00
Jonas Linter
373cf0882a Added empty success 2025-09-29 16:41:37 +02:00
Jonas Linter
6c2ce2dc08 Swapped form id for submission id 2025-09-29 16:40:26 +02:00
Jonas Linter
7f25fb2b02 Works but form_id is not unique. Need to find something else 2025-09-29 16:09:42 +02:00
Jonas Linter
54c002ac96 Looking good. Db querying works 2025-09-29 15:51:58 +02:00
Jonas Linter
382bf2334a fixed up the testing function 2025-09-29 14:09:31 +02:00
Jonas Linter
06739ebea9 Got db saving working 2025-09-29 13:56:34 +02:00
Jonas Linter
384fb2b558 Mostly works. Just needs some additional logic to setup the db correctly at the start 2025-09-29 13:19:30 +02:00
Jonas Linter
8d4ccc4041 housekeeping + async db 2025-09-29 12:56:47 +02:00
Jonas Linter
6688a9a465 Error handling for config loading 2025-09-29 12:15:51 +02:00
Jonas Linter
12f245ae06 Added config to api for auth 2025-09-29 12:03:49 +02:00
679785dd1c Removed debug loggings 2025-09-29 09:05:55 +00:00
9eb993cba5 Added urllib parsing for gzip compressed content 2025-09-29 09:03:41 +00:00
Jonas Linter
b79288f6b6 Trying to figure out why gzip fails 2025-09-29 10:24:56 +02:00
Jonas Linter
52114a7443 Merge branch 'main' of git.linter-home.com:jonas/alpinebits_python into main 2025-09-29 10:17:01 +02:00
Jonas Linter
784ff0e5da Saving to db and creating quote-request from db works 2025-09-29 10:17:00 +02:00
0a6c4f64e8 Updated dockerfile 2025-09-29 07:59:47 +00:00
Jonas Linter
958e48b40a Getting docker build ready again 2025-09-29 09:48:44 +02:00
Jonas Linter
76176f8a79 Adedd formid as unique id for reservations 2025-09-29 09:42:26 +02:00
Jonas Linter
4416397a69 Added guestCounts to output.xml 2025-09-29 09:24:19 +02:00
Jonas Linter
ff00edf35d Started with db development 2025-09-27 17:35:05 +02:00
Jonas Linter
7b539ea42f Updated requirements. Added 2025-09-27 17:06:54 +02:00
Jonas Linter
5fb313d4cc Changed fbclid and gclid matching 2025-09-27 17:00:19 +02:00
Jonas Linter
553fcc7a24 Started parsing wix-data to xml 2025-09-27 12:15:43 +02:00
Jonas Linter
0f7f1532a0 Started merging the two projects for simplicity 2025-09-27 10:09:58 +02:00
Jonas Linter
4cfc00abb1 Changed some error messages 2025-09-26 16:01:55 +02:00
Jonas Linter
9f80f49693 genua für heit 2025-09-25 17:13:57 +02:00
Jonas Linter
e95b680ff0 Seems like I have to remember updating the toml before adding a releaese 2025-09-25 16:42:27 +02:00
Jonas Linter
4ceb50f9ed testing with checkurl 2025-09-25 16:39:47 +02:00
Jonas Linter
eda3bf505e Testing skip existing to atleast avoid errors 2025-09-25 16:37:22 +02:00
Jonas Linter
d9f814ca64 Testing with --force to overwrite existing 2025-09-25 16:26:58 +02:00
Jonas Linter
06ea9caded now wtih build in sync 2025-09-25 16:25:28 +02:00
Jonas Linter
2af9558b35 removed print testing publish again 2025-09-25 16:13:25 +02:00
Jonas Linter
e9cf606dde added a .com to much 2025-09-25 16:11:35 +02:00
Jonas Linter
7ed6ceecc5 removed the tests since they don't work because I haven't set them up 2025-09-25 16:05:57 +02:00
Jonas Linter
31b6c7eceb downgraded to hopefully more stable 2025-09-25 16:04:32 +02:00
Jonas Linter
85214344ef always run for now for better tests 2025-09-25 15:57:32 +02:00
121 changed files with 1340463 additions and 2757 deletions

BIN
.coverage Normal file

Binary file not shown.

6
.env Normal file
View File

@@ -0,0 +1,6 @@
# Environment variables for development
# You can add project-specific environment variables here
# Example:
# ALPINEBITS_CONFIG_DIR=./config
# PYTHONPATH=./src

143
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,143 @@
# AlpineBits Python Server - AI Agent Instructions
## Project Overview
This is an **AlpineBits 2024-10 server** that bridges booking requests from Wix landing pages to hotel partners. It's a dual-purpose system:
1. **FastAPI webhook receiver** - accepts booking forms from wix.com landing pages via `/api/webhook/wix-form`
2. **AlpineBits OTA server** - exposes hotel reservation data at `/api/alpinebits/server-2024-10` using OpenTravel Alliance XML protocol
Data flows: Wix form → Database → AlpineBits XML → Hotel systems (pull or push)
## Architecture Patterns
### XML Generation with xsdata
- **Never manually construct XML strings**. Use xsdata-generated Pydantic dataclasses from `src/alpine_bits_python/generated/alpinebits.py`
- Parse XML: `XmlParser().from_string(xml_string, OtaPingRq)`
- Serialize XML: `XmlSerializer(config=SerializerConfig(...)).render(ota_object)`
- Factory pattern: Use classes in `alpine_bits_helpers.py` (e.g., `CustomerFactory`, `GuestCountsFactory`) to build complex OTA objects from DB models
- Example: `create_res_retrieve_response()` builds OTA_ResRetrieveRS from `(Reservation, Customer)` tuples
- **Regenerating XML classes**: Run `xsdata` on `AlpineBits-HotelData-2024-10/files/schema-xsd/alpinebits.xsd` to regenerate `generated/alpinebits.py` (only if XSD spec changes)
### Configuration System
- Config loaded from YAML with secret injection via `!secret` tags (see `config_loader.py`)
- Default config location: `config/config.yaml` + `config/secrets.yaml`
- Override via `ALPINEBITS_CONFIG_DIR` environment variable
- Multi-hotel support: Each hotel in `alpine_bits_auth` array gets own credentials and optional `push_endpoint`
- **Logging**: Centralized logging configured via `logger` section (see `logging_config.py` and `LOGGING.md`)
- Use `from logging_config import get_logger; _LOGGER = get_logger(__name__)` in any module
- Logs to console always; optionally to file if `logger.file` is set
- Format includes timestamp: `%(asctime)s - %(name)s - %(levelname)s - %(message)s`
### Database Layer
- **Async-only SQLAlchemy** with `AsyncSession` (see `db.py`)
- Three core tables: `Customer`, `Reservation`, `AckedRequest` (tracks which clients acknowledged which reservations)
- DB URL configurable: SQLite for dev (`sqlite+aiosqlite:///alpinebits.db`), PostgreSQL for prod
- Database auto-created on startup in `api.py:create_app()`
### Event-Driven Push System
- `EventDispatcher` in `api.py` enables hotel-specific listeners: `event_dispatcher.register_hotel_listener("reservation:created", hotel_code, push_listener)`
- Push listener sends OTA_HotelResNotif XML to hotel's configured `push_endpoint.url` with Bearer token auth
- Push requests logged to `logs/push_requests/` with timestamp and unique ID
- **Note**: Push endpoint support is currently dormant - configured but not actively used by partners
### AlpineBits Action Pattern
- Each OTA action is a class inheriting `AlpineBitsActionHandler` (see `alpinebits_server.py`)
- Actions: `PingAction`, `ReadAction`, `NotifReportAction`, `PushAction`
- Request flow: Parse XML → Call `handle()` → Return `AlpineBitsActionResult` with XML response + HTTP status
- `AlpineBitsActionName` enum maps capability names to request names (e.g., `OTA_READ``"OTA_Read:GuestRequests"`)
- Server supports multiple AlpineBits versions (2024-10, 2022-10) when actions are identical across versions
### Acknowledgment System
- `AckedRequest` table tracks which clients acknowledged which reservations via `OTA_NotifReport:GuestRequests`
- Read requests filter out acknowledged reservations for clients with `client_id`
- Prevents duplicate reservation sends: once acknowledged, data won't appear in subsequent reads for that client
## Critical Workflows
### Running Locally
```bash
uv sync # Install dependencies (uses uv, not pip!)
uv run python -m alpine_bits_python.run_api # Start server on port 8080, clears DB on startup
```
### Testing
```bash
uv run pytest # Run all tests
uv run pytest tests/test_alpine_bits_server_read.py # Specific test file
```
- Tests use in-memory SQLite via `test_db_engine` fixture (see `tests/test_alpine_bits_server_read.py`)
- Test data fixtures in `tests/test_data/` directory
### Building for Deployment
```bash
uv sync
docker build . -t gitea.linter-home.com/jonas/asa_api:master
```
- Multi-stage Dockerfile: builder stage installs deps with uv, production stage copies `.venv`
- Runs as non-root user (UID 1000) for security
- Requires `ALPINEBITS_CONFIG_DIR=/config` volume mount for config files
- **Deployment**: Docker build pipeline exists and works; can also build manually on target system
## Project-Specific Conventions
### Naming Patterns
- OTA message types use full AlpineBits names: `OtaReadRq`, `OtaResRetrieveRs`, `OtaHotelResNotifRq`
- Factory classes suffix with `Factory`: `CustomerFactory`, `HotelReservationIdFactory`
- DB models in `db.py`, validation schemas in `schemas.py`, OTA helpers in `alpine_bits_helpers.py`
### Data Validation Flow
1. **API Layer** → Pydantic schemas (`schemas.py`) validate incoming data
2. **DB Layer** → SQLAlchemy models (`db.py`) persist validated data
3. **XML Layer** → xsdata classes (`generated/alpinebits.py`) + factories (`alpine_bits_helpers.py`) generate OTA XML
This separation prevents mixing concerns (validation ≠ persistence ≠ XML generation).
### Unique ID Generation
- Reservation IDs: 35-char max, format `{hotel_code}_{uuid4}_{timestamp}`
- Generated via `generate_unique_id()` in `auth.py`
### Rate Limiting
- Uses `slowapi` with Redis backend
- Three tiers: `DEFAULT_RATE_LIMIT` (100/hour), `WEBHOOK_RATE_LIMIT` (300/hour), `BURST_RATE_LIMIT` (10/minute)
- Applied via decorators: `@limiter.limit(DEFAULT_RATE_LIMIT)`
## Common Pitfalls
1. **Don't use synchronous SQLAlchemy calls** - Always `await session.execute()`, never `session.query()`
2. **Don't hardcode XML namespaces** - Let xsdata handle them via generated classes
3. **Don't skip config validation** - Voluptuous schemas in `config_loader.py` catch config errors early
4. **Auth is per-hotel** - HTTP Basic Auth credentials from `alpine_bits_auth` config array
5. **AlpineBits version matters** - Server implements 2024-10 spec (see `AlpineBits-HotelData-2024-10/` directory)
## Key Files Reference
- `api.py` - FastAPI app, all endpoints, event dispatcher
- `alpinebits_server.py` - AlpineBits action handlers (Ping, Read, NotifReport)
- `alpine_bits_helpers.py` - Factory classes for building OTA XML from DB models
- `config_loader.py` - YAML config loading with secret injection
- `db.py` - SQLAlchemy async models (Customer, Reservation, AckedRequest)
- `schemas.py` - Pydantic validation schemas
- `generated/alpinebits.py` - xsdata-generated OTA XML classes (DO NOT EDIT - regenerate from XSD)
## Testing Strategy
- Fixtures create isolated in-memory databases per test
- Use `test_config()` fixture for test configuration
- XML serialization/parsing tested via xsdata round-trips
- Push endpoint mocking via httpx in tests

88
.github/workflows/build.yaml vendored Normal file
View File

@@ -0,0 +1,88 @@
name: CI to Docker Hub
# Controls when the workflow will run
on:
# Triggers the workflow on push or pull request events but only for the main branch
push:
branches: [ "*" ]
tags: [ "*" ]
pull_request:
branches: [ main ]
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
# This workflow contains a single job called "build"
build:
# The type of runner that the job will run on
runs-on: ubuntu-latest
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: UV sync
run: uv auth login gitea.linter-home.com --username jonas --password ${{ secrets.CI_TOKEN }} && uv lock
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Login to Gitea Docker Registry
uses: docker/login-action@v2
with:
registry: ${{ vars.REGISTRY }}
username: ${{ vars.USER_NAME }}
password: ${{ secrets.CI_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ vars.REGISTRY }}/${{ vars.USER_NAME }}/asa_api
# generate Docker tags based on the following events/attributes
tags: |
type=schedule
type=ref,event=branch
type=ref,event=pr
type=ref,event=tag
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha
# - name: Debug DNS Resolution
# run: sudo apt-get update && sudo apt-get install -y dnsutils &&
# nslookup https://${{ vars.REGISTRY }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
registry: ${{ vars.REGISTRY }}
username: ${{ vars.USER_NAME }}
password: ${{ secrets.CI_TOKEN }}
- name: Build and push
id: docker_build
uses: docker/build-push-action@v6
with:
context: .
build-args: |
CI_TOKEN=${{ secrets.CI_TOKEN }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}

View File

@@ -13,15 +13,11 @@ jobs:
- name: Checkout
uses: actions/checkout@v5
- name: Install uv
uses: astral-sh/setup-uv@v6
uses: astral-sh/setup-uv@v5
- name: Install Python 3.13
run: uv python install 3.13
- name: Build
run: uv build
run: uv auth login gitea.linter-home.com --username jonas --password ${{ secrets.CI_TOKEN }} && uv build
# Check that basic features work and we didn't miss to include crucial files
- name: Smoke test (wheel)
run: uv run --isolated --no-project --with dist/*.whl tests/smoke_test.py
- name: Smoke test (source distribution)
run: uv run --isolated --no-project --with dist/*.tar.gz tests/smoke_test.py
- name: Publish
run: uv publish --publish-url https://gitea.linter-home.com.com/api/packages/jonas/pypi --username jonas --password ${{ secrets.GITEA_TOKEN }}
run: uv publish --publish-url https://gitea.linter-home.com/api/packages/jonas/pypi --username jonas --password ${{ secrets.CI_TOKEN }}

27
.gitignore vendored
View File

@@ -13,3 +13,30 @@ wheels/
# exclude ruff cache
.ruff_cache/
# ignore test_data content but keep the folder
test_data/*
test/test_output/*
logs/*
# ignore secrets
secrets.yaml
# ignore PostgreSQL config (contains credentials)
config/postgres.yaml
# ignore db
alpinebits.db
# ignore sql
*.sql
*.csv
# test output files
test_output.txt
output.xml

5
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,5 @@
{
"recommendations": [
"ms-python.python"
]
}

38
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,38 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Python: Debug Tests",
"type": "debugpy",
"request": "launch",
"program": "${file}",
"purpose": [
"debug-test"
],
"console": "integratedTerminal",
"justMyCode": false,
"env": {
"PYTEST_ADDOPTS": "--no-cov"
}
},
{
"name": "Python: Current File",
"type": "debugpy",
"request": "launch",
"program": "${file}",
"console": "integratedTerminal",
"justMyCode": true
},
{
"name": "Python: API Server",
"type": "debugpy",
"request": "launch",
"module": "alpine_bits_python.run_api",
"console": "integratedTerminal",
"justMyCode": true,
"env": {
"ALPINEBITS_CONFIG_DIR": "${workspaceFolder}/config"
}
}
]
}

61
.vscode/settings.json vendored
View File

@@ -1,7 +1,56 @@
{
"python.testing.pytestArgs": [
"test"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true
}
"editor.formatOnSave": true,
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit"
}
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"notebook.formatOnSave.enabled": true,
"notebook.codeActionsOnSave": {
// "notebook.source.fixAll": "explicit",
// "notebook.source.organizeImports": "explicit"
},
"notebook.output.wordWrap": true,
"notebook.output.textLineLimit": 200,
"jupyter.debugJustMyCode": false,
"python.defaultInterpreterPath": "./.venv/bin/python",
"python.terminal.activateEnvironment": true,
"python.terminal.activateEnvInCurrentTerminal": true,
"python.envFile": "${workspaceFolder}/.env",
"terminal.integrated.env.linux": {
"VIRTUAL_ENV": "${workspaceFolder}/.venv",
"PATH": "${workspaceFolder}/.venv/bin:${env:PATH}"
},
"terminal.integrated.defaultProfile.linux": "bash",
"terminal.integrated.profiles.linux": {
"bash": {
"path": "bash",
"args": [
"-c",
"source ${workspaceFolder}/.venv/bin/activate && exec bash"
]
}
},
"python.testing.pytestEnabled": true,
"python.testing.pytestArgs": ["tests"],
"python.testing.pytestPath": "${workspaceFolder}/.venv/bin/pytest",
"python.testing.unittestEnabled": false,
"python.testing.autoTestDiscoverOnSaveEnabled": false,
"python.testing.cwd": "${workspaceFolder}",
"python.testing.debugPort": 5678,
"files.exclude": {
"**/*.egg-info": true,
"**/htmlcov": true,
"**/~$*": true,
"**/.coverage.*": true,
"**/.venv": true,
"**/__pycache__": true,
"**/.mypy_cache": true,
"**/.pytest_cache": true
}
}

24
99Tales_Testexport.xml Normal file
View 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>

View File

@@ -1,59 +0,0 @@
## AlpineBits Action Mapping System
### Problem Solved
The AlpineBits specification uses different names for the same action:
- **Capability JSON**: `"action_OTA_Read"` (advertised in handshake)
- **Request Action**: `"OTA_Read:GuestRequests"` (actual request parameter)
### Solution Architecture
#### 1. Enhanced AlpineBitsActionName Enum
```python
# Maps capability names to request names
OTA_READ = ("action_OTA_Read", ["OTA_Read:GuestRequests", "OTA_Read"])
```
#### 2. Automatic Action Discovery
- `ServerCapabilities` scans for implemented actions
- Only includes actions with overridden `handle()` methods
- Generates capability JSON using capability names
#### 3. Request Routing
- `AlpineBitsServer.handle_request()` accepts request action names
- Maps request names back to capability names
- Routes to appropriate action handler
- Validates version support
### Key Features
**Automatic Discovery**: New action implementations are automatically detected
**Name Mapping**: Handles capability vs request name differences
**Version Support**: Actions can support multiple versions
**Error Handling**: Proper HTTP status codes (200, 400, 401, 500)
**Capability Generation**: Dynamic JSON generation for handshakes
### Usage Example
```python
# Server automatically discovers implemented actions
server = AlpineBitsServer()
# Handle request with different name format
response = await server.handle_request(
"OTA_Read:GuestRequests", # Request name
xml_content,
"2024-10"
)
# Capability JSON uses "action_OTA_Read" automatically
capabilities = server.get_capabilities_json()
```
### Adding New Actions
1. Create action class inheriting from `AlpineBitsAction`
2. Add mapping to `AlpineBitsActionName` enum
3. Implement `handle()` method
4. Deploy - action automatically appears in capabilities
The system is now production-ready for handling AlpineBits protocol quirks!

5
CLAUDE.md Normal file
View File

@@ -0,0 +1,5 @@
This python project is managed by uv. Use uv run to execute app and tests.
The Configuration is handled in a config.yaml file. The annotatedyaml library is used to load secrets. !secret SOME_SECRET in the yaml file refers to a secret definition in a secrets.yaml file
When adding something to the config make sure to also add it to the voluptuos schema in config. If the config changes and there is an easy way to migrate an old config file do so. If its an addition then don't.

View File

@@ -1,93 +0,0 @@
class GivenNameType(GeneratedsSuper):
__hash__ = GeneratedsSuper.__hash__
subclass = None
superclass = None
def __init__(self, valueOf_=None, gds_collector_=None, **kwargs_):
self.gds_collector_ = gds_collector_
self.gds_elementtree_node_ = None
self.original_tagname_ = None
self.parent_object_ = kwargs_.get('parent_object_')
self.ns_prefix_ = None
self.valueOf_ = valueOf_
def factory(*args_, **kwargs_):
if CurrentSubclassModule_ is not None:
subclass = getSubclassFromModule_(
CurrentSubclassModule_, GivenNameType)
if subclass is not None:
return subclass(*args_, **kwargs_)
if GivenNameType.subclass:
return GivenNameType.subclass(*args_, **kwargs_)
else:
return GivenNameType(*args_, **kwargs_)
factory = staticmethod(factory)
def get_ns_prefix_(self):
return self.ns_prefix_
def set_ns_prefix_(self, ns_prefix):
self.ns_prefix_ = ns_prefix
def get_valueOf_(self): return self.valueOf_
def set_valueOf_(self, valueOf_): self.valueOf_ = valueOf_
def validate_StringLength1to64(self, value):
result = True
# Validate type StringLength1to64, a restriction on xs:string.
if value is not None and Validate_simpletypes_ and self.gds_collector_ is not None:
if not isinstance(value, str):
lineno = self.gds_get_node_lineno_()
self.gds_collector_.add_message('Value "%(value)s"%(lineno)s is not of the correct base simple type (str)' % {"value": value, "lineno": lineno, })
return False
if len(value) > 64:
lineno = self.gds_get_node_lineno_()
self.gds_collector_.add_message('Value "%(value)s"%(lineno)s does not match xsd maxLength restriction on StringLength1to64' % {"value" : encode_str_2_3(value), "lineno": lineno} )
result = False
if len(value) < 1:
lineno = self.gds_get_node_lineno_()
self.gds_collector_.add_message('Value "%(value)s"%(lineno)s does not match xsd minLength restriction on StringLength1to64' % {"value" : encode_str_2_3(value), "lineno": lineno} )
result = False
return result
def has__content(self):
if (
(1 if type(self.valueOf_) in [int,float] else self.valueOf_)
):
return True
else:
return False
def export(self, outfile, level, namespaceprefix_='', namespacedef_='', name_='GivenNameType', pretty_print=True):
imported_ns_def_ = GenerateDSNamespaceDefs_.get('GivenNameType')
if imported_ns_def_ is not None:
namespacedef_ = imported_ns_def_
if pretty_print:
eol_ = '\n'
else:
eol_ = ''
if self.original_tagname_ is not None and name_ == 'GivenNameType':
name_ = self.original_tagname_
if UseCapturedNS_ and self.ns_prefix_:
namespaceprefix_ = self.ns_prefix_ + ':'
showIndent(outfile, level, pretty_print)
outfile.write('<%s%s%s' % (namespaceprefix_, name_, namespacedef_ and ' ' + namespacedef_ or '', ))
already_processed = set()
self._exportAttributes(outfile, level, already_processed, namespaceprefix_, name_='GivenNameType')
outfile.write('>')
self._exportChildren(outfile, level + 1, namespaceprefix_, namespacedef_, name_, pretty_print=pretty_print)
outfile.write(self.convert_unicode(self.valueOf_))
outfile.write('</%s%s>%s' % (namespaceprefix_, name_, eol_))
def _exportAttributes(self, outfile, level, already_processed, namespaceprefix_='', name_='GivenNameType'):
pass
def _exportChildren(self, outfile, level, namespaceprefix_='', namespacedef_='', name_='GivenNameType', fromsubclass_=False, pretty_print=True):
pass
def build(self, node, gds_collector_=None):
self.gds_collector_ = gds_collector_
if SaveElementTreeNode:
self.gds_elementtree_node_ = node
already_processed = set()
self.ns_prefix_ = node.prefix
self._buildAttributes(node, node.attrib, already_processed)
self.valueOf_ = get_all_text_(node)
for child in node:
nodeName_ = Tag_pattern_.match(child.tag).groups()[-1]
self._buildChildren(child, node, nodeName_, gds_collector_=gds_collector_)
return self
def _buildAttributes(self, node, attrs, already_processed):
pass
def _buildChildren(self, child_, node, nodeName_, fromsubclass_=False, gds_collector_=None):
pass
# end class GivenNameType

View File

@@ -1,93 +0,0 @@
class SurnameType(GeneratedsSuper):
__hash__ = GeneratedsSuper.__hash__
subclass = None
superclass = None
def __init__(self, valueOf_=None, gds_collector_=None, **kwargs_):
self.gds_collector_ = gds_collector_
self.gds_elementtree_node_ = None
self.original_tagname_ = None
self.parent_object_ = kwargs_.get('parent_object_')
self.ns_prefix_ = None
self.valueOf_ = valueOf_
def factory(*args_, **kwargs_):
if CurrentSubclassModule_ is not None:
subclass = getSubclassFromModule_(
CurrentSubclassModule_, SurnameType)
if subclass is not None:
return subclass(*args_, **kwargs_)
if SurnameType.subclass:
return SurnameType.subclass(*args_, **kwargs_)
else:
return SurnameType(*args_, **kwargs_)
factory = staticmethod(factory)
def get_ns_prefix_(self):
return self.ns_prefix_
def set_ns_prefix_(self, ns_prefix):
self.ns_prefix_ = ns_prefix
def get_valueOf_(self): return self.valueOf_
def set_valueOf_(self, valueOf_): self.valueOf_ = valueOf_
def validate_StringLength1to64(self, value):
result = True
# Validate type StringLength1to64, a restriction on xs:string.
if value is not None and Validate_simpletypes_ and self.gds_collector_ is not None:
if not isinstance(value, str):
lineno = self.gds_get_node_lineno_()
self.gds_collector_.add_message('Value "%(value)s"%(lineno)s is not of the correct base simple type (str)' % {"value": value, "lineno": lineno, })
return False
if len(value) > 64:
lineno = self.gds_get_node_lineno_()
self.gds_collector_.add_message('Value "%(value)s"%(lineno)s does not match xsd maxLength restriction on StringLength1to64' % {"value" : encode_str_2_3(value), "lineno": lineno} )
result = False
if len(value) < 1:
lineno = self.gds_get_node_lineno_()
self.gds_collector_.add_message('Value "%(value)s"%(lineno)s does not match xsd minLength restriction on StringLength1to64' % {"value" : encode_str_2_3(value), "lineno": lineno} )
result = False
return result
def has__content(self):
if (
(1 if type(self.valueOf_) in [int,float] else self.valueOf_)
):
return True
else:
return False
def export(self, outfile, level, namespaceprefix_='', namespacedef_='', name_='SurnameType', pretty_print=True):
imported_ns_def_ = GenerateDSNamespaceDefs_.get('SurnameType')
if imported_ns_def_ is not None:
namespacedef_ = imported_ns_def_
if pretty_print:
eol_ = '\n'
else:
eol_ = ''
if self.original_tagname_ is not None and name_ == 'SurnameType':
name_ = self.original_tagname_
if UseCapturedNS_ and self.ns_prefix_:
namespaceprefix_ = self.ns_prefix_ + ':'
showIndent(outfile, level, pretty_print)
outfile.write('<%s%s%s' % (namespaceprefix_, name_, namespacedef_ and ' ' + namespacedef_ or '', ))
already_processed = set()
self._exportAttributes(outfile, level, already_processed, namespaceprefix_, name_='SurnameType')
outfile.write('>')
self._exportChildren(outfile, level + 1, namespaceprefix_, namespacedef_, name_, pretty_print=pretty_print)
outfile.write(self.convert_unicode(self.valueOf_))
outfile.write('</%s%s>%s' % (namespaceprefix_, name_, eol_))
def _exportAttributes(self, outfile, level, already_processed, namespaceprefix_='', name_='SurnameType'):
pass
def _exportChildren(self, outfile, level, namespaceprefix_='', namespacedef_='', name_='SurnameType', fromsubclass_=False, pretty_print=True):
pass
def build(self, node, gds_collector_=None):
self.gds_collector_ = gds_collector_
if SaveElementTreeNode:
self.gds_elementtree_node_ = node
already_processed = set()
self.ns_prefix_ = node.prefix
self._buildAttributes(node, node.attrib, already_processed)
self.valueOf_ = get_all_text_(node)
for child in node:
nodeName_ = Tag_pattern_.match(child.tag).groups()[-1]
self._buildChildren(child, node, nodeName_, gds_collector_=gds_collector_)
return self
def _buildAttributes(self, node, attrs, already_processed):
pass
def _buildChildren(self, child_, node, nodeName_, fromsubclass_=False, gds_collector_=None):
pass
# end class SurnameType

66
Dockerfile Normal file
View File

@@ -0,0 +1,66 @@
# Multi-stage build for smaller final image
FROM python:3.13-slim AS builder
# Install uv for fast dependency management
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
# Set working directory
WORKDIR /app
# Copy dependency files
COPY pyproject.toml uv.lock README.md ./
ARG CI_TOKEN
# Install dependencies in a virtual environment
RUN uv sync --frozen --no-cache
# Production stage
FROM python:3.13-slim
# Create non-root user for security
RUN groupadd -r appuser && useradd -r -g appuser -u 1000 appuser
# Install uv in production image
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
# Set working directory
WORKDIR /app
# Copy virtual environment from builder stage
COPY --from=builder /app/.venv /app/.venv
# Copy application code
COPY src/ ./src/
# Create directories and set permissions
RUN mkdir -p /app/logs && \
chown -R appuser:appuser /app
# Switch to non-root user
USER appuser
# Set environment variables
ENV PATH="/app/.venv/bin:$PATH" \
PYTHONPATH="/app/src" \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
# Expose port (non-privileged port)
EXPOSE 8000
# Health check
HEALTHCHECK --interval=120s --timeout=10s --start-period=60s --retries=3 \
CMD python -c "import requests; requests.get('http://localhost:8000/api/health', timeout=5)"
# Run the application with uvicorn
WORKDIR /app/src
CMD uvicorn alpine_bits_python.api:app \
--host 0.0.0.0 \
--port 8000 \
--workers 4 \
--log-level info \
--access-log \
--forwarded-allow-ips "${FORWARDED_ALLOW_IPS:-127.0.0.1}" \
--proxy-headers \
--no-server-header

118
LOGGING.md Normal file
View File

@@ -0,0 +1,118 @@
# Logging Configuration
The AlpineBits Python server uses a centralized logging system that can be configured via the `config.yaml` file.
## Configuration
Add the following section to your `config/config.yaml`:
```yaml
logger:
level: "INFO" # Options: DEBUG, INFO, WARNING, ERROR, CRITICAL
file: "logs/alpinebits.log" # Optional: path to log file (omit or set to null for console-only)
```
### Log Levels
- **DEBUG**: Detailed diagnostic information (very verbose)
- **INFO**: General informational messages about application progress
- **WARNING**: Warning messages about potential issues
- **ERROR**: Error messages when something goes wrong
- **CRITICAL**: Critical errors that may cause application failure
### Log Output
- **Console**: Logs are always written to console (stdout)
- **File**: Optionally write logs to a file by specifying the `file` parameter
- File logs include the same timestamp and formatting as console logs
- Log directory will be created automatically if it doesn't exist
## Usage in Code
To use logging in any module:
```python
from alpine_bits_python.logging_config import get_logger
_LOGGER = get_logger(__name__)
# Then use the logger
_LOGGER.info("Application started")
_LOGGER.debug("Detailed debug information: %s", some_variable)
_LOGGER.warning("Something unusual happened")
_LOGGER.error("An error occurred: %s", error_message)
_LOGGER.exception("Critical error with stack trace")
```
## Log Format
All log entries include:
- Timestamp (YYYY-MM-DD HH:MM:SS)
- Module name (logger name)
- Log level
- Message
Example:
```
2025-10-09 14:23:45 - alpine_bits_python.api - INFO - Application startup initiated
2025-10-09 14:23:45 - alpine_bits_python.api - INFO - Logging configured at INFO level
2025-10-09 14:23:46 - alpine_bits_python.api - INFO - Database tables checked/created at startup.
```
## Best Practices
1. **Use structured logging**: Pass variables as arguments, not f-strings
```python
# Good
_LOGGER.info("Processing reservation %s for hotel %s", reservation_id, hotel_code)
# Avoid (performance overhead, linting warnings)
_LOGGER.info(f"Processing reservation {reservation_id} for hotel {hotel_code}")
```
2. **Use appropriate log levels**:
- `DEBUG`: Detailed tracing for development
- `INFO`: Normal application flow events
- `WARNING`: Unexpected but handled situations
- `ERROR`: Errors that need attention
- `CRITICAL`: Severe errors requiring immediate action
3. **Use `exception()` for error handling**:
```python
try:
risky_operation()
except Exception:
_LOGGER.exception("Operation failed") # Automatically includes stack trace
```
4. **Don't log sensitive data**: Avoid logging passwords, tokens, or personal data
## Examples
### Console-only logging (development)
```yaml
logger:
level: "DEBUG"
```
### File logging (production)
```yaml
logger:
level: "INFO"
file: "/var/log/alpinebits/app.log"
```
### Minimal logging
```yaml
logger:
level: "WARNING"
file: "logs/warnings.log"
```

108
QUICK_REFERENCE.md Normal file
View File

@@ -0,0 +1,108 @@
# Multi-Worker Quick Reference
## TL;DR
**Problem**: Using 4 workers causes duplicate emails and race conditions.
**Solution**: File-based locking ensures only ONE worker runs schedulers.
## Commands
```bash
# Development (1 worker - auto primary)
uvicorn alpine_bits_python.api:app --reload
# Production (4 workers - one becomes primary)
uvicorn alpine_bits_python.api:app --workers 4 --host 0.0.0.0 --port 8000
# Test worker coordination
uv run python test_worker_coordination.py
# Run all tests
uv run pytest tests/ -v
```
## Check Which Worker is Primary
Look for startup logs:
```
[INFO] Worker startup: pid=1001, primary=True ← PRIMARY
[INFO] Worker startup: pid=1002, primary=False ← SECONDARY
[INFO] Worker startup: pid=1003, primary=False ← SECONDARY
[INFO] Worker startup: pid=1004, primary=False ← SECONDARY
[INFO] Daily report scheduler started ← Only on PRIMARY
```
## Lock File
**Location**: `/tmp/alpinebits_primary_worker.lock`
**Check lock status**:
```bash
# See which PID holds the lock
cat /tmp/alpinebits_primary_worker.lock
# Output: 1001
# Verify process is running
ps aux | grep 1001
```
**Clean stale lock** (if needed):
```bash
rm /tmp/alpinebits_primary_worker.lock
# Then restart application
```
## What Runs Where
| Service | Primary Worker | Secondary Workers |
|---------|---------------|-------------------|
| HTTP requests | ✓ Yes | ✓ Yes |
| Email scheduler | ✓ Yes | ✗ No |
| Error alerts | ✓ Yes | ✓ Yes (all workers can send) |
| DB migrations | ✓ Yes | ✗ No |
| Customer hashing | ✓ Yes | ✗ No |
## Troubleshooting
### All workers think they're primary
**Cause**: Lock file not accessible
**Fix**: Check permissions on `/tmp/` or change lock location
### No worker becomes primary
**Cause**: Stale lock file
**Fix**: `rm /tmp/alpinebits_primary_worker.lock` and restart
### Still getting duplicate emails
**Check**: Are you seeing duplicate **scheduled reports** or **error alerts**?
- Scheduled reports should only come from primary ✓
- Error alerts can come from any worker (by design) ✓
## Code Example
```python
from alpine_bits_python.worker_coordination import is_primary_worker
async def lifespan(app: FastAPI):
# Acquire lock - only one worker succeeds
is_primary, worker_lock = is_primary_worker()
if is_primary:
# Start singleton services
scheduler.start()
# All workers handle requests
yield
# Release lock on shutdown
if worker_lock:
worker_lock.release()
```
## Documentation
- **Full guide**: `docs/MULTI_WORKER_DEPLOYMENT.md`
- **Solution summary**: `SOLUTION_SUMMARY.md`
- **Implementation**: `src/alpine_bits_python/worker_coordination.py`
- **Test script**: `test_worker_coordination.py`

169
README.md
View File

@@ -1,10 +1,169 @@
# Alpine bits
# Übersicht
Hour alpine bits application needs to mostly act as a server. It needs to provide room reservation information to the ASA alpinebits client at the hotel.
Enthält einen in Python geschriebenen Alpine Bits Server zur Übertragung von Buchungsanfragen von Landingpages an Partnerhotels. Ein Fastapi Endpoint empfängt Anfrageformulare von den wix.com landingpages, und speichert sie in die Datenbank ab. Der Alpine Bits Server stellt diese dann Hotels auf dem Endpoint `www.99tales.net/api/alpinebits/server-2024-10` zu Verfügung.
However in other things we act could potentially act as the client with ASA acting as the alpinebits server. Basically according to the documentation the whole thing depends on who is requesting information and who has it.
## Entwicklung
When ASA wants to know our GuestRequests from the Landing page then they are the client and we the server. This causes some problems because our system actually knows less than the hotel system. We can't easiliy add Room Rate information and publish a reservation to ASA because we don't actually know the rooms.
Auf dem Entwicklungsystem muss git und der uv python package manager installiert sein.
Just for GuestRequests this should be fine however.
### Git Authentification
Wenn über http geklont wird muss lokal der [git-credential-oauth](https://github.com/hickford/git-credential-oauth) helper installiert sein. Besser gehts über ssh. Da muss ein ssh-key in gitea für den eigenen Benutzer angelegt sein.
1. Repo klonen.
2. `uv sync` ausführen
3. `uv run python -m alpine_bits_python.run_api` führt die API lokal auf Port 8080 aus. Datenbank wird automatisch erstellt und bei jedem start geleert.
## Konfiguration
Erfolgt über zwei yaml files. Zu konfigurieren ist die Verbindung zur Datenbank und die Konfiguration der einzelnen Hotels. In zukunft kommt vermutlich auch noch die Push URL hinzu.
```yaml
database:
url: "sqlite+aiosqlite:///alpinebits.db" # For local dev, use SQLite. For prod, override with PostgreSQL URL.
# url: "postgresql://user:password@host:port/dbname" # Example for Postgres
alpine_bits_auth:
- hotel_id: "123"
hotel_name: "Frangart Inn"
username: "alice"
password: !secret ALICE_PASSWORD
- hotel_id: "456"
hotel_name: "Bemelmans"
username: "bob"
password: !secret BOB_PASSWORD
```
!secret verweist auf einen Eintrag in secrets.yaml. Diese Datei wird aus Sicherheitsgründen nicht auf die Repository hochgeladen. In secrets.yaml können passwörter folgendermaßen angegeben werden
```yaml
ALICE_PASSWORD: "supersecretpassword123"
```
## Deployment
Die Applikation wird in einem Dockercontainer deployed. Um das Container Image zu erstellen ist folgender Befehl notwendig
```bash
uv sync
docker build . -t gitea.linter-home.com/jonas/asa_api:master
```
Dieser Build Befehl bezieht sich noch auf die Automatische Buildpipeline in meinem Heimsystem. Eine solche Pipeline habe ich auf dem 99tales.net server noch nicht eingerichtet weils lästiges Zeug isch.
Dieser Befehl muss im Wurzelverzeichnis der Repository ausgeführt werden. `pwd` sollte irgendwas/alpinebits_python ergeben. Der Punkt hinter dem docker build befehl verweißt nämlich auf das lokale Dockerfile. "-t" steht für tag. In diesem Beispiel wird das Image mit dem Tag `gitea.linter-home.com/jonas/asa_api:master` versehen.
Ideal wäre eine Build Pipeline in Gitea selbst aber dies aufzusetzen ist etwas schwierig und es ist gut möglich das die Hetzner VM das nicht herhat. Lokal bei mir zuhause ist dies aufgesetzt. War alles andere als leicht.
Am besten einfach direkt auf dem Zielsystem den Container bauen und im Docker Compose File dann auf dieses Image referenzieren.
### Docker Compose Beispiel mit Traefik Reverse Proxy
```yaml
services:
asa_connector:
image: gitea.linter-home.com/jonas/asa_api:master
container_name: asa_connector
restart: unless-stopped
# Environment variables via .env file
env_file:
- asa_connector.env
networks:
- external
# Only expose internally - Traefik will handle external access
expose:
- "8000"
user: "1000:1000" # Run as user with UID 1000 and GID 1000
environment:
- ALPINE_BITS_CONFIG_DIR=/config
volumes:
- /home/jonas/asa_connector_logs:/app/src/logs
- /home/jonas/alpinebits_config:/config
# Traefik labels for automatic service discovery
labels:
- "traefik.enable=true"
# API router - handles /api/* paths on 99tales.net
- "traefik.http.routers.asa_connector.rule=Host(`99tales.net`) && PathPrefix(`/api`)"
- "traefik.http.routers.asa_connector.entrypoints=https"
- "traefik.http.routers.asa_connector.tls.certresolver=letsencrypt"
- "traefik.http.services.asa_connector.loadbalancer.server.port=8000"
- "traefik.http.routers.asa_connector.priority=100"
# Redirect middleware for non-API paths
- "traefik.http.middlewares.redirect-to-99tales-it.redirectregex.regex=^https://99tales\\.net/(.*)$$"
- "traefik.http.middlewares.redirect-to-99tales-it.redirectregex.replacement=https://99tales.it/$${1}"
- "traefik.http.middlewares.redirect-to-99tales-it.redirectregex.permanent=true"
# Catch-all router for non-API paths on 99tales.net (lower priority)
- "traefik.http.routers.redirect-router.rule=Host(`99tales.net`)"
- "traefik.http.routers.redirect-router.entrypoints=https"
- "traefik.http.routers.redirect-router.tls.certresolver=letsencrypt"
- "traefik.http.routers.redirect-router.middlewares=redirect-to-99tales-it"
- "traefik.http.routers.redirect-router.service=noop@internal"
- "traefik.http.routers.redirect-router.priority=1"
dockerproxy:
image: ghcr.io/tecnativa/docker-socket-proxy:latest
container_name: dockerproxy
restart: unless-stopped
environment:
CONTAINERS: 1 # read only
POST: 0
networks:
- external
volumes:
- /var/run/docker.sock:/var/run/docker.sock
traefik:
image: traefik:latest
container_name: traefik
restart: unless-stopped
environment:
- DOCKER_HOST=dockerproxy
networks:
- external
ports:
- "80:80" # HTTP
- "443:443" # HTTPS
- "22:22" # SSH for Gitea
volumes:
- /home/jonas/traefik:/etc/traefik # Traefik configuration files
# Health check
healthcheck:
test:
[
"CMD",
"python",
"-c",
"import requests; requests.get('http://localhost:8000/health', timeout=5)",
]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
networks: # custom bridge network named 'external'
external:
name: external
driver: bridge
```
Damit das ganze auch funktioniert müssen dns Einträge auf die Virtuelle Machine zeigen in der das ganze läuft. Wurde bei Hostinger für 99tales.net eingerichtet.
Wie in dem Beispiel ersichtlich wird sowohl ein Log Ordner als auch ein Config ordner in den Container gemapped. Diesen am besten auf dem Host vor Erstellung des Containers erstellen.
Die Umgebungsvariable `ALPINE_BITS_CONFIG_DIR` sagt dann dem Programm wo es die Config finden soll. In dem Ordner kann man die obens erwähnten Konfigurationsdateien speichern. Falls sqlite als Datenbank verwendet wird, findet man dort auch die Datenbank nach erstem ausführen.

193
SOLUTION_SUMMARY.md Normal file
View File

@@ -0,0 +1,193 @@
# Multi-Worker Deployment Solution Summary
## Problem
When running FastAPI with `uvicorn --workers 4`, the `lifespan` function executes in **all 4 worker processes**, causing:
-**Duplicate email notifications** (4x emails sent)
-**Multiple schedulers** running simultaneously
-**Race conditions** in database operations
## Root Cause
Your original implementation tried to detect the primary worker using:
```python
multiprocessing.current_process().name == "MainProcess"
```
**This doesn't work** because with `uvicorn --workers N`, each worker is a separate process with its own name, and none are reliably named "MainProcess".
## Solution Implemented
### File-Based Worker Locking
We implemented a **file-based locking mechanism** that ensures only ONE worker runs singleton services:
```python
# worker_coordination.py
class WorkerLock:
"""Uses fcntl.flock() to coordinate workers across processes"""
def acquire(self) -> bool:
"""Try to acquire exclusive lock - only one process succeeds"""
fcntl.flock(self.lock_fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
```
### Updated Lifespan Function
```python
async def lifespan(app: FastAPI):
# File-based lock ensures only one worker is primary
is_primary, worker_lock = is_primary_worker()
if is_primary:
# ✓ Start email scheduler (ONCE)
# ✓ Run database migrations (ONCE)
# ✓ Start background tasks (ONCE)
else:
# Skip singleton services
pass
# All workers handle HTTP requests normally
yield
# Release lock on shutdown
if worker_lock:
worker_lock.release()
```
## How It Works
```
uvicorn --workers 4
├─ Worker 0 → tries lock → ✓ SUCCESS → PRIMARY (runs schedulers)
├─ Worker 1 → tries lock → ✗ BUSY → SECONDARY (handles requests)
├─ Worker 2 → tries lock → ✗ BUSY → SECONDARY (handles requests)
└─ Worker 3 → tries lock → ✗ BUSY → SECONDARY (handles requests)
```
## Verification
### Test Results
```bash
$ uv run python test_worker_coordination.py
Worker 0 (PID 30773): ✓ I am PRIMARY
Worker 1 (PID 30774): ✗ I am SECONDARY
Worker 2 (PID 30775): ✗ I am SECONDARY
Worker 3 (PID 30776): ✗ I am SECONDARY
✓ Test complete: Only ONE worker should have been PRIMARY
```
### All Tests Pass
```bash
$ uv run pytest tests/ -v
======================= 120 passed, 23 warnings in 1.96s =======================
```
## Files Modified
1. **`worker_coordination.py`** (NEW)
- `WorkerLock` class with `fcntl` file locking
- `is_primary_worker()` function for easy integration
2. **`api.py`** (MODIFIED)
- Import `is_primary_worker` from worker_coordination
- Replace manual worker detection with file-based locking
- Use `is_primary` flag to conditionally start schedulers
- Release lock on shutdown
## Advantages of This Solution
**No external dependencies** - uses standard library `fcntl`
**Automatic failover** - if primary crashes, lock is auto-released
**Works with any ASGI server** - uvicorn, gunicorn, hypercorn
**Simple and reliable** - battle-tested Unix file locking
**No race conditions** - atomic lock acquisition
**Production-ready** - handles edge cases gracefully
## Usage
### Development (Single Worker)
```bash
uvicorn alpine_bits_python.api:app --reload
# Single worker becomes primary automatically
```
### Production (Multiple Workers)
```bash
uvicorn alpine_bits_python.api:app --workers 4
# Worker that starts first becomes primary
# Others become secondary workers
```
### Check Logs
```
[INFO] Worker startup: process=SpawnProcess-1, pid=1001, primary=True
[INFO] Worker startup: process=SpawnProcess-2, pid=1002, primary=False
[INFO] Worker startup: process=SpawnProcess-3, pid=1003, primary=False
[INFO] Worker startup: process=SpawnProcess-4, pid=1004, primary=False
[INFO] Daily report scheduler started # ← Only on primary!
```
## What This Fixes
| Issue | Before | After |
|-------|--------|-------|
| **Email notifications** | Sent 4x (one per worker) | Sent 1x (only primary) |
| **Daily report scheduler** | 4 schedulers running | 1 scheduler running |
| **Customer hashing** | Race condition across workers | Only primary hashes |
| **Startup logs** | Confusing worker detection | Clear primary/secondary status |
## Alternative Approaches Considered
### ❌ Environment Variables
```bash
ALPINEBITS_PRIMARY_WORKER=true uvicorn app:app
```
**Problem**: Manual configuration, no automatic failover
### ❌ Process Name Detection
```python
multiprocessing.current_process().name == "MainProcess"
```
**Problem**: Unreliable with uvicorn's worker processes
### ✅ Redis-Based Locking
```python
redis.lock.Lock(redis_client, "primary_worker")
```
**When to use**: Multi-container deployments (Docker Swarm, Kubernetes)
## Recommendations
### For Single-Host Deployments (Your Case)
✅ Use the file-based locking solution (implemented)
### For Multi-Container Deployments
Consider Redis-based locks if deploying across multiple containers/hosts:
```python
# In worker_coordination.py, add Redis option
def is_primary_worker(use_redis=False):
if use_redis:
return redis_based_lock()
else:
return file_based_lock() # Current implementation
```
## Conclusion
Your FastAPI application now correctly handles multiple workers:
- ✅ Only **one worker** runs singleton services (schedulers, migrations)
- ✅ All **workers** handle HTTP requests concurrently
- ✅ No **duplicate email notifications**
- ✅ No **race conditions** in database operations
-**Automatic failover** if primary worker crashes
**Result**: You get the performance benefits of multiple workers WITHOUT the duplicate notification problem! 🎉

14113
alpinebits.log Normal file

File diff suppressed because it is too large Load Diff

BIN
alpinebits_capi_test.db Normal file

Binary file not shown.

338636
config/alpinebits.log Normal file

File diff suppressed because it is too large Load Diff

108
config/config.yaml Normal file
View File

@@ -0,0 +1,108 @@
# AlpineBits Python config
# Use annotatedyaml for secrets and environment-specific overrides
database:
url: "postgresql+asyncpg://meta_user:meta_password@localhost:5555/meta_insights"
schema: "alpinebits"
# AlpineBits Python config
# Use annotatedyaml for secrets and environment-specific overrides
logger:
level: "INFO" # Set to DEBUG for more verbose output
file: "config/alpinebits.log" # Log file path, or null for console only
server:
codecontext: "ADVERTISING"
code: 70597314
companyname: "99tales Gmbh"
res_id_source_context: "99tales"
alpine_bits_auth:
- hotel_id: "39054_001"
hotel_name: "Bemelmans Post"
username: "bemelman"
password: !secret BEMELMANS_PASSWORD
meta_account: "238334370765317"
google_account: "7581209925" # Optional: Meta advertising account ID
- hotel_id: "135"
hotel_name: "Testhotel"
username: "sebastian"
password: !secret BOB_PASSWORD
- hotel_id: "39052_001"
hotel_name: "Jagthof Kaltern"
username: "jagthof"
password: !secret JAGTHOF_PASSWORD
meta_account: "948363300784757"
google_account: "1951919786" # Optional: Meta advertising account ID
- hotel_id: "39040_001"
hotel_name: "Residence Erika"
username: "erika"
password: !secret ERIKA_PASSWORD
google_account: "6604634947"
api_tokens:
- tLTI8wXF1OVEvUX7kdZRhSW3Qr5feBCz0mHo-kbnEp0
# Email configuration (SMTP service config - kept for when port is unblocked)
email:
# SMTP server configuration
smtp:
host: "smtp.titan.email" # Your SMTP server
port: 465 # Usually 587 for TLS, 465 for SSL
username: info@99tales.net # SMTP username
password: !secret EMAIL_PASSWORD # SMTP password
use_tls: false # Use STARTTLS
use_ssl: true # Use SSL/TLS from start
# Email addresses
from_address: "info@99tales.net" # Sender address
from_name: "AlpineBits Monitor" # Sender display name
# Pushover configuration (push notification service config)
pushover:
# Pushover API credentials (get from https://pushover.net)
user_key: !secret PUSHOVER_USER_KEY # Your user/group key
api_token: !secret PUSHOVER_API_TOKEN # Your application API token
# Unified notification system - recipient-based routing
notifications:
# Recipients and their preferred notification methods
recipients:
- name: "jonas"
methods:
# Uncomment email when port is unblocked
#- type: "email"
# address: "jonas@vaius.ai"
- type: "pushover"
priority: 0 # Pushover priority: -2=lowest, -1=low, 0=normal, 1=high, 2=emergency
# Daily report configuration (applies to all recipients)
daily_report:
enabled: false # Set to true to enable daily reports
send_time: "08:00" # Time to send daily report (24h format, local time)
include_stats: true # Include reservation/customer stats
include_errors: true # Include error summary
# Error alert configuration (applies to all recipients)
error_alerts:
enabled: false # Set to true to enable error alerts
# Alert is sent immediately if threshold is reached
error_threshold: 5 # Send immediate alert after N errors
# Otherwise, alert is sent after buffer time expires
buffer_minutes: 15 # Wait N minutes before sending buffered errors
# Cooldown period to prevent alert spam
cooldown_minutes: 15 # Wait N min before sending another alert
# Error severity levels to monitor
log_levels:
- "ERROR"
- "CRITICAL"

View File

@@ -0,0 +1,16 @@
# PostgreSQL configuration for migration
# Copy this file to postgres.yaml and fill in your PostgreSQL credentials
# This file should NOT be committed to git (add postgres.yaml to .gitignore)
database:
url: "postgresql+asyncpg://username:password@hostname:5432/database_name"
# Example: "postgresql+asyncpg://alpinebits_user:your_password@localhost:5432/alpinebits"
schema: "alpinebits" # Optional: PostgreSQL schema name (default: public)
# If using annotatedyaml secrets:
# database:
# url: !secret POSTGRES_URL
# schema: "alpinebits" # Optional: PostgreSQL schema name
#
# Then in secrets.yaml:
# POSTGRES_URL: "postgresql+asyncpg://username:password@hostname:5432/database_name"

13
conftest.py Normal file
View File

@@ -0,0 +1,13 @@
"""Pytest configuration and path setup for VS Code.
This configuration file ensures that VS Code can properly discover and run tests
by setting up the Python path to include the src directory.
"""
import sys
from pathlib import Path
# Add the src directory to Python path for VS Code test discovery
src_path = Path(__file__).parent / "src"
if str(src_path) not in sys.path:
sys.path.insert(0, str(src_path))

1
coverage.json Normal file

File diff suppressed because one or more lines are too long

423
docs/EMAIL_MONITORING.md Normal file
View File

@@ -0,0 +1,423 @@
# Email Monitoring and Alerting
This document describes the email monitoring and alerting system for the AlpineBits Python server.
## Overview
The email monitoring system provides two main features:
1. **Error Alerts**: Automatic email notifications when errors occur in the application
2. **Daily Reports**: Scheduled daily summary emails with statistics and error logs
## Architecture
### Components
- **EmailService** ([email_service.py](../src/alpine_bits_python/email_service.py)): Core SMTP email sending functionality
- **EmailAlertHandler** ([email_monitoring.py](../src/alpine_bits_python/email_monitoring.py)): Custom logging handler that captures errors and sends alerts
- **DailyReportScheduler** ([email_monitoring.py](../src/alpine_bits_python/email_monitoring.py)): Background task that sends daily reports
### How It Works
#### Error Alerts (Hybrid Approach)
The `EmailAlertHandler` uses a **hybrid threshold + time-based** approach:
1. **Immediate Alerts**: If the error threshold is reached (e.g., 5 errors), an alert email is sent immediately
2. **Buffered Alerts**: Otherwise, errors accumulate in a buffer and are sent after the buffer duration (e.g., 15 minutes)
3. **Cooldown Period**: After sending an alert, the system waits for a cooldown period before sending another alert to prevent spam
**Flow Diagram:**
```
Error occurs
Add to buffer
Buffer >= threshold? ──Yes──> Send immediate alert
↓ No ↓
Wait for buffer time Reset buffer
↓ ↓
Send buffered alert Enter cooldown
Reset buffer
```
#### Daily Reports
The `DailyReportScheduler` runs as a background task that:
1. Waits until the configured send time (e.g., 8:00 AM)
2. Collects statistics from the application
3. Gathers errors that occurred during the day
4. Formats and sends an email report
5. Clears the error log
6. Schedules the next report for the following day
## Configuration
### Email Configuration Keys
Add the following to your [config.yaml](../config/config.yaml):
```yaml
email:
# SMTP server configuration
smtp:
host: "smtp.gmail.com" # Your SMTP server hostname
port: 587 # SMTP port (587 for TLS, 465 for SSL)
username: !secret EMAIL_USERNAME # SMTP username (use !secret for env vars)
password: !secret EMAIL_PASSWORD # SMTP password (use !secret for env vars)
use_tls: true # Use STARTTLS encryption
use_ssl: false # Use SSL/TLS from start (mutually exclusive with use_tls)
# Sender information
from_address: "noreply@99tales.com"
from_name: "AlpineBits Monitor"
# Monitoring and alerting
monitoring:
# Daily report configuration
daily_report:
enabled: true # Enable/disable daily reports
recipients:
- "admin@99tales.com"
- "dev@99tales.com"
send_time: "08:00" # Time to send (24h format, local time)
include_stats: true # Include application statistics
include_errors: true # Include error summary
# Error alert configuration
error_alerts:
enabled: true # Enable/disable error alerts
recipients:
- "alerts@99tales.com"
- "oncall@99tales.com"
error_threshold: 5 # Send immediate alert after N errors
buffer_minutes: 15 # Wait N minutes before sending buffered errors
cooldown_minutes: 15 # Wait N minutes before sending another alert
log_levels: # Log levels to monitor
- "ERROR"
- "CRITICAL"
```
### Environment Variables
For security, store sensitive credentials in environment variables:
```bash
# Create a .env file (never commit this!)
EMAIL_USERNAME=your-smtp-username@gmail.com
EMAIL_PASSWORD=your-smtp-app-password
```
The `annotatedyaml` library automatically loads values marked with `!secret` from environment variables.
### Gmail Configuration
If using Gmail, you need to:
1. Enable 2-factor authentication on your Google account
2. Generate an "App Password" for SMTP access
3. Use the app password as `EMAIL_PASSWORD`
**Gmail Settings:**
```yaml
smtp:
host: "smtp.gmail.com"
port: 587
use_tls: true
use_ssl: false
```
### Other SMTP Providers
**SendGrid:**
```yaml
smtp:
host: "smtp.sendgrid.net"
port: 587
username: "apikey"
password: !secret SENDGRID_API_KEY
use_tls: true
```
**AWS SES:**
```yaml
smtp:
host: "email-smtp.us-east-1.amazonaws.com"
port: 587
username: !secret AWS_SES_USERNAME
password: !secret AWS_SES_PASSWORD
use_tls: true
```
## Usage
### Automatic Error Monitoring
Once configured, the system automatically captures all `ERROR` and `CRITICAL` log messages:
```python
from alpine_bits_python.logging_config import get_logger
_LOGGER = get_logger(__name__)
# This error will be captured and sent via email
_LOGGER.error("Database connection failed")
# This will also be captured
try:
risky_operation()
except Exception:
_LOGGER.exception("Operation failed") # Includes stack trace
```
### Triggering Test Alerts
To test your email configuration, you can manually trigger errors:
```python
import logging
_LOGGER = logging.getLogger(__name__)
# Generate multiple errors to trigger immediate alert (if threshold = 5)
for i in range(5):
_LOGGER.error(f"Test error {i + 1}")
```
### Daily Report Statistics
To include custom statistics in daily reports, set a stats collector function:
```python
async def collect_stats():
"""Collect application statistics for daily report."""
return {
"total_reservations": await count_reservations(),
"new_customers": await count_new_customers(),
"active_hotels": await count_active_hotels(),
"api_requests": get_request_count(),
}
# Register the collector
report_scheduler = app.state.report_scheduler
if report_scheduler:
report_scheduler.set_stats_collector(collect_stats)
```
## Email Templates
### Error Alert Email
**Subject:** 🚨 AlpineBits Error Alert: 5 errors (threshold exceeded)
**Body:**
```
Error Alert - 2025-10-15 14:30:45
======================================================================
Alert Type: Immediate Alert
Error Count: 5
Time Range: 14:25:00 to 14:30:00
Reason: (threshold of 5 exceeded)
======================================================================
Errors:
----------------------------------------------------------------------
[2025-10-15 14:25:12] ERROR: Database connection timeout
Module: db:245 (alpine_bits_python.db)
[2025-10-15 14:26:34] ERROR: Failed to process reservation
Module: api:567 (alpine_bits_python.api)
Exception:
Traceback (most recent call last):
...
----------------------------------------------------------------------
Generated by AlpineBits Email Monitoring at 2025-10-15 14:30:45
```
### Daily Report Email
**Subject:** AlpineBits Daily Report - 2025-10-15
**Body (HTML):**
```html
AlpineBits Daily Report
Date: 2025-10-15
Statistics
┌────────────────────────┬────────┐
│ Metric │ Value │
├────────────────────────┼────────┤
│ total_reservations │ 42 │
│ new_customers │ 15 │
│ active_hotels │ 4 │
│ api_requests │ 1,234 │
└────────────────────────┴────────┘
Errors (3)
┌──────────────┬──────────┬─────────────────────────┐
│ Time │ Level │ Message │
├──────────────┼──────────┼─────────────────────────┤
│ 08:15:23 │ ERROR │ Connection timeout │
│ 12:45:10 │ ERROR │ Invalid form data │
│ 18:30:00 │ CRITICAL │ Database unavailable │
└──────────────┴──────────┴─────────────────────────┘
Generated by AlpineBits Server
```
## Monitoring and Troubleshooting
### Check Email Configuration
```python
from alpine_bits_python.email_service import create_email_service
from alpine_bits_python.config_loader import load_config
config = load_config()
email_service = create_email_service(config)
if email_service:
print("✓ Email service configured")
else:
print("✗ Email service not configured")
```
### Test Email Sending
```python
import asyncio
from alpine_bits_python.email_service import EmailService, EmailConfig
async def test_email():
config = EmailConfig({
"smtp": {
"host": "smtp.gmail.com",
"port": 587,
"username": "your-email@gmail.com",
"password": "your-app-password",
"use_tls": True,
},
"from_address": "sender@example.com",
"from_name": "Test",
})
service = EmailService(config)
result = await service.send_email(
recipients=["recipient@example.com"],
subject="Test Email",
body="This is a test email from AlpineBits server.",
)
if result:
print("✓ Email sent successfully")
else:
print("✗ Email sending failed")
asyncio.run(test_email())
```
### Common Issues
**Issue: "Authentication failed"**
- Verify SMTP username and password are correct
- For Gmail, ensure you're using an App Password, not your regular password
- Check that 2FA is enabled on Gmail
**Issue: "Connection timeout"**
- Verify SMTP host and port are correct
- Check firewall rules allow outbound SMTP connections
- Try using port 465 with SSL instead of 587 with TLS
**Issue: "No email alerts received"**
- Check that `enabled: true` in config
- Verify recipient email addresses are correct
- Check application logs for email sending errors
- Ensure errors are being logged at ERROR or CRITICAL level
**Issue: "Too many emails being sent"**
- Increase `cooldown_minutes` to reduce alert frequency
- Increase `buffer_minutes` to batch more errors together
- Increase `error_threshold` to only alert on serious issues
## Performance Considerations
### SMTP is Blocking
Email sending uses the standard Python `smtplib`, which performs blocking I/O. To prevent blocking the async event loop:
- Email operations are automatically run in a thread pool executor
- This happens transparently via `loop.run_in_executor()`
- No performance impact on request handling
### Memory Usage
- Error buffer size is limited by `buffer_minutes` duration
- Old errors are automatically cleared after sending
- Daily report error log is cleared after each report
- Typical memory usage: <1 MB for error buffering
### Error Handling
- Email sending failures are logged but never crash the application
- If SMTP is unavailable, errors are logged to console/file as normal
- The logging handler has exception safety - it will never cause application failures
## Security Considerations
1. **Never commit credentials to git**
- Use `!secret` annotation in YAML
- Store credentials in environment variables
- Add `.env` to `.gitignore`
2. **Use TLS/SSL encryption**
- Always set `use_tls: true` or `use_ssl: true`
- Never send credentials in plaintext
3. **Limit email recipients**
- Only send alerts to authorized personnel
- Use dedicated monitoring email addresses
- Consider using distribution lists
4. **Sensitive data in logs**
- Be careful not to log passwords, API keys, or PII
- Error messages in emails may contain sensitive context
- Review log messages before enabling email alerts
## Testing
Run the test suite:
```bash
# Test email service only
uv run pytest tests/test_email_service.py -v
# Test with coverage
uv run pytest tests/test_email_service.py --cov=alpine_bits_python.email_service --cov=alpine_bits_python.email_monitoring
```
## Future Enhancements
Potential improvements for future versions:
- [ ] Support for email templates (Jinja2)
- [ ] Configurable retry logic for failed sends
- [ ] Email queuing for high-volume scenarios
- [ ] Integration with external monitoring services (PagerDuty, Slack)
- [ ] Weekly/monthly report options
- [ ] Custom alert rules based on error patterns
- [ ] Email attachments for detailed logs
- [ ] HTML email styling improvements
## References
- [Python smtplib Documentation](https://docs.python.org/3/library/smtplib.html)
- [Python logging Documentation](https://docs.python.org/3/library/logging.html)
- [Gmail SMTP Settings](https://support.google.com/mail/answer/7126229)
- [annotatedyaml Documentation](https://github.com/yourusername/annotatedyaml)

View File

@@ -0,0 +1,301 @@
# Email Monitoring Implementation Summary
## Overview
Successfully implemented a comprehensive email monitoring and alerting system for the AlpineBits Python server with proper configuration schema validation.
## Implementation Completed
### 1. Core Components ✅
- **[email_service.py](../src/alpine_bits_python/email_service.py)** - SMTP email service with TLS/SSL support
- **[email_monitoring.py](../src/alpine_bits_python/email_monitoring.py)** - Logging integration with hybrid alert strategy
- **[logging_config.py](../src/alpine_bits_python/logging_config.py)** - Integration with existing logging system
- **[api.py](../src/alpine_bits_python/api.py)** - Lifecycle management (startup/shutdown)
- **[config_loader.py](../src/alpine_bits_python/config_loader.py)** - **Schema validation for email config**
### 2. Configuration Schema ✅
Added comprehensive Voluptuous schemas to `config_loader.py`:
```python
# SMTP configuration
smtp_schema = Schema({
Required("host", default="localhost"): str,
Required("port", default=587): Range(min=1, max=65535),
Optional("username"): str,
Optional("password"): str,
Required("use_tls", default=True): Boolean(),
Required("use_ssl", default=False): Boolean(),
})
# Error alerts configuration
error_alerts_schema = Schema({
Required("enabled", default=False): Boolean(),
Optional("recipients", default=[]): [str],
Required("error_threshold", default=5): Range(min=1),
Required("buffer_minutes", default=15): Range(min=1),
Required("cooldown_minutes", default=15): Range(min=0),
Required("log_levels", default=["ERROR", "CRITICAL"]): [
In(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"])
],
})
# Daily report configuration
daily_report_schema = Schema({
Required("enabled", default=False): Boolean(),
Optional("recipients", default=[]): [str],
Required("send_time", default="08:00"): str,
Required("include_stats", default=True): Boolean(),
Required("include_errors", default=True): Boolean(),
})
```
**Benefits:**
- ✅ Type validation (strings, integers, booleans, lists)
- ✅ Range validation (port 1-65535, positive integers)
- ✅ Enum validation (log levels must be valid)
- ✅ Default values for all optional fields
- ✅ Prevents typos and misconfigurations
- ✅ Clear error messages when config is invalid
### 3. Configuration Files ✅
**[config/config.yaml](../config/config.yaml)** - Email configuration (currently disabled by default):
```yaml
email:
smtp:
host: "smtp.gmail.com"
port: 587
username: !secret EMAIL_USERNAME
password: !secret EMAIL_PASSWORD
use_tls: true
from_address: "noreply@99tales.com"
from_name: "AlpineBits Monitor"
monitoring:
error_alerts:
enabled: false # Set to true to enable
recipients: ["alerts@99tales.com"]
error_threshold: 5
buffer_minutes: 15
cooldown_minutes: 15
daily_report:
enabled: false # Set to true to enable
recipients: ["admin@99tales.com"]
send_time: "08:00"
```
**[config/.env.example](../config/.env.example)** - Template for environment variables
**[config/secrets.yaml](../config/secrets.yaml)** - Secret values (not committed to git)
### 4. Testing ✅
**[tests/test_email_service.py](../tests/test_email_service.py)** - Comprehensive test suite (17 tests, all passing)
Test coverage:
- ✅ EmailConfig initialization and defaults
- ✅ Email sending (plain text and HTML)
- ✅ Error record creation and formatting
- ✅ EmailAlertHandler buffering and thresholds
- ✅ DailyReportScheduler initialization and scheduling
- ✅ Config schema validation
**[examples/test_email_monitoring.py](../examples/test_email_monitoring.py)** - Interactive test script
### 5. Documentation ✅
- **[EMAIL_MONITORING.md](./EMAIL_MONITORING.md)** - Complete documentation
- **[EMAIL_MONITORING_QUICKSTART.md](./EMAIL_MONITORING_QUICKSTART.md)** - Quick start guide
- **[EMAIL_MONITORING_IMPLEMENTATION.md](./EMAIL_MONITORING_IMPLEMENTATION.md)** - This document
## Key Features
### Hybrid Alert Strategy
The system uses a smart hybrid approach that balances responsiveness with spam prevention:
1. **Immediate Alerts** - When error threshold is reached (e.g., 5 errors), send alert immediately
2. **Buffered Alerts** - Otherwise, accumulate errors and send after buffer time (e.g., 15 minutes)
3. **Cooldown Period** - After sending, wait before sending another alert to prevent spam
### Automatic Integration
- **Zero Code Changes Required** - All existing `logger.error()` calls automatically trigger email alerts
- **Non-Blocking** - SMTP operations run in thread pool, won't block async requests
- **Thread-Safe** - Works correctly in multi-threaded async environment
- **Production Ready** - Proper error handling, never crashes the application
### Schema Validation
The Voluptuous schema ensures:
- ✅ All config values are valid before the app starts
- ✅ Clear error messages for misconfigurations
- ✅ Sensible defaults for optional values
- ✅ Type safety (no runtime type errors)
- ✅ PREVENT_EXTRA prevents typos in config keys
## Testing Results
### Schema Validation Test
```bash
✅ Config loaded successfully
✅ Email config found
SMTP host: smtp.gmail.com
SMTP port: 587
From: noreply@99tales.com
From name: AlpineBits Monitor
Error alerts enabled: False
Error threshold: 5
Daily reports enabled: False
Send time: 08:00
✅ All schema validations passed!
```
### Email Service Initialization Test
```bash
✅ Config loaded and validated by schema
✅ Email service created successfully
SMTP: smtp.gmail.com:587
TLS: True
From: AlpineBits Monitor <noreply@99tales.com>
🎉 Email monitoring is ready to use!
```
### Unit Tests
```bash
============================= test session starts ==============================
tests/test_email_service.py::TestEmailConfig::test_email_config_initialization PASSED
tests/test_email_service.py::TestEmailConfig::test_email_config_defaults PASSED
tests/test_email_service.py::TestEmailConfig::test_email_config_tls_ssl_conflict PASSED
tests/test_email_service.py::TestEmailService::test_send_email_success PASSED
tests/test_email_service.py::TestEmailService::test_send_email_no_recipients PASSED
tests/test_email_service.py::TestEmailService::test_send_email_with_html PASSED
tests/test_email_service.py::TestEmailService::test_send_alert PASSED
tests/test_email_service.py::TestEmailService::test_send_daily_report PASSED
tests/test_email_service.py::TestErrorRecord::test_error_record_creation PASSED
tests/test_email_service.py::TestErrorRecord::test_error_record_to_dict PASSED
tests/test_email_service.py::TestErrorRecord::test_error_record_format_plain_text PASSED
tests/test_email_service.py::TestEmailAlertHandler::test_handler_initialization PASSED
tests/test_email_service.py::TestEmailAlertHandler::test_handler_emit_below_threshold PASSED
tests/test_email_service.py::TestEmailAlertHandler::test_handler_ignores_non_error_levels PASSED
tests/test_email_service.py::TestDailyReportScheduler::test_scheduler_initialization PASSED
tests/test_email_service.py::TestDailyReportScheduler::test_scheduler_log_error PASSED
tests/test_email_service.py::TestDailyReportScheduler::test_scheduler_set_stats_collector PASSED
================= 17 passed, 1 warning in 0.11s ==================
```
### Regression Tests
```bash
✅ All existing API tests still pass
✅ No breaking changes to existing functionality
```
## Usage
### To Enable Email Monitoring:
1. **Add SMTP credentials** to `config/secrets.yaml`:
```yaml
EMAIL_USERNAME: your-email@gmail.com
EMAIL_PASSWORD: your-app-password
```
2. **Enable features** in `config/config.yaml`:
```yaml
email:
monitoring:
error_alerts:
enabled: true # Enable error alerts
daily_report:
enabled: true # Enable daily reports
```
3. **Restart the server** - Email monitoring will start automatically
### To Test Email Monitoring:
```bash
# Run the interactive test suite
uv run python examples/test_email_monitoring.py
```
This will:
1. Send a test email
2. Trigger an error alert by exceeding the threshold
3. Trigger a buffered alert by waiting for buffer time
4. Send a test daily report
## Architecture Decisions
### Why Voluptuous Schema Validation?
The project already uses Voluptuous for config validation, so we:
- ✅ Maintained consistency with existing codebase
- ✅ Leveraged existing validation patterns
- ✅ Kept dependencies minimal (no new libraries needed)
- ✅ Ensured config errors are caught at startup, not runtime
### Why Hybrid Alert Strategy?
The hybrid approach (immediate + buffered) provides:
- ✅ **Fast response** for critical issues (5+ errors = immediate alert)
- ✅ **Spam prevention** for occasional errors (buffered alerts)
- ✅ **Cooldown period** prevents alert fatigue
- ✅ **Always sends** buffered errors (no minimum threshold for time-based flush)
### Why Custom Logging Handler?
Using a custom `logging.Handler` provides:
- ✅ **Zero code changes** - automatically captures all error logs
- ✅ **Clean separation** - monitoring logic separate from business logic
- ✅ **Standard pattern** - follows Python logging best practices
- ✅ **Easy to disable** - just remove handler from logger
## Files Changed/Created
### Created Files
- `src/alpine_bits_python/email_service.py` (new)
- `src/alpine_bits_python/email_monitoring.py` (new)
- `tests/test_email_service.py` (new)
- `examples/test_email_monitoring.py` (new)
- `docs/EMAIL_MONITORING.md` (new)
- `docs/EMAIL_MONITORING_QUICKSTART.md` (new)
- `docs/EMAIL_MONITORING_IMPLEMENTATION.md` (new)
- `config/.env.example` (new)
### Modified Files
- `src/alpine_bits_python/logging_config.py` - Added email handler integration
- `src/alpine_bits_python/api.py` - Added email service initialization
- `src/alpine_bits_python/config_loader.py` - **Added email config schema validation** ✅
- `config/config.yaml` - Added email configuration section
## Next Steps (Optional Enhancements)
Potential future improvements:
- [ ] Email templates with Jinja2
- [ ] Retry logic for failed email sends
- [ ] Integration with Slack, PagerDuty, Discord
- [ ] Weekly/monthly report options
- [ ] Custom alert rules based on error patterns
- [ ] Email queuing for high-volume scenarios
- [ ] Attachments support for detailed logs
- [ ] HTML email styling improvements
- [ ] Health check endpoint showing email status
## Conclusion
**Email monitoring system is complete and production-ready!**
The system provides:
- Robust SMTP email sending with TLS/SSL support
- Intelligent error alerting with hybrid threshold + time-based approach
- Scheduled daily reports with statistics and error summaries
- Comprehensive schema validation using Voluptuous
- Full test coverage with 17 passing tests
- Complete documentation and quick start guides
- Zero impact on existing functionality
**The system is ready to use!** Just configure SMTP credentials and enable the desired features.

View File

@@ -0,0 +1,177 @@
# Email Monitoring Quick Start
Get email notifications for errors and daily reports in 5 minutes.
## 1. Configure SMTP Settings
Edit `config/config.yaml` and add:
```yaml
email:
smtp:
host: "smtp.gmail.com"
port: 587
username: !secret EMAIL_USERNAME
password: !secret EMAIL_PASSWORD
use_tls: true
from_address: "noreply@yourdomain.com"
from_name: "AlpineBits Monitor"
```
## 2. Set Environment Variables
In the secrets.yaml file add the secrets
```yaml
EMAIL_USERNAME: "your_email_username"
EMAIL_PASSWORD: "your_email_password"
```
> **Note:** For Gmail, use an [App Password](https://support.google.com/accounts/answer/185833), not your regular password.
## 3. Enable Error Alerts
In `config/config.yaml`:
```yaml
email:
monitoring:
error_alerts:
enabled: true
recipients:
- "alerts@yourdomain.com"
error_threshold: 5
buffer_minutes: 15
cooldown_minutes: 15
```
**How it works:**
- Sends immediate alert after 5 errors
- Otherwise sends after 15 minutes
- Waits 15 minutes between alerts (cooldown)
## 4. Enable Daily Reports (Optional)
In `config/config.yaml`:
```yaml
email:
monitoring:
daily_report:
enabled: true
recipients:
- "admin@yourdomain.com"
send_time: "08:00"
include_stats: true
include_errors: true
```
## 5. Test Your Configuration
Run the test script:
```bash
uv run python examples/test_email_monitoring.py
```
This will:
- ✅ Send a test email
- ✅ Trigger an error alert
- ✅ Send a test daily report
## What You Get
### Error Alert Email
When errors occur, you'll receive:
```
🚨 AlpineBits Error Alert: 5 errors (threshold exceeded)
Error Count: 5
Time Range: 14:25:00 to 14:30:00
Errors:
----------------------------------------------------------------------
[2025-10-15 14:25:12] ERROR: Database connection timeout
Module: db:245
[2025-10-15 14:26:34] ERROR: Failed to process reservation
Module: api:567
Exception: ValueError: Invalid hotel code
```
### Daily Report Email
Every day at 8 AM, you'll receive:
```
📊 AlpineBits Daily Report - 2025-10-15
Statistics:
total_reservations: 42
new_customers: 15
active_hotels: 4
Errors (3):
[08:15:23] ERROR: Connection timeout
[12:45:10] ERROR: Invalid form data
[18:30:00] CRITICAL: Database unavailable
```
## Troubleshooting
### No emails received?
1. Check your SMTP credentials:
```bash
echo $EMAIL_USERNAME
echo $EMAIL_PASSWORD
```
2. Check application logs for errors:
```bash
tail -f alpinebits.log | grep -i email
```
3. Test SMTP connection manually:
```bash
uv run python -c "
import smtplib
with smtplib.SMTP('smtp.gmail.com', 587) as smtp:
smtp.starttls()
smtp.login('$EMAIL_USERNAME', '$EMAIL_PASSWORD')
print('✅ SMTP connection successful')
"
```
### Gmail authentication failed?
- Enable 2-factor authentication on your Google account
- Generate an App Password at https://myaccount.google.com/apppasswords
- Use the App Password (not your regular password)
### Too many emails?
- Increase `error_threshold` to only alert on serious issues
- Increase `buffer_minutes` to batch more errors together
- Increase `cooldown_minutes` to reduce alert frequency
## Next Steps
- Read the full [Email Monitoring Documentation](./EMAIL_MONITORING.md)
- Configure custom statistics for daily reports
- Set up multiple recipient groups
- Integrate with Slack or PagerDuty (coming soon)
## Support
For issues or questions:
- Check the [documentation](./EMAIL_MONITORING.md)
- Review [test examples](../examples/test_email_monitoring.py)
- Open an issue on GitHub

View File

@@ -0,0 +1,297 @@
# Multi-Worker Deployment Guide
## Problem Statement
When running FastAPI with multiple workers (e.g., `uvicorn app:app --workers 4`), the `lifespan` function runs in **every worker process**. This causes singleton services to run multiple times:
-**Email schedulers** send duplicate notifications (4x emails if 4 workers)
-**Background tasks** run redundantly across all workers
-**Database migrations/hashing** may cause race conditions
## Solution: File-Based Worker Coordination
We use **file-based locking** to ensure only ONE worker runs singleton services. This approach:
- ✅ Works across different process managers (uvicorn, gunicorn, systemd)
- ✅ No external dependencies (Redis, databases)
- ✅ Automatic failover (if primary worker crashes, another can acquire lock)
- ✅ Simple and reliable
## Implementation
### 1. Worker Coordination Module
The `worker_coordination.py` module provides:
```python
from alpine_bits_python.worker_coordination import is_primary_worker
# In your lifespan function
is_primary, worker_lock = is_primary_worker()
if is_primary:
# Start schedulers, background tasks, etc.
start_email_scheduler()
else:
# This is a secondary worker - skip singleton services
pass
```
### 2. How It Works
```
┌─────────────────────────────────────────────────────┐
│ uvicorn --workers 4 │
└─────────────────────────────────────────────────────┘
├─── Worker 0 (PID 1001) ─┐
├─── Worker 1 (PID 1002) ─┤
├─── Worker 2 (PID 1003) ─┤ All try to acquire
└─── Worker 3 (PID 1004) ─┘ /tmp/alpinebits_primary_worker.lock
Worker 0: ✓ Lock acquired → PRIMARY
Worker 1: ✗ Lock busy → SECONDARY
Worker 2: ✗ Lock busy → SECONDARY
Worker 3: ✗ Lock busy → SECONDARY
```
### 3. Lifespan Function
```python
async def lifespan(app: FastAPI):
# Determine primary worker using file lock
is_primary, worker_lock = is_primary_worker()
_LOGGER.info("Worker startup: pid=%d, primary=%s", os.getpid(), is_primary)
# All workers: shared setup
config = load_config()
engine = create_async_engine(DATABASE_URL)
# Only primary worker: singleton services
if is_primary:
# Start email scheduler
email_handler, report_scheduler = setup_logging(
config, email_service, loop, enable_scheduler=True
)
report_scheduler.start()
# Run database migrations/hashing
await hash_existing_customers()
else:
# Secondary workers: skip schedulers
email_handler, report_scheduler = setup_logging(
config, email_service, loop, enable_scheduler=False
)
yield
# Cleanup
if report_scheduler:
report_scheduler.stop()
# Release lock
if worker_lock:
worker_lock.release()
```
## Deployment Scenarios
### Development (Single Worker)
```bash
# No special configuration needed
uvicorn alpine_bits_python.api:app --reload
```
Result: Single worker becomes primary automatically.
### Production (Multiple Workers)
```bash
# 4 workers for handling concurrent requests
uvicorn alpine_bits_python.api:app --workers 4 --host 0.0.0.0 --port 8000
```
Result:
- Worker 0 becomes PRIMARY → runs schedulers
- Workers 1-3 are SECONDARY → handle requests only
### With Gunicorn
```bash
gunicorn alpine_bits_python.api:app \
--workers 4 \
--worker-class uvicorn.workers.UvicornWorker \
--bind 0.0.0.0:8000
```
Result: Same as uvicorn - one primary, rest secondary.
### Docker Compose
```yaml
services:
api:
image: alpinebits-api
command: uvicorn alpine_bits_python.api:app --workers 4 --host 0.0.0.0
volumes:
- /tmp:/tmp # Important: Share lock file location
```
**Important**: When using multiple containers, ensure they share the same lock file location or use Redis-based coordination instead.
## Monitoring & Debugging
### Check Which Worker is Primary
Look for log messages at startup:
```
Worker startup: pid=1001, primary=True
Worker startup: pid=1002, primary=False
Worker startup: pid=1003, primary=False
Worker startup: pid=1004, primary=False
```
### Check Lock File
```bash
# See which PID holds the lock
cat /tmp/alpinebits_primary_worker.lock
# Output: 1001
# Verify process is running
ps aux | grep 1001
```
### Testing Worker Coordination
Run the test script:
```bash
uv run python test_worker_coordination.py
```
Expected output:
```
Worker 0 (PID 30773): ✓ I am PRIMARY
Worker 1 (PID 30774): ✗ I am SECONDARY
Worker 2 (PID 30775): ✗ I am SECONDARY
Worker 3 (PID 30776): ✗ I am SECONDARY
```
## Failover Behavior
### Primary Worker Crashes
1. Primary worker holds lock
2. Primary worker crashes/exits → lock is automatically released by OS
3. Existing secondary workers remain secondary (they already failed to acquire lock)
4. **Next restart**: First worker to start becomes new primary
### Graceful Restart
1. Send SIGTERM to workers
2. Primary worker releases lock in shutdown
3. New workers start, one becomes primary
## Lock File Location
Default: `/tmp/alpinebits_primary_worker.lock`
### Change Lock Location
```python
from alpine_bits_python.worker_coordination import WorkerLock
# Custom location
lock = WorkerLock("/var/run/alpinebits/primary.lock")
is_primary = lock.acquire()
```
**Production recommendation**: Use `/var/run/` or `/run/` for lock files (automatically cleaned on reboot).
## Common Issues
### Issue: All workers think they're primary
**Cause**: Lock file path not accessible or workers running in separate containers.
**Solution**:
- Check file permissions on lock directory
- For containers: Use shared volume or Redis-based coordination
### Issue: No worker becomes primary
**Cause**: Lock file from previous run still exists.
**Solution**:
```bash
# Clean up stale lock file
rm /tmp/alpinebits_primary_worker.lock
# Restart application
```
### Issue: Duplicate emails still being sent
**Cause**: Email handler running on all workers (not just schedulers).
**Solution**: Email **alert handler** runs on all workers (to catch errors from any worker). Email **scheduler** only runs on primary. This is correct behavior - alerts come from any worker, scheduled reports only from primary.
## Alternative Approaches
### Redis-Based Coordination
For multi-container deployments, consider Redis-based locks:
```python
import redis
from redis.lock import Lock
redis_client = redis.Redis(host='redis', port=6379)
lock = Lock(redis_client, "alpinebits_primary_worker", timeout=60)
if lock.acquire(blocking=False):
# This is the primary worker
start_schedulers()
```
**Pros**: Works across containers
**Cons**: Requires Redis dependency
### Environment Variable (Not Recommended)
```bash
# Manually set primary worker
ALPINEBITS_PRIMARY_WORKER=true uvicorn app:app
```
**Pros**: Simple
**Cons**: Manual configuration, no automatic failover
## Best Practices
1.**Use file locks for single-host deployments** (our implementation)
2.**Use Redis locks for multi-container deployments**
3.**Log primary/secondary status at startup**
4.**Always release locks on shutdown**
5.**Keep lock files in `/var/run/` or `/tmp/`**
6.**Don't rely on process names** (unreliable with uvicorn)
7.**Don't use environment variables** (no automatic failover)
8.**Don't skip coordination** (will cause duplicate notifications)
## Summary
With file-based worker coordination:
- ✅ Only ONE worker runs singleton services (schedulers, migrations)
- ✅ All workers handle HTTP requests normally
- ✅ Automatic failover if primary worker crashes
- ✅ No external dependencies needed
- ✅ Works with uvicorn, gunicorn, and other ASGI servers
This ensures you get the benefits of multiple workers (concurrency) without duplicate email notifications or race conditions.

View File

@@ -0,0 +1,154 @@
╔══════════════════════════════════════════════════════════════════════════════╗
║ MULTI-WORKER FASTAPI ARCHITECTURE ║
╚══════════════════════════════════════════════════════════════════════════════╝
┌─────────────────────────────────────────────────────────────────────────────┐
│ Command: uvicorn alpine_bits_python.api:app --workers 4 │
└─────────────────────────────────────────────────────────────────────────────┘
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Master Process (uvicorn supervisor) ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
│ │ │ │
┌───────────┼──────────┼──────────┼──────────┼───────────┐
│ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌──────────────────┐
│Worker 0│ │Worker 1│ │Worker 2│ │Worker 3│ │Lock File │
│PID:1001│ │PID:1002│ │PID:1003│ │PID:1004│ │/tmp/alpinebits │
└────┬───┘ └───┬────┘ └───┬────┘ └───┬────┘ │_primary_worker │
│ │ │ │ │.lock │
│ │ │ │ └──────────────────┘
│ │ │ │ ▲
│ │ │ │ │
└─────────┴──────────┴──────────┴─────────────┤
All try to acquire lock │
│ │
▼ │
┌───────────────────────┐ │
│ fcntl.flock(LOCK_EX) │────────────┘
│ Non-blocking attempt │
└───────────────────────┘
┏━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━┓
▼ ▼
┌─────────┐ ┌──────────────┐
│SUCCESS │ │ WOULD BLOCK │
│(First) │ │(Others) │
└────┬────┘ └──────┬───────┘
│ │
▼ ▼
╔════════════════════════════════╗ ╔══════════════════════════════╗
║ PRIMARY WORKER ║ ║ SECONDARY WORKERS ║
║ (Worker 0, PID 1001) ║ ║ (Workers 1-3) ║
╠════════════════════════════════╣ ╠══════════════════════════════╣
║ ║ ║ ║
║ ✓ Handle HTTP requests ║ ║ ✓ Handle HTTP requests ║
║ ✓ Start email scheduler ║ ║ ✗ Skip email scheduler ║
║ ✓ Send daily reports ║ ║ ✗ Skip daily reports ║
║ ✓ Run DB migrations ║ ║ ✗ Skip DB migrations ║
║ ✓ Hash customers (startup) ║ ║ ✗ Skip customer hashing ║
║ ✓ Send error alerts ║ ║ ✓ Send error alerts ║
║ ✓ Process webhooks ║ ║ ✓ Process webhooks ║
║ ✓ AlpineBits endpoints ║ ║ ✓ AlpineBits endpoints ║
║ ║ ║ ║
║ Holds: worker_lock ║ ║ worker_lock = None ║
║ ║ ║ ║
╚════════════════════════════════╝ ╚══════════════════════════════╝
│ │
│ │
└──────────┬───────────────────────────┘
┌───────────────────────────┐
│ Incoming HTTP Request │
└───────────────────────────┘
(Load balanced by OS)
┌───────────┴──────────────┐
│ │
▼ ▼
Any worker can handle Round-robin distribution
the request normally across all 4 workers
╔══════════════════════════════════════════════════════════════════════════════╗
║ SINGLETON SERVICES ║
╚══════════════════════════════════════════════════════════════════════════════╝
Only run on PRIMARY worker:
┌─────────────────────────────────────────────────────────────┐
│ Email Scheduler │
│ ├─ Daily Report: 8:00 AM │
│ └─ Stats Collection: Per-hotel reservation counts │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Startup Tasks (One-time) │
│ ├─ Database table creation │
│ ├─ Customer data hashing/backfill │
│ └─ Configuration validation │
└─────────────────────────────────────────────────────────────┘
╔══════════════════════════════════════════════════════════════════════════════╗
║ SHARED SERVICES ║
╚══════════════════════════════════════════════════════════════════════════════╝
Run on ALL workers (primary + secondary):
┌─────────────────────────────────────────────────────────────┐
│ HTTP Request Handling │
│ ├─ Webhook endpoints (/api/webhook/*) │
│ ├─ AlpineBits endpoints (/api/alpinebits/*) │
│ └─ Health checks (/api/health) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Error Alert Handler │
│ └─ Any worker can send immediate error alerts │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Event Dispatching │
│ └─ Background tasks triggered by webhooks │
└─────────────────────────────────────────────────────────────┘
╔══════════════════════════════════════════════════════════════════════════════╗
║ SHUTDOWN & FAILOVER ║
╚══════════════════════════════════════════════════════════════════════════════╝
Graceful Shutdown:
┌─────────────────────────────────────────────────────────────┐
│ 1. SIGTERM received │
│ 2. Stop scheduler (primary only) │
│ 3. Close email handler │
│ 4. Release worker_lock (primary only) │
│ 5. Dispose database engine │
└─────────────────────────────────────────────────────────────┘
Primary Worker Crash:
┌─────────────────────────────────────────────────────────────┐
│ 1. Primary worker crashes │
│ 2. OS automatically releases file lock │
│ 3. Secondary workers continue handling requests │
│ 4. On next restart, first worker becomes new primary │
└─────────────────────────────────────────────────────────────┘
╔══════════════════════════════════════════════════════════════════════════════╗
║ KEY BENEFITS ║
╚══════════════════════════════════════════════════════════════════════════════╝
✓ No duplicate email notifications
✓ No race conditions in database operations
✓ Automatic failover if primary crashes
✓ Load distribution for HTTP requests
✓ No external dependencies (Redis, etc.)
✓ Simple and reliable

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<reservations>
<Deletedreservation ID="2473" />
<Deletedreservation ID="2475" />
</reservations>

View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<reservations>
<reservation hotelID="135" id="2498" number="240" date="2025-10-21"
creationTime="2025-10-21T14:03:24" type="reservation" bookingChannel="WHO_KNOWS_WHO_KNOWS"
advertisingMedium="99TALES" advertisingPartner="cpc" advertisingCampagne="IwAR123fbclid456">
<guest id="380" lastName="Schmidt" firstName="Maria" language="de" gender="female"
email="maria.schmidt@gmail.com" />
<roomReservations>
<roomReservation arrival="2025-11-15" departure="2025-11-18" status="reserved"
roomType="EZ" roomNumber="106" adults="1" ratePlanCode="STD" connectedRoomType="0">
<dailySales>
<dailySale date="2025-11-15" revenueTotal="165" revenueLogis="140.2"
revenueBoard="9" revenueFB="10" revenueSpa="1" revenueOther="4.8" />
<dailySale date="2025-11-16" revenueTotal="165" revenueLogis="140.2"
revenueBoard="9" revenueFB="10" revenueSpa="1" revenueOther="4.8" />
<dailySale date="2025-11-17" revenueTotal="165" revenueLogis="140.2"
revenueBoard="9" revenueFB="10" revenueSpa="1" revenueOther="4.8" />
<dailySale date="2025-11-18" />
</dailySales>
</roomReservation>
</roomReservations>
</reservation>
<reservation hotelID="135" id="2499" number="241" date="2025-10-21"
creationTime="2025-10-21T14:04:26" type="reservation" bookingChannel="WHO_KNOWS_WHO_KNOWS"
advertisingMedium="99TALES" advertisingPartner="website"
advertisingCampagne="nduaitreuditaor">
<guest id="381" lastName="Linter" firstName="Jonas" language="de" gender="male"
email="jonas@vaius.ai" />
<roomReservations>
<roomReservation arrival="2025-10-28" departure="2025-10-30" status="reserved"
roomType="DZ" roomNumber="101" adults="2" connectedRoomType="0">
<dailySales>
<dailySale date="2025-10-28" revenueTotal="474" revenueLogis="372.16"
revenueBoard="67.96" revenueFB="20" revenueSpa="2" revenueOther="11.88" />
<dailySale date="2025-10-29" revenueTotal="474" revenueLogis="372.16"
revenueBoard="67.96" revenueFB="20" revenueSpa="2" revenueOther="11.88" />
<dailySale date="2025-10-30" />
</dailySales>
</roomReservation>
</roomReservations>
</reservation>
</reservations>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,305 @@
"""Example script to test email monitoring functionality.
This script demonstrates how to:
1. Configure the email service
2. Send test emails
3. Trigger error alerts
4. Test daily report generation
Usage:
uv run python examples/test_email_monitoring.py
"""
import asyncio
import logging
from datetime import datetime
from alpine_bits_python.config_loader import load_config
from alpine_bits_python.email_monitoring import (
DailyReportScheduler,
EmailAlertHandler,
)
from alpine_bits_python.email_service import create_email_service
from alpine_bits_python.logging_config import get_logger, setup_logging
_LOGGER = get_logger(__name__)
async def test_basic_email():
"""Test 1: Send a basic test email."""
print("\n" + "=" * 60)
print("Test 1: Basic Email Sending")
print("=" * 60)
config = load_config()
email_service = create_email_service(config)
if not email_service:
print("❌ Email service not configured. Check your config.yaml")
return False
print("✓ Email service initialized")
# Get the first recipient from error_alerts config
email_config = config.get("email", {})
monitoring_config = email_config.get("monitoring", {})
error_alerts_config = monitoring_config.get("error_alerts", {})
recipients = error_alerts_config.get("recipients", [])
if not recipients:
print("❌ No recipients configured in error_alerts")
return False
print(f"✓ Sending test email to: {recipients[0]}")
success = await email_service.send_email(
recipients=[recipients[0]],
subject="AlpineBits Email Test - Basic",
body=f"""This is a test email from the AlpineBits server.
Timestamp: {datetime.now().isoformat()}
Test: Basic email sending
If you received this email, your SMTP configuration is working correctly!
---
AlpineBits Python Server
Email Monitoring System
""",
)
if success:
print("✅ Test email sent successfully!")
return True
else:
print("❌ Failed to send test email. Check logs for details.")
return False
async def test_error_alert_threshold():
"""Test 2: Trigger immediate error alert by exceeding threshold."""
print("\n" + "=" * 60)
print("Test 2: Error Alert - Threshold Trigger")
print("=" * 60)
config = load_config()
email_service = create_email_service(config)
if not email_service:
print("❌ Email service not configured")
return False
# Setup logging with email monitoring
loop = asyncio.get_running_loop()
email_handler, _ = setup_logging(config, email_service, loop)
if not email_handler:
print("❌ Error alert handler not configured")
return False
print(f"✓ Error alert handler configured (threshold: {email_handler.error_threshold})")
print(f" Recipients: {email_handler.recipients}")
# Generate errors to exceed threshold
threshold = email_handler.error_threshold
print(f"\n📨 Generating {threshold} errors to trigger immediate alert...")
logger = logging.getLogger("test.error.threshold")
for i in range(threshold):
logger.error(f"Test error #{i + 1} - Threshold test at {datetime.now().isoformat()}")
print(f" → Error {i + 1}/{threshold} logged")
await asyncio.sleep(0.1) # Small delay between errors
# Wait a bit for email to be sent
print("\n⏳ Waiting for alert email to be sent...")
await asyncio.sleep(3)
print("✅ Threshold test complete! Check your email for the alert.")
return True
async def test_error_alert_buffer():
"""Test 3: Trigger buffered error alert by waiting for buffer time."""
print("\n" + "=" * 60)
print("Test 3: Error Alert - Buffer Time Trigger")
print("=" * 60)
config = load_config()
email_service = create_email_service(config)
if not email_service:
print("❌ Email service not configured")
return False
# Setup logging with email monitoring
loop = asyncio.get_running_loop()
email_handler, _ = setup_logging(config, email_service, loop)
if not email_handler:
print("❌ Error alert handler not configured")
return False
print(f"✓ Error alert handler configured (buffer: {email_handler.buffer_minutes} minutes)")
# Generate fewer errors than threshold
num_errors = max(1, email_handler.error_threshold - 2)
print(f"\n📨 Generating {num_errors} errors (below threshold)...")
logger = logging.getLogger("test.error.buffer")
for i in range(num_errors):
logger.error(f"Test error #{i + 1} - Buffer test at {datetime.now().isoformat()}")
print(f" → Error {i + 1}/{num_errors} logged")
buffer_seconds = email_handler.buffer_minutes * 60
print(f"\n⏳ Waiting {email_handler.buffer_minutes} minute(s) for buffer to flush...")
print(" (This will send an email with all buffered errors)")
# Wait for buffer time + a bit extra
await asyncio.sleep(buffer_seconds + 2)
print("✅ Buffer test complete! Check your email for the alert.")
return True
async def test_daily_report():
"""Test 4: Generate and send a test daily report."""
print("\n" + "=" * 60)
print("Test 4: Daily Report")
print("=" * 60)
config = load_config()
email_service = create_email_service(config)
if not email_service:
print("❌ Email service not configured")
return False
# Create a daily report scheduler
daily_report_config = (
config.get("email", {})
.get("monitoring", {})
.get("daily_report", {})
)
if not daily_report_config.get("enabled"):
print("⚠️ Daily reports not enabled in config")
print(" Set email.monitoring.daily_report.enabled = true")
return False
scheduler = DailyReportScheduler(email_service, daily_report_config)
print(f"✓ Daily report scheduler configured")
print(f" Recipients: {scheduler.recipients}")
print(f" Send time: {scheduler.send_time}")
# Add some test statistics
test_stats = {
"total_reservations": 42,
"new_customers": 15,
"active_hotels": 4,
"api_requests_today": 1234,
"average_response_time_ms": 45,
"success_rate": "99.2%",
}
# Add some test errors
test_errors = [
{
"timestamp": "2025-10-15 08:15:23",
"level": "ERROR",
"message": "Connection timeout to external API",
},
{
"timestamp": "2025-10-15 12:45:10",
"level": "ERROR",
"message": "Invalid form data submitted",
},
{
"timestamp": "2025-10-15 18:30:00",
"level": "CRITICAL",
"message": "Database connection pool exhausted",
},
]
print("\n📊 Sending test daily report...")
print(f" Stats: {len(test_stats)} metrics")
print(f" Errors: {len(test_errors)} entries")
success = await email_service.send_daily_report(
recipients=scheduler.recipients,
stats=test_stats,
errors=test_errors,
)
if success:
print("✅ Daily report sent successfully!")
return True
else:
print("❌ Failed to send daily report. Check logs for details.")
return False
async def run_all_tests():
"""Run all email monitoring tests."""
print("\n" + "=" * 60)
print("AlpineBits Email Monitoring Test Suite")
print("=" * 60)
tests = [
("Basic Email", test_basic_email),
("Error Alert (Threshold)", test_error_alert_threshold),
("Error Alert (Buffer)", test_error_alert_buffer),
("Daily Report", test_daily_report),
]
results = []
for test_name, test_func in tests:
try:
result = await test_func()
results.append((test_name, result))
except Exception as e:
print(f"\n❌ Test '{test_name}' failed with exception: {e}")
results.append((test_name, False))
# Wait between tests to avoid rate limiting
await asyncio.sleep(2)
# Print summary
print("\n" + "=" * 60)
print("Test Summary")
print("=" * 60)
passed = sum(1 for _, result in results if result)
total = len(results)
for test_name, result in results:
status = "✅ PASS" if result else "❌ FAIL"
print(f"{status}: {test_name}")
print(f"\nTotal: {passed}/{total} tests passed")
if passed == total:
print("\n🎉 All tests passed!")
else:
print(f"\n⚠️ {total - passed} test(s) failed")
def main():
"""Main entry point."""
print("Starting email monitoring tests...")
print("Make sure you have configured email settings in config.yaml")
print("and set EMAIL_USERNAME and EMAIL_PASSWORD environment variables.")
# Run the tests
try:
asyncio.run(run_all_tests())
except KeyboardInterrupt:
print("\n\n⚠️ Tests interrupted by user")
except Exception as e:
print(f"\n\n❌ Fatal error: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
main()

46
format_xml.py Normal file
View File

@@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""Format a large XML file for readability."""
import xml.dom.minidom
import sys
from pathlib import Path
def format_xml(input_path, output_path=None):
"""Format XML file with proper indentation."""
input_file = Path(input_path)
if not input_file.exists():
print(f"Error: File {input_path} not found", file=sys.stderr)
sys.exit(1)
print(f"Reading {input_file.name}...", file=sys.stderr)
with open(input_file, 'r', encoding='utf-8') as f:
xml_content = f.read()
print("Parsing XML...", file=sys.stderr)
dom = xml.dom.minidom.parseString(xml_content)
print("Formatting XML...", file=sys.stderr)
pretty_xml = dom.toprettyxml(indent=" ")
# Remove extra blank lines that toprettyxml adds
pretty_xml = "\n".join([line for line in pretty_xml.split("\n") if line.strip()])
if output_path is None:
output_path = input_file.with_stem(input_file.stem + "_formatted")
print(f"Writing formatted XML to {output_path}...", file=sys.stderr)
with open(output_path, 'w', encoding='utf-8') as f:
f.write(pretty_xml)
print(f"Done! Formatted XML saved to {output_path}", file=sys.stderr)
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python format_xml.py <input_file> [output_file]", file=sys.stderr)
sys.exit(1)
input_file = sys.argv[1]
output_file = sys.argv[2] if len(sys.argv) > 2 else None
format_xml(input_file, output_file)

View File

@@ -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 &amp; 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,250 @@
{
"timestamp": "2025-09-29T15:44:11.839852",
"client_ip": "127.0.0.1",
"headers": {
"host": "localhost:8080",
"content-type": "application/json",
"user-agent": "insomnia/2023.5.8",
"accept": "*/*",
"content-length": "6920"
},
"data": {
"data": {
"formName": "Contact us",
"submissions": [
{
"label": "Angebot auswählen",
"value": "Herbstferien - Familienzeit mit Dolomitenblick"
},
{
"label": "Anreisedatum",
"value": "2025-10-31"
},
{
"label": "Abreisedatum",
"value": "2025-11-02"
},
{
"label": "Anzahl Erwachsene",
"value": "2"
},
{
"label": "Anzahl Kinder",
"value": "3"
},
{
"label": "Alter Kind 1",
"value": "3"
},
{
"label": "Alter Kind 2",
"value": "1"
},
{
"label": "Alter Kind 3",
"value": "0"
},
{
"label": "Anrede",
"value": "Frau"
},
{
"label": "Vorname",
"value": "Elena"
},
{
"label": "Nachname",
"value": "Battiloro"
},
{
"label": "Email",
"value": "e.battiloro1@gmail.com"
},
{
"label": "Phone",
"value": "+39 333 767 3262"
},
{
"label": "Einwilligung Marketing",
"value": "Non selezionato"
},
{
"label": "utm_Source",
"value": "ig"
},
{
"label": "utm_Medium",
"value": "Instagram_Stories"
},
{
"label": "utm_Campaign",
"value": "Conversions_Hotel_Bemelmans_ITA"
},
{
"label": "utm_Term",
"value": "Cold_Traffic_Conversions_Hotel_Bemelmans_ITA"
},
{
"label": "utm_Content",
"value": "Grafik_4_Spätsommer_23.08-07.09_Landingpage_ITA"
},
{
"label": "utm_term_id",
"value": "120232007764490196"
},
{
"label": "utm_content_id",
"value": "120232007764490196"
},
{
"label": "gad_source",
"value": ""
},
{
"label": "gad_campaignid",
"value": ""
},
{
"label": "gbraid",
"value": ""
},
{
"label": "gclid",
"value": ""
},
{
"label": "fbclid",
"value": "PAZXh0bgNhZW0BMABhZGlkAasmYBhk4DQBp02L46Rl1jAuccxsOaeFSv7WSFnP-MQCsOrz9yDnKRH4hwZ7GEgxF9gy0_OF_aem_qSvrs6xsBkvTaI_Y9_hfnQ"
}
],
"field:date_picker_7e65": "2025-11-02",
"field:number_7cf5": "2",
"field:utm_source": "ig",
"submissionTime": "2025-09-28T13:26:07.938Z",
"field:alter_kind_3": "3",
"field:gad_source": "",
"field:form_field_5a7b": "Non selezionato",
"field:gad_campaignid": "",
"field:utm_medium": "Instagram_Stories",
"field:utm_term_id": "120232007764490196",
"context": {
"metaSiteId": "1dea821c-8168-4736-96e4-4b92e8b364cf",
"activationId": "3fd865e1-f44a-49d2-ae29-19cf77ee488a"
},
"field:email_5139": "e.battiloro1@gmail.com",
"field:phone_4c77": "+39 333 767 3262",
"_context": {
"activation": {
"id": "3fd865e1-f44a-49d2-ae29-19cf77ee488a"
},
"configuration": {
"id": "a976f18c-fa86-495d-be1e-676df188eeae"
},
"app": {
"id": "225dd912-7dea-4738-8688-4b8c6955ffc2"
},
"action": {
"id": "152db4d7-5263-40c4-be2b-1c81476318b7"
},
"trigger": {
"key": "wix_form_app-form_submitted"
}
},
"field:gclid": "",
"formFieldMask": [
"field:angebot_auswaehlen",
"field:date_picker_a7c8",
"field:date_picker_7e65",
"field:number_7cf5",
"field:anzahl_kinder",
"field:alter_kind_3",
"field:alter_kind_25",
"field:alter_kind_4",
"field:alter_kind_5",
"field:alter_kind_6",
"field:alter_kind_7",
"field:alter_kind_8",
"field:alter_kind_9",
"field:alter_kind_10",
"field:alter_kind_11",
"field:anrede",
"field:first_name_abae",
"field:last_name_d97c",
"field:email_5139",
"field:phone_4c77",
"field:long_answer_3524",
"field:form_field_5a7b",
"field:utm_source",
"field:utm_medium",
"field:utm_campaign",
"field:utm_term",
"field:utm_content",
"field:utm_term_id",
"field:utm_content_id",
"field:gad_source",
"field:gad_campaignid",
"field:gbraid",
"field:gclid",
"field:fbclid",
"metaSiteId"
],
"field:alter_kind_4": "0",
"contact": {
"name": {
"first": "Elena",
"last": "Battiloro"
},
"email": "e.battiloro1@gmail.com",
"locale": "it-it",
"phones": [
{
"tag": "UNTAGGED",
"formattedPhone": "+39 333 767 3262",
"id": "7e5c8512-b88e-4cf0-8d0c-9ebe6b210924",
"countryCode": "IT",
"e164Phone": "+393337673262",
"primary": true,
"phone": "333 767 3262"
}
],
"contactId": "b9d47825-9f84-4ae7-873c-d169851b5888",
"emails": [
{
"id": "c5609c67-5eba-4068-ab21-8a2ab9a09a27",
"tag": "UNTAGGED",
"email": "e.battiloro1@gmail.com",
"primary": true
}
],
"updatedDate": "2025-09-28T13:26:09.916Z",
"phone": "+393337673262",
"createdDate": "2025-08-08T13:05:23.733Z"
},
"submissionId": "02fbc71c-745b-4c73-9cba-827d0958117a",
"field:anzahl_kinder": "3",
"field:alter_kind_25": "1",
"field:first_name_abae": "Elena",
"field:utm_content_id": "120232007764490196",
"field:utm_campaign": "Conversions_Hotel_Bemelmans_ITA",
"field:utm_term": "Cold_Traffic_Conversions_Hotel_Bemelmans_ITA",
"contactId": "b9d47825-9f84-4ae7-873c-d169851b5888",
"field:date_picker_a7c8": "2025-10-31",
"field:angebot_auswaehlen": "Herbstferien - Familienzeit mit Dolomitenblick",
"field:utm_content": "Grafik_4_Spätsommer_23.08-07.09_Landingpage_ITA",
"field:last_name_d97c": "Battiloro",
"submissionsLink": "https://manage.wix.app/forms/submissions/1dea821c-8168-4736-96e4-4b92e8b364cf/e084006b-ae83-4e4d-b2f5-074118cdb3b1?d=https%3A%2F%2Fmanage.wix.com%2Fdashboard%2F1dea821c-8168-4736-96e4-4b92e8b364cf%2Fwix-forms%2Fform%2Fe084006b-ae83-4e4d-b2f5-074118cdb3b1%2Fsubmissions&s=true",
"field:gbraid": "",
"field:fbclid": "PAZXh0bgNhZW0BMABhZGlkAasmYBhk4DQBp02L46Rl1jAuccxsOaeFSv7WSFnP-MQCsOrz9yDnKRH4hwZ7GEgxF9gy0_OF_aem_qSvrs6xsBkvTaI_Y9_hfnQ",
"field:anrede": "Frau",
"formId": "e084006b-ae83-4e4d-b2f5-074118cdb3b1"
}
},
"origin_header": null,
"all_headers": {
"host": "localhost:8080",
"content-type": "application/json",
"user-agent": "insomnia/2023.5.8",
"accept": "*/*",
"content-length": "6920"
}
}

View File

@@ -0,0 +1,250 @@
{
"timestamp": "2025-09-29T15:44:54.746579",
"client_ip": "127.0.0.1",
"headers": {
"host": "localhost:8080",
"content-type": "application/json",
"user-agent": "insomnia/2023.5.8",
"accept": "*/*",
"content-length": "6920"
},
"data": {
"data": {
"formName": "Contact us",
"submissions": [
{
"label": "Angebot auswählen",
"value": "Herbstferien - Familienzeit mit Dolomitenblick"
},
{
"label": "Anreisedatum",
"value": "2025-10-31"
},
{
"label": "Abreisedatum",
"value": "2025-11-02"
},
{
"label": "Anzahl Erwachsene",
"value": "2"
},
{
"label": "Anzahl Kinder",
"value": "3"
},
{
"label": "Alter Kind 1",
"value": "3"
},
{
"label": "Alter Kind 2",
"value": "1"
},
{
"label": "Alter Kind 3",
"value": "0"
},
{
"label": "Anrede",
"value": "Frau"
},
{
"label": "Vorname",
"value": "Elena"
},
{
"label": "Nachname",
"value": "Battiloro"
},
{
"label": "Email",
"value": "e.battiloro1@gmail.com"
},
{
"label": "Phone",
"value": "+39 333 767 3262"
},
{
"label": "Einwilligung Marketing",
"value": "Non selezionato"
},
{
"label": "utm_Source",
"value": "ig"
},
{
"label": "utm_Medium",
"value": "Instagram_Stories"
},
{
"label": "utm_Campaign",
"value": "Conversions_Hotel_Bemelmans_ITA"
},
{
"label": "utm_Term",
"value": "Cold_Traffic_Conversions_Hotel_Bemelmans_ITA"
},
{
"label": "utm_Content",
"value": "Grafik_4_Spätsommer_23.08-07.09_Landingpage_ITA"
},
{
"label": "utm_term_id",
"value": "120232007764490196"
},
{
"label": "utm_content_id",
"value": "120232007764490196"
},
{
"label": "gad_source",
"value": ""
},
{
"label": "gad_campaignid",
"value": ""
},
{
"label": "gbraid",
"value": ""
},
{
"label": "gclid",
"value": ""
},
{
"label": "fbclid",
"value": "PAZXh0bgNhZW0BMABhZGlkAasmYBhk4DQBp02L46Rl1jAuccxsOaeFSv7WSFnP-MQCsOrz9yDnKRH4hwZ7GEgxF9gy0_OF_aem_qSvrs6xsBkvTaI_Y9_hfnQ"
}
],
"field:date_picker_7e65": "2025-11-02",
"field:number_7cf5": "2",
"field:utm_source": "ig",
"submissionTime": "2025-09-28T13:26:07.938Z",
"field:alter_kind_3": "3",
"field:gad_source": "",
"field:form_field_5a7b": "Non selezionato",
"field:gad_campaignid": "",
"field:utm_medium": "Instagram_Stories",
"field:utm_term_id": "120232007764490196",
"context": {
"metaSiteId": "1dea821c-8168-4736-96e4-4b92e8b364cf",
"activationId": "3fd865e1-f44a-49d2-ae29-19cf77ee488a"
},
"field:email_5139": "e.battiloro1@gmail.com",
"field:phone_4c77": "+39 333 767 3262",
"_context": {
"activation": {
"id": "3fd865e1-f44a-49d2-ae29-19cf77ee488a"
},
"configuration": {
"id": "a976f18c-fa86-495d-be1e-676df188eeae"
},
"app": {
"id": "225dd912-7dea-4738-8688-4b8c6955ffc2"
},
"action": {
"id": "152db4d7-5263-40c4-be2b-1c81476318b7"
},
"trigger": {
"key": "wix_form_app-form_submitted"
}
},
"field:gclid": "",
"formFieldMask": [
"field:angebot_auswaehlen",
"field:date_picker_a7c8",
"field:date_picker_7e65",
"field:number_7cf5",
"field:anzahl_kinder",
"field:alter_kind_3",
"field:alter_kind_25",
"field:alter_kind_4",
"field:alter_kind_5",
"field:alter_kind_6",
"field:alter_kind_7",
"field:alter_kind_8",
"field:alter_kind_9",
"field:alter_kind_10",
"field:alter_kind_11",
"field:anrede",
"field:first_name_abae",
"field:last_name_d97c",
"field:email_5139",
"field:phone_4c77",
"field:long_answer_3524",
"field:form_field_5a7b",
"field:utm_source",
"field:utm_medium",
"field:utm_campaign",
"field:utm_term",
"field:utm_content",
"field:utm_term_id",
"field:utm_content_id",
"field:gad_source",
"field:gad_campaignid",
"field:gbraid",
"field:gclid",
"field:fbclid",
"metaSiteId"
],
"field:alter_kind_4": "0",
"contact": {
"name": {
"first": "Elena",
"last": "Battiloro"
},
"email": "e.battiloro1@gmail.com",
"locale": "it-it",
"phones": [
{
"tag": "UNTAGGED",
"formattedPhone": "+39 333 767 3262",
"id": "7e5c8512-b88e-4cf0-8d0c-9ebe6b210924",
"countryCode": "IT",
"e164Phone": "+393337673262",
"primary": true,
"phone": "333 767 3262"
}
],
"contactId": "b9d47825-9f84-4ae7-873c-d169851b5888",
"emails": [
{
"id": "c5609c67-5eba-4068-ab21-8a2ab9a09a27",
"tag": "UNTAGGED",
"email": "e.battiloro1@gmail.com",
"primary": true
}
],
"updatedDate": "2025-09-28T13:26:09.916Z",
"phone": "+393337673262",
"createdDate": "2025-08-08T13:05:23.733Z"
},
"submissionId": "02fbc71c-745b-4c73-9cba-827d0958117a",
"field:anzahl_kinder": "3",
"field:alter_kind_25": "1",
"field:first_name_abae": "Elena",
"field:utm_content_id": "120232007764490196",
"field:utm_campaign": "Conversions_Hotel_Bemelmans_ITA",
"field:utm_term": "Cold_Traffic_Conversions_Hotel_Bemelmans_ITA",
"contactId": "b9d47825-9f84-4ae7-873c-d169851b5888",
"field:date_picker_a7c8": "2025-10-31",
"field:angebot_auswaehlen": "Herbstferien - Familienzeit mit Dolomitenblick",
"field:utm_content": "Grafik_4_Spätsommer_23.08-07.09_Landingpage_ITA",
"field:last_name_d97c": "Battiloro",
"submissionsLink": "https://manage.wix.app/forms/submissions/1dea821c-8168-4736-96e4-4b92e8b364cf/e084006b-ae83-4e4d-b2f5-074118cdb3b1?d=https%3A%2F%2Fmanage.wix.com%2Fdashboard%2F1dea821c-8168-4736-96e4-4b92e8b364cf%2Fwix-forms%2Fform%2Fe084006b-ae83-4e4d-b2f5-074118cdb3b1%2Fsubmissions&s=true",
"field:gbraid": "",
"field:fbclid": "PAZXh0bgNhZW0BMABhZGlkAasmYBhk4DQBp02L46Rl1jAuccxsOaeFSv7WSFnP-MQCsOrz9yDnKRH4hwZ7GEgxF9gy0_OF_aem_qSvrs6xsBkvTaI_Y9_hfnQ",
"field:anrede": "Frau",
"formId": "e084006b-ae83-4e4d-b2f5-074118cdb3b1"
}
},
"origin_header": null,
"all_headers": {
"host": "localhost:8080",
"content-type": "application/json",
"user-agent": "insomnia/2023.5.8",
"accept": "*/*",
"content-length": "6920"
}
}

View File

@@ -0,0 +1,170 @@
{
"timestamp": "2025-09-29T16:08:43.177480",
"client_ip": "127.0.0.1",
"headers": {
"host": "localhost:8080",
"content-type": "application/json",
"user-agent": "insomnia/2023.5.8",
"accept": "*/*",
"content-length": "4518"
},
"data": {
"data": {
"formName": "Contact us",
"submissions": [
{
"label": "Anreisedatum",
"value": "2026-01-17"
},
{
"label": "Abreisedatum",
"value": "2026-01-24"
},
{
"label": "Anzahl Erwachsene",
"value": "2"
},
{
"label": "Anzahl Kinder",
"value": "0"
},
{
"label": "Anrede",
"value": "Herr"
},
{
"label": "Vorname",
"value": "Weislinger "
},
{
"label": "Nachname",
"value": "Alain "
},
{
"label": "Email",
"value": "alain-et-evelyne@hotmail.fr"
},
{
"label": "Phone",
"value": "+33 6 41 77 99 09"
},
{
"label": "Einwilligung Marketing",
"value": "Cochée"
}
],
"field:date_picker_7e65": "2026-01-24",
"field:number_7cf5": "2",
"submissionTime": "2025-09-27T19:36:39.137Z",
"field:form_field_5a7b": "Cochée",
"context": {
"metaSiteId": "7b28c2ce-1e20-4d07-9e86-73d822007e18",
"activationId": "d59c463c-96e0-4742-b4f7-70b8f0431168"
},
"field:email_5139": "alain-et-evelyne@hotmail.fr",
"field:phone_4c77": "+33 6 41 77 99 09",
"_context": {
"activation": {
"id": "d59c463c-96e0-4742-b4f7-70b8f0431168"
},
"configuration": {
"id": "483806f6-24ba-413f-9431-6b1ad9379f5c"
},
"app": {
"id": "225dd912-7dea-4738-8688-4b8c6955ffc2"
},
"action": {
"id": "a85d9873-f8ed-426a-90b0-fb64a8e50406"
},
"trigger": {
"key": "wix_form_app-form_submitted"
}
},
"formFieldMask": [
"field:angebot_auswaehlen",
"field:date_picker_a7c8",
"field:date_picker_7e65",
"field:number_7cf5",
"field:anzahl_kinder",
"field:alter_kind_3",
"field:alter_kind_25",
"field:alter_kind_4",
"field:alter_kind_5",
"field:alter_kind_6",
"field:alter_kind_7",
"field:alter_kind_8",
"field:alter_kind_9",
"field:alter_kind_10",
"field:alter_kind_11",
"field:anrede",
"field:first_name_abae",
"field:last_name_d97c",
"field:email_5139",
"field:phone_4c77",
"field:long_answer_3524",
"field:form_field_5a7b",
"field:utm_source",
"field:utm_medium",
"field:utm_campaign",
"field:utm_term",
"field:utm_content",
"field:utm_term_id",
"field:utm_content_id",
"field:gad_source",
"field:gad_campaignid",
"field:gbraid",
"field:gclid",
"field:fbclid",
"metaSiteId"
],
"contact": {
"name": {
"first": "Weislinger",
"last": "Alain"
},
"email": "alain-et-evelyne@hotmail.fr",
"locale": "de-de",
"phones": [
{
"tag": "UNTAGGED",
"formattedPhone": "+33 6 41 77 99 09",
"id": "90ffc824-1fd7-4167-b29f-24a4b62a0773",
"countryCode": "FR",
"e164Phone": "+33641779909",
"primary": true,
"phone": "6 41 77 99 09"
}
],
"contactId": "250e24db-d41e-4f6e-835d-75acdf2ef2b7",
"emails": [
{
"id": "2c071108-2410-4db8-99fa-b50b75a02493",
"tag": "UNTAGGED",
"email": "alain-et-evelyne@hotmail.fr",
"primary": true
}
],
"updatedDate": "2025-09-27T19:36:41.908Z",
"phone": "+33641779909",
"createdDate": "2025-09-27T19:36:41.054Z"
},
"submissionId": "6cfee967-69a8-454a-a10e-0aa03868ba6d",
"field:anzahl_kinder": "0",
"field:first_name_abae": "Weislinger ",
"contactId": "250e24db-d41e-4f6e-835d-75acdf2ef2b7",
"field:date_picker_a7c8": "2026-01-17",
"field:last_name_d97c": "Alain ",
"submissionsLink": "https://manage.wix.app/forms/submissions/7b28c2ce-1e20-4d07-9e86-73d822007e18/e084006b-ae83-4e4d-b2f5-074118cdb3b1?d=https%3A%2F%2Fmanage.wix.com%2Fdashboard%2F7b28c2ce-1e20-4d07-9e86-73d822007e18%2Fwix-forms%2Fform%2Fe084006b-ae83-4e4d-b2f5-074118cdb3b1%2Fsubmissions&s=true",
"field:anrede": "Herr",
"formId": "e084006b-ae83-4e4d-b2f5-074118cdb3b1"
}
},
"origin_header": null,
"all_headers": {
"host": "localhost:8080",
"content-type": "application/json",
"user-agent": "insomnia/2023.5.8",
"accept": "*/*",
"content-length": "4518"
}
}

View File

@@ -0,0 +1,170 @@
{
"timestamp": "2025-09-29T16:24:47.833595",
"client_ip": "127.0.0.1",
"headers": {
"host": "localhost:8080",
"content-type": "application/json",
"user-agent": "insomnia/2023.5.8",
"accept": "*/*",
"content-length": "4518"
},
"data": {
"data": {
"formName": "Contact us",
"submissions": [
{
"label": "Anreisedatum",
"value": "2026-01-17"
},
{
"label": "Abreisedatum",
"value": "2026-01-24"
},
{
"label": "Anzahl Erwachsene",
"value": "2"
},
{
"label": "Anzahl Kinder",
"value": "0"
},
{
"label": "Anrede",
"value": "Herr"
},
{
"label": "Vorname",
"value": "Weislinger "
},
{
"label": "Nachname",
"value": "Alain "
},
{
"label": "Email",
"value": "alain-et-evelyne@hotmail.fr"
},
{
"label": "Phone",
"value": "+33 6 41 77 99 09"
},
{
"label": "Einwilligung Marketing",
"value": "Cochée"
}
],
"field:date_picker_7e65": "2026-01-24",
"field:number_7cf5": "2",
"submissionTime": "2025-09-27T19:36:39.137Z",
"field:form_field_5a7b": "Cochée",
"context": {
"metaSiteId": "7b28c2ce-1e20-4d07-9e86-73d822007e18",
"activationId": "d59c463c-96e0-4742-b4f7-70b8f0431168"
},
"field:email_5139": "alain-et-evelyne@hotmail.fr",
"field:phone_4c77": "+33 6 41 77 99 09",
"_context": {
"activation": {
"id": "d59c463c-96e0-4742-b4f7-70b8f0431168"
},
"configuration": {
"id": "483806f6-24ba-413f-9431-6b1ad9379f5c"
},
"app": {
"id": "225dd912-7dea-4738-8688-4b8c6955ffc2"
},
"action": {
"id": "a85d9873-f8ed-426a-90b0-fb64a8e50406"
},
"trigger": {
"key": "wix_form_app-form_submitted"
}
},
"formFieldMask": [
"field:angebot_auswaehlen",
"field:date_picker_a7c8",
"field:date_picker_7e65",
"field:number_7cf5",
"field:anzahl_kinder",
"field:alter_kind_3",
"field:alter_kind_25",
"field:alter_kind_4",
"field:alter_kind_5",
"field:alter_kind_6",
"field:alter_kind_7",
"field:alter_kind_8",
"field:alter_kind_9",
"field:alter_kind_10",
"field:alter_kind_11",
"field:anrede",
"field:first_name_abae",
"field:last_name_d97c",
"field:email_5139",
"field:phone_4c77",
"field:long_answer_3524",
"field:form_field_5a7b",
"field:utm_source",
"field:utm_medium",
"field:utm_campaign",
"field:utm_term",
"field:utm_content",
"field:utm_term_id",
"field:utm_content_id",
"field:gad_source",
"field:gad_campaignid",
"field:gbraid",
"field:gclid",
"field:fbclid",
"metaSiteId"
],
"contact": {
"name": {
"first": "Weislinger",
"last": "Alain"
},
"email": "alain-et-evelyne@hotmail.fr",
"locale": "de-de",
"phones": [
{
"tag": "UNTAGGED",
"formattedPhone": "+33 6 41 77 99 09",
"id": "90ffc824-1fd7-4167-b29f-24a4b62a0773",
"countryCode": "FR",
"e164Phone": "+33641779909",
"primary": true,
"phone": "6 41 77 99 09"
}
],
"contactId": "250e24db-d41e-4f6e-835d-75acdf2ef2b7",
"emails": [
{
"id": "2c071108-2410-4db8-99fa-b50b75a02493",
"tag": "UNTAGGED",
"email": "alain-et-evelyne@hotmail.fr",
"primary": true
}
],
"updatedDate": "2025-09-27T19:36:41.908Z",
"phone": "+33641779909",
"createdDate": "2025-09-27T19:36:41.054Z"
},
"submissionId": "6cfee967-69a8-454a-a10e-0aa03868ba6d",
"field:anzahl_kinder": "0",
"field:first_name_abae": "Weislinger ",
"contactId": "250e24db-d41e-4f6e-835d-75acdf2ef2b7",
"field:date_picker_a7c8": "2026-01-17",
"field:last_name_d97c": "Alain ",
"submissionsLink": "https://manage.wix.app/forms/submissions/7b28c2ce-1e20-4d07-9e86-73d822007e18/e084006b-ae83-4e4d-b2f5-074118cdb3b1?d=https%3A%2F%2Fmanage.wix.com%2Fdashboard%2F7b28c2ce-1e20-4d07-9e86-73d822007e18%2Fwix-forms%2Fform%2Fe084006b-ae83-4e4d-b2f5-074118cdb3b1%2Fsubmissions&s=true",
"field:anrede": "Herr",
"formId": "e084006b-ae83-4e4d-b2f5-074118cdb3b1"
}
},
"origin_header": null,
"all_headers": {
"host": "localhost:8080",
"content-type": "application/json",
"user-agent": "insomnia/2023.5.8",
"accept": "*/*",
"content-length": "4518"
}
}

View File

@@ -0,0 +1,170 @@
{
"timestamp": "2025-09-29T16:32:12.776585",
"client_ip": "127.0.0.1",
"headers": {
"host": "localhost:8080",
"content-type": "application/json",
"user-agent": "insomnia/2023.5.8",
"accept": "*/*",
"content-length": "4518"
},
"data": {
"data": {
"formName": "Contact us",
"submissions": [
{
"label": "Anreisedatum",
"value": "2026-01-17"
},
{
"label": "Abreisedatum",
"value": "2026-01-24"
},
{
"label": "Anzahl Erwachsene",
"value": "2"
},
{
"label": "Anzahl Kinder",
"value": "0"
},
{
"label": "Anrede",
"value": "Herr"
},
{
"label": "Vorname",
"value": "Weislinger "
},
{
"label": "Nachname",
"value": "Alain "
},
{
"label": "Email",
"value": "alain-et-evelyne@hotmail.fr"
},
{
"label": "Phone",
"value": "+33 6 41 77 99 09"
},
{
"label": "Einwilligung Marketing",
"value": "Cochée"
}
],
"field:date_picker_7e65": "2026-01-24",
"field:number_7cf5": "2",
"submissionTime": "2025-09-27T19:36:39.137Z",
"field:form_field_5a7b": "Cochée",
"context": {
"metaSiteId": "7b28c2ce-1e20-4d07-9e86-73d822007e18",
"activationId": "d59c463c-96e0-4742-b4f7-70b8f0431168"
},
"field:email_5139": "alain-et-evelyne@hotmail.fr",
"field:phone_4c77": "+33 6 41 77 99 09",
"_context": {
"activation": {
"id": "d59c463c-96e0-4742-b4f7-70b8f0431168"
},
"configuration": {
"id": "483806f6-24ba-413f-9431-6b1ad9379f5c"
},
"app": {
"id": "225dd912-7dea-4738-8688-4b8c6955ffc2"
},
"action": {
"id": "a85d9873-f8ed-426a-90b0-fb64a8e50406"
},
"trigger": {
"key": "wix_form_app-form_submitted"
}
},
"formFieldMask": [
"field:angebot_auswaehlen",
"field:date_picker_a7c8",
"field:date_picker_7e65",
"field:number_7cf5",
"field:anzahl_kinder",
"field:alter_kind_3",
"field:alter_kind_25",
"field:alter_kind_4",
"field:alter_kind_5",
"field:alter_kind_6",
"field:alter_kind_7",
"field:alter_kind_8",
"field:alter_kind_9",
"field:alter_kind_10",
"field:alter_kind_11",
"field:anrede",
"field:first_name_abae",
"field:last_name_d97c",
"field:email_5139",
"field:phone_4c77",
"field:long_answer_3524",
"field:form_field_5a7b",
"field:utm_source",
"field:utm_medium",
"field:utm_campaign",
"field:utm_term",
"field:utm_content",
"field:utm_term_id",
"field:utm_content_id",
"field:gad_source",
"field:gad_campaignid",
"field:gbraid",
"field:gclid",
"field:fbclid",
"metaSiteId"
],
"contact": {
"name": {
"first": "Weislinger",
"last": "Alain"
},
"email": "alain-et-evelyne@hotmail.fr",
"locale": "de-de",
"phones": [
{
"tag": "UNTAGGED",
"formattedPhone": "+33 6 41 77 99 09",
"id": "90ffc824-1fd7-4167-b29f-24a4b62a0773",
"countryCode": "FR",
"e164Phone": "+33641779909",
"primary": true,
"phone": "6 41 77 99 09"
}
],
"contactId": "250e24db-d41e-4f6e-835d-75acdf2ef2b7",
"emails": [
{
"id": "2c071108-2410-4db8-99fa-b50b75a02493",
"tag": "UNTAGGED",
"email": "alain-et-evelyne@hotmail.fr",
"primary": true
}
],
"updatedDate": "2025-09-27T19:36:41.908Z",
"phone": "+33641779909",
"createdDate": "2025-09-27T19:36:41.054Z"
},
"submissionId": "6cfee967-69a8-454a-a10e-0aa03868ba6d",
"field:anzahl_kinder": "0",
"field:first_name_abae": "Weislinger ",
"contactId": "250e24db-d41e-4f6e-835d-75acdf2ef2b7",
"field:date_picker_a7c8": "2026-01-17",
"field:last_name_d97c": "Alain ",
"submissionsLink": "https://manage.wix.app/forms/submissions/7b28c2ce-1e20-4d07-9e86-73d822007e18/e084006b-ae83-4e4d-b2f5-074118cdb3b1?d=https%3A%2F%2Fmanage.wix.com%2Fdashboard%2F7b28c2ce-1e20-4d07-9e86-73d822007e18%2Fwix-forms%2Fform%2Fe084006b-ae83-4e4d-b2f5-074118cdb3b1%2Fsubmissions&s=true",
"field:anrede": "Herr",
"formId": "e084006b-ae83-4e4d-b2f5-074118cdb3b1"
}
},
"origin_header": null,
"all_headers": {
"host": "localhost:8080",
"content-type": "application/json",
"user-agent": "insomnia/2023.5.8",
"accept": "*/*",
"content-length": "4518"
}
}

View File

@@ -0,0 +1,240 @@
{
"timestamp": "2025-09-29T16:34:49.785457",
"client_ip": "127.0.0.1",
"headers": {
"host": "localhost:8080",
"content-type": "application/json",
"user-agent": "insomnia/2023.5.8",
"accept": "*/*",
"content-length": "6638"
},
"data": {
"data": {
"formName": "Contact us",
"submissions": [
{
"label": "Angebot auswählen",
"value": "Zimmer: Doppelzimmer"
},
{
"label": "Anreisedatum",
"value": "2025-10-03"
},
{
"label": "Abreisedatum",
"value": "2025-10-05"
},
{
"label": "Anzahl Erwachsene",
"value": "2"
},
{
"label": "Anzahl Kinder",
"value": "1"
},
{
"label": "Alter Kind 1",
"value": "3"
},
{
"label": "Anrede",
"value": "Familie"
},
{
"label": "Vorname",
"value": "Miriana"
},
{
"label": "Nachname",
"value": "Darman"
},
{
"label": "Email",
"value": "miriana.m9@gmail.com"
},
{
"label": "Phone",
"value": "+39 348 443 0969"
},
{
"label": "Einwilligung Marketing",
"value": "Non selezionato"
},
{
"label": "utm_Source",
"value": "ig"
},
{
"label": "utm_Medium",
"value": "Instagram_Stories"
},
{
"label": "utm_Campaign",
"value": "Conversions_Hotel_Bemelmans_ITA"
},
{
"label": "utm_Term",
"value": "Cold_Traffic_Conversions_Hotel_Bemelmans_ITA"
},
{
"label": "utm_Content",
"value": "Grafik_4_Spätsommer_23.08-07.09_Landingpage_ITA"
},
{
"label": "utm_term_id",
"value": "120232007764490196"
},
{
"label": "utm_content_id",
"value": "120232007764490196"
},
{
"label": "gad_source",
"value": ""
},
{
"label": "gad_campaignid",
"value": ""
},
{
"label": "gbraid",
"value": ""
},
{
"label": "gclid",
"value": ""
},
{
"label": "fbclid",
"value": "PAZXh0bgNhZW0BMABhZGlkAasmYBTNE3QBp1jWuJ9zIpfEGRJMP63fMAMI405yvG5EtH-OT0PxSkAbBJaudFHR6cMtkdHu_aem_fopaFtECyVPNW9fmWfEkyA"
}
],
"field:date_picker_7e65": "2025-10-05",
"field:number_7cf5": "2",
"field:utm_source": "ig",
"submissionTime": "2025-09-27T07:04:55.843Z",
"field:alter_kind_3": "3",
"field:gad_source": "",
"field:form_field_5a7b": "Non selezionato",
"field:gad_campaignid": "",
"field:utm_medium": "Instagram_Stories",
"field:utm_term_id": "120232007764490196",
"context": {
"metaSiteId": "1dea821c-8168-4736-96e4-4b92e8b364cf",
"activationId": "d41b7796-dca2-40f1-8245-c2f26a096f19"
},
"field:email_5139": "miriana.m9@gmail.com",
"field:phone_4c77": "+39 348 443 0969",
"_context": {
"activation": {
"id": "d41b7796-dca2-40f1-8245-c2f26a096f19"
},
"configuration": {
"id": "a976f18c-fa86-495d-be1e-676df188eeae"
},
"app": {
"id": "225dd912-7dea-4738-8688-4b8c6955ffc2"
},
"action": {
"id": "152db4d7-5263-40c4-be2b-1c81476318b7"
},
"trigger": {
"key": "wix_form_app-form_submitted"
}
},
"field:gclid": "",
"formFieldMask": [
"field:angebot_auswaehlen",
"field:date_picker_a7c8",
"field:date_picker_7e65",
"field:number_7cf5",
"field:anzahl_kinder",
"field:alter_kind_3",
"field:alter_kind_25",
"field:alter_kind_4",
"field:alter_kind_5",
"field:alter_kind_6",
"field:alter_kind_7",
"field:alter_kind_8",
"field:alter_kind_9",
"field:alter_kind_10",
"field:alter_kind_11",
"field:anrede",
"field:first_name_abae",
"field:last_name_d97c",
"field:email_5139",
"field:phone_4c77",
"field:long_answer_3524",
"field:form_field_5a7b",
"field:utm_source",
"field:utm_medium",
"field:utm_campaign",
"field:utm_term",
"field:utm_content",
"field:utm_term_id",
"field:utm_content_id",
"field:gad_source",
"field:gad_campaignid",
"field:gbraid",
"field:gclid",
"field:fbclid",
"metaSiteId"
],
"contact": {
"name": {
"first": "Miriana",
"last": "Darman"
},
"email": "miriana.m9@gmail.com",
"locale": "it-it",
"phones": [
{
"tag": "UNTAGGED",
"formattedPhone": "+39 348 443 0969",
"id": "ac9d623e-6aaa-4022-856a-0dd64d0ff3fb",
"countryCode": "IT",
"e164Phone": "+393484430969",
"primary": true,
"phone": "348 443 0969"
}
],
"contactId": "bcc29403-82ac-445a-be52-90a67180f16f",
"emails": [
{
"id": "448de804-7353-46ed-9ae3-9c13ca521917",
"tag": "UNTAGGED",
"email": "miriana.m9@gmail.com",
"primary": true
}
],
"updatedDate": "2025-09-27T07:04:58.724Z",
"phone": "+393484430969",
"createdDate": "2025-09-27T07:04:57.752Z"
},
"submissionId": "3150614e-1b0a-47ba-a774-b0a0c71d8110",
"field:anzahl_kinder": "1",
"field:first_name_abae": "Miriana",
"field:utm_content_id": "120232007764490196",
"field:utm_campaign": "Conversions_Hotel_Bemelmans_ITA",
"field:utm_term": "Cold_Traffic_Conversions_Hotel_Bemelmans_ITA",
"contactId": "bcc29403-82ac-445a-be52-90a67180f16f",
"field:date_picker_a7c8": "2025-10-03",
"field:angebot_auswaehlen": "Zimmer: Doppelzimmer",
"field:utm_content": "Grafik_4_Spätsommer_23.08-07.09_Landingpage_ITA",
"field:last_name_d97c": "Darman",
"submissionsLink": "https://manage.wix.app/forms/submissions/1dea821c-8168-4736-96e4-4b92e8b364cf/e084006b-ae83-4e4d-b2f5-074118cdb3b1?d=https%3A%2F%2Fmanage.wix.com%2Fdashboard%2F1dea821c-8168-4736-96e4-4b92e8b364cf%2Fwix-forms%2Fform%2Fe084006b-ae83-4e4d-b2f5-074118cdb3b1%2Fsubmissions&s=true",
"field:gbraid": "",
"field:fbclid": "PAZXh0bgNhZW0BMABhZGlkAasmYBTNE3QBp1jWuJ9zIpfEGRJMP63fMAMI405yvG5EtH-OT0PxSkAbBJaudFHR6cMtkdHu_aem_fopaFtECyVPNW9fmWfEkyA",
"field:anrede": "Familie",
"formId": "e084006b-ae83-4e4d-b2f5-074118cdb3b1"
}
},
"origin_header": null,
"all_headers": {
"host": "localhost:8080",
"content-type": "application/json",
"user-agent": "insomnia/2023.5.8",
"accept": "*/*",
"content-length": "6638"
}
}

View File

@@ -0,0 +1,262 @@
{
"timestamp": "2025-10-06T10:46:42.527300",
"client_ip": "127.0.0.1",
"headers": {
"host": "localhost:8080",
"content-type": "application/json",
"user-agent": "insomnia/2023.5.8",
"accept": "*/*",
"content-length": "7499"
},
"data": {
"data": {
"formName": "Contact us",
"submissions": [
{
"label": "Angebot auswählen",
"value": "Zimmer: Doppelzimmer"
},
{
"label": "Anreisedatum",
"value": "2025-12-21"
},
{
"label": "Abreisedatum",
"value": "2025-10-28"
},
{
"label": "Anzahl Erwachsene",
"value": "2"
},
{
"label": "Anzahl Kinder",
"value": "0"
},
{
"label": "Anrede",
"value": "Herr"
},
{
"label": "Vorname",
"value": "Ernst-Dieter"
},
{
"label": "Nachname",
"value": "Koepper"
},
{
"label": "Email",
"value": "koepper-ed@t-online.de"
},
{
"label": "Phone",
"value": "+49 175 8555456"
},
{
"label": "Message",
"value": "Guten Morgen,\nwir sind nicht gebau an die Reisedaten gebunden: Anreise ist möglich ab 20. Dezember, Aufenthalt mindestens eine Woche, gern auch 8 oder 9 Tage. Natürlich mit Halbpension. Mit freundlichem Gruß D. Köpper"
},
{
"label": "Einwilligung Marketing",
"value": "Angekreuzt"
},
{
"label": "utm_Source",
"value": ""
},
{
"label": "utm_Medium",
"value": ""
},
{
"label": "utm_Campaign",
"value": ""
},
{
"label": "utm_Term",
"value": ""
},
{
"label": "utm_Content",
"value": ""
},
{
"label": "utm_term_id",
"value": ""
},
{
"label": "utm_content_id",
"value": ""
},
{
"label": "gad_source",
"value": "5"
},
{
"label": "gad_campaignid",
"value": "23065043477"
},
{
"label": "gbraid",
"value": ""
},
{
"label": "gclid",
"value": "EAIaIQobChMI-d7Bn_-OkAMVuZJQBh09uD0vEAAYASAAEgKR8_D_BwE"
},
{
"label": "fbclid",
"value": ""
},
{
"label": "hotelid",
"value": "12345"
},
{
"label": "hotelname",
"value": "Bemelmans Post"
}
],
"field:date_picker_7e65": "2025-10-28",
"field:number_7cf5": "2",
"field:utm_source": "",
"submissionTime": "2025-10-06T07:05:34.001Z",
"field:gad_source": "5",
"field:form_field_5a7b": "Angekreuzt",
"field:gad_campaignid": "23065043477",
"field:utm_medium": "",
"field:utm_term_id": "",
"context": {
"metaSiteId": "1dea821c-8168-4736-96e4-4b92e8b364cf",
"activationId": "fd8e9c90-0335-4fd2-976d-985f065f3f80"
},
"field:email_5139": "koepper-ed@t-online.de",
"field:phone_4c77": "+49 175 8555456",
"_context": {
"activation": {
"id": "fd8e9c90-0335-4fd2-976d-985f065f3f80"
},
"configuration": {
"id": "a976f18c-fa86-495d-be1e-676df188eeae"
},
"app": {
"id": "225dd912-7dea-4738-8688-4b8c6955ffc2"
},
"action": {
"id": "152db4d7-5263-40c4-be2b-1c81476318b7"
},
"trigger": {
"key": "wix_form_app-form_submitted"
}
},
"field:gclid": "EAIaIQobChMI-d7Bn_-OkAMVuZJQBh09uD0vEAAYASAAEgKR8_D_BwE",
"formFieldMask": [
"field:",
"field:",
"field:angebot_auswaehlen",
"field:date_picker_a7c8",
"field:date_picker_7e65",
"field:",
"field:number_7cf5",
"field:anzahl_kinder",
"field:alter_kind_3",
"field:alter_kind_25",
"field:alter_kind_4",
"field:alter_kind_5",
"field:alter_kind_6",
"field:alter_kind_7",
"field:alter_kind_8",
"field:alter_kind_9",
"field:alter_kind_10",
"field:alter_kind_11",
"field:",
"field:anrede",
"field:first_name_abae",
"field:last_name_d97c",
"field:email_5139",
"field:phone_4c77",
"field:long_answer_3524",
"field:form_field_5a7b",
"field:",
"field:utm_source",
"field:utm_medium",
"field:utm_campaign",
"field:utm_term",
"field:utm_content",
"field:utm_term_id",
"field:utm_content_id",
"field:gad_source",
"field:gad_campaignid",
"field:gbraid",
"field:gclid",
"field:fbclid",
"field:hotelid",
"field:hotelname",
"field:",
"metaSiteId"
],
"contact": {
"name": {
"first": "Ernst-Dieter",
"last": "Koepper"
},
"email": "koepper-ed@t-online.de",
"locale": "de-de",
"phones": [
{
"tag": "UNTAGGED",
"formattedPhone": "+49 175 8555456",
"id": "530a3bf4-6dbe-4611-8963-a50df805785d",
"countryCode": "DE",
"e164Phone": "+491758555456",
"primary": true,
"phone": "175 8555456"
}
],
"contactId": "13659da8-4035-47fe-a66b-6ce461ad290f",
"emails": [
{
"id": "e1d2168e-ca3c-4844-8f93-f2e1b0ae70e3",
"tag": "UNTAGGED",
"email": "koepper-ed@t-online.de",
"primary": true
}
],
"updatedDate": "2025-10-06T07:05:35.675Z",
"phone": "+491758555456",
"createdDate": "2025-10-06T07:05:35.675Z"
},
"submissionId": "86d247dc-9d5a-4eb7-87a7-677bf64645ad",
"field:anzahl_kinder": "0",
"field:first_name_abae": "Ernst-Dieter",
"field:utm_content_id": "",
"field:utm_campaign": "",
"field:utm_term": "",
"contactId": "13659da8-4035-47fe-a66b-6ce461ad290f",
"field:date_picker_a7c8": "2025-12-21",
"field:hotelname": "Bemelmans Post",
"field:angebot_auswaehlen": "Zimmer: Doppelzimmer",
"field:utm_content": "",
"field:last_name_d97c": "Koepper",
"field:hotelid": "12345",
"submissionsLink": "https://manage.wix.app/forms/submissions/1dea821c-8168-4736-96e4-4b92e8b364cf/e084006b-ae83-4e4d-b2f5-074118cdb3b1?d=https%3A%2F%2Fmanage.wix.com%2Fdashboard%2F1dea821c-8168-4736-96e4-4b92e8b364cf%2Fwix-forms%2Fform%2Fe084006b-ae83-4e4d-b2f5-074118cdb3b1%2Fsubmissions&s=true",
"field:gbraid": "",
"field:fbclid": "",
"submissionPdf": {
"fileName": "86d247dc-9d5a-4eb7-87a7-677bf64645ad.pdf",
"downloadUrl": "https://manage.wix.com/_api/form-submission-service/v4/submissions/86d247dc-9d5a-4eb7-87a7-677bf64645ad/download?accessToken=JWS.eyJraWQiOiJWLVNuLWhwZSIsImFsZyI6IkhTMjU2In0.eyJkYXRhIjoie1wibWV0YVNpdGVJZFwiOlwiMWRlYTgyMWMtODE2OC00NzM2LTk2ZTQtNGI5MmU4YjM2NGNmXCJ9IiwiaWF0IjoxNzU5NzM0MzM1LCJleHAiOjE3NTk3MzQ5MzV9.9koy-O_ptm0dRspjh01Yefkt2rCHiUlRCFtE_S3auYw"
},
"field:anrede": "Herr",
"field:long_answer_3524": "Guten Morgen,\nwir sind nicht gebau an die Reisedaten gebunden: Anreise ist möglich ab 20. Dezember, Aufenthalt mindestens eine Woche, gern auch 8 oder 9 Tage. Natürlich mit Halbpension. Mit freundlichem Gruß D. Köpper",
"formId": "e084006b-ae83-4e4d-b2f5-074118cdb3b1"
}
},
"origin_header": null,
"all_headers": {
"host": "localhost:8080",
"content-type": "application/json",
"user-agent": "insomnia/2023.5.8",
"accept": "*/*",
"content-length": "7499"
}
}

View File

@@ -0,0 +1,262 @@
{
"timestamp": "2025-10-06T10:57:32.973217",
"client_ip": "127.0.0.1",
"headers": {
"host": "localhost:8080",
"content-type": "application/json",
"user-agent": "insomnia/2023.5.8",
"accept": "*/*",
"content-length": "7499"
},
"data": {
"data": {
"formName": "Contact us",
"submissions": [
{
"label": "Angebot auswählen",
"value": "Zimmer: Doppelzimmer"
},
{
"label": "Anreisedatum",
"value": "2025-12-21"
},
{
"label": "Abreisedatum",
"value": "2025-10-28"
},
{
"label": "Anzahl Erwachsene",
"value": "2"
},
{
"label": "Anzahl Kinder",
"value": "0"
},
{
"label": "Anrede",
"value": "Herr"
},
{
"label": "Vorname",
"value": "Ernst-Dieter"
},
{
"label": "Nachname",
"value": "Koepper"
},
{
"label": "Email",
"value": "koepper-ed@t-online.de"
},
{
"label": "Phone",
"value": "+49 175 8555456"
},
{
"label": "Message",
"value": "Guten Morgen,\nwir sind nicht gebau an die Reisedaten gebunden: Anreise ist möglich ab 20. Dezember, Aufenthalt mindestens eine Woche, gern auch 8 oder 9 Tage. Natürlich mit Halbpension. Mit freundlichem Gruß D. Köpper"
},
{
"label": "Einwilligung Marketing",
"value": "Angekreuzt"
},
{
"label": "utm_Source",
"value": ""
},
{
"label": "utm_Medium",
"value": ""
},
{
"label": "utm_Campaign",
"value": ""
},
{
"label": "utm_Term",
"value": ""
},
{
"label": "utm_Content",
"value": ""
},
{
"label": "utm_term_id",
"value": ""
},
{
"label": "utm_content_id",
"value": ""
},
{
"label": "gad_source",
"value": "5"
},
{
"label": "gad_campaignid",
"value": "23065043477"
},
{
"label": "gbraid",
"value": ""
},
{
"label": "gclid",
"value": "EAIaIQobChMI-d7Bn_-OkAMVuZJQBh09uD0vEAAYASAAEgKR8_D_BwE"
},
{
"label": "fbclid",
"value": ""
},
{
"label": "hotelid",
"value": "12345"
},
{
"label": "hotelname",
"value": "Bemelmans Post"
}
],
"field:date_picker_7e65": "2025-10-28",
"field:number_7cf5": "2",
"field:utm_source": "",
"submissionTime": "2025-10-06T07:05:34.001Z",
"field:gad_source": "5",
"field:form_field_5a7b": "Angekreuzt",
"field:gad_campaignid": "23065043477",
"field:utm_medium": "",
"field:utm_term_id": "",
"context": {
"metaSiteId": "1dea821c-8168-4736-96e4-4b92e8b364cf",
"activationId": "fd8e9c90-0335-4fd2-976d-985f065f3f80"
},
"field:email_5139": "koepper-ed@t-online.de",
"field:phone_4c77": "+49 175 8555456",
"_context": {
"activation": {
"id": "fd8e9c90-0335-4fd2-976d-985f065f3f80"
},
"configuration": {
"id": "a976f18c-fa86-495d-be1e-676df188eeae"
},
"app": {
"id": "225dd912-7dea-4738-8688-4b8c6955ffc2"
},
"action": {
"id": "152db4d7-5263-40c4-be2b-1c81476318b7"
},
"trigger": {
"key": "wix_form_app-form_submitted"
}
},
"field:gclid": "EAIaIQobChMI-d7Bn_-OkAMVuZJQBh09uD0vEAAYASAAEgKR8_D_BwE",
"formFieldMask": [
"field:",
"field:",
"field:angebot_auswaehlen",
"field:date_picker_a7c8",
"field:date_picker_7e65",
"field:",
"field:number_7cf5",
"field:anzahl_kinder",
"field:alter_kind_3",
"field:alter_kind_25",
"field:alter_kind_4",
"field:alter_kind_5",
"field:alter_kind_6",
"field:alter_kind_7",
"field:alter_kind_8",
"field:alter_kind_9",
"field:alter_kind_10",
"field:alter_kind_11",
"field:",
"field:anrede",
"field:first_name_abae",
"field:last_name_d97c",
"field:email_5139",
"field:phone_4c77",
"field:long_answer_3524",
"field:form_field_5a7b",
"field:",
"field:utm_source",
"field:utm_medium",
"field:utm_campaign",
"field:utm_term",
"field:utm_content",
"field:utm_term_id",
"field:utm_content_id",
"field:gad_source",
"field:gad_campaignid",
"field:gbraid",
"field:gclid",
"field:fbclid",
"field:hotelid",
"field:hotelname",
"field:",
"metaSiteId"
],
"contact": {
"name": {
"first": "Ernst-Dieter",
"last": "Koepper"
},
"email": "koepper-ed@t-online.de",
"locale": "de-de",
"phones": [
{
"tag": "UNTAGGED",
"formattedPhone": "+49 175 8555456",
"id": "530a3bf4-6dbe-4611-8963-a50df805785d",
"countryCode": "DE",
"e164Phone": "+491758555456",
"primary": true,
"phone": "175 8555456"
}
],
"contactId": "13659da8-4035-47fe-a66b-6ce461ad290f",
"emails": [
{
"id": "e1d2168e-ca3c-4844-8f93-f2e1b0ae70e3",
"tag": "UNTAGGED",
"email": "koepper-ed@t-online.de",
"primary": true
}
],
"updatedDate": "2025-10-06T07:05:35.675Z",
"phone": "+491758555456",
"createdDate": "2025-10-06T07:05:35.675Z"
},
"submissionId": "86d247dc-9d5a-4eb7-87a7-677bf64645ad",
"field:anzahl_kinder": "0",
"field:first_name_abae": "Ernst-Dieter",
"field:utm_content_id": "",
"field:utm_campaign": "",
"field:utm_term": "",
"contactId": "13659da8-4035-47fe-a66b-6ce461ad290f",
"field:date_picker_a7c8": "2025-12-21",
"field:hotelname": "Bemelmans Post",
"field:angebot_auswaehlen": "Zimmer: Doppelzimmer",
"field:utm_content": "",
"field:last_name_d97c": "Koepper",
"field:hotelid": "12345",
"submissionsLink": "https://manage.wix.app/forms/submissions/1dea821c-8168-4736-96e4-4b92e8b364cf/e084006b-ae83-4e4d-b2f5-074118cdb3b1?d=https%3A%2F%2Fmanage.wix.com%2Fdashboard%2F1dea821c-8168-4736-96e4-4b92e8b364cf%2Fwix-forms%2Fform%2Fe084006b-ae83-4e4d-b2f5-074118cdb3b1%2Fsubmissions&s=true",
"field:gbraid": "",
"field:fbclid": "",
"submissionPdf": {
"fileName": "86d247dc-9d5a-4eb7-87a7-677bf64645ad.pdf",
"downloadUrl": "https://manage.wix.com/_api/form-submission-service/v4/submissions/86d247dc-9d5a-4eb7-87a7-677bf64645ad/download?accessToken=JWS.eyJraWQiOiJWLVNuLWhwZSIsImFsZyI6IkhTMjU2In0.eyJkYXRhIjoie1wibWV0YVNpdGVJZFwiOlwiMWRlYTgyMWMtODE2OC00NzM2LTk2ZTQtNGI5MmU4YjM2NGNmXCJ9IiwiaWF0IjoxNzU5NzM0MzM1LCJleHAiOjE3NTk3MzQ5MzV9.9koy-O_ptm0dRspjh01Yefkt2rCHiUlRCFtE_S3auYw"
},
"field:anrede": "Herr",
"field:long_answer_3524": "Guten Morgen,\nwir sind nicht gebau an die Reisedaten gebunden: Anreise ist möglich ab 20. Dezember, Aufenthalt mindestens eine Woche, gern auch 8 oder 9 Tage. Natürlich mit Halbpension. Mit freundlichem Gruß D. Köpper",
"formId": "e084006b-ae83-4e4d-b2f5-074118cdb3b1"
}
},
"origin_header": null,
"all_headers": {
"host": "localhost:8080",
"content-type": "application/json",
"user-agent": "insomnia/2023.5.8",
"accept": "*/*",
"content-length": "7499"
}
}

View File

@@ -0,0 +1,262 @@
{
"timestamp": "2025-10-06T15:43:06.732884",
"client_ip": "127.0.0.1",
"headers": {
"host": "localhost:8080",
"content-type": "application/json",
"user-agent": "insomnia/2023.5.8",
"accept": "*/*",
"content-length": "7081"
},
"data": {
"data": {
"formName": "Contact us",
"submissions": [
{
"label": "Angebot auswählen",
"value": "Zimmer: Doppelzimmer"
},
{
"label": "Anreisedatum",
"value": "2025-10-21"
},
{
"label": "Abreisedatum",
"value": "2025-12-28"
},
{
"label": "Anzahl Erwachsene",
"value": "4"
},
{
"label": "Anzahl Kinder",
"value": "0"
},
{
"label": "Anrede",
"value": "Herr"
},
{
"label": "Vorname",
"value": "Jonas"
},
{
"label": "Nachname",
"value": "Linter"
},
{
"label": "Email",
"value": "jonas@vaius.ai"
},
{
"label": "Phone",
"value": "+39 392 007 6982"
},
{
"label": "Message",
"value": "Hallo nachricht in der Kommentarsection"
},
{
"label": "Einwilligung Marketing",
"value": "Angekreuzt"
},
{
"label": "utm_Source",
"value": ""
},
{
"label": "utm_Medium",
"value": ""
},
{
"label": "utm_Campaign",
"value": ""
},
{
"label": "utm_Term",
"value": ""
},
{
"label": "utm_Content",
"value": ""
},
{
"label": "utm_term_id",
"value": ""
},
{
"label": "utm_content_id",
"value": ""
},
{
"label": "gad_source",
"value": "5"
},
{
"label": "gad_campaignid",
"value": "23065043477"
},
{
"label": "gbraid",
"value": ""
},
{
"label": "gclid",
"value": "EAIaIQobChMI-d7Bn_-OkAMVuZJQBh09uD0vEAAYASAAEgKR8_D_BwE"
},
{
"label": "fbclid",
"value": ""
},
{
"label": "hotelid",
"value": "12345"
},
{
"label": "hotelname",
"value": "Bemelmans Post"
}
],
"field:date_picker_7e65": "2025-10-28",
"field:number_7cf5": "2",
"field:utm_source": "",
"submissionTime": "2025-10-06T07:05:34.001Z",
"field:gad_source": "5",
"field:form_field_5a7b": "Angekreuzt",
"field:gad_campaignid": "23065043477",
"field:utm_medium": "",
"field:utm_term_id": "",
"context": {
"metaSiteId": "1dea821c-8168-4736-96e4-4b92e8b364cf",
"activationId": "fd8e9c90-0335-4fd2-976d-985f065f3f80"
},
"field:email_5139": "jonas@vaius.ai",
"field:phone_4c77": "+39 392 007 6982",
"_context": {
"activation": {
"id": "fd8e9c90-0335-4fd2-976d-985f065f3f80"
},
"configuration": {
"id": "a976f18c-fa86-495d-be1e-676df188eeae"
},
"app": {
"id": "225dd912-7dea-4738-8688-4b8c6955ffc2"
},
"action": {
"id": "152db4d7-5263-40c4-be2b-1c81476318b7"
},
"trigger": {
"key": "wix_form_app-form_submitted"
}
},
"field:gclid": "EAIaIQobChMI-d7Bn_-OkAMVuZJQBh09uD0vEAAYASAAEgKR8_D_BwE",
"formFieldMask": [
"field:",
"field:",
"field:angebot_auswaehlen",
"field:date_picker_a7c8",
"field:date_picker_7e65",
"field:",
"field:number_7cf5",
"field:anzahl_kinder",
"field:alter_kind_3",
"field:alter_kind_25",
"field:alter_kind_4",
"field:alter_kind_5",
"field:alter_kind_6",
"field:alter_kind_7",
"field:alter_kind_8",
"field:alter_kind_9",
"field:alter_kind_10",
"field:alter_kind_11",
"field:",
"field:anrede",
"field:first_name_abae",
"field:last_name_d97c",
"field:email_5139",
"field:phone_4c77",
"field:long_answer_3524",
"field:form_field_5a7b",
"field:",
"field:utm_source",
"field:utm_medium",
"field:utm_campaign",
"field:utm_term",
"field:utm_content",
"field:utm_term_id",
"field:utm_content_id",
"field:gad_source",
"field:gad_campaignid",
"field:gbraid",
"field:gclid",
"field:fbclid",
"field:hotelid",
"field:hotelname",
"field:",
"metaSiteId"
],
"contact": {
"name": {
"first": "Jonas",
"last": "Linter"
},
"email": "jonas@vaius.ai",
"locale": "de-de",
"phones": [
{
"tag": "UNTAGGED",
"formattedPhone": "+39 392 007 6982",
"id": "530a3bf4-6dbe-4611-8963-a50df805785d",
"countryCode": "DE",
"e164Phone": "+493920076982",
"primary": true,
"phone": "392 0076982"
}
],
"contactId": "66659da8-4035-47fe-a66b-6ce461ad290f",
"emails": [
{
"id": "e1d2168e-ca3c-4844-8f93-f2e1b0ae70e3",
"tag": "UNTAGGED",
"email": "koepper-ed@t-online.de",
"primary": true
}
],
"updatedDate": "2025-10-06T07:05:35.675Z",
"phone": "+491758555456",
"createdDate": "2025-10-06T07:05:35.675Z"
},
"submissionId": "666247dc-9d5a-4eb7-87a7-677bf64645ad",
"field:anzahl_kinder": "0",
"field:first_name_abae": "Ernst-Dieter",
"field:utm_content_id": "",
"field:utm_campaign": "",
"field:utm_term": "",
"contactId": "66659da8-4035-47fe-a66b-6ce461ad290f",
"field:date_picker_a7c8": "2025-12-21",
"field:hotelname": "Testhotel",
"field:angebot_auswaehlen": "Zimmer: Doppelzimmer",
"field:utm_content": "",
"field:last_name_d97c": "Linter",
"field:hotelid": "135",
"submissionsLink": "https://manage.wix.app/forms/submissions/1dea821c-8168-4736-96e4-4b92e8b364cf/e084006b-ae83-4e4d-b2f5-074118cdb3b1?d=https%3A%2F%2Fmanage.wix.com%2Fdashboard%2F1dea821c-8168-4736-96e4-4b92e8b364cf%2Fwix-forms%2Fform%2Fe084006b-ae83-4e4d-b2f5-074118cdb3b1%2Fsubmissions&s=true",
"field:gbraid": "",
"field:fbclid": "",
"submissionPdf": {
"fileName": "86d247dc-9d5a-4eb7-87a7-677bf64645ad.pdf",
"downloadUrl": "https://manage.wix.com/_api/form-submission-service/v4/submissions/86d247dc-9d5a-4eb7-87a7-677bf64645ad/download?accessToken=JWS.eyJraWQiOiJWLVNuLWhwZSIsImFsZyI6IkhTMjU2In0.eyJkYXRhIjoie1wibWV0YVNpdGVJZFwiOlwiMWRlYTgyMWMtODE2OC00NzM2LTk2ZTQtNGI5MmU4YjM2NGNmXCJ9IiwiaWF0IjoxNzU5NzM0MzM1LCJleHAiOjE3NTk3MzQ5MzV9.9koy-O_ptm0dRspjh01Yefkt2rCHiUlRCFtE_S3auYw"
},
"field:anrede": "Herr",
"field:long_answer_3524": "Kommentarsektion vermutlich",
"formId": "e084006b-ae83-4e4d-b2f5-074118cdb3b1"
}
},
"origin_header": null,
"all_headers": {
"host": "localhost:8080",
"content-type": "application/json",
"user-agent": "insomnia/2023.5.8",
"accept": "*/*",
"content-length": "7081"
}
}

View File

@@ -0,0 +1,262 @@
{
"timestamp": "2025-10-06T15:44:35.341703",
"client_ip": "127.0.0.1",
"headers": {
"host": "localhost:8080",
"content-type": "application/json",
"user-agent": "insomnia/2023.5.8",
"accept": "*/*",
"content-length": "7081"
},
"data": {
"data": {
"formName": "Contact us",
"submissions": [
{
"label": "Angebot auswählen",
"value": "Zimmer: Doppelzimmer"
},
{
"label": "Anreisedatum",
"value": "2025-10-21"
},
{
"label": "Abreisedatum",
"value": "2025-12-28"
},
{
"label": "Anzahl Erwachsene",
"value": "4"
},
{
"label": "Anzahl Kinder",
"value": "0"
},
{
"label": "Anrede",
"value": "Herr"
},
{
"label": "Vorname",
"value": "Jonas"
},
{
"label": "Nachname",
"value": "Linter"
},
{
"label": "Email",
"value": "jonas@vaius.ai"
},
{
"label": "Phone",
"value": "+39 392 007 6982"
},
{
"label": "Message",
"value": "Hallo nachricht in der Kommentarsection"
},
{
"label": "Einwilligung Marketing",
"value": "Angekreuzt"
},
{
"label": "utm_Source",
"value": ""
},
{
"label": "utm_Medium",
"value": ""
},
{
"label": "utm_Campaign",
"value": ""
},
{
"label": "utm_Term",
"value": ""
},
{
"label": "utm_Content",
"value": ""
},
{
"label": "utm_term_id",
"value": ""
},
{
"label": "utm_content_id",
"value": ""
},
{
"label": "gad_source",
"value": "5"
},
{
"label": "gad_campaignid",
"value": "23065043477"
},
{
"label": "gbraid",
"value": ""
},
{
"label": "gclid",
"value": "EAIaIQobChMI-d7Bn_-OkAMVuZJQBh09uD0vEAAYASAAEgKR8_D_BwE"
},
{
"label": "fbclid",
"value": ""
},
{
"label": "hotelid",
"value": "12345"
},
{
"label": "hotelname",
"value": "Bemelmans Post"
}
],
"field:date_picker_7e65": "2025-10-28",
"field:number_7cf5": "2",
"field:utm_source": "",
"submissionTime": "2025-10-06T07:05:34.001Z",
"field:gad_source": "5",
"field:form_field_5a7b": "Angekreuzt",
"field:gad_campaignid": "23065043477",
"field:utm_medium": "",
"field:utm_term_id": "",
"context": {
"metaSiteId": "1dea821c-8168-4736-96e4-4b92e8b364cf",
"activationId": "fd8e9c90-0335-4fd2-976d-985f065f3f80"
},
"field:email_5139": "jonas@vaius.ai",
"field:phone_4c77": "+39 392 007 6982",
"_context": {
"activation": {
"id": "fd8e9c90-0335-4fd2-976d-985f065f3f80"
},
"configuration": {
"id": "a976f18c-fa86-495d-be1e-676df188eeae"
},
"app": {
"id": "225dd912-7dea-4738-8688-4b8c6955ffc2"
},
"action": {
"id": "152db4d7-5263-40c4-be2b-1c81476318b7"
},
"trigger": {
"key": "wix_form_app-form_submitted"
}
},
"field:gclid": "EAIaIQobChMI-d7Bn_-OkAMVuZJQBh09uD0vEAAYASAAEgKR8_D_BwE",
"formFieldMask": [
"field:",
"field:",
"field:angebot_auswaehlen",
"field:date_picker_a7c8",
"field:date_picker_7e65",
"field:",
"field:number_7cf5",
"field:anzahl_kinder",
"field:alter_kind_3",
"field:alter_kind_25",
"field:alter_kind_4",
"field:alter_kind_5",
"field:alter_kind_6",
"field:alter_kind_7",
"field:alter_kind_8",
"field:alter_kind_9",
"field:alter_kind_10",
"field:alter_kind_11",
"field:",
"field:anrede",
"field:first_name_abae",
"field:last_name_d97c",
"field:email_5139",
"field:phone_4c77",
"field:long_answer_3524",
"field:form_field_5a7b",
"field:",
"field:utm_source",
"field:utm_medium",
"field:utm_campaign",
"field:utm_term",
"field:utm_content",
"field:utm_term_id",
"field:utm_content_id",
"field:gad_source",
"field:gad_campaignid",
"field:gbraid",
"field:gclid",
"field:fbclid",
"field:hotelid",
"field:hotelname",
"field:",
"metaSiteId"
],
"contact": {
"name": {
"first": "Jonas",
"last": "Linter"
},
"email": "jonas@vaius.ai",
"locale": "de-de",
"phones": [
{
"tag": "UNTAGGED",
"formattedPhone": "+39 392 007 6982",
"id": "530a3bf4-6dbe-4611-8963-a50df805785d",
"countryCode": "DE",
"e164Phone": "+493920076982",
"primary": true,
"phone": "392 0076982"
}
],
"contactId": "66659da8-4035-47fe-a66b-6ce461ad290f",
"emails": [
{
"id": "e1d2168e-ca3c-4844-8f93-f2e1b0ae70e3",
"tag": "UNTAGGED",
"email": "koepper-ed@t-online.de",
"primary": true
}
],
"updatedDate": "2025-10-06T07:05:35.675Z",
"phone": "+491758555456",
"createdDate": "2025-10-06T07:05:35.675Z"
},
"submissionId": "666247dc-9d5a-4eb7-87a7-677bf64645ad",
"field:anzahl_kinder": "0",
"field:first_name_abae": "Ernst-Dieter",
"field:utm_content_id": "",
"field:utm_campaign": "",
"field:utm_term": "",
"contactId": "66659da8-4035-47fe-a66b-6ce461ad290f",
"field:date_picker_a7c8": "2025-12-21",
"field:hotelname": "Testhotel",
"field:angebot_auswaehlen": "Zimmer: Doppelzimmer",
"field:utm_content": "",
"field:last_name_d97c": "Linter",
"field:hotelid": "135",
"submissionsLink": "https://manage.wix.app/forms/submissions/1dea821c-8168-4736-96e4-4b92e8b364cf/e084006b-ae83-4e4d-b2f5-074118cdb3b1?d=https%3A%2F%2Fmanage.wix.com%2Fdashboard%2F1dea821c-8168-4736-96e4-4b92e8b364cf%2Fwix-forms%2Fform%2Fe084006b-ae83-4e4d-b2f5-074118cdb3b1%2Fsubmissions&s=true",
"field:gbraid": "",
"field:fbclid": "",
"submissionPdf": {
"fileName": "86d247dc-9d5a-4eb7-87a7-677bf64645ad.pdf",
"downloadUrl": "https://manage.wix.com/_api/form-submission-service/v4/submissions/86d247dc-9d5a-4eb7-87a7-677bf64645ad/download?accessToken=JWS.eyJraWQiOiJWLVNuLWhwZSIsImFsZyI6IkhTMjU2In0.eyJkYXRhIjoie1wibWV0YVNpdGVJZFwiOlwiMWRlYTgyMWMtODE2OC00NzM2LTk2ZTQtNGI5MmU4YjM2NGNmXCJ9IiwiaWF0IjoxNzU5NzM0MzM1LCJleHAiOjE3NTk3MzQ5MzV9.9koy-O_ptm0dRspjh01Yefkt2rCHiUlRCFtE_S3auYw"
},
"field:anrede": "Herr",
"field:long_answer_3524": "Kommentarsektion vermutlich",
"formId": "e084006b-ae83-4e4d-b2f5-074118cdb3b1"
}
},
"origin_header": null,
"all_headers": {
"host": "localhost:8080",
"content-type": "application/json",
"user-agent": "insomnia/2023.5.8",
"accept": "*/*",
"content-length": "7081"
}
}

View 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"
}
}

View 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"
}
}

View File

@@ -1,13 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<OTA_ResRetrieveRS xmlns="http://www.opentravel.org/OTA/2003/05" Version="7.000">
<ReservationsList>
<HotelReservation CreateDateTime="2025-09-25T13:33:19.275224+00:00" ResStatus="Requested" RoomStayReservation="true">
<UniqueID Type="14" ID="6b34fe24ac2ff811"/>
<HotelReservation CreateDateTime="2025-09-29T12:08:55.313540+00:00" ResStatus="Requested" RoomStayReservation="true">
<UniqueID Type="14" ID="e084006b-ae83-4e4d-b2f5-074118cdb3b1"/>
<RoomStays>
<RoomStay>
<TimeSpan>
<StartDateWindow EarliestDate="2024-10-01" LatestDate="2024-10-02"/>
</TimeSpan>
<GuestCounts>
<GuestCount Count="2"/>
<GuestCount Count="1" Age="3"/>
<GuestCount Count="1" Age="0"/>
<GuestCount Count="1" Age="1"/>
</GuestCounts>
<TimeSpan Start="2025-10-31" End="2025-11-02"/>
</RoomStay>
</RoomStays>
<ResGuests>
@@ -15,21 +19,14 @@
<Profiles>
<ProfileInfo>
<Profile>
<Customer Gender="Male" BirthDate="1980-01-01" Language="en">
<Customer Language="it">
<PersonName>
<NamePrefix>Mr.</NamePrefix>
<GivenName>John</GivenName>
<Surname>Doe</Surname>
<NamePrefix>Frau</NamePrefix>
<GivenName>Elena</GivenName>
<Surname>Battiloro</Surname>
</PersonName>
<Telephone PhoneTechType="5" PhoneNumber="+1234567890"/>
<Telephone PhoneNumber="+0987654321"/>
<Email Remark="newsletter:yes">john.doe@example.com</Email>
<Address Remark="catalog:no">
<AddressLine>123 Main Street</AddressLine>
<CityName>Anytown</CityName>
<PostalCode>12345</PostalCode>
<CountryName Code="US"/>
</Address>
<Telephone PhoneTechType="5" PhoneNumber="+393337673262"/>
<Email Remark="newsletter:no">e.battiloro1@gmail.com</Email>
</Customer>
</Profile>
</ProfileInfo>
@@ -38,16 +35,13 @@
</ResGuests>
<ResGlobalInfo>
<Comments>
<Comment Name="customer comment">
<ListItem ListItem="1" Language="en">Landing page comment</ListItem>
<Text>This is a sample comment.</Text>
</Comment>
<Comment Name="additional info">
<Text>This is a special request comment.</Text>
<ListItem ListItem="1" Language="it">Herbstferien - Familienzeit mit Dolomitenblick</ListItem>
<Text>Angebot/Offerta</Text>
</Comment>
</Comments>
<HotelReservationIDs>
<HotelReservationID ResID_Type="13" ResID_SourceContext="99tales"/>
<HotelReservationID ResID_Type="13" ResID_Value="PAZXh0bgNhZW0BMABhZGlkAasmYBhk4DQBp02L46Rl1jAuccxsOaeFSv7WSFnP-MQCsOrz9yDnKRH4hwZ7GEgxF9gy0_OF_aem_qSvrs6xsBkvTaI_Y9_hfnQ" ResID_SourceContext="99tales"/>
</HotelReservationIDs>
<BasicPropertyInfo HotelCode="123" HotelName="Frangart Inn"/>
</ResGlobalInfo>

View File

@@ -4,15 +4,30 @@ build-backend = "hatchling.build"
[project]
name = "alpine-bits-python-server"
version = "0.1.0"
version = "0.1.2"
description = "Alpine Bits Python Server implementation"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"aiosqlite>=0.21.0",
"annotatedyaml>=1.0.0",
"asyncpg>=0.30.0",
"dotenv>=0.9.9",
"fast-langdetect>=1.0.0",
"fastapi>=0.117.1",
"generateds>=2.44.3",
"httpx>=0.28.1",
"lxml>=6.0.1",
"pushover-complete>=2.0.0",
"pydantic[email]>=2.11.9",
"pytest>=8.4.2",
"pytest-asyncio>=1.2.0",
"redis>=6.4.0",
"ruff>=0.13.1",
"slowapi>=0.1.9",
"sqlalchemy>=2.0.43",
"uvicorn>=0.37.0",
"voluptuous>=0.15.2",
"xsdata-pydantic[cli,lxml,soap]>=24.5",
"xsdata[cli,lxml,soap]>=25.7",
]
@@ -24,8 +39,119 @@ alpine-bits-server = "alpine_bits_python.main:main"
packages = ["src/alpine_bits_python"]
[tool.pytest.ini_options]
testpaths = ["test"]
testpaths = ["tests"]
pythonpath = ["src"]
[tool.ruff]
src = ["src", "test"]
src = ["src", "tests"]
[tool.ruff.lint]
select = [
"A001", # Variable {name} is shadowing a Python builtin
"ASYNC210", # Async functions should not call blocking HTTP methods
"ASYNC220", # Async functions should not create subprocesses with blocking methods
"ASYNC221", # Async functions should not run processes with blocking methods
"ASYNC222", # Async functions should not wait on processes with blocking methods
"ASYNC230", # Async functions should not open files with blocking methods like open
"ASYNC251", # Async functions should not call time.sleep
"B002", # Python does not support the unary prefix increment
"B005", # Using .strip() with multi-character strings is misleading
"B007", # Loop control variable {name} not used within loop body
"B014", # Exception handler with duplicate exception
"B015", # Pointless comparison. Did you mean to assign a value? Otherwise, prepend assert or remove it.
"B017", # pytest.raises(BaseException) should be considered evil
"B018", # Found useless attribute access. Either assign it to a variable or remove it.
"B023", # Function definition does not bind loop variable {name}
"B024", # `{name}` is an abstract base class, but it has no abstract methods or properties
"B026", # Star-arg unpacking after a keyword argument is strongly discouraged
"B032", # Possible unintentional type annotation (using :). Did you mean to assign (using =)?
"B035", # Dictionary comprehension uses static key
"B904", # Use raise from to specify exception cause
"B905", # zip() without an explicit strict= parameter
"BLE",
"C", # complexity
"COM818", # Trailing comma on bare tuple prohibited
"D", # docstrings
"DTZ003", # Use datetime.now(tz=) instead of datetime.utcnow()
"DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts)
"E", # pycodestyle
"F", # pyflakes/autoflake
"F541", # f-string without any placeholders
"FLY", # flynt
"FURB", # refurb
"G", # flake8-logging-format
"I", # isort
"INP", # flake8-no-pep420
"ISC", # flake8-implicit-str-concat
"ICN001", # import concentions; {name} should be imported as {asname}
"LOG", # flake8-logging
"N804", # First argument of a class method should be named cls
"N805", # First argument of a method should be named self
"N815", # Variable {name} in class scope should not be mixedCase
"PERF", # Perflint
"PGH", # pygrep-hooks
"PIE", # flake8-pie
"PL", # pylint
"PT", # flake8-pytest-style
"PTH", # flake8-pathlib
"PYI", # flake8-pyi
"RET", # flake8-return
"RSE", # flake8-raise
"RUF005", # Consider iterable unpacking instead of concatenation
"RUF006", # Store a reference to the return value of asyncio.create_task
"RUF007", # Prefer itertools.pairwise() over zip() when iterating over successive pairs
"RUF008", # Do not use mutable default values for dataclass attributes
"RUF010", # Use explicit conversion flag
"RUF013", # PEP 484 prohibits implicit Optional
"RUF016", # Slice in indexed access to type {value_type} uses type {index_type} instead of an integer
"RUF017", # Avoid quadratic list summation
"RUF018", # Avoid assignment expressions in assert statements
"RUF019", # Unnecessary key check before dictionary access
"RUF020", # {never_like} | T is equivalent to T
"RUF021", # Parenthesize a and b expressions when chaining and and or together, to make the precedence clear
"RUF022", # Sort __all__
"RUF023", # Sort __slots__
"RUF024", # Do not pass mutable objects as values to dict.fromkeys
"RUF026", # default_factory is a positional-only argument to defaultdict
"RUF030", # print() call in assert statement is likely unintentional
"RUF032", # Decimal() called with float literal argument
"RUF033", # __post_init__ method with argument defaults
"RUF034", # Useless if-else condition
"RUF100", # Unused `noqa` directive
"RUF101", # noqa directives that use redirected rule codes
"RUF200", # Failed to parse pyproject.toml: {message}
"S102", # Use of exec detected
"S103", # bad-file-permissions
"S108", # hardcoded-temp-file
"S306", # suspicious-mktemp-usage
"S307", # suspicious-eval-usage
"S313", # suspicious-xmlc-element-tree-usage
"S314", # suspicious-xml-element-tree-usage
"S315", # suspicious-xml-expat-reader-usage
"S316", # suspicious-xml-expat-builder-usage
"S317", # suspicious-xml-sax-usage
"S318", # suspicious-xml-mini-dom-usage
"S319", # suspicious-xml-pull-dom-usage
"S601", # paramiko-call
"S602", # subprocess-popen-with-shell-equals-true
"S604", # call-with-shell-equals-true
"S608", # hardcoded-sql-expression
"S609", # unix-command-wildcard-injection
"SIM", # flake8-simplify
"SLF", # flake8-self
"SLOT", # flake8-slots
"T100", # Trace found: {name} used
"T20", # flake8-print
"TC", # flake8-type-checking
"TID", # Tidy imports
"TRY", # tryceratops
"UP", # pyupgrade
"UP031", # Use format specifiers instead of percent format
"UP032", # Use f-string instead of `format` call
"W", # pycodestyle
]
[dependency-groups]
dev = [
"pytest-cov>=7.0.0",
]

View File

@@ -0,0 +1 @@
"""AlpineBits Python Server package."""

View File

@@ -1,5 +0,0 @@
"""Entry point for alpine_bits_python package."""
from .main import main
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,934 @@
import re
import traceback
from dataclasses import dataclass
from datetime import UTC
from enum import Enum
from typing import Any
from email_validator import EmailNotValidError, validate_email
from alpine_bits_python.db import Customer, Reservation
from alpine_bits_python.logging_config import get_logger
from alpine_bits_python.schemas import (
CommentData,
CommentListItemData,
CommentsData,
CustomerData,
HotelReservationIdData,
PhoneTechType,
)
# Import the generated classes
from .generated.alpinebits import (
CommentName2,
HotelReservationResStatus,
OtaHotelResNotifRq,
OtaResRetrieveRs,
ProfileProfileType,
RoomTypeRoomType,
UniqueIdType2,
)
_LOGGER = get_logger(__name__)
# Define type aliases for the two Customer types
NotifCustomer = OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGuests.ResGuest.Profiles.ProfileInfo.Profile.Customer # noqa: E501
RetrieveCustomer = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGuests.ResGuest.Profiles.ProfileInfo.Profile.Customer # noqa: E501
# Define type aliases for HotelReservationId types
NotifHotelReservationId = OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGlobalInfo.HotelReservationIds.HotelReservationId # noqa: E501
RetrieveHotelReservationId = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.HotelReservationIds.HotelReservationId # noqa: E501
# Define type aliases for Comments types
NotifComments = (
OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGlobalInfo.Comments
)
RetrieveComments = (
OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.Comments
)
NotifComment = (
OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGlobalInfo.Comments.Comment
)
RetrieveComment = (
OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.Comments.Comment
)
# type aliases for GuestCounts
NotifGuestCounts = (
OtaHotelResNotifRq.HotelReservations.HotelReservation.RoomStays.RoomStay.GuestCounts
)
RetrieveGuestCounts = (
OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays.RoomStay.GuestCounts
)
NotifUniqueId = OtaHotelResNotifRq.HotelReservations.HotelReservation.UniqueId
RetrieveUniqueId = OtaResRetrieveRs.ReservationsList.HotelReservation.UniqueId
NotifTimeSpan = (
OtaHotelResNotifRq.HotelReservations.HotelReservation.RoomStays.RoomStay.TimeSpan
)
RetrieveTimeSpan = (
OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays.RoomStay.TimeSpan
)
NotifRoomStays = OtaHotelResNotifRq.HotelReservations.HotelReservation.RoomStays
RetrieveRoomStays = OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays
NotifHotelReservation = OtaHotelResNotifRq.HotelReservations.HotelReservation
RetrieveHotelReservation = OtaResRetrieveRs.ReservationsList.HotelReservation
NotifRoomTypes = (
OtaHotelResNotifRq.HotelReservations.HotelReservation.RoomStays.RoomStay.RoomTypes
)
RetrieveRoomTypes = (
OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays.RoomStay.RoomTypes
)
from .const import RESERVATION_ID_TYPE
# Enum to specify which OTA message type to use
class OtaMessageType(Enum):
NOTIF = "notification" # For OtaHotelResNotifRq
RETRIEVE = "retrieve" # For OtaResRetrieveRs
@dataclass
class KidsAgeData:
"""Data class to hold information about children's ages."""
ages: list[int]
class GuestCountsFactory:
"""Factory class to create GuestCounts instances for both OtaHotelResNotifRq and OtaResRetrieveRs."""
@staticmethod
def create_guest_counts(
adults: int,
kids: list[int] | None = None,
message_type: OtaMessageType = OtaMessageType.RETRIEVE,
) -> NotifGuestCounts:
"""Create a GuestCounts object for OtaHotelResNotifRq or OtaResRetrieveRs.
:param adults: Number of adults
:param kids: List of ages for each kid (optional)
:return: GuestCounts instance
"""
if message_type == OtaMessageType.RETRIEVE:
return GuestCountsFactory._create_guest_counts(
adults, kids, RetrieveGuestCounts
)
if message_type == OtaMessageType.NOTIF:
return GuestCountsFactory._create_guest_counts(
adults, kids, NotifGuestCounts
)
raise ValueError(f"Unsupported message type: {message_type}")
@staticmethod
def _create_guest_counts(
adults: int, kids: list[int] | None, guest_counts_class: type
) -> Any:
"""Create a GuestCounts object of the specified type.
:param adults: Number of adults
:param kids: List of ages for each kid (optional)
:param guest_counts_class: The GuestCounts class to instantiate
:return: GuestCounts instance
"""
GuestCount = guest_counts_class.GuestCount
guest_count_list = []
if adults > 0:
guest_count_list.append(GuestCount(count=str(adults)))
if kids:
# create a dict with amount of kids for each age
age_count = {}
for age in kids:
if age in age_count:
age_count[age] += 1
else:
age_count[age] = 1
for age, count in age_count.items():
guest_count_list.append(GuestCount(count=str(count), age=str(age)))
return guest_counts_class(guest_count=guest_count_list)
class CustomerFactory:
"""Factory class to create Customer instances for both Retrieve and Notif."""
@staticmethod
def create_notif_customer(data: CustomerData) -> NotifCustomer:
"""Create a Customer for OtaHotelResNotifRq."""
return CustomerFactory._create_customer(NotifCustomer, data)
@staticmethod
def create_retrieve_customer(data: CustomerData) -> RetrieveCustomer:
"""Create a Customer for OtaResRetrieveRs."""
return CustomerFactory._create_customer(RetrieveCustomer, data)
@staticmethod
def _create_customer(
customer_class: type[RetrieveCustomer | NotifCustomer], data: CustomerData
) -> Any:
"""Create a customer of the specified type."""
# Create PersonName
person_name = customer_class.PersonName(
given_name=data.given_name,
surname=data.surname,
name_prefix=data.name_prefix,
name_title=data.name_title,
)
# Create telephone list
telephones = []
for phone_number, phone_tech_type in data.phone_numbers:
telephone = customer_class.Telephone(
phone_number=phone_number,
phone_tech_type=phone_tech_type.value if phone_tech_type else None,
)
telephones.append(telephone)
# Create email if provided
email = None
if data.email_address:
remark = None
if data.email_newsletter is not None:
remark = f"newsletter:{'yes' if data.email_newsletter else 'no'}"
email = customer_class.Email(value=data.email_address, remark=remark)
# Create address if any address fields are provided
address = None
if any(
[data.address_line, data.city_name, data.postal_code, data.country_code]
):
country_name = None
if data.country_code:
country_name = customer_class.Address.CountryName(
code=data.country_code
)
address_remark = None
if data.address_catalog is not None:
address_remark = f"catalog:{'yes' if data.address_catalog else 'no'}"
address = customer_class.Address(
address_line=data.address_line,
city_name=data.city_name,
postal_code=data.postal_code,
country_name=country_name,
remark=address_remark,
)
# Create the customer
return customer_class(
person_name=person_name,
telephone=telephones,
email=email,
address=address,
gender=data.gender,
birth_date=data.birth_date,
language=data.language,
)
@staticmethod
def from_notif_customer(customer: NotifCustomer) -> CustomerData:
"""Convert a NotifCustomer back to CustomerData."""
return CustomerFactory._customer_to_data(customer)
@staticmethod
def from_retrieve_customer(customer: RetrieveCustomer) -> CustomerData:
"""Convert a RetrieveCustomer back to CustomerData."""
return CustomerFactory._customer_to_data(customer)
@staticmethod
def _customer_to_data(customer: Any) -> CustomerData:
"""Convert any customer type to CustomerData."""
# Extract phone numbers
phone_numbers = []
if customer.telephone:
phone_numbers.extend(
[
(
tel.phone_number,
PhoneTechType(tel.phone_tech_type)
if tel.phone_tech_type
else None,
)
for tel in customer.telephone
]
)
# Extract email info
email_address = None
email_newsletter = None
if customer.email:
email_address = customer.email.value
if customer.email.remark:
if "newsletter:yes" in customer.email.remark:
email_newsletter = True
elif "newsletter:no" in customer.email.remark:
email_newsletter = False
# Extract address info
address_line = None
city_name = None
postal_code = None
country_code = None
address_catalog = None
if customer.address:
address_line = customer.address.address_line
city_name = customer.address.city_name
postal_code = customer.address.postal_code
if customer.address.country_name:
country_code = customer.address.country_name.code
if customer.address.remark:
if "catalog:yes" in customer.address.remark:
address_catalog = True
elif "catalog:no" in customer.address.remark:
address_catalog = False
return CustomerData(
given_name=customer.person_name.given_name,
surname=customer.person_name.surname,
name_prefix=customer.person_name.name_prefix,
name_title=customer.person_name.name_title,
phone_numbers=phone_numbers,
email_address=email_address,
email_newsletter=email_newsletter,
address_line=address_line,
city_name=city_name,
postal_code=postal_code,
country_code=country_code,
address_catalog=address_catalog,
gender=customer.gender,
birth_date=customer.birth_date,
language=customer.language,
)
class HotelReservationIdFactory:
"""Factory class to create HotelReservationId instances for both OtaHotelResNotifRq and OtaResRetrieveRs."""
@staticmethod
def create_notif_hotel_reservation_id(
data: HotelReservationIdData,
) -> NotifHotelReservationId:
"""Create a HotelReservationId for OtaHotelResNotifRq."""
return HotelReservationIdFactory._create_hotel_reservation_id(
NotifHotelReservationId, data
)
@staticmethod
def create_retrieve_hotel_reservation_id(
data: HotelReservationIdData,
) -> RetrieveHotelReservationId:
"""Create a HotelReservationId for OtaResRetrieveRs."""
return HotelReservationIdFactory._create_hotel_reservation_id(
RetrieveHotelReservationId, data
)
@staticmethod
def _create_hotel_reservation_id(
hotel_reservation_id_class: type, data: HotelReservationIdData
) -> Any:
"""Create a hotel reservation id of the specified type."""
return hotel_reservation_id_class(
res_id_type=data.res_id_type,
res_id_value=data.res_id_value,
res_id_source=data.res_id_source,
res_id_source_context=data.res_id_source_context,
)
@staticmethod
def from_notif_hotel_reservation_id(
hotel_reservation_id: NotifHotelReservationId,
) -> HotelReservationIdData:
"""Convert a NotifHotelReservationId back to HotelReservationIdData."""
return HotelReservationIdFactory._hotel_reservation_id_to_data(
hotel_reservation_id
)
@staticmethod
def from_retrieve_hotel_reservation_id(
hotel_reservation_id: RetrieveHotelReservationId,
) -> HotelReservationIdData:
"""Convert a RetrieveHotelReservationId back to HotelReservationIdData."""
return HotelReservationIdFactory._hotel_reservation_id_to_data(
hotel_reservation_id
)
@staticmethod
def _hotel_reservation_id_to_data(
hotel_reservation_id: Any,
) -> HotelReservationIdData:
"""Internal method to convert any hotel reservation id type to HotelReservationIdData."""
return HotelReservationIdData(
res_id_type=hotel_reservation_id.res_id_type,
res_id_value=hotel_reservation_id.res_id_value,
res_id_source=hotel_reservation_id.res_id_source,
res_id_source_context=hotel_reservation_id.res_id_source_context,
)
class CommentFactory:
"""Factory class to create Comment instances for both OtaHotelResNotifRq and OtaResRetrieveRs."""
@staticmethod
def create_notif_comments(data: CommentsData) -> NotifComments:
"""Create Comments for OtaHotelResNotifRq."""
return CommentFactory._create_comments(NotifComments, NotifComment, data)
@staticmethod
def create_retrieve_comments(data: CommentsData) -> RetrieveComments:
"""Create Comments for OtaResRetrieveRs."""
return CommentFactory._create_comments(RetrieveComments, RetrieveComment, data)
@staticmethod
def _create_comments(
comments_class: type[RetrieveComments] | type[NotifComments],
comment_class: type[RetrieveComment] | type[NotifComment],
data: CommentsData,
) -> Any:
"""Internal method to create comments of the specified type."""
comments_list = []
for comment_data in data.comments:
# Create list items
list_items = []
for item_data in comment_data.list_items:
_LOGGER.debug(
"Creating list item: value=%s, list_item=%s, language=%s",
item_data.value,
item_data.list_item,
item_data.language,
)
list_item = comment_class.ListItem(
value=item_data.value,
list_item=item_data.list_item,
language=item_data.language,
)
list_items.append(list_item)
# Create comment
comment = comment_class(
name=comment_data.name, text=comment_data.text, list_item=list_items
)
comments_list.append(comment)
# Create comments container
return comments_class(comment=comments_list)
@staticmethod
def from_notif_comments(comments: NotifComments) -> CommentsData:
"""Convert NotifComments back to CommentsData."""
return CommentFactory._comments_to_data(comments)
@staticmethod
def from_retrieve_comments(comments: RetrieveComments) -> CommentsData:
"""Convert RetrieveComments back to CommentsData."""
return CommentFactory._comments_to_data(comments)
@staticmethod
def _comments_to_data(comments: Any) -> CommentsData:
"""Internal method to convert any comments type to CommentsData."""
comments_data_list = []
for comment in comments.comment:
# Extract list items
list_items_data = []
if comment.list_item:
for list_item in comment.list_item:
list_items_data.append(
CommentListItemData(
value=list_item.value,
list_item=list_item.list_item,
language=list_item.language,
)
)
comments_data_list.append(comment)
return CommentsData(comments=comments_data_list)
# Define type aliases for ResGuests types
NotifResGuests = OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGuests
RetrieveResGuests = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGuests
class ResGuestFactory:
"""Factory class to create complete ResGuests structures with a primary customer."""
@staticmethod
def create_notif_res_guests(customer_data: CustomerData) -> NotifResGuests:
"""Create a complete ResGuests structure for OtaHotelResNotifRq with primary customer."""
return ResGuestFactory._create_res_guests(
NotifResGuests, NotifCustomer, customer_data
)
@staticmethod
def create_retrieve_res_guests(customer_data: CustomerData) -> RetrieveResGuests:
"""Create a complete ResGuests structure for OtaResRetrieveRs with primary customer."""
return ResGuestFactory._create_res_guests(
RetrieveResGuests, RetrieveCustomer, customer_data
)
@staticmethod
def _create_res_guests(
res_guests_class: type[RetrieveResGuests] | type[NotifResGuests],
customer_class: type[NotifCustomer | RetrieveCustomer],
customer_data: CustomerData,
) -> Any:
"""Create the complete ResGuests structure."""
# Create the customer using the existing CustomerFactory
customer = CustomerFactory._create_customer(customer_class, customer_data)
# Create Profile with the customer
profile = res_guests_class.ResGuest.Profiles.ProfileInfo.Profile(
customer=customer
)
# Create ProfileInfo with the profile
profile_info = res_guests_class.ResGuest.Profiles.ProfileInfo(profile=profile)
# Create Profiles with the profile_info
profiles = res_guests_class.ResGuest.Profiles(profile_info=profile_info)
# Create ResGuest with the profiles
res_guest = res_guests_class.ResGuest(profiles=profiles)
# Create ResGuests with the res_guest
return res_guests_class(res_guest=res_guest)
@staticmethod
def extract_primary_customer(
res_guests: NotifResGuests | RetrieveResGuests,
) -> CustomerData:
"""Extract the primary customer data from a ResGuests structure."""
# Navigate down the nested structure to get the customer
customer = res_guests.res_guest.profiles.profile_info.profile.customer
# Use the existing CustomerFactory conversion method
if isinstance(res_guests, NotifResGuests):
return CustomerFactory.from_notif_customer(customer)
return CustomerFactory.from_retrieve_customer(customer)
class AlpineBitsFactory:
"""Unified factory class for creating AlpineBits objects with a simple interface."""
@staticmethod
def create(
data: CustomerData | HotelReservationIdData | CommentsData,
message_type: OtaMessageType,
) -> Any:
"""Create an AlpineBits object based on the data type and message type.
Args:
data: The data object (CustomerData, HotelReservationIdData, CommentsData, etc.)
message_type: Whether to create for NOTIF or RETRIEVE message types
Returns:
The appropriate AlpineBits object based on the data type and message type
"""
if isinstance(data, CustomerData):
if message_type == OtaMessageType.NOTIF:
return CustomerFactory.create_notif_customer(data)
return CustomerFactory.create_retrieve_customer(data)
if isinstance(data, HotelReservationIdData):
if message_type == OtaMessageType.NOTIF:
return HotelReservationIdFactory.create_notif_hotel_reservation_id(data)
return HotelReservationIdFactory.create_retrieve_hotel_reservation_id(data)
if isinstance(data, CommentsData):
if message_type == OtaMessageType.NOTIF:
return CommentFactory.create_notif_comments(data)
return CommentFactory.create_retrieve_comments(data)
raise ValueError(f"Unsupported data type: {type(data)}")
@staticmethod
def create_res_guests(
customer_data: CustomerData, message_type: OtaMessageType
) -> NotifResGuests | RetrieveResGuests:
"""Create a complete ResGuests structure with a primary customer.
Args:
customer_data: The customer data
message_type: Whether to create for NOTIF or RETRIEVE message types
Returns:
The appropriate ResGuests object
"""
if message_type == OtaMessageType.NOTIF:
return ResGuestFactory.create_notif_res_guests(customer_data)
return ResGuestFactory.create_retrieve_res_guests(customer_data)
@staticmethod
def extract_data(
obj: Any,
) -> CustomerData | HotelReservationIdData | CommentsData:
"""Extract data from an AlpineBits object back to a simple data class.
Args:
obj: The AlpineBits object to extract data from
Returns:
The appropriate data object
"""
# Check if it's a Customer object
if hasattr(obj, "person_name") and hasattr(obj.person_name, "given_name"):
if isinstance(obj, NotifCustomer):
return CustomerFactory.from_notif_customer(obj)
if isinstance(obj, RetrieveCustomer):
return CustomerFactory.from_retrieve_customer(obj)
# Check if it's a HotelReservationId object
elif hasattr(obj, "res_id_type"):
if isinstance(obj, NotifHotelReservationId):
return HotelReservationIdFactory.from_notif_hotel_reservation_id(obj)
if isinstance(obj, RetrieveHotelReservationId):
return HotelReservationIdFactory.from_retrieve_hotel_reservation_id(obj)
# Check if it's a Comments object
elif hasattr(obj, "comment"):
if isinstance(obj, NotifComments):
return CommentFactory.from_notif_comments(obj)
if isinstance(obj, RetrieveComments):
return CommentFactory.from_retrieve_comments(obj)
# Check if it's a ResGuests object
elif hasattr(obj, "res_guest"):
return ResGuestFactory.extract_primary_customer(obj)
else:
raise ValueError(f"Unsupported object type: {type(obj)}")
return None
def create_res_retrieve_response(
list: list[tuple[Reservation, Customer]], config: dict[str, Any]
) -> OtaResRetrieveRs:
"""Create RetrievedReservation XML from database entries."""
return _create_xml_from_db(list, OtaMessageType.RETRIEVE, config)
def create_res_notif_push_message(
list: tuple[Reservation, Customer], config: dict[str, Any]
):
"""Create Reservation Notification XML from database entries."""
return _create_xml_from_db(list, OtaMessageType.NOTIF, config)
def _validate_and_repair_email(email: str | None) -> str | None:
if email is None:
return None
try:
# remove numbers from top-level domain (TLD) if any
email = re.sub(r"\.\d+", ".", email)
email_info = validate_email(email)
except EmailNotValidError as e:
_LOGGER.warning("invalid email address: %s", e)
return None
return email_info.normalized
def _process_single_reservation(
reservation: Reservation,
customer: Customer,
message_type: OtaMessageType,
config: dict[str, Any],
):
phone_numbers = (
[(customer.phone, PhoneTechType.MOBILE)] if customer.phone is not None else []
)
# Validate and repair email address
email = _validate_and_repair_email(customer.email_address)
customer_data = CustomerData(
given_name=customer.given_name,
surname=customer.surname,
name_prefix=customer.name_prefix,
name_title=customer.name_title,
phone_numbers=phone_numbers,
email_address=email,
email_newsletter=customer.email_newsletter,
address_line=customer.address_line,
city_name=customer.city_name,
postal_code=customer.postal_code,
country_code=customer.country_code,
address_catalog=customer.address_catalog,
gender=customer.gender,
birth_date=customer.birth_date,
language=customer.language,
)
alpine_bits_factory = AlpineBitsFactory()
res_guests = alpine_bits_factory.create_res_guests(customer_data, message_type)
# Guest counts
children_ages = [int(a) for a in reservation.children_ages.split(",") if a]
guest_counts = GuestCountsFactory.create_guest_counts(
reservation.num_adults, children_ages, message_type
)
if message_type == OtaMessageType.NOTIF:
UniqueId = NotifUniqueId
RoomStays = NotifRoomStays
HotelReservation = NotifHotelReservation
Profile = OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGlobalInfo.Profiles.ProfileInfo.Profile
elif message_type == OtaMessageType.RETRIEVE:
UniqueId = RetrieveUniqueId
RoomStays = RetrieveRoomStays
HotelReservation = RetrieveHotelReservation
Profile = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.Profiles.ProfileInfo.Profile
else:
raise ValueError("Unsupported message type: %s", message_type.value)
unique_id_str = reservation.md5_unique_id
# UniqueID
unique_id = UniqueId(type_value=UniqueIdType2.VALUE_14, id=unique_id_str)
# TimeSpan
time_span = RoomStays.RoomStay.TimeSpan(
start=reservation.start_date.isoformat() if reservation.start_date else None,
end=reservation.end_date.isoformat() if reservation.end_date else None,
)
# RoomTypes (optional) - only create if at least one field is present
room_types = None
if any([reservation.room_type_code, reservation.room_classification_code, reservation.room_type]):
# Convert room_type string to enum if present
room_type_enum = None
if reservation.room_type:
room_type_enum = RoomTypeRoomType(reservation.room_type)
# Create RoomType instance
room_type_obj = RoomStays.RoomStay.RoomTypes.RoomType(
room_type_code=reservation.room_type_code,
room_classification_code=reservation.room_classification_code,
room_type=room_type_enum,
)
# Create RoomTypes container
room_types = RoomStays.RoomStay.RoomTypes(room_type=room_type_obj)
room_stay = RoomStays.RoomStay(
time_span=time_span,
guest_counts=guest_counts,
room_types=room_types,
)
room_stays = RoomStays(
room_stay=[room_stay],
)
res_id_source = "website"
klick_id = None
if reservation.fbclid != "":
klick_id = str(reservation.fbclid)
res_id_source = "meta"
elif reservation.gclid != "":
klick_id = str(reservation.gclid)
res_id_source = "google"
# Get utm_medium if available, otherwise use source
if reservation.utm_medium is not None and str(reservation.utm_medium) != "":
res_id_source = str(reservation.utm_medium)
# Use Pydantic model for automatic validation and truncation
# It will automatically:
# - Trim whitespace
# - Truncate to 64 characters if needed
# - Convert empty strings to None
res_id_source_context = config["server"]["res_id_source_context"]
hotel_res_id_data = HotelReservationIdData(
res_id_type=RESERVATION_ID_TYPE,
res_id_value=klick_id,
res_id_source=res_id_source,
res_id_source_context=res_id_source_context,
)
hotel_res_id = alpine_bits_factory.create(hotel_res_id_data, message_type)
hotel_res_ids = HotelReservation.ResGlobalInfo.HotelReservationIds(
hotel_reservation_id=[hotel_res_id]
)
if reservation.hotel_code is None:
raise ValueError("Reservation hotel_code is None")
hotel_code = str(reservation.hotel_code)
hotel_name = None if reservation.hotel_name is None else str(reservation.hotel_name)
basic_property_info = HotelReservation.ResGlobalInfo.BasicPropertyInfo(
hotel_code=hotel_code,
hotel_name=hotel_name,
)
# Comments
offer_comment = None
if reservation.offer is not None:
offer_comment = CommentData(
name=CommentName2.ADDITIONAL_INFO,
text="Angebot/Offerta: " + reservation.offer,
# list_items=[
# CommentListItemData(
# value=reservation.offer,
# language=customer.language,
# list_item="1",
# )
# ],
)
comment = None
if reservation.user_comment:
comment = CommentData(
name=CommentName2.CUSTOMER_COMMENT,
text=reservation.user_comment,
# list_items=[
# CommentListItemData(
# value="Landing page comment",
# language=customer.language,
# list_item="1",
# )
# ],
)
comments = [offer_comment, comment]
# filter out None comments
comments = [c for c in comments if c is not None]
comments_xml = None
if comments:
for c in comments:
_LOGGER.debug(
"Creating comment: name=%s, text=%s, list_items=%s",
c.name,
c.text,
len(c.list_items),
)
comments_data = CommentsData(comments=comments)
comments_xml = alpine_bits_factory.create(comments_data, message_type)
company_name_value = config["server"]["companyname"]
company_code = config["server"]["code"]
codecontext = config["server"]["codecontext"]
company_name = Profile.CompanyInfo.CompanyName(
value=company_name_value, code=company_code, code_context=codecontext
)
company_info = Profile.CompanyInfo(company_name=company_name)
profile = Profile(
company_info=company_info, profile_type=ProfileProfileType.VALUE_4
)
profile_info = HotelReservation.ResGlobalInfo.Profiles.ProfileInfo(profile=profile)
_LOGGER.info("Type of profile_info: %s", type(profile_info))
profiles = HotelReservation.ResGlobalInfo.Profiles(profile_info=profile_info)
res_global_info = HotelReservation.ResGlobalInfo(
hotel_reservation_ids=hotel_res_ids,
basic_property_info=basic_property_info,
comments=comments_xml,
profiles=profiles,
)
return HotelReservation(
create_date_time=reservation.created_at.replace(tzinfo=UTC).isoformat(),
res_status=HotelReservationResStatus.REQUESTED,
room_stay_reservation="true",
unique_id=unique_id,
room_stays=room_stays,
res_guests=res_guests,
res_global_info=res_global_info,
)
def _create_xml_from_db(
entries: list[tuple[Reservation, Customer]] | tuple[Reservation, Customer],
type: OtaMessageType,
config: dict[str, Any],
):
"""Create RetrievedReservation XML from database entries.
list of pairs (Reservation, Customer)
"""
reservations_list = []
# if entries isn't a list wrap the element in a list
if not isinstance(entries, list):
entries = [entries]
for reservation, customer in entries:
_LOGGER.info(
"Creating XML for reservation %s and customer %s",
reservation.id,
customer.id,
)
try:
hotel_reservation = _process_single_reservation(
reservation, customer, type, config
)
reservations_list.append(hotel_reservation)
except Exception:
_LOGGER.exception(
"Error creating XML for reservation %s and customer %s",
reservation.unique_id,
customer.given_name,
)
_LOGGER.debug(traceback.format_exc())
if type == OtaMessageType.NOTIF:
res_list_obj = OtaHotelResNotifRq.HotelReservations(
hotel_reservation=reservations_list
)
ota_hotel_res_notif_rq = OtaHotelResNotifRq(
version="7.000", hotel_reservations=res_list_obj
)
try:
ota_hotel_res_notif_rq.model_validate(ota_hotel_res_notif_rq.model_dump())
except Exception:
_LOGGER.exception("Validation error: ")
raise
return ota_hotel_res_notif_rq
if type == OtaMessageType.RETRIEVE:
res_list_obj = OtaResRetrieveRs.ReservationsList(
hotel_reservation=reservations_list
)
ota_res_retrieve_rs = OtaResRetrieveRs(
version="7.000", success="", reservations_list=res_list_obj
)
try:
ota_res_retrieve_rs.model_validate(ota_res_retrieve_rs.model_dump())
except Exception as e:
_LOGGER.exception(f"Validation error: {e}")
raise
return ota_res_retrieve_rs
raise ValueError(f"Unsupported message type: {type}")

View File

@@ -1,178 +0,0 @@
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from typing import List, Optional
# TimeSpan class according to XSD: <TimeSpan Start="..." End="..." Duration="..." StartWindow="..." EndWindow="..."/>
class TimeSpan:
def __init__(
self,
start: str,
end: str = None,
duration: str = None,
start_window: str = None,
end_window: str = None,
):
self.start = start
self.end = end
self.duration = duration
self.start_window = start_window
self.end_window = end_window
def to_xml(self):
attrib = {"Start": self.start}
if self.end:
attrib["End"] = self.end
if self.duration:
attrib["Duration"] = self.duration
if self.start_window:
attrib["StartWindow"] = self.start_window
if self.end_window:
attrib["EndWindow"] = self.end_window
return ET.Element(_ns("TimeSpan"), attrib)
NAMESPACE = "http://www.opentravel.org/OTA/2003/05"
ET.register_namespace("", NAMESPACE)
def _ns(tag):
return f"{{{NAMESPACE}}}{tag}"
class ResGuest:
def __init__(
self,
given_name: str,
surname: str,
gender: Optional[str] = None,
birth_date: Optional[str] = None,
language: Optional[str] = None,
name_prefix: Optional[str] = None,
name_title: Optional[str] = None,
email: Optional[str] = None,
address: Optional[dict] = None,
telephones: Optional[list] = None,
):
self.given_name = given_name
self.surname = surname
self.gender = gender
self.birth_date = birth_date
self.language = language
self.name_prefix = name_prefix
self.name_title = name_title
self.email = email
self.address = address or {}
self.telephones = telephones or []
def to_xml(self):
resguest_elem = ET.Element(_ns("ResGuest"))
profiles_elem = ET.SubElement(resguest_elem, _ns("Profiles"))
profileinfo_elem = ET.SubElement(profiles_elem, _ns("ProfileInfo"))
profile_elem = ET.SubElement(profileinfo_elem, _ns("Profile"))
customer_elem = ET.SubElement(profile_elem, _ns("Customer"))
if self.gender:
customer_elem.set("Gender", self.gender)
if self.birth_date:
customer_elem.set("BirthDate", self.birth_date)
if self.language:
customer_elem.set("Language", self.language)
personname_elem = ET.SubElement(customer_elem, _ns("PersonName"))
if self.name_prefix:
ET.SubElement(personname_elem, _ns("NamePrefix")).text = self.name_prefix
ET.SubElement(personname_elem, _ns("GivenName")).text = self.given_name
ET.SubElement(personname_elem, _ns("Surname")).text = self.surname
if self.name_title:
ET.SubElement(personname_elem, _ns("NameTitle")).text = self.name_title
for tel in self.telephones:
tel_elem = ET.SubElement(customer_elem, _ns("Telephone"))
for k, v in tel.items():
tel_elem.set(k, v)
if self.email:
ET.SubElement(customer_elem, _ns("Email")).text = self.email
if self.address:
address_elem = ET.SubElement(customer_elem, _ns("Address"))
for k, v in self.address.items():
if k == "CountryName":
country_elem = ET.SubElement(address_elem, _ns("CountryName"))
if isinstance(v, dict):
for ck, cv in v.items():
country_elem.set(ck, cv)
else:
country_elem.text = v
else:
ET.SubElement(address_elem, _ns(k)).text = v
return resguest_elem
def __str__(self):
from lxml import etree
elem = self.to_xml()
xml_bytes = ET.tostring(elem, encoding="utf-8")
parser = etree.XMLParser(remove_blank_text=True)
lxml_elem = etree.fromstring(xml_bytes, parser)
return etree.tostring(lxml_elem, pretty_print=True, encoding="unicode")
class RoomStay:
def __init__(self, room_type: str, timespan: TimeSpan, guests: List[ResGuest]):
self.room_type = room_type
self.timespan = timespan
self.guests = guests
def to_xml(self):
roomstay_elem = ET.Element(_ns("RoomStay"))
ET.SubElement(roomstay_elem, _ns("RoomType")).set(
"RoomTypeCode", self.room_type
)
roomstay_elem.append(self.timespan.to_xml())
guests_elem = ET.SubElement(roomstay_elem, _ns("Guests"))
for guest in self.guests:
guests_elem.append(guest.to_xml())
return roomstay_elem
def __str__(self):
from lxml import etree
elem = self.to_xml()
xml_bytes = ET.tostring(elem, encoding="utf-8")
parser = etree.XMLParser(remove_blank_text=True)
lxml_elem = etree.fromstring(xml_bytes, parser)
return etree.tostring(lxml_elem, pretty_print=True, encoding="unicode")
class Reservation:
def __init__(
self,
reservation_id: str,
hotel_code: str,
roomstays: List[RoomStay],
create_time: Optional[str] = None,
):
self.reservation_id = reservation_id
self.hotel_code = hotel_code
self.roomstays = roomstays
self.create_time = create_time or datetime.now(timezone.utc).isoformat()
def to_xml(self):
res_elem = ET.Element(_ns("HotelReservation"))
uniqueid_elem = ET.SubElement(res_elem, _ns("UniqueID"))
uniqueid_elem.set("Type", "14")
uniqueid_elem.set("ID", self.reservation_id)
hotel_elem = ET.SubElement(res_elem, _ns("Hotel"))
hotel_elem.set("HotelCode", self.hotel_code)
roomstays_elem = ET.SubElement(res_elem, _ns("RoomStays"))
for rs in self.roomstays:
roomstays_elem.append(rs.to_xml())
res_elem.set("CreateDateTime", self.create_time)
return res_elem
def to_xml_string(self):
root = ET.Element(
_ns("OTA_ResRetrieveRS"),
{"Version": "2024-10", "TimeStamp": datetime.now(timezone.utc).isoformat()},
)
success_elem = ET.SubElement(root, _ns("Success"))
reservations_list = ET.SubElement(root, _ns("ReservationsList"))
reservations_list.append(self.to_xml())
return ET.tostring(root, encoding="utf-8", xml_declaration=True).decode("utf-8")

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,114 @@
import hashlib
import hmac
import os
import secrets
from dotenv import load_dotenv
from fastapi import HTTPException, Security, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
# Load environment variables from .env file
load_dotenv()
from .logging_config import get_logger
logger = get_logger(__name__)
# Security scheme
security = HTTPBearer()
# API Keys - In production, store these in environment variables or a secure database
API_KEYS = {
# Example API keys - replace with your own secure keys
"wix-webhook-key": "sk_live_your_secure_api_key_here",
"admin-key": "sk_admin_your_admin_key_here",
}
# Load API keys from environment if available
if os.getenv("WIX_API_KEY"):
API_KEYS["wix-webhook-key"] = os.getenv("WIX_API_KEY")
if os.getenv("ADMIN_API_KEY"):
API_KEYS["admin-key"] = os.getenv("ADMIN_API_KEY")
def generate_unique_id() -> str:
"""Generate a unique ID with max length 32 characters."""
return secrets.token_urlsafe(26)[:32] # 26 bytes -> 32 chars in base64url
def generate_api_key() -> str:
"""Generate a secure API key."""
return f"sk_live_{secrets.token_urlsafe(32)}"
def validate_api_key(
credentials: HTTPAuthorizationCredentials = Security(security),
) -> str:
"""Validate API key from Authorization header.
Expected format: Authorization: Bearer your_api_key_here
"""
token = credentials.credentials
# Check if the token is in our valid API keys
for key_name, valid_key in API_KEYS.items():
if secrets.compare_digest(token, valid_key):
logger.info(f"Valid API key used: {key_name}")
return key_name
logger.warning(f"Invalid API key attempted: {token[:10]}...")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key",
headers={"WWW-Authenticate": "Bearer"},
)
def validate_wix_signature(payload: bytes, signature: str, secret: str) -> bool:
"""Validate Wix webhook signature for additional security.
Wix signs their webhooks with HMAC-SHA256.
"""
if not signature or not secret:
return False
try:
# Remove 'sha256=' prefix if present
signature = signature.removeprefix("sha256=")
# Calculate expected signature
expected_signature = hmac.new(
secret.encode("utf-8"), payload, hashlib.sha256
).hexdigest()
# Compare signatures securely
return secrets.compare_digest(signature, expected_signature)
except Exception as e:
logger.exception(f"Error validating signature: {e}")
return False
class APIKeyAuth:
"""Simple API key authentication class."""
def __init__(self, api_keys: dict):
self.api_keys = api_keys
def authenticate(self, api_key: str) -> str | None:
"""Authenticate an API key and return the key name if valid."""
for key_name, valid_key in self.api_keys.items():
if secrets.compare_digest(api_key, valid_key):
return key_name
return None
def add_key(self, name: str, key: str):
"""Add a new API key."""
self.api_keys[name] = key
def remove_key(self, name: str):
"""Remove an API key."""
if name in self.api_keys:
del self.api_keys[name]
# Initialize auth system
auth_system = APIKeyAuth(API_KEYS)

View File

@@ -0,0 +1,331 @@
import os
from pathlib import Path
from annotatedyaml.loader import Secrets
from annotatedyaml.loader import load_yaml as load_annotated_yaml
from voluptuous import (
PREVENT_EXTRA,
All,
Boolean,
In,
Length,
MultipleInvalid,
Optional,
Range,
Required,
Schema,
)
from alpine_bits_python.const import (
CONF_ALPINE_BITS_AUTH,
CONF_DATABASE,
CONF_GOOGLE_ACCOUNT,
CONF_HOTEL_ID,
CONF_HOTEL_NAME,
CONF_LOGGING,
CONF_LOGGING_FILE,
CONF_LOGGING_LEVEL,
CONF_META_ACCOUNT,
CONF_PASSWORD,
CONF_PUSH_ENDPOINT,
CONF_PUSH_TOKEN,
CONF_PUSH_URL,
CONF_PUSH_USERNAME,
CONF_SERVER,
CONF_SERVER_CODE,
CONF_SERVER_CODECONTEXT,
CONF_SERVER_COMPANYNAME,
CONF_SERVER_RES_ID_SOURCE_CONTEXT,
CONF_USERNAME,
ENV_ALPINE_BITS_CONFIG_PATH,
)
# --- Voluptuous schemas ---
database_schema = Schema(
{Required("url"): str, Optional("schema"): str}, extra=PREVENT_EXTRA
)
logger_schema = Schema(
{
Required(CONF_LOGGING_LEVEL, default="INFO"): str,
Optional(CONF_LOGGING_FILE): str, # If not provided, log to console
},
extra=PREVENT_EXTRA,
)
def ensure_string(value):
"""Ensure the value is a string."""
if isinstance(value, str):
return value
return str(value)
server_info = Schema(
{
Required(CONF_SERVER_CODECONTEXT, default="ADVERTISING"): ensure_string,
Required(CONF_SERVER_CODE, default="70597314"): ensure_string,
Required(CONF_SERVER_COMPANYNAME, default="99tales Gmbh"): ensure_string,
Required(CONF_SERVER_RES_ID_SOURCE_CONTEXT, default="99tales"): ensure_string,
}
)
hotel_auth_schema = Schema(
{
Required(CONF_HOTEL_ID): ensure_string,
Required(CONF_HOTEL_NAME): str,
Required(CONF_USERNAME): str,
Required(CONF_PASSWORD): str,
Optional(CONF_META_ACCOUNT): str,
Optional(CONF_GOOGLE_ACCOUNT): str,
Optional(CONF_PUSH_ENDPOINT): {
Required(CONF_PUSH_URL): str,
Required(CONF_PUSH_TOKEN): str,
Optional(CONF_PUSH_USERNAME): str,
},
},
extra=PREVENT_EXTRA,
)
basic_auth_schema = Schema(All([hotel_auth_schema], Length(min=1)))
# Email SMTP configuration schema
smtp_schema = Schema(
{
Required("host", default="localhost"): str,
Required("port", default=587): Range(min=1, max=65535),
Optional("username"): str,
Optional("password"): str,
Required("use_tls", default=True): Boolean(),
Required("use_ssl", default=False): Boolean(),
},
extra=PREVENT_EXTRA,
)
# Email daily report configuration schema
daily_report_schema = Schema(
{
Required("enabled", default=False): Boolean(),
Optional("recipients", default=[]): [str],
Required("send_time", default="08:00"): str,
Required("include_stats", default=True): Boolean(),
Required("include_errors", default=True): Boolean(),
},
extra=PREVENT_EXTRA,
)
# Email error alerts configuration schema
error_alerts_schema = Schema(
{
Required("enabled", default=False): Boolean(),
Optional("recipients", default=[]): [str],
Required("error_threshold", default=5): Range(min=1),
Required("buffer_minutes", default=15): Range(min=1),
Required("cooldown_minutes", default=15): Range(min=0),
Required("log_levels", default=["ERROR", "CRITICAL"]): [
In(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"])
],
},
extra=PREVENT_EXTRA,
)
# Email monitoring configuration schema
monitoring_schema = Schema(
{
Optional("daily_report", default={}): daily_report_schema,
Optional("error_alerts", default={}): error_alerts_schema,
},
extra=PREVENT_EXTRA,
)
# Complete email configuration schema
email_schema = Schema(
{
Optional("smtp", default={}): smtp_schema,
Required("from_address", default="noreply@example.com"): str,
Required("from_name", default="AlpineBits Server"): str,
Optional("timeout", default=10): Range(min=1, max=300),
Optional("monitoring", default={}): monitoring_schema,
},
extra=PREVENT_EXTRA,
)
# Pushover daily report configuration schema
pushover_daily_report_schema = Schema(
{
Required("enabled", default=False): Boolean(),
Required("send_time", default="08:00"): str,
Required("include_stats", default=True): Boolean(),
Required("include_errors", default=True): Boolean(),
Required("priority", default=0): Range(
min=-2, max=2
), # Pushover priority levels
},
extra=PREVENT_EXTRA,
)
# Pushover error alerts configuration schema
pushover_error_alerts_schema = Schema(
{
Required("enabled", default=False): Boolean(),
Required("error_threshold", default=5): Range(min=1),
Required("buffer_minutes", default=15): Range(min=1),
Required("cooldown_minutes", default=15): Range(min=0),
Required("log_levels", default=["ERROR", "CRITICAL"]): [
In(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"])
],
Required("priority", default=1): Range(
min=-2, max=2
), # Pushover priority levels
},
extra=PREVENT_EXTRA,
)
# Pushover monitoring configuration schema
pushover_monitoring_schema = Schema(
{
Optional("daily_report", default={}): pushover_daily_report_schema,
Optional("error_alerts", default={}): pushover_error_alerts_schema,
},
extra=PREVENT_EXTRA,
)
# Complete pushover configuration schema
pushover_schema = Schema(
{
Optional("user_key"): str, # Optional but required for pushover to work
Optional("api_token"): str, # Optional but required for pushover to work
Optional("monitoring", default={}): pushover_monitoring_schema,
},
extra=PREVENT_EXTRA,
)
# Unified notification method schema
notification_method_schema = Schema(
{
Required("type"): In(["email", "pushover"]),
Optional("address"): str, # For email
Optional("priority"): Range(min=-2, max=2), # For pushover
},
extra=PREVENT_EXTRA,
)
# Unified notification recipient schema
notification_recipient_schema = Schema(
{
Required("name"): str,
Required("methods"): [notification_method_schema],
},
extra=PREVENT_EXTRA,
)
# Unified daily report configuration schema (without recipients)
unified_daily_report_schema = Schema(
{
Required("enabled", default=False): Boolean(),
Required("send_time", default="08:00"): str,
Required("include_stats", default=True): Boolean(),
Required("include_errors", default=True): Boolean(),
},
extra=PREVENT_EXTRA,
)
# Unified error alerts configuration schema (without recipients)
unified_error_alerts_schema = Schema(
{
Required("enabled", default=False): Boolean(),
Required("error_threshold", default=5): Range(min=1),
Required("buffer_minutes", default=15): Range(min=1),
Required("cooldown_minutes", default=15): Range(min=0),
Required("log_levels", default=["ERROR", "CRITICAL"]): [
In(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"])
],
},
extra=PREVENT_EXTRA,
)
# Unified notifications configuration schema
notifications_schema = Schema(
{
Required("recipients", default=[]): [notification_recipient_schema],
Optional("daily_report", default={}): unified_daily_report_schema,
Optional("error_alerts", default={}): unified_error_alerts_schema,
},
extra=PREVENT_EXTRA,
)
config_schema = Schema(
{
Required(CONF_DATABASE): database_schema,
Required(CONF_ALPINE_BITS_AUTH): basic_auth_schema,
Required(CONF_SERVER): server_info,
Required(CONF_LOGGING): logger_schema,
Optional("email"): email_schema, # Email is optional (service config only)
Optional(
"pushover"
): pushover_schema, # Pushover is optional (service config only)
Optional("notifications"): notifications_schema, # Unified notification config
Optional("api_tokens", default=[]): [str], # API tokens for bearer auth
},
extra=PREVENT_EXTRA,
)
DEFAULT_CONFIG_FILE = "config.yaml"
class Config:
"""Class to load and hold the configuration."""
def __init__(
self,
config_folder: str | Path | None = None,
config_name: str = DEFAULT_CONFIG_FILE,
testing_mode: bool = False,
):
if config_folder is None:
config_folder = os.environ.get(ENV_ALPINE_BITS_CONFIG_PATH)
if not config_folder:
config_folder = Path(__file__).parent.joinpath("../../config").resolve()
if isinstance(config_folder, str):
config_folder = Path(config_folder)
self.config_folder = config_folder
self.config_path = config_folder / config_name
self.secrets = Secrets(config_folder)
self.testing_mode = testing_mode
self._load_config()
def _load_config(self):
stuff = load_annotated_yaml(self.config_path, secrets=self.secrets)
try:
validated = config_schema(stuff)
except MultipleInvalid as e:
raise ValueError(f"Config validation error: {e}")
self.database = validated["database"]
self.basic_auth = validated["alpine_bits_auth"]
self.config = validated
def get(self, key, default=None):
return self.config.get(key, default)
@property
def db_url(self) -> str:
return self.database["url"]
@property
def hotel_id(self) -> str:
return self.basic_auth["hotel_id"]
@property
def hotel_name(self) -> str:
return self.basic_auth["hotel_name"]
@property
def users(self) -> list[dict[str, str]]:
return self.basic_auth["users"]
# For backward compatibility
def load_config():
return Config().config

View File

@@ -0,0 +1,47 @@
from enum import IntEnum
from typing import Final
class HttpStatusCode(IntEnum):
"""Allowed HTTP status codes for AlpineBits responses."""
OK = 200
BAD_REQUEST = 400
UNAUTHORIZED = 401
INTERNAL_SERVER_ERROR = 500
RESERVATION_ID_TYPE: str = (
"13" # Default reservation ID type for Reservation. 14 would be cancellation
)
CONF_LOGGING: Final[str] = "logger"
CONF_LOGGING_LEVEL: Final[str] = "level"
CONF_LOGGING_FILE: Final[str] = "file"
CONF_DATABASE: Final[str] = "database"
CONF_SERVER: Final[str] = "server"
CONF_SERVER_CODECONTEXT: Final[str] = "codecontext"
CONF_SERVER_CODE: Final[str] = "code"
CONF_SERVER_COMPANYNAME: Final[str] = "companyname"
CONF_SERVER_RES_ID_SOURCE_CONTEXT: Final[str] = "res_id_source_context"
CONF_ALPINE_BITS_AUTH: Final[str] = "alpine_bits_auth"
CONF_HOTEL_ID: Final[str] = "hotel_id"
CONF_HOTEL_NAME: Final[str] = "hotel_name"
CONF_USERNAME: Final[str] = "username"
CONF_PASSWORD: Final[str] = "password"
CONF_META_ACCOUNT: Final[str] = "meta_account"
CONF_GOOGLE_ACCOUNT: Final[str] = "google_account"
CONF_PUSH_ENDPOINT: Final[str] = "push_endpoint"
CONF_PUSH_URL: Final[str] = "url"
CONF_PUSH_TOKEN: Final[str] = "token"
CONF_PUSH_USERNAME: Final[str] = "username"
ENV_ALPINE_BITS_CONFIG_PATH: Final[str] = "ALPINE_BITS_CONFIG_DIR"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,294 @@
"""Customer service layer for handling customer and hashed customer operations."""
from datetime import UTC, datetime
from pydantic import ValidationError
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from .db import Customer, HashedCustomer
from .logging_config import get_logger
from .schemas import CustomerData
_LOGGER = get_logger(__name__)
class CustomerService:
"""Service for managing customers and their hashed versions.
Automatically maintains hashed customer data whenever customers are
created or updated, ensuring data is always in sync for Meta Conversion API.
"""
def __init__(self, session: AsyncSession):
self.session = session
async def create_customer(self, customer_data: dict) -> Customer:
"""Create a new customer and automatically create its hashed version.
Args:
customer_data: Dictionary containing customer fields
Returns:
The created Customer instance (with hashed_version relationship populated)
Raises:
ValidationError: If customer_data fails validation
(e.g., invalid country code)
"""
# Validate customer data through Pydantic model
validated_data = CustomerData(**customer_data)
# Create the customer with validated data
# Exclude 'phone_numbers' as Customer model uses 'phone' field
customer = Customer(
**validated_data.model_dump(exclude_none=True, exclude={"phone_numbers"})
)
# Set fields not in CustomerData model separately
if "contact_id" in customer_data:
customer.contact_id = customer_data["contact_id"]
if "phone" in customer_data:
customer.phone = customer_data["phone"]
self.session.add(customer)
await self.session.flush() # Flush to get the customer.id
# Create hashed version
hashed_customer = customer.create_hashed_customer()
hashed_customer.created_at = datetime.now(UTC)
self.session.add(hashed_customer)
await self.session.commit()
await self.session.refresh(customer)
return customer
async def update_customer(self, customer: Customer, update_data: dict) -> Customer:
"""Update an existing customer and sync its hashed version.
Args:
customer: The customer to update
update_data: Dictionary of fields to update
Returns:
The updated Customer instance
Raises:
ValidationError: If update_data fails validation
(e.g., invalid country code)
"""
# Validate update data through Pydantic model
# We need to merge with existing data for validation
existing_data = {
"given_name": customer.given_name,
"surname": customer.surname,
"name_prefix": customer.name_prefix,
"email_address": customer.email_address,
"phone": customer.phone,
"email_newsletter": customer.email_newsletter,
"address_line": customer.address_line,
"city_name": customer.city_name,
"postal_code": customer.postal_code,
"country_code": customer.country_code,
"gender": customer.gender,
"birth_date": customer.birth_date,
"language": customer.language,
"address_catalog": customer.address_catalog,
"name_title": customer.name_title,
}
# Merge update_data into existing_data (only CustomerData fields)
# Filter to include only fields that exist in CustomerData model
customer_data_fields = set(CustomerData.model_fields.keys())
# Include 'phone' field (maps to CustomerData)
existing_data.update(
{
k: v
for k, v in update_data.items()
if k in customer_data_fields or k == "phone"
}
)
# Validate merged data
validated_data = CustomerData(**existing_data)
# Update customer fields with validated data
# Exclude 'phone_numbers' as Customer model uses 'phone' field
# Note: We don't use exclude_none=True to allow setting fields to None
for key, value in validated_data.model_dump(exclude={"phone_numbers"}).items():
if hasattr(customer, key):
setattr(customer, key, value)
# Update fields not in CustomerData model separately
if "contact_id" in update_data:
customer.contact_id = update_data["contact_id"]
if "phone" in update_data:
customer.phone = update_data["phone"]
# Update or create hashed version
result = await self.session.execute(
select(HashedCustomer).where(HashedCustomer.customer_id == customer.id)
)
hashed_customer = result.scalar_one_or_none()
if hashed_customer:
# Update existing hashed customer
new_hashed = customer.create_hashed_customer()
hashed_customer.hashed_email = new_hashed.hashed_email
hashed_customer.hashed_phone = new_hashed.hashed_phone
hashed_customer.hashed_given_name = new_hashed.hashed_given_name
hashed_customer.hashed_surname = new_hashed.hashed_surname
hashed_customer.hashed_city = new_hashed.hashed_city
hashed_customer.hashed_postal_code = new_hashed.hashed_postal_code
hashed_customer.hashed_country_code = new_hashed.hashed_country_code
hashed_customer.hashed_gender = new_hashed.hashed_gender
hashed_customer.hashed_birth_date = new_hashed.hashed_birth_date
else:
# Create new hashed customer if it doesn't exist
hashed_customer = customer.create_hashed_customer()
hashed_customer.created_at = datetime.now(UTC)
self.session.add(hashed_customer)
await self.session.commit()
await self.session.refresh(customer)
return customer
async def get_customer_by_contact_id(self, contact_id: str) -> Customer | None:
"""Get a customer by contact_id.
Args:
contact_id: The contact_id to search for
Returns:
Customer instance if found, None otherwise
"""
result = await self.session.execute(
select(Customer).where(Customer.contact_id == contact_id)
)
return result.scalar_one_or_none()
async def get_or_create_customer(self, customer_data: dict) -> Customer:
"""Get existing customer or create new one if not found.
Uses contact_id to identify existing customers if provided.
Args:
customer_data: Dictionary containing customer fields
(contact_id is optional)
Returns:
Existing or newly created Customer instance
"""
contact_id = customer_data.get("contact_id")
if contact_id:
existing = await self.get_customer_by_contact_id(contact_id)
if existing:
# Update existing customer
return await self.update_customer(existing, customer_data)
# Create new customer (either no contact_id or customer doesn't exist)
return await self.create_customer(customer_data)
async def get_hashed_customer(self, customer_id: int) -> HashedCustomer | None:
"""Get the hashed version of a customer.
Args:
customer_id: The customer ID
Returns:
HashedCustomer instance if found, None otherwise
"""
result = await self.session.execute(
select(HashedCustomer).where(HashedCustomer.customer_id == customer_id)
)
return result.scalar_one_or_none()
async def hash_existing_customers(self) -> int:
"""Hash all existing customers that don't have a hashed version yet.
This is useful for backfilling hashed data for customers created
before the hashing system was implemented.
Also validates and sanitizes customer data (e.g., normalizes country
codes to uppercase). Customers with invalid data that cannot be fixed
will be skipped and logged.
Returns:
Number of customers that were hashed
"""
# Get all customers
result = await self.session.execute(select(Customer))
customers = result.scalars().all()
hashed_count = 0
skipped_count = 0
for customer in customers:
# Check if this customer already has a hashed version
existing_hashed = await self.get_hashed_customer(customer.id)
if not existing_hashed:
# Validate and sanitize customer data before hashing
customer_dict = {
"given_name": customer.given_name,
"surname": customer.surname,
"name_prefix": customer.name_prefix,
"email_address": customer.email_address,
"phone": customer.phone,
"email_newsletter": customer.email_newsletter,
"address_line": customer.address_line,
"city_name": customer.city_name,
"postal_code": customer.postal_code,
"country_code": customer.country_code,
"gender": customer.gender,
"birth_date": customer.birth_date,
"language": customer.language,
"address_catalog": customer.address_catalog,
"name_title": customer.name_title,
}
try:
# Validate through Pydantic (normalizes country code)
validated = CustomerData(**customer_dict)
# Update customer with sanitized data
# Exclude 'phone_numbers' as Customer model uses 'phone' field
for key, value in validated.model_dump(
exclude_none=True, exclude={"phone_numbers"}
).items():
if hasattr(customer, key):
setattr(customer, key, value)
# Create hashed version with sanitized data
hashed_customer = customer.create_hashed_customer()
hashed_customer.created_at = datetime.now(UTC)
self.session.add(hashed_customer)
hashed_count += 1
except ValidationError as e:
# Skip customers with invalid data and log
skipped_count += 1
_LOGGER.warning(
"Skipping customer ID %s due to validation error: %s",
customer.id,
e,
)
if hashed_count > 0:
await self.session.commit()
if skipped_count > 0:
_LOGGER.warning(
"Skipped %d customers with invalid data. "
"Please fix these customers manually.",
skipped_count,
)
return hashed_count

View File

@@ -0,0 +1,478 @@
import asyncio
import hashlib
import os
from typing import Any, AsyncGenerator, Callable, TypeVar
from sqlalchemy import Boolean, Column, Date, DateTime, ForeignKey, Integer, String, JSON
from sqlalchemy.exc import DBAPIError
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import declarative_base, relationship
from .logging_config import get_logger
_LOGGER = get_logger(__name__)
Base = declarative_base()
# Type variable for async functions
T = TypeVar("T")
# Maximum number of retries for session operations
MAX_RETRIES = 3
# Delay between retries in seconds
RETRY_DELAY = 0.5
# Async SQLAlchemy setup
def get_database_url(config=None):
db_url = None
if config and "database" in config and "url" in config["database"]:
db_url = config["database"]["url"]
if not db_url:
db_url = os.environ.get("DATABASE_URL")
if not db_url:
db_url = "sqlite+aiosqlite:///alpinebits.db"
return db_url
def get_database_schema(config=None):
"""Get the PostgreSQL schema name from config.
Args:
config: Configuration dictionary
Returns:
Schema name string, or None if not configured
"""
if config and "database" in config and "schema" in config["database"]:
return config["database"]["schema"]
return os.environ.get("DATABASE_SCHEMA")
def configure_schema(schema_name=None):
"""Configure the database schema for all models.
This should be called before creating tables or running migrations.
For PostgreSQL, this sets the schema for all tables.
For other databases, this is a no-op.
Args:
schema_name: Name of the schema to use (e.g., "alpinebits")
"""
if schema_name:
# Update the schema for all tables in Base metadata
for table in Base.metadata.tables.values():
table.schema = schema_name
def create_database_engine(config=None, echo=False) -> AsyncEngine:
"""Create a configured database engine with schema support.
This function:
1. Gets the database URL from config
2. Gets the schema name (if configured)
3. Configures all models to use the schema
4. Creates the async engine with appropriate connect_args for PostgreSQL
Args:
config: Configuration dictionary
echo: Whether to echo SQL statements (default: False)
Returns:
Configured AsyncEngine instance
"""
database_url = get_database_url(config)
schema_name = get_database_schema(config)
# Configure schema for all models if specified
if schema_name:
configure_schema(schema_name)
_LOGGER.info("Configured database schema: %s", schema_name)
# Create engine with connect_args to set search_path for PostgreSQL
connect_args = {}
if schema_name and "postgresql" in database_url:
connect_args = {
"server_settings": {"search_path": f"{schema_name},public"}
}
_LOGGER.info("Setting PostgreSQL search_path to: %s,public", schema_name)
return create_async_engine(database_url, echo=echo, connect_args=connect_args)
class ResilientAsyncSession:
"""Wrapper around AsyncSession that handles connection recovery.
This wrapper automatically retries operations on connection loss or OID errors,
disposing the connection pool and creating a fresh session on failure.
"""
def __init__(
self,
async_sessionmaker_: async_sessionmaker[AsyncSession],
engine: AsyncEngine,
):
"""Initialize the resilient session wrapper.
Args:
async_sessionmaker_: Factory for creating async sessions
engine: The SQLAlchemy async engine for connection recovery
"""
self.async_sessionmaker = async_sessionmaker_
self.engine = engine
async def execute_with_retry(
self, func: Callable[..., T], *args, **kwargs
) -> T:
"""Execute a function with automatic retry on connection errors.
Args:
func: Async function that takes a session as first argument
*args: Positional arguments to pass to func (first arg should be session)
**kwargs: Keyword arguments to pass to func
Returns:
Result of the function call
Raises:
The original exception if all retries are exhausted
"""
last_error = None
for attempt in range(MAX_RETRIES):
try:
async with self.async_sessionmaker() as session:
return await func(session, *args, **kwargs)
except DBAPIError as e:
last_error = e
error_msg = str(e).lower()
# Check if this is an OID error or connection loss
if (
"could not open relation" in error_msg
or "lost connection" in error_msg
or "connection closed" in error_msg
or "connection refused" in error_msg
):
_LOGGER.warning(
"Connection error on attempt %d/%d: %s. Disposing pool and retrying...",
attempt + 1,
MAX_RETRIES,
e.__class__.__name__,
)
# Dispose the entire connection pool to force new connections
await self.engine.dispose()
# Wait before retry (exponential backoff)
if attempt < MAX_RETRIES - 1:
wait_time = RETRY_DELAY * (2 ** attempt)
await asyncio.sleep(wait_time)
else:
# Not a connection-related error, re-raise immediately
raise
except Exception:
# Any other exception, re-raise immediately
raise
# All retries exhausted
_LOGGER.error(
"Failed to execute query after %d retries: %s",
MAX_RETRIES,
last_error.__class__.__name__,
)
raise last_error
class SessionMaker:
"""Factory for creating independent AsyncSession instances.
This class enables concurrent processing by allowing each task to create
and manage its own database session. Useful for processing large datasets
where concurrent execution is desired but each concurrent task needs its own
database transaction context.
"""
def __init__(self, async_sessionmaker_: async_sessionmaker[AsyncSession]):
"""Initialize the SessionMaker.
Args:
async_sessionmaker_: SQLAlchemy async_sessionmaker factory
"""
self.async_sessionmaker = async_sessionmaker_
async def create_session(self) -> AsyncSession:
"""Create a new independent AsyncSession.
Returns:
A new AsyncSession instance ready for use. Caller is responsible
for managing the session lifecycle (closing when done).
"""
return self.async_sessionmaker()
async def get_resilient_session(
resilient_session: "ResilientAsyncSession",
) -> AsyncGenerator[AsyncSession, None]:
"""Dependency for FastAPI that provides a resilient async session.
This generator creates a new session with automatic retry capability
on connection errors. Used as a dependency in FastAPI endpoints.
Args:
resilient_session: ResilientAsyncSession instance from app state
Yields:
AsyncSession instance for database operations
"""
async with resilient_session.async_sessionmaker() as session:
yield session
class Customer(Base):
__tablename__ = "customers"
id = Column(Integer, primary_key=True)
given_name = Column(String)
contact_id = Column(String, unique=True)
surname = Column(String)
name_prefix = Column(String)
email_address = Column(String)
phone = Column(String)
email_newsletter = Column(Boolean)
address_line = Column(String)
city_name = Column(String)
postal_code = Column(String)
country_code = Column(String)
gender = Column(String)
birth_date = Column(String)
language = Column(String)
address_catalog = Column(Boolean) # Added for XML
name_title = Column(String) # Added for XML
reservations = relationship("Reservation", back_populates="customer")
@staticmethod
def _normalize_and_hash(value):
"""Normalize and hash a value according to Meta Conversion API requirements."""
if not value:
return None
# Normalize: lowercase, strip whitespace
normalized = str(value).lower().strip()
# Remove spaces for phone numbers
is_phone = (
normalized.startswith("+")
or normalized.replace("-", "").replace(" ", "").isdigit()
)
if is_phone:
chars_to_remove = [" ", "-", "(", ")"]
for char in chars_to_remove:
normalized = normalized.replace(char, "")
# SHA256 hash
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
def create_hashed_customer(self):
"""Create a HashedCustomer instance from this Customer."""
return HashedCustomer(
customer_id=self.id,
contact_id=self.contact_id,
hashed_email=self._normalize_and_hash(self.email_address),
hashed_phone=self._normalize_and_hash(self.phone),
hashed_given_name=self._normalize_and_hash(self.given_name),
hashed_surname=self._normalize_and_hash(self.surname),
hashed_city=self._normalize_and_hash(self.city_name),
hashed_postal_code=self._normalize_and_hash(self.postal_code),
hashed_country_code=self._normalize_and_hash(self.country_code),
hashed_gender=self._normalize_and_hash(self.gender),
hashed_birth_date=self._normalize_and_hash(self.birth_date),
)
class HashedCustomer(Base):
"""Hashed customer data for Meta Conversion API.
Stores SHA256 hashed versions of customer PII according to Meta's requirements.
This allows sending conversion events without exposing raw customer data.
"""
__tablename__ = "hashed_customers"
id = Column(Integer, primary_key=True)
customer_id = Column(
Integer, ForeignKey("customers.id"), unique=True, nullable=False
)
contact_id = Column(String, unique=True) # Keep unhashed for reference
hashed_email = Column(String(64)) # SHA256 produces 64 hex chars
hashed_phone = Column(String(64))
hashed_given_name = Column(String(64))
hashed_surname = Column(String(64))
hashed_city = Column(String(64))
hashed_postal_code = Column(String(64))
hashed_country_code = Column(String(64))
hashed_gender = Column(String(64))
hashed_birth_date = Column(String(64))
created_at = Column(DateTime(timezone=True))
customer = relationship("Customer", backref="hashed_version")
class Reservation(Base):
__tablename__ = "reservations"
id = Column(Integer, primary_key=True)
customer_id = Column(Integer, ForeignKey("customers.id"))
unique_id = Column(String, unique=True)
md5_unique_id = Column(String(32), unique=True) # max length 32 guaranteed
start_date = Column(Date)
end_date = Column(Date)
num_adults = Column(Integer)
num_children = Column(Integer)
children_ages = Column(String) # comma-separated
offer = Column(String)
created_at = Column(DateTime(timezone=True))
# Add all UTM fields and user comment for XML
utm_source = Column(String)
utm_medium = Column(String)
utm_campaign = Column(String)
utm_term = Column(String)
utm_content = Column(String)
user_comment = Column(String)
fbclid = Column(String)
gclid = Column(String)
# Advertising account IDs (stored conditionally based on fbclid/gclid presence)
meta_account_id = Column(String)
google_account_id = Column(String)
# Add hotel_code and hotel_name for XML
hotel_code = Column(String)
hotel_name = Column(String)
# RoomTypes fields (optional)
room_type_code = Column(String)
room_classification_code = Column(String)
room_type = Column(String)
customer = relationship("Customer", back_populates="reservations")
# Table for tracking acknowledged requests by client
class AckedRequest(Base):
__tablename__ = "acked_requests"
id = Column(Integer, primary_key=True)
client_id = Column(String, index=True)
username = Column(String, index=True, nullable=True) # Username of the client making the request
unique_id = Column(
String, index=True
) # Should match Reservation.form_id or another unique field
timestamp = Column(DateTime(timezone=True))
class Conversion(Base):
"""Conversion data from hotel PMS.
Represents a single reservation event from the PMS XML with all its metadata.
Each row links to one reservation from the PMS system. A reservation can have
multiple room reservations (stored in RoomReservation table).
Linked to reservations via advertising tracking data (fbclid, gclid, etc)
stored in advertisingCampagne field.
"""
__tablename__ = "conversions"
id = Column(Integer, primary_key=True)
# Link to reservation (nullable since matching may not always work)
reservation_id = Column(
Integer, ForeignKey("reservations.id"), nullable=True, index=True
)
customer_id = Column(Integer, ForeignKey("customers.id"), nullable=True, index=True)
hashed_customer_id = Column(
Integer, ForeignKey("hashed_customers.id"), nullable=True, index=True
)
# Reservation metadata from XML
hotel_id = Column(String, index=True) # hotelID attribute
pms_reservation_id = Column(String, index=True) # id attribute from reservation
reservation_number = Column(String) # number attribute
reservation_date = Column(Date) # date attribute (when reservation was made)
creation_time = Column(DateTime(timezone=True)) # creationTime attribute
reservation_type = Column(String) # type attribute (e.g., "reservation")
booking_channel = Column(String) # bookingChannel attribute
# Guest information from reservation XML - used for matching
guest_first_name = Column(String, index=True) # firstName from guest element
guest_last_name = Column(String, index=True) # lastName from guest element
guest_email = Column(String, index=True) # email from guest element
guest_country_code = Column(String) # countryCode from guest element
# Advertising/tracking data - used for matching to existing reservations
advertising_medium = Column(
String, index=True
) # advertisingMedium (e.g., "99TALES")
advertising_partner = Column(
String, index=True
) # advertisingPartner (e.g., "cpc", "website")
advertising_campagne = Column(
String, index=True
) # advertisingCampagne (contains fbclid/gclid)
# Metadata
created_at = Column(DateTime(timezone=True)) # When this record was imported
updated_at = Column(DateTime(timezone=True)) # When this record was last updated
# Relationships
reservation = relationship("Reservation", backref="conversions")
customer = relationship("Customer", backref="conversions")
hashed_customer = relationship("HashedCustomer", backref="conversions")
room_reservations = relationship(
"RoomReservation", back_populates="conversion", cascade="all, delete-orphan"
)
class RoomReservation(Base):
"""Room reservation data from hotel PMS.
Represents a single room reservation within a conversion/PMS reservation.
One conversion can have multiple room reservations (e.g., customer books 3 rooms).
Daily sales are stored as a JSON blob with an extracted total_revenue field
for efficient querying.
"""
__tablename__ = "room_reservations"
id = Column(Integer, primary_key=True)
# Link to the parent conversion/PMS reservation
conversion_id = Column(
Integer, ForeignKey("conversions.id"), nullable=False, index=True
)
# Identifier for this room reservation (for upserts)
# Composite: pms_reservation_id + room_number
# Note: Not globally unique - same room number can exist across different hotels
pms_hotel_reservation_id = Column(String, index=True)
# Room reservation details
arrival_date = Column(Date, index=True) # arrival attribute
departure_date = Column(Date, index=True) # departure attribute
room_status = Column(String) # status attribute (e.g., "reserved", "departed")
room_type = Column(String) # roomType attribute (e.g., "VDS", "EZR")
room_number = Column(String, index=True) # roomNumber attribute
num_adults = Column(Integer) # adults attribute
rate_plan_code = Column(String) # ratePlanCode attribute
connected_room_type = Column(String) # connectedRoomType attribute
# Daily sales data stored as JSON
# Format: [
# {"date": "2021-10-09", "revenueTotal": "13.6", "revenueOther": "13.6"},
# {"date": "2021-10-10", "revenueTotal": "306.1", "revenueLogis": "254", ...},
# ...
# ]
daily_sales = Column(JSON, nullable=True) # JSON array of daily sales
# Extracted total revenue for efficient querying (sum of all revenue_total in daily_sales)
# Kept as string to preserve decimal precision
total_revenue = Column(String, nullable=True)
# Metadata
created_at = Column(DateTime(timezone=True)) # When this record was imported
updated_at = Column(DateTime(timezone=True)) # When this record was last updated
# Relationships
conversion = relationship("Conversion", back_populates="room_reservations")

View File

@@ -0,0 +1,571 @@
"""Email monitoring and alerting through logging integration.
This module provides a custom logging handler that accumulates errors and sends
email alerts based on configurable thresholds and time windows.
"""
import asyncio
import logging
import threading
from collections import defaultdict, deque
from datetime import datetime, timedelta
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import async_sessionmaker
from .db import Reservation
from .email_service import EmailService
from .logging_config import get_logger
_LOGGER = get_logger(__name__)
class ErrorRecord:
"""Represents a single error log record for monitoring.
Attributes:
timestamp: When the error occurred
level: Log level (ERROR, CRITICAL, etc.)
logger_name: Name of the logger that generated the error
message: The error message
exception: Exception info if available
module: Module where error occurred
line_no: Line number where error occurred
"""
def __init__(self, record: logging.LogRecord):
"""Initialize from a logging.LogRecord.
Args:
record: The logging record to wrap
"""
self.timestamp = datetime.fromtimestamp(record.created)
self.level = record.levelname
self.logger_name = record.name
self.message = record.getMessage()
self.exception = record.exc_text if record.exc_info else None
self.module = record.module
self.line_no = record.lineno
self.pathname = record.pathname
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary format.
Returns:
Dictionary representation of the error
"""
return {
"timestamp": self.timestamp.strftime("%Y-%m-%d %H:%M:%S"),
"level": self.level,
"logger_name": self.logger_name,
"message": self.message,
"exception": self.exception,
"module": self.module,
"line_no": self.line_no,
"pathname": self.pathname,
}
def format_plain_text(self) -> str:
"""Format error as plain text for email.
Returns:
Formatted plain text string
"""
text = f"[{self.timestamp.strftime('%Y-%m-%d %H:%M:%S')}] {self.level}: {self.message}\n"
text += f" Module: {self.module}:{self.line_no} ({self.logger_name})\n"
if self.exception:
text += f" Exception:\n{self.exception}\n"
return text
class EmailAlertHandler(logging.Handler):
"""Custom logging handler that sends email alerts for errors.
This handler uses a hybrid approach:
- Accumulates errors in a buffer
- Sends immediately if error threshold is reached
- Otherwise sends after buffer duration expires
- Always sends buffered errors (no minimum threshold for time-based flush)
- Implements cooldown to prevent alert spam
The handler is thread-safe and works with asyncio event loops.
"""
def __init__(
self,
email_service: EmailService,
config: dict[str, Any],
loop: asyncio.AbstractEventLoop | None = None,
):
"""Initialize the email alert handler.
Args:
email_service: Email service instance for sending alerts
config: Configuration dictionary for error alerts
loop: Asyncio event loop (will use current loop if not provided)
"""
super().__init__()
self.email_service = email_service
self.config = config
self.loop = loop # Will be set when first error occurs if not provided
# Configuration
self.recipients = config.get("recipients", [])
self.error_threshold = config.get("error_threshold", 5)
self.buffer_minutes = config.get("buffer_minutes", 15)
self.cooldown_minutes = config.get("cooldown_minutes", 15)
self.log_levels = config.get("log_levels", ["ERROR", "CRITICAL"])
# State
self.error_buffer: deque[ErrorRecord] = deque()
self.last_sent = datetime.min # Last time we sent an alert
self._flush_task: asyncio.Task | None = None
self._lock = threading.Lock() # Thread-safe for multi-threaded logging
_LOGGER.info(
"EmailAlertHandler initialized: threshold=%d, buffer=%dmin, cooldown=%dmin",
self.error_threshold,
self.buffer_minutes,
self.cooldown_minutes,
)
def emit(self, record: logging.LogRecord) -> None:
"""Handle a log record.
This is called automatically by the logging system when an error is logged.
It's important that this method is fast and doesn't block.
Args:
record: The log record to handle
"""
# Only handle configured log levels
if record.levelname not in self.log_levels:
return
try:
# Ensure we have an event loop
if self.loop is None:
try:
self.loop = asyncio.get_running_loop()
except RuntimeError:
# No running loop, we'll need to handle this differently
_LOGGER.warning("No asyncio event loop available for email alerts")
return
# Add error to buffer (thread-safe)
with self._lock:
error_record = ErrorRecord(record)
self.error_buffer.append(error_record)
buffer_size = len(self.error_buffer)
# Determine if we should send immediately
should_send_immediately = buffer_size >= self.error_threshold
if should_send_immediately:
# Cancel any pending flush task
if self._flush_task and not self._flush_task.done():
self._flush_task.cancel()
# Schedule immediate flush
self._flush_task = asyncio.run_coroutine_threadsafe(
self._flush_buffer(immediate=True),
self.loop,
)
# Schedule delayed flush if not already scheduled
elif not self._flush_task or self._flush_task.done():
self._flush_task = asyncio.run_coroutine_threadsafe(
self._schedule_delayed_flush(),
self.loop,
)
except Exception:
# Never let the handler crash - just log and continue
_LOGGER.exception("Error in EmailAlertHandler.emit")
async def _schedule_delayed_flush(self) -> None:
"""Schedule a delayed buffer flush after buffer duration."""
await asyncio.sleep(self.buffer_minutes * 60)
await self._flush_buffer(immediate=False)
async def _flush_buffer(self, *, immediate: bool) -> None:
"""Flush the error buffer and send email alert.
Args:
immediate: Whether this is an immediate flush (threshold hit)
"""
# Check cooldown period
now = datetime.now()
time_since_last = (now - self.last_sent).total_seconds() / 60
if time_since_last < self.cooldown_minutes:
_LOGGER.info(
"Alert cooldown active (%.1f min remaining), buffering errors",
self.cooldown_minutes - time_since_last,
)
# Don't clear buffer - let errors accumulate until cooldown expires
return
# Get all buffered errors (thread-safe)
with self._lock:
if not self.error_buffer:
return
errors = list(self.error_buffer)
self.error_buffer.clear()
# Update last sent time
self.last_sent = now
# Format email
error_count = len(errors)
time_range = (
f"{errors[0].timestamp.strftime('%H:%M:%S')} to "
f"{errors[-1].timestamp.strftime('%H:%M:%S')}"
)
# Determine alert type for subject
alert_type = "Immediate Alert" if immediate else "Scheduled Alert"
if immediate:
emoji = "🚨"
reason = f"(threshold of {self.error_threshold} exceeded)"
else:
emoji = "⚠️"
reason = f"({self.buffer_minutes} minute buffer)"
subject = (
f"{emoji} AlpineBits Error {alert_type}: {error_count} errors {reason}"
)
# Build plain text body
body = f"Error Alert - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
body += "=" * 70 + "\n\n"
body += f"Alert Type: {alert_type}\n"
body += f"Error Count: {error_count}\n"
body += f"Time Range: {time_range}\n"
body += f"Reason: {reason}\n"
body += "\n" + "=" * 70 + "\n\n"
# Add individual errors
body += "Errors:\n"
body += "-" * 70 + "\n\n"
for error in errors:
body += error.format_plain_text()
body += "\n"
body += "-" * 70 + "\n"
body += f"Generated by AlpineBits Email Monitoring at {now.strftime('%Y-%m-%d %H:%M:%S')}\n"
# Send email
try:
success = await self.email_service.send_alert(
recipients=self.recipients,
subject=subject,
body=body,
)
if success:
_LOGGER.info(
"Email alert sent successfully: %d errors to %s",
error_count,
self.recipients,
)
else:
_LOGGER.error("Failed to send email alert for %d errors", error_count)
except Exception:
_LOGGER.exception("Exception while sending email alert")
def close(self) -> None:
"""Close the handler and flush any remaining errors.
This is called when the logging system shuts down.
"""
# Cancel any pending flush tasks
if self._flush_task and not self._flush_task.done():
self._flush_task.cancel()
# Flush any remaining errors immediately
if self.error_buffer and self.loop:
try:
# Check if the loop is still running
if not self.loop.is_closed():
future = asyncio.run_coroutine_threadsafe(
self._flush_buffer(immediate=False),
self.loop,
)
future.result(timeout=5)
else:
_LOGGER.warning(
"Event loop closed, cannot flush %d remaining errors",
len(self.error_buffer),
)
except Exception:
_LOGGER.exception("Error flushing buffer on close")
super().close()
class DailyReportScheduler:
"""Scheduler for sending daily reports at configured times.
This runs as a background task and sends daily reports containing
statistics and error summaries.
"""
def __init__(
self,
email_service: EmailService,
config: dict[str, Any],
):
"""Initialize the daily report scheduler.
Args:
email_service: Email service for sending reports
config: Configuration for daily reports
"""
self.email_service = email_service
self.config = config
self.recipients = config.get("recipients", [])
self.send_time = config.get("send_time", "08:00") # Default 8 AM
self.include_stats = config.get("include_stats", True)
self.include_errors = config.get("include_errors", True)
self._task: asyncio.Task | None = None
self._stats_collector = None # Will be set by application
self._error_log: list[dict[str, Any]] = []
_LOGGER.info(
"DailyReportScheduler initialized: send_time=%s, recipients=%s",
self.send_time,
self.recipients,
)
def start(self) -> None:
"""Start the daily report scheduler."""
if self._task is None or self._task.done():
self._task = asyncio.create_task(self._run())
_LOGGER.info("Daily report scheduler started")
def stop(self) -> None:
"""Stop the daily report scheduler."""
if self._task and not self._task.done():
self._task.cancel()
_LOGGER.info("Daily report scheduler stopped")
def log_error(self, error: dict[str, Any]) -> None:
"""Log an error for inclusion in daily report.
Args:
error: Error information dictionary
"""
self._error_log.append(error)
async def _run(self) -> None:
"""Run the daily report scheduler loop."""
while True:
try:
# Calculate time until next report
now = datetime.now()
target_hour, target_minute = map(int, self.send_time.split(":"))
# Calculate next send time
next_send = now.replace(
hour=target_hour,
minute=target_minute,
second=0,
microsecond=0,
)
# If time has passed today, schedule for tomorrow
if next_send <= now:
next_send += timedelta(days=1)
# Calculate sleep duration
sleep_seconds = (next_send - now).total_seconds()
_LOGGER.info(
"Next daily report scheduled for %s (in %.1f hours)",
next_send.strftime("%Y-%m-%d %H:%M:%S"),
sleep_seconds / 3600,
)
# Wait until send time
await asyncio.sleep(sleep_seconds)
# Send report
await self._send_report()
except asyncio.CancelledError:
_LOGGER.info("Daily report scheduler cancelled")
break
except Exception:
_LOGGER.exception("Error in daily report scheduler")
# Sleep a bit before retrying
await asyncio.sleep(60)
async def _send_report(self) -> None:
"""Send the daily report."""
stats = {}
# Collect statistics if enabled
if self.include_stats and self._stats_collector:
try:
stats = await self._stats_collector()
except Exception:
_LOGGER.exception("Error collecting statistics for daily report")
# Get errors if enabled
errors = self._error_log.copy() if self.include_errors else None
# Send report
try:
success = await self.email_service.send_daily_report(
recipients=self.recipients,
stats=stats,
errors=errors,
)
if success:
_LOGGER.info("Daily report sent successfully to %s", self.recipients)
# Clear error log after successful send
self._error_log.clear()
else:
_LOGGER.error("Failed to send daily report")
except Exception:
_LOGGER.exception("Exception while sending daily report")
def set_stats_collector(self, collector) -> None:
"""Set the statistics collector function.
Args:
collector: Async function that returns statistics dictionary
"""
self._stats_collector = collector
class ReservationStatsCollector:
"""Collects reservation statistics per hotel for daily reports.
This collector queries the database for reservations created since the last
report and aggregates them by hotel. It includes hotel_code and hotel_name
from the configuration.
"""
def __init__(
self,
async_sessionmaker: async_sessionmaker,
config: dict[str, Any],
):
"""Initialize the stats collector.
Args:
async_sessionmaker: SQLAlchemy async session maker
config: Application configuration containing hotel information
"""
self.async_sessionmaker = async_sessionmaker
self.config = config
self._last_report_time = datetime.now()
# Build hotel mapping from config
self._hotel_map = {}
for hotel in config.get("alpine_bits_auth", []):
hotel_id = hotel.get("hotel_id")
hotel_name = hotel.get("hotel_name")
if hotel_id:
self._hotel_map[hotel_id] = hotel_name or "Unknown Hotel"
_LOGGER.info(
"ReservationStatsCollector initialized with %d hotels",
len(self._hotel_map),
)
async def collect_stats(self, lookback_hours: int | None = None) -> dict[str, Any]:
"""Collect reservation statistics for the reporting period.
Args:
lookback_hours: Optional override to look back N hours from now.
If None, uses time since last report.
Returns:
Dictionary with statistics including reservations per hotel
"""
now = datetime.now()
if lookback_hours is not None:
# Override mode: look back N hours from now
period_start = now - timedelta(hours=lookback_hours)
period_end = now
else:
# Normal mode: since last report
period_start = self._last_report_time
period_end = now
_LOGGER.info(
"Collecting reservation stats from %s to %s",
period_start.strftime("%Y-%m-%d %H:%M:%S"),
period_end.strftime("%Y-%m-%d %H:%M:%S"),
)
async with self.async_sessionmaker() as session:
# Query reservations created in the reporting period
result = await session.execute(
select(Reservation.hotel_code, func.count(Reservation.id))
.where(Reservation.created_at >= period_start)
.where(Reservation.created_at < period_end)
.group_by(Reservation.hotel_code)
)
hotel_counts = dict(result.all())
# Build stats with hotel names from config
hotels_stats = []
total_reservations = 0
for hotel_code, count in hotel_counts.items():
hotel_name = self._hotel_map.get(hotel_code, "Unknown Hotel")
hotels_stats.append(
{
"hotel_code": hotel_code,
"hotel_name": hotel_name,
"reservations": count,
}
)
total_reservations += count
# Sort by reservation count descending
hotels_stats.sort(key=lambda x: x["reservations"], reverse=True)
# Update last report time only in normal mode (not lookback mode)
if lookback_hours is None:
self._last_report_time = now
stats = {
"reporting_period": {
"start": period_start.strftime("%Y-%m-%d %H:%M:%S"),
"end": period_end.strftime("%Y-%m-%d %H:%M:%S"),
},
"total_reservations": total_reservations,
"hotels": hotels_stats,
}
_LOGGER.info(
"Collected stats: %d total reservations across %d hotels",
total_reservations,
len(hotels_stats),
)
return stats

View File

@@ -0,0 +1,373 @@
"""Email service for sending alerts and reports.
This module provides email functionality for the AlpineBits application,
including error alerts and daily reports.
"""
import asyncio
import smtplib
import ssl
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Any
from pydantic import EmailStr, Field, field_validator
from .logging_config import get_logger
_LOGGER = get_logger(__name__)
class EmailConfig:
"""Configuration for email service.
Attributes:
smtp_host: SMTP server hostname
smtp_port: SMTP server port
smtp_username: SMTP authentication username
smtp_password: SMTP authentication password
use_tls: Use STARTTLS for encryption
use_ssl: Use SSL/TLS from the start
from_address: Sender email address
from_name: Sender display name
timeout: Connection timeout in seconds
"""
def __init__(self, config: dict[str, Any]):
"""Initialize email configuration from config dict.
Args:
config: Email configuration dictionary
"""
smtp_config = config.get("smtp", {})
self.smtp_host: str = smtp_config.get("host", "localhost")
self.smtp_port: int = smtp_config.get("port", 587)
self.smtp_username: str | None = smtp_config.get("username")
self.smtp_password: str | None = smtp_config.get("password")
self.use_tls: bool = smtp_config.get("use_tls", True)
self.use_ssl: bool = smtp_config.get("use_ssl", False)
self.from_address: str = config.get("from_address", "noreply@example.com")
self.from_name: str = config.get("from_name", "AlpineBits Server")
self.timeout: int = config.get("timeout", 10)
# Validate configuration
if self.use_tls and self.use_ssl:
msg = "Cannot use both TLS and SSL"
raise ValueError(msg)
class EmailService:
"""Service for sending emails via SMTP.
This service handles sending both plain text and HTML emails,
with support for TLS/SSL encryption and authentication.
"""
def __init__(self, config: EmailConfig):
"""Initialize email service.
Args:
config: Email configuration
"""
self.config = config
# Create dedicated thread pool for SMTP operations (max 2 threads is enough for email)
# This prevents issues with default executor in multi-process environments
self._executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="smtp-")
async def send_email(
self,
recipients: list[str],
subject: str,
body: str,
html_body: str | None = None,
) -> bool:
"""Send an email to recipients.
Args:
recipients: List of recipient email addresses
subject: Email subject line
body: Plain text email body
html_body: Optional HTML email body
Returns:
True if email was sent successfully, False otherwise
"""
if not recipients:
_LOGGER.warning("No recipients specified for email: %s", subject)
return False
try:
# Build message
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = f"{self.config.from_name} <{self.config.from_address}>"
msg["To"] = ", ".join(recipients)
msg["Date"] = datetime.now().strftime("%a, %d %b %Y %H:%M:%S %z")
# Attach plain text body
msg.attach(MIMEText(body, "plain"))
# Attach HTML body if provided
if html_body:
msg.attach(MIMEText(html_body, "html"))
# Send email in dedicated thread pool (SMTP is blocking)
loop = asyncio.get_event_loop()
await loop.run_in_executor(self._executor, self._send_smtp, msg, recipients)
_LOGGER.info("Email sent successfully to %s: %s", recipients, subject)
return True
except Exception:
_LOGGER.exception("Failed to send email to %s: %s", recipients, subject)
return False
def _send_smtp(self, msg: MIMEMultipart, recipients: list[str]) -> None:
"""Send email via SMTP (blocking operation).
Args:
msg: Email message to send
recipients: List of recipient addresses
Raises:
Exception: If email sending fails
"""
if self.config.use_ssl:
# Connect with SSL from the start
context = ssl.create_default_context()
with smtplib.SMTP_SSL(
self.config.smtp_host,
self.config.smtp_port,
timeout=self.config.timeout,
context=context,
) as server:
if self.config.smtp_username and self.config.smtp_password:
server.login(self.config.smtp_username, self.config.smtp_password)
server.send_message(msg, self.config.from_address, recipients)
else:
# Connect and optionally upgrade to TLS
with smtplib.SMTP(
self.config.smtp_host,
self.config.smtp_port,
timeout=self.config.timeout,
) as server:
if self.config.use_tls:
context = ssl.create_default_context()
server.starttls(context=context)
if self.config.smtp_username and self.config.smtp_password:
server.login(self.config.smtp_username, self.config.smtp_password)
server.send_message(msg, self.config.from_address, recipients)
async def send_alert(
self,
recipients: list[str],
subject: str,
body: str,
) -> bool:
"""Send an alert email (convenience method).
Args:
recipients: List of recipient email addresses
subject: Email subject line
body: Email body text
Returns:
True if email was sent successfully, False otherwise
"""
return await self.send_email(recipients, subject, body)
async def send_daily_report(
self,
recipients: list[str],
stats: dict[str, Any],
errors: list[dict[str, Any]] | None = None,
) -> bool:
"""Send a daily report email.
Args:
recipients: List of recipient email addresses
stats: Dictionary containing statistics to include in report
errors: Optional list of errors to include
Returns:
True if email was sent successfully, False otherwise
"""
date_str = datetime.now().strftime("%Y-%m-%d")
subject = f"AlpineBits Daily Report - {date_str}"
# Build plain text body
body = f"AlpineBits Daily Report for {date_str}\n"
body += "=" * 60 + "\n\n"
# Add statistics
if stats:
body += "Statistics:\n"
body += "-" * 60 + "\n"
for key, value in stats.items():
body += f" {key}: {value}\n"
body += "\n"
# Add errors if present
if errors:
body += f"Errors ({len(errors)}):\n"
body += "-" * 60 + "\n"
for error in errors[:20]: # Limit to 20 most recent errors
timestamp = error.get("timestamp", "Unknown")
level = error.get("level", "ERROR")
message = error.get("message", "No message")
body += f" [{timestamp}] {level}: {message}\n"
if len(errors) > 20:
body += f" ... and {len(errors) - 20} more errors\n"
body += "\n"
body += "-" * 60 + "\n"
body += "Generated by AlpineBits Server\n"
# Build HTML body for better formatting
html_body = self._build_daily_report_html(date_str, stats, errors)
return await self.send_email(recipients, subject, body, html_body)
def _build_daily_report_html(
self,
date_str: str,
stats: dict[str, Any],
errors: list[dict[str, Any]] | None,
) -> str:
"""Build HTML version of daily report.
Args:
date_str: Date string for the report
stats: Statistics dictionary
errors: Optional list of errors
Returns:
HTML string for the email body
"""
html = f"""
<html>
<head>
<style>
body {{ font-family: Arial, sans-serif; }}
h1 {{ color: #333; }}
h2 {{ color: #666; margin-top: 20px; }}
table {{ border-collapse: collapse; width: 100%; }}
th, td {{ text-align: left; padding: 8px; border-bottom: 1px solid #ddd; }}
th {{ background-color: #f2f2f2; }}
.error {{ color: #d32f2f; }}
.warning {{ color: #f57c00; }}
.footer {{ margin-top: 30px; color: #999; font-size: 12px; }}
</style>
</head>
<body>
<h1>AlpineBits Daily Report</h1>
<p><strong>Date:</strong> {date_str}</p>
"""
# Add statistics table
if stats:
html += """
<h2>Statistics</h2>
<table>
<tr>
<th>Metric</th>
<th>Value</th>
</tr>
"""
for key, value in stats.items():
html += f"""
<tr>
<td>{key}</td>
<td>{value}</td>
</tr>
"""
html += "</table>"
# Add errors table
if errors:
html += f"""
<h2>Errors ({len(errors)})</h2>
<table>
<tr>
<th>Time</th>
<th>Level</th>
<th>Message</th>
</tr>
"""
for error in errors[:20]: # Limit to 20 most recent
timestamp = error.get("timestamp", "Unknown")
level = error.get("level", "ERROR")
message = error.get("message", "No message")
css_class = "error" if level == "ERROR" or level == "CRITICAL" else "warning"
html += f"""
<tr>
<td>{timestamp}</td>
<td class="{css_class}">{level}</td>
<td>{message}</td>
</tr>
"""
if len(errors) > 20:
html += f"""
<tr>
<td colspan="3"><em>... and {len(errors) - 20} more errors</em></td>
</tr>
"""
html += "</table>"
html += """
<div class="footer">
<p>Generated by AlpineBits Server</p>
</div>
</body>
</html>
"""
return html
def shutdown(self) -> None:
"""Shutdown the email service and clean up thread pool.
This should be called during application shutdown to ensure
proper cleanup of the thread pool executor.
"""
if self._executor:
_LOGGER.info("Shutting down email service thread pool")
self._executor.shutdown(wait=True, cancel_futures=False)
_LOGGER.info("Email service thread pool shut down complete")
def create_email_service(config: dict[str, Any]) -> EmailService | None:
"""Create an email service from configuration.
Args:
config: Full application configuration dictionary
Returns:
EmailService instance if email is configured, None otherwise
"""
email_config = config.get("email")
if not email_config:
_LOGGER.info("Email not configured, email service disabled")
return None
try:
email_cfg = EmailConfig(email_config)
service = EmailService(email_cfg)
_LOGGER.info("Email service initialized: %s:%s", email_cfg.smtp_host, email_cfg.smtp_port)
return service
except Exception:
_LOGGER.exception("Failed to initialize email service")
return None

View File

@@ -85,6 +85,7 @@ __all__ = [
"CommentName1",
"CommentName2",
"ContactInfoLocation",
"DefSendComplete",
"DescriptionName",
"DescriptionTextFormat1",
"DescriptionTextFormat2",
@@ -103,6 +104,7 @@ __all__ = [
"MealsIncludedMealPlanIndicator",
"MultimediaDescriptionInfoCode1",
"MultimediaDescriptionInfoCode2",
"OccupancyAgeQualifyingCode",
"OtaHotelDescriptiveContentNotifRq",
"OtaHotelDescriptiveContentNotifRs",
"OtaHotelDescriptiveInfoRq",
@@ -123,7 +125,6 @@ __all__ = [
"OtaPingRs",
"OtaReadRq",
"OtaResRetrieveRs",
"OccupancyAgeQualifyingCode",
"PositionAltitudeUnitOfMeasureCode",
"PrerequisiteInventoryInvType",
"ProfileProfileType",
@@ -150,12 +151,11 @@ __all__ = [
"TextTextFormat2",
"TimeUnitType",
"TypeRoomRoomType",
"UrlType",
"UniqueIdInstance",
"UniqueIdType1",
"UniqueIdType2",
"UniqueIdType3",
"UrlType",
"VideoItemCategory",
"WarningStatus",
"DefSendComplete",
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,174 @@
"""Centralized logging configuration for AlpineBits application.
This module sets up logging based on config and provides a function to get
loggers from anywhere in the application.
"""
import asyncio
import logging
import sys
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from alpine_bits_python.email_monitoring import (
DailyReportScheduler,
EmailAlertHandler,
)
from alpine_bits_python.email_service import EmailService
from alpine_bits_python.pushover_service import PushoverService
def setup_logging(
config: dict | None = None,
email_service: "EmailService | None" = None,
pushover_service: "PushoverService | None" = None,
loop: asyncio.AbstractEventLoop | None = None,
enable_scheduler: bool = True,
) -> tuple[logging.Handler | None, object | None]:
"""Configure logging based on application config.
Args:
config: Application configuration dict with optional 'logger' section
email_service: Optional email service for email alerts
pushover_service: Optional pushover service for push notifications
loop: Optional asyncio event loop for email alerts
enable_scheduler: Whether to enable the daily report scheduler
(should be False for non-primary workers)
Returns:
Tuple of (alert_handler, daily_report_scheduler) if monitoring
is enabled, otherwise (None, None)
Logger config format:
logger:
level: "INFO" # DEBUG, INFO, WARNING, ERROR, CRITICAL
file: "alpinebits.log" # Optional, logs to console if not provided
"""
if config is None:
config = {}
logger_config = config.get("logger", {})
level = logger_config.get("level", "INFO").upper()
log_file = logger_config.get("file")
# Convert string level to logging constant
numeric_level = getattr(logging, level, logging.INFO)
# Create formatter with timestamp
formatter = logging.Formatter(
fmt="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
# Get root logger
root_logger = logging.getLogger()
root_logger.setLevel(numeric_level)
# Remove existing handlers to avoid duplicates
root_logger.handlers.clear()
# Console handler (always add this)
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(numeric_level)
console_handler.setFormatter(formatter)
root_logger.addHandler(console_handler)
# File handler (optional)
if log_file:
log_path = Path(log_file)
# Create logs directory if it doesn't exist
if log_path.parent != Path():
log_path.parent.mkdir(parents=True, exist_ok=True)
file_handler = logging.FileHandler(log_file, encoding="utf-8")
file_handler.setLevel(numeric_level)
file_handler.setFormatter(formatter)
root_logger.addHandler(file_handler)
root_logger.info("Logging to file: %s", log_file)
root_logger.info("Logging configured at %s level", level)
# Setup unified notification monitoring if configured
alert_handler = None
report_scheduler = None
# Check if unified notifications are configured
notifications_config = config.get("notifications", {})
if notifications_config and (email_service or pushover_service):
try:
# Import here to avoid circular dependencies
from alpine_bits_python.notification_manager import (
get_notification_config,
setup_notification_service,
)
from alpine_bits_python.unified_monitoring import (
UnifiedAlertHandler,
UnifiedDailyReportScheduler,
)
# Setup unified notification service
notification_service = setup_notification_service(
config=config,
email_service=email_service,
pushover_service=pushover_service,
)
if notification_service:
# Setup error alert handler
error_alerts_config = get_notification_config("error_alerts", config)
if error_alerts_config.get("enabled", False):
try:
alert_handler = UnifiedAlertHandler(
notification_service=notification_service,
config=error_alerts_config,
loop=loop,
)
alert_handler.setLevel(logging.ERROR)
root_logger.addHandler(alert_handler)
root_logger.info("Unified alert handler enabled for error monitoring")
except Exception:
root_logger.exception("Failed to setup unified alert handler")
# Setup daily report scheduler (only if enabled and this is primary worker)
daily_report_config = get_notification_config("daily_report", config)
if daily_report_config.get("enabled", False) and enable_scheduler:
try:
report_scheduler = UnifiedDailyReportScheduler(
notification_service=notification_service,
config=daily_report_config,
)
root_logger.info("Unified daily report scheduler configured (primary worker)")
except Exception:
root_logger.exception("Failed to setup unified daily report scheduler")
elif daily_report_config.get("enabled", False) and not enable_scheduler:
root_logger.info(
"Unified daily report scheduler disabled (non-primary worker)"
)
except Exception:
root_logger.exception("Failed to setup unified notification monitoring")
return alert_handler, report_scheduler
def get_logger(name: str) -> logging.Logger:
"""Get a logger instance for the given module name.
Usage:
from alpine_bits_python.logging_config import get_logger
_LOGGER = get_logger(__name__)
_LOGGER.info("Something happened")
Args:
name: Usually __name__ from the calling module
Returns:
Configured logger instance
"""
return logging.getLogger(name)

View File

@@ -1,195 +0,0 @@
from .alpinebits_guestrequests import ResGuest, RoomStay
from .generated import alpinebits as ab
from io import BytesIO
import sys
from datetime import datetime, timezone
import re
from xsdata_pydantic.bindings import XmlSerializer
from .simplified_access import (
CommentData,
CommentsData,
CommentListItemData,
CustomerData,
HotelReservationIdData,
PhoneTechType,
AlpineBitsFactory,
OtaMessageType
)
def main():
# Success - use None instead of object() for cleaner XML output
success = None
# UniqueID
unique_id = ab.OtaResRetrieveRs.ReservationsList.HotelReservation.UniqueId(
type_value=ab.UniqueIdType2.VALUE_14, id="6b34fe24ac2ff811"
)
# TimeSpan - use the actual nested class
start_date_window = ab.OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays.RoomStay.TimeSpan.StartDateWindow(
earliest_date="2024-10-01", latest_date="2024-10-02"
)
time_span = ab.OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays.RoomStay.TimeSpan(
start_date_window=start_date_window
)
# RoomStay with TimeSpan
room_stay = (
ab.OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays.RoomStay(
time_span=time_span
)
)
room_stays = ab.OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays(
room_stay=[room_stay]
)
customer_data = CustomerData(
given_name="John",
surname="Doe",
name_prefix="Mr.",
phone_numbers=[
("+1234567890", PhoneTechType.MOBILE), # Phone number with type
("+0987654321", None), # Phone number without type
],
email_address="john.doe@example.com",
email_newsletter=True,
address_line="123 Main Street",
city_name="Anytown",
postal_code="12345",
country_code="US",
address_catalog=False,
gender="Male",
birth_date="1980-01-01",
language="en",
)
alpine_bits_factory = AlpineBitsFactory()
res_guests = alpine_bits_factory.create_res_guests(customer_data, OtaMessageType.RETRIEVE)
hotel_res_id_data = HotelReservationIdData(
res_id_type="13",
res_id_value=None,
res_id_source=None,
res_id_source_context="99tales",
)
# Create HotelReservationId using the factory
hotel_res_id = alpine_bits_factory.create(hotel_res_id_data, OtaMessageType.RETRIEVE)
# Use the actual nested HotelReservationIds class
hotel_res_ids = ab.OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.HotelReservationIds(
hotel_reservation_id=[hotel_res_id]
)
# Basic property info
basic_property_info = ab.OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.BasicPropertyInfo(
hotel_code="123", hotel_name="Frangart Inn"
)
comment = CommentData(
name= ab.CommentName2.CUSTOMER_COMMENT,
text="This is a sample comment.",
list_items=[CommentListItemData(
value="Landing page comment",
language="en",
list_item="1",
)],
)
comment2 = CommentData(
name= ab.CommentName2.ADDITIONAL_INFO,
text="This is a special request comment.",
)
comments_data = CommentsData(comments=[comment, comment2])
comments = alpine_bits_factory.create(comments_data, OtaMessageType.RETRIEVE)
# ResGlobalInfo
res_global_info = (
ab.OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo(
hotel_reservation_ids=hotel_res_ids, basic_property_info=basic_property_info, comments=comments
)
)
# Hotel Reservation
hotel_reservation = ab.OtaResRetrieveRs.ReservationsList.HotelReservation(
create_date_time=datetime.now(timezone.utc).isoformat(),
res_status=ab.HotelReservationResStatus.REQUESTED,
room_stay_reservation="true",
unique_id=unique_id,
room_stays=room_stays,
res_guests=res_guests,
res_global_info=res_global_info,
)
reservations_list = ab.OtaResRetrieveRs.ReservationsList(
hotel_reservation=[hotel_reservation]
)
# Root element
ota_res_retrieve_rs = ab.OtaResRetrieveRs(
version="7.000", success=success, reservations_list=reservations_list
)
# Serialize using Pydantic's model_dump and convert to XML
try:
# First validate the model
ota_res_retrieve_rs.model_validate(ota_res_retrieve_rs.model_dump())
print("✅ Pydantic validation successful!")
# For XML serialization with Pydantic models, we need to use xsdata-pydantic serializer
from xsdata.formats.dataclass.serializers.config import SerializerConfig
config = SerializerConfig(
pretty_print=True, xml_declaration=True, encoding="UTF-8"
)
serializer = XmlSerializer(config=config)
# Use ns_map to control namespace prefixes - set default namespace
ns_map = {None: "http://www.opentravel.org/OTA/2003/05"}
xml_string = serializer.render(ota_res_retrieve_rs, ns_map=ns_map)
with open("output.xml", "w", encoding="utf-8") as outfile:
outfile.write(xml_string)
print("✅ XML serialization successful!")
print(f"Generated XML written to output.xml")
# Also print the pretty formatted XML to console
print("\n📄 Generated XML:")
print(xml_string)
# Test parsing back
from xsdata_pydantic.bindings import XmlParser
parser = XmlParser()
with open("output.xml", "r", encoding="utf-8") as infile:
xml_content = infile.read()
parsed_result = parser.from_string(xml_content, ab.OtaResRetrieveRs)
print("✅ Round-trip validation successful!")
print(
f"Parsed reservation status: {parsed_result.reservations_list.hotel_reservation[0].res_status}"
)
except Exception as e:
print(f"❌ Validation/Serialization failed: {e}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,365 @@
"""Database migrations for AlpineBits.
This module contains migration functions that are automatically run at app startup
to update existing database schemas without losing data.
"""
from typing import Any
from sqlalchemy import inspect, text
from sqlalchemy.ext.asyncio import AsyncEngine
from .const import CONF_GOOGLE_ACCOUNT, CONF_HOTEL_ID, CONF_META_ACCOUNT
from .logging_config import get_logger
from .db import Reservation
_LOGGER = get_logger(__name__)
async def check_column_exists(engine: AsyncEngine, table_name: str, column_name: str) -> bool:
"""Check if a column exists in a table.
Args:
engine: SQLAlchemy async engine
table_name: Name of the table to check
column_name: Name of the column to check
Returns:
True if column exists, False otherwise
"""
async with engine.connect() as conn:
def _check(connection):
inspector = inspect(connection)
columns = [col['name'] for col in inspector.get_columns(table_name)]
return column_name in columns
result = await conn.run_sync(_check)
return result
async def add_column_if_not_exists(
engine: AsyncEngine,
table_name: str,
column_name: str,
column_type: str = "VARCHAR"
) -> bool:
"""Add a column to a table if it doesn't already exist.
Args:
engine: SQLAlchemy async engine
table_name: Name of the table
column_name: Name of the column to add
column_type: SQL type of the column (default: VARCHAR)
Returns:
True if column was added, False if it already existed
"""
exists = await check_column_exists(engine, table_name, column_name)
if exists:
_LOGGER.debug("Column %s.%s already exists, skipping", table_name, column_name)
return False
_LOGGER.info("Adding column %s.%s (%s)", table_name, column_name, column_type)
async with engine.begin() as conn:
sql = f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_type}"
await conn.execute(text(sql))
_LOGGER.info("Successfully added column %s.%s", table_name, column_name)
return True
async def migrate_add_room_types(engine: AsyncEngine) -> None:
"""Migration: Add RoomTypes fields to reservations table.
This migration adds three optional fields:
- room_type_code: String (max 8 chars)
- room_classification_code: String (numeric pattern)
- room_type: String (enum: 1-5)
Safe to run multiple times - will skip if columns already exist.
"""
_LOGGER.info("Running migration: add_room_types")
added_count = 0
# Add each column if it doesn't exist
if await add_column_if_not_exists(engine, "reservations", "room_type_code", "VARCHAR"):
added_count += 1
if await add_column_if_not_exists(engine, "reservations", "room_classification_code", "VARCHAR"):
added_count += 1
if await add_column_if_not_exists(engine, "reservations", "room_type", "VARCHAR"):
added_count += 1
if added_count > 0:
_LOGGER.info("Migration add_room_types: Added %d columns", added_count)
else:
_LOGGER.info("Migration add_room_types: No changes needed (already applied)")
async def migrate_add_advertising_account_ids(engine: AsyncEngine, config: dict[str, Any] | None = None) -> None:
"""Migration: Add advertising account ID fields to reservations table.
This migration adds two optional fields:
- meta_account_id: String (Meta/Facebook advertising account ID)
- google_account_id: String (Google advertising account ID)
These fields are populated conditionally based on fbclid/gclid presence.
For existing reservations, backfills account IDs from config based on hotel_code and fbclid/gclid.
Safe to run multiple times - will skip if columns already exist.
Args:
engine: SQLAlchemy async engine
config: Application configuration dict containing hotel account IDs
"""
_LOGGER.info("Running migration: add_advertising_account_ids")
added_count = 0
# Add each column if it doesn't exist
if await add_column_if_not_exists(engine, "reservations", "meta_account_id", "VARCHAR"):
added_count += 1
if await add_column_if_not_exists(engine, "reservations", "google_account_id", "VARCHAR"):
added_count += 1
if added_count > 0:
_LOGGER.info("Migration add_advertising_account_ids: Added %d columns", added_count)
else:
_LOGGER.info("Migration add_advertising_account_ids: Columns already exist")
# Backfill existing reservations with account IDs based on config and fbclid/gclid presence
if config:
await _backfill_advertising_account_ids(engine, config)
else:
_LOGGER.warning("No config provided, skipping backfill of advertising account IDs")
async def _backfill_advertising_account_ids(engine: AsyncEngine, config: dict[str, Any]) -> None:
"""Backfill advertising account IDs for existing reservations.
Updates existing reservations to populate meta_account_id and google_account_id
based on the conditional logic:
- If fbclid is present, set meta_account_id from hotel config
- If gclid is present, set google_account_id from hotel config
Args:
engine: SQLAlchemy async engine
config: Application configuration dict
"""
_LOGGER.info("Backfilling advertising account IDs for existing reservations...")
# Build a mapping of hotel_id -> account IDs from config
hotel_accounts = {}
alpine_bits_auth = config.get("alpine_bits_auth", [])
for hotel in alpine_bits_auth:
hotel_id = hotel.get(CONF_HOTEL_ID)
meta_account = hotel.get(CONF_META_ACCOUNT)
google_account = hotel.get(CONF_GOOGLE_ACCOUNT)
if hotel_id:
hotel_accounts[hotel_id] = {
"meta_account": meta_account,
"google_account": google_account
}
if not hotel_accounts:
_LOGGER.info("No hotel accounts found in config, skipping backfill")
return
_LOGGER.info("Found %d hotel(s) with account configurations", len(hotel_accounts))
# Update reservations with meta_account_id where fbclid is present
meta_updated = 0
for hotel_id, accounts in hotel_accounts.items():
if accounts["meta_account"]:
async with engine.begin() as conn:
sql = text(
"UPDATE reservations "
"SET meta_account_id = :meta_account "
"WHERE hotel_code = :hotel_id "
"AND fbclid IS NOT NULL "
"AND fbclid != '' "
"AND (meta_account_id IS NULL OR meta_account_id = '')"
)
result = await conn.execute(
sql,
{"meta_account": accounts["meta_account"], "hotel_id": hotel_id}
)
count = result.rowcount
if count > 0:
_LOGGER.info("Updated %d reservations with meta_account_id for hotel %s", count, hotel_id)
meta_updated += count
# Update reservations with google_account_id where gclid is present
google_updated = 0
for hotel_id, accounts in hotel_accounts.items():
if accounts["google_account"]:
async with engine.begin() as conn:
sql = text(
"UPDATE reservations "
"SET google_account_id = :google_account "
"WHERE hotel_code = :hotel_id "
"AND gclid IS NOT NULL "
"AND gclid != '' "
"AND (google_account_id IS NULL OR google_account_id = '')"
)
result = await conn.execute(
sql,
{"google_account": accounts["google_account"], "hotel_id": hotel_id}
)
count = result.rowcount
if count > 0:
_LOGGER.info("Updated %d reservations with google_account_id for hotel %s", count, hotel_id)
google_updated += count
_LOGGER.info(
"Backfill complete: %d reservations updated with meta_account_id, %d with google_account_id",
meta_updated,
google_updated
)
async def migrate_add_username_to_acked_requests(engine: AsyncEngine, config: dict[str, Any] | None = None) -> None:
"""Migration: Add username column to acked_requests table and backfill with hotel usernames.
This migration adds a username column to acked_requests to track acknowledgements by username
instead of just client_id. This improves consistency since client_ids can change but usernames are stable.
For existing acknowledgements, this migration queries reservations to determine the hotel_code,
then looks up the corresponding username from the config and populates the new column.
Safe to run multiple times - will skip if column already exists.
Args:
engine: SQLAlchemy async engine
config: Application configuration dict containing hotel usernames
"""
_LOGGER.info("Running migration: add_username_to_acked_requests")
# Add the username column if it doesn't exist
if await add_column_if_not_exists(engine, "acked_requests", "username", "VARCHAR"):
_LOGGER.info("Added username column to acked_requests table")
else:
_LOGGER.info("Username column already exists in acked_requests, skipping")
return
# Backfill existing acknowledgements with username from config
if config:
await _backfill_acked_requests_username(engine, config)
else:
_LOGGER.warning("No config provided, skipping backfill of acked_requests usernames")
async def _backfill_acked_requests_username(engine: AsyncEngine, config: dict[str, Any]) -> None:
"""Backfill username for existing acked_requests records.
For each acknowledgement, find the corresponding reservation to determine its hotel_code,
then look up the username for that hotel in the config and update the acked_request record.
Args:
engine: SQLAlchemy async engine
config: Application configuration dict
"""
_LOGGER.info("Backfilling usernames for existing acked_requests...")
# Build a mapping of hotel_id -> username from config
hotel_usernames = {}
alpine_bits_auth = config.get("alpine_bits_auth", [])
for hotel in alpine_bits_auth:
hotel_id = hotel.get(CONF_HOTEL_ID)
username = hotel.get("username")
if hotel_id and username:
hotel_usernames[hotel_id] = username
if not hotel_usernames:
_LOGGER.info("No hotel usernames found in config, skipping backfill")
return
_LOGGER.info("Found %d hotel(s) with usernames in config", len(hotel_usernames))
# Update acked_requests with usernames by matching to reservations
total_updated = 0
async with engine.begin() as conn:
for hotel_id, username in hotel_usernames.items():
sql = text("""
UPDATE acked_requests
SET username = :username
WHERE unique_id IN (
SELECT md5_unique_id FROM reservations WHERE hotel_code = :hotel_id
)
AND username IS NULL
""")
result = await conn.execute(
sql,
{"username": username, "hotel_id": hotel_id}
)
count = result.rowcount
if count > 0:
_LOGGER.info("Updated %d acknowledgements with username for hotel %s", count, hotel_id)
total_updated += count
_LOGGER.info("Backfill complete: %d acknowledgements updated with username", total_updated)
async def migrate_normalize_conversions(engine: AsyncEngine) -> None:
"""Migration: Normalize conversions and room reservations structure.
This migration redesigns the conversion data structure:
- conversions: One row per PMS reservation (with guest/advertising metadata)
- room_reservations: One row per room reservation (linked to conversion)
- daily_sales: JSON array of daily sales within each room reservation
- total_revenue: Extracted sum of all daily sales for efficiency
Old structure: One row per daily sale (denormalized, lots of duplication)
New structure: One row per room reservation, daily sales as JSON with extracted total
This allows:
- Upserts on room reservations (same room doesn't get duplicated)
- Better tracking of room data separate from daily sales data
- Efficient querying via extracted total_revenue field
- All daily sales details preserved in JSON for analysis
The tables are created via Base.metadata.create_all() at startup.
Safe to run multiple times - idempotent.
"""
_LOGGER.info("Running migration: normalize_conversions")
_LOGGER.info(
"Conversion data structure redesigned: "
"conversions (1 per PMS reservation) + "
"room_reservations (1 per room, daily_sales as JSON). "
"Tables created/updated via Base.metadata.create_all()"
)
async def run_all_migrations(engine: AsyncEngine, config: dict[str, Any] | None = None) -> None:
"""Run all pending migrations.
This function should be called at app startup, after Base.metadata.create_all.
Each migration function should be idempotent (safe to run multiple times).
Args:
engine: SQLAlchemy async engine
config: Application configuration dict (optional, but required for some migrations)
"""
_LOGGER.info("Starting database migrations...")
try:
# Add new migrations here in chronological order
await migrate_add_room_types(engine)
await migrate_add_advertising_account_ids(engine, config)
await migrate_add_username_to_acked_requests(engine, config)
await migrate_normalize_conversions(engine)
_LOGGER.info("Database migrations completed successfully")
except Exception as e:
_LOGGER.exception("Migration failed: %s", e)
raise

View File

@@ -0,0 +1,127 @@
"""Adapters for notification backends.
This module provides adapters that wrap email and Pushover services
to work with the unified notification service interface.
"""
from typing import Any
from .email_service import EmailService
from .logging_config import get_logger
from .pushover_service import PushoverService
_LOGGER = get_logger(__name__)
class EmailNotificationAdapter:
"""Adapter for EmailService to work with NotificationService."""
def __init__(self, email_service: EmailService, recipients: list[str]):
"""Initialize the email notification adapter.
Args:
email_service: EmailService instance
recipients: List of recipient email addresses
"""
self.email_service = email_service
self.recipients = recipients
async def send_alert(self, title: str, message: str, **kwargs) -> bool:
"""Send an alert via email.
Args:
title: Email subject
message: Email body
**kwargs: Ignored for email
Returns:
True if sent successfully
"""
return await self.email_service.send_alert(
recipients=self.recipients,
subject=title,
body=message,
)
async def send_daily_report(
self,
stats: dict[str, Any],
errors: list[dict[str, Any]] | None = None,
**kwargs,
) -> bool:
"""Send a daily report via email.
Args:
stats: Statistics dictionary
errors: Optional list of errors
**kwargs: Ignored for email
Returns:
True if sent successfully
"""
return await self.email_service.send_daily_report(
recipients=self.recipients,
stats=stats,
errors=errors,
)
class PushoverNotificationAdapter:
"""Adapter for PushoverService to work with NotificationService."""
def __init__(self, pushover_service: PushoverService, priority: int = 0):
"""Initialize the Pushover notification adapter.
Args:
pushover_service: PushoverService instance
priority: Default priority level for notifications
"""
self.pushover_service = pushover_service
self.priority = priority
async def send_alert(self, title: str, message: str, **kwargs) -> bool:
"""Send an alert via Pushover.
Args:
title: Notification title
message: Notification message
**kwargs: Can include 'priority' to override default
Returns:
True if sent successfully
"""
priority = kwargs.get("priority", self.priority)
return await self.pushover_service.send_alert(
title=title,
message=message,
priority=priority,
)
async def send_daily_report(
self,
stats: dict[str, Any],
errors: list[dict[str, Any]] | None = None,
**kwargs,
) -> bool:
"""Send a daily report via Pushover.
Args:
stats: Statistics dictionary
errors: Optional list of errors
**kwargs: Can include 'priority' to override default
Returns:
True if sent successfully
"""
priority = kwargs.get("priority", self.priority)
return await self.pushover_service.send_daily_report(
stats=stats,
errors=errors,
priority=priority,
)

View File

@@ -0,0 +1,156 @@
"""Unified notification manager for setting up recipient-based notification routing.
This module provides helpers to initialize the unified notification system
based on the recipients configuration.
"""
from typing import Any
from .email_service import EmailService
from .logging_config import get_logger
from .notification_adapters import EmailNotificationAdapter, PushoverNotificationAdapter
from .notification_service import NotificationService
from .pushover_service import PushoverService
_LOGGER = get_logger(__name__)
def setup_notification_service(
config: dict[str, Any],
email_service: EmailService | None = None,
pushover_service: PushoverService | None = None,
) -> NotificationService | None:
"""Set up unified notification service from config.
Args:
config: Full configuration dictionary
email_service: Optional EmailService instance
pushover_service: Optional PushoverService instance
Returns:
NotificationService instance, or None if no recipients configured
"""
notifications_config = config.get("notifications", {})
recipients = notifications_config.get("recipients", [])
if not recipients:
_LOGGER.info("No notification recipients configured")
return None
notification_service = NotificationService()
# Process each recipient and their methods
for recipient in recipients:
recipient_name = recipient.get("name", "unknown")
methods = recipient.get("methods", [])
for method in methods:
method_type = method.get("type")
if method_type == "email":
if not email_service:
_LOGGER.warning(
"Email method configured for %s but email service not available",
recipient_name,
)
continue
email_address = method.get("address")
if not email_address:
_LOGGER.warning(
"Email method for %s missing address", recipient_name
)
continue
# Create a unique backend name for this recipient's email
backend_name = f"email_{recipient_name}"
# Check if we already have an email backend
if not notification_service.has_backend("email"):
# Create email adapter with all email recipients
email_recipients = []
for r in recipients:
for m in r.get("methods", []):
if m.get("type") == "email" and m.get("address"):
email_recipients.append(m.get("address"))
if email_recipients:
email_adapter = EmailNotificationAdapter(
email_service, email_recipients
)
notification_service.register_backend("email", email_adapter)
_LOGGER.info(
"Registered email backend with %d recipient(s)",
len(email_recipients),
)
elif method_type == "pushover":
if not pushover_service:
_LOGGER.warning(
"Pushover method configured for %s but pushover service not available",
recipient_name,
)
continue
priority = method.get("priority", 0)
# Check if we already have a pushover backend
if not notification_service.has_backend("pushover"):
# Pushover sends to user_key configured in pushover service
pushover_adapter = PushoverNotificationAdapter(
pushover_service, priority
)
notification_service.register_backend("pushover", pushover_adapter)
_LOGGER.info("Registered pushover backend with priority %d", priority)
if not notification_service.backends:
_LOGGER.warning("No notification backends could be configured")
return None
_LOGGER.info(
"Notification service configured with backends: %s",
list(notification_service.backends.keys()),
)
return notification_service
def get_enabled_backends(
notification_type: str, config: dict[str, Any]
) -> list[str] | None:
"""Get list of enabled backends for a notification type.
Args:
notification_type: "daily_report" or "error_alerts"
config: Full configuration dictionary
Returns:
List of backend names to use, or None for all backends
"""
notifications_config = config.get("notifications", {})
notification_config = notifications_config.get(notification_type, {})
if not notification_config.get("enabled", False):
return []
# Return None to indicate all backends should be used
# The NotificationService will send to all registered backends
return None
def get_notification_config(
notification_type: str, config: dict[str, Any]
) -> dict[str, Any]:
"""Get configuration for a specific notification type.
Args:
notification_type: "daily_report" or "error_alerts"
config: Full configuration dictionary
Returns:
Configuration dictionary for the notification type
"""
notifications_config = config.get("notifications", {})
return notifications_config.get(notification_type, {})

View File

@@ -0,0 +1,177 @@
"""Unified notification service supporting multiple backends.
This module provides a unified interface for sending notifications through
different channels (email, Pushover, etc.) for alerts and daily reports.
"""
from typing import Any, Protocol
from .logging_config import get_logger
_LOGGER = get_logger(__name__)
class NotificationBackend(Protocol):
"""Protocol for notification backends."""
async def send_alert(self, title: str, message: str, **kwargs) -> bool:
"""Send an alert notification.
Args:
title: Alert title/subject
message: Alert message/body
**kwargs: Backend-specific parameters
Returns:
True if sent successfully, False otherwise
"""
...
async def send_daily_report(
self,
stats: dict[str, Any],
errors: list[dict[str, Any]] | None = None,
**kwargs,
) -> bool:
"""Send a daily report notification.
Args:
stats: Statistics dictionary
errors: Optional list of errors
**kwargs: Backend-specific parameters
Returns:
True if sent successfully, False otherwise
"""
...
class NotificationService:
"""Unified notification service that supports multiple backends.
This service can send notifications through multiple channels simultaneously
(email, Pushover, etc.) based on configuration.
"""
def __init__(self):
"""Initialize the notification service."""
self.backends: dict[str, NotificationBackend] = {}
def register_backend(self, name: str, backend: NotificationBackend) -> None:
"""Register a notification backend.
Args:
name: Backend name (e.g., "email", "pushover")
backend: Backend instance implementing NotificationBackend protocol
"""
self.backends[name] = backend
_LOGGER.info("Registered notification backend: %s", name)
async def send_alert(
self,
title: str,
message: str,
backends: list[str] | None = None,
**kwargs,
) -> dict[str, bool]:
"""Send an alert through specified backends.
Args:
title: Alert title/subject
message: Alert message/body
backends: List of backend names to use (None = all registered)
**kwargs: Backend-specific parameters
Returns:
Dictionary mapping backend names to success status
"""
if backends is None:
backends = list(self.backends.keys())
results = {}
for backend_name in backends:
backend = self.backends.get(backend_name)
if backend is None:
_LOGGER.warning("Backend not found: %s", backend_name)
results[backend_name] = False
continue
try:
success = await backend.send_alert(title, message, **kwargs)
results[backend_name] = success
except Exception:
_LOGGER.exception(
"Error sending alert through backend %s", backend_name
)
results[backend_name] = False
return results
async def send_daily_report(
self,
stats: dict[str, Any],
errors: list[dict[str, Any]] | None = None,
backends: list[str] | None = None,
**kwargs,
) -> dict[str, bool]:
"""Send a daily report through specified backends.
Args:
stats: Statistics dictionary
errors: Optional list of errors
backends: List of backend names to use (None = all registered)
**kwargs: Backend-specific parameters
Returns:
Dictionary mapping backend names to success status
"""
if backends is None:
backends = list(self.backends.keys())
results = {}
for backend_name in backends:
backend = self.backends.get(backend_name)
if backend is None:
_LOGGER.warning("Backend not found: %s", backend_name)
results[backend_name] = False
continue
try:
success = await backend.send_daily_report(stats, errors, **kwargs)
results[backend_name] = success
except Exception:
_LOGGER.exception(
"Error sending daily report through backend %s", backend_name
)
results[backend_name] = False
return results
def get_backend(self, name: str) -> NotificationBackend | None:
"""Get a specific notification backend.
Args:
name: Backend name
Returns:
Backend instance or None if not found
"""
return self.backends.get(name)
def has_backend(self, name: str) -> bool:
"""Check if a backend is registered.
Args:
name: Backend name
Returns:
True if backend is registered
"""
return name in self.backends

View File

@@ -0,0 +1,281 @@
"""Pushover service for sending push notifications.
This module provides push notification functionality for the AlpineBits application,
including error alerts and daily reports via Pushover.
"""
import asyncio
from datetime import datetime
from typing import Any
from pushover_complete import PushoverAPI
from .logging_config import get_logger
_LOGGER = get_logger(__name__)
class PushoverConfig:
"""Configuration for Pushover service.
Attributes:
user_key: Pushover user/group key
api_token: Pushover application API token
"""
def __init__(self, config: dict[str, Any]):
"""Initialize Pushover configuration from config dict.
Args:
config: Pushover configuration dictionary
"""
self.user_key: str | None = config.get("user_key")
self.api_token: str | None = config.get("api_token")
# Validate configuration
if not self.user_key or not self.api_token:
msg = "Both user_key and api_token are required for Pushover"
raise ValueError(msg)
class PushoverService:
"""Service for sending push notifications via Pushover.
This service handles sending notifications through the Pushover API,
including alerts and daily reports.
"""
def __init__(self, config: PushoverConfig):
"""Initialize Pushover service.
Args:
config: Pushover configuration
"""
self.config = config
self.api = PushoverAPI(config.api_token)
async def send_notification(
self,
title: str,
message: str,
priority: int = 0,
url: str | None = None,
url_title: str | None = None,
) -> bool:
"""Send a push notification via Pushover.
Args:
title: Notification title
message: Notification message
priority: Priority level (-2 to 2, default 0)
url: Optional supplementary URL
url_title: Optional title for the URL
Returns:
True if notification was sent successfully, False otherwise
"""
try:
# Send notification in thread pool (API is blocking)
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None,
self._send_pushover,
title,
message,
priority,
url,
url_title,
)
_LOGGER.info("Pushover notification sent successfully: %s", title)
return True
except Exception:
_LOGGER.exception("Failed to send Pushover notification: %s", title)
return False
def _send_pushover(
self,
title: str,
message: str,
priority: int,
url: str | None,
url_title: str | None,
) -> None:
"""Send notification via Pushover (blocking operation).
Args:
title: Notification title
message: Notification message
priority: Priority level
url: Optional URL
url_title: Optional URL title
Raises:
Exception: If notification sending fails
"""
kwargs = {
"user": self.config.user_key,
"title": title,
"message": message,
"priority": priority,
}
if url:
kwargs["url"] = url
if url_title:
kwargs["url_title"] = url_title
self.api.send_message(**kwargs)
async def send_alert(
self,
title: str,
message: str,
priority: int = 1,
) -> bool:
"""Send an alert notification (convenience method).
Args:
title: Alert title
message: Alert message
priority: Priority level (default 1 for high priority)
Returns:
True if notification was sent successfully, False otherwise
"""
return await self.send_notification(title, message, priority=priority)
async def send_daily_report(
self,
stats: dict[str, Any],
errors: list[dict[str, Any]] | None = None,
priority: int = 0,
) -> bool:
"""Send a daily report notification.
Args:
stats: Dictionary containing statistics to include in report
errors: Optional list of errors to include
priority: Priority level (default 0 for normal)
Returns:
True if notification was sent successfully, False otherwise
"""
date_str = datetime.now().strftime("%Y-%m-%d")
title = f"AlpineBits Daily Report - {date_str}"
# Build message body (Pushover has a 1024 character limit)
message = self._build_daily_report_message(date_str, stats, errors)
return await self.send_notification(title, message, priority=priority)
def _build_daily_report_message(
self,
date_str: str,
stats: dict[str, Any],
errors: list[dict[str, Any]] | None,
) -> str:
"""Build daily report message for Pushover.
Args:
date_str: Date string for the report
stats: Statistics dictionary
errors: Optional list of errors
Returns:
Formatted message string (max 1024 chars for Pushover)
"""
lines = [f"Report for {date_str}", ""]
# Add statistics (simplified for push notification)
if stats:
# Handle reporting period
period = stats.get("reporting_period", {})
if period:
start = period.get("start", "")
end = period.get("end", "")
if start and end:
# Parse the datetime strings to check if they're on different days
if " " in start and " " in end:
start_date, start_time = start.split(" ")
end_date, end_time = end.split(" ")
# If same day, just show times
if start_date == end_date:
lines.append(f"Period: {start_time} - {end_time}")
else:
# Different days, show date + time in compact format
# Format: "MM-DD HH:MM - MM-DD HH:MM"
start_compact = f"{start_date[5:]} {start_time[:5]}"
end_compact = f"{end_date[5:]} {end_time[:5]}"
lines.append(f"Period: {start_compact} - {end_compact}")
else:
# Fallback if format is unexpected
lines.append(f"Period: {start} - {end}")
# Total reservations
total = stats.get("total_reservations", 0)
lines.append(f"Total Reservations: {total}")
# Per-hotel breakdown (top 5 only to save space)
hotels = stats.get("hotels", [])
if hotels:
lines.append("")
lines.append("By Hotel:")
for hotel in hotels[:5]: # Top 5 hotels
hotel_name = hotel.get("hotel_name", "Unknown")
count = hotel.get("reservations", 0)
# Truncate long hotel names
if len(hotel_name) > 20:
hotel_name = hotel_name[:17] + "..."
lines.append(f"{hotel_name}: {count}")
if len(hotels) > 5:
lines.append(f" • ... and {len(hotels) - 5} more")
# Add error summary if present
if errors:
lines.append("")
lines.append(f"Errors: {len(errors)} (see logs)")
message = "\n".join(lines)
# Truncate if too long (Pushover limit is 1024 chars)
if len(message) > 1020:
message = message[:1017] + "..."
return message
def create_pushover_service(config: dict[str, Any]) -> PushoverService | None:
"""Create a Pushover service from configuration.
Args:
config: Full application configuration dictionary
Returns:
PushoverService instance if Pushover is configured, None otherwise
"""
pushover_config = config.get("pushover")
if not pushover_config:
_LOGGER.info("Pushover not configured, push notification service disabled")
return None
try:
pushover_cfg = PushoverConfig(pushover_config)
service = PushoverService(pushover_cfg)
_LOGGER.info("Pushover service initialized successfully")
return service
except Exception:
_LOGGER.exception("Failed to initialize Pushover service")
return None

View File

@@ -0,0 +1,99 @@
import logging
import os
import redis
from fastapi import Request
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address
logger = logging.getLogger(__name__)
# Rate limiting configuration
DEFAULT_RATE_LIMIT = "10/minute" # 10 requests per minute per IP
WEBHOOK_RATE_LIMIT = "60/minute" # 60 webhook requests per minute per IP
BURST_RATE_LIMIT = "3/second" # Max 3 requests per second per IP
# Redis configuration for distributed rate limiting (optional)
REDIS_URL = os.getenv("REDIS_URL", None)
def get_remote_address_with_forwarded(request: Request):
"""Get client IP address, considering forwarded headers from proxies/load balancers."""
# Check for forwarded headers (common in production behind proxies)
forwarded_for = request.headers.get("X-Forwarded-For")
if forwarded_for:
# Take the first IP in the chain
return forwarded_for.split(",")[0].strip()
real_ip = request.headers.get("X-Real-IP")
if real_ip:
return real_ip
# Fallback to direct connection IP
return get_remote_address(request)
# Initialize limiter
if REDIS_URL:
# Use Redis for distributed rate limiting (recommended for production)
try:
import redis
redis_client = redis.from_url(REDIS_URL)
limiter = Limiter(
key_func=get_remote_address_with_forwarded, storage_uri=REDIS_URL
)
logger.info("Rate limiting initialized with Redis backend")
except Exception as e:
logger.warning(
f"Failed to connect to Redis: {e}. Using in-memory rate limiting."
)
limiter = Limiter(key_func=get_remote_address_with_forwarded)
else:
# Use in-memory rate limiting (fine for single instance)
limiter = Limiter(key_func=get_remote_address_with_forwarded)
logger.info("Rate limiting initialized with in-memory backend")
def get_api_key_identifier(request: Request) -> str:
"""Get identifier for rate limiting based on API key if available, otherwise IP
This allows different rate limits per API key.
"""
# Try to get API key from Authorization header
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
api_key = auth_header[7:] # Remove "Bearer " prefix
# Use first 10 chars of API key as identifier (don't log full key)
return f"api_key:{api_key[:10]}"
# Fallback to IP address
return f"ip:{get_remote_address_with_forwarded(request)}"
# Custom rate limit key function for API key based limiting
def api_key_rate_limit_key(request: Request):
return get_api_key_identifier(request)
# Rate limiting decorators for different endpoint types
webhook_limiter = Limiter(
key_func=api_key_rate_limit_key, storage_uri=REDIS_URL if REDIS_URL else None
)
# Custom rate limit exceeded handler
def custom_rate_limit_handler(request: Request, exc: RateLimitExceeded):
"""Custom handler for rate limit exceeded."""
logger.warning(
f"Rate limit exceeded for {get_remote_address_with_forwarded(request)}: "
f"{exc.detail}"
)
response = _rate_limit_exceeded_handler(request, exc)
# Add custom headers
response.headers["X-RateLimit-Limit"] = str(exc.retry_after)
response.headers["X-RateLimit-Retry-After"] = str(exc.retry_after)
return response

View File

@@ -0,0 +1,289 @@
"""Reservation service layer for handling reservation database operations."""
import hashlib
from datetime import UTC, datetime
from typing import Optional
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from .db import AckedRequest, Customer, Reservation
from .schemas import ReservationData
class ReservationService:
"""Service for managing reservations and related operations.
Handles all database operations for reservations including creation,
retrieval, and acknowledgement tracking.
"""
def __init__(self, session: AsyncSession):
self.session = session
def _convert_reservation_data_to_db(
self, reservation_model: ReservationData, customer_id: int
) -> Reservation:
"""Convert ReservationData to Reservation model.
Args:
reservation_model: ReservationData instance
customer_id: Customer ID to link to
Returns:
Reservation instance ready for database insertion
"""
data = reservation_model.model_dump(exclude_none=True)
# Convert children_ages list to CSV string
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 foreign key
data["customer_id"] = customer_id
return Reservation(**data)
async def create_reservation(
self, reservation_data: ReservationData, customer_id: int
) -> Reservation:
"""Create a new reservation.
Args:
reservation_data: ReservationData containing reservation details
customer_id: ID of the customer making the reservation
Returns:
Created Reservation instance
"""
reservation = self._convert_reservation_data_to_db(
reservation_data, customer_id
)
self.session.add(reservation)
await self.session.commit()
await self.session.refresh(reservation)
return reservation
async def get_reservation_by_unique_id(
self, unique_id: str
) -> Optional[Reservation]:
"""Get a reservation by unique_id.
Args:
unique_id: The unique_id to search for
Returns:
Reservation instance if found, None otherwise
"""
result = await self.session.execute(
select(Reservation).where(Reservation.unique_id == unique_id)
)
return result.scalar_one_or_none()
async def get_reservation_by_md5_unique_id(
self, md5_unique_id: str
) -> Optional[Reservation]:
"""Get a reservation by md5_unique_id.
Args:
md5_unique_id: The MD5 hash of unique_id
Returns:
Reservation instance if found, None otherwise
"""
result = await self.session.execute(
select(Reservation).where(
Reservation.md5_unique_id == md5_unique_id
)
)
return result.scalar_one_or_none()
async def check_duplicate_reservation(
self, unique_id: str, md5_unique_id: str
) -> bool:
"""Check if a reservation already exists.
Args:
unique_id: The unique_id to check
md5_unique_id: The MD5 hash to check
Returns:
True if reservation exists, False otherwise
"""
existing = await self.get_reservation_by_unique_id(unique_id)
if existing:
return True
existing_md5 = await self.get_reservation_by_md5_unique_id(md5_unique_id)
return existing_md5 is not None
async def get_reservations_for_customer(
self, customer_id: int
) -> list[Reservation]:
"""Get all reservations for a customer.
Args:
customer_id: The customer ID
Returns:
List of Reservation instances
"""
result = await self.session.execute(
select(Reservation).where(Reservation.customer_id == customer_id)
)
return list(result.scalars().all())
async def get_reservations_with_filters(
self,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
hotel_code: Optional[str] = None,
) -> list[tuple[Reservation, Customer]]:
"""Get reservations with optional filters, joined with customers.
Args:
start_date: Filter by created_at >= this value
end_date: Filter by created_at <= this value
hotel_code: Filter by hotel code
Returns:
List of (Reservation, Customer) tuples
"""
query = select(Reservation, Customer).join(
Customer, Reservation.customer_id == Customer.id
)
filters = []
if start_date:
filters.append(Reservation.created_at >= start_date)
if end_date:
filters.append(Reservation.created_at <= end_date)
if hotel_code:
filters.append(Reservation.hotel_code == hotel_code)
if filters:
query = query.where(and_(*filters))
result = await self.session.execute(query)
return list(result.all())
async def get_unacknowledged_reservations(
self,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
hotel_code: Optional[str] = None,
username: Optional[str] = None,
client_id: Optional[str] = None,
) -> list[tuple[Reservation, Customer]]:
"""Get reservations that haven't been acknowledged by a client.
Prioritizes checking by username if provided, falls back to client_id for backward compatibility.
Args:
start_date: Filter by start date >= this value
end_date: Filter by end date <= this value
hotel_code: Filter by hotel code
username: The username of the client (preferred for lookup)
client_id: The client ID (fallback for backward compatibility)
Returns:
List of (Reservation, Customer) tuples that are unacknowledged
"""
# Get all acknowledged unique_ids for this client/username
if username:
acked_result = await self.session.execute(
select(AckedRequest.unique_id).where(
AckedRequest.username == username
)
)
else:
acked_result = await self.session.execute(
select(AckedRequest.unique_id).where(
AckedRequest.client_id == client_id
)
)
acked_md5_ids = {row[0] for row in acked_result.all()}
# Get all reservations with filters
all_reservations = await self.get_reservations_with_filters(
start_date, end_date, hotel_code
)
# Filter out acknowledged ones (comparing md5_unique_id)
return [
(res, cust)
for res, cust in all_reservations
if res.md5_unique_id not in acked_md5_ids
]
async def record_acknowledgement(
self, client_id: str, unique_id: str, username: Optional[str] = None
) -> AckedRequest:
"""Record that a client has acknowledged a reservation.
Args:
client_id: The client ID
unique_id: The unique_id of the reservation (md5_unique_id)
username: The username of the client making the request (optional)
Returns:
Created AckedRequest instance
"""
acked = AckedRequest(
client_id=client_id,
username=username,
unique_id=unique_id,
timestamp=datetime.now(UTC),
)
self.session.add(acked)
await self.session.commit()
await self.session.refresh(acked)
return acked
async def is_acknowledged(self, unique_id: str, username: Optional[str] = None, client_id: Optional[str] = None) -> bool:
"""Check if a reservation has been acknowledged by a client.
Prioritizes checking by username if provided, falls back to client_id for backward compatibility.
Args:
unique_id: The reservation unique_id
username: The username of the client (preferred for lookup)
client_id: The client ID (fallback for backward compatibility)
Returns:
True if acknowledged, False otherwise
"""
if username:
result = await self.session.execute(
select(AckedRequest).where(
and_(
AckedRequest.username == username,
AckedRequest.unique_id == unique_id,
)
)
)
else:
result = await self.session.execute(
select(AckedRequest).where(
and_(
AckedRequest.client_id == client_id,
AckedRequest.unique_id == unique_id,
)
)
)
return result.scalar_one_or_none() is not None
@staticmethod
def generate_md5_unique_id(unique_id: str) -> str:
"""Generate MD5 hash of unique_id.
Args:
unique_id: The unique_id to hash
Returns:
MD5 hash as hex string
"""
return hashlib.md5(unique_id.encode("utf-8")).hexdigest()

View File

@@ -0,0 +1,19 @@
#!/usr/bin/env python3
"""Startup script for the Wix Form Handler API."""
import os
import uvicorn
if __name__ == "__main__":
# db_path = "alpinebits.db" # Adjust path if needed
# if os.path.exists(db_path):
# os.remove(db_path)
uvicorn.run(
"alpine_bits_python.api:app",
host="0.0.0.0",
port=8080,
reload=True, # Enable auto-reload during development
log_level="info",
)

View File

@@ -0,0 +1,343 @@
"""Pydantic models for data validation in AlpineBits.
These models provide validation for data before it's passed to:
- SQLAlchemy database models
- AlpineBits XML generation
- API endpoints
Separating validation (Pydantic) from persistence (SQLAlchemy) and
from XML generation (xsdata) follows clean architecture principles.
"""
import hashlib
from datetime import date, datetime
from enum import Enum
from pydantic import BaseModel, EmailStr, Field, field_validator, model_validator
# Country name to ISO 3166-1 alpha-2 code mapping
COUNTRY_NAME_TO_CODE = {
# English names
"germany": "DE",
"italy": "IT",
"austria": "AT",
"switzerland": "CH",
"france": "FR",
"netherlands": "NL",
"belgium": "BE",
"spain": "ES",
"portugal": "PT",
"united kingdom": "GB",
"uk": "GB",
"czech republic": "CZ",
"poland": "PL",
"hungary": "HU",
"croatia": "HR",
"slovenia": "SI",
# German names
"deutschland": "DE",
"italien": "IT",
"österreich": "AT",
"schweiz": "CH",
"frankreich": "FR",
"niederlande": "NL",
"belgien": "BE",
"spanien": "ES",
"vereinigtes königreich": "GB",
"tschechien": "CZ",
"polen": "PL",
"ungarn": "HU",
"kroatien": "HR",
"slowenien": "SI",
# Italian names
"germania": "DE",
"italia": "IT",
"svizzera": "CH",
"francia": "FR",
"paesi bassi": "NL",
"belgio": "BE",
"spagna": "ES",
"portogallo": "PT",
"regno unito": "GB",
"repubblica ceca": "CZ",
"polonia": "PL",
"ungheria": "HU",
"croazia": "HR",
}
# phonetechtype enum 1,3,5 voice, fax, mobile
class PhoneTechType(Enum):
VOICE = "1"
FAX = "3"
MOBILE = "5"
class PhoneNumber(BaseModel):
"""Phone number with optional type."""
number: str = Field(..., min_length=1, max_length=50, pattern=r"^\+?[0-9\s\-()]+$")
tech_type: str | None = Field(None, pattern="^[135]$") # 1=voice, 3=fax, 5=mobile
@field_validator("number")
@classmethod
def clean_phone_number(cls, v: str) -> str:
"""Remove extra spaces from phone number."""
return " ".join(v.split())
class ReservationData(BaseModel):
"""Validated reservation data."""
unique_id: str = Field(..., min_length=1, max_length=200)
md5_unique_id: str | None = Field(None, min_length=1, max_length=32)
start_date: date
end_date: date
created_at: datetime = Field(default_factory=datetime.now)
num_adults: int = Field(..., ge=1)
num_children: int = Field(0, ge=0, le=10)
children_ages: list[int] = Field(default_factory=list)
hotel_code: str = Field(..., min_length=1, max_length=50)
hotel_name: str | None = Field(None, max_length=200)
offer: str | None = Field(None, max_length=500)
user_comment: str | None = Field(None, max_length=2000)
fbclid: str | None = Field(None, max_length=300)
gclid: str | None = Field(None, max_length=300)
# Advertising account IDs (populated conditionally based on fbclid/gclid)
meta_account_id: str | None = Field(None, max_length=200)
google_account_id: str | None = Field(None, max_length=200)
utm_source: str | None = Field(None, max_length=150)
utm_medium: str | None = Field(None, max_length=150)
utm_campaign: str | None = Field(None, max_length=150)
utm_term: str | None = Field(None, max_length=150)
utm_content: str | None = Field(None, max_length=150)
# RoomTypes fields (optional)
room_type_code: str | None = Field(None, min_length=1, max_length=8)
room_classification_code: str | None = Field(None, pattern=r"[0-9]+")
room_type: str | None = Field(None, pattern=r"^[1-5]$")
@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)
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", mode="before")
@classmethod
def normalize_country_code(cls, v: str | None) -> str | None:
"""Normalize country input to ISO 3166-1 alpha-2 code.
Handles:
- Country names in English, German, and Italian
- Already valid 2-letter codes (case-insensitive)
- None/empty values
Runs in 'before' mode to normalize before other validations.
This ensures that old data saved incorrectly in the database is
transformed into the correct format when retrieved, and that new
data is always normalized regardless of the source.
Args:
v: Country name or code (case-insensitive)
Returns:
2-letter ISO country code (uppercase) or None if input is None/empty
"""
if not v:
return None
# Convert to string and strip whitespace
country_input = str(v).strip()
if not country_input:
return None
# If already 2 letters, assume it's a country code (ISO 3166-1 alpha-2)
iso_country_code_length = 2
if len(country_input) == iso_country_code_length and country_input.isalpha():
return country_input.upper()
# Try to match as country name (case-insensitive)
country_lower = country_input.lower()
return COUNTRY_NAME_TO_CODE.get(country_lower, country_input)
@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

View File

@@ -1,740 +0,0 @@
from typing import Union, Optional, Any, TypeVar
from pydantic import BaseModel, ConfigDict, Field
from dataclasses import dataclass
from enum import Enum
# Import the generated classes
from .generated.alpinebits import OtaHotelResNotifRq, OtaResRetrieveRs, CommentName2
# Define type aliases for the two Customer types
NotifCustomer = OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGuests.ResGuest.Profiles.ProfileInfo.Profile.Customer
RetrieveCustomer = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGuests.ResGuest.Profiles.ProfileInfo.Profile.Customer
# Define type aliases for HotelReservationId types
NotifHotelReservationId = OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGlobalInfo.HotelReservationIds.HotelReservationId
RetrieveHotelReservationId = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.HotelReservationIds.HotelReservationId
# Define type aliases for Comments types
NotifComments = OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGlobalInfo.Comments
RetrieveComments = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.Comments
NotifComment = OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGlobalInfo.Comments.Comment
RetrieveComment = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.Comments.Comment
# 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
class OtaMessageType(Enum):
NOTIF = "notification" # For OtaHotelResNotifRq
RETRIEVE = "retrieve" # For OtaResRetrieveRs
@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 CustomerFactory:
"""Factory class to create Customer instances for both OtaHotelResNotifRq and OtaResRetrieveRs."""
@staticmethod
def create_notif_customer(data: CustomerData) -> NotifCustomer:
"""Create a Customer for OtaHotelResNotifRq."""
return CustomerFactory._create_customer(NotifCustomer, data)
@staticmethod
def create_retrieve_customer(data: CustomerData) -> RetrieveCustomer:
"""Create a Customer for OtaResRetrieveRs."""
return CustomerFactory._create_customer(RetrieveCustomer, data)
@staticmethod
def _create_customer(customer_class: type, data: CustomerData) -> Any:
"""Internal method to create a customer of the specified type."""
# Create PersonName
person_name = customer_class.PersonName(
given_name=data.given_name,
surname=data.surname,
name_prefix=data.name_prefix,
name_title=data.name_title,
)
# Create telephone list
telephones = []
for phone_number, phone_tech_type in data.phone_numbers:
telephone = customer_class.Telephone(
phone_number=phone_number,
phone_tech_type=phone_tech_type.value if phone_tech_type else None,
)
telephones.append(telephone)
# Create email if provided
email = None
if data.email_address:
remark = None
if data.email_newsletter is not None:
remark = f"newsletter:{'yes' if data.email_newsletter else 'no'}"
email = customer_class.Email(value=data.email_address, remark=remark)
# Create address if any address fields are provided
address = None
if any(
[data.address_line, data.city_name, data.postal_code, data.country_code]
):
country_name = None
if data.country_code:
country_name = customer_class.Address.CountryName(
code=data.country_code
)
address_remark = None
if data.address_catalog is not None:
address_remark = f"catalog:{'yes' if data.address_catalog else 'no'}"
address = customer_class.Address(
address_line=data.address_line,
city_name=data.city_name,
postal_code=data.postal_code,
country_name=country_name,
remark=address_remark,
)
# Create the customer
return customer_class(
person_name=person_name,
telephone=telephones,
email=email,
address=address,
gender=data.gender,
birth_date=data.birth_date,
language=data.language,
)
@staticmethod
def from_notif_customer(customer: NotifCustomer) -> CustomerData:
"""Convert a NotifCustomer back to CustomerData."""
return CustomerFactory._customer_to_data(customer)
@staticmethod
def from_retrieve_customer(customer: RetrieveCustomer) -> CustomerData:
"""Convert a RetrieveCustomer back to CustomerData."""
return CustomerFactory._customer_to_data(customer)
@staticmethod
def _customer_to_data(customer: Any) -> CustomerData:
"""Internal method to convert any customer type to CustomerData."""
# Extract phone numbers
phone_numbers = []
if customer.telephone:
for tel in customer.telephone:
phone_numbers.append(
(
tel.phone_number,
PhoneTechType(tel.phone_tech_type)
if tel.phone_tech_type
else None,
)
)
# Extract email info
email_address = None
email_newsletter = None
if customer.email:
email_address = customer.email.value
if customer.email.remark:
if "newsletter:yes" in customer.email.remark:
email_newsletter = True
elif "newsletter:no" in customer.email.remark:
email_newsletter = False
# Extract address info
address_line = None
city_name = None
postal_code = None
country_code = None
address_catalog = None
if customer.address:
address_line = customer.address.address_line
city_name = customer.address.city_name
postal_code = customer.address.postal_code
if customer.address.country_name:
country_code = customer.address.country_name.code
if customer.address.remark:
if "catalog:yes" in customer.address.remark:
address_catalog = True
elif "catalog:no" in customer.address.remark:
address_catalog = False
return CustomerData(
given_name=customer.person_name.given_name,
surname=customer.person_name.surname,
name_prefix=customer.person_name.name_prefix,
name_title=customer.person_name.name_title,
phone_numbers=phone_numbers,
email_address=email_address,
email_newsletter=email_newsletter,
address_line=address_line,
city_name=city_name,
postal_code=postal_code,
country_code=country_code,
address_catalog=address_catalog,
gender=customer.gender,
birth_date=customer.birth_date,
language=customer.language,
)
@dataclass
class HotelReservationIdData:
"""Simple data class to hold hotel reservation ID information without nested type constraints."""
res_id_type: str # Required field - pattern: [0-9]+
res_id_value: None | str = None # Max 64 characters
res_id_source: None | str = None # Max 64 characters
res_id_source_context: None | str = None # Max 64 characters
class HotelReservationIdFactory:
"""Factory class to create HotelReservationId instances for both OtaHotelResNotifRq and OtaResRetrieveRs."""
@staticmethod
def create_notif_hotel_reservation_id(
data: HotelReservationIdData,
) -> NotifHotelReservationId:
"""Create a HotelReservationId for OtaHotelResNotifRq."""
return HotelReservationIdFactory._create_hotel_reservation_id(
NotifHotelReservationId, data
)
@staticmethod
def create_retrieve_hotel_reservation_id(
data: HotelReservationIdData,
) -> RetrieveHotelReservationId:
"""Create a HotelReservationId for OtaResRetrieveRs."""
return HotelReservationIdFactory._create_hotel_reservation_id(
RetrieveHotelReservationId, data
)
@staticmethod
def _create_hotel_reservation_id(
hotel_reservation_id_class: type, data: HotelReservationIdData
) -> Any:
"""Internal method to create a hotel reservation id of the specified type."""
return hotel_reservation_id_class(
res_id_type=data.res_id_type,
res_id_value=data.res_id_value,
res_id_source=data.res_id_source,
res_id_source_context=data.res_id_source_context,
)
@staticmethod
def from_notif_hotel_reservation_id(
hotel_reservation_id: NotifHotelReservationId,
) -> HotelReservationIdData:
"""Convert a NotifHotelReservationId back to HotelReservationIdData."""
return HotelReservationIdFactory._hotel_reservation_id_to_data(
hotel_reservation_id
)
@staticmethod
def from_retrieve_hotel_reservation_id(
hotel_reservation_id: RetrieveHotelReservationId,
) -> HotelReservationIdData:
"""Convert a RetrieveHotelReservationId back to HotelReservationIdData."""
return HotelReservationIdFactory._hotel_reservation_id_to_data(
hotel_reservation_id
)
@staticmethod
def _hotel_reservation_id_to_data(
hotel_reservation_id: Any,
) -> HotelReservationIdData:
"""Internal method to convert any hotel reservation id type to HotelReservationIdData."""
return HotelReservationIdData(
res_id_type=hotel_reservation_id.res_id_type,
res_id_value=hotel_reservation_id.res_id_value,
res_id_source=hotel_reservation_id.res_id_source,
res_id_source_context=hotel_reservation_id.res_id_source_context,
)
@dataclass
class CommentListItemData:
"""Simple data class to hold comment list item information."""
value: str # The text content of the list item
list_item: str # Numeric identifier (pattern: [0-9]+)
language: str # Two-letter language code (pattern: [a-z][a-z])
@dataclass
class CommentData:
"""Simple data class to hold comment information without nested type constraints."""
name: CommentName2 # Required: "included services", "customer comment", "additional info"
text: Optional[str] = None # Optional text content
list_items: list[CommentListItemData] = None # Optional list items
def __post_init__(self):
if self.list_items is None:
self.list_items = []
@dataclass
class CommentsData:
"""Simple data class to hold multiple comments (1-3 max)."""
comments: list[CommentData] = None # 1-3 comments maximum
def __post_init__(self):
if self.comments is None:
self.comments = []
class CommentFactory:
"""Factory class to create Comment instances for both OtaHotelResNotifRq and OtaResRetrieveRs."""
@staticmethod
def create_notif_comments(data: CommentsData) -> NotifComments:
"""Create Comments for OtaHotelResNotifRq."""
return CommentFactory._create_comments(NotifComments, NotifComment, data)
@staticmethod
def create_retrieve_comments(data: CommentsData) -> RetrieveComments:
"""Create Comments for OtaResRetrieveRs."""
return CommentFactory._create_comments(RetrieveComments, RetrieveComment, data)
@staticmethod
def _create_comments(comments_class: type, comment_class: type, data: CommentsData) -> Any:
"""Internal method to create comments of the specified type."""
comments_list = []
for comment_data in data.comments:
# Create list items
list_items = []
for item_data in comment_data.list_items:
list_item = comment_class.ListItem(
value=item_data.value,
list_item=item_data.list_item,
language=item_data.language
)
list_items.append(list_item)
# Create comment
comment = comment_class(
name=comment_data.name,
text=comment_data.text,
list_item=list_items
)
comments_list.append(comment)
# Create comments container
return comments_class(comment=comments_list)
@staticmethod
def from_notif_comments(comments: NotifComments) -> CommentsData:
"""Convert NotifComments back to CommentsData."""
return CommentFactory._comments_to_data(comments)
@staticmethod
def from_retrieve_comments(comments: RetrieveComments) -> CommentsData:
"""Convert RetrieveComments back to CommentsData."""
return CommentFactory._comments_to_data(comments)
@staticmethod
def _comments_to_data(comments: Any) -> CommentsData:
"""Internal method to convert any comments type to CommentsData."""
comments_data_list = []
for comment in comments.comment:
# Extract list items
list_items_data = []
if comment.list_item:
for list_item in comment.list_item:
list_items_data.append(CommentListItemData(
value=list_item.value,
list_item=list_item.list_item,
language=list_item.language
))
# Extract comment data
comment_data = CommentData(
name=comment.name,
text=comment.text,
list_items=list_items_data
)
comments_data_list.append(comment_data)
return CommentsData(comments=comments_data_list)
# Define type aliases for ResGuests types
NotifResGuests = OtaHotelResNotifRq.HotelReservations.HotelReservation.ResGuests
RetrieveResGuests = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGuests
class ResGuestFactory:
"""Factory class to create complete ResGuests structures with a primary customer."""
@staticmethod
def create_notif_res_guests(customer_data: CustomerData) -> NotifResGuests:
"""Create a complete ResGuests structure for OtaHotelResNotifRq with primary customer."""
return ResGuestFactory._create_res_guests(
NotifResGuests, NotifCustomer, customer_data
)
@staticmethod
def create_retrieve_res_guests(customer_data: CustomerData) -> RetrieveResGuests:
"""Create a complete ResGuests structure for OtaResRetrieveRs with primary customer."""
return ResGuestFactory._create_res_guests(
RetrieveResGuests, RetrieveCustomer, customer_data
)
@staticmethod
def _create_res_guests(
res_guests_class: type, customer_class: type, customer_data: CustomerData
) -> Any:
"""Internal method to create complete ResGuests structure."""
# Create the customer using the existing CustomerFactory
customer = CustomerFactory._create_customer(customer_class, customer_data)
# Create Profile with the customer
profile = res_guests_class.ResGuest.Profiles.ProfileInfo.Profile(
customer=customer
)
# Create ProfileInfo with the profile
profile_info = res_guests_class.ResGuest.Profiles.ProfileInfo(profile=profile)
# Create Profiles with the profile_info
profiles = res_guests_class.ResGuest.Profiles(profile_info=profile_info)
# Create ResGuest with the profiles
res_guest = res_guests_class.ResGuest(profiles=profiles)
# Create ResGuests with the res_guest
return res_guests_class(res_guest=res_guest)
@staticmethod
def extract_primary_customer(
res_guests: Union[NotifResGuests, RetrieveResGuests],
) -> CustomerData:
"""Extract the primary customer data from a ResGuests structure."""
# Navigate down the nested structure to get the customer
customer = res_guests.res_guest.profiles.profile_info.profile.customer
# Use the existing CustomerFactory conversion method
if isinstance(res_guests, NotifResGuests):
return CustomerFactory.from_notif_customer(customer)
else:
return CustomerFactory.from_retrieve_customer(customer)
class AlpineBitsFactory:
"""Unified factory class for creating AlpineBits objects with a simple interface."""
@staticmethod
def create(data: Union[CustomerData, HotelReservationIdData, CommentsData], message_type: OtaMessageType) -> Any:
"""
Create an AlpineBits object based on the data type and message type.
Args:
data: The data object (CustomerData, HotelReservationIdData, CommentsData, etc.)
message_type: Whether to create for NOTIF or RETRIEVE message types
Returns:
The appropriate AlpineBits object based on the data type and message type
"""
if isinstance(data, CustomerData):
if message_type == OtaMessageType.NOTIF:
return CustomerFactory.create_notif_customer(data)
else:
return CustomerFactory.create_retrieve_customer(data)
elif isinstance(data, HotelReservationIdData):
if message_type == OtaMessageType.NOTIF:
return HotelReservationIdFactory.create_notif_hotel_reservation_id(data)
else:
return HotelReservationIdFactory.create_retrieve_hotel_reservation_id(data)
elif isinstance(data, CommentsData):
if message_type == OtaMessageType.NOTIF:
return CommentFactory.create_notif_comments(data)
else:
return CommentFactory.create_retrieve_comments(data)
else:
raise ValueError(f"Unsupported data type: {type(data)}")
@staticmethod
def create_res_guests(customer_data: CustomerData, message_type: OtaMessageType) -> Union[NotifResGuests, RetrieveResGuests]:
"""
Create a complete ResGuests structure with a primary customer.
Args:
customer_data: The customer data
message_type: Whether to create for NOTIF or RETRIEVE message types
Returns:
The appropriate ResGuests object
"""
if message_type == OtaMessageType.NOTIF:
return ResGuestFactory.create_notif_res_guests(customer_data)
else:
return ResGuestFactory.create_retrieve_res_guests(customer_data)
@staticmethod
def extract_data(obj: Any) -> Union[CustomerData, HotelReservationIdData, CommentsData]:
"""
Extract data from an AlpineBits object back to a simple data class.
Args:
obj: The AlpineBits object to extract data from
Returns:
The appropriate data object
"""
# Check if it's a Customer object
if hasattr(obj, 'person_name') and hasattr(obj.person_name, 'given_name'):
if isinstance(obj, NotifCustomer):
return CustomerFactory.from_notif_customer(obj)
elif isinstance(obj, RetrieveCustomer):
return CustomerFactory.from_retrieve_customer(obj)
# Check if it's a HotelReservationId object
elif hasattr(obj, 'res_id_type'):
if isinstance(obj, NotifHotelReservationId):
return HotelReservationIdFactory.from_notif_hotel_reservation_id(obj)
elif isinstance(obj, RetrieveHotelReservationId):
return HotelReservationIdFactory.from_retrieve_hotel_reservation_id(obj)
# Check if it's a Comments object
elif hasattr(obj, 'comment'):
if isinstance(obj, NotifComments):
return CommentFactory.from_notif_comments(obj)
elif isinstance(obj, RetrieveComments):
return CommentFactory.from_retrieve_comments(obj)
# Check if it's a ResGuests object
elif hasattr(obj, 'res_guest'):
return ResGuestFactory.extract_primary_customer(obj)
else:
raise ValueError(f"Unsupported object type: {type(obj)}")
# Usage examples
if __name__ == "__main__":
# Create customer data using simple data class
customer_data = CustomerData(
given_name="John",
surname="Doe",
name_prefix="Mr.",
phone_numbers=[
("+1234567890", PhoneTechType.MOBILE), # Phone number with type
("+0987654321", None), # Phone number without type
],
email_address="john.doe@example.com",
email_newsletter=True,
address_line="123 Main Street",
city_name="Anytown",
postal_code="12345",
country_code="US",
address_catalog=False,
gender="Male",
birth_date="1980-01-01",
language="en",
)
# Create customer for OtaHotelResNotifRq
notif_customer = CustomerFactory.create_notif_customer(customer_data)
print(
"Created NotifCustomer:",
notif_customer.person_name.given_name,
notif_customer.person_name.surname,
)
# Create customer for OtaResRetrieveRs
retrieve_customer = CustomerFactory.create_retrieve_customer(customer_data)
print(
"Created RetrieveCustomer:",
retrieve_customer.person_name.given_name,
retrieve_customer.person_name.surname,
)
# Convert back to data class
converted_data = CustomerFactory.from_notif_customer(notif_customer)
print("Converted back to data:", converted_data.given_name, converted_data.surname)
# Verify they contain the same information
print("Original and converted data match:", customer_data == converted_data)
print("\n--- HotelReservationIdFactory Examples ---")
# Create hotel reservation ID data
reservation_id_data = HotelReservationIdData(
res_id_type="123",
res_id_value="RESERVATION-456",
res_id_source="HOTEL_SYSTEM",
res_id_source_context="BOOKING_ENGINE",
)
# Create HotelReservationId for both types
notif_res_id = HotelReservationIdFactory.create_notif_hotel_reservation_id(
reservation_id_data
)
retrieve_res_id = HotelReservationIdFactory.create_retrieve_hotel_reservation_id(
reservation_id_data
)
print(
"Created NotifHotelReservationId:",
notif_res_id.res_id_type,
notif_res_id.res_id_value,
)
print(
"Created RetrieveHotelReservationId:",
retrieve_res_id.res_id_type,
retrieve_res_id.res_id_value,
)
# Convert back to data class
converted_res_id_data = HotelReservationIdFactory.from_notif_hotel_reservation_id(
notif_res_id
)
print(
"Converted back to reservation ID data:",
converted_res_id_data.res_id_type,
converted_res_id_data.res_id_value,
)
# Verify they contain the same information
print(
"Original and converted reservation ID data match:",
reservation_id_data == converted_res_id_data,
)
print("\n--- ResGuestFactory Examples ---")
# Create complete ResGuests structure for OtaHotelResNotifRq - much simpler!
notif_res_guests = ResGuestFactory.create_notif_res_guests(customer_data)
print(
"Created NotifResGuests with customer:",
notif_res_guests.res_guest.profiles.profile_info.profile.customer.person_name.given_name,
)
# Create complete ResGuests structure for OtaResRetrieveRs - much simpler!
retrieve_res_guests = ResGuestFactory.create_retrieve_res_guests(customer_data)
print(
"Created RetrieveResGuests with customer:",
retrieve_res_guests.res_guest.profiles.profile_info.profile.customer.person_name.given_name,
)
# Extract primary customer data back from ResGuests structure
extracted_data = ResGuestFactory.extract_primary_customer(retrieve_res_guests)
print("Extracted customer data:", extracted_data.given_name, extracted_data.surname)
# Verify roundtrip conversion
print("Roundtrip conversion successful:", customer_data == extracted_data)
print("\n--- Unified AlpineBitsFactory Examples ---")
# Much simpler approach - single factory with enum parameter!
print("=== Customer Creation ===")
notif_customer = AlpineBitsFactory.create(customer_data, OtaMessageType.NOTIF)
retrieve_customer = AlpineBitsFactory.create(customer_data, OtaMessageType.RETRIEVE)
print("Created customers using unified factory")
print("=== HotelReservationId Creation ===")
reservation_id_data = HotelReservationIdData(
res_id_type="123",
res_id_value="RESERVATION-456",
res_id_source="HOTEL_SYSTEM"
)
notif_res_id = AlpineBitsFactory.create(reservation_id_data, OtaMessageType.NOTIF)
retrieve_res_id = AlpineBitsFactory.create(reservation_id_data, OtaMessageType.RETRIEVE)
print("Created reservation IDs using unified factory")
print("=== Comments Creation ===")
comments_data = CommentsData(comments=[
CommentData(
name=CommentName2.CUSTOMER_COMMENT,
text="This is a customer comment about the reservation",
list_items=[
CommentListItemData(
value="Special dietary requirements: vegetarian",
list_item="1",
language="en"
),
CommentListItemData(
value="Late arrival expected",
list_item="2",
language="en"
)
]
),
CommentData(
name=CommentName2.ADDITIONAL_INFO,
text="Additional information about the stay"
)
])
notif_comments = AlpineBitsFactory.create(comments_data, OtaMessageType.NOTIF)
retrieve_comments = AlpineBitsFactory.create(comments_data, OtaMessageType.RETRIEVE)
print("Created comments using unified factory")
print("=== ResGuests Creation ===")
notif_res_guests = AlpineBitsFactory.create_res_guests(customer_data, OtaMessageType.NOTIF)
retrieve_res_guests = AlpineBitsFactory.create_res_guests(customer_data, OtaMessageType.RETRIEVE)
print("Created ResGuests using unified factory")
print("=== Data Extraction ===")
# Extract data back using unified interface
extracted_customer_data = AlpineBitsFactory.extract_data(notif_customer)
extracted_res_id_data = AlpineBitsFactory.extract_data(notif_res_id)
extracted_comments_data = AlpineBitsFactory.extract_data(retrieve_comments)
extracted_from_res_guests = AlpineBitsFactory.extract_data(retrieve_res_guests)
print("Data extraction successful:")
print("- Customer roundtrip:", customer_data == extracted_customer_data)
print("- ReservationId roundtrip:", reservation_id_data == extracted_res_id_data)
print("- Comments roundtrip:", comments_data == extracted_comments_data)
print("- ResGuests roundtrip:", customer_data == extracted_from_res_guests)
print("\n--- Comparison with old approach ---")
print("Old way required multiple imports and knowing specific factory methods")
print("New way: single import, single factory, enum parameter to specify type!")

View File

@@ -0,0 +1,108 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>99 Tales - Under Construction</title>
<style>
body {
margin: 0;
padding: 0;
font-family: 'Arial', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
}
.container {
max-width: 600px;
padding: 2rem;
background: rgba(255, 255, 255, 0.1);
border-radius: 20px;
backdrop-filter: blur(10px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
h1 {
font-size: 3rem;
margin-bottom: 1rem;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
}
.subtitle {
font-size: 1.5rem;
margin-bottom: 2rem;
opacity: 0.9;
}
.description {
font-size: 1.1rem;
line-height: 1.6;
margin-bottom: 2rem;
opacity: 0.8;
}
.construction-icon {
font-size: 4rem;
margin-bottom: 1rem;
animation: bounce 2s infinite;
}
@keyframes bounce {
0%, 20%, 50%, 80%, 100% {
transform: translateY(0);
}
40% {
transform: translateY(-10px);
}
60% {
transform: translateY(-5px);
}
}
.contact-info {
margin-top: 2rem;
font-size: 0.9rem;
opacity: 0.7;
}
.api-link {
display: inline-block;
margin-top: 1rem;
padding: 0.5rem 1rem;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 10px;
color: white;
text-decoration: none;
transition: all 0.3s ease;
}
.api-link:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
}
</style>
</head>
<body>
<div class="container">
<div class="construction-icon">🏗️</div>
<h1>99 Tales</h1>
<div class="subtitle">Coming Soon</div>
<div class="description">
We're working hard to bring you something amazing. Our team is putting the finishing touches on an exciting new experience.
</div>
<div class="description">
Thank you for your patience while we build something special for you.
</div>
<a href="/api" class="api-link">API Documentation</a>
<div class="contact-info">
Check back soon for updates!
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,390 @@
"""Unified monitoring with support for multiple notification backends.
This module provides alert handlers and schedulers that work with the
unified notification service to send alerts through multiple channels.
"""
import asyncio
import logging
import threading
from collections import deque
from datetime import datetime, timedelta
from typing import Any
from .email_monitoring import ErrorRecord, ReservationStatsCollector
from .logging_config import get_logger
from .notification_service import NotificationService
_LOGGER = get_logger(__name__)
class UnifiedAlertHandler(logging.Handler):
"""Custom logging handler that sends alerts through unified notification service.
This handler uses a hybrid approach:
- Accumulates errors in a buffer
- Sends immediately if error threshold is reached
- Otherwise sends after buffer duration expires
- Always sends buffered errors (no minimum threshold for time-based flush)
- Implements cooldown to prevent alert spam
The handler is thread-safe and works with asyncio event loops.
"""
def __init__(
self,
notification_service: NotificationService,
config: dict[str, Any],
loop: asyncio.AbstractEventLoop | None = None,
):
"""Initialize the unified alert handler.
Args:
notification_service: Unified notification service
config: Configuration dictionary for error alerts
loop: Asyncio event loop (will use current loop if not provided)
"""
super().__init__()
self.notification_service = notification_service
self.config = config
self.loop = loop # Will be set when first error occurs if not provided
# Configuration
self.error_threshold = config.get("error_threshold", 5)
self.buffer_minutes = config.get("buffer_minutes", 15)
self.cooldown_minutes = config.get("cooldown_minutes", 15)
self.log_levels = config.get("log_levels", ["ERROR", "CRITICAL"])
# State
self.error_buffer: deque[ErrorRecord] = deque()
self.last_sent = datetime.min # Last time we sent an alert
self._flush_task: asyncio.Task | None = None
self._lock = threading.Lock() # Thread-safe for multi-threaded logging
_LOGGER.info(
"UnifiedAlertHandler initialized: threshold=%d, buffer=%dmin, cooldown=%dmin",
self.error_threshold,
self.buffer_minutes,
self.cooldown_minutes,
)
def emit(self, record: logging.LogRecord) -> None:
"""Handle a log record.
This is called automatically by the logging system when an error is logged.
It's important that this method is fast and doesn't block.
Args:
record: The log record to handle
"""
# Only handle configured log levels
if record.levelname not in self.log_levels:
return
try:
# Ensure we have an event loop
if self.loop is None:
try:
self.loop = asyncio.get_running_loop()
except RuntimeError:
# No running loop, we'll need to handle this differently
_LOGGER.warning("No asyncio event loop available for alerts")
return
# Add error to buffer (thread-safe)
with self._lock:
error_record = ErrorRecord(record)
self.error_buffer.append(error_record)
buffer_size = len(self.error_buffer)
# Determine if we should send immediately
should_send_immediately = buffer_size >= self.error_threshold
if should_send_immediately:
# Cancel any pending flush task
if self._flush_task and not self._flush_task.done():
self._flush_task.cancel()
# Schedule immediate flush
self._flush_task = asyncio.run_coroutine_threadsafe(
self._flush_buffer(immediate=True),
self.loop,
)
# Schedule delayed flush if not already scheduled
elif not self._flush_task or self._flush_task.done():
self._flush_task = asyncio.run_coroutine_threadsafe(
self._schedule_delayed_flush(),
self.loop,
)
except Exception:
# Never let the handler crash - just log and continue
_LOGGER.exception("Error in UnifiedAlertHandler.emit")
async def _schedule_delayed_flush(self) -> None:
"""Schedule a delayed buffer flush after buffer duration."""
await asyncio.sleep(self.buffer_minutes * 60)
await self._flush_buffer(immediate=False)
async def _flush_buffer(self, *, immediate: bool) -> None:
"""Flush the error buffer and send alert.
Args:
immediate: Whether this is an immediate flush (threshold hit)
"""
# Check cooldown period
now = datetime.now()
time_since_last = (now - self.last_sent).total_seconds() / 60
if time_since_last < self.cooldown_minutes:
_LOGGER.info(
"Alert cooldown active (%.1f min remaining), buffering errors",
self.cooldown_minutes - time_since_last,
)
# Don't clear buffer - let errors accumulate until cooldown expires
return
# Get all buffered errors (thread-safe)
with self._lock:
if not self.error_buffer:
return
errors = list(self.error_buffer)
self.error_buffer.clear()
# Update last sent time
self.last_sent = now
# Format alert
error_count = len(errors)
time_range = (
f"{errors[0].timestamp.strftime('%H:%M:%S')} to "
f"{errors[-1].timestamp.strftime('%H:%M:%S')}"
)
# Determine alert type
alert_type = "Immediate Alert" if immediate else "Scheduled Alert"
if immediate:
reason = f"(threshold of {self.error_threshold} exceeded)"
else:
reason = f"({self.buffer_minutes} minute buffer)"
title = f"AlpineBits Error {alert_type}: {error_count} errors {reason}"
# Build message
message = f"Error Alert - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
message += "=" * 70 + "\n\n"
message += f"Alert Type: {alert_type}\n"
message += f"Error Count: {error_count}\n"
message += f"Time Range: {time_range}\n"
message += f"Reason: {reason}\n"
message += "\n" + "=" * 70 + "\n\n"
# Add individual errors
message += "Errors:\n"
message += "-" * 70 + "\n\n"
for error in errors:
message += error.format_plain_text()
message += "\n"
message += "-" * 70 + "\n"
message += f"Generated by AlpineBits Monitoring at {now.strftime('%Y-%m-%d %H:%M:%S')}\n"
# Send through unified notification service
try:
results = await self.notification_service.send_alert(
title=title,
message=message,
backends=None, # Send to all backends
)
success_count = sum(1 for success in results.values() if success)
if success_count > 0:
_LOGGER.info(
"Alert sent successfully through %d/%d backend(s): %d errors",
success_count,
len(results),
error_count,
)
else:
_LOGGER.error("Failed to send alert through any backend: %d errors", error_count)
except Exception:
_LOGGER.exception("Exception while sending alert")
def close(self) -> None:
"""Close the handler and flush any remaining errors.
This is called when the logging system shuts down.
"""
# Cancel any pending flush tasks
if self._flush_task and not self._flush_task.done():
self._flush_task.cancel()
# Flush any remaining errors immediately
if self.error_buffer and self.loop:
try:
# Check if the loop is still running
if not self.loop.is_closed():
future = asyncio.run_coroutine_threadsafe(
self._flush_buffer(immediate=False),
self.loop,
)
future.result(timeout=5)
else:
_LOGGER.warning(
"Event loop closed, cannot flush %d remaining errors",
len(self.error_buffer),
)
except Exception:
_LOGGER.exception("Error flushing buffer on close")
super().close()
class UnifiedDailyReportScheduler:
"""Scheduler for sending daily reports through unified notification service.
This runs as a background task and sends daily reports containing
statistics and error summaries through all configured notification backends.
"""
def __init__(
self,
notification_service: NotificationService,
config: dict[str, Any],
):
"""Initialize the unified daily report scheduler.
Args:
notification_service: Unified notification service
config: Configuration for daily reports
"""
self.notification_service = notification_service
self.config = config
self.send_time = config.get("send_time", "08:00") # Default 8 AM
self.include_stats = config.get("include_stats", True)
self.include_errors = config.get("include_errors", True)
self._task: asyncio.Task | None = None
self._stats_collector = None # Will be set by application
self._error_log: list[dict[str, Any]] = []
_LOGGER.info(
"UnifiedDailyReportScheduler initialized: send_time=%s",
self.send_time,
)
def start(self) -> None:
"""Start the daily report scheduler."""
if self._task is None or self._task.done():
self._task = asyncio.create_task(self._run())
_LOGGER.info("Daily report scheduler started")
def stop(self) -> None:
"""Stop the daily report scheduler."""
if self._task and not self._task.done():
self._task.cancel()
_LOGGER.info("Daily report scheduler stopped")
def log_error(self, error: dict[str, Any]) -> None:
"""Log an error for inclusion in daily report.
Args:
error: Error information dictionary
"""
self._error_log.append(error)
async def _run(self) -> None:
"""Run the daily report scheduler loop."""
while True:
try:
# Calculate time until next report
now = datetime.now()
target_hour, target_minute = map(int, self.send_time.split(":"))
# Calculate next send time
next_send = now.replace(
hour=target_hour,
minute=target_minute,
second=0,
microsecond=0,
)
# If time has passed today, schedule for tomorrow
if next_send <= now:
next_send += timedelta(days=1)
# Calculate sleep duration
sleep_seconds = (next_send - now).total_seconds()
_LOGGER.info(
"Next daily report scheduled for %s (in %.1f hours)",
next_send.strftime("%Y-%m-%d %H:%M:%S"),
sleep_seconds / 3600,
)
# Wait until send time
await asyncio.sleep(sleep_seconds)
# Send report
await self._send_report()
except asyncio.CancelledError:
_LOGGER.info("Daily report scheduler cancelled")
break
except Exception:
_LOGGER.exception("Error in daily report scheduler")
# Sleep a bit before retrying
await asyncio.sleep(60)
async def _send_report(self) -> None:
"""Send the daily report."""
stats = {}
# Collect statistics if enabled
if self.include_stats and self._stats_collector:
try:
stats = await self._stats_collector()
except Exception:
_LOGGER.exception("Error collecting statistics for daily report")
# Get errors if enabled
errors = self._error_log.copy() if self.include_errors else None
# Send report through unified notification service
try:
results = await self.notification_service.send_daily_report(
stats=stats,
errors=errors,
backends=None, # Send to all backends
)
success_count = sum(1 for success in results.values() if success)
if success_count > 0:
_LOGGER.info(
"Daily report sent successfully through %d/%d backend(s)",
success_count,
len(results),
)
# Clear error log after successful send
self._error_log.clear()
else:
_LOGGER.error("Failed to send daily report through any backend")
except Exception:
_LOGGER.exception("Exception while sending daily report")
def set_stats_collector(self, collector) -> None:
"""Set the statistics collector function.
Args:
collector: Async function that returns statistics dictionary
"""
self._stats_collector = collector

View File

@@ -1 +1 @@
"""Utility functions for alpine_bits_python."""
"""Utility functions for alpine_bits_python."""

View File

@@ -1,5 +1,6 @@
"""Entry point for util package."""
from .handshake_util import main
if __name__ == "__main__":
main()
main()

View File

@@ -0,0 +1,274 @@
#!/usr/bin/env python3
"""Fix PostgreSQL sequences and migrate datetime columns after SQLite migration.
This script performs two operations:
1. Migrates DateTime columns to TIMESTAMP WITH TIME ZONE for timezone-aware support
2. Resets all ID sequence values to match the current maximum ID in each table
The sequence reset is necessary because the migration script inserts records
with explicit IDs, which doesn't automatically advance PostgreSQL sequences.
The datetime migration ensures proper handling of timezone-aware datetimes,
which is required by the application code.
Schema Support:
The script automatically detects and uses the schema configured in your config file.
If you have database.schema: "alpinebits" in your config, it will work with that schema.
Usage:
# Using default config.yaml (includes schema if configured)
uv run python -m alpine_bits_python.util.fix_postgres_sequences
# Using a specific config file (with schema support)
uv run python -m alpine_bits_python.util.fix_postgres_sequences \
--config config/postgres.yaml
# Using DATABASE_URL environment variable (schema from config or DATABASE_SCHEMA env var)
DATABASE_URL="postgresql+asyncpg://user:pass@host/db" \
DATABASE_SCHEMA="alpinebits" \
uv run python -m alpine_bits_python.util.fix_postgres_sequences
# Using command line argument (schema from config)
uv run python -m alpine_bits_python.util.fix_postgres_sequences \
--database-url postgresql+asyncpg://user:pass@host/db
"""
import argparse
import asyncio
import os
import sys
from pathlib import Path
# Add parent directory to path so we can import alpine_bits_python
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
import yaml
from sqlalchemy import text
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from alpine_bits_python.db import get_database_schema, get_database_url
from alpine_bits_python.logging_config import get_logger
_LOGGER = get_logger(__name__)
async def migrate_datetime_columns(session, schema_prefix: str = "") -> None:
"""Migrate DateTime columns to TIMESTAMP WITH TIME ZONE.
This updates the columns to properly handle timezone-aware datetimes.
Args:
session: Database session
schema_prefix: Schema prefix (e.g., "alpinebits." or "")
"""
_LOGGER.info("\nMigrating DateTime columns to timezone-aware...")
datetime_columns = [
("hashed_customers", "created_at"),
("reservations", "created_at"),
("acked_requests", "timestamp"),
]
for table_name, column_name in datetime_columns:
full_table = f"{schema_prefix}{table_name}"
_LOGGER.info(f" {full_table}.{column_name}: Converting to TIMESTAMPTZ")
await session.execute(
text(
f"ALTER TABLE {full_table} "
f"ALTER COLUMN {column_name} TYPE TIMESTAMP WITH TIME ZONE"
)
)
await session.commit()
_LOGGER.info("✓ DateTime columns migrated to timezone-aware")
async def fix_sequences(database_url: str, schema_name: str = None) -> None:
"""Fix PostgreSQL sequences to match current max IDs and migrate datetime columns.
Args:
database_url: PostgreSQL database URL
schema_name: Schema name (e.g., "alpinebits") or None for public
"""
_LOGGER.info("=" * 70)
_LOGGER.info("PostgreSQL Migration & Sequence Fix")
_LOGGER.info("=" * 70)
_LOGGER.info(
"Database: %s",
database_url.split("@")[-1] if "@" in database_url else database_url,
)
if schema_name:
_LOGGER.info("Schema: %s", schema_name)
_LOGGER.info("=" * 70)
# Create engine and session with schema support
connect_args = {}
if schema_name:
connect_args = {
"server_settings": {"search_path": f"{schema_name},public"}
}
engine = create_async_engine(database_url, echo=False, connect_args=connect_args)
SessionMaker = async_sessionmaker(engine, expire_on_commit=False)
# Determine schema prefix for SQL statements
schema_prefix = f"{schema_name}." if schema_name else ""
try:
# Migrate datetime columns first
async with SessionMaker() as session:
await migrate_datetime_columns(session, schema_prefix)
# Then fix sequences
async with SessionMaker() as session:
# List of tables and their sequence names
tables = [
("customers", "customers_id_seq"),
("hashed_customers", "hashed_customers_id_seq"),
("reservations", "reservations_id_seq"),
("acked_requests", "acked_requests_id_seq"),
("conversions", "conversions_id_seq"),
]
_LOGGER.info("\nResetting sequences...")
for table_name, sequence_name in tables:
full_table = f"{schema_prefix}{table_name}"
full_sequence = f"{schema_prefix}{sequence_name}"
# Get current max ID
result = await session.execute(
text(f"SELECT MAX(id) FROM {full_table}")
)
max_id = result.scalar()
# Get current sequence value
result = await session.execute(
text(f"SELECT last_value FROM {full_sequence}")
)
current_seq = result.scalar()
if max_id is None:
_LOGGER.info(f" {full_table}: empty table, setting sequence to 1")
await session.execute(
text(f"SELECT setval('{full_sequence}', 1, false)")
)
elif current_seq <= max_id:
new_seq = max_id + 1
_LOGGER.info(
f" {full_table}: max_id={max_id}, "
f"old_seq={current_seq}, new_seq={new_seq}"
)
await session.execute(
text(f"SELECT setval('{full_sequence}', {new_seq}, false)")
)
else:
_LOGGER.info(
f" {full_table}: sequence already correct "
f"(max_id={max_id}, seq={current_seq})"
)
await session.commit()
_LOGGER.info("\n" + "=" * 70)
_LOGGER.info("✓ Migration completed successfully!")
_LOGGER.info("=" * 70)
_LOGGER.info("\nChanges applied:")
_LOGGER.info(" 1. DateTime columns are now timezone-aware (TIMESTAMPTZ)")
_LOGGER.info(" 2. Sequences are reset to match current max IDs")
_LOGGER.info("\nYou can now insert new records without conflicts.")
except Exception as e:
_LOGGER.exception("Failed to fix sequences: %s", e)
raise
finally:
await engine.dispose()
async def main():
"""Run the sequence fix."""
parser = argparse.ArgumentParser(
description="Fix PostgreSQL sequences after SQLite migration",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument(
"--database-url",
help="PostgreSQL database URL (default: from config or DATABASE_URL env var)",
)
parser.add_argument(
"--config",
help=(
"Path to config file containing PostgreSQL database URL "
"(keeps password out of bash history)"
),
)
args = parser.parse_args()
# Determine database URL and schema
schema_name = None
if args.config:
# Load config file as plain YAML (no validation)
_LOGGER.info("Loading database config from: %s", args.config)
try:
with open(args.config) as f:
config = yaml.safe_load(f)
# Get database URL
if not config or "database" not in config or "url" not in config["database"]:
_LOGGER.error("Config file must contain database.url")
sys.exit(1)
database_url = config["database"]["url"]
schema_name = config.get("database", {}).get("schema")
_LOGGER.info("Successfully loaded config")
except FileNotFoundError:
_LOGGER.error("Config file not found: %s", args.config)
sys.exit(1)
except yaml.YAMLError as e:
_LOGGER.error("Failed to parse YAML config: %s", e)
sys.exit(1)
elif args.database_url:
database_url = args.database_url
# Get schema from environment variable
schema_name = os.environ.get("DATABASE_SCHEMA")
else:
# Try environment variable or default config.yaml
database_url = os.environ.get("DATABASE_URL")
schema_name = os.environ.get("DATABASE_SCHEMA")
if not database_url:
# Try to load from default config.yaml as plain YAML
try:
with open("config/config.yaml") as f:
config = yaml.safe_load(f)
database_url = config.get("database", {}).get("url")
if not schema_name:
schema_name = config.get("database", {}).get("schema")
except Exception:
pass # Ignore if default config doesn't exist
if not database_url:
_LOGGER.error("No database URL provided")
_LOGGER.error("Provide via --config, --database-url, or DATABASE_URL env var")
sys.exit(1)
if "postgresql" not in database_url and "postgres" not in database_url:
_LOGGER.error("This script only works with PostgreSQL databases.")
url_type = database_url.split("+")[0] if "+" in database_url else "unknown"
_LOGGER.error("Current database URL type detected: %s", url_type)
_LOGGER.error("\nSpecify PostgreSQL database using one of:")
_LOGGER.error(" - --config config/postgres.yaml")
_LOGGER.error(" - DATABASE_URL environment variable")
_LOGGER.error(" - --database-url postgresql+asyncpg://user:pass@host/db")
sys.exit(1)
# Run the fix
await fix_sequences(database_url, schema_name)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -1,52 +0,0 @@
from ..generated.alpinebits import OtaPingRq, OtaPingRs
from xsdata_pydantic.bindings import XmlParser
def main():
# test parsing a ping request sample
path = "AlpineBits-HotelData-2024-10/files/samples/Handshake/Handshake-OTA_PingRS.xml"
with open(
path, "r", encoding="utf-8") as f:
xml = f.read()
# Parse the XML into the request object
# Test parsing back
parser = XmlParser()
parsed_result = parser.from_string(xml, OtaPingRs)
print(parsed_result.echo_data)
warning = parsed_result.warnings.warning[0]
print(warning.type_value)
print(type(warning.content))
print(warning.content[0])
# save json in echo_data to file with indents
output_path = "echo_data_response.json"
with open(output_path, "w", encoding="utf-8") as out_f:
import json
json.dump(json.loads(parsed_result.echo_data), out_f, indent=4)
print(f"Saved echo_data json to {output_path}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""Migration script to add RoomTypes fields to Reservation table.
This migration adds three optional fields to the reservations table:
- room_type_code: String (max 8 chars)
- room_classification_code: String (numeric pattern)
- room_type: String (enum: 1-5)
This script can be run manually before starting the server, or the changes
will be applied automatically when the server starts via Base.metadata.create_all.
"""
import asyncio
import sys
from pathlib import Path
# Add parent directory to path so we can import alpine_bits_python
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from sqlalchemy import inspect, text
from sqlalchemy.ext.asyncio import create_async_engine
from alpine_bits_python.config_loader import load_config
from alpine_bits_python.db import get_database_url
from alpine_bits_python.logging_config import get_logger, setup_logging
_LOGGER = get_logger(__name__)
async def check_columns_exist(engine, table_name: str, columns: list[str]) -> dict[str, bool]:
"""Check which columns exist in the table.
Returns a dict mapping column name to whether it exists.
"""
async with engine.connect() as conn:
def _check(connection):
inspector = inspect(connection)
existing_cols = [col['name'] for col in inspector.get_columns(table_name)]
return {col: col in existing_cols for col in columns}
result = await conn.run_sync(_check)
return result
async def add_room_types_columns(engine):
"""Add RoomTypes columns to reservations table if they don't exist."""
from alpine_bits_python.db import Base
table_name = "reservations"
columns_to_add = ["room_type_code", "room_classification_code", "room_type"]
# First, ensure the table exists by creating all tables if needed
_LOGGER.info("Ensuring database tables exist...")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
_LOGGER.info("Database tables checked/created.")
_LOGGER.info("Checking which columns need to be added to %s table...", table_name)
# Check which columns already exist
columns_exist = await check_columns_exist(engine, table_name, columns_to_add)
columns_to_create = [col for col, exists in columns_exist.items() if not exists]
if not columns_to_create:
_LOGGER.info("All RoomTypes columns already exist in %s table. No migration needed.", table_name)
return
_LOGGER.info("Adding columns to %s table: %s", table_name, ", ".join(columns_to_create))
# Build ALTER TABLE statements for missing columns
# Note: SQLite supports ALTER TABLE ADD COLUMN but not ADD MULTIPLE COLUMNS
async with engine.begin() as conn:
for column in columns_to_create:
sql = f"ALTER TABLE {table_name} ADD COLUMN {column} VARCHAR"
_LOGGER.info("Executing: %s", sql)
await conn.execute(text(sql))
_LOGGER.info("Successfully added %d columns to %s table", len(columns_to_create), table_name)
async def main():
"""Run the migration."""
try:
# Load config
config = load_config()
setup_logging(config)
except Exception as e:
_LOGGER.warning("Failed to load config: %s. Using defaults.", e)
config = {}
_LOGGER.info("=" * 60)
_LOGGER.info("Starting RoomTypes Migration")
_LOGGER.info("=" * 60)
# Get database URL
database_url = get_database_url(config)
_LOGGER.info("Database URL: %s", database_url.replace("://", "://***:***@").split("@")[-1])
# Create engine
engine = create_async_engine(database_url, echo=False)
try:
# Run migration
await add_room_types_columns(engine)
_LOGGER.info("=" * 60)
_LOGGER.info("Migration completed successfully!")
_LOGGER.info("=" * 60)
except Exception as e:
_LOGGER.exception("Migration failed: %s", e)
sys.exit(1)
finally:
await engine.dispose()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,515 @@
#!/usr/bin/env python3
"""Migration script to copy data from SQLite to PostgreSQL.
This script:
1. Connects to both SQLite and PostgreSQL databases
2. Reads all data from SQLite using SQLAlchemy models
3. Writes data to PostgreSQL using the same models
4. Ensures data integrity and provides progress feedback
Prerequisites:
- PostgreSQL database must be created and empty (or you can use --drop-tables flag)
- asyncpg must be installed: uv pip install asyncpg
- Configure target PostgreSQL URL in config.yaml or via DATABASE_URL env var
Usage:
# Dry run (preview what will be migrated)
uv run python -m alpine_bits_python.util.migrate_sqlite_to_postgres --dry-run
# Actual migration using target config file
uv run python -m alpine_bits_python.util.migrate_sqlite_to_postgres \
--target-config config/postgres.yaml
# Drop existing tables first (careful!)
uv run python -m alpine_bits_python.util.migrate_sqlite_to_postgres \
--target-config config/postgres.yaml --drop-tables
# Alternative: use DATABASE_URL environment variable
DATABASE_URL="postgresql+asyncpg://user:pass@host/db" \
uv run python -m alpine_bits_python.util.migrate_sqlite_to_postgres
# Alternative: specify URLs directly
uv run python -m alpine_bits_python.util.migrate_sqlite_to_postgres \
--source sqlite+aiosqlite:///old.db \
--target postgresql+asyncpg://user:pass@localhost/dbname
"""
import argparse
import asyncio
import sys
from pathlib import Path
# Add parent directory to path so we can import alpine_bits_python
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
import yaml
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from alpine_bits_python.config_loader import load_config
from alpine_bits_python.db import (
AckedRequest,
Base,
Customer,
HashedCustomer,
Reservation,
get_database_url,
)
from alpine_bits_python.logging_config import get_logger, setup_logging
_LOGGER = get_logger(__name__)
def mask_db_url(url: str) -> str:
"""Mask sensitive parts of database URL for logging."""
if "://" not in url:
return url
protocol, rest = url.split("://", 1)
if "@" in rest:
credentials, location = rest.split("@", 1)
return f"{protocol}://***:***@{location}"
return url
async def get_table_counts(session: AsyncSession) -> dict[str, int]:
"""Get row counts for all tables."""
counts = {}
# Count customers
result = await session.execute(select(Customer))
counts["customers"] = len(result.scalars().all())
# Count hashed_customers
result = await session.execute(select(HashedCustomer))
counts["hashed_customers"] = len(result.scalars().all())
# Count reservations
result = await session.execute(select(Reservation))
counts["reservations"] = len(result.scalars().all())
# Count acked_requests
result = await session.execute(select(AckedRequest))
counts["acked_requests"] = len(result.scalars().all())
return counts
async def reset_sequences(session: AsyncSession) -> None:
"""Reset PostgreSQL sequences to match the current max ID values.
This is necessary after migrating data with explicit IDs from SQLite,
as PostgreSQL sequences won't automatically advance when IDs are set explicitly.
"""
tables = [
("customers", "customers_id_seq"),
("hashed_customers", "hashed_customers_id_seq"),
("reservations", "reservations_id_seq"),
("acked_requests", "acked_requests_id_seq"),
]
for table_name, sequence_name in tables:
# Set sequence to max(id) + 1, or 1 if table is empty
query = text(f"""
SELECT setval('{sequence_name}',
COALESCE((SELECT MAX(id) FROM {table_name}), 0) + 1,
false)
""")
await session.execute(query)
await session.commit()
async def migrate_data(
source_url: str,
target_url: str,
dry_run: bool = False,
drop_tables: bool = False,
) -> None:
"""Migrate data from source database to target database.
Args:
source_url: Source database URL (SQLite)
target_url: Target database URL (PostgreSQL)
dry_run: If True, only preview what would be migrated
drop_tables: If True, drop existing tables in target before creating
"""
_LOGGER.info("=" * 70)
_LOGGER.info("SQLite to PostgreSQL Migration")
_LOGGER.info("=" * 70)
_LOGGER.info("Source: %s", mask_db_url(source_url))
_LOGGER.info("Target: %s", mask_db_url(target_url))
_LOGGER.info("Mode: %s", "DRY RUN" if dry_run else "LIVE MIGRATION")
_LOGGER.info("=" * 70)
# Create engines
_LOGGER.info("Creating database connections...")
source_engine = create_async_engine(source_url, echo=False)
target_engine = create_async_engine(target_url, echo=False)
# Create session makers
SourceSession = async_sessionmaker(source_engine, expire_on_commit=False)
TargetSession = async_sessionmaker(target_engine, expire_on_commit=False)
try:
# Check source database
_LOGGER.info("\nChecking source database...")
async with SourceSession() as source_session:
source_counts = await get_table_counts(source_session)
_LOGGER.info("Source database contains:")
for table, count in source_counts.items():
_LOGGER.info(" - %s: %d rows", table, count)
total_rows = sum(source_counts.values())
if total_rows == 0:
_LOGGER.warning("Source database is empty. Nothing to migrate.")
return
if dry_run:
_LOGGER.info("\n" + "=" * 70)
_LOGGER.info("DRY RUN: Would migrate %d total rows", total_rows)
_LOGGER.info("=" * 70)
return
# Prepare target database
_LOGGER.info("\nPreparing target database...")
if drop_tables:
_LOGGER.warning("Dropping existing tables in target database...")
async with target_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
_LOGGER.info("Tables dropped.")
_LOGGER.info("Creating tables in target database...")
async with target_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
_LOGGER.info("Tables created.")
# Check if target already has data
_LOGGER.info("\nChecking target database...")
async with TargetSession() as target_session:
target_counts = await get_table_counts(target_session)
if sum(target_counts.values()) > 0:
_LOGGER.warning("Target database is not empty:")
for table, count in target_counts.items():
if count > 0:
_LOGGER.warning(" - %s: %d rows", table, count)
response = input("\nContinue anyway? This may cause conflicts. (yes/no): ")
if response.lower() != "yes":
_LOGGER.info("Migration cancelled.")
return
# Migrate data table by table
_LOGGER.info("\n" + "=" * 70)
_LOGGER.info("Starting data migration...")
_LOGGER.info("=" * 70)
# 1. Migrate Customers first (no dependencies)
_LOGGER.info("\n[1/4] Migrating Customers...")
async with SourceSession() as source_session:
result = await source_session.execute(select(Customer))
customers = result.scalars().all()
if customers:
async with TargetSession() as target_session:
for i, customer in enumerate(customers, 1):
# Create new instance with same data
new_customer = Customer(
id=customer.id,
given_name=customer.given_name,
contact_id=customer.contact_id,
surname=customer.surname,
name_prefix=customer.name_prefix,
email_address=customer.email_address,
phone=customer.phone,
email_newsletter=customer.email_newsletter,
address_line=customer.address_line,
city_name=customer.city_name,
postal_code=customer.postal_code,
country_code=customer.country_code,
gender=customer.gender,
birth_date=customer.birth_date,
language=customer.language,
address_catalog=customer.address_catalog,
name_title=customer.name_title,
)
target_session.add(new_customer)
if i % 100 == 0:
_LOGGER.info(" Progress: %d/%d customers", i, len(customers))
await target_session.commit()
_LOGGER.info("✓ Migrated %d customers", len(customers))
# 2. Migrate HashedCustomers (depends on Customers)
_LOGGER.info("\n[2/4] Migrating HashedCustomers...")
async with SourceSession() as source_session:
result = await source_session.execute(select(HashedCustomer))
hashed_customers = result.scalars().all()
if hashed_customers:
async with TargetSession() as target_session:
for i, hashed in enumerate(hashed_customers, 1):
new_hashed = HashedCustomer(
id=hashed.id,
customer_id=hashed.customer_id,
contact_id=hashed.contact_id,
hashed_email=hashed.hashed_email,
hashed_phone=hashed.hashed_phone,
hashed_given_name=hashed.hashed_given_name,
hashed_surname=hashed.hashed_surname,
hashed_city=hashed.hashed_city,
hashed_postal_code=hashed.hashed_postal_code,
hashed_country_code=hashed.hashed_country_code,
hashed_gender=hashed.hashed_gender,
hashed_birth_date=hashed.hashed_birth_date,
created_at=hashed.created_at,
)
target_session.add(new_hashed)
if i % 100 == 0:
_LOGGER.info(" Progress: %d/%d hashed customers", i, len(hashed_customers))
await target_session.commit()
_LOGGER.info("✓ Migrated %d hashed customers", len(hashed_customers))
# 3. Migrate Reservations (depends on Customers)
_LOGGER.info("\n[3/4] Migrating Reservations...")
async with SourceSession() as source_session:
result = await source_session.execute(select(Reservation))
reservations = result.scalars().all()
if reservations:
async with TargetSession() as target_session:
for i, reservation in enumerate(reservations, 1):
new_reservation = Reservation(
id=reservation.id,
customer_id=reservation.customer_id,
unique_id=reservation.unique_id,
md5_unique_id=reservation.md5_unique_id,
start_date=reservation.start_date,
end_date=reservation.end_date,
num_adults=reservation.num_adults,
num_children=reservation.num_children,
children_ages=reservation.children_ages,
offer=reservation.offer,
created_at=reservation.created_at,
utm_source=reservation.utm_source,
utm_medium=reservation.utm_medium,
utm_campaign=reservation.utm_campaign,
utm_term=reservation.utm_term,
utm_content=reservation.utm_content,
user_comment=reservation.user_comment,
fbclid=reservation.fbclid,
gclid=reservation.gclid,
hotel_code=reservation.hotel_code,
hotel_name=reservation.hotel_name,
room_type_code=reservation.room_type_code,
room_classification_code=reservation.room_classification_code,
room_type=reservation.room_type,
)
target_session.add(new_reservation)
if i % 100 == 0:
_LOGGER.info(" Progress: %d/%d reservations", i, len(reservations))
await target_session.commit()
_LOGGER.info("✓ Migrated %d reservations", len(reservations))
# 4. Migrate AckedRequests (no dependencies)
_LOGGER.info("\n[4/4] Migrating AckedRequests...")
async with SourceSession() as source_session:
result = await source_session.execute(select(AckedRequest))
acked_requests = result.scalars().all()
if acked_requests:
async with TargetSession() as target_session:
for i, acked in enumerate(acked_requests, 1):
new_acked = AckedRequest(
id=acked.id,
client_id=acked.client_id,
unique_id=acked.unique_id,
timestamp=acked.timestamp,
)
target_session.add(new_acked)
if i % 100 == 0:
_LOGGER.info(" Progress: %d/%d acked requests", i, len(acked_requests))
await target_session.commit()
_LOGGER.info("✓ Migrated %d acked requests", len(acked_requests))
# Migrate datetime columns to timezone-aware
_LOGGER.info("\n[5/6] Converting DateTime columns to timezone-aware...")
async with target_engine.begin() as conn:
await conn.execute(
text(
"ALTER TABLE hashed_customers "
"ALTER COLUMN created_at TYPE TIMESTAMP WITH TIME ZONE"
)
)
await conn.execute(
text(
"ALTER TABLE reservations "
"ALTER COLUMN created_at TYPE TIMESTAMP WITH TIME ZONE"
)
)
await conn.execute(
text(
"ALTER TABLE acked_requests "
"ALTER COLUMN timestamp TYPE TIMESTAMP WITH TIME ZONE"
)
)
_LOGGER.info("✓ DateTime columns converted to timezone-aware")
# Reset PostgreSQL sequences
_LOGGER.info("\n[6/6] Resetting PostgreSQL sequences...")
async with TargetSession() as target_session:
await reset_sequences(target_session)
_LOGGER.info("✓ Sequences reset to match current max IDs")
# Verify migration
_LOGGER.info("\n" + "=" * 70)
_LOGGER.info("Verifying migration...")
_LOGGER.info("=" * 70)
async with TargetSession() as target_session:
final_counts = await get_table_counts(target_session)
_LOGGER.info("Target database now contains:")
all_match = True
for table, count in final_counts.items():
source_count = source_counts[table]
match = "" if count == source_count else ""
_LOGGER.info(" %s %s: %d rows (source: %d)", match, table, count, source_count)
if count != source_count:
all_match = False
if all_match:
_LOGGER.info("\n" + "=" * 70)
_LOGGER.info("✓ Migration completed successfully!")
_LOGGER.info("=" * 70)
_LOGGER.info("\nNext steps:")
_LOGGER.info("1. Test your application with PostgreSQL")
_LOGGER.info("2. Update config.yaml or DATABASE_URL to use PostgreSQL")
_LOGGER.info("3. Keep SQLite backup until you're confident everything works")
else:
_LOGGER.error("\n" + "=" * 70)
_LOGGER.error("✗ Migration completed with mismatches!")
_LOGGER.error("=" * 70)
_LOGGER.error("Please review the counts above and investigate.")
except Exception as e:
_LOGGER.exception("Migration failed: %s", e)
raise
finally:
await source_engine.dispose()
await target_engine.dispose()
async def main():
"""Run the migration."""
parser = argparse.ArgumentParser(
description="Migrate data from SQLite to PostgreSQL",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument(
"--source",
help="Source database URL (default: from config or sqlite+aiosqlite:///alpinebits.db)",
)
parser.add_argument(
"--target",
help=(
"Target database URL "
"(default: from DATABASE_URL env var or --target-config)"
),
)
parser.add_argument(
"--target-config",
help=(
"Path to config file containing target PostgreSQL database URL "
"(keeps password out of bash history)"
),
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Preview migration without making changes",
)
parser.add_argument(
"--drop-tables",
action="store_true",
help="Drop existing tables in target database before migration",
)
args = parser.parse_args()
try:
# Load config
config = load_config()
setup_logging(config)
except Exception as e:
_LOGGER.warning("Failed to load config: %s. Using defaults.", e)
config = {}
# Determine source URL (default to SQLite)
if args.source:
source_url = args.source
else:
source_url = get_database_url(config)
if "sqlite" not in source_url:
_LOGGER.error("Source database must be SQLite. Use --source to specify.")
sys.exit(1)
# Determine target URL (must be PostgreSQL)
if args.target:
target_url = args.target
elif args.target_config:
# Load target config file manually (simpler YAML without secrets)
_LOGGER.info("Loading target database config from: %s", args.target_config)
try:
config_path = Path(args.target_config)
with config_path.open() as f:
target_config = yaml.safe_load(f)
target_url = target_config["database"]["url"]
_LOGGER.info("Successfully loaded target config")
except (FileNotFoundError, ValueError, KeyError):
_LOGGER.exception("Failed to load target config")
_LOGGER.info(
"Config file should contain: database.url with PostgreSQL connection"
)
sys.exit(1)
else:
import os
target_url = os.environ.get("DATABASE_URL")
if not target_url:
_LOGGER.error("Target database URL not specified.")
_LOGGER.error("Specify target database using one of:")
_LOGGER.error(" - --target-config config/postgres.yaml")
_LOGGER.error(" - DATABASE_URL environment variable")
_LOGGER.error(" - --target postgresql+asyncpg://user:pass@host/db")
sys.exit(1)
if "postgresql" not in target_url and "postgres" not in target_url:
_LOGGER.error("Target database must be PostgreSQL.")
sys.exit(1)
# Run migration
await migrate_data(
source_url=source_url,
target_url=target_url,
dry_run=args.dry_run,
drop_tables=args.drop_tables,
)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,165 @@
"""Worker coordination utilities for multi-worker FastAPI deployments.
This module provides utilities to ensure singleton services (schedulers, background tasks)
run on only one worker when using uvicorn --workers N.
"""
import fcntl
import os
from pathlib import Path
from typing import ContextManager
from .logging_config import get_logger
_LOGGER = get_logger(__name__)
class WorkerLock:
"""File-based lock to coordinate worker processes.
Only one worker can hold the lock at a time. This ensures singleton
services like schedulers only run on one worker.
"""
def __init__(self, lock_file: str | None = None):
"""Initialize the worker lock.
Args:
lock_file: Path to the lock file. If None, will try /var/run first,
falling back to /tmp if /var/run is not writable.
"""
if lock_file is None:
# Try /var/run first (more persistent), fall back to /tmp
for candidate in ["/var/run/alpinebits_primary_worker.lock",
"/tmp/alpinebits_primary_worker.lock"]:
try:
candidate_path = Path(candidate)
candidate_path.parent.mkdir(parents=True, exist_ok=True)
# Test if we can write to this location
test_file = candidate_path.parent / ".alpinebits_test"
test_file.touch()
test_file.unlink()
lock_file = candidate
break
except (PermissionError, OSError):
continue
else:
# If all fail, default to /tmp
lock_file = "/tmp/alpinebits_primary_worker.lock"
self.lock_file = Path(lock_file)
self.lock_fd = None
self.is_primary = False
def acquire(self) -> bool:
"""Try to acquire the primary worker lock.
Returns:
True if lock was acquired (this is the primary worker)
False if lock is held by another worker
"""
try:
# Create lock file if it doesn't exist
self.lock_file.parent.mkdir(parents=True, exist_ok=True)
# Open lock file
self.lock_fd = open(self.lock_file, "w")
# Try to acquire exclusive lock (non-blocking)
fcntl.flock(self.lock_fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
# Write PID to lock file for debugging
self.lock_fd.write(f"{os.getpid()}\n")
self.lock_fd.flush()
self.is_primary = True
_LOGGER.info(
"Acquired primary worker lock (pid=%d, lock_file=%s)",
os.getpid(),
self.lock_file,
)
return True
except (IOError, OSError) as e:
# Lock is held by another process
if self.lock_fd:
self.lock_fd.close()
self.lock_fd = None
self.is_primary = False
_LOGGER.info(
"Could not acquire primary worker lock - another worker is primary (pid=%d)",
os.getpid(),
)
return False
def release(self) -> None:
"""Release the primary worker lock."""
if self.lock_fd and self.is_primary:
try:
fcntl.flock(self.lock_fd.fileno(), fcntl.LOCK_UN)
self.lock_fd.close()
# Try to remove lock file (best effort)
try:
self.lock_file.unlink()
except Exception:
pass
_LOGGER.info("Released primary worker lock (pid=%d)", os.getpid())
except Exception:
_LOGGER.exception("Error releasing primary worker lock")
finally:
self.lock_fd = None
self.is_primary = False
def __enter__(self) -> "WorkerLock":
"""Context manager entry."""
self.acquire()
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
"""Context manager exit."""
self.release()
def is_primary_worker() -> tuple[bool, WorkerLock | None]:
"""Determine if this worker should run singleton services.
Uses file-based locking to coordinate between workers.
Includes stale lock detection and cleanup.
Returns:
Tuple of (is_primary, lock_object)
- is_primary: True if this is the primary worker
- lock_object: WorkerLock instance (must be kept alive)
"""
lock = WorkerLock()
# Check for stale locks from dead processes
if lock.lock_file.exists():
try:
with open(lock.lock_file, 'r') as f:
old_pid_str = f.read().strip()
if old_pid_str:
old_pid = int(old_pid_str)
# Check if the process with this PID still exists
try:
os.kill(old_pid, 0) # Signal 0 just checks existence
_LOGGER.debug("Lock held by active process pid=%d", old_pid)
except ProcessLookupError:
# Process is dead, remove stale lock
_LOGGER.warning(
"Removing stale lock file from dead process pid=%d",
old_pid
)
try:
lock.lock_file.unlink()
except Exception as e:
_LOGGER.warning("Failed to remove stale lock: %s", e)
except (ValueError, FileNotFoundError, PermissionError) as e:
_LOGGER.warning("Error checking lock file: %s", e)
is_primary = lock.acquire()
return is_primary, lock

12
start_api.py Normal file
View File

@@ -0,0 +1,12 @@
#!/usr/bin/env python3
"""Convenience launcher for the Wix Form Handler API."""
import os
import subprocess
# Change to src directory
src_dir = os.path.join(os.path.dirname(__file__), "src/alpine_bits_python")
# Run the API using uv
if __name__ == "__main__":
subprocess.run(["uv", "run", "python", os.path.join(src_dir, "run_api.py")], check=False)

View File

@@ -1,61 +0,0 @@
#!/usr/bin/env python3
"""
Quick test to demonstrate how the ServerCapabilities automatically
discovers implemented vs unimplemented actions.
"""
from alpine_bits_python.alpinebits_server import (
ServerCapabilities,
AlpineBitsAction,
AlpineBitsActionName,
Version,
AlpineBitsResponse,
HttpStatusCode
)
import asyncio
class NewImplementedAction(AlpineBitsAction):
"""A new action that IS implemented."""
def __init__(self):
self.name = AlpineBitsActionName.OTA_HOTEL_DESCRIPTIVE_INFO_INFO
self.version = Version.V2024_10
async def handle(self, action: str, request_xml: str, version: Version) -> AlpineBitsResponse:
"""This action is implemented."""
return AlpineBitsResponse("Implemented!", HttpStatusCode.OK)
class NewUnimplementedAction(AlpineBitsAction):
"""A new action that is NOT implemented (no handle override)."""
def __init__(self):
self.name = AlpineBitsActionName.OTA_HOTEL_DESCRIPTIVE_CONTENT_NOTIF_INFO
self.version = Version.V2024_10
# Notice: No handle method override - will use default "not implemented"
async def main():
print("🔍 Testing Action Discovery Logic")
print("=" * 50)
# Create capabilities and see what gets discovered
capabilities = ServerCapabilities()
print("📋 Actions found by discovery:")
for action_name in capabilities.get_supported_actions():
print(f"{action_name}")
print(f"\n📊 Total discovered: {len(capabilities.get_supported_actions())}")
# Test the new implemented action
implemented_action = NewImplementedAction()
result = await implemented_action.handle("test", "<xml/>", Version.V2024_10)
print(f"\n🟢 NewImplementedAction result: {result.xml_content}")
# Test the unimplemented action (should use default behavior)
unimplemented_action = NewUnimplementedAction()
result = await unimplemented_action.handle("test", "<xml/>", Version.V2024_10)
print(f"🔴 NewUnimplementedAction result: {result.xml_content}")
if __name__ == "__main__":
asyncio.run(main())

291
test_capi.py Normal file
View File

@@ -0,0 +1,291 @@
#!/usr/bin/env python3
"""Test sending a test event to the Conversions Api from Meta."""
import asyncio
import json
import logging
import time
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from src.alpine_bits_python.customer_service import CustomerService
from src.alpine_bits_python.db import Base
from src.alpine_bits_python.reservation_service import ReservationService
# Set up logging
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
TEST_CODE = "TEST54726"
# Meta CAPI configuration (placeholder values)
PIXEL_ID = "539512870322352"
ACCESS_TOKEN = "EAATsRaQOv94BPoib5XUn9ZBjNPfeZB4JlKJR1LYtiMdzbEoIa7XFDmHq3pY8UvOcHnbNYDraym107hwRd3EfzO8EpQ5ZB5C4OfF7KJ41KIrfQntkoWrCYcQReecd4vhzVk82hjm55yGDhkxzuNuzG85FZCT0nZB6VyIxZAVLR2estoUSAoQ06J742aMkZCN2AZDZD"
CAPI_ENDPOINT = f"https://graph.facebook.com/v19.0/{PIXEL_ID}/events"
async def load_test_data_from_db():
"""Load reservations and hashed customers from the database."""
# Connect to the test database
db_url = "sqlite+aiosqlite:///alpinebits_capi_test.db"
engine = create_async_engine(db_url, echo=False)
# Create tables if they don't exist
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Create async session
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
# Initialize services
reservation_service = ReservationService(session)
customer_service = CustomerService(session)
# Get all reservations with customers
reservations_with_customers = (
await reservation_service.get_reservations_with_filters()
)
if not reservations_with_customers:
logger.warning("No reservations found in database")
return []
logger.info("Found %d reservations", len(reservations_with_customers))
# Prepare data with hashed customer info
result = []
for reservation, customer in reservations_with_customers:
# Get hashed customer data
hashed_customer = await customer_service.get_hashed_customer(customer.id)
result.append(
{
"reservation": reservation,
"customer": customer,
"hashed_customer": hashed_customer,
}
)
await engine.dispose()
return result
def _build_user_data(hashed_customer):
"""Build user_data dict from hashed customer information.
Args:
hashed_customer: HashedCustomer database object with SHA256 hashed PII
Returns:
dict: User data for Meta Conversions API
"""
user_data = {}
if not hashed_customer:
return user_data
# Map hashed customer fields to Meta CAPI field names
field_mapping = {
"hashed_email": "em",
"hashed_phone": "ph",
"hashed_given_name": "fn",
"hashed_surname": "ln",
"hashed_city": "ct",
"hashed_postal_code": "zp",
"hashed_country_code": "country",
"hashed_gender": "ge",
"hashed_birth_date": "db",
}
for source_field, target_field in field_mapping.items():
value = getattr(hashed_customer, source_field, None)
if value:
user_data[target_field] = value
return user_data
def _build_custom_data(reservation, booking_value):
"""Build custom_data dict from reservation information.
Args:
reservation: Reservation database object
booking_value: Booking value in EUR
Returns:
dict: Custom data for Meta Conversions API
"""
custom_data = {
"currency": "EUR",
"value": booking_value,
"content_type": "hotel_booking",
}
# Add optional reservation details
optional_fields = {
"hotel_code": "hotel_code",
"hotel_name": "hotel_name",
"num_adults": "num_adults",
"num_children": "num_children",
}
for source_field, target_field in optional_fields.items():
value = getattr(reservation, source_field, None)
if value:
custom_data[target_field] = value
# Add date fields with ISO format
if reservation.start_date:
custom_data["checkin_date"] = reservation.start_date.isoformat()
if reservation.end_date:
custom_data["checkout_date"] = reservation.end_date.isoformat()
return custom_data
def _add_utm_parameters(custom_data, reservation):
"""Add UTM parameters to custom_data if available.
Args:
custom_data: Custom data dict to modify
reservation: Reservation database object
"""
utm_fields = ["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content"]
if any(getattr(reservation, field, None) for field in utm_fields):
for field in utm_fields:
custom_data[field] = getattr(reservation, field, None)
def _format_fbc(fbclid, timestamp):
"""Format Facebook Click ID (fbclid) to fbc parameter.
The fbc format is: fb.{subdomain_index}.{timestamp_ms}.{fbclid}
Args:
fbclid: Facebook Click ID from the ad URL
timestamp: DateTime object from reservation creation
Returns:
str: Formatted fbc value for Meta Conversions API
"""
# Extract timestamp in milliseconds
timestamp_ms = int(timestamp.timestamp() * 1000)
# Subdomain index is typically 1
subdomain_index = 1
return f"fb.{subdomain_index}.{timestamp_ms}.{fbclid}"
def create_meta_capi_event(reservation, customer, hashed_customer):
"""Create a Meta Conversions API event from reservation and customer data.
Args:
reservation: Reservation database object
customer: Customer database object (currently unused)
hashed_customer: HashedCustomer database object with SHA256 hashed PII
Returns:
dict: Formatted event for Meta Conversions API
"""
del customer # Currently unused but kept for API consistency
# Calculate booking value (example: random value between 500-2000 EUR)
booking_value = 1250.00 # Euro
# Current timestamp
event_time = int(time.time())
# Build user_data with hashed customer information
user_data = _build_user_data(hashed_customer)
# Add tracking parameters if available
if reservation.fbclid and reservation.created_at:
# Format fbclid as fbc parameter
user_data["fbc"] = _format_fbc(reservation.fbclid, reservation.created_at)
if reservation.gclid:
user_data["gclid"] = reservation.gclid
# Build custom_data
custom_data = _build_custom_data(reservation, booking_value)
# Add UTM parameters to event
_add_utm_parameters(custom_data, reservation)
# Return the event
return {
"event_name": "Purchase",
"event_time": event_time,
"event_id": reservation.unique_id, # Unique event ID for deduplication
"event_source_url": "https://example.com/booking-confirmation",
"action_source": "website",
"user_data": user_data,
"custom_data": custom_data,
}
async def send_test_event():
"""Load data from DB and create test Meta CAPI event."""
logger.info("Loading test data from database...")
# Load data
test_data = await load_test_data_from_db()
if not test_data:
logger.error("No test data available. Please add reservations to the database.")
return
# Use the first reservation for testing
data = test_data[0]
reservation = data["reservation"]
customer = data["customer"]
hashed_customer = data["hashed_customer"]
logger.info("Using reservation: %s", reservation.unique_id)
logger.info("Customer: %s %s", customer.given_name, customer.surname)
# Create the event
event = create_meta_capi_event(reservation, customer, hashed_customer)
# Create the full payload with test_event_code at top level
payload = {
"data": [event],
"test_event_code": TEST_CODE,
}
# Log the event (pretty print)
separator = "=" * 80
logger.info("\n%s", separator)
logger.info("META CONVERSIONS API EVENT")
logger.info("%s", separator)
logger.info("\nEndpoint: %s", CAPI_ENDPOINT)
logger.info("\nPayload:\n%s", json.dumps(payload, indent=2))
logger.info("\n%s", separator)
logger.info("\nNOTE: This is a test event. To actually send it:")
logger.info("1. Set PIXEL_ID to your Meta Pixel ID")
logger.info("2. Set ACCESS_TOKEN to your Meta access token")
logger.info("3. Uncomment the httpx.post() call below")
logger.info(
"4. Test the event at: https://developers.facebook.com/tools/events_manager/"
)
logger.info(" Use test event code: %s", TEST_CODE)
# Uncomment to actually send the event
# async with httpx.AsyncClient() as client:
# response = await client.post(
# CAPI_ENDPOINT,
# json=payload,
# params={"access_token": ACCESS_TOKEN},
# )
# logger.info("Response status: %s", response.status_code)
# logger.info("Response body: %s", response.text)
if __name__ == "__main__":
asyncio.run(send_test_event())

View File

@@ -1,29 +0,0 @@
#!/usr/bin/env python3
"""
Test the handshake functionality with the real AlpineBits sample file.
"""
import asyncio
from alpine_bits_python.alpinebits_server import AlpineBitsServer
async def main():
print("🔄 Testing AlpineBits Handshake with Sample File")
print("=" * 60)
# Create server instance
server = AlpineBitsServer()
# Read the sample handshake request
with open("AlpineBits-HotelData-2024-10/files/samples/Handshake/Handshake-OTA_PingRQ.xml", "r") as f:
ping_request_xml = f.read()
print("📤 Sending handshake request...")
# Handle the ping request
response = await server.handle_request("OTA_Ping:Handshaking", ping_request_xml, "2024-10")
print(f"\n📥 Response Status: {response.status_code}")
print(f"📄 Response XML:\n{response.xml_content}")
if __name__ == "__main__":
asyncio.run(main())

Some files were not shown because too many files have changed in this diff Show More