Skip to content

Commit

Permalink
🚧 [#2178] qmatic
Browse files Browse the repository at this point in the history
  • Loading branch information
stevenbal committed Mar 19, 2024
1 parent 761f3e2 commit 344d0c6
Show file tree
Hide file tree
Showing 17 changed files with 424 additions and 3 deletions.
2 changes: 2 additions & 0 deletions src/open_inwoner/accounts/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
MyNotificationsView,
MyProfileView,
NewsletterSubscribeView,
MyAppointmentsView,
)
from .registration import CustomRegistrationView, NecessaryFieldsUserView

Expand Down Expand Up @@ -79,6 +80,7 @@
"MyNotificationsView",
"MyProfileView",
"NewsletterSubscribeView",
"MyAppointmentsView",
"CustomRegistrationView",
"NecessaryFieldsUserView",
]
18 changes: 18 additions & 0 deletions src/open_inwoner/accounts/views/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -335,3 +335,21 @@ def form_valid(self, form):
self.request.user, _("users newsletter subscriptions were modified")
)
return HttpResponseRedirect(self.get_success_url())


class MyAppointmentsView(
LogMixin, LoginRequiredMixin, CommonPageMixin, BaseBreadcrumbMixin, TemplateView
):
template_name = "pages/profile/appointments.html"

def get_context_data(self, **kwargs) -> dict[str, Any]:
context = super().get_context_data(**kwargs)
context["appointments"] = []
return context

@cached_property
def crumbs(self):
return [
(_("Mijn profiel"), reverse("profile:detail")),
(_("Mijn afspraken"), reverse("profile:appointments")),
]
85 changes: 85 additions & 0 deletions src/open_inwoner/apimock/apis/qmatic/qmatic-api/appointments.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
[
{
"notifications": [],
"meta": {
"start": "",
"end": "",
"totalResults": 1,
"offset": null,
"limit": null,
"fields": "",
"arguments": {}
},
"appointment": {
"services": [
{
"additionalCustomerDuration": 0,
"duration": 5,
"updated": 1475589228781,
"created": 1475589228595,
"name": "Product 1",
"publicId": "1e0c3d34acb5a4ad0133b2927959e8",
"active": true,
"publicEnabled": true,
"custom": null
}
],
"allDay": false,
"status": 20,
"resource": {
"name": "Resource 1"
},
"customers": [
{
"dateOfBirth": -2206357200000,
"addressState": "Zuid Holland",
"lastName": "Achternaam",
"phone": "06-11223344",
"addressCity": "Plaatsnaam",
"externalId": null,
"addressLine2": null,
"addressLine1": "Straatnaam 1",
"updated": null,
"created": 1478619026558,
"email": "[email protected]",
"name": "Voornaam Achternaam",
"publicId": "f9c6a5fa1b978b4181accd7a6434e4b9",
"firstName": "Voornaam",
"addressCountry": "Nederland",
"custom": null,
"identificationNumber": "1234567890",
"addressZip": "1111AB"
}
],
"blocking": false,
"title": "Online booking",
"start": "2016-11-10T12:30:00.000+00:00",
"created": 1478618716117,
"updated": 1478619027200,
"publicId": "d50517a0ae88cdbc495f7a32e011cb",
"branch": {
"addressState": null,
"phone": null,
"addressCity": "City",
"fullTimeZone": "Europe/Amsterdam",
"timeZone": "Europe/Amsterdam",
"addressLine2": "Street 1",
"addressLine1": "Branch 1",
"updated": 1475589234069,
"created": 1475589234008,
"email": null,
"name": "Branch 1",
"publicId": "f364d92b7fa07a48c4ecc862de30",
"longitude": null,
"branchPrefix": null,
"latitude": null,
"addressCountry": "Netherlands",
"custom": null,
"addressZip": "1111 AA"
},
"notes": "Geboekt via internet",
"end": "2016-11-10T12:35:00.000+00:00",
"custom": null
}
}
]
1 change: 1 addition & 0 deletions src/open_inwoner/cms/profile/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@ def get_config_fields(self):
"questions",
"ssd",
"newsletters",
"appointments",
)
5 changes: 5 additions & 0 deletions src/open_inwoner/cms/profile/cms_appconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,8 @@ class ProfileConfig(AppHookConfig):
default=False,
help_text=_("Designates whether 'Nieuwsbrieven' section is rendered or not."),
)
appointments = models.BooleanField(
verbose_name=_("Mijn afspraken"),
default=False,
help_text=_("Designates whether 'Mijn afspraken' section is rendered or not."),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Generated by Django 4.2.10 on 2024-03-18 13:13

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("profile", "0008_profileconfig_newsletters"),
]

operations = [
migrations.AddField(
model_name="profileconfig",
name="appointments",
field=models.BooleanField(
default=False,
help_text="Designates whether 'Mijn afspraken' section is rendered or not.",
verbose_name="Mijn afspraken",
),
),
]
2 changes: 2 additions & 0 deletions src/open_inwoner/cms/profile/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
MyProfileView,
NecessaryFieldsUserView,
NewsletterSubscribeView,
MyAppointmentsView,
)
from open_inwoner.accounts.views.actions import ActionDeleteView

