Merge pull request #1051 from ae-utbm/fragment-mixin

Fragment mixins
This commit is contained in:
thomas girod 2025-04-07 11:31:50 +02:00 committed by GitHub
commit 811c83552f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 567 additions and 116 deletions

View File

@ -13,40 +13,20 @@
# #
# #
from dataclasses import dataclass
from datetime import date, timedelta from datetime import date, timedelta
# Image utils # Image utils
from io import BytesIO from io import BytesIO
from typing import Any
import PIL import PIL
from django.conf import settings from django.conf import settings
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.forms import BaseForm
from django.http import HttpRequest 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 django.utils.timezone import localdate
from PIL import ExifTags from PIL import ExifTags
from PIL.Image import Image, Resampling 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: def get_start_of_semester(today: date | None = None) -> date:
"""Return the date of the start of the semester of the given 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. If no date is given, return the start date of the current semester.

View File

@ -1,8 +1,14 @@
from typing import ClassVar import copy
import inspect
from typing import Any, ClassVar, LiteralString, Protocol, Unpack
from django.conf import settings from django.conf import settings
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.http import HttpRequest, HttpResponse
from django.template.loader import render_to_string
from django.utils.safestring import SafeString
from django.views import View from django.views import View
from django.views.generic.base import ContextMixin, TemplateResponseMixin
class TabedViewMixin(View): class TabedViewMixin(View):
@ -71,3 +77,152 @@ class AllowFragment:
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs["is_fragment"] = self.request.headers.get("HX-Request", False) kwargs["is_fragment"] = self.request.headers.get("HX-Request", False)
return super().get_context_data(**kwargs) 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()),
]
```
"""
reload_on_redirect: bool = False
"""If True, this fragment will trigger a full page reload on redirect."""
@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)
# any POST action on the fragment will be dealt by the fragment itself.
# So, if the view that is rendering this fragment is in a POST context,
# let's pretend anyway it's a GET, in order to be sure the fragment
# won't try to do any POST action (like form validation) on initial render.
self.request = copy.copy(request)
self.request.method = "GET"
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,
)
def dispatch(self, *args, **kwargs):
res: HttpResponse = super().dispatch(*args, **kwargs)
if 300 <= res.status_code < 400 and self.reload_on_redirect:
# HTMX doesn't care about redirection codes (because why not),
# so we must transform the redirection code into a 200.
res.status_code = 200
res.headers["HX-Redirect"] = res["Location"]
return res
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():
is_cls = inspect.isclass(fragment) and issubclass(fragment, FragmentMixin)
_fragment = fragment.as_fragment() if is_cls 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

@ -41,6 +41,7 @@ from django.template.loader import render_to_string
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.utils.safestring import SafeString
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.views.generic import ( from django.views.generic import (
CreateView, CreateView,
@ -63,8 +64,9 @@ from core.views.forms import (
UserGroupsForm, UserGroupsForm,
UserProfileForm, UserProfileForm,
) )
from core.views.mixins import QuickNotifMixin, TabedViewMixin from core.views.mixins import QuickNotifMixin, TabedViewMixin, UseFragmentsMixin
from counter.models import Counter, Refilling, Selling from counter.models import Counter, Refilling, Selling
from counter.views.student_card import StudentCardFormFragment
from eboutic.models import Invoice from eboutic.models import Invoice
from subscription.models import Subscription from subscription.models import Subscription
from trombi.views import UserTrombiForm from trombi.views import UserTrombiForm
@ -508,7 +510,7 @@ class UserClubView(UserTabsMixin, CanViewMixin, DetailView):
current_tab = "clubs" current_tab = "clubs"
class UserPreferencesView(UserTabsMixin, CanEditMixin, UpdateView): class UserPreferencesView(UserTabsMixin, UseFragmentsMixin, CanEditMixin, UpdateView):
"""Edit a user's preferences.""" """Edit a user's preferences."""
model = User model = User
@ -526,17 +528,18 @@ class UserPreferencesView(UserTabsMixin, CanEditMixin, UpdateView):
kwargs.update({"instance": pref}) kwargs.update({"instance": pref})
return kwargs 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): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
if not hasattr(self.object, "trombi_user"): if not hasattr(self.object, "trombi_user"):
kwargs["trombi_form"] = UserTrombiForm() 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 return kwargs

