diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
new file mode 100644
index 0000000..780a1ff
--- /dev/null
+++ b/.github/workflows/build.yaml
@@ -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 }}
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index b0c767a..211c784 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,6 +17,8 @@ wheels/
# ignore test_data content but keep the folder
test_data/*
+test/test_output/*
+
# ignore secrets
secrets.yaml
diff --git a/AlpineBits-HotelData-2024-10/AlpineBits-HotelData-2024-10.pdf b/AlpineBits-HotelData-2024-10/AlpineBits-HotelData-2024-10.pdf
index ac56209..d496ecb 100644
Binary files a/AlpineBits-HotelData-2024-10/AlpineBits-HotelData-2024-10.pdf and b/AlpineBits-HotelData-2024-10/AlpineBits-HotelData-2024-10.pdf differ
diff --git a/config/config.yaml b/config/config.yaml
index 5d8fecb..85111c3 100644
--- a/config/config.yaml
+++ b/config/config.yaml
@@ -6,10 +6,14 @@ database:
# url: "postgresql://user:password@host:port/dbname" # Example for Postgres
alpine_bits_auth:
- - hotel_id: "123"
- hotel_name: "Frangart Inn"
+ - hotel_id: "12345"
+ hotel_name: "Bemelmans Post"
username: "alice"
password: !secret ALICE_PASSWORD
+ push_endpoint:
+ url: "https://example.com/push"
+ token: !secret PUSH_TOKEN_ALICE
+ username: "alice"
- hotel_id: "456"
hotel_name: "Bemelmans"
username: "bob"
diff --git a/logs/wix_test_data_20251006_104642.json b/logs/wix_test_data_20251006_104642.json
new file mode 100644
index 0000000..f60d983
--- /dev/null
+++ b/logs/wix_test_data_20251006_104642.json
@@ -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"
+ }
+}
\ No newline at end of file
diff --git a/logs/wix_test_data_20251006_105732.json b/logs/wix_test_data_20251006_105732.json
new file mode 100644
index 0000000..83adc96
--- /dev/null
+++ b/logs/wix_test_data_20251006_105732.json
@@ -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"
+ }
+}
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index 06a57dc..073ea9b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -14,8 +14,10 @@ dependencies = [
"dotenv>=0.9.9",
"fastapi>=0.117.1",
"generateds>=2.44.3",
+ "httpx>=0.28.1",
"lxml>=6.0.1",
"pytest>=8.4.2",
+ "pytest-asyncio>=1.2.0",
"redis>=6.4.0",
"ruff>=0.13.1",
"slowapi>=0.1.9",
diff --git a/src/alpine_bits_python/alpine_bits_helpers.py b/src/alpine_bits_python/alpine_bits_helpers.py
index 5bf677a..e09cadd 100644
--- a/src/alpine_bits_python/alpine_bits_helpers.py
+++ b/src/alpine_bits_python/alpine_bits_helpers.py
@@ -1,4 +1,5 @@
from datetime import datetime, timezone
+import traceback
from typing import Union, Optional, Any, TypeVar
from pydantic import BaseModel, ConfigDict, Field
from dataclasses import dataclass
@@ -14,6 +15,7 @@ from .generated.alpinebits import (
OtaHotelResNotifRq,
OtaResRetrieveRs,
CommentName2,
+ ProfileProfileType,
UniqueIdType2,
)
import logging
@@ -51,6 +53,18 @@ 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)
+
# phonetechtype enum 1,3,5 voice, fax, mobile
class PhoneTechType(Enum):
@@ -104,31 +118,25 @@ class CustomerData:
class GuestCountsFactory:
+ """Factory class to create GuestCounts instances for both OtaHotelResNotifRq and OtaResRetrieveRs."""
@staticmethod
- def create_notif_guest_counts(
+ def create_guest_counts(
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 kids: List of ages for each kid (optional)
: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
def _create_guest_counts(
@@ -567,6 +575,9 @@ class ResGuestFactory:
return CustomerFactory.from_notif_customer(customer)
else:
return CustomerFactory.from_retrieve_customer(customer)
+
+
+
class AlpineBitsFactory:
@@ -669,9 +680,247 @@ class AlpineBitsFactory:
else:
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.
list of pairs (Reservation, Customer)
@@ -679,173 +928,65 @@ def create_xml_from_db(list: list[Tuple[Reservation, Customer]]):
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(
- 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:
- 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
- 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,
- )
+ hotel_reservation = _process_single_reservation(reservation, customer, type)
reservations_list.append(hotel_reservation)
except Exception as e:
_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(
- hotel_reservation=reservations_list
- )
+ if type == OtaMessageType.NOTIF:
+ retrieved_reservations = OtaHotelResNotifRq.HotelReservations(
+ hotel_reservation=reservations_list
+ )
- ota_res_retrieve_rs = OtaResRetrieveRs(
- version="7.000", success="", reservations_list=retrieved_reservations
- )
+ ota_hotel_res_notif_rq = OtaHotelResNotifRq(
+ version="7.000", hotel_reservations=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
+ try:
+ ota_hotel_res_notif_rq.model_validate(ota_hotel_res_notif_rq.model_dump())
+ except Exception as e:
+ _LOGGER.error(f"Validation error: {e}")
+ 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
diff --git a/src/alpine_bits_python/alpinebits_guestrequests.py b/src/alpine_bits_python/alpinebits_guestrequests.py
deleted file mode 100644
index 9433626..0000000
--- a/src/alpine_bits_python/alpinebits_guestrequests.py
+++ /dev/null
@@ -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:
-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")
diff --git a/src/alpine_bits_python/alpinebits_server.py b/src/alpine_bits_python/alpinebits_server.py
index 75ccccc..10afc8e 100644
--- a/src/alpine_bits_python/alpinebits_server.py
+++ b/src/alpine_bits_python/alpinebits_server.py
@@ -8,6 +8,7 @@ handshaking functionality with configurable supported actions and capabilities.
import asyncio
from datetime import datetime
+from zoneinfo import ZoneInfo
import difflib
import json
import inspect
@@ -17,16 +18,16 @@ from xml.etree import ElementTree as ET
from dataclasses import dataclass
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.formats.dataclass.serializers.config import SerializerConfig
from abc import ABC, abstractmethod
from xsdata_pydantic.bindings import XmlParser
import logging
-from .db import Reservation, Customer
+from .db import AckedRequest, Reservation, Customer
from sqlalchemy import select
from sqlalchemy.orm import joinedload
@@ -52,10 +53,14 @@ class AlpineBitsActionName(Enum):
OTA_PING = ("action_OTA_Ping", "OTA_Ping:Handshaking")
OTA_READ = ("action_OTA_Read", "OTA_Read:GuestRequests")
OTA_HOTEL_AVAIL_NOTIF = ("action_OTA_HotelAvailNotif", "OTA_HotelAvailNotif")
- OTA_HOTEL_RES_NOTIF_GUEST_REQUESTS = (
+ OTA_HOTEL_RES_NOTIF_GUEST_REQUESTS = ( ## Push Action for Guest Requests
"action_OTA_HotelResNotif_GuestRequests",
"OTA_HotelResNotif:GuestRequests",
)
+ OTA_HOTEL_NOTIF_REPORT = (
+ "action_OTA_Read", # if read is supported this is also supported
+ "OTA_NotifReport:GuestRequests",
+ )
OTA_HOTEL_DESCRIPTIVE_CONTENT_NOTIF_INVENTORY = (
"action_OTA_HotelDescriptiveContentNotif_Inventory",
"OTA_HotelDescriptiveContentNotif:Inventory",
@@ -191,7 +196,7 @@ class ServerCapabilities:
"""
def __init__(self):
- self.action_registry: Dict[str, Type[AlpineBitsAction]] = {}
+ self.action_registry: Dict[AlpineBitsActionName, Type[AlpineBitsAction]] = {}
self._discover_actions()
self.capability_dict = None
@@ -209,8 +214,8 @@ class ServerCapabilities:
if self._is_action_implemented(obj):
action_instance = obj()
if hasattr(action_instance, "name"):
- # Use capability name for the registry key
- self.action_registry[action_instance.name.capability_name] = obj
+ # Use capability attribute as registry key
+ self.action_registry[action_instance.name] = obj
def _is_action_implemented(self, action_class: Type[AlpineBitsAction]) -> bool:
"""
@@ -229,7 +234,7 @@ class ServerCapabilities:
"""
versions_dict = {}
- for action_name, action_class in self.action_registry.items():
+ for action_enum, action_class in self.action_registry.items():
action_instance = action_class()
# Get supported versions for this action
@@ -245,7 +250,7 @@ class ServerCapabilities:
if version_str not in versions_dict:
versions_dict[version_str] = {"version": version_str, "actions": []}
- action_dict = {"action": action_name}
+ action_dict = {"action": action_enum.capability_name}
# Add supports field if the action has custom supports
if hasattr(action_instance, "supports") and action_instance.supports:
@@ -255,6 +260,25 @@ class ServerCapabilities:
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
def get_capabilities_dict(self) -> Dict:
@@ -374,12 +398,17 @@ class PingAction(AlpineBitsAction):
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(
version="7.000",
warnings=warning_response,
- echo_data=all_capabilities,
+ echo_data=all_capabilities_json,
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
+
+
stmt = (
select(Reservation, Customer)
.join(Customer, Reservation.customer_id == Customer.id)
@@ -500,6 +531,20 @@ class ReadAction(AlpineBitsAction):
)
if start_date:
stmt = stmt.filter(Reservation.start_date >= start_date)
+ else:
+ # remove reservations that have been acknowledged via client_id
+ if client_info.client_id:
+ subquery = (
+ select(Reservation.id)
+ .join(
+ AckedRequest,
+ AckedRequest.unique_id == Reservation.unique_id,
+ )
+ .filter(AckedRequest.client_id == client_info.client_id)
+ )
+ stmt = stmt.filter(~Reservation.id.in_(subquery))
+
+
result = await dbsession.execute(stmt)
reservation_customer_pairs: list[tuple[Reservation, Customer]] = (
@@ -514,7 +559,7 @@ class ReadAction(AlpineBitsAction):
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(
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"""
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.config = config
@@ -540,26 +585,102 @@ class NotifReportReadAction(AlpineBitsAction):
action: str,
request_xml: str,
version: Version,
+ client_info: AlpineBitsClientInfo,
dbsession=None,
- username=None,
- password=None,
+ server_capabilities=None,
) -> AlpineBitsResponse:
"""Handle read requests."""
- return AlpineBitsResponse(
- f"Error: Action {action} not implemented", HttpStatusCode.BAD_REQUEST
+ notif_report = XmlParser().from_string(request_xml, OtaNotifReportRq)
+
+ # we can't check hotel auth here, because this action does not contain hotel info
+
+ warnings = notif_report.warnings
+ notif_report_details = notif_report.notif_details
+
+ 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):
- """Unimplemented action - will not appear in capabilities."""
+ config = SerializerConfig(
+ pretty_print=True, xml_declaration=True, encoding="UTF-8"
+ )
+ serializer = XmlSerializer(config=config)
+ response_xml = serializer.render(
+ success_message, ns_map={None: "http://www.opentravel.org/OTA/2003/05"}
+ )
- 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.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:
"""
@@ -579,6 +700,7 @@ class AlpineBitsServer:
def _initialize_action_instances(self):
"""Initialize instances of all discovered action classes."""
for capability_name, action_class in self.capabilities.action_registry.items():
+ _LOGGER.info(f"Initializing action instance for {capability_name}")
self._action_instances[capability_name] = action_class(config=self.config)
def get_capabilities(self) -> Dict:
@@ -592,7 +714,7 @@ class AlpineBitsServer:
async def handle_request(
self,
request_action_name: str,
- request_xml: str,
+ request_xml: str | Tuple[Reservation, Customer],
client_info: AlpineBitsClientInfo,
version: str = "2024-10",
dbsession=None,
@@ -602,7 +724,7 @@ class AlpineBitsServer:
Args:
request_action_name: The action name from the request (e.g., "OTA_Read:GuestRequests")
- request_xml: The XML request body
+ request_xml: The XML request body. Gets passed to the action handler. In case of PushRequest can be the data to be pushed
version: The AlpineBits version (defaults to "2024-10")
Returns:
@@ -618,6 +740,8 @@ class AlpineBitsServer:
# Find the action by request name
action_enum = AlpineBitsActionName.get_by_request_name(request_action_name)
+
+ _LOGGER.info(f"Handling request for action: {request_action_name} with action enum: {action_enum}")
if not action_enum:
return AlpineBitsResponse(
f"Error: Unknown action {request_action_name}",
@@ -625,14 +749,14 @@ class AlpineBitsServer:
)
# Check if we have an implementation for this action
- capability_name = action_enum.capability_name
- if capability_name not in self._action_instances:
+
+ if action_enum not in self._action_instances:
return AlpineBitsResponse(
f"Error: Action {request_action_name} is not implemented",
HttpStatusCode.BAD_REQUEST,
)
- action_instance: AlpineBitsAction = self._action_instances[capability_name]
+ action_instance: AlpineBitsAction = self._action_instances[action_enum]
# Check if the action supports the requested version
if not await action_instance.check_version_supported(version_enum):
@@ -644,11 +768,26 @@ class AlpineBitsServer:
# Handle the request
try:
# Special case for ping action - pass server capabilities
- if capability_name == "action_OTA_Ping":
+
+ if action_enum == AlpineBitsActionName.OTA_HOTEL_RES_NOTIF_GUEST_REQUESTS:
+
+ action_instance: PushAction
+ if request_xml is None or not isinstance(request_xml, tuple):
+ return AlpineBitsResponse(
+ 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(
action=request_action_name, request_xml=request_xml, version=version_enum, server_capabilities=self.capabilities, client_info=client_info
)
else:
+
return await action_instance.handle(
action=request_action_name,
request_xml=request_xml,
diff --git a/src/alpine_bits_python/api.py b/src/alpine_bits_python/api.py
index 98d7d25..252368f 100644
--- a/src/alpine_bits_python/api.py
+++ b/src/alpine_bits_python/api.py
@@ -16,7 +16,7 @@ from .config_loader import load_config
from fastapi.responses import HTMLResponse, PlainTextResponse, Response
from .models import WixFormSubmission
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 (
limiter,
webhook_limiter,
@@ -31,11 +31,14 @@ from datetime import datetime
from typing import Dict, Any, Optional, List
import json
import os
+import asyncio
import gzip
import xml.etree.ElementTree as ET
-from .alpinebits_server import AlpineBitsClientInfo, AlpineBitsServer, Version
+from .alpinebits_server import AlpineBitsClientInfo, AlpineBitsServer, Version, AlpineBitsActionName
import urllib.parse
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
+from functools import partial
+import httpx
from .db import (
Base,
@@ -52,10 +55,99 @@ _LOGGER = logging.getLogger(__name__)
# HTTP Basic auth for AlpineBits
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
-@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):
# Setup DB
@@ -68,10 +160,31 @@ async def lifespan(app: FastAPI):
DATABASE_URL = get_database_url(config)
engine = create_async_engine(DATABASE_URL, echo=True)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
+
app.state.engine = engine
app.state.async_sessionmaker = AsyncSessionLocal
app.state.config = 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
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")
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
city_name = 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_Content", "utm_content"),
]
- utm_comment_text = []
- for label, field in utm_fields:
- val = data.get(f"field:{field}") or data.get(label)
- if val:
- utm_comment_text.append(f"{label}: {val}")
- utm_comment = ",".join(utm_comment_text) if utm_comment_text else None
+
+ # get submissionId and ensure max length 35. Generate one if not present
+
+ unique_id = data.get("submissionId", generate_unique_id())
+
+ if len(unique_id) > 35:
+ # strip to first 35 chars
+ unique_id = unique_id[:35]
+
+
# use database session
@@ -309,19 +428,35 @@ async def process_wix_form_submission(request: Request, data: Dict[str, Any], db
name_title=None,
)
db.add(db_customer)
- await db.commit()
- await db.refresh(db_customer)
+ await db.flush() # This assigns db_customer.id without committing
+ #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(
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,
end_date=date.fromisoformat(end_date) if end_date else None,
num_adults=num_adults,
num_children=num_children,
children_ages=",".join(str(a) for a in children_ages),
offer=offer,
- utm_comment=utm_comment,
created_at=datetime.now(timezone.utc),
utm_source=data.get("field:utm_source"),
utm_medium=data.get("field:utm_medium"),
@@ -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", ""),
fbclid=data.get("field:fbclid"),
gclid=data.get("field:gclid"),
- hotel_code="123",
- hotel_name="Frangart Inn",
+ hotel_code=hotel_code,
+ hotel_name=hotel_name,
)
db.add(db_reservation)
await db.commit()
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 {
"status": "success",
diff --git a/src/alpine_bits_python/auth.py b/src/alpine_bits_python/auth.py
index 5a7632e..6cb20e0 100644
--- a/src/alpine_bits_python/auth.py
+++ b/src/alpine_bits_python/auth.py
@@ -30,6 +30,10 @@ if os.getenv("WIX_API_KEY"):
if os.getenv("ADMIN_API_KEY"):
API_KEYS["admin-key"] = os.getenv("ADMIN_API_KEY")
+def generate_unique_id() -> str:
+ """Generate a unique ID with max length 35 characters"""
+ return secrets.token_urlsafe(26)[:35] # 26 bytes -> 35 chars in base64url
+
def generate_api_key() -> str:
"""Generate a secure API key"""
diff --git a/src/alpine_bits_python/db.py b/src/alpine_bits_python/db.py
index 8810aca..1cc790d 100644
--- a/src/alpine_bits_python/db.py
+++ b/src/alpine_bits_python/db.py
@@ -44,14 +44,13 @@ class Reservation(Base):
__tablename__ = "reservations"
id = Column(Integer, primary_key=True)
customer_id = Column(Integer, ForeignKey("customers.id"))
- form_id = Column(String, unique=True)
+ unique_id = Column(String(35), unique=True) # max length 35
start_date = Column(Date)
end_date = Column(Date)
num_adults = Column(Integer)
num_children = Column(Integer)
children_ages = Column(String) # comma-separated
offer = Column(String)
- utm_comment = Column(String)
created_at = Column(DateTime)
# Add all UTM fields and user comment for XML
utm_source = Column(String)
@@ -68,11 +67,11 @@ class Reservation(Base):
customer = relationship("Customer", back_populates="reservations")
-class HashedCustomer(Base):
- __tablename__ = "hashed_customers"
+
+# Table for tracking acknowledged requests by client
+class AckedRequest(Base):
+ __tablename__ = 'acked_requests'
id = Column(Integer, primary_key=True)
- customer_id = Column(Integer)
- hashed_email = Column(String)
- hashed_phone = Column(String)
- hashed_name = Column(String)
- redacted_at = Column(DateTime)
+ client_id = Column(String, index=True)
+ unique_id = Column(String, index=True) # Should match Reservation.form_id or another unique field
+ timestamp = Column(DateTime)
diff --git a/src/alpine_bits_python/main.py b/src/alpine_bits_python/main.py
index 2d51ef4..3112d20 100644
--- a/src/alpine_bits_python/main.py
+++ b/src/alpine_bits_python/main.py
@@ -256,7 +256,7 @@ def create_xml_from_db(customer: DBCustomer, reservation: DBReservation):
# 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
diff --git a/test/test_simplified_access.py b/test/test_alpine_bits_helper.py
similarity index 99%
rename from test/test_simplified_access.py
rename to test/test_alpine_bits_helper.py
index 6b1c96a..c098c50 100644
--- a/test/test_simplified_access.py
+++ b/test/test_alpine_bits_helper.py
@@ -3,10 +3,9 @@ from typing import Union
import sys
import os
-# Add the src directory to the path so we can import our modules
-sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
-from simplified_access import (
+
+from alpine_bits_python.alpine_bits_helpers import (
CustomerData,
CustomerFactory,
ResGuestFactory,
diff --git a/test/test_alpine_bits_server.py b/test/test_alpine_bits_server.py
new file mode 100644
index 0000000..e69de29
diff --git a/test/test_alpinebits_server_ping.py b/test/test_alpinebits_server_ping.py
new file mode 100644
index 0000000..27ee031
--- /dev/null
+++ b/test/test_alpinebits_server_ping.py
@@ -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 "
+
+
+
+
+
+{
+ "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"
+ ]
+ }
+ ]
+ }
+ ]
+}
+
+
diff --git a/test/test_data/Handshake-OTA_PingRS.xml b/test/test_data/Handshake-OTA_PingRS.xml
new file mode 100644
index 0000000..555c9c9
--- /dev/null
+++ b/test/test_data/Handshake-OTA_PingRS.xml
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
+ {
+ "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"
+ }
+ ]
+ }
+ ]
+}
+
+ {
+ "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"
+ }
+ ]
+ }
+ ]
+}
+
\ No newline at end of file
diff --git a/test/test_discovery.py b/test/test_discovery.py
deleted file mode 100644
index 2eda866..0000000
--- a/test/test_discovery.py
+++ /dev/null
@@ -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", "", 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", "", Version.V2024_10)
- print(f"🔴 NewUnimplementedAction result: {result.xml_content}")
-
-
-if __name__ == "__main__":
- asyncio.run(main())
diff --git a/uv.lock b/uv.lock
index b4adb07..b9286a4 100644
--- a/uv.lock
+++ b/uv.lock
@@ -24,8 +24,10 @@ dependencies = [
{ name = "dotenv" },
{ name = "fastapi" },
{ name = "generateds" },
+ { name = "httpx" },
{ name = "lxml" },
{ name = "pytest" },
+ { name = "pytest-asyncio" },
{ name = "redis" },
{ name = "ruff" },
{ name = "slowapi" },
@@ -43,8 +45,10 @@ requires-dist = [
{ name = "dotenv", specifier = ">=0.9.9" },
{ name = "fastapi", specifier = ">=0.117.1" },
{ name = "generateds", specifier = ">=2.44.3" },
+ { name = "httpx", specifier = ">=0.28.1" },
{ name = "lxml", specifier = ">=6.0.1" },
{ name = "pytest", specifier = ">=8.4.2" },
+ { name = "pytest-asyncio", specifier = ">=1.2.0" },
{ name = "redis", specifier = ">=6.4.0" },
{ name = "ruff", specifier = ">=0.13.1" },
{ name = "slowapi", specifier = ">=0.1.9" },
@@ -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" },
]
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
+]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "certifi" },
+ { name = "httpcore" },
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
+]
+
[[package]]
name = "idna"
version = "3.10"
@@ -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" },
]
+[[package]]
+name = "pytest-asyncio"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" },
+]
+
[[package]]
name = "python-dotenv"
version = "1.1.1"