Expand Down Expand Up @@ -107,5 +108,6 @@
NewsletterSubscribeView.as_view(),
name="newsletters",
),
path("appointments", MyAppointmentsView.as_view(), name="appointments"),
path("", MyProfileView.as_view(), name="detail"),
]
1 change: 1 addition & 0 deletions src/open_inwoner/conf/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@
"open_inwoner.components",
"open_inwoner.kvk",
"open_inwoner.laposta",
"open_inwoner.qmatic",
"open_inwoner.ckeditor5",
"open_inwoner.pdc",
"open_inwoner.plans",
Expand Down
1 change: 1 addition & 0 deletions src/open_inwoner/qmatic/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
default_app_config = "open_inwoner.qmatic.apps.QmaticConfig"
11 changes: 11 additions & 0 deletions src/open_inwoner/qmatic/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from django.contrib import admin

from solo.admin import SingletonModelAdmin

from .models import QmaticConfig



@admin.register(QmaticConfig)
class QmaticConfigAdmin(SingletonModelAdmin):
pass
5 changes: 5 additions & 0 deletions src/open_inwoner/qmatic/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from django.apps import AppConfig


class QmaticConfig(AppConfig):
name = "open_inwoner.qmatic"
135 changes: 135 additions & 0 deletions src/open_inwoner/qmatic/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
from datetime import date, datetime, time
from typing import TypedDict
from zoneinfo import ZoneInfo

from ape_pie.client import APIClient
from dateutil.parser import isoparse
from zgw_consumers.client import build_client
from zgw_consumers.models import Service

from .exceptions import QmaticException
from .models import QmaticConfig

# API DATA DEFINITIONS


class ServiceDict(TypedDict):
publicId: str
name: str
# could be float too in theory, documentation is not specific (it gives an int example)
duration: int
additionalCustomerDuration: int
custom: str | None


class FullServiceDict(ServiceDict):
active: bool
publicEnabled: bool
created: int
updated: int


class ServiceGroupDict(TypedDict):
services: list[ServiceDict]


class BranchDict(TypedDict):
branchPublicId: str
branchName: str
serviceGroups: list[ServiceGroupDict]


class BranchDetailDict(TypedDict):
name: str
publicId: str
phone: str
email: str
branchPrefix: str | None

addressLine1: str | None
addressLine2: str | None
addressZip: str | None
addressCity: str | None
addressState: str | None
addressCountry: str | None

latitude: float | None
longitude: float | None
timeZone: str
fullTimeZone: str
custom: str | None
created: int
updated: int


class Appointment(TypedDict):
services: list[ServiceDict]
title: str
start: datetime
end: datetime
created: int
updated: int
publicId: str
branch: BranchDetailDict
notes: str | None


class NoServiceConfigured(RuntimeError):
pass


# API CLIENT IMPLEMENTATIONS, per major version of the API


def QmaticClient() -> "Client":
"""
Create a Qmatic client instance from the database configuration.
"""
config = QmaticConfig.get_solo()
assert isinstance(config, QmaticConfig)
if (service := config.service) is None:
raise NoServiceConfigured("No Qmatic service defined, aborting!")
assert isinstance(service, Service)
return build_client(service, client_factory=Client)


def startswith_version(url: str) -> bool:
if url.startswith("v1/"):
return True
if url.startswith("v2/"):
return True
return False


class Client(APIClient):
"""
Client implementation for Qmatic.
"""

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.headers["Content-Type"] = "application/json"

def request(self, method: str, url: str, *args, **kwargs):
# ensure there is a version identifier in the URL
if not startswith_version(url):
url = f"v1/{url}"

response = super().request(method, url, *args, **kwargs)

if response.status_code == 500:
error_msg = response.headers.get(
"error_message", response.content.decode("utf-8")
)
raise QmaticException(
f"Server error (HTTP {response.status_code}): {error_msg}"
)

return response

def list_appointments_for_customer(self, customer_publicid: str) -> list[Appointment]:
endpoint = f"customers/{customer_publicid}/appointments"
response = self.get(endpoint)
response.raise_for_status()
appointment_list: list[Appointment] = response.json()["appointmentList"]
return appointment_list
44 changes: 44 additions & 0 deletions src/open_inwoner/qmatic/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from django.db.models import TextChoices
from django.utils.translation import gettext_lazy as _


class CustomerFields(TextChoices):
"""
Enum of possible customer field names offered by Qmatic.
Documentation reference: "Book an appointment for a selected branch, service, date
and time" and the "Customer" data model.
.. note:: None of the customer fields are mandatory, but not providing any information
makes it impossible to identify a customer.
The enum values are the fields as they go in the customer record.
"""

first_name = "firstName", _("First name") # string, max length 200
last_name = "lastName", _("Last name") # string, max length 200
email = "email", _("Email address") # string, max length 255
phone_number = "phone", _("Phone number") # string, max length 50
address_line_1 = "addressLine1", _(
"Street name and number"
) # string, max length 255
address_line_2 = "addressLine2", _("Address line 2") # string, max length 255
address_city = "addressCity", _("City") # string, max length 255
address_state = "addressState", _("State") # string, max length 255
address_zip = "addressZip", _("Postal code") # string, max length 255
address_country = "addressCountry", _("Country") # string, max length 255
identification_number = "identificationNumber", _(
"Identification number"
) # string, max length 255
external_id = "externalId", _(
"Unique customer identification/account number"
) # string, max length 255
"""
A unique customer identification or account number.
This could be used programmatically, but should not be set by the end-user as it is
untrusted input.
"""
birthday = "dateOfBirth", _(
"Birthday"
) # string, ISO-8601 date (return value is number)
8 changes: 8 additions & 0 deletions src/open_inwoner/qmatic/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
class QmaticException(BaseException):
pass


class GracefulQmaticException(QmaticException):
"""
Raise when the program execution can continue with a fallback error.
"""
Loading

0 comments on commit 344d0c6

Please sign in to comment.