View File

@ -56,7 +56,7 @@ from counter.views.home import (
CounterMain, CounterMain,
) )
from counter.views.invoice import InvoiceCallView from counter.views.invoice import InvoiceCallView
from counter.views.student_card import StudentCardDeleteView, StudentCardFormView from counter.views.student_card import StudentCardDeleteView, StudentCardFormFragment
urlpatterns = [ urlpatterns = [
path("<int:counter_id>/", CounterMain.as_view(), name="details"), path("<int:counter_id>/", CounterMain.as_view(), name="details"),
@ -83,7 +83,7 @@ urlpatterns = [
path("eticket/<int:selling_id>/pdf/", EticketPDFView.as_view(), name="eticket_pdf"), path("eticket/<int:selling_id>/pdf/", EticketPDFView.as_view(), name="eticket_pdf"),
path( path(
"customer/<int:customer_id>/card/add/", "customer/<int:customer_id>/card/add/",
StudentCardFormView.as_view(), StudentCardFormFragment.as_view(),
name="add_student_card", name="add_student_card",
), ),
path( path(

View File

@ -26,7 +26,8 @@ from django.forms import (
) )
from django.http import Http404 from django.http import Http404
from django.shortcuts import get_object_or_404, redirect, resolve_url 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.utils.translation import gettext_lazy as _
from django.views.generic import FormView from django.views.generic import FormView
from django.views.generic.detail import SingleObjectMixin from django.views.generic.detail import SingleObjectMixin
@ -34,7 +35,7 @@ from ninja.main import HttpRequest
from core.auth.mixins import CanViewMixin from core.auth.mixins import CanViewMixin
from core.models import User from core.models import User
from core.utils import FormFragmentTemplateData from core.views.mixins import FragmentMixin, UseFragmentsMixin
from counter.forms import RefillForm from counter.forms import RefillForm
from counter.models import ( from counter.models import (
Counter, Counter,
@ -45,7 +46,7 @@ from counter.models import (
) )
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter
from counter.views.mixins import CounterTabsMixin 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: def get_operator(request: HttpRequest, counter: Counter, customer: Customer) -> User:
@ -163,7 +164,9 @@ BasketForm = formset_factory(
) )
class CounterClick(CounterTabsMixin, CanViewMixin, SingleObjectMixin, FormView): class CounterClick(
CounterTabsMixin, UseFragmentsMixin, CanViewMixin, SingleObjectMixin, FormView
):
"""The click view """The click view
This is a detail view not to have to worry about loading the counter This is a detail view not to have to worry about loading the counter
Everything is made by hand in the post method. Everything is made by hand in the post method.
@ -304,6 +307,18 @@ class CounterClick(CounterTabsMixin, CanViewMixin, SingleObjectMixin, FormView):
def get_success_url(self): def get_success_url(self):
return resolve_url(self.object) 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): def get_context_data(self, **kwargs):
"""Add customer to the context.""" """Add customer to the context."""
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
@ -321,39 +336,15 @@ class CounterClick(CounterTabsMixin, CanViewMixin, SingleObjectMixin, FormView):
kwargs["form_errors"] = [ kwargs["form_errors"] = [
list(field_error.values()) for field_error in 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 return kwargs
class RefillingCreateView(FormView): class RefillingCreateView(FragmentMixin, FormView):
"""This is a fragment only view which integrates with counter_click.jinja""" """This is a fragment only view which integrates with counter_click.jinja"""
form_class = RefillForm form_class = RefillForm
template_name = "counter/fragments/create_refill.jinja" 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): def dispatch(self, request, *args, **kwargs):
self.customer: Customer = get_object_or_404(Customer, pk=kwargs["customer_id"]) self.customer: Customer = get_object_or_404(Customer, pk=kwargs["customer_id"])
if not self.customer.can_buy: if not self.customer.can_buy:
@ -373,6 +364,10 @@ class RefillingCreateView(FormView):
return super().dispatch(request, *args, **kwargs) 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): def form_valid(self, form):
res = super().form_valid(form) res = super().form_valid(form)
form.clean() form.clean()
@ -383,10 +378,11 @@ class RefillingCreateView(FormView):
return res return res
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
data = self.get_template_data(self.customer, form_instance=context["form"]) kwargs["action"] = reverse(
context.update(data.context) "counter:refilling_create", kwargs={"customer_id": self.customer.pk}
return context )
return kwargs
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
return self.request.path return self.request.path

View File

@ -13,16 +13,16 @@
# #
# #
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.http import Http404, HttpRequest, HttpResponse from django.http import Http404, HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.urls import reverse from django.urls import reverse
from django.utils.safestring import SafeString
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.views.generic.edit import DeleteView, FormView from django.views.generic.edit import DeleteView, FormView
from core.auth.mixins import can_edit 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.forms import StudentCardForm
from counter.models import Customer, StudentCard from counter.models import Customer, StudentCard
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter
@ -62,28 +62,12 @@ class StudentCardDeleteView(DeleteView):
) )
class StudentCardFormView(FormView): class StudentCardFormFragment(FragmentMixin, FormView):
"""Add a new student card. This is a fragment view !""" """Add a new student card."""
form_class = StudentCardForm form_class = StudentCardForm
template_name = "counter/fragments/create_student_card.jinja" 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): def dispatch(self, request: HttpRequest, *args, **kwargs):
self.customer = get_object_or_404( self.customer = get_object_or_404(
Customer.objects.select_related("student_card"), pk=kwargs["customer_id"] Customer.objects.select_related("student_card"), pk=kwargs["customer_id"]
@ -96,6 +80,10 @@ class StudentCardFormView(FormView):
return super().dispatch(request, *args, **kwargs) 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: def form_valid(self, form: StudentCardForm) -> HttpResponse:
data = form.clean() data = form.clean()
StudentCard.objects.update_or_create( StudentCard.objects.update_or_create(
@ -104,10 +92,12 @@ class StudentCardFormView(FormView):
return super().form_valid(form) return super().form_valid(form)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) return super().get_context_data(**kwargs) | {
data = self.get_template_data(self.customer, form_instance=context["form"]) "action": reverse(
context.update(data.context) "counter:add_student_card", kwargs={"customer_id": self.customer.pk}
return context ),
"customer": self.customer,
}
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
return self.request.path return self.request.path

View File

@ -0,0 +1,10 @@
::: core.views.mixins
handler: python
options:
heading_level: 3
members:
- TabedViewMixin
- QuickNotifMixin
- AllowFragment
- FragmentMixin
- UseFragmentsMixin

View File

@ -1,40 +1,356 @@
Pour utiliser HTMX, on a besoin de renvoyer des fragments depuis le backend. ## Qu'est-ce qu'un fragment
Le truc, c'est que tout est optimisé pour utiliser `base.jinja` qui est assez gros.
Dans beaucoup de scénario, on veut pouvoir renvoyer soit la vue complète, soit Une application web django traditionnelle suit en général
juste le fragment. En particulier quand on utilise l'attribut `hx-history` de htmx. le schéma suivant :
Pour remédier à cela, il existe le mixin [AllowFragment][core.views.AllowFragment]. 1. l'utilisateur envoie une requête au serveur
2. le serveur renvoie une page HTML,
qui contient en général des liens et/ou des formulaires
3. lorsque l'utilisateur clique sur un lien ou valide
un formulaire, on retourne à l'étape 1
Une fois ajouté à une vue Django, il ajoute le boolean `is_fragment` dans les C'est un processus qui marche, mais qui est lourd :
templates jinja. Sa valeur est `True` uniquement si HTMX envoie la requête. générer une page entière demande du travail au serveur
Il est ensuite très simple de faire un if/else pour hériter de et effectuer le rendu de cette page en demande également
`core/base_fragment.jinja` au lieu de `core/base.jinja` dans cette situation. beaucoup au client.
Or, des temps de chargement plus longs et des
rechargements complets de page peuvent nuire
à l'expérience utilisateur, en particulier
lorsqu'ils interviennent lors d'opérations simples.
Exemple d'utilisation d'une vue avec fragment: Pour éviter ce genre de rechargement complet,
on peut utiliser AlpineJS pour rendre la page
interactive et effectuer des appels à l'API.
Cette technique fonctionne particulièrement bien
lorsqu'on veut afficher des objets ou des listes
d'objets de manière dynamique.
En revanche, elle est moins efficace pour certaines
opérations, telles que la validation de formulaire.
En effet, valider un formulaire demande beaucoup
de travail de nettoyage des données et d'affichage
des messages d'erreur appropriés.
Or, tout ce travail existe déjà dans django.
On veut donc, dans ces cas-là, ne pas demander
toute une page HTML au serveur, mais uniquement
une toute petite partie, que l'on utilisera
pour remplacer la partie qui correspond sur la page actuelle.
Ce sont des fragments.
## HTMX
Toutes les fonctionnalités d'interaction avec les
fragments, côté client, s'appuient sur la librairie htmx.
L'usage qui en est fait est en général assez simple
et ressemblera souvent à ça :
```html+jinja
<form
hx-trigger="submit" {# Lorsque le formulaire est validé... #}
hx-post="{{ url("foo:bar") }}" {# ...envoie une requête POST vers l'url donnée... #}
hx-swap="outerHTML" {# ...et remplace tout l'html du formulaire par le contenu de la réponse HTTP #}
>
{% csrf_token %}
{{ form }}
<input type="submit" value="{% trans %}Go{% endtrans %}"/>
</form>
```
C'est la majorité de ce que vous avez besoin de savoir
pour utiliser HTMX sur le site.
Bien entendu, ce n'est pas tout, il y a d'autres
options et certaines subtilités, mais pour ça,
consultez [la doc officielle d'HTMX](https://htmx.org/docs/).
## La surcouche du site
Pour faciliter et standardiser l'intégration d'HTMX
dans la base de code du site AE,
nous avons créé certains mixins à utiliser
dans les vues basées sur des classes.
### [AllowFragment][core.views.mixins.AllowFragment]
`AllowFragment` est extrêmement simple dans son
concept : il met à disposition la variable `is_fragment`,
qui permet de savoir si la vue est appelée par HTMX,
ou si elle provient d'un autre contexte.
Grâce à ça, on peut écrire des vues qui
fonctionnent dans les deux contextes.
Par exemple, supposons que nous avons
une `EditView` très simple, contenant
uniquement un formulaire.
On peut écrire la vue et le template de la manière
suivante :
=== "`views.py`"
```python
from django.views.generic import UpdateView
class FooUpdateView(UpdateView):
model = Foo
fields = ["foo", "bar"]
pk_url_kwarg = "foo_id"
template_name = "app/foo.jinja"
```
=== "`app/foo.jinja`"
```html+jinja
{% if is_fragment %}
{% extends "core/base_fragment.jinja" %}
{% else %}
{% extends "core/base.jinja" %}
{% endif %}
{% block content %}
<form hx-trigger="submit" hx-swap="outerHTML">
{% csrf_token %}
{{ form }}
<input type="submit" value="{% trans %}Go{% endtrans %}"/>
</form>
{% endblock %}
```
Lors du chargement initial de la page, le template
entier sera rendu, mais lors de la soumission du formulaire,
seul le fragment html de ce dernier sera changé.
### [FragmentMixin][core.views.mixins.FragmentMixin]
Il arrive des situations où le résultat que l'on
veut accomplir est plus complexe.
Dans ces situations, pouvoir décomposer une vue
en plusieurs vues de fragment permet de ne plus
raisonner en termes de condition, mais en termes
de composition : on n'a pas un seul template
qui peut changer les situations, on a plusieurs
templates que l'on injecte dans un template principal.
Supposons, par exemple, que nous n'avons plus un,
mais deux formulaires à afficher sur la page.
Dans ce cas, nous pouvons créer deux templates,
qui seront alors injectés.
=== "`urls.py`"
```python
from django.urls import path
from app import views
urlpatterns = [
path("", FooCompositeView.as_view(), name="main"),
path("create/", FooUpdateFragment.as_view(), name="update_foo"),
path("update/", FooCreateFragment.as_view(), name="create_foo"),
]
```
=== "`view.py`"
```python
from django.views.generic import CreateView, UpdateView, TemplateView
from core.views.mixins import FragmentMixin
class FooCreateFragment(FragmentMixin, CreateView):
model = Foo
fields = ["foo", "bar"]
template_name = "app/fragments/create_foo.jinja"
class FooUpdateFragment(FragmentMixin, UpdateView):
model = Foo
fields = ["foo", "bar"]
pk_url_kwarg = "foo_id"
template_name = "app/fragments/update_foo.jinja"
class FooCompositeFormView(TemplateView):
template_name = "app/foo.jinja"
def get_context_data(**kwargs):
return super().get_context_data(**kwargs) | {
"create_fragment": FooCreateFragment.as_fragment()(),
"update_fragment": FooUpdateFragment.as_fragment()(foo_id=1)
}
```
=== "`app/fragment/create_foo.jinja`"
```html+jinja
<form
hx-trigger="submit"
hx-post="{{ url("app:create_foo") }}"
hx-swap="outerHTML"
>
{% csrf_token %}
{{ form }}
<input type="submit" value="{% trans %}Create{% endtrans %}"/>
</form>
```
=== "`app/fragment/update_foo.jinja`"
```html+jinja
<form
hx-trigger="submit"
hx-post="{{ url("app:update_foo") }}"
hx-swap="outerHTML"
>
{% csrf_token %}
{{ form }}
<input type="submit" value="{% trans %}Update{% endtrans %}"/>
</form>
```
=== "`app/foo.jinja`"
```html+jinja
{% extends "core/base.html" %}
{% block content %}
<h2>{% trans %}Update current foo{% endtrans %}</h2>
{{ update_fragment }}
<h2>{% trans %}Create new foo{% endtrans %}</h2>
{{ create_fragment }}
{% endblock %}
```
Le résultat consistera en l'affichage d'une page
contenant deux formulaires.
Le rendu des fragments n'est pas effectué
par `FooCompositeView`, mais par les vues
des fragments elles-mêmes, en sautant
les méthodes `dispatch` et `get`/`post` de ces dernières.
À chaque validation de formulaire, la requête
sera envoyée à la vue responsable du fragment,
qui se comportera alors comme une vue normale.
#### La méthode `as_fragment`
Il est à noter que l'instantiation d'un fragment
se fait en deux étapes :
- on commence par instantier la vue en tant que renderer.
- on appelle le renderer en lui-même
Ce qui donne la syntaxe `Fragment.as_fragment()()`.
Cette conception est une manière de se rapprocher
le plus possible de l'interface déjà existante
pour la méthode `as_view` des vues.
La méthode `as_fragment` prend en argument les mêmes
paramètres que `as_view`.
Par exemple, supposons que nous voulons rajouter
des variables de contexte lors du rendu du fragment.
On peut écrire ça ainsi :
```python
fragment = Fragment.as_fragment(extra_context={"foo": "bar"})()
```
#### Personnaliser le rendu
En plus de la personnalisation permise par
`as_fragment`, on peut surcharger la méthode
`render_fragment` pour accomplir des actions
spécifiques, et ce uniquement lorsqu'on effectue
le rendu du fragment.
Supposons qu'on veuille manipuler un entier
dans la vue et que, lorsqu'on est en train
de faire le rendu du template, on veuille augmenter
la valeur de cet entier (c'est juste pour l'exemple).
On peut écrire ça ainsi :
```python
from django.views.generic import CreateView
from core.views.mixins import FragmentMixin
class FooCreateFragment(FragmentMixin, CreateView):
model = Foo
fields = ["foo", "bar"]
template_name = "app/fragments/create_foo.jinja"
def render_fragment(self, request, **kwargs):
if "foo" in kwargs:
kwargs["foo"] += 2
return super().render_fragment(request, **kwargs)
```
Et on effectuera le rendu du fragment de la manière suivante :
```python
FooCreateFragment.as_fragment()(foo=4)
```
### [UseFragmentsMixin][core.views.mixins.UseFragmentsMixin]
Lorsqu'on a plusieurs fragments, il est parfois
plus aisé des les aggréger au sein de la vue
principale en utilisant `UseFragmentsMixin`.
Elle permet de marquer de manière plus explicite
la séparation entre les fragments et le reste du contexte.
Reprenons `FooUpdateFragment` et la version modifiée
de `FooCreateFragment`.
`FooCompositeView` peut être réécrite ainsi :
```python ```python
from django.views.generic import TemplateView from django.views.generic import TemplateView
from core.views import AllowFragment from core.views.mixins import UseFragmentsMixin
class FragmentView(AllowFragment, TemplateView):
template_name = "my_template.jinja" class FooCompositeFormView(UseFragmentsMixin, TemplateView):
fragments = {
"create_fragment": FooCreateFragment,
"update_fragment": FooUpdateFragment
}
fragment_data = {
"update_fragment": {"foo": 4}
}
template_name = "app/foo.jinja"
``` ```
Exemple de template (`my_template.jinja`) Le résultat sera alors strictement le même.
```jinja
{% if is_fragment %} Pour personnaliser le rendu de tous les fragments,
{% extends "core/base_fragment.jinja" %} on peut également surcharger la méthode
{% else %} `get_fragment_context_data`.
{% extends "core/base.jinja" %} Cette méthode remplit les mêmes objectifs
{% endif %} que `get_context_data`, mais uniquement pour les fragments.
Il s'agit simplement d'un utilitaire pour séparer les responsabilités.
```python
from django.views.generic import TemplateView
from core.views.mixins import UseFragmentsMixin
{% block title %} class FooCompositeFormView(UseFragmentsMixin, TemplateView):
{% trans %}My view with a fragment{% endtrans %} fragments = {
{% endblock %} "create_fragment": FooCreateFragment
}
template_name = "app/foo.jinja"
{% block content %} def get_fragment_context_data(self):
<h3>{% trans %}This will be a fragment when is_fragment is True{% endtrans %} # let's render the update fragment here
{% endblock %} # instead of using the class variables
return super().get_fragment_context_data() | {
"create_fragment": FooUpdateFragment.as_fragment()(foo=4)
}
``` ```

View File

@ -66,7 +66,7 @@ nav:
- Structure du projet: tutorial/structure.md - Structure du projet: tutorial/structure.md
- Gestion des permissions: tutorial/perms.md - Gestion des permissions: tutorial/perms.md
- Gestion des groupes: tutorial/groups.md - Gestion des groupes: tutorial/groups.md
- Créer des fragments: tutorial/fragments.md - Les fragments: tutorial/fragments.md
- Etransactions: tutorial/etransaction.md - Etransactions: tutorial/etransaction.md
- How-to: - How-to:
- L'ORM de Django: howto/querysets.md - L'ORM de Django: howto/querysets.md
@ -94,6 +94,7 @@ nav:
- reference/core/models.md - reference/core/models.md
- Champs de modèle: reference/core/model_fields.md - Champs de modèle: reference/core/model_fields.md
- reference/core/views.md - reference/core/views.md
- reference/core/mixins.md
- reference/core/schemas.md - reference/core/schemas.md
- reference/core/auth.md - reference/core/auth.md
- counter: - counter: