18 Commits

Author SHA1 Message Date
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
21 changed files with 1661 additions and 465 deletions

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

2
.gitignore vendored
View File

@@ -17,6 +17,8 @@ wheels/
# ignore test_data content but keep the folder # ignore test_data content but keep the folder
test_data/* test_data/*
test/test_output/*
# ignore secrets # ignore secrets
secrets.yaml secrets.yaml

View File

@@ -6,10 +6,14 @@ database:
# url: "postgresql://user:password@host:port/dbname" # Example for Postgres # url: "postgresql://user:password@host:port/dbname" # Example for Postgres
alpine_bits_auth: alpine_bits_auth:
- hotel_id: "123" - hotel_id: "12345"
hotel_name: "Frangart Inn" hotel_name: "Bemelmans Post"
username: "alice" username: "alice"
password: !secret ALICE_PASSWORD password: !secret ALICE_PASSWORD
push_endpoint:
url: "https://example.com/push"
token: !secret PUSH_TOKEN_ALICE
username: "alice"
- hotel_id: "456" - hotel_id: "456"
hotel_name: "Bemelmans" hotel_name: "Bemelmans"
username: "bob" username: "bob"

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

@@ -14,8 +14,10 @@ dependencies = [
"dotenv>=0.9.9", "dotenv>=0.9.9",
"fastapi>=0.117.1", "fastapi>=0.117.1",
"generateds>=2.44.3", "generateds>=2.44.3",
"httpx>=0.28.1",
"lxml>=6.0.1", "lxml>=6.0.1",
"pytest>=8.4.2", "pytest>=8.4.2",
"pytest-asyncio>=1.2.0",
"redis>=6.4.0", "redis>=6.4.0",
"ruff>=0.13.1", "ruff>=0.13.1",
"slowapi>=0.1.9", "slowapi>=0.1.9",

View File

@@ -1,4 +1,5 @@
from datetime import datetime, timezone from datetime import datetime, timezone
import traceback
from typing import Union, Optional, Any, TypeVar from typing import Union, Optional, Any, TypeVar
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from dataclasses import dataclass from dataclasses import dataclass
@@ -14,6 +15,7 @@ from .generated.alpinebits import (
OtaHotelResNotifRq, OtaHotelResNotifRq,
OtaResRetrieveRs, OtaResRetrieveRs,
CommentName2, CommentName2,
ProfileProfileType,
UniqueIdType2, UniqueIdType2,
) )
import logging import logging
@@ -51,6 +53,18 @@ RetrieveGuestCounts = (
OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays.RoomStay.GuestCounts 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)
# phonetechtype enum 1,3,5 voice, fax, mobile # phonetechtype enum 1,3,5 voice, fax, mobile
class PhoneTechType(Enum): class PhoneTechType(Enum):
@@ -104,31 +118,25 @@ class CustomerData:
class GuestCountsFactory: class GuestCountsFactory:
"""Factory class to create GuestCounts instances for both OtaHotelResNotifRq and OtaResRetrieveRs."""
@staticmethod @staticmethod
def create_notif_guest_counts( def create_guest_counts(
adults: int, kids: Optional[list[int]] = None adults: int, kids: Optional[list[int]] = None
) -> NotifGuestCounts: , message_type: OtaMessageType = OtaMessageType.RETRIEVE) -> NotifGuestCounts:
""" """
Create a GuestCounts object for OtaHotelResNotifRq. Create a GuestCounts object for OtaHotelResNotifRq or OtaResRetrieveRs.
:param adults: Number of adults :param adults: Number of adults
:param kids: List of ages for each kid (optional) :param kids: List of ages for each kid (optional)
:return: GuestCounts instance :return: GuestCounts instance
""" """
return GuestCountsFactory._create_guest_counts(adults, kids, NotifGuestCounts) if message_type == OtaMessageType.RETRIEVE:
return GuestCountsFactory._create_guest_counts(adults, kids, RetrieveGuestCounts)
elif message_type == OtaMessageType.NOTIF:
return GuestCountsFactory._create_guest_counts(adults, kids, NotifGuestCounts)
else:
raise ValueError(f"Unsupported message type: {message_type}")
@staticmethod
def create_retrieve_guest_counts(
adults: int, kids: Optional[list[int]] = None
) -> RetrieveGuestCounts:
"""
Create a GuestCounts object for OtaResRetrieveRs.
:param adults: Number of adults
:param kids: List of ages for each kid (optional)
:return: GuestCounts instance
"""
return GuestCountsFactory._create_guest_counts(
adults, kids, RetrieveGuestCounts
)
@staticmethod @staticmethod
def _create_guest_counts( def _create_guest_counts(
@@ -567,6 +575,9 @@ class ResGuestFactory:
return CustomerFactory.from_notif_customer(customer) return CustomerFactory.from_notif_customer(customer)
else: else:
return CustomerFactory.from_retrieve_customer(customer) return CustomerFactory.from_retrieve_customer(customer)
class AlpineBitsFactory: class AlpineBitsFactory:
@@ -669,9 +680,247 @@ class AlpineBitsFactory:
else: else:
raise ValueError(f"Unsupported object type: {type(obj)}") raise ValueError(f"Unsupported object type: {type(obj)}")
def create_res_retrieve_response(list: list[Tuple[Reservation, Customer]]):
"""Create RetrievedReservation XML from database entries."""
return _create_xml_from_db(list, OtaMessageType.RETRIEVE)
def create_res_notif_push_message(list: Tuple[Reservation, Customer]):
"""Create Reservation Notification XML from database entries."""
return _create_xml_from_db(list, OtaMessageType.NOTIF)
def create_xml_from_db(list: list[Tuple[Reservation, Customer]]): def _process_single_reservation(reservation: Reservation, customer: Customer, message_type: OtaMessageType):
phone_numbers = (
[(customer.phone, PhoneTechType.MOBILE)]
if customer.phone is not None
else []
)
customer_data = CustomerData(
given_name=customer.given_name,
surname=customer.surname,
name_prefix=customer.name_prefix,
name_title=customer.name_title,
phone_numbers=phone_numbers,
email_address=customer.email_address,
email_newsletter=customer.email_newsletter,
address_line=customer.address_line,
city_name=customer.city_name,
postal_code=customer.postal_code,
country_code=customer.country_code,
address_catalog=customer.address_catalog,
gender=customer.gender,
birth_date=customer.birth_date,
language=customer.language,
)
alpine_bits_factory = AlpineBitsFactory()
res_guests = alpine_bits_factory.create_res_guests(
customer_data, message_type
)
# Guest counts
children_ages = [int(a) for a in reservation.children_ages.split(",") if a]
guest_counts = GuestCountsFactory.create_guest_counts(
reservation.num_adults, children_ages, message_type
)
unique_id_string = reservation.unique_id
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(f"Unsupported message type: {message_type}")
# UniqueID
unique_id = UniqueId(
type_value=UniqueIdType2.VALUE_14, id=unique_id_string
)
# TimeSpan
time_span = RoomStays.RoomStay.TimeSpan(
start=reservation.start_date.isoformat()
if reservation.start_date
else None,
end=reservation.end_date.isoformat() if reservation.end_date else None,
)
room_stay = (
RoomStays.RoomStay(
time_span=time_span,
guest_counts=guest_counts,
)
)
room_stays = RoomStays(
room_stay=[room_stay],
)
res_id_source = "website"
if reservation.fbclid != "":
klick_id = reservation.fbclid
res_id_source = "meta"
elif reservation.gclid != "":
klick_id = reservation.gclid
res_id_source = "google"
# explicitly set klick_id to None otherwise an empty string will be sent
if klick_id in (None, "", "None"):
klick_id = None
else: # extract string from Column object
klick_id = str(klick_id)
hotel_res_id_data = HotelReservationIdData(
res_id_type="13",
res_id_value=klick_id,
res_id_source=res_id_source,
res_id_source_context="99tales",
)
# explicitly set klick_id to None otherwise an empty string will be sent
if klick_id in (None, "", "None"):
klick_id = None
else: # extract string from Column object
klick_id = str(klick_id)
utm_medium = (
str(reservation.utm_medium)
if reservation.utm_medium is not None and str(reservation.utm_medium) != ""
else "website"
)
#shorten klick_id if longer than 64 characters
if klick_id is not None and len(klick_id) > 64:
klick_id = klick_id[:64]
hotel_res_id_data = HotelReservationIdData(
res_id_type="13",
res_id_value=klick_id,
res_id_source=utm_medium,
res_id_source_context="99tales",
)
hotel_res_id = alpine_bits_factory.create(
hotel_res_id_data, message_type
)
hotel_res_ids = HotelReservation.ResGlobalInfo.HotelReservationIds(
hotel_reservation_id=[hotel_res_id]
)
if reservation.hotel_code is None:
raise ValueError("Reservation hotel_code is None")
else:
hotel_code = str(reservation.hotel_code)
if reservation.hotel_name is None:
hotel_name = None
else:
hotel_name = str(reservation.hotel_name)
basic_property_info = HotelReservation.ResGlobalInfo.BasicPropertyInfo(
hotel_code=hotel_code,
hotel_name=hotel_name,
)
# Comments
offer_comment = None
if reservation.offer is not None:
offer_comment = CommentData(
name=CommentName2.ADDITIONAL_INFO,
text="Angebot/Offerta",
list_items=[
CommentListItemData(
value=reservation.offer,
language=customer.language,
list_item="1",
)
],
)
comment = None
if reservation.user_comment:
comment = CommentData(
name=CommentName2.CUSTOMER_COMMENT,
text=reservation.user_comment,
list_items=[
CommentListItemData(
value="Landing page comment",
language=customer.language,
list_item="1",
)
],
)
comments = [offer_comment, comment]
# filter out None comments
comments = [c for c in comments if c is not None]
comments_xml = None
if comments:
for c in comments:
_LOGGER.info(
f"Creating comment: name={c.name}, text={c.text}, list_items={len(c.list_items)}"
)
comments_data = CommentsData(comments=comments)
comments_xml = alpine_bits_factory.create(
comments_data, message_type
)
company_name = Profile.CompanyInfo.CompanyName(value="99tales GmbH", code="who knows?", code_context="who knows?")
company_info = Profile.CompanyInfo(company_name=company_name)
profile = Profile(company_info=company_info, profile_type=ProfileProfileType.VALUE_4)
profile_info = HotelReservation.ResGlobalInfo.Profiles.ProfileInfo(profile=profile)
_LOGGER.info(f"Type of profile_info: {type(profile_info)}")
profiles = HotelReservation.ResGlobalInfo.Profiles(profile_info=profile_info)
res_global_info = (
HotelReservation.ResGlobalInfo(
hotel_reservation_ids=hotel_res_ids,
basic_property_info=basic_property_info,
comments=comments_xml,
profiles=profiles,
)
)
hotel_reservation = HotelReservation(
create_date_time=datetime.now(timezone.utc).isoformat(),
res_status=HotelReservationResStatus.REQUESTED,
room_stay_reservation="true",
unique_id=unique_id,
room_stays=room_stays,
res_guests=res_guests,
res_global_info=res_global_info,
)
return hotel_reservation
def _create_xml_from_db(entries: list[Tuple[Reservation, Customer]] | Tuple[Reservation, Customer], type: OtaMessageType):
"""Create RetrievedReservation XML from database entries. """Create RetrievedReservation XML from database entries.
list of pairs (Reservation, Customer) list of pairs (Reservation, Customer)
@@ -679,173 +928,65 @@ def create_xml_from_db(list: list[Tuple[Reservation, Customer]]):
reservations_list = [] reservations_list = []
for reservation, customer in 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( _LOGGER.info(
f"Creating XML for reservation {reservation.form_id} and customer {customer.given_name}" f"Creating XML for reservation {reservation.unique_id} and customer {customer.given_name}"
) )
try: try:
phone_numbers = (
[(customer.phone, PhoneTechType.MOBILE)]
if customer.phone is not None
else []
)
customer_data = CustomerData(
given_name=customer.given_name,
surname=customer.surname,
name_prefix=customer.name_prefix,
name_title=customer.name_title,
phone_numbers=phone_numbers,
email_address=customer.email_address,
email_newsletter=customer.email_newsletter,
address_line=customer.address_line,
city_name=customer.city_name,
postal_code=customer.postal_code,
country_code=customer.country_code,
address_catalog=customer.address_catalog,
gender=customer.gender,
birth_date=customer.birth_date,
language=customer.language,
)
alpine_bits_factory = AlpineBitsFactory()
res_guests = alpine_bits_factory.create_res_guests(
customer_data, OtaMessageType.RETRIEVE
)
# Guest counts hotel_reservation = _process_single_reservation(reservation, customer, type)
children_ages = [int(a) for a in reservation.children_ages.split(",") if a]
guest_counts = GuestCountsFactory.create_retrieve_guest_counts(
reservation.num_adults, children_ages
)
unique_id_string = reservation.form_id
if len(unique_id_string) > 32:
unique_id_string = unique_id_string[:32] # Truncate to 32 characters
# UniqueID
unique_id = OtaResRetrieveRs.ReservationsList.HotelReservation.UniqueId(
type_value=UniqueIdType2.VALUE_14, id=unique_id_string
)
# TimeSpan
time_span = OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays.RoomStay.TimeSpan(
start=reservation.start_date.isoformat()
if reservation.start_date
else None,
end=reservation.end_date.isoformat() if reservation.end_date else None,
)
room_stay = (
OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays.RoomStay(
time_span=time_span,
guest_counts=guest_counts,
)
)
room_stays = OtaResRetrieveRs.ReservationsList.HotelReservation.RoomStays(
room_stay=[room_stay],
)
hotel_res_id_data = HotelReservationIdData(
res_id_type="13",
res_id_value=reservation.fbclid or reservation.gclid,
res_id_source=None,
res_id_source_context="99tales",
)
hotel_res_id = alpine_bits_factory.create(
hotel_res_id_data, OtaMessageType.RETRIEVE
)
hotel_res_ids = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.HotelReservationIds(
hotel_reservation_id=[hotel_res_id]
)
basic_property_info = OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo.BasicPropertyInfo(
hotel_code=reservation.hotel_code,
hotel_name=reservation.hotel_name,
)
# Comments
offer_comment = None
if reservation.offer is not None:
offer_comment = CommentData(
name=CommentName2.ADDITIONAL_INFO,
text="Angebot/Offerta",
list_items=[
CommentListItemData(
value=reservation.offer,
language=customer.language,
list_item="1",
)
],
)
comment = None
if reservation.user_comment:
comment = CommentData(
name=CommentName2.CUSTOMER_COMMENT,
text=reservation.user_comment,
list_items=[
CommentListItemData(
value="Landing page comment",
language=customer.language,
list_item="1",
)
],
)
comments = [offer_comment, comment]
# filter out None comments
comments = [c for c in comments if c is not None]
comments_xml = None
if comments:
for c in comments:
_LOGGER.info(
f"Creating comment: name={c.name}, text={c.text}, list_items={len(c.list_items)}"
)
comments_data = CommentsData(comments=comments)
comments_xml = alpine_bits_factory.create(
comments_data, OtaMessageType.RETRIEVE
)
res_global_info = (
OtaResRetrieveRs.ReservationsList.HotelReservation.ResGlobalInfo(
hotel_reservation_ids=hotel_res_ids,
basic_property_info=basic_property_info,
comments=comments_xml,
)
)
hotel_reservation = OtaResRetrieveRs.ReservationsList.HotelReservation(
create_date_time=datetime.now(timezone.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,
)
reservations_list.append(hotel_reservation) reservations_list.append(hotel_reservation)
except Exception as e: except Exception as e:
_LOGGER.error( _LOGGER.error(
f"Error creating XML for reservation {reservation.form_id} and customer {customer.given_name}: {e}" f"Error creating XML for reservation {reservation.unique_id} and customer {customer.given_name}: {e}"
) )
_LOGGER.debug(traceback.format_exc())
retrieved_reservations = OtaResRetrieveRs.ReservationsList( if type == OtaMessageType.NOTIF:
hotel_reservation=reservations_list retrieved_reservations = OtaHotelResNotifRq.HotelReservations(
) hotel_reservation=reservations_list
)
ota_res_retrieve_rs = OtaResRetrieveRs( ota_hotel_res_notif_rq = OtaHotelResNotifRq(
version="7.000", success="", reservations_list=retrieved_reservations version="7.000", hotel_reservations=retrieved_reservations
) )
try: try:
ota_res_retrieve_rs.model_validate(ota_res_retrieve_rs.model_dump()) ota_hotel_res_notif_rq.model_validate(ota_hotel_res_notif_rq.model_dump())
except Exception as e: except Exception as e:
_LOGGER.error(f"Validation error: {e}") _LOGGER.error(f"Validation error: {e}")
raise raise
return ota_res_retrieve_rs return ota_hotel_res_notif_rq
elif type == OtaMessageType.RETRIEVE:
retrieved_reservations = OtaResRetrieveRs.ReservationsList(
hotel_reservation=reservations_list
)
ota_res_retrieve_rs = OtaResRetrieveRs(
version="7.000", success="", reservations_list=retrieved_reservations
)
try:
ota_res_retrieve_rs.model_validate(ota_res_retrieve_rs.model_dump())
except Exception as e:
_LOGGER.error(f"Validation error: {e}")
raise
return ota_res_retrieve_rs
else:
raise ValueError(f"Unsupported message type: {type}")
# Usage examples # Usage examples

View File

@@ -1,169 +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
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")

View File

@@ -8,6 +8,7 @@ handshaking functionality with configurable supported actions and capabilities.
import asyncio import asyncio
from datetime import datetime from datetime import datetime
from zoneinfo import ZoneInfo
import difflib import difflib
import json import json
import inspect import inspect
@@ -17,16 +18,16 @@ from xml.etree import ElementTree as ET
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum, IntEnum from enum import Enum, IntEnum
from alpine_bits_python.alpine_bits_helpers import PhoneTechType, create_xml_from_db from alpine_bits_python.alpine_bits_helpers import PhoneTechType, create_res_notif_push_message, create_res_retrieve_response
from .generated.alpinebits import OtaPingRq, OtaPingRs, WarningStatus, OtaReadRq from .generated.alpinebits import OtaNotifReportRq, OtaNotifReportRs, OtaPingRq, OtaPingRs, WarningStatus, OtaReadRq
from xsdata_pydantic.bindings import XmlSerializer from xsdata_pydantic.bindings import XmlSerializer
from xsdata.formats.dataclass.serializers.config import SerializerConfig from xsdata.formats.dataclass.serializers.config import SerializerConfig
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from xsdata_pydantic.bindings import XmlParser from xsdata_pydantic.bindings import XmlParser
import logging import logging
from .db import Reservation, Customer from .db import AckedRequest, Reservation, Customer
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
@@ -52,10 +53,14 @@ class AlpineBitsActionName(Enum):
OTA_PING = ("action_OTA_Ping", "OTA_Ping:Handshaking") OTA_PING = ("action_OTA_Ping", "OTA_Ping:Handshaking")
OTA_READ = ("action_OTA_Read", "OTA_Read:GuestRequests") OTA_READ = ("action_OTA_Read", "OTA_Read:GuestRequests")
OTA_HOTEL_AVAIL_NOTIF = ("action_OTA_HotelAvailNotif", "OTA_HotelAvailNotif") OTA_HOTEL_AVAIL_NOTIF = ("action_OTA_HotelAvailNotif", "OTA_HotelAvailNotif")
OTA_HOTEL_RES_NOTIF_GUEST_REQUESTS = ( OTA_HOTEL_RES_NOTIF_GUEST_REQUESTS = ( ## Push Action for Guest Requests
"action_OTA_HotelResNotif_GuestRequests", "action_OTA_HotelResNotif_GuestRequests",
"OTA_HotelResNotif:GuestRequests", "OTA_HotelResNotif:GuestRequests",
) )
OTA_HOTEL_NOTIF_REPORT = (
"action_OTA_Read", # if read is supported this is also supported
"OTA_NotifReport:GuestRequests",
)
OTA_HOTEL_DESCRIPTIVE_CONTENT_NOTIF_INVENTORY = ( OTA_HOTEL_DESCRIPTIVE_CONTENT_NOTIF_INVENTORY = (
"action_OTA_HotelDescriptiveContentNotif_Inventory", "action_OTA_HotelDescriptiveContentNotif_Inventory",
"OTA_HotelDescriptiveContentNotif:Inventory", "OTA_HotelDescriptiveContentNotif:Inventory",
@@ -191,7 +196,7 @@ class ServerCapabilities:
""" """
def __init__(self): def __init__(self):
self.action_registry: Dict[str, Type[AlpineBitsAction]] = {} self.action_registry: Dict[AlpineBitsActionName, Type[AlpineBitsAction]] = {}
self._discover_actions() self._discover_actions()
self.capability_dict = None self.capability_dict = None
@@ -209,8 +214,8 @@ class ServerCapabilities:
if self._is_action_implemented(obj): if self._is_action_implemented(obj):
action_instance = obj() action_instance = obj()
if hasattr(action_instance, "name"): if hasattr(action_instance, "name"):
# Use capability name for the registry key # Use capability attribute as registry key
self.action_registry[action_instance.name.capability_name] = obj self.action_registry[action_instance.name] = obj
def _is_action_implemented(self, action_class: Type[AlpineBitsAction]) -> bool: def _is_action_implemented(self, action_class: Type[AlpineBitsAction]) -> bool:
""" """
@@ -229,7 +234,7 @@ class ServerCapabilities:
""" """
versions_dict = {} versions_dict = {}
for action_name, action_class in self.action_registry.items(): for action_enum, action_class in self.action_registry.items():
action_instance = action_class() action_instance = action_class()
# Get supported versions for this action # Get supported versions for this action
@@ -245,7 +250,7 @@ class ServerCapabilities:
if version_str not in versions_dict: if version_str not in versions_dict:
versions_dict[version_str] = {"version": version_str, "actions": []} versions_dict[version_str] = {"version": version_str, "actions": []}
action_dict = {"action": action_name} action_dict = {"action": action_enum.capability_name}
# Add supports field if the action has custom supports # Add supports field if the action has custom supports
if hasattr(action_instance, "supports") and action_instance.supports: if hasattr(action_instance, "supports") and action_instance.supports:
@@ -255,6 +260,25 @@ class ServerCapabilities:
self.capability_dict = {"versions": list(versions_dict.values())} self.capability_dict = {"versions": list(versions_dict.values())}
# filter duplicates in actions for each version
for version in self.capability_dict["versions"]:
seen_actions = set()
unique_actions = []
for action in version["actions"]:
if action["action"] not in seen_actions:
seen_actions.add(action["action"])
unique_actions.append(action)
version["actions"] = unique_actions
# remove action_OTA_Ping from version 2024-10
for version in self.capability_dict["versions"]:
if version["version"] == "2024-10":
version["actions"] = [
action for action in version["actions"]
if action.get("action") != "action_OTA_Ping"
]
return None return None
def get_capabilities_dict(self) -> Dict: def get_capabilities_dict(self) -> Dict:
@@ -374,12 +398,17 @@ class PingAction(AlpineBitsAction):
warning_response = OtaPingRs.Warnings(warning=[warning]) warning_response = OtaPingRs.Warnings(warning=[warning])
all_capabilities = server_capabilities.get_capabilities_json()
# remove action_OTA_Ping from version 2024-10
all_capabilities = capabilities_dict
all_capabilities_json = json.dumps(all_capabilities, indent=2)
response_ota_ping = OtaPingRs( response_ota_ping = OtaPingRs(
version="7.000", version="7.000",
warnings=warning_response, warnings=warning_response,
echo_data=all_capabilities, echo_data=all_capabilities_json,
success="", success="",
) )
@@ -493,6 +522,8 @@ class ReadAction(AlpineBitsAction):
# query all reservations for this hotel from the database, where start_date is greater than or equal to the given start_date # query all reservations for this hotel from the database, where start_date is greater than or equal to the given start_date
stmt = ( stmt = (
select(Reservation, Customer) select(Reservation, Customer)
.join(Customer, Reservation.customer_id == Customer.id) .join(Customer, Reservation.customer_id == Customer.id)
@@ -500,6 +531,20 @@ class ReadAction(AlpineBitsAction):
) )
if start_date: if start_date:
stmt = stmt.filter(Reservation.start_date >= start_date) stmt = stmt.filter(Reservation.start_date >= start_date)
else:
# remove reservations that have been acknowledged via client_id
if client_info.client_id:
subquery = (
select(Reservation.id)
.join(
AckedRequest,
AckedRequest.unique_id == Reservation.unique_id,
)
.filter(AckedRequest.client_id == client_info.client_id)
)
stmt = stmt.filter(~Reservation.id.in_(subquery))
result = await dbsession.execute(stmt) result = await dbsession.execute(stmt)
reservation_customer_pairs: list[tuple[Reservation, Customer]] = ( reservation_customer_pairs: list[tuple[Reservation, Customer]] = (
@@ -514,7 +559,7 @@ class ReadAction(AlpineBitsAction):
f"Reservation: {reservation.id}, Customer: {customer.given_name}" f"Reservation: {reservation.id}, Customer: {customer.given_name}"
) )
res_retrive_rs = create_xml_from_db(reservation_customer_pairs) res_retrive_rs = create_res_retrieve_response(reservation_customer_pairs)
config = SerializerConfig( config = SerializerConfig(
pretty_print=True, xml_declaration=True, encoding="UTF-8" pretty_print=True, xml_declaration=True, encoding="UTF-8"
@@ -531,7 +576,7 @@ class NotifReportReadAction(AlpineBitsAction):
"""Necessary for read action to follow specification. Clients need to report acknowledgements""" """Necessary for read action to follow specification. Clients need to report acknowledgements"""
def __init__(self, config: Dict = {}): def __init__(self, config: Dict = {}):
self.name = AlpineBitsActionName.OTA_HOTEL_RES_NOTIF_GUEST_REQUESTS self.name = AlpineBitsActionName.OTA_HOTEL_NOTIF_REPORT
self.version = [Version.V2024_10, Version.V2022_10] self.version = [Version.V2024_10, Version.V2022_10]
self.config = config self.config = config
@@ -540,26 +585,102 @@ class NotifReportReadAction(AlpineBitsAction):
action: str, action: str,
request_xml: str, request_xml: str,
version: Version, version: Version,
client_info: AlpineBitsClientInfo,
dbsession=None, dbsession=None,
username=None, server_capabilities=None,
password=None,
) -> AlpineBitsResponse: ) -> AlpineBitsResponse:
"""Handle read requests.""" """Handle read requests."""
return AlpineBitsResponse( notif_report = XmlParser().from_string(request_xml, OtaNotifReportRq)
f"Error: Action {action} not implemented", HttpStatusCode.BAD_REQUEST
# we can't check hotel auth here, because this action does not contain hotel info
warnings = notif_report.warnings
notif_report_details = notif_report.notif_details
success_message = OtaNotifReportRs(
version="7.000", success=""
) )
if client_info.client_id is None:
return AlpineBitsResponse(
"ERROR:no valid client id provided", HttpStatusCode.BAD_REQUEST
)
class GuestRequestsAction(AlpineBitsAction): config = SerializerConfig(
"""Unimplemented action - will not appear in capabilities.""" pretty_print=True, xml_declaration=True, encoding="UTF-8"
)
serializer = XmlSerializer(config=config)
response_xml = serializer.render(
success_message, ns_map={None: "http://www.opentravel.org/OTA/2003/05"}
)
def __init__(self): if warnings is None and notif_report_details is None:
return AlpineBitsResponse(
response_xml, HttpStatusCode.OK
) # Nothing to process
elif notif_report_details is not None and notif_report_details.hotel_notif_report is None:
return AlpineBitsResponse(
response_xml, HttpStatusCode.OK
) # Nothing to process
else:
if dbsession is None:
return AlpineBitsResponse(
"Error: Something went wrong", HttpStatusCode.INTERNAL_SERVER_ERROR
)
timestamp = datetime.now(ZoneInfo("UTC"))
for entry in notif_report_details.hotel_notif_report.hotel_reservations.hotel_reservation: # type: ignore
unique_id = entry.unique_id.id
acked_request = AckedRequest(
unique_id=unique_id, client_id=client_info.client_id, timestamp=timestamp
)
dbsession.add(acked_request)
await dbsession.commit()
return AlpineBitsResponse(
response_xml, HttpStatusCode.OK
)
class PushAction(AlpineBitsAction):
"""Creates the necessary xml for OTA_HotelResNotif:GuestRequests"""
def __init__(self, config: Dict = {}):
self.name = AlpineBitsActionName.OTA_HOTEL_RES_NOTIF_GUEST_REQUESTS self.name = AlpineBitsActionName.OTA_HOTEL_RES_NOTIF_GUEST_REQUESTS
self.version = Version.V2024_10 self.version = [Version.V2024_10, Version.V2022_10]
self.config = config
# Note: This class doesn't override the handle method, so it won't be discovered async def handle(
self,
action: str,
request_xml: Tuple[Reservation, Customer],
version: Version,
client_info: AlpineBitsClientInfo,
dbsession=None,
server_capabilities=None,
) -> AlpineBitsResponse:
"""Create push request XML."""
xml_push_request = create_res_notif_push_message(request_xml)
config = SerializerConfig(
pretty_print=True, xml_declaration=True, encoding="UTF-8"
)
serializer = XmlSerializer(config=config)
xml_push_request = serializer.render(
xml_push_request, ns_map={None: "http://www.opentravel.org/OTA/2003/05"}
)
return AlpineBitsResponse(xml_push_request, HttpStatusCode.OK)
class AlpineBitsServer: class AlpineBitsServer:
""" """
@@ -579,6 +700,7 @@ class AlpineBitsServer:
def _initialize_action_instances(self): def _initialize_action_instances(self):
"""Initialize instances of all discovered action classes.""" """Initialize instances of all discovered action classes."""
for capability_name, action_class in self.capabilities.action_registry.items(): for capability_name, action_class in self.capabilities.action_registry.items():
_LOGGER.info(f"Initializing action instance for {capability_name}")
self._action_instances[capability_name] = action_class(config=self.config) self._action_instances[capability_name] = action_class(config=self.config)
def get_capabilities(self) -> Dict: def get_capabilities(self) -> Dict:
@@ -592,7 +714,7 @@ class AlpineBitsServer:
async def handle_request( async def handle_request(
self, self,
request_action_name: str, request_action_name: str,
request_xml: str, request_xml: str | Tuple[Reservation, Customer],
client_info: AlpineBitsClientInfo, client_info: AlpineBitsClientInfo,
version: str = "2024-10", version: str = "2024-10",
dbsession=None, dbsession=None,
@@ -602,7 +724,7 @@ class AlpineBitsServer:
Args: Args:
request_action_name: The action name from the request (e.g., "OTA_Read:GuestRequests") request_action_name: The action name from the request (e.g., "OTA_Read:GuestRequests")
request_xml: The XML request body request_xml: The XML request body. Gets passed to the action handler. In case of PushRequest can be the data to be pushed
version: The AlpineBits version (defaults to "2024-10") version: The AlpineBits version (defaults to "2024-10")
Returns: Returns:
@@ -618,6 +740,8 @@ class AlpineBitsServer:
# Find the action by request name # Find the action by request name
action_enum = AlpineBitsActionName.get_by_request_name(request_action_name) action_enum = AlpineBitsActionName.get_by_request_name(request_action_name)
_LOGGER.info(f"Handling request for action: {request_action_name} with action enum: {action_enum}")
if not action_enum: if not action_enum:
return AlpineBitsResponse( return AlpineBitsResponse(
f"Error: Unknown action {request_action_name}", f"Error: Unknown action {request_action_name}",
@@ -625,14 +749,14 @@ class AlpineBitsServer:
) )
# Check if we have an implementation for this action # Check if we have an implementation for this action
capability_name = action_enum.capability_name
if capability_name not in self._action_instances: if action_enum not in self._action_instances:
return AlpineBitsResponse( return AlpineBitsResponse(
f"Error: Action {request_action_name} is not implemented", f"Error: Action {request_action_name} is not implemented",
HttpStatusCode.BAD_REQUEST, HttpStatusCode.BAD_REQUEST,
) )
action_instance: AlpineBitsAction = self._action_instances[capability_name] action_instance: AlpineBitsAction = self._action_instances[action_enum]
# Check if the action supports the requested version # Check if the action supports the requested version
if not await action_instance.check_version_supported(version_enum): if not await action_instance.check_version_supported(version_enum):
@@ -644,11 +768,26 @@ class AlpineBitsServer:
# Handle the request # Handle the request
try: try:
# Special case for ping action - pass server capabilities # Special case for ping action - pass server capabilities
if capability_name == "action_OTA_Ping":
if action_enum == AlpineBitsActionName.OTA_HOTEL_RES_NOTIF_GUEST_REQUESTS:
action_instance: PushAction
if request_xml is None or not isinstance(request_xml, tuple):
return AlpineBitsResponse(
f"Error: Invalid data for push request",
HttpStatusCode.BAD_REQUEST,
)
return await action_instance.handle(
action=request_action_name, request_xml=request_xml, version=version_enum, client_info=client_info
)
if action_enum == AlpineBitsActionName.OTA_PING:
return await action_instance.handle( return await action_instance.handle(
action=request_action_name, request_xml=request_xml, version=version_enum, server_capabilities=self.capabilities, client_info=client_info action=request_action_name, request_xml=request_xml, version=version_enum, server_capabilities=self.capabilities, client_info=client_info
) )
else: else:
return await action_instance.handle( return await action_instance.handle(
action=request_action_name, action=request_action_name,
request_xml=request_xml, request_xml=request_xml,

View File

@@ -16,7 +16,7 @@ from .config_loader import load_config
from fastapi.responses import HTMLResponse, PlainTextResponse, Response from fastapi.responses import HTMLResponse, PlainTextResponse, Response
from .models import WixFormSubmission from .models import WixFormSubmission
from datetime import datetime, date, timezone from datetime import datetime, date, timezone
from .auth import validate_api_key, validate_wix_signature, generate_api_key from .auth import generate_unique_id, validate_api_key, validate_wix_signature, generate_api_key
from .rate_limit import ( from .rate_limit import (
limiter, limiter,
webhook_limiter, webhook_limiter,
@@ -31,11 +31,14 @@ from datetime import datetime
from typing import Dict, Any, Optional, List from typing import Dict, Any, Optional, List
import json import json
import os import os
import asyncio
import gzip import gzip
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from .alpinebits_server import AlpineBitsClientInfo, AlpineBitsServer, Version from .alpinebits_server import AlpineBitsClientInfo, AlpineBitsServer, Version, AlpineBitsActionName
import urllib.parse import urllib.parse
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from functools import partial
import httpx
from .db import ( from .db import (
Base, Base,
@@ -52,10 +55,99 @@ _LOGGER = logging.getLogger(__name__)
# HTTP Basic auth for AlpineBits # HTTP Basic auth for AlpineBits
security_basic = HTTPBasic() security_basic = HTTPBasic()
from collections import defaultdict
# --- Enhanced event dispatcher with hotel-specific routing ---
class EventDispatcher:
def __init__(self):
self.listeners = defaultdict(list)
self.hotel_listeners = defaultdict(list) # hotel_code -> list of listeners
def register(self, event_name, func):
self.listeners[event_name].append(func)
def register_hotel_listener(self, event_name, hotel_code, func):
"""Register a listener for a specific hotel"""
self.hotel_listeners[f"{event_name}:{hotel_code}"].append(func)
async def dispatch(self, event_name, *args, **kwargs):
for func in self.listeners[event_name]:
await func(*args, **kwargs)
async def dispatch_for_hotel(self, event_name, hotel_code, *args, **kwargs):
"""Dispatch event only to listeners registered for specific hotel"""
key = f"{event_name}:{hotel_code}"
for func in self.hotel_listeners[key]:
await func(*args, **kwargs)
event_dispatcher = EventDispatcher()
# Load config at startup # Load config at startup
@asynccontextmanager async def push_listener(customer: DBCustomer, reservation: DBReservation, hotel):
"""
Push listener that sends reservation data to hotel's push endpoint.
Only called for reservations that match this hotel's hotel_id.
"""
push_endpoint = hotel.get("push_endpoint")
if not push_endpoint:
_LOGGER.warning(f"No push endpoint configured for hotel {hotel.get('hotel_id')}")
return
server: AlpineBitsServer = app.state.alpine_bits_server
hotel_id = hotel['hotel_id']
reservation_hotel_id = reservation.hotel_code
# Double-check hotel matching (should be guaranteed by dispatcher)
if hotel_id != reservation_hotel_id:
_LOGGER.warning(f"Hotel ID mismatch: listener for {hotel_id}, reservation for {reservation_hotel_id}")
return
_LOGGER.info(f"Processing push notification for hotel {hotel_id}, reservation {reservation.unique_id}")
# Prepare payload for push notification
request = await server.handle_request(request_action_name=AlpineBitsActionName.OTA_HOTEL_RES_NOTIF_GUEST_REQUESTS.request_name, request_xml=(reservation, customer), client_info=None, version=Version.V2024_10)
if request.status_code != 200:
_LOGGER.error(f"Failed to generate push request for hotel {hotel_id}, reservation {reservation.unique_id}: {request.xml_content}")
return
# save push request to file
logs_dir = "logs/push_requests"
if not os.path.exists(logs_dir):
os.makedirs(logs_dir, mode=0o755, exist_ok=True)
stat_info = os.stat(logs_dir)
_LOGGER.info(
f"Created directory owner: uid:{stat_info.st_uid}, gid:{stat_info.st_gid}"
)
_LOGGER.info(f"Directory mode: {oct(stat_info.st_mode)[-3:]}")
log_filename = (
f"{logs_dir}/alpinebits_push_{hotel_id}_{reservation.unique_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xml"
)
with open(log_filename, "w", encoding="utf-8") as f:
f.write(request.xml_content)
return
headers = {"Authorization": f"Bearer {push_endpoint.get('token','')}"} if push_endpoint.get('token') else {}
""
try:
async with httpx.AsyncClient() as client:
resp = await client.post(push_endpoint["url"], json=payload, headers=headers, timeout=10)
_LOGGER.info(f"Push event fired to {push_endpoint['url']} for hotel {hotel['hotel_id']}, status: {resp.status_code}")
if resp.status_code not in [200, 201, 202]:
_LOGGER.warning(f"Push endpoint returned non-success status {resp.status_code}: {resp.text}")
except Exception as e:
_LOGGER.error(f"Push event failed for hotel {hotel['hotel_id']}: {e}")
# Optionally implement retry logic here@asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# Setup DB # Setup DB
@@ -68,10 +160,31 @@ async def lifespan(app: FastAPI):
DATABASE_URL = get_database_url(config) DATABASE_URL = get_database_url(config)
engine = create_async_engine(DATABASE_URL, echo=True) engine = create_async_engine(DATABASE_URL, echo=True)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False) AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
app.state.engine = engine app.state.engine = engine
app.state.async_sessionmaker = AsyncSessionLocal app.state.async_sessionmaker = AsyncSessionLocal
app.state.config = config app.state.config = config
app.state.alpine_bits_server = AlpineBitsServer(config) app.state.alpine_bits_server = AlpineBitsServer(config)
app.state.event_dispatcher = event_dispatcher
# Register push listeners for hotels with push_endpoint
for hotel in config.get("alpine_bits_auth", []):
push_endpoint = hotel.get("push_endpoint")
hotel_id = hotel.get("hotel_id")
if push_endpoint and hotel_id:
# Register hotel-specific listener
event_dispatcher.register_hotel_listener(
"form_processed",
hotel_id,
partial(push_listener, hotel=hotel)
)
_LOGGER.info(f"Registered push listener for hotel {hotel_id} with endpoint {push_endpoint.get('url')}")
elif push_endpoint and not hotel_id:
_LOGGER.warning(f"Hotel has push_endpoint but no hotel_id: {hotel}")
elif hotel_id and not push_endpoint:
_LOGGER.info(f"Hotel {hotel_id} has no push_endpoint configured")
# Create tables # Create tables
async with engine.begin() as conn: async with engine.begin() as conn:
@@ -236,7 +349,9 @@ async def process_wix_form_submission(request: Request, data: Dict[str, Any], db
contact_id = contact_info.get("contactId") contact_id = contact_info.get("contactId")
name_prefix = data.get("field:anrede") name_prefix = data.get("field:anrede")
email_newsletter = data.get("field:form_field_5a7b", "") != "Non selezionato" email_newsletter_string = data.get("field:form_field_5a7b", "")
yes_values = {"Selezionato", "Angekreuzt", "Checked"}
email_newsletter = (email_newsletter_string in yes_values)
address_line = None address_line = None
city_name = None city_name = None
postal_code = None postal_code = None
@@ -280,12 +395,16 @@ async def process_wix_form_submission(request: Request, data: Dict[str, Any], db
("utm_Term", "utm_term"), ("utm_Term", "utm_term"),
("utm_Content", "utm_content"), ("utm_Content", "utm_content"),
] ]
utm_comment_text = []
for label, field in utm_fields: # get submissionId and ensure max length 35. Generate one if not present
val = data.get(f"field:{field}") or data.get(label)
if val: unique_id = data.get("submissionId", generate_unique_id())
utm_comment_text.append(f"{label}: {val}")
utm_comment = ",".join(utm_comment_text) if utm_comment_text else None if len(unique_id) > 35:
# strip to first 35 chars
unique_id = unique_id[:35]
# use database session # use database session
@@ -309,19 +428,35 @@ async def process_wix_form_submission(request: Request, data: Dict[str, Any], db
name_title=None, name_title=None,
) )
db.add(db_customer) db.add(db_customer)
await db.commit() await db.flush() # This assigns db_customer.id without committing
await db.refresh(db_customer) #await db.refresh(db_customer)
# Determine hotel_code and hotel_name
# Priority: 1) Form field, 2) Configuration default, 3) Hardcoded fallback
hotel_code = (
data.get("field:hotelid") or
data.get("hotelid") or
request.app.state.config.get("default_hotel_code") or
"123" # fallback
)
hotel_name = (
data.get("field:hotelname") or
data.get("hotelname") or
request.app.state.config.get("default_hotel_name") or
"Frangart Inn" # fallback
)
db_reservation = DBReservation( db_reservation = DBReservation(
customer_id=db_customer.id, customer_id=db_customer.id,
form_id=data.get("submissionId"), unique_id=unique_id,
start_date=date.fromisoformat(start_date) if start_date else None, start_date=date.fromisoformat(start_date) if start_date else None,
end_date=date.fromisoformat(end_date) if end_date else None, end_date=date.fromisoformat(end_date) if end_date else None,
num_adults=num_adults, num_adults=num_adults,
num_children=num_children, num_children=num_children,
children_ages=",".join(str(a) for a in children_ages), children_ages=",".join(str(a) for a in children_ages),
offer=offer, offer=offer,
utm_comment=utm_comment,
created_at=datetime.now(timezone.utc), created_at=datetime.now(timezone.utc),
utm_source=data.get("field:utm_source"), utm_source=data.get("field:utm_source"),
utm_medium=data.get("field:utm_medium"), utm_medium=data.get("field:utm_medium"),
@@ -331,12 +466,28 @@ async def process_wix_form_submission(request: Request, data: Dict[str, Any], db
user_comment=data.get("field:long_answer_3524", ""), user_comment=data.get("field:long_answer_3524", ""),
fbclid=data.get("field:fbclid"), fbclid=data.get("field:fbclid"),
gclid=data.get("field:gclid"), gclid=data.get("field:gclid"),
hotel_code="123", hotel_code=hotel_code,
hotel_name="Frangart Inn", hotel_name=hotel_name,
) )
db.add(db_reservation) db.add(db_reservation)
await db.commit() await db.commit()
await db.refresh(db_reservation) await db.refresh(db_reservation)
async def push_event():
# Fire event for listeners (push, etc.) - hotel-specific dispatch
dispatcher = getattr(request.app.state, "event_dispatcher", None)
if dispatcher:
# Get hotel_code from reservation to target the right listeners
hotel_code = getattr(db_reservation, 'hotel_code', None)
if hotel_code and hotel_code.strip():
await dispatcher.dispatch_for_hotel("form_processed", hotel_code, db_customer, db_reservation)
_LOGGER.info(f"Dispatched form_processed event for hotel {hotel_code}")
else:
_LOGGER.warning("No hotel_code in reservation, skipping push notifications")
asyncio.create_task(push_event())
return { return {
"status": "success", "status": "success",

View File

@@ -30,6 +30,10 @@ if os.getenv("WIX_API_KEY"):
if os.getenv("ADMIN_API_KEY"): if os.getenv("ADMIN_API_KEY"):
API_KEYS["admin-key"] = os.getenv("ADMIN_API_KEY") API_KEYS["admin-key"] = os.getenv("ADMIN_API_KEY")
def generate_unique_id() -> str:
"""Generate a unique ID with max length 35 characters"""
return secrets.token_urlsafe(26)[:35] # 26 bytes -> 35 chars in base64url
def generate_api_key() -> str: def generate_api_key() -> str:
"""Generate a secure API key""" """Generate a secure API key"""

View File

@@ -44,14 +44,13 @@ class Reservation(Base):
__tablename__ = "reservations" __tablename__ = "reservations"
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
customer_id = Column(Integer, ForeignKey("customers.id")) customer_id = Column(Integer, ForeignKey("customers.id"))
form_id = Column(String, unique=True) unique_id = Column(String(35), unique=True) # max length 35
start_date = Column(Date) start_date = Column(Date)
end_date = Column(Date) end_date = Column(Date)
num_adults = Column(Integer) num_adults = Column(Integer)
num_children = Column(Integer) num_children = Column(Integer)
children_ages = Column(String) # comma-separated children_ages = Column(String) # comma-separated
offer = Column(String) offer = Column(String)
utm_comment = Column(String)
created_at = Column(DateTime) created_at = Column(DateTime)
# Add all UTM fields and user comment for XML # Add all UTM fields and user comment for XML
utm_source = Column(String) utm_source = Column(String)
@@ -68,11 +67,11 @@ class Reservation(Base):
customer = relationship("Customer", back_populates="reservations") customer = relationship("Customer", back_populates="reservations")
class HashedCustomer(Base):
__tablename__ = "hashed_customers" # Table for tracking acknowledged requests by client
class AckedRequest(Base):
__tablename__ = 'acked_requests'
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
customer_id = Column(Integer) client_id = Column(String, index=True)
hashed_email = Column(String) unique_id = Column(String, index=True) # Should match Reservation.form_id or another unique field
hashed_phone = Column(String) timestamp = Column(DateTime)
hashed_name = Column(String)
redacted_at = Column(DateTime)

View File

@@ -256,7 +256,7 @@ def create_xml_from_db(customer: DBCustomer, reservation: DBReservation):
# UniqueID # UniqueID
unique_id = ab.OtaResRetrieveRs.ReservationsList.HotelReservation.UniqueId( unique_id = ab.OtaResRetrieveRs.ReservationsList.HotelReservation.UniqueId(
type_value=ab.UniqueIdType2.VALUE_14, id=reservation.form_id type_value=ab.UniqueIdType2.VALUE_14, id=reservation.unique_id
) )
# TimeSpan # TimeSpan

View File

@@ -3,10 +3,9 @@ from typing import Union
import sys import sys
import os import os
# Add the src directory to the path so we can import our modules
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
from simplified_access import (
from alpine_bits_python.alpine_bits_helpers import (
CustomerData, CustomerData,
CustomerFactory, CustomerFactory,
ResGuestFactory, ResGuestFactory,

View File

View File

@@ -0,0 +1,96 @@
import json
import pytest
import asyncio
from alpine_bits_python.alpinebits_server import AlpineBitsServer, AlpineBitsClientInfo
import re
from xsdata_pydantic.bindings import XmlParser
from alpine_bits_python.generated.alpinebits import OtaPingRs
def extract_relevant_sections(xml_string):
# Remove version attribute value, keep only presence
# Use the same XmlParser as AlpineBitsServer
parser = XmlParser()
obj = parser.from_string(xml_string, OtaPingRs)
return obj
@pytest.mark.asyncio
async def test_ping_action_response_matches_expected():
with open("test/test_data/Handshake-OTA_PingRQ.xml", "r", encoding="utf-8") as f:
server = AlpineBitsServer()
with open("test/test_data/Handshake-OTA_PingRQ.xml", "r", encoding="utf-8") as f:
request_xml = f.read()
with open("test/test_data/Handshake-OTA_PingRS.xml", "r", encoding="utf-8") as f:
expected_xml = f.read()
client_info = AlpineBitsClientInfo(username="irrelevant", password="irrelevant")
response = await server.handle_request(
request_action_name="OTA_Ping:Handshaking",
request_xml=request_xml,
client_info=client_info,
version="2024-10"
)
actual_obj = extract_relevant_sections(response.xml_content)
expected_obj = extract_relevant_sections(expected_xml)
actual_matches = actual_obj.warnings.warning
expected_matches = expected_obj.warnings.warning
assert actual_matches == expected_matches, f"Expected warnings {expected_matches}, got {actual_matches}"
actual_capabilities = actual_obj.echo_data
expected_capabilities = expected_obj.echo_data
assert actual_capabilities == expected_capabilities, f"Expected echo data {expected_capabilities}, got {actual_capabilities}"
@pytest.mark.asyncio
async def test_ping_action_response_success():
server = AlpineBitsServer()
with open("test/test_data/Handshake-OTA_PingRQ.xml", "r", encoding="utf-8") as f:
request_xml = f.read()
client_info = AlpineBitsClientInfo(username="irrelevant", password="irrelevant")
response = await server.handle_request(
request_action_name="OTA_Ping:Handshaking",
request_xml=request_xml,
client_info=client_info,
version="2024-10"
)
assert response.status_code == 200
assert "<OTA_PingRS" in response.xml_content
assert "<Success" in response.xml_content
assert "Version=" in response.xml_content
@pytest.mark.asyncio
async def test_ping_action_response_version_arbitrary():
server = AlpineBitsServer()
with open("test/test_data/Handshake-OTA_PingRQ.xml", "r", encoding="utf-8") as f:
request_xml = f.read()
client_info = AlpineBitsClientInfo(username="irrelevant", password="irrelevant")
response = await server.handle_request(
request_action_name="OTA_Ping:Handshaking",
request_xml=request_xml,
client_info=client_info,
version="2022-10"
)
assert response.status_code == 200
assert "<OTA_PingRS" in response.xml_content
assert "Version=" in response.xml_content
@pytest.mark.asyncio
async def test_ping_action_response_invalid_action():
server = AlpineBitsServer()
with open("test/test_data/Handshake-OTA_PingRQ.xml", "r", encoding="utf-8") as f:
request_xml = f.read()
client_info = AlpineBitsClientInfo(username="irrelevant", password="irrelevant")
response = await server.handle_request(
request_action_name="InvalidAction",
request_xml=request_xml,
client_info=client_info,
version="2024-10"
)
assert response.status_code == 400
assert "Error" in response.xml_content

View File

@@ -0,0 +1,158 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
AlpineBits 2024-10
https://www.alpinebits.org/
Sample message file for a Handshake request
Changelog:
v. 2024-10 1.2 Example extended with all capabilities and two supported releases
v. 2024-10 1.1 Removed the OTA_Ping action
v. 2024-10 1.0 added supported version 2024-10 in the example
v. 2018-10 1.0 initial example
-->
<OTA_PingRQ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.opentravel.org/OTA/2003/05"
xsi:schemaLocation="http://www.opentravel.org/OTA/2003/05 OTA_PingRQ.xsd"
Version="8.000">
<EchoData>
{
"versions": [
{
"version": "2024-10",
"actions": [
{
"action": "action_OTA_Read"
},
{
"action": "action_OTA_HotelResNotif_GuestRequests"
},
{
"action": "action_OTA_HotelResNotif_GuestRequests_StatusUpdate"
},
{
"action": "action_OTA_HotelInvCountNotif",
"supports": [
"OTA_HotelInvCountNotif_accept_rooms",
"OTA_HotelInvCountNotif_accept_categories",
"OTA_HotelInvCountNotif_accept_deltas",
"OTA_HotelInvCountNotif_accept_out_of_market",
"OTA_HotelInvCountNotif_accept_out_of_order",
"OTA_HotelInvCountNotif_accept_complete_set",
"OTA_HotelInvCountNotif_accept_closing_seasons"
]
},
{
"action": "action_OTA_HotelDescriptiveContentNotif_Inventory",
"supports": [
"OTA_HotelDescriptiveContentNotif_Inventory_use_rooms",
"OTA_HotelDescriptiveContentNotif_Inventory_occupancy_children"
]
},
{
"action": "action_OTA_HotelDescriptiveContentNotif_Info"
},
{
"action": "action_OTA_HotelDescriptiveInfo_Inventory"
},
{
"action": "action_OTA_HotelDescriptiveInfo_Info"
},
{
"action": "action_OTA_HotelRatePlanNotif_RatePlans",
"supports": [
"OTA_HotelRatePlanNotif_accept_ArrivalDOW",
"OTA_HotelRatePlanNotif_accept_DepartureDOW",
"OTA_HotelRatePlanNotif_accept_RatePlan_BookingRule",
"OTA_HotelRatePlanNotif_accept_RatePlan_RoomType_BookingRule",
"OTA_HotelRatePlanNotif_accept_RatePlan_mixed_BookingRule",
"OTA_HotelRatePlanNotif_accept_Supplements",
"OTA_HotelRatePlanNotif_accept_FreeNightsOffers",
"OTA_HotelRatePlanNotif_accept_FamilyOffers",
"OTA_HotelRatePlanNotif_accept_full",
"OTA_HotelRatePlanNotif_accept_overlay",
"OTA_HotelRatePlanNotif_accept_RatePlanJoin",
"OTA_HotelRatePlanNotif_accept_OfferRule_BookingOffset",
"OTA_HotelRatePlanNotif_accept_OfferRule_DOWLOS"
]
},
{
"action": "action_OTA_HotelRatePlan_BaseRates",
"supports": [
"OTA_HotelRatePlan_BaseRates_deltas"
]
},
{
"action": "action_OTA_HotelPostEventNotif_EventReports"
}
]
},
{
"version": "2022-10",
"actions": [
{
"action": "action_OTA_Ping"
},
{
"action": "action_OTA_Read"
},
{
"action": "action_OTA_HotelResNotif_GuestRequests"
},
{
"action": "action_OTA_HotelResNotif_GuestRequests_StatusUpdate"
},
{
"action": "action_OTA_HotelInvCountNotif",
"supports": [
"OTA_HotelInvCountNotif_accept_rooms",
"OTA_HotelInvCountNotif_accept_categories",
"OTA_HotelInvCountNotif_accept_deltas",
"OTA_HotelInvCountNotif_accept_out_of_market",
"OTA_HotelInvCountNotif_accept_out_of_order",
"OTA_HotelInvCountNotif_accept_complete_set",
"OTA_HotelInvCountNotif_accept_closing_seasons"
]
},
{
"action": "action_OTA_HotelDescriptiveContentNotif_Inventory",
"supports": [
"OTA_HotelDescriptiveContentNotif_Inventory_use_rooms",
"OTA_HotelDescriptiveContentNotif_Inventory_occupancy_children"
]
},
{
"action": "action_OTA_HotelDescriptiveContentNotif_Info"
},
{
"action": "action_OTA_HotelDescriptiveInfo_Inventory"
},
{
"action": "action_OTA_HotelDescriptiveInfo_Info"
},
{
"action": "action_OTA_HotelRatePlanNotif_RatePlans",
"supports": [
"OTA_HotelRatePlanNotif_accept_ArrivalDOW",
"OTA_HotelRatePlanNotif_accept_DepartureDOW",
"OTA_HotelRatePlanNotif_accept_RatePlan_BookingRule",
"OTA_HotelRatePlanNotif_accept_RatePlan_RoomType_BookingRule",
"OTA_HotelRatePlanNotif_accept_RatePlan_mixed_BookingRule",
"OTA_HotelRatePlanNotif_accept_Supplements",
"OTA_HotelRatePlanNotif_accept_FreeNightsOffers",
"OTA_HotelRatePlanNotif_accept_FamilyOffers",
"OTA_HotelRatePlanNotif_accept_overlay",
"OTA_HotelRatePlanNotif_accept_RatePlanJoin",
"OTA_HotelRatePlanNotif_accept_OfferRule_BookingOffset",
"OTA_HotelRatePlanNotif_accept_OfferRule_DOWLOS"
]
}
]
}
]
}
</EchoData>
</OTA_PingRQ>

View File

@@ -0,0 +1,81 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
AlpineBits 2024-10
https://www.alpinebits.org/
Sample message file for a Handshake response
Changelog:
v. 2024-10 1.2 Example extended with all capabilities and two supported releases
v. 2024-10 1.1 Removed the OTA_Ping action
v. 2024-10 1.0 added supported version 2024-10 in the example
v. 2018-10 1.0 initial example
-->
<OTA_PingRS xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.opentravel.org/OTA/2003/05"
xsi:schemaLocation="http://www.opentravel.org/OTA/2003/05 OTA_PingRS.xsd"
Version="8.000">
<Success/>
<Warnings>
<Warning Type="11" Status="ALPINEBITS_HANDSHAKE">{
"versions": [
{
"version": "2024-10",
"actions": [
{
"action": "action_OTA_Read"
},
{
"action": "action_OTA_HotelResNotif_GuestRequests"
}
]
},
{
"version": "2022-10",
"actions": [
{
"action": "action_OTA_Ping"
},
{
"action": "action_OTA_Read"
},
{
"action": "action_OTA_HotelResNotif_GuestRequests"
}
]
}
]
}</Warning>
</Warnings>
<EchoData>{
"versions": [
{
"version": "2024-10",
"actions": [
{
"action": "action_OTA_Read"
},
{
"action": "action_OTA_HotelResNotif_GuestRequests"
}
]
},
{
"version": "2022-10",
"actions": [
{
"action": "action_OTA_Read"
},
{
"action": "action_OTA_Ping"
},
{
"action": "action_OTA_HotelResNotif_GuestRequests"
}
]
}
]
}</EchoData>
</OTA_PingRS>

View File

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

44
uv.lock generated
View File

@@ -24,8 +24,10 @@ dependencies = [
{ name = "dotenv" }, { name = "dotenv" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "generateds" }, { name = "generateds" },
{ name = "httpx" },
{ name = "lxml" }, { name = "lxml" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "redis" }, { name = "redis" },
{ name = "ruff" }, { name = "ruff" },
{ name = "slowapi" }, { name = "slowapi" },
@@ -43,8 +45,10 @@ requires-dist = [
{ name = "dotenv", specifier = ">=0.9.9" }, { name = "dotenv", specifier = ">=0.9.9" },
{ name = "fastapi", specifier = ">=0.117.1" }, { name = "fastapi", specifier = ">=0.117.1" },
{ name = "generateds", specifier = ">=2.44.3" }, { name = "generateds", specifier = ">=2.44.3" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "lxml", specifier = ">=6.0.1" }, { name = "lxml", specifier = ">=6.0.1" },
{ name = "pytest", specifier = ">=8.4.2" }, { name = "pytest", specifier = ">=8.4.2" },
{ name = "pytest-asyncio", specifier = ">=1.2.0" },
{ name = "redis", specifier = ">=6.4.0" }, { name = "redis", specifier = ">=6.4.0" },
{ name = "ruff", specifier = ">=0.13.1" }, { name = "ruff", specifier = ">=0.13.1" },
{ name = "slowapi", specifier = ">=0.1.9" }, { name = "slowapi", specifier = ">=0.1.9" },
@@ -286,6 +290,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
] ]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.10" version = "3.10"
@@ -529,6 +561,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
] ]
[[package]]
name = "pytest-asyncio"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" },
]
[[package]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.1.1" version = "1.1.1"