Compare commits
18 Commits
59347f504f
...
808f0eccc8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
808f0eccc8 | ||
|
|
b8e4f4fd01 | ||
|
|
17c3fc57b2 | ||
|
|
87668e6dc0 | ||
|
|
68e49aab34 | ||
|
|
2944b52d43 | ||
|
|
325965bb10 | ||
|
|
48aec92794 | ||
|
|
82118a1fa8 | ||
|
|
233a682e35 | ||
|
|
9c292a9897 | ||
|
|
277bd1934e | ||
|
|
b7afe4f528 | ||
|
|
36c32c44d8 | ||
|
|
ea9b6c72e4 | ||
|
|
dbfbd53ad9 | ||
|
|
579db2231f | ||
|
|
9f289e4750 |
88
.github/workflows/build.yaml
vendored
Normal file
88
.github/workflows/build.yaml
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
name: CI to Docker Hub
|
||||
|
||||
# Controls when the workflow will run
|
||||
on:
|
||||
# Triggers the workflow on push or pull request events but only for the main branch
|
||||
push:
|
||||
branches: [ "*" ]
|
||||
tags: [ "*" ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
|
||||
jobs:
|
||||
# This workflow contains a single job called "build"
|
||||
build:
|
||||
# The type of runner that the job will run on
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# Steps represent a sequence of tasks that will be executed as part of the job
|
||||
steps:
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
|
||||
- name: UV sync
|
||||
run: uv auth login gitea.linter-home.com --username jonas --password ${{ secrets.CI_TOKEN }} && uv lock
|
||||
|
||||
|
||||
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Login to Gitea Docker Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ${{ vars.REGISTRY }}
|
||||
username: ${{ vars.USER_NAME }}
|
||||
password: ${{ secrets.CI_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ vars.REGISTRY }}/${{ vars.USER_NAME }}/asa_api
|
||||
# generate Docker tags based on the following events/attributes
|
||||
tags: |
|
||||
type=schedule
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=ref,event=tag
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=sha
|
||||
|
||||
# - name: Debug DNS Resolution
|
||||
# run: sudo apt-get update && sudo apt-get install -y dnsutils &&
|
||||
# nslookup https://${{ vars.REGISTRY }}
|
||||
|
||||
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
registry: ${{ vars.REGISTRY }}
|
||||
username: ${{ vars.USER_NAME }}
|
||||
password: ${{ secrets.CI_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
build-args: |
|
||||
CI_TOKEN=${{ secrets.CI_TOKEN }}
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Image digest
|
||||
run: echo ${{ steps.docker_build.outputs.digest }}
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -17,6 +17,8 @@ wheels/
|
||||
# ignore test_data content but keep the folder
|
||||
test_data/*
|
||||
|
||||
test/test_output/*
|
||||
|
||||
|
||||
# ignore secrets
|
||||
secrets.yaml
|
||||
|
||||
Binary file not shown.
@@ -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"
|
||||
|
||||
262
logs/wix_test_data_20251006_104642.json
Normal file
262
logs/wix_test_data_20251006_104642.json
Normal file
@@ -0,0 +1,262 @@
|
||||
{
|
||||
"timestamp": "2025-10-06T10:46:42.527300",
|
||||
"client_ip": "127.0.0.1",
|
||||
"headers": {
|
||||
"host": "localhost:8080",
|
||||
"content-type": "application/json",
|
||||
"user-agent": "insomnia/2023.5.8",
|
||||
"accept": "*/*",
|
||||
"content-length": "7499"
|
||||
},
|
||||
"data": {
|
||||
"data": {
|
||||
"formName": "Contact us",
|
||||
"submissions": [
|
||||
{
|
||||
"label": "Angebot auswählen",
|
||||
"value": "Zimmer: Doppelzimmer"
|
||||
},
|
||||
{
|
||||
"label": "Anreisedatum",
|
||||
"value": "2025-12-21"
|
||||
},
|
||||
{
|
||||
"label": "Abreisedatum",
|
||||
"value": "2025-10-28"
|
||||
},
|
||||
{
|
||||
"label": "Anzahl Erwachsene",
|
||||
"value": "2"
|
||||
},
|
||||
{
|
||||
"label": "Anzahl Kinder",
|
||||
"value": "0"
|
||||
},
|
||||
{
|
||||
"label": "Anrede",
|
||||
"value": "Herr"
|
||||
},
|
||||
{
|
||||
"label": "Vorname",
|
||||
"value": "Ernst-Dieter"
|
||||
},
|
||||
{
|
||||
"label": "Nachname",
|
||||
"value": "Koepper"
|
||||
},
|
||||
{
|
||||
"label": "Email",
|
||||
"value": "koepper-ed@t-online.de"
|
||||
},
|
||||
{
|
||||
"label": "Phone",
|
||||
"value": "+49 175 8555456"
|
||||
},
|
||||
{
|
||||
"label": "Message",
|
||||
"value": "Guten Morgen,\nwir sind nicht gebau an die Reisedaten gebunden: Anreise ist möglich ab 20. Dezember, Aufenthalt mindestens eine Woche, gern auch 8 oder 9 Tage. Natürlich mit Halbpension. Mit freundlichem Gruß D. Köpper"
|
||||
},
|
||||
{
|
||||
"label": "Einwilligung Marketing",
|
||||
"value": "Angekreuzt"
|
||||
},
|
||||
{
|
||||
"label": "utm_Source",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "utm_Medium",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "utm_Campaign",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "utm_Term",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "utm_Content",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "utm_term_id",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "utm_content_id",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "gad_source",
|
||||
"value": "5"
|
||||
},
|
||||
{
|
||||
"label": "gad_campaignid",
|
||||
"value": "23065043477"
|
||||
},
|
||||
{
|
||||
"label": "gbraid",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "gclid",
|
||||
"value": "EAIaIQobChMI-d7Bn_-OkAMVuZJQBh09uD0vEAAYASAAEgKR8_D_BwE"
|
||||
},
|
||||
{
|
||||
"label": "fbclid",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "hotelid",
|
||||
"value": "12345"
|
||||
},
|
||||
{
|
||||
"label": "hotelname",
|
||||
"value": "Bemelmans Post"
|
||||
}
|
||||
],
|
||||
"field:date_picker_7e65": "2025-10-28",
|
||||
"field:number_7cf5": "2",
|
||||
"field:utm_source": "",
|
||||
"submissionTime": "2025-10-06T07:05:34.001Z",
|
||||
"field:gad_source": "5",
|
||||
"field:form_field_5a7b": "Angekreuzt",
|
||||
"field:gad_campaignid": "23065043477",
|
||||
"field:utm_medium": "",
|
||||
"field:utm_term_id": "",
|
||||
"context": {
|
||||
"metaSiteId": "1dea821c-8168-4736-96e4-4b92e8b364cf",
|
||||
"activationId": "fd8e9c90-0335-4fd2-976d-985f065f3f80"
|
||||
},
|
||||
"field:email_5139": "koepper-ed@t-online.de",
|
||||
"field:phone_4c77": "+49 175 8555456",
|
||||
"_context": {
|
||||
"activation": {
|
||||
"id": "fd8e9c90-0335-4fd2-976d-985f065f3f80"
|
||||
},
|
||||
"configuration": {
|
||||
"id": "a976f18c-fa86-495d-be1e-676df188eeae"
|
||||
},
|
||||
"app": {
|
||||
"id": "225dd912-7dea-4738-8688-4b8c6955ffc2"
|
||||
},
|
||||
"action": {
|
||||
"id": "152db4d7-5263-40c4-be2b-1c81476318b7"
|
||||
},
|
||||
"trigger": {
|
||||
"key": "wix_form_app-form_submitted"
|
||||
}
|
||||
},
|
||||
"field:gclid": "EAIaIQobChMI-d7Bn_-OkAMVuZJQBh09uD0vEAAYASAAEgKR8_D_BwE",
|
||||
"formFieldMask": [
|
||||
"field:",
|
||||
"field:",
|
||||
"field:angebot_auswaehlen",
|
||||
"field:date_picker_a7c8",
|
||||
"field:date_picker_7e65",
|
||||
"field:",
|
||||
"field:number_7cf5",
|
||||
"field:anzahl_kinder",
|
||||
"field:alter_kind_3",
|
||||
"field:alter_kind_25",
|
||||
"field:alter_kind_4",
|
||||
"field:alter_kind_5",
|
||||
"field:alter_kind_6",
|
||||
"field:alter_kind_7",
|
||||
"field:alter_kind_8",
|
||||
"field:alter_kind_9",
|
||||
"field:alter_kind_10",
|
||||
"field:alter_kind_11",
|
||||
"field:",
|
||||
"field:anrede",
|
||||
"field:first_name_abae",
|
||||
"field:last_name_d97c",
|
||||
"field:email_5139",
|
||||
"field:phone_4c77",
|
||||
"field:long_answer_3524",
|
||||
"field:form_field_5a7b",
|
||||
"field:",
|
||||
"field:utm_source",
|
||||
"field:utm_medium",
|
||||
"field:utm_campaign",
|
||||
"field:utm_term",
|
||||
"field:utm_content",
|
||||
"field:utm_term_id",
|
||||
"field:utm_content_id",
|
||||
"field:gad_source",
|
||||
"field:gad_campaignid",
|
||||
"field:gbraid",
|
||||
"field:gclid",
|
||||
"field:fbclid",
|
||||
"field:hotelid",
|
||||
"field:hotelname",
|
||||
"field:",
|
||||
"metaSiteId"
|
||||
],
|
||||
"contact": {
|
||||
"name": {
|
||||
"first": "Ernst-Dieter",
|
||||
"last": "Koepper"
|
||||
},
|
||||
"email": "koepper-ed@t-online.de",
|
||||
"locale": "de-de",
|
||||
"phones": [
|
||||
{
|
||||
"tag": "UNTAGGED",
|
||||
"formattedPhone": "+49 175 8555456",
|
||||
"id": "530a3bf4-6dbe-4611-8963-a50df805785d",
|
||||
"countryCode": "DE",
|
||||
"e164Phone": "+491758555456",
|
||||
"primary": true,
|
||||
"phone": "175 8555456"
|
||||
}
|
||||
],
|
||||
"contactId": "13659da8-4035-47fe-a66b-6ce461ad290f",
|
||||
"emails": [
|
||||
{
|
||||
"id": "e1d2168e-ca3c-4844-8f93-f2e1b0ae70e3",
|
||||
"tag": "UNTAGGED",
|
||||
"email": "koepper-ed@t-online.de",
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"updatedDate": "2025-10-06T07:05:35.675Z",
|
||||
"phone": "+491758555456",
|
||||
"createdDate": "2025-10-06T07:05:35.675Z"
|
||||
},
|
||||
"submissionId": "86d247dc-9d5a-4eb7-87a7-677bf64645ad",
|
||||
"field:anzahl_kinder": "0",
|
||||
"field:first_name_abae": "Ernst-Dieter",
|
||||
"field:utm_content_id": "",
|
||||
"field:utm_campaign": "",
|
||||
"field:utm_term": "",
|
||||
"contactId": "13659da8-4035-47fe-a66b-6ce461ad290f",
|
||||
"field:date_picker_a7c8": "2025-12-21",
|
||||
"field:hotelname": "Bemelmans Post",
|
||||
"field:angebot_auswaehlen": "Zimmer: Doppelzimmer",
|
||||
"field:utm_content": "",
|
||||
"field:last_name_d97c": "Koepper",
|
||||
"field:hotelid": "12345",
|
||||
"submissionsLink": "https://manage.wix.app/forms/submissions/1dea821c-8168-4736-96e4-4b92e8b364cf/e084006b-ae83-4e4d-b2f5-074118cdb3b1?d=https%3A%2F%2Fmanage.wix.com%2Fdashboard%2F1dea821c-8168-4736-96e4-4b92e8b364cf%2Fwix-forms%2Fform%2Fe084006b-ae83-4e4d-b2f5-074118cdb3b1%2Fsubmissions&s=true",
|
||||
"field:gbraid": "",
|
||||
"field:fbclid": "",
|
||||
"submissionPdf": {
|
||||
"fileName": "86d247dc-9d5a-4eb7-87a7-677bf64645ad.pdf",
|
||||
"downloadUrl": "https://manage.wix.com/_api/form-submission-service/v4/submissions/86d247dc-9d5a-4eb7-87a7-677bf64645ad/download?accessToken=JWS.eyJraWQiOiJWLVNuLWhwZSIsImFsZyI6IkhTMjU2In0.eyJkYXRhIjoie1wibWV0YVNpdGVJZFwiOlwiMWRlYTgyMWMtODE2OC00NzM2LTk2ZTQtNGI5MmU4YjM2NGNmXCJ9IiwiaWF0IjoxNzU5NzM0MzM1LCJleHAiOjE3NTk3MzQ5MzV9.9koy-O_ptm0dRspjh01Yefkt2rCHiUlRCFtE_S3auYw"
|
||||
},
|
||||
"field:anrede": "Herr",
|
||||
"field:long_answer_3524": "Guten Morgen,\nwir sind nicht gebau an die Reisedaten gebunden: Anreise ist möglich ab 20. Dezember, Aufenthalt mindestens eine Woche, gern auch 8 oder 9 Tage. Natürlich mit Halbpension. Mit freundlichem Gruß D. Köpper",
|
||||
"formId": "e084006b-ae83-4e4d-b2f5-074118cdb3b1"
|
||||
}
|
||||
},
|
||||
"origin_header": null,
|
||||
"all_headers": {
|
||||
"host": "localhost:8080",
|
||||
"content-type": "application/json",
|
||||
"user-agent": "insomnia/2023.5.8",
|
||||
"accept": "*/*",
|
||||
"content-length": "7499"
|
||||
}
|
||||
}
|
||||
262
logs/wix_test_data_20251006_105732.json
Normal file
262
logs/wix_test_data_20251006_105732.json
Normal file
@@ -0,0 +1,262 @@
|
||||
{
|
||||
"timestamp": "2025-10-06T10:57:32.973217",
|
||||
"client_ip": "127.0.0.1",
|
||||
"headers": {
|
||||
"host": "localhost:8080",
|
||||
"content-type": "application/json",
|
||||
"user-agent": "insomnia/2023.5.8",
|
||||
"accept": "*/*",
|
||||
"content-length": "7499"
|
||||
},
|
||||
"data": {
|
||||
"data": {
|
||||
"formName": "Contact us",
|
||||
"submissions": [
|
||||
{
|
||||
"label": "Angebot auswählen",
|
||||
"value": "Zimmer: Doppelzimmer"
|
||||
},
|
||||
{
|
||||
"label": "Anreisedatum",
|
||||
"value": "2025-12-21"
|
||||
},
|
||||
{
|
||||
"label": "Abreisedatum",
|
||||
"value": "2025-10-28"
|
||||
},
|
||||
{
|
||||
"label": "Anzahl Erwachsene",
|
||||
"value": "2"
|
||||
},
|
||||
{
|
||||
"label": "Anzahl Kinder",
|
||||
"value": "0"
|
||||
},
|
||||
{
|
||||
"label": "Anrede",
|
||||
"value": "Herr"
|
||||
},
|
||||
{
|
||||
"label": "Vorname",
|
||||
"value": "Ernst-Dieter"
|
||||
},
|
||||
{
|
||||
"label": "Nachname",
|
||||
"value": "Koepper"
|
||||
},
|
||||
{
|
||||
"label": "Email",
|
||||
"value": "koepper-ed@t-online.de"
|
||||
},
|
||||
{
|
||||
"label": "Phone",
|
||||
"value": "+49 175 8555456"
|
||||
},
|
||||
{
|
||||
"label": "Message",
|
||||
"value": "Guten Morgen,\nwir sind nicht gebau an die Reisedaten gebunden: Anreise ist möglich ab 20. Dezember, Aufenthalt mindestens eine Woche, gern auch 8 oder 9 Tage. Natürlich mit Halbpension. Mit freundlichem Gruß D. Köpper"
|
||||
},
|
||||
{
|
||||
"label": "Einwilligung Marketing",
|
||||
"value": "Angekreuzt"
|
||||
},
|
||||
{
|
||||
"label": "utm_Source",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "utm_Medium",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "utm_Campaign",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "utm_Term",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "utm_Content",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "utm_term_id",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "utm_content_id",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "gad_source",
|
||||
"value": "5"
|
||||
},
|
||||
{
|
||||
"label": "gad_campaignid",
|
||||
"value": "23065043477"
|
||||
},
|
||||
{
|
||||
"label": "gbraid",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "gclid",
|
||||
"value": "EAIaIQobChMI-d7Bn_-OkAMVuZJQBh09uD0vEAAYASAAEgKR8_D_BwE"
|
||||
},
|
||||
{
|
||||
"label": "fbclid",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"label": "hotelid",
|
||||
"value": "12345"
|
||||
},
|
||||
{
|
||||
"label": "hotelname",
|
||||
"value": "Bemelmans Post"
|
||||
}
|
||||
],
|
||||
"field:date_picker_7e65": "2025-10-28",
|
||||
"field:number_7cf5": "2",
|
||||
"field:utm_source": "",
|
||||
"submissionTime": "2025-10-06T07:05:34.001Z",
|
||||
"field:gad_source": "5",
|
||||
"field:form_field_5a7b": "Angekreuzt",
|
||||
"field:gad_campaignid": "23065043477",
|
||||
"field:utm_medium": "",
|
||||
"field:utm_term_id": "",
|
||||
"context": {
|
||||
"metaSiteId": "1dea821c-8168-4736-96e4-4b92e8b364cf",
|
||||
"activationId": "fd8e9c90-0335-4fd2-976d-985f065f3f80"
|
||||
},
|
||||
"field:email_5139": "koepper-ed@t-online.de",
|
||||
"field:phone_4c77": "+49 175 8555456",
|
||||
"_context": {
|
||||
"activation": {
|
||||
"id": "fd8e9c90-0335-4fd2-976d-985f065f3f80"
|
||||
},
|
||||
"configuration": {
|
||||
"id": "a976f18c-fa86-495d-be1e-676df188eeae"
|
||||
},
|
||||
"app": {
|
||||
"id": "225dd912-7dea-4738-8688-4b8c6955ffc2"
|
||||
},
|
||||
"action": {
|
||||
"id": "152db4d7-5263-40c4-be2b-1c81476318b7"
|
||||
},
|
||||
"trigger": {
|
||||
"key": "wix_form_app-form_submitted"
|
||||
}
|
||||
},
|
||||
"field:gclid": "EAIaIQobChMI-d7Bn_-OkAMVuZJQBh09uD0vEAAYASAAEgKR8_D_BwE",
|
||||
"formFieldMask": [
|
||||
"field:",
|
||||
"field:",
|
||||
"field:angebot_auswaehlen",
|
||||
"field:date_picker_a7c8",
|
||||
"field:date_picker_7e65",
|
||||
"field:",
|
||||
"field:number_7cf5",
|
||||
"field:anzahl_kinder",
|
||||
"field:alter_kind_3",
|
||||
"field:alter_kind_25",
|
||||
"field:alter_kind_4",
|
||||
"field:alter_kind_5",
|
||||
"field:alter_kind_6",
|
||||
"field:alter_kind_7",
|
||||
"field:alter_kind_8",
|
||||
"field:alter_kind_9",
|
||||
"field:alter_kind_10",
|
||||
"field:alter_kind_11",
|
||||
"field:",
|
||||
"field:anrede",
|
||||
"field:first_name_abae",
|
||||
"field:last_name_d97c",
|
||||
"field:email_5139",
|
||||
"field:phone_4c77",
|
||||
"field:long_answer_3524",
|
||||
"field:form_field_5a7b",
|
||||
"field:",
|
||||
"field:utm_source",
|
||||
"field:utm_medium",
|
||||
"field:utm_campaign",
|
||||
"field:utm_term",
|
||||
"field:utm_content",
|
||||
"field:utm_term_id",
|
||||
"field:utm_content_id",
|
||||
"field:gad_source",
|
||||
"field:gad_campaignid",
|
||||
"field:gbraid",
|
||||
"field:gclid",
|
||||
"field:fbclid",
|
||||
"field:hotelid",
|
||||
"field:hotelname",
|
||||
"field:",
|
||||
"metaSiteId"
|
||||
],
|
||||
"contact": {
|
||||
"name": {
|
||||
"first": "Ernst-Dieter",
|
||||
"last": "Koepper"
|
||||
},
|
||||
"email": "koepper-ed@t-online.de",
|
||||
"locale": "de-de",
|
||||
"phones": [
|
||||
{
|
||||
"tag": "UNTAGGED",
|
||||
"formattedPhone": "+49 175 8555456",
|
||||
"id": "530a3bf4-6dbe-4611-8963-a50df805785d",
|
||||
"countryCode": "DE",
|
||||
"e164Phone": "+491758555456",
|
||||
"primary": true,
|
||||
"phone": "175 8555456"
|
||||
}
|
||||
],
|
||||
"contactId": "13659da8-4035-47fe-a66b-6ce461ad290f",
|
||||
"emails": [
|
||||
{
|
||||
"id": "e1d2168e-ca3c-4844-8f93-f2e1b0ae70e3",
|
||||
"tag": "UNTAGGED",
|
||||
"email": "koepper-ed@t-online.de",
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"updatedDate": "2025-10-06T07:05:35.675Z",
|
||||
"phone": "+491758555456",
|
||||
"createdDate": "2025-10-06T07:05:35.675Z"
|
||||
},
|
||||
"submissionId": "86d247dc-9d5a-4eb7-87a7-677bf64645ad",
|
||||
"field:anzahl_kinder": "0",
|
||||
"field:first_name_abae": "Ernst-Dieter",
|
||||
"field:utm_content_id": "",
|
||||
"field:utm_campaign": "",
|
||||
"field:utm_term": "",
|
||||
"contactId": "13659da8-4035-47fe-a66b-6ce461ad290f",
|
||||
"field:date_picker_a7c8": "2025-12-21",
|
||||
"field:hotelname": "Bemelmans Post",
|
||||
"field:angebot_auswaehlen": "Zimmer: Doppelzimmer",
|
||||
"field:utm_content": "",
|
||||
"field:last_name_d97c": "Koepper",
|
||||
"field:hotelid": "12345",
|
||||
"submissionsLink": "https://manage.wix.app/forms/submissions/1dea821c-8168-4736-96e4-4b92e8b364cf/e084006b-ae83-4e4d-b2f5-074118cdb3b1?d=https%3A%2F%2Fmanage.wix.com%2Fdashboard%2F1dea821c-8168-4736-96e4-4b92e8b364cf%2Fwix-forms%2Fform%2Fe084006b-ae83-4e4d-b2f5-074118cdb3b1%2Fsubmissions&s=true",
|
||||
"field:gbraid": "",
|
||||
"field:fbclid": "",
|
||||
"submissionPdf": {
|
||||
"fileName": "86d247dc-9d5a-4eb7-87a7-677bf64645ad.pdf",
|
||||
"downloadUrl": "https://manage.wix.com/_api/form-submission-service/v4/submissions/86d247dc-9d5a-4eb7-87a7-677bf64645ad/download?accessToken=JWS.eyJraWQiOiJWLVNuLWhwZSIsImFsZyI6IkhTMjU2In0.eyJkYXRhIjoie1wibWV0YVNpdGVJZFwiOlwiMWRlYTgyMWMtODE2OC00NzM2LTk2ZTQtNGI5MmU4YjM2NGNmXCJ9IiwiaWF0IjoxNzU5NzM0MzM1LCJleHAiOjE3NTk3MzQ5MzV9.9koy-O_ptm0dRspjh01Yefkt2rCHiUlRCFtE_S3auYw"
|
||||
},
|
||||
"field:anrede": "Herr",
|
||||
"field:long_answer_3524": "Guten Morgen,\nwir sind nicht gebau an die Reisedaten gebunden: Anreise ist möglich ab 20. Dezember, Aufenthalt mindestens eine Woche, gern auch 8 oder 9 Tage. Natürlich mit Halbpension. Mit freundlichem Gruß D. Köpper",
|
||||
"formId": "e084006b-ae83-4e4d-b2f5-074118cdb3b1"
|
||||
}
|
||||
},
|
||||
"origin_header": null,
|
||||
"all_headers": {
|
||||
"host": "localhost:8080",
|
||||
"content-type": "application/json",
|
||||
"user-agent": "insomnia/2023.5.8",
|
||||
"accept": "*/*",
|
||||
"content-length": "7499"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
@@ -569,6 +577,9 @@ class ResGuestFactory:
|
||||
return CustomerFactory.from_retrieve_customer(customer)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class AlpineBitsFactory:
|
||||
"""Unified factory class for creating AlpineBits objects with a simple interface."""
|
||||
|
||||
@@ -670,8 +681,246 @@ class AlpineBitsFactory:
|
||||
else:
|
||||
raise ValueError(f"Unsupported object type: {type(obj)}")
|
||||
|
||||
def create_res_retrieve_response(list: list[Tuple[Reservation, Customer]]):
|
||||
|
||||
def create_xml_from_db(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 _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
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
# TimeSpan class according to XSD: <TimeSpan Start="..." End="..." Duration="..." StartWindow="..." EndWindow="..."/>
|
||||
class TimeSpan:
|
||||
def __init__(
|
||||
self,
|
||||
start: str,
|
||||
end: str = None,
|
||||
duration: str = None,
|
||||
start_window: str = None,
|
||||
end_window: str = None,
|
||||
):
|
||||
self.start = start
|
||||
self.end = end
|
||||
self.duration = duration
|
||||
self.start_window = start_window
|
||||
self.end_window = end_window
|
||||
|
||||
def to_xml(self):
|
||||
attrib = {"Start": self.start}
|
||||
if self.end:
|
||||
attrib["End"] = self.end
|
||||
if self.duration:
|
||||
attrib["Duration"] = self.duration
|
||||
if self.start_window:
|
||||
attrib["StartWindow"] = self.start_window
|
||||
if self.end_window:
|
||||
attrib["EndWindow"] = self.end_window
|
||||
return ET.Element(_ns("TimeSpan"), attrib)
|
||||
|
||||
|
||||
NAMESPACE = "http://www.opentravel.org/OTA/2003/05"
|
||||
ET.register_namespace("", NAMESPACE)
|
||||
|
||||
|
||||
def _ns(tag):
|
||||
return f"{{{NAMESPACE}}}{tag}"
|
||||
|
||||
|
||||
class ResGuest:
|
||||
def __init__(
|
||||
self,
|
||||
given_name: str,
|
||||
surname: str,
|
||||
gender: Optional[str] = None,
|
||||
birth_date: Optional[str] = None,
|
||||
language: Optional[str] = None,
|
||||
name_prefix: Optional[str] = None,
|
||||
name_title: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
address: Optional[dict] = None,
|
||||
telephones: Optional[list] = None,
|
||||
):
|
||||
self.given_name = given_name
|
||||
self.surname = surname
|
||||
self.gender = gender
|
||||
self.birth_date = birth_date
|
||||
self.language = language
|
||||
self.name_prefix = name_prefix
|
||||
self.name_title = name_title
|
||||
self.email = email
|
||||
self.address = address or {}
|
||||
self.telephones = telephones or []
|
||||
|
||||
def to_xml(self):
|
||||
resguest_elem = ET.Element(_ns("ResGuest"))
|
||||
profiles_elem = ET.SubElement(resguest_elem, _ns("Profiles"))
|
||||
profileinfo_elem = ET.SubElement(profiles_elem, _ns("ProfileInfo"))
|
||||
profile_elem = ET.SubElement(profileinfo_elem, _ns("Profile"))
|
||||
customer_elem = ET.SubElement(profile_elem, _ns("Customer"))
|
||||
if self.gender:
|
||||
customer_elem.set("Gender", self.gender)
|
||||
if self.birth_date:
|
||||
customer_elem.set("BirthDate", self.birth_date)
|
||||
if self.language:
|
||||
customer_elem.set("Language", self.language)
|
||||
personname_elem = ET.SubElement(customer_elem, _ns("PersonName"))
|
||||
if self.name_prefix:
|
||||
ET.SubElement(personname_elem, _ns("NamePrefix")).text = self.name_prefix
|
||||
ET.SubElement(personname_elem, _ns("GivenName")).text = self.given_name
|
||||
ET.SubElement(personname_elem, _ns("Surname")).text = self.surname
|
||||
if self.name_title:
|
||||
ET.SubElement(personname_elem, _ns("NameTitle")).text = self.name_title
|
||||
for tel in self.telephones:
|
||||
tel_elem = ET.SubElement(customer_elem, _ns("Telephone"))
|
||||
for k, v in tel.items():
|
||||
tel_elem.set(k, v)
|
||||
if self.email:
|
||||
ET.SubElement(customer_elem, _ns("Email")).text = self.email
|
||||
if self.address:
|
||||
address_elem = ET.SubElement(customer_elem, _ns("Address"))
|
||||
for k, v in self.address.items():
|
||||
if k == "CountryName":
|
||||
country_elem = ET.SubElement(address_elem, _ns("CountryName"))
|
||||
if isinstance(v, dict):
|
||||
for ck, cv in v.items():
|
||||
country_elem.set(ck, cv)
|
||||
else:
|
||||
country_elem.text = v
|
||||
else:
|
||||
ET.SubElement(address_elem, _ns(k)).text = v
|
||||
return resguest_elem
|
||||
|
||||
def __str__(self):
|
||||
from lxml import etree
|
||||
|
||||
elem = self.to_xml()
|
||||
xml_bytes = ET.tostring(elem, encoding="utf-8")
|
||||
parser = etree.XMLParser(remove_blank_text=True)
|
||||
lxml_elem = etree.fromstring(xml_bytes, parser)
|
||||
return etree.tostring(lxml_elem, pretty_print=True, encoding="unicode")
|
||||
|
||||
|
||||
class RoomStay:
|
||||
def __init__(self, room_type: str, timespan: TimeSpan, guests: List[ResGuest]):
|
||||
self.room_type = room_type
|
||||
self.timespan = timespan
|
||||
self.guests = guests
|
||||
|
||||
def to_xml(self):
|
||||
roomstay_elem = ET.Element(_ns("RoomStay"))
|
||||
ET.SubElement(roomstay_elem, _ns("RoomType")).set(
|
||||
"RoomTypeCode", self.room_type
|
||||
)
|
||||
roomstay_elem.append(self.timespan.to_xml())
|
||||
guests_elem = ET.SubElement(roomstay_elem, _ns("Guests"))
|
||||
for guest in self.guests:
|
||||
guests_elem.append(guest.to_xml())
|
||||
return roomstay_elem
|
||||
|
||||
|
||||
class Reservation:
|
||||
def __init__(
|
||||
self,
|
||||
reservation_id: str,
|
||||
hotel_code: str,
|
||||
roomstays: List[RoomStay],
|
||||
create_time: Optional[str] = None,
|
||||
):
|
||||
self.reservation_id = reservation_id
|
||||
self.hotel_code = hotel_code
|
||||
self.roomstays = roomstays
|
||||
self.create_time = create_time or datetime.now(timezone.utc).isoformat()
|
||||
|
||||
def to_xml(self):
|
||||
res_elem = ET.Element(_ns("HotelReservation"))
|
||||
uniqueid_elem = ET.SubElement(res_elem, _ns("UniqueID"))
|
||||
uniqueid_elem.set("Type", "14")
|
||||
uniqueid_elem.set("ID", self.reservation_id)
|
||||
hotel_elem = ET.SubElement(res_elem, _ns("Hotel"))
|
||||
hotel_elem.set("HotelCode", self.hotel_code)
|
||||
roomstays_elem = ET.SubElement(res_elem, _ns("RoomStays"))
|
||||
for rs in self.roomstays:
|
||||
roomstays_elem.append(rs.to_xml())
|
||||
res_elem.set("CreateDateTime", self.create_time)
|
||||
return res_elem
|
||||
|
||||
def to_xml_string(self):
|
||||
root = ET.Element(
|
||||
_ns("OTA_ResRetrieveRS"),
|
||||
{"Version": "2024-10", "TimeStamp": datetime.now(timezone.utc).isoformat()},
|
||||
)
|
||||
success_elem = ET.SubElement(root, _ns("Success"))
|
||||
reservations_list = ET.SubElement(root, _ns("ReservationsList"))
|
||||
reservations_list.append(self.to_xml())
|
||||
return ET.tostring(root, encoding="utf-8", xml_declaration=True).decode("utf-8")
|
||||
@@ -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,25 +585,101 @@ 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
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# Note: This class doesn't override the handle method, so it won't be discovered
|
||||
|
||||
|
||||
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,
|
||||
|
||||
@@ -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,13 +466,29 @@ 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",
|
||||
"message": "Wix form data received successfully",
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
0
test/test_alpine_bits_server.py
Normal file
0
test/test_alpine_bits_server.py
Normal file
96
test/test_alpinebits_server_ping.py
Normal file
96
test/test_alpinebits_server_ping.py
Normal file
@@ -0,0 +1,96 @@
|
||||
|
||||
import json
|
||||
import pytest
|
||||
import asyncio
|
||||
from alpine_bits_python.alpinebits_server import AlpineBitsServer, AlpineBitsClientInfo
|
||||
import re
|
||||
from xsdata_pydantic.bindings import XmlParser
|
||||
from alpine_bits_python.generated.alpinebits import OtaPingRs
|
||||
|
||||
|
||||
|
||||
|
||||
def extract_relevant_sections(xml_string):
|
||||
# Remove version attribute value, keep only presence
|
||||
# Use the same XmlParser as AlpineBitsServer
|
||||
parser = XmlParser()
|
||||
obj = parser.from_string(xml_string, OtaPingRs)
|
||||
return obj
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ping_action_response_matches_expected():
|
||||
|
||||
with open("test/test_data/Handshake-OTA_PingRQ.xml", "r", encoding="utf-8") as f:
|
||||
server = AlpineBitsServer()
|
||||
with open("test/test_data/Handshake-OTA_PingRQ.xml", "r", encoding="utf-8") as f:
|
||||
request_xml = f.read()
|
||||
with open("test/test_data/Handshake-OTA_PingRS.xml", "r", encoding="utf-8") as f:
|
||||
expected_xml = f.read()
|
||||
client_info = AlpineBitsClientInfo(username="irrelevant", password="irrelevant")
|
||||
response = await server.handle_request(
|
||||
request_action_name="OTA_Ping:Handshaking",
|
||||
request_xml=request_xml,
|
||||
client_info=client_info,
|
||||
version="2024-10"
|
||||
)
|
||||
actual_obj = extract_relevant_sections(response.xml_content)
|
||||
expected_obj = extract_relevant_sections(expected_xml)
|
||||
|
||||
actual_matches = actual_obj.warnings.warning
|
||||
|
||||
expected_matches = expected_obj.warnings.warning
|
||||
|
||||
assert actual_matches == expected_matches, f"Expected warnings {expected_matches}, got {actual_matches}"
|
||||
|
||||
actual_capabilities = actual_obj.echo_data
|
||||
expected_capabilities = expected_obj.echo_data
|
||||
|
||||
assert actual_capabilities == expected_capabilities, f"Expected echo data {expected_capabilities}, got {actual_capabilities}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ping_action_response_success():
|
||||
server = AlpineBitsServer()
|
||||
with open("test/test_data/Handshake-OTA_PingRQ.xml", "r", encoding="utf-8") as f:
|
||||
request_xml = f.read()
|
||||
client_info = AlpineBitsClientInfo(username="irrelevant", password="irrelevant")
|
||||
response = await server.handle_request(
|
||||
request_action_name="OTA_Ping:Handshaking",
|
||||
request_xml=request_xml,
|
||||
client_info=client_info,
|
||||
version="2024-10"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "<OTA_PingRS" in response.xml_content
|
||||
assert "<Success" in response.xml_content
|
||||
assert "Version=" in response.xml_content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ping_action_response_version_arbitrary():
|
||||
server = AlpineBitsServer()
|
||||
with open("test/test_data/Handshake-OTA_PingRQ.xml", "r", encoding="utf-8") as f:
|
||||
request_xml = f.read()
|
||||
client_info = AlpineBitsClientInfo(username="irrelevant", password="irrelevant")
|
||||
response = await server.handle_request(
|
||||
request_action_name="OTA_Ping:Handshaking",
|
||||
request_xml=request_xml,
|
||||
client_info=client_info,
|
||||
version="2022-10"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "<OTA_PingRS" in response.xml_content
|
||||
assert "Version=" in response.xml_content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ping_action_response_invalid_action():
|
||||
server = AlpineBitsServer()
|
||||
with open("test/test_data/Handshake-OTA_PingRQ.xml", "r", encoding="utf-8") as f:
|
||||
request_xml = f.read()
|
||||
client_info = AlpineBitsClientInfo(username="irrelevant", password="irrelevant")
|
||||
response = await server.handle_request(
|
||||
request_action_name="InvalidAction",
|
||||
request_xml=request_xml,
|
||||
client_info=client_info,
|
||||
version="2024-10"
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "Error" in response.xml_content
|
||||
158
test/test_data/Handshake-OTA_PingRQ.xml
Normal file
158
test/test_data/Handshake-OTA_PingRQ.xml
Normal file
@@ -0,0 +1,158 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<!--
|
||||
AlpineBits 2024-10
|
||||
https://www.alpinebits.org/
|
||||
|
||||
Sample message file for a Handshake request
|
||||
|
||||
Changelog:
|
||||
v. 2024-10 1.2 Example extended with all capabilities and two supported releases
|
||||
v. 2024-10 1.1 Removed the OTA_Ping action
|
||||
v. 2024-10 1.0 added supported version 2024-10 in the example
|
||||
v. 2018-10 1.0 initial example
|
||||
-->
|
||||
|
||||
<OTA_PingRQ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns="http://www.opentravel.org/OTA/2003/05"
|
||||
xsi:schemaLocation="http://www.opentravel.org/OTA/2003/05 OTA_PingRQ.xsd"
|
||||
Version="8.000">
|
||||
<EchoData>
|
||||
{
|
||||
"versions": [
|
||||
{
|
||||
"version": "2024-10",
|
||||
"actions": [
|
||||
{
|
||||
"action": "action_OTA_Read"
|
||||
},
|
||||
{
|
||||
"action": "action_OTA_HotelResNotif_GuestRequests"
|
||||
},
|
||||
{
|
||||
"action": "action_OTA_HotelResNotif_GuestRequests_StatusUpdate"
|
||||
},
|
||||
{
|
||||
"action": "action_OTA_HotelInvCountNotif",
|
||||
"supports": [
|
||||
"OTA_HotelInvCountNotif_accept_rooms",
|
||||
"OTA_HotelInvCountNotif_accept_categories",
|
||||
"OTA_HotelInvCountNotif_accept_deltas",
|
||||
"OTA_HotelInvCountNotif_accept_out_of_market",
|
||||
"OTA_HotelInvCountNotif_accept_out_of_order",
|
||||
"OTA_HotelInvCountNotif_accept_complete_set",
|
||||
"OTA_HotelInvCountNotif_accept_closing_seasons"
|
||||
]
|
||||
},
|
||||
{
|
||||
"action": "action_OTA_HotelDescriptiveContentNotif_Inventory",
|
||||
"supports": [
|
||||
"OTA_HotelDescriptiveContentNotif_Inventory_use_rooms",
|
||||
"OTA_HotelDescriptiveContentNotif_Inventory_occupancy_children"
|
||||
]
|
||||
},
|
||||
{
|
||||
"action": "action_OTA_HotelDescriptiveContentNotif_Info"
|
||||
},
|
||||
{
|
||||
"action": "action_OTA_HotelDescriptiveInfo_Inventory"
|
||||
},
|
||||
{
|
||||
"action": "action_OTA_HotelDescriptiveInfo_Info"
|
||||
},
|
||||
{
|
||||
"action": "action_OTA_HotelRatePlanNotif_RatePlans",
|
||||
"supports": [
|
||||
"OTA_HotelRatePlanNotif_accept_ArrivalDOW",
|
||||
"OTA_HotelRatePlanNotif_accept_DepartureDOW",
|
||||
"OTA_HotelRatePlanNotif_accept_RatePlan_BookingRule",
|
||||
"OTA_HotelRatePlanNotif_accept_RatePlan_RoomType_BookingRule",
|
||||
"OTA_HotelRatePlanNotif_accept_RatePlan_mixed_BookingRule",
|
||||
"OTA_HotelRatePlanNotif_accept_Supplements",
|
||||
"OTA_HotelRatePlanNotif_accept_FreeNightsOffers",
|
||||
"OTA_HotelRatePlanNotif_accept_FamilyOffers",
|
||||
"OTA_HotelRatePlanNotif_accept_full",
|
||||
"OTA_HotelRatePlanNotif_accept_overlay",
|
||||
"OTA_HotelRatePlanNotif_accept_RatePlanJoin",
|
||||
"OTA_HotelRatePlanNotif_accept_OfferRule_BookingOffset",
|
||||
"OTA_HotelRatePlanNotif_accept_OfferRule_DOWLOS"
|
||||
]
|
||||
},
|
||||
{
|
||||
"action": "action_OTA_HotelRatePlan_BaseRates",
|
||||
"supports": [
|
||||
"OTA_HotelRatePlan_BaseRates_deltas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"action": "action_OTA_HotelPostEventNotif_EventReports"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2022-10",
|
||||
"actions": [
|
||||
{
|
||||
"action": "action_OTA_Ping"
|
||||
},
|
||||
{
|
||||
"action": "action_OTA_Read"
|
||||
},
|
||||
{
|
||||
"action": "action_OTA_HotelResNotif_GuestRequests"
|
||||
},
|
||||
{
|
||||
"action": "action_OTA_HotelResNotif_GuestRequests_StatusUpdate"
|
||||
},
|
||||
{
|
||||
"action": "action_OTA_HotelInvCountNotif",
|
||||
"supports": [
|
||||
"OTA_HotelInvCountNotif_accept_rooms",
|
||||
"OTA_HotelInvCountNotif_accept_categories",
|
||||
"OTA_HotelInvCountNotif_accept_deltas",
|
||||
"OTA_HotelInvCountNotif_accept_out_of_market",
|
||||
"OTA_HotelInvCountNotif_accept_out_of_order",
|
||||
"OTA_HotelInvCountNotif_accept_complete_set",
|
||||
"OTA_HotelInvCountNotif_accept_closing_seasons"
|
||||
]
|
||||
},
|
||||
{
|
||||
"action": "action_OTA_HotelDescriptiveContentNotif_Inventory",
|
||||
"supports": [
|
||||
"OTA_HotelDescriptiveContentNotif_Inventory_use_rooms",
|
||||
"OTA_HotelDescriptiveContentNotif_Inventory_occupancy_children"
|
||||
]
|
||||
},
|
||||
{
|
||||
"action": "action_OTA_HotelDescriptiveContentNotif_Info"
|
||||
},
|
||||
{
|
||||
"action": "action_OTA_HotelDescriptiveInfo_Inventory"
|
||||
},
|
||||
{
|
||||
"action": "action_OTA_HotelDescriptiveInfo_Info"
|
||||
},
|
||||
|
||||
{
|
||||
"action": "action_OTA_HotelRatePlanNotif_RatePlans",
|
||||
"supports": [
|
||||
"OTA_HotelRatePlanNotif_accept_ArrivalDOW",
|
||||
"OTA_HotelRatePlanNotif_accept_DepartureDOW",
|
||||
"OTA_HotelRatePlanNotif_accept_RatePlan_BookingRule",
|
||||
"OTA_HotelRatePlanNotif_accept_RatePlan_RoomType_BookingRule",
|
||||
"OTA_HotelRatePlanNotif_accept_RatePlan_mixed_BookingRule",
|
||||
"OTA_HotelRatePlanNotif_accept_Supplements",
|
||||
"OTA_HotelRatePlanNotif_accept_FreeNightsOffers",
|
||||
"OTA_HotelRatePlanNotif_accept_FamilyOffers",
|
||||
"OTA_HotelRatePlanNotif_accept_overlay",
|
||||
"OTA_HotelRatePlanNotif_accept_RatePlanJoin",
|
||||
"OTA_HotelRatePlanNotif_accept_OfferRule_BookingOffset",
|
||||
"OTA_HotelRatePlanNotif_accept_OfferRule_DOWLOS"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
</EchoData>
|
||||
</OTA_PingRQ>
|
||||
81
test/test_data/Handshake-OTA_PingRS.xml
Normal file
81
test/test_data/Handshake-OTA_PingRS.xml
Normal file
@@ -0,0 +1,81 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<!--
|
||||
AlpineBits 2024-10
|
||||
https://www.alpinebits.org/
|
||||
|
||||
Sample message file for a Handshake response
|
||||
|
||||
Changelog:
|
||||
v. 2024-10 1.2 Example extended with all capabilities and two supported releases
|
||||
v. 2024-10 1.1 Removed the OTA_Ping action
|
||||
v. 2024-10 1.0 added supported version 2024-10 in the example
|
||||
v. 2018-10 1.0 initial example
|
||||
-->
|
||||
|
||||
<OTA_PingRS xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns="http://www.opentravel.org/OTA/2003/05"
|
||||
xsi:schemaLocation="http://www.opentravel.org/OTA/2003/05 OTA_PingRS.xsd"
|
||||
Version="8.000">
|
||||
<Success/>
|
||||
<Warnings>
|
||||
<Warning Type="11" Status="ALPINEBITS_HANDSHAKE">{
|
||||
"versions": [
|
||||
{
|
||||
"version": "2024-10",
|
||||
"actions": [
|
||||
{
|
||||
"action": "action_OTA_Read"
|
||||
},
|
||||
{
|
||||
"action": "action_OTA_HotelResNotif_GuestRequests"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2022-10",
|
||||
"actions": [
|
||||
{
|
||||
"action": "action_OTA_Ping"
|
||||
},
|
||||
{
|
||||
"action": "action_OTA_Read"
|
||||
},
|
||||
{
|
||||
"action": "action_OTA_HotelResNotif_GuestRequests"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}</Warning>
|
||||
</Warnings>
|
||||
<EchoData>{
|
||||
"versions": [
|
||||
{
|
||||
"version": "2024-10",
|
||||
"actions": [
|
||||
{
|
||||
"action": "action_OTA_Read"
|
||||
},
|
||||
{
|
||||
"action": "action_OTA_HotelResNotif_GuestRequests"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2022-10",
|
||||
"actions": [
|
||||
{
|
||||
"action": "action_OTA_Read"
|
||||
},
|
||||
{
|
||||
"action": "action_OTA_Ping"
|
||||
},
|
||||
{
|
||||
"action": "action_OTA_HotelResNotif_GuestRequests"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}</EchoData>
|
||||
</OTA_PingRS>
|
||||
@@ -1,67 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Quick test to demonstrate how the ServerCapabilities automatically
|
||||
discovers implemented vs unimplemented actions.
|
||||
"""
|
||||
|
||||
from alpine_bits_python.alpinebits_server import (
|
||||
ServerCapabilities,
|
||||
AlpineBitsAction,
|
||||
AlpineBitsActionName,
|
||||
Version,
|
||||
AlpineBitsResponse,
|
||||
HttpStatusCode,
|
||||
)
|
||||
import asyncio
|
||||
|
||||
|
||||
class NewImplementedAction(AlpineBitsAction):
|
||||
"""A new action that IS implemented."""
|
||||
|
||||
def __init__(self):
|
||||
self.name = AlpineBitsActionName.OTA_HOTEL_DESCRIPTIVE_INFO_INFO
|
||||
self.version = Version.V2024_10
|
||||
|
||||
async def handle(
|
||||
self, action: str, request_xml: str, version: Version
|
||||
) -> AlpineBitsResponse:
|
||||
"""This action is implemented."""
|
||||
return AlpineBitsResponse("Implemented!", HttpStatusCode.OK)
|
||||
|
||||
|
||||
class NewUnimplementedAction(AlpineBitsAction):
|
||||
"""A new action that is NOT implemented (no handle override)."""
|
||||
|
||||
def __init__(self):
|
||||
self.name = AlpineBitsActionName.OTA_HOTEL_DESCRIPTIVE_CONTENT_NOTIF_INFO
|
||||
self.version = Version.V2024_10
|
||||
|
||||
# Notice: No handle method override - will use default "not implemented"
|
||||
|
||||
|
||||
async def main():
|
||||
print("🔍 Testing Action Discovery Logic")
|
||||
print("=" * 50)
|
||||
|
||||
# Create capabilities and see what gets discovered
|
||||
capabilities = ServerCapabilities()
|
||||
|
||||
print("📋 Actions found by discovery:")
|
||||
for action_name in capabilities.get_supported_actions():
|
||||
print(f" ✅ {action_name}")
|
||||
|
||||
print(f"\n📊 Total discovered: {len(capabilities.get_supported_actions())}")
|
||||
|
||||
# Test the new implemented action
|
||||
implemented_action = NewImplementedAction()
|
||||
result = await implemented_action.handle("test", "<xml/>", Version.V2024_10)
|
||||
print(f"\n🟢 NewImplementedAction result: {result.xml_content}")
|
||||
|
||||
# Test the unimplemented action (should use default behavior)
|
||||
unimplemented_action = NewUnimplementedAction()
|
||||
result = await unimplemented_action.handle("test", "<xml/>", Version.V2024_10)
|
||||
print(f"🔴 NewUnimplementedAction result: {result.xml_content}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
44
uv.lock
generated
44
uv.lock
generated
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user