From af376fb85cac0c6b5c1ac6db9d509ad27fb23d2c Mon Sep 17 00:00:00 2001 From: imperosol Date: Fri, 28 Mar 2025 17:09:04 +0100 Subject: [PATCH] add `FragmentMixin` and `UseFragmentsMixin` classes --- core/utils.py | 20 ----- core/views/mixins.py | 139 ++++++++++++++++++++++++++++++++++ core/views/user.py | 21 ++--- counter/urls.py | 7 +- counter/views/click.py | 62 +++++++-------- counter/views/student_card.py | 38 ++++------ 6 files changed, 196 insertions(+), 91 deletions(-) diff --git a/core/utils.py b/core/utils.py index 6b72bde8..2f56cebf 100644 --- a/core/utils.py +++ b/core/utils.py @@ -13,40 +13,20 @@ # # -from dataclasses import dataclass from datetime import date, timedelta # Image utils from io import BytesIO -from typing import Any import PIL from django.conf import settings from django.core.files.base import ContentFile -from django.forms import BaseForm from django.http import HttpRequest -from django.template.loader import render_to_string -from django.utils.html import SafeString from django.utils.timezone import localdate from PIL import ExifTags from PIL.Image import Image, Resampling -@dataclass -class FormFragmentTemplateData[T: BaseForm]: - """Dataclass used to pre-render form fragments""" - - form: T - template: str - context: dict[str, Any] - - def render(self, request: HttpRequest) -> SafeString: - # Request is needed for csrf_tokens - return render_to_string( - self.template, context={"form": self.form, **self.context}, request=request - ) - - def get_start_of_semester(today: date | None = None) -> date: """Return the date of the start of the semester of the given date. If no date is given, return the start date of the current semester. diff --git a/core/views/mixins.py b/core/views/mixins.py index 9687f5d9..e1659b12 100644 --- a/core/views/mixins.py +++ b/core/views/mixins.py @@ -1,6 +1,12 @@ +from typing import Any, LiteralString, Protocol, Unpack + from django.conf import settings from django.core.exceptions import ImproperlyConfigured +from django.http import HttpRequest +from django.template.loader import render_to_string +from django.utils.safestring import SafeString from django.views import View +from django.views.generic.base import ContextMixin, TemplateResponseMixin class TabedViewMixin(View): @@ -65,3 +71,136 @@ class AllowFragment: def get_context_data(self, **kwargs): kwargs["is_fragment"] = self.request.headers.get("HX-Request", False) return super().get_context_data(**kwargs) + + +class FragmentRenderer(Protocol): + def __call__( + self, request: HttpRequest, **kwargs: Unpack[dict[str, Any]] + ) -> SafeString: ... + + +class FragmentMixin(TemplateResponseMixin, ContextMixin): + """Make a view buildable as a fragment that can be embedded in a template. + + Most fragments are used in two different ways : + - in the request/response cycle, like any regular view + - in templates, where the rendering is done in another view + + This mixin aims to simplify the initial fragment rendering. + The rendered fragment will then be able to re-render itself + through the request/response cycle if it uses HTMX. + + !!!Example + ```python + class MyFragment(FragmentMixin, FormView): + template_name = "app/fragment.jinja" + form_class = MyForm + success_url = reverse_lazy("foo:bar") + + # in another view : + def some_view(request): + fragment = MyFragment.as_fragment() + return render( + request, + "app/template.jinja", + context={"fragment": fragment(request) + } + + # in urls.py + urlpatterns = [ + path("foo/view", some_view), + path("foo/fragment", MyFragment.as_view()), + ] + ``` + """ + + @classmethod + def as_fragment(cls, **initkwargs) -> FragmentRenderer: + # the following code is heavily inspired from the base View.as_view method + for key in initkwargs: + if not hasattr(cls, key): + raise TypeError( + "%s() received an invalid keyword %r. as_view " + "only accepts arguments that are already " + "attributes of the class." % (cls.__name__, key) + ) + + def fragment(request: HttpRequest, **kwargs) -> SafeString: + self = cls(**initkwargs) + self.request = request + self.kwargs = kwargs + return self.render_fragment(request, **kwargs) + + fragment.__doc__ = cls.__doc__ + fragment.__module__ = cls.__module__ + return fragment + + def render_fragment(self, request, **kwargs) -> SafeString: + return render_to_string( + self.get_template_names(), + context=self.get_context_data(**kwargs), + request=request, + ) + + +class UseFragmentsMixin(ContextMixin): + """Mark a view as using fragments. + + This mixin is not mandatory + (you may as well render manually your fragments in the `get_context_data` method). + However, the interface of this class bring some distinction + between fragments and other context data, which may + reduce boilerplate. + + !!!Example + ```python + class FooFragment(FragmentMixin, FormView): ... + + class BarFragment(FragmentMixin, FormView): ... + + class AdminFragment(FragmentMixin, FormView): ... + + class MyView(UseFragmentsMixin, TemplateView) + template_name = "app/view.jinja" + fragments = { + "foo": FooFragment + "bar": BarFragment(template_name="some_template.jinja") + } + fragments_data = { + "foo": {"some": "data"} # this will be passed to the FooFragment renderer + } + + def get_fragments(self): + res = super().get_fragments() + if self.request.user.is_superuser: + res["admin_fragment"] = AdminFragment + return res + ``` + """ + + fragments: dict[LiteralString, type[FragmentMixin] | FragmentRenderer] | None = None + fragment_data: dict[LiteralString, dict[LiteralString, Any]] | None = None + + def get_fragments(self) -> dict[str, type[FragmentMixin] | FragmentRenderer]: + return self.fragments if self.fragments is not None else {} + + def get_fragment_data(self) -> dict[str, dict[str, Any]]: + """Return eventual data used to initialize the fragments.""" + return self.fragment_data if self.fragment_data is not None else {} + + def get_fragment_context_data(self) -> dict[str, SafeString]: + """Return the rendered fragments as context data.""" + res = {} + data = self.get_fragment_data() + for name, fragment in self.get_fragments().items(): + _fragment = ( + fragment.as_fragment() if fragment is FragmentMixin else fragment + ) + fragment_data = data.get(name, {}) + res[name] = _fragment(self.request, **fragment_data) + return res + + def get_context_data(self, **kwargs): + kwargs = super().get_context_data(**kwargs) + kwargs.update(self.get_fragment_context_data()) + return kwargs diff --git a/core/views/user.py b/core/views/user.py index 8e7b092c..7eaf646c 100644 --- a/core/views/user.py +++ b/core/views/user.py @@ -42,6 +42,7 @@ from django.template.loader import render_to_string from django.template.response import TemplateResponse from django.urls import reverse, reverse_lazy from django.utils.decorators import method_decorator +from django.utils.safestring import SafeString from django.utils.translation import gettext as _ from django.views.generic import ( CreateView, @@ -64,8 +65,9 @@ from core.views.forms import ( UserGroupsForm, UserProfileForm, ) -from core.views.mixins import QuickNotifMixin, TabedViewMixin +from core.views.mixins import QuickNotifMixin, TabedViewMixin, UseFragmentsMixin from counter.models import Refilling, Selling +from counter.views.student_card import StudentCardFormFragment from eboutic.models import Invoice from subscription.models import Subscription from trombi.views import UserTrombiForm @@ -531,7 +533,7 @@ class UserClubView(UserTabsMixin, CanViewMixin, DetailView): current_tab = "clubs" -class UserPreferencesView(UserTabsMixin, CanEditMixin, UpdateView): +class UserPreferencesView(UserTabsMixin, UseFragmentsMixin, CanEditMixin, UpdateView): """Edit a user's preferences.""" model = User @@ -549,17 +551,18 @@ class UserPreferencesView(UserTabsMixin, CanEditMixin, UpdateView): kwargs.update({"instance": pref}) return kwargs + def get_fragment_context_data(self) -> dict[str, SafeString]: + res = super().get_fragment_context_data() + if hasattr(self.object, "customer"): + res["student_card_fragment"] = StudentCardFormFragment.as_fragment()( + self.request, customer=self.object.customer + ) + return res + def get_context_data(self, **kwargs): kwargs = super().get_context_data(**kwargs) - if not hasattr(self.object, "trombi_user"): kwargs["trombi_form"] = UserTrombiForm() - if hasattr(self.object, "customer"): - from counter.views.student_card import StudentCardFormView - - kwargs["student_card_fragment"] = StudentCardFormView.get_template_data( - self.object.customer - ).render(self.request) return kwargs diff --git a/counter/urls.py b/counter/urls.py index 885b4b14..5ab6487c 100644 --- a/counter/urls.py +++ b/counter/urls.py @@ -51,10 +51,7 @@ from counter.views.home import ( CounterMain, ) from counter.views.invoice import InvoiceCallView -from counter.views.student_card import ( - StudentCardDeleteView, - StudentCardFormView, -) +from counter.views.student_card import StudentCardDeleteView, StudentCardFormFragment urlpatterns = [ path("/", CounterMain.as_view(), name="details"), @@ -81,7 +78,7 @@ urlpatterns = [ path("eticket//pdf/", EticketPDFView.as_view(), name="eticket_pdf"), path( "customer//card/add/", - StudentCardFormView.as_view(), + StudentCardFormFragment.as_view(), name="add_student_card", ), path( diff --git a/counter/views/click.py b/counter/views/click.py index 46bf8e62..718d7b83 100644 --- a/counter/views/click.py +++ b/counter/views/click.py @@ -25,7 +25,8 @@ from django.forms import ( ) from django.http import Http404 from django.shortcuts import get_object_or_404, redirect, resolve_url -from django.urls import reverse_lazy +from django.urls import reverse +from django.utils.safestring import SafeString from django.utils.translation import gettext_lazy as _ from django.views.generic import FormView from django.views.generic.detail import SingleObjectMixin @@ -33,12 +34,12 @@ from ninja.main import HttpRequest from core.auth.mixins import CanViewMixin from core.models import User -from core.utils import FormFragmentTemplateData +from core.views.mixins import FragmentMixin, UseFragmentsMixin from counter.forms import RefillForm from counter.models import Counter, Customer, Product, Selling from counter.utils import is_logged_in_counter from counter.views.mixins import CounterTabsMixin -from counter.views.student_card import StudentCardFormView +from counter.views.student_card import StudentCardFormFragment def get_operator(request: HttpRequest, counter: Counter, customer: Customer) -> User: @@ -140,7 +141,9 @@ BasketForm = formset_factory( ) -class CounterClick(CounterTabsMixin, CanViewMixin, SingleObjectMixin, FormView): +class CounterClick( + CounterTabsMixin, UseFragmentsMixin, CanViewMixin, SingleObjectMixin, FormView +): """The click view This is a detail view not to have to worry about loading the counter Everything is made by hand in the post method. @@ -251,6 +254,18 @@ class CounterClick(CounterTabsMixin, CanViewMixin, SingleObjectMixin, FormView): def get_success_url(self): return resolve_url(self.object) + def get_fragment_context_data(self) -> dict[str, SafeString]: + res = super().get_fragment_context_data() + if self.object.type == "BAR": + res["student_card_fragment"] = StudentCardFormFragment.as_fragment()( + self.request, customer=self.customer + ) + if self.object.can_refill(): + res["refilling_fragment"] = RefillingCreateView.as_fragment()( + self.request, customer=self.customer + ) + return res + def get_context_data(self, **kwargs): """Add customer to the context.""" kwargs = super().get_context_data(**kwargs) @@ -268,39 +283,15 @@ class CounterClick(CounterTabsMixin, CanViewMixin, SingleObjectMixin, FormView): kwargs["form_errors"] = [ list(field_error.values()) for field_error in kwargs["form"].errors ] - if self.object.type == "BAR": - kwargs["student_card_fragment"] = StudentCardFormView.get_template_data( - self.customer - ).render(self.request) - - if self.object.can_refill(): - kwargs["refilling_fragment"] = RefillingCreateView.get_template_data( - self.customer - ).render(self.request) - return kwargs -class RefillingCreateView(FormView): +class RefillingCreateView(FragmentMixin, FormView): """This is a fragment only view which integrates with counter_click.jinja""" form_class = RefillForm template_name = "counter/fragments/create_refill.jinja" - @classmethod - def get_template_data( - cls, customer: Customer, *, form_instance: form_class | None = None - ) -> FormFragmentTemplateData[form_class]: - return FormFragmentTemplateData( - form=form_instance if form_instance else cls.form_class(), - template=cls.template_name, - context={ - "action": reverse_lazy( - "counter:refilling_create", kwargs={"customer_id": customer.pk} - ), - }, - ) - def dispatch(self, request, *args, **kwargs): self.customer: Customer = get_object_or_404(Customer, pk=kwargs["customer_id"]) if not self.customer.can_buy: @@ -320,6 +311,10 @@ class RefillingCreateView(FormView): return super().dispatch(request, *args, **kwargs) + def render_fragment(self, request, **kwargs) -> SafeString: + self.customer = kwargs.pop("customer") + return super().render_fragment(request, **kwargs) + def form_valid(self, form): res = super().form_valid(form) form.clean() @@ -330,10 +325,11 @@ class RefillingCreateView(FormView): return res def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - data = self.get_template_data(self.customer, form_instance=context["form"]) - context.update(data.context) - return context + kwargs = super().get_context_data(**kwargs) + kwargs["action"] = reverse( + "counter:refilling_create", kwargs={"customer_id": self.customer.pk} + ) + return kwargs def get_success_url(self, **kwargs): return self.request.path diff --git a/counter/views/student_card.py b/counter/views/student_card.py index 6e2a3358..9aef2e11 100644 --- a/counter/views/student_card.py +++ b/counter/views/student_card.py @@ -13,16 +13,16 @@ # # - from django.core.exceptions import PermissionDenied from django.http import Http404, HttpRequest, HttpResponse from django.shortcuts import get_object_or_404 from django.urls import reverse +from django.utils.safestring import SafeString from django.utils.translation import gettext as _ from django.views.generic.edit import DeleteView, FormView from core.auth.mixins import can_edit -from core.utils import FormFragmentTemplateData +from core.views.mixins import FragmentMixin from counter.forms import StudentCardForm from counter.models import Customer, StudentCard from counter.utils import is_logged_in_counter @@ -62,28 +62,12 @@ class StudentCardDeleteView(DeleteView): ) -class StudentCardFormView(FormView): - """Add a new student card. This is a fragment view !""" +class StudentCardFormFragment(FragmentMixin, FormView): + """Add a new student card.""" form_class = StudentCardForm template_name = "counter/fragments/create_student_card.jinja" - @classmethod - def get_template_data( - cls, customer: Customer, *, form_instance: form_class | None = None - ) -> FormFragmentTemplateData[form_class]: - """Get necessary data to pre-render the fragment""" - return FormFragmentTemplateData( - form=form_instance if form_instance else cls.form_class(), - template=cls.template_name, - context={ - "action": reverse( - "counter:add_student_card", kwargs={"customer_id": customer.pk} - ), - "customer": customer, - }, - ) - def dispatch(self, request: HttpRequest, *args, **kwargs): self.customer = get_object_or_404( Customer.objects.select_related("student_card"), pk=kwargs["customer_id"] @@ -96,6 +80,10 @@ class StudentCardFormView(FormView): return super().dispatch(request, *args, **kwargs) + def render_fragment(self, request, **kwargs) -> SafeString: + self.customer = kwargs.pop("customer") + return super().render_fragment(request, **kwargs) + def form_valid(self, form: StudentCardForm) -> HttpResponse: data = form.clean() StudentCard.objects.update_or_create( @@ -104,10 +92,12 @@ class StudentCardFormView(FormView): return super().form_valid(form) def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - data = self.get_template_data(self.customer, form_instance=context["form"]) - context.update(data.context) - return context + return super().get_context_data(**kwargs) | { + "action": reverse( + "counter:add_student_card", kwargs={"customer_id": self.customer.pk} + ), + "customer": self.customer, + } def get_success_url(self, **kwargs): return self.request.path