Merge af376fb85cac0c6b5c1ac6db9d509ad27fb23d2c into 15d541b59696b7fb0e5efa1c26dc1a8157d67efa

This commit is contained in:
thomas girod 2025-03-28 16:21:59 +00:00 committed by GitHub
commit e41251164f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 196 additions and 91 deletions

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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("<int:counter_id>/", CounterMain.as_view(), name="details"),
@ -81,7 +78,7 @@ urlpatterns = [
path("eticket/<int:selling_id>/pdf/", EticketPDFView.as_view(), name="eticket_pdf"),
path(
"customer/<int:customer_id>/card/add/",
StudentCardFormView.as_view(),
StudentCardFormFragment.as_view(),
name="add_student_card",
),
path(

View File

@ -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

View File

@ -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