__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" __license__ = "AGPL v3" from typing import Any, Dict from django.contrib import messages from django.contrib.contenttypes.models import ContentType from django.core.paginator import Paginator from django.db.models.query import QuerySet from django.forms import formset_factory, modelformset_factory from django.forms.forms import BaseForm from django.forms.formsets import ManagementForm from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.template.response import TemplateResponse from django.urls import reverse from django.utils.html import format_html from django.views import View from django.views.generic import FormView, ListView from django.views.generic.detail import SingleObjectMixin from scipost.permissions import HTMXResponse from .forms import HTMXInlineCRUDModelForm def empty(request): return HttpResponse("") class HTMXInlineCRUDModelFormView(FormView): template_name = "htmx/htmx_inline_crud_form.html" form_class = HTMXInlineCRUDModelForm instance_li_template_name = None target_element_id = "htmx-crud-{instance_type}-{instance_id}" edit = False def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.instance_type = self.form_class.Meta.model.__name__.lower() def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["target_element_id"] = self.get_target_element_id() context["instance_li_template_name"] = self.instance_li_template_name context["instance_type"] = self.instance_type context[self.instance_type] = context["instance"] = self.instance return context def post(self, request, *args, **kwargs): self.instance = get_object_or_404(self.form_class.Meta.model, pk=kwargs["pk"]) self.edit = True return super().post(request, *args, **kwargs) def get(self, request, *args, **kwargs): self.instance = get_object_or_404(self.form_class.Meta.model, pk=kwargs["pk"]) self.edit = bool(request.GET.get("edit", None)) super().get(request, *args, **kwargs) return render(request, self.template_name, self.get_context_data(**kwargs)) def delete(self, request, *args, **kwargs): self.instance = get_object_or_404(self.form_class.Meta.model, pk=kwargs["pk"]) self.instance.delete() messages.success( self.request, f"{self.instance_type.title()} deleted successfully" ) return empty(request) def get_form(self) -> BaseForm: if self.request.method == "GET" and not self.edit: return None return super().get_form() def get_form_kwargs(self): kwargs = super().get_form_kwargs() kwargs.update({"instance": self.instance}) return kwargs def get_target_element_id(self) -> str: return self.target_element_id.format( instance_type=self.instance_type, instance_id=self.instance.id, ) def get_success_url(self) -> str: return self.get_context_data()["view"].request.path def form_valid(self, form: BaseForm) -> HttpResponse: form.save() messages.success( self.request, f"{self.instance_type.title()} saved successfully" ) return super().form_valid(form) class HTMXInlineCRUDModelListView(ListView): template_name = "htmx/htmx_inline_crud_list.html" add_form_class = None model = None model_form_view_url = None def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) self.instance_type = self.model.__name__.lower() def _append_model_form_view_url(self, queryset: QuerySet, **kwargs) -> QuerySet: for object in queryset: kwargs.update({"pk": object.pk}) object.model_form_view_url = reverse( self.model_form_view_url, kwargs=kwargs ) return queryset def post(self, request, *args, **kwargs): """ Post requests to the list view are treated as new object creation requests. """ if self.add_form_class is None: return empty(request) add_form = self.add_form_class(request.POST or None, **kwargs) if add_form.is_valid(): object = add_form.save() kwargs.update({"pk": object.pk}) messages.success(self.request, f"{self.instance_type.title()} successfully") return redirect(reverse(self.model_form_view_url, kwargs=kwargs)) else: response = TemplateResponse( request, "htmx/htmx_inline_crud_new_form.html", { "list_url": request.path, "add_form": add_form, "instance_type": self.instance_type, }, ) # Modify headers to swap in place with "HX-Reswap": "outerHTML" # This will avoid duplication of the form if errors are present response["HX-Reswap"] = "outerHTML" return response def get_context_data(self, **kwargs: Any): context = super().get_context_data(**kwargs) context["list_url"] = self.request.path context["instance_type"] = self.instance_type return context class HXDynselSelectOptionView(View): def get(self, request, content_type_id, object_id): obj = self.get_object(content_type_id, object_id) return HttpResponse( format_html('<option value="{}" selected>{}</option>', obj.pk, str(obj)) ) def get_object(self, content_type_id, object_id): model = ContentType.objects.get_for_id(content_type_id).model_class() if model is None: raise ValueError("Model not found") return get_object_or_404(model, pk=object_id) class HXDynselAutocomplete(View): model = None template_name = "htmx/dynsel_list_page.html" paginate_by = 16 def get(self, request): self.page_nr = request.GET.get("page") self.q = request.GET.get("q", "") context = self.get_context_data() return self.render_to_response(context) def get_page_obj(self, page_nr): paginator = Paginator(self.get_queryset(), self.paginate_by) page_obj = paginator.get_page(page_nr) return page_obj def get_queryset(self): result = self.search( self.model.objects.all(), self.q, ) return result def render_to_response(self, context): return TemplateResponse( self.request, self.template_name, context, ) def search(self, queryset, q): return queryset def get_context_data(self, **kwargs): context = {} context["model_name"] = self.model._meta.verbose_name_plural context["q"] = self.q context["page_obj"] = self.get_page_obj(self.page_nr) return context class HXFormSetView(View): """ Class-based view for handling formsets with HTMX. """ form_class = None formset_prefix = "formset" template_name = "htmx/formset_form.html" template_name_form = "htmx/crispy_form.html" def get_initial(self): """ Return the initial form instances to be used in the formset if pre-existing data is available. Does not set the initial data for each new form. """ return [] def get_form_kwargs(self): return {"initial": {}} def get_factory_kwargs(self): return {} def get_formset_kwargs(self): kwargs: dict[str, Any] = { "form_kwargs": self.get_form_kwargs(), } if self.request.method in ("POST", "PUT"): kwargs.update( { "data": self.request.POST, "files": self.request.FILES, } ) return kwargs def get_formset(self, data=None): # Determine if the formset is modelformset or regular formset if hasattr(self.form_class, "Meta") and hasattr(self.form_class.Meta, "model"): factory = modelformset_factory( self.form_class.Meta.model, form=self.form_class, **self.get_factory_kwargs(), ) else: factory = formset_factory(self.form_class, **self.get_factory_kwargs()) formset = factory(**self.get_formset_kwargs()) # This sets up the initial forms, not the (same) initial data for each (new) form formset.initial = self.get_initial() # Remove form tag if using crispy forms for form in formset: if getattr(form, "helper", None): form.helper.form_tag = False return formset def get_context_data(self, **kwargs: Any): context = {} context["formset"] = self.get_formset() return context def formset_invalid(self): return render(self.request, self.template_name, self.get_context_data()) def formset_valid(self): response = HTMXResponse("Formset saved successfully", tag="success") return response def get(self, request, **kwargs): self.request = request self.kwargs = kwargs return render(request, self.template_name, self.get_context_data()) def post(self, request, **kwargs): self.request = request self.kwargs = kwargs formset = self.get_formset() # If the "add extra form" button was pressed, add an extra form to the formset if request.POST.get("add-extra-form", False): return self._hx_add_extra_form(request, formset) # formset = self.get_formset() else: formset.full_clean() if formset.is_valid(): formset.save() return self.formset_valid() else: return self.formset_invalid() def _hx_add_extra_form(self, request, formset): """ Creates a new form and adds it to the formset. Also updates the formset's total form count to reflect the addition. Returns the updated formset to be replaced in the DOM. """ # Create a new form and add it to the formset # omit the form tag if using crispy forms form = formset.empty_form if getattr(form, "helper", None): form.helper.form_tag = False # add prefix to the form form.prefix = formset.add_prefix(formset.total_form_count()) management_form = ManagementForm( auto_id=formset.auto_id, prefix=formset.prefix, initial={ "TOTAL_FORMS": formset.total_form_count() + 1, "INITIAL_FORMS": formset.initial_form_count(), "MIN_NUM_FORMS": formset.min_num, "MAX_NUM_FORMS": formset.max_num, }, renderer=formset.renderer, ) response = render( request, self.template_name_form, { "form": form, "formset_prefix": formset.prefix, "management_form": management_form, }, ) response["HX-Retarget"] = f"#{formset.prefix}-formset-forms" response["HX-Reswap"] = "beforeend" return response