Skip to content

Commit

Permalink
Add HTMx destinations page
Browse files Browse the repository at this point in the history
Heavily reuses the destinations API which is not ideal.
  • Loading branch information
stveit authored Dec 9, 2024
1 parent 74011a1 commit 0e44800
Show file tree
Hide file tree
Showing 15 changed files with 1,026 additions and 25 deletions.
1 change: 1 addition & 0 deletions changelog.d/1001.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add HTMX version of the destinations page
File renamed without changes.
126 changes: 126 additions & 0 deletions src/argus/htmx/destination/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
from django import forms
from django.forms import ModelForm

from argus.notificationprofile.models import DestinationConfig, Media
from argus.notificationprofile.serializers import RequestDestinationConfigSerializer
from argus.notificationprofile.media import api_safely_get_medium_object


class DestinationFormCreate(ModelForm):
settings = forms.CharField(required=True)

def __init__(self, *args, **kwargs):
# Serializer request the request object
self.request = kwargs.pop("request", None)
super().__init__(*args, **kwargs)

class Meta:
model = DestinationConfig
fields = ["label", "media", "settings"]
labels = {
"label": "Name",
}
widgets = {
"media": forms.Select(attrs={"class": "select input-bordered w-full max-w-xs"}),
}

def clean(self):
super().clean()
settings_key = _get_settings_key_for_media(self.cleaned_data["media"])
# Convert settings value (e.g. email address) to be compatible with JSONField
self.cleaned_data["settings"] = {settings_key: self.cleaned_data["settings"]}
self._init_serializer()
return self._validate_serializer()

def save(self):
# self.serializer should be initiated and validated in clean() before save() is called
self.serializer.save(user=self.request.user)

def _init_serializer(self):
serializer = RequestDestinationConfigSerializer(
data={
"media": self.cleaned_data["media"],
"label": self.cleaned_data.get("label", ""),
"settings": self.cleaned_data["settings"],
},
context={"request": self.request},
)
self.serializer = serializer

def _validate_serializer(self):
media = self.cleaned_data["media"]
settings_key = _get_settings_key_for_media(media)

# Add error messages from serializer to form
if not self.serializer.is_valid():
for error_name, error_detail in self.serializer.errors.items():
if error_name in ["media", "label", settings_key]:
if error_name == settings_key:
error_name = "settings"
self.add_error(error_name, error_detail)
# Serializer might add more data to the JSON dict
if settings := self.serializer.data.get("settings"):
self.cleaned_data["settings"] = settings
else:
# Serializer might add more data to the JSON dict
if settings := self.serializer.validated_data.get("settings"):
self.cleaned_data["settings"] = settings

if label := self.cleaned_data.get("label"):
destination_filter = DestinationConfig.objects.filter(label=label)
if self.instance:
destination_filter = destination_filter.exclude(pk=self.instance.pk)
if destination_filter.exists():
self.add_error("label", "Name must be unique per media")

return self.cleaned_data


class DestinationFormUpdate(DestinationFormCreate):
def __init__(self, *args, **kwargs):
if instance := kwargs.get("instance"):
settings_key = _get_settings_key_for_media(instance.media)
# Extract settings value (email address etc.) from JSONField
instance.settings = instance.settings.get(settings_key)
super().__init__(*args, **kwargs)

class Meta:
model = DestinationConfig
fields = ["label", "media", "settings"]
labels = {
"label": "Name",
}
widgets = {
"media": forms.HiddenInput(),
}

def _init_serializer(self):
# self.instance is modified in __init__,
# so get unmodified version here for the serializer
destination = DestinationConfig.objects.get(pk=self.instance.pk)
settings_key = _get_settings_key_for_media(destination.media)
data = {}

if "label" in self.cleaned_data:
label = self.cleaned_data["label"]
if label != destination.label:
data["label"] = label

settings = self.cleaned_data["settings"]
if settings.get(settings_key) != destination.settings.get(settings_key):
data["settings"] = settings

self.serializer = RequestDestinationConfigSerializer(
destination,
data=data,
context={"request": self.request},
partial=True,
)


def _get_settings_key_for_media(media: Media) -> str:
"""Returns the required settings key for the given media,
e.g. "email_address", "phone_number"
"""
medium = api_safely_get_medium_object(media.slug)
return medium.MEDIA_JSON_SCHEMA["required"][0]
11 changes: 11 additions & 0 deletions src/argus/htmx/destination/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from django.urls import path

