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"