from .views import destination_list, create_htmx, delete_htmx, update_htmx

app_name = "htmx"
urlpatterns = [
path("", destination_list, name="destination-list"),
path("htmx-create/", create_htmx, name="htmx-create"),
path("<int:pk>/htmx-delete/", delete_htmx, name="htmx-delete"),
path("<int:pk>/htmx-update/", update_htmx, name="htmx-update"),
]
112 changes: 112 additions & 0 deletions src/argus/htmx/destination/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
from typing import Optional, Sequence
from django.shortcuts import render, get_object_or_404

from django.views.decorators.http import require_http_methods
from django.http import HttpResponse

from argus.notificationprofile.models import DestinationConfig, Media
from argus.notificationprofile.media import api_safely_get_medium_object
from argus.notificationprofile.media.base import NotificationMedium

from .forms import DestinationFormCreate, DestinationFormUpdate


@require_http_methods(["GET"])
def destination_list(request):
return _render_destination_list(request)


@require_http_methods(["POST"])
def create_htmx(request) -> HttpResponse:
form = DestinationFormCreate(request.POST or None, request=request)
template = "htmx/destination/_content.html"
if form.is_valid():
form.save()
return _render_destination_list(request, template=template)
return _render_destination_list(request, create_form=form, template=template)


@require_http_methods(["POST"])
def delete_htmx(request, pk: int) -> HttpResponse:
destination = get_object_or_404(request.user.destinations.all(), pk=pk)
template = "htmx/destination/_form_list.html"
try:
medium = api_safely_get_medium_object(destination.media.slug)
medium.raise_if_not_deletable(destination)
except NotificationMedium.NotDeletableError:
error_msg = "This destination cannot be deleted."
return _render_destination_list(request, errors=[error_msg], template=template)
else:
destination.delete()
return _render_destination_list(request, template=template)


@require_http_methods(["POST"])
def update_htmx(request, pk: int) -> HttpResponse:
destination = DestinationConfig.objects.get(pk=pk)
form = DestinationFormUpdate(request.POST or None, instance=destination, request=request)
template = "htmx/destination/_form_list.html"
if form.is_valid():
form.save()
return _render_destination_list(request, template=template)

update_forms = _get_update_forms(request.user)
for index, update_form in enumerate(update_forms):
if update_form.instance.pk == pk:
update_forms[index] = form
break
return _render_destination_list(request, update_forms=update_forms, template=template)


def _render_destination_list(
request,
create_form: Optional[DestinationFormCreate] = None,
update_forms: Optional[Sequence[DestinationFormUpdate]] = None,
errors: Optional[Sequence[str]] = None,
template: str = "htmx/destination/destination_list.html",
) -> HttpResponse:
"""Function to render the destinations page.
:param create_form: this is used to display the form for creating a new destination
with errors while retaining the user input. If you want a blank form, pass None.
:param update_forms: list of update forms to display. Useful for rendering forms
with error messages while retaining the user input.
If this is None, the update forms will be generated from the user's destinations.
:param errors: a list of error messages to display on the page. Will not be tied to
any form fields."""

if create_form is None:
create_form = DestinationFormCreate()
if update_forms is None:
update_forms = _get_update_forms(request.user)
if errors is None:
errors = []
grouped_forms = _group_update_forms_by_media(update_forms)
context = {
"create_form": create_form,
"grouped_forms": grouped_forms,
"errors": errors,
}
return render(request, template, context=context)


def _get_update_forms(user) -> list[DestinationFormUpdate]:
# Sort by oldest first
destinations = user.destinations.all().order_by("pk")
return [DestinationFormUpdate(instance=destination) for destination in destinations]


def _group_update_forms_by_media(
destination_forms: Sequence[DestinationFormUpdate],
) -> dict[Media, list[DestinationFormUpdate]]:
grouped_destinations = {}

# Adding a media to the dict even if there are no destinations for it
# is useful so that the template can render a section for that media
for media in Media.objects.all():
grouped_destinations[media] = []

for form in destination_forms:
grouped_destinations[form.instance.media].append(form)

return grouped_destinations
23 changes: 0 additions & 23 deletions src/argus/htmx/destinations/urls.py

This file was deleted.

Loading

0 comments on commit 0e44800

Please sign in to comment.