From 6a5da0302d688e443d1b2cf8166939ce9ae8a9da Mon Sep 17 00:00:00 2001 From: imperosol Date: Fri, 28 Mar 2025 17:09:04 +0100 Subject: [PATCH 01/26] add `FragmentMixin` and `UseFragmentsMixin` classes --- core/utils.py | 20 ----- core/views/mixins.py | 157 +++++++++++++++++++++++++++++++++- core/views/user.py | 21 +++-- counter/urls.py | 4 +- counter/views/click.py | 62 +++++++------- counter/views/student_card.py | 38 +++----- 6 files changed, 213 insertions(+), 89 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 e5b445d6..2a18955c 100644 --- a/core/views/mixins.py +++ b/core/views/mixins.py @@ -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.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.generic.base import ContextMixin, TemplateResponseMixin class TabedViewMixin(View): @@ -71,3 +77,152 @@ 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()), + ] + ``` + """ + + 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 diff --git a/core/views/user.py b/core/views/user.py index a9ce811f..250eef16 100644 --- a/core/views/user.py +++ b/core/views/user.py @@ -41,6 +41,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, @@ -63,8 +64,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 Counter, Refilling, Selling +from counter.views.student_card import StudentCardFormFragment from eboutic.models import Invoice from subscription.models import Subscription from trombi.views import UserTrombiForm @@ -505,7 +507,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 @@ -523,17 +525,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 04757aa1..67c7d950 100644 --- a/counter/urls.py +++ b/counter/urls.py @@ -56,7 +56,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"), @@ -83,7 +83,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 a44e841f..4da38643 100644 --- a/counter/views/click.py +++ b/counter/views/click.py @@ -26,7 +26,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 @@ -34,7 +35,7 @@ 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, @@ -45,7 +46,7 @@ from counter.models import ( ) 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: @@ -163,7 +164,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. @@ -304,6 +307,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) @@ -321,39 +336,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: @@ -373,6 +364,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() @@ -383,10 +378,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 From 76234741242975f7a6fbc0d5959f5c1e4a334bf8 Mon Sep 17 00:00:00 2001 From: Thomas Girod Date: Thu, 3 Apr 2025 20:53:15 +0200 Subject: [PATCH 02/26] add fragments documentation --- docs/reference/core/mixins.md | 10 + docs/tutorial/fragments.md | 368 +++++++++++++++++++++++++++++++--- mkdocs.yml | 3 +- 3 files changed, 354 insertions(+), 27 deletions(-) create mode 100644 docs/reference/core/mixins.md diff --git a/docs/reference/core/mixins.md b/docs/reference/core/mixins.md new file mode 100644 index 00000000..d08c1ee8 --- /dev/null +++ b/docs/reference/core/mixins.md @@ -0,0 +1,10 @@ +::: core.views.mixins + handler: python + options: + heading_level: 3 + members: + - TabedViewMixin + - QuickNotifMixin + - AllowFragment + - FragmentMixin + - UseFragmentsMixin \ No newline at end of file diff --git a/docs/tutorial/fragments.md b/docs/tutorial/fragments.md index 007d4c0d..bcb85909 100644 --- a/docs/tutorial/fragments.md +++ b/docs/tutorial/fragments.md @@ -1,40 +1,356 @@ -Pour utiliser HTMX, on a besoin de renvoyer des fragments depuis le backend. -Le truc, c'est que tout est optimisé pour utiliser `base.jinja` qui est assez gros. +## Qu'est-ce qu'un fragment -Dans beaucoup de scénario, on veut pouvoir renvoyer soit la vue complète, soit -juste le fragment. En particulier quand on utilise l'attribut `hx-history` de htmx. +Une application web django traditionnelle suit en général +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 -templates jinja. Sa valeur est `True` uniquement si HTMX envoie la requête. -Il est ensuite très simple de faire un if/else pour hériter de -`core/base_fragment.jinja` au lieu de `core/base.jinja` dans cette situation. +C'est un processus qui marche, mais qui est lourd : +générer une page entière demande du travail au serveur +et effectuer le rendu de cette page en demande également +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 +
+ {% csrf_token %} + {{ 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 %} +
+ {% csrf_token %} + {{ 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 +
+ {% csrf_token %} + {{ form }} + +
+ ``` + +=== "`app/fragment/update_foo.jinja`" + + ```html+jinja +
+ {% csrf_token %} + {{ form }} + +
+ ``` + +=== "`app/foo.jinja`" + + ```html+jinja + {% extends "core/base.html" %} + + {% block content %} +

{% trans %}Update current foo{% endtrans %}

+ {{ update_fragment }} + +

{% trans %}Create new foo{% endtrans %}

+ {{ 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 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`) -```jinja -{% if is_fragment %} - {% extends "core/base_fragment.jinja" %} -{% else %} - {% extends "core/base.jinja" %} -{% endif %} +Le résultat sera alors strictement le même. + +Pour personnaliser le rendu de tous les fragments, +on peut également surcharger la méthode +`get_fragment_context_data`. +Cette méthode remplit les mêmes objectifs +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 %} - {% trans %}My view with a fragment{% endtrans %} -{% endblock %} +class FooCompositeFormView(UseFragmentsMixin, TemplateView): + fragments = { + "create_fragment": FooCreateFragment + } + template_name = "app/foo.jinja" -{% block content %} -

{% trans %}This will be a fragment when is_fragment is True{% endtrans %} -{% endblock %} + def get_fragment_context_data(self): + # let's render the update fragment here + # instead of using the class variables + return super().get_fragment_context_data() | { + "create_fragment": FooUpdateFragment.as_fragment()(foo=4) + } ``` + + diff --git a/mkdocs.yml b/mkdocs.yml index 73758b9d..4dfe67dc 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -66,7 +66,7 @@ nav: - Structure du projet: tutorial/structure.md - Gestion des permissions: tutorial/perms.md - Gestion des groupes: tutorial/groups.md - - Créer des fragments: tutorial/fragments.md + - Les fragments: tutorial/fragments.md - Etransactions: tutorial/etransaction.md - How-to: - L'ORM de Django: howto/querysets.md @@ -94,6 +94,7 @@ nav: - reference/core/models.md - Champs de modèle: reference/core/model_fields.md - reference/core/views.md + - reference/core/mixins.md - reference/core/schemas.md - reference/core/auth.md - counter: From 597339749a2751e1337d8e9a02a5b9917efc3789 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 6 Apr 2025 21:17:09 +0000 Subject: [PATCH 03/26] Bump vite from 6.2.3 to 6.2.5 Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.2.3 to 6.2.5. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/v6.2.5/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v6.2.5/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 6.2.5 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index dfa465c1..10edbdeb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,7 +46,7 @@ "@rollup/plugin-inject": "^5.0.5", "@types/alpinejs": "^3.13.10", "@types/jquery": "^3.5.31", - "vite": "^6.2.3", + "vite": "^6.2.5", "vite-bundle-visualizer": "^1.2.1", "vite-plugin-static-copy": "^2.1.0" } @@ -5705,9 +5705,9 @@ } }, "node_modules/vite": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.3.tgz", - "integrity": "sha512-IzwM54g4y9JA/xAeBPNaDXiBF8Jsgl3VBQ2YQ/wOY6fyW3xMdSoltIV3Bo59DErdqdE6RxUfv8W69DvUorE4Eg==", + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.5.tgz", + "integrity": "sha512-j023J/hCAa4pRIUH6J9HemwYfjB5llR2Ps0CWeikOtdR8+pAURAk0DoJC5/mm9kd+UgdnIy7d6HE4EAvlYhPhA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index d5a23a86..0c52edb6 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "@rollup/plugin-inject": "^5.0.5", "@types/alpinejs": "^3.13.10", "@types/jquery": "^3.5.31", - "vite": "^6.2.3", + "vite": "^6.2.5", "vite-bundle-visualizer": "^1.2.1", "vite-plugin-static-copy": "^2.1.0" }, From e52b2eadbe4f8f33a0d7d3105cf2483318a6ea25 Mon Sep 17 00:00:00 2001 From: Sli Date: Sun, 6 Apr 2025 23:31:15 +0200 Subject: [PATCH 04/26] Bump ical to 9.1.0 version --- pyproject.toml | 2 +- uv.lock | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 78c1b9c4..587a29df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ dependencies = [ "tomli<3.0.0,>=2.2.1", "django-honeypot<2.0.0,>=1.2.1", "pydantic-extra-types<3.0.0,>=2.10.2", - "ical<9.0.0,>=8.3.1", + "ical<10.0.0,>=9.1.0", "redis[hiredis]<6.0.0,>=5.2.0", "environs[django]<15.0.0,>=14.1.0", "requests>=2.32.3", diff --git a/uv.lock b/uv.lock index 61b782df..72965778 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,4 @@ version = 1 -revision = 1 requires-python = ">=3.12, <4.0" [[package]] @@ -640,7 +639,7 @@ wheels = [ [[package]] name = "ical" -version = "8.3.1" +version = "9.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, @@ -648,9 +647,9 @@ dependencies = [ { name = "python-dateutil" }, { name = "tzdata" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/56/f6/f623166c503de1c3eff70a54e1e8a4cc83aca5de0c4d108ec4f6d97251b3/ical-8.3.1.tar.gz", hash = "sha256:4d3c8c215230c18b1544d11f8e3342301943b5c6fc1b9980442735b84f8015cd", size = 117126 } +sdist = { url = "https://files.pythonhosted.org/packages/67/ed/3b23916c730d136d0a96366079de9c9ac619a0dbb85a1c1a4dcfb8ca25e8/ical-9.1.0.tar.gz", hash = "sha256:0d27946eec356536f4addacb63f8b9016b3b06160d77dc2a46981aa55519a2a7", size = 118297 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/ba/4abd303bd8f32bf482ab1f2f090f4a59edcfa8897bd68779b6853428a87a/ical-8.3.1-py3-none-any.whl", hash = "sha256:22e07acd9d36ef0ccb3970a47d5efd5ff29a833fe541145d6f1d3a7676334fa3", size = 117317 }, + { url = "https://files.pythonhosted.org/packages/8e/41/e28a56f0ffd255f93195d3ce3b8b0aac59f00668dd0bfbae43b9ff9f08e3/ical-9.1.0-py3-none-any.whl", hash = "sha256:f931b22d2dbeae69b463784dc6e7ec26887554e5bab41251767d91067125f63b", size = 118736 }, ] [[package]] @@ -1640,7 +1639,7 @@ requires-dist = [ { name = "django-simple-captcha", specifier = ">=0.6.2,<1.0.0" }, { name = "environs", extras = ["django"], specifier = ">=14.1.0,<15.0.0" }, { name = "honcho", specifier = ">=2.0.0" }, - { name = "ical", specifier = ">=8.3.1,<9.0.0" }, + { name = "ical", specifier = ">=9.1.0,<10.0.0" }, { name = "jinja2", specifier = ">=3.1.6,<4.0.0" }, { name = "libsass", specifier = ">=0.23.0,<1.0.0" }, { name = "mistune", specifier = ">=3.1.2,<4.0.0" }, From b09d5e5ffdf99a0b5b4bcc6b3cf09323f8b212b8 Mon Sep 17 00:00:00 2001 From: Sli Date: Mon, 7 Apr 2025 13:58:08 +0200 Subject: [PATCH 05/26] Remote calendar link for external sync --- com/ics_calendar.py | 17 +++++++++--- .../com/components/ics-calendar-index.ts | 26 +++++++++++++++++++ com/static/com/components/ics-calendar.scss | 17 ++++++++++++ locale/fr/LC_MESSAGES/djangojs.po | 10 ++++++- sith/settings.py | 6 +++-- 5 files changed, 70 insertions(+), 6 deletions(-) diff --git a/com/ics_calendar.py b/com/ics_calendar.py index e5324c8a..6cb10d2d 100644 --- a/com/ics_calendar.py +++ b/com/ics_calendar.py @@ -1,9 +1,11 @@ from pathlib import Path -from typing import final from dateutil.relativedelta import relativedelta from django.conf import settings +from django.contrib.sites.models import Site +from django.contrib.syndication.views import add_domain from django.db.models import F, QuerySet +from django.http import HttpRequest from django.urls import reverse from django.utils import timezone from ical.calendar import Calendar @@ -14,7 +16,14 @@ from com.models import NewsDate from core.models import User -@final +def as_absolute_url(url: str, request: HttpRequest | None = None) -> str: + return add_domain( + Site.objects.get_current(request=request), + url, + secure=request.is_secure() if request is not None else settings.HTTPS, + ) + + class IcsCalendar: _CACHE_FOLDER: Path = settings.MEDIA_ROOT / "com" / "calendars" _INTERNAL_CALENDAR = _CACHE_FOLDER / "internal.ics" @@ -58,7 +67,9 @@ class IcsCalendar: summary=news_date.news_title, start=news_date.start_date, end=news_date.end_date, - url=reverse("com:news_detail", kwargs={"news_id": news_date.news.id}), + url=as_absolute_url( + reverse("com:news_detail", kwargs={"news_id": news_date.news.id}) + ), ) calendar.events.append(event) diff --git a/com/static/bundled/com/components/ics-calendar-index.ts b/com/static/bundled/com/components/ics-calendar-index.ts index d8fc79d7..2befc97e 100644 --- a/com/static/bundled/com/components/ics-calendar-index.ts +++ b/com/static/bundled/com/components/ics-calendar-index.ts @@ -303,10 +303,36 @@ export class IcsCalendar extends inheritHtmlElement("div") { this.calendar = new Calendar(this.node, { plugins: [dayGridPlugin, iCalendarPlugin, listPlugin], locales: [frLocale, enLocale], + customButtons: { + getCalendarLink: { + text: gettext("Copy calendar link"), + click: async (event: Event) => { + const button = event.target as HTMLButtonElement; + button.classList.add("text-copy"); + button.setAttribute( + "tooltip", + gettext("Calendar link copied to the clipboard"), + ); + navigator.clipboard.writeText( + new URL( + await makeUrl(calendarCalendarInternal), + window.location.origin, + ).toString(), + ); + setTimeout(() => { + button.classList.remove("text-copied"); + button.classList.add("text-copied"); + button.classList.remove("text-copy"); + button.removeAttribute("tooltip"); + }, 700); + }, + }, + }, height: "auto", locale: this.locale, initialView: this.currentView(), headerToolbar: this.currentToolbar(), + footerToolbar: { start: "getCalendarLink" }, eventSources: await this.getEventSources(), windowResize: () => { this.calendar.changeView(this.currentView()); diff --git a/com/static/com/components/ics-calendar.scss b/com/static/com/components/ics-calendar.scss index 6c86cce0..bb0fea47 100644 --- a/com/static/com/components/ics-calendar.scss +++ b/com/static/com/components/ics-calendar.scss @@ -98,4 +98,21 @@ ics-calendar { background: white; } } + + .fc .fc-toolbar.fc-footer-toolbar { + margin-bottom: 0.5em; + } + + button.text-copy, + button.text-copy:focus, + button.text-copy:hover { + background-color: green !important; + transition: 200ms linear; + } + + button.text-copied, + button.text-copied:focus, + button.text-copied:hover { + transition: 200ms linear; + } } \ No newline at end of file diff --git a/locale/fr/LC_MESSAGES/djangojs.po b/locale/fr/LC_MESSAGES/djangojs.po index 7952baa4..e078509a 100644 --- a/locale/fr/LC_MESSAGES/djangojs.po +++ b/locale/fr/LC_MESSAGES/djangojs.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-04-06 15:47+0200\n" +"POT-Creation-Date: 2025-04-07 13:56+0200\n" "PO-Revision-Date: 2024-09-17 11:54+0200\n" "Last-Translator: Sli \n" "Language-Team: AE info \n" @@ -33,6 +33,14 @@ msgstr "Dépublier" msgid "Delete" msgstr "Supprimer" +#: com/static/bundled/com/components/ics-calendar-index.ts +msgid "Copy calendar link" +msgstr "Copier le lien du calendrier" + +#: com/static/bundled/com/components/ics-calendar-index.ts +msgid "Calendar link copied to the clipboard" +msgstr "Lien du calendrier copié dans le presse papier" + #: com/static/bundled/com/components/moderation-alert-index.ts #, javascript-format msgid "" diff --git a/sith/settings.py b/sith/settings.py index e948e971..7e3ef14f 100644 --- a/sith/settings.py +++ b/sith/settings.py @@ -78,10 +78,12 @@ DEBUG = env.bool("SITH_DEBUG", default=False) TESTING = "pytest" in sys.modules INTERNAL_IPS = ["127.0.0.1"] +HTTPS = env.bool("HTTPS", default=True) + # force csrf tokens and cookies to be secure when in https -CSRF_COOKIE_SECURE = env.bool("HTTPS", default=True) +CSRF_COOKIE_SECURE = HTTPS CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=[]) -SESSION_COOKIE_SECURE = env.bool("HTTPS", default=True) +SESSION_COOKIE_SECURE = HTTPS X_FRAME_OPTIONS = "SAMEORIGIN" ALLOWED_HOSTS = ["*"] From 2e1a849aff54c8410c83cb02c64c774a1055f688 Mon Sep 17 00:00:00 2001 From: Kenneth SOARES Date: Mon, 7 Apr 2025 19:15:16 +0200 Subject: [PATCH 06/26] modification du style du tooltip --- .../com/components/ics-calendar-index.ts | 4 +-- com/static/com/components/ics-calendar.scss | 35 ++++++++++++++++++- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/com/static/bundled/com/components/ics-calendar-index.ts b/com/static/bundled/com/components/ics-calendar-index.ts index 2befc97e..ea1cadc5 100644 --- a/com/static/bundled/com/components/ics-calendar-index.ts +++ b/com/static/bundled/com/components/ics-calendar-index.ts @@ -310,7 +310,7 @@ export class IcsCalendar extends inheritHtmlElement("div") { const button = event.target as HTMLButtonElement; button.classList.add("text-copy"); button.setAttribute( - "tooltip", + "data-tooltip", gettext("Calendar link copied to the clipboard"), ); navigator.clipboard.writeText( @@ -323,7 +323,7 @@ export class IcsCalendar extends inheritHtmlElement("div") { button.classList.remove("text-copied"); button.classList.add("text-copied"); button.classList.remove("text-copy"); - button.removeAttribute("tooltip"); + button.removeAttribute("data-tooltip"); }, 700); }, }, diff --git a/com/static/com/components/ics-calendar.scss b/com/static/com/components/ics-calendar.scss index bb0fea47..e0047842 100644 --- a/com/static/com/components/ics-calendar.scss +++ b/com/static/com/components/ics-calendar.scss @@ -22,6 +22,8 @@ ics-calendar { border: none; box-shadow: none; + overflow: visible; + z-index: 1; #event-details { z-index: 10; @@ -106,7 +108,7 @@ ics-calendar { button.text-copy, button.text-copy:focus, button.text-copy:hover { - background-color: green !important; + background-color: #67AE6E !important; transition: 200ms linear; } @@ -115,4 +117,35 @@ ics-calendar { button.text-copied:hover { transition: 200ms linear; } + + + + /* Tooltip styles */ + button[data-tooltip] { + position: relative; + cursor: pointer; + } + + button[data-tooltip]::after { + font-size: 10px; + content: attr(data-tooltip); + position: absolute; + bottom: 120%; /* Adjust this value to position the tooltip above the button */ + left: 50%; + transform: translateX(-50%); + background-color: #333; + color: #fff; + padding: 5px 10px; + border-radius: 5px; + white-space: nowrap; + opacity: 0; + transition: opacity 0.3s; + pointer-events: none; + z-index: 99999; + } + + button[data-tooltip]:hover::after, + button[data-tooltip]:focus::after { + opacity: 1; + } } \ No newline at end of file From 5de05c036058f8399ab45074062f177a263e9dc7 Mon Sep 17 00:00:00 2001 From: Sli Date: Tue, 8 Apr 2025 10:12:05 +0200 Subject: [PATCH 07/26] Introduce position attributes for tooltips --- .../com/components/ics-calendar-index.ts | 6 ++- com/static/com/components/ics-calendar.scss | 33 +--------------- core/static/core/style.scss | 39 +++++++++++++++---- 3 files changed, 38 insertions(+), 40 deletions(-) diff --git a/com/static/bundled/com/components/ics-calendar-index.ts b/com/static/bundled/com/components/ics-calendar-index.ts index ea1cadc5..1373c517 100644 --- a/com/static/bundled/com/components/ics-calendar-index.ts +++ b/com/static/bundled/com/components/ics-calendar-index.ts @@ -310,9 +310,10 @@ export class IcsCalendar extends inheritHtmlElement("div") { const button = event.target as HTMLButtonElement; button.classList.add("text-copy"); button.setAttribute( - "data-tooltip", + "tooltip", gettext("Calendar link copied to the clipboard"), ); + button.setAttribute("position", "top"); navigator.clipboard.writeText( new URL( await makeUrl(calendarCalendarInternal), @@ -323,7 +324,8 @@ export class IcsCalendar extends inheritHtmlElement("div") { button.classList.remove("text-copied"); button.classList.add("text-copied"); button.classList.remove("text-copy"); - button.removeAttribute("data-tooltip"); + button.removeAttribute("tooltip"); + button.removeAttribute("position"); }, 700); }, }, diff --git a/com/static/com/components/ics-calendar.scss b/com/static/com/components/ics-calendar.scss index e0047842..9cb5638e 100644 --- a/com/static/com/components/ics-calendar.scss +++ b/com/static/com/components/ics-calendar.scss @@ -22,8 +22,6 @@ ics-calendar { border: none; box-shadow: none; - overflow: visible; - z-index: 1; #event-details { z-index: 10; @@ -118,34 +116,7 @@ ics-calendar { transition: 200ms linear; } - - - /* Tooltip styles */ - button[data-tooltip] { - position: relative; - cursor: pointer; - } - - button[data-tooltip]::after { - font-size: 10px; - content: attr(data-tooltip); - position: absolute; - bottom: 120%; /* Adjust this value to position the tooltip above the button */ - left: 50%; - transform: translateX(-50%); - background-color: #333; - color: #fff; - padding: 5px 10px; - border-radius: 5px; - white-space: nowrap; - opacity: 0; - transition: opacity 0.3s; - pointer-events: none; - z-index: 99999; - } - - button[data-tooltip]:hover::after, - button[data-tooltip]:focus::after { - opacity: 1; + [tooltip]::before { + font-size: 10px; // this will overflow otherwise } } \ No newline at end of file diff --git a/core/static/core/style.scss b/core/static/core/style.scss index f064332a..9ee96b7e 100644 --- a/core/static/core/style.scss +++ b/core/static/core/style.scss @@ -51,26 +51,51 @@ body { [tooltip]::before { @include shadow; - opacity: 0; z-index: 1; + pointer-events: none; content: attr(tooltip); - background: hsl(219.6, 20.8%, 96%); - color: $black-color; + left: 50%; + transform: translateX(-50%); + background-color: #333; + color: #fff; border: 0.5px solid hsl(0, 0%, 50%); - ; border-radius: 5px; - padding: 5px; - top: 1em; + padding: 5px 10px; position: absolute; - margin-top: 5px; white-space: nowrap; + opacity: 0; transition: opacity 500ms ease-out; + top: 120%; // Put the tooltip under the element } [tooltip]:hover::before { opacity: 1; } +[position="top"][tooltip]::before { + top: initial; + bottom: 120%; +} + +[position="bottom"][tooltip]::before { + top: 120%; + bottom: initial; +} + +[position="left"][tooltip]::before { + top: initial; + bottom: 0%; + left: initial; + right: 65%; +} + +[position="right"][tooltip]::before { + top: initial; + bottom: 0%; + left: 150%; + right: initial; +} + .ib { display: inline-block; padding: 1px; From 8a381aed3839ca8c5e48c05d95574f47a2490014 Mon Sep 17 00:00:00 2001 From: Sli Date: Tue, 8 Apr 2025 11:54:19 +0200 Subject: [PATCH 08/26] Smooth animation --- .../com/components/ics-calendar-index.ts | 37 +++++++++++++------ com/static/com/components/ics-calendar.scss | 9 +++-- core/static/core/style.scss | 6 +++ locale/fr/LC_MESSAGES/djangojs.po | 6 +-- 4 files changed, 39 insertions(+), 19 deletions(-) diff --git a/com/static/bundled/com/components/ics-calendar-index.ts b/com/static/bundled/com/components/ics-calendar-index.ts index 1373c517..09336db5 100644 --- a/com/static/bundled/com/components/ics-calendar-index.ts +++ b/com/static/bundled/com/components/ics-calendar-index.ts @@ -44,7 +44,18 @@ export class IcsCalendar extends inheritHtmlElement("div") { return this.isMobile() ? "listMonth" : "dayGridMonth"; } - currentToolbar() { + currentFooterToolbar() { + if (this.isMobile()) { + return { + start: "", + center: "getCalendarLink", + end: "", + }; + } + return { start: "getCalendarLink", center: "", end: "" }; + } + + currentHeaderToolbar() { if (this.isMobile()) { return { left: "prev,next", @@ -309,11 +320,14 @@ export class IcsCalendar extends inheritHtmlElement("div") { click: async (event: Event) => { const button = event.target as HTMLButtonElement; button.classList.add("text-copy"); - button.setAttribute( - "tooltip", - gettext("Calendar link copied to the clipboard"), - ); - button.setAttribute("position", "top"); + if (!button.hasAttribute("position")) { + button.setAttribute("tooltip", gettext("Link copied")); + button.setAttribute("position", "top"); + button.setAttribute("no-hover", ""); + } + if (button.classList.contains("text-copied")) { + button.classList.remove("text-copied"); + } navigator.clipboard.writeText( new URL( await makeUrl(calendarCalendarInternal), @@ -324,21 +338,20 @@ export class IcsCalendar extends inheritHtmlElement("div") { button.classList.remove("text-copied"); button.classList.add("text-copied"); button.classList.remove("text-copy"); - button.removeAttribute("tooltip"); - button.removeAttribute("position"); - }, 700); + }, 1500); }, }, }, height: "auto", locale: this.locale, initialView: this.currentView(), - headerToolbar: this.currentToolbar(), - footerToolbar: { start: "getCalendarLink" }, + headerToolbar: this.currentHeaderToolbar(), + footerToolbar: this.currentFooterToolbar(), eventSources: await this.getEventSources(), windowResize: () => { this.calendar.changeView(this.currentView()); - this.calendar.setOption("headerToolbar", this.currentToolbar()); + this.calendar.setOption("headerToolbar", this.currentHeaderToolbar()); + this.calendar.setOption("footerToolbar", this.currentFooterToolbar()); }, eventClick: (event) => { // Avoid our popup to be deleted because we clicked outside of it diff --git a/com/static/com/components/ics-calendar.scss b/com/static/com/components/ics-calendar.scss index 9cb5638e..49713f82 100644 --- a/com/static/com/components/ics-calendar.scss +++ b/com/static/com/components/ics-calendar.scss @@ -107,16 +107,17 @@ ics-calendar { button.text-copy:focus, button.text-copy:hover { background-color: #67AE6E !important; - transition: 200ms linear; + transition: 500ms ease-in; } button.text-copied, button.text-copied:focus, button.text-copied:hover { - transition: 200ms linear; + transition: 500ms ease-out; } - [tooltip]::before { - font-size: 10px; // this will overflow otherwise + button.text-copied[tooltip]::before { + opacity: 0; + transition: opacity 500ms ease-out; } } \ No newline at end of file diff --git a/core/static/core/style.scss b/core/static/core/style.scss index 9ee96b7e..a87ed3a3 100644 --- a/core/static/core/style.scss +++ b/core/static/core/style.scss @@ -70,6 +70,12 @@ body { [tooltip]:hover::before { opacity: 1; + transition: opacity 500ms ease-in; +} + +[no-hover][tooltip]::before { + opacity: 1; + transition: opacity 500ms ease-in; } [position="top"][tooltip]::before { diff --git a/locale/fr/LC_MESSAGES/djangojs.po b/locale/fr/LC_MESSAGES/djangojs.po index e078509a..3693c144 100644 --- a/locale/fr/LC_MESSAGES/djangojs.po +++ b/locale/fr/LC_MESSAGES/djangojs.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-04-07 13:56+0200\n" +"POT-Creation-Date: 2025-04-08 11:42+0200\n" "PO-Revision-Date: 2024-09-17 11:54+0200\n" "Last-Translator: Sli \n" "Language-Team: AE info \n" @@ -38,8 +38,8 @@ msgid "Copy calendar link" msgstr "Copier le lien du calendrier" #: com/static/bundled/com/components/ics-calendar-index.ts -msgid "Calendar link copied to the clipboard" -msgstr "Lien du calendrier copié dans le presse papier" +msgid "Link copied" +msgstr "Lien copié" #: com/static/bundled/com/components/moderation-alert-index.ts #, javascript-format From fab0d19eeb982025921736a020c77e84ed06783f Mon Sep 17 00:00:00 2001 From: Sli Date: Tue, 8 Apr 2025 15:12:19 +0200 Subject: [PATCH 09/26] Fix news x overflow on mobile --- com/static/com/css/news-list.scss | 3 +++ core/static/core/markdown.scss | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/com/static/com/css/news-list.scss b/com/static/com/css/news-list.scss index 40da2157..cc423ccc 100644 --- a/com/static/com/css/news-list.scss +++ b/com/static/com/css/news-list.scss @@ -56,9 +56,11 @@ #upcoming-events { max-height: 600px; overflow-y: scroll; + overflow-x: clip; #load-more-news-button { text-align: center; + button { width: 150px; } @@ -194,6 +196,7 @@ img { height: 75px; } + .header_content { display: flex; flex-direction: column; diff --git a/core/static/core/markdown.scss b/core/static/core/markdown.scss index 86778e46..098d86be 100644 --- a/core/static/core/markdown.scss +++ b/core/static/core/markdown.scss @@ -15,6 +15,7 @@ ol, p { line-height: 22px; + word-break: break-word; } code { @@ -71,7 +72,8 @@ a:hover { text-decoration: underline; } + .footnotes { font-size: 85%; } -} +} \ No newline at end of file From 156305a16ad45d52c9a3703665e4ad7963327faa Mon Sep 17 00:00:00 2001 From: imperosol Date: Wed, 26 Mar 2025 15:06:41 +0100 Subject: [PATCH 10/26] add api endpoint to upload a sas picture --- core/utils.py | 14 +++++++ .../commands/generate_galaxy_test_data.py | 9 +---- sas/api.py | 40 ++++++++++++++++++- sas/tests/test_api.py | 27 ++++++++++++- 4 files changed, 79 insertions(+), 11 deletions(-) diff --git a/core/utils.py b/core/utils.py index 2f56cebf..d9e8f180 100644 --- a/core/utils.py +++ b/core/utils.py @@ -17,6 +17,7 @@ from datetime import date, timedelta # Image utils from io import BytesIO +from typing import Final import PIL from django.conf import settings @@ -26,6 +27,19 @@ from django.utils.timezone import localdate from PIL import ExifTags from PIL.Image import Image, Resampling +RED_PIXEL_PNG: Final[bytes] = ( + b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52" + b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90\x77\x53" + b"\xde\x00\x00\x00\x0c\x49\x44\x41\x54\x08\xd7\x63\xf8\xcf\xc0\x00" + b"\x00\x03\x01\x01\x00\x18\xdd\x8d\xb0\x00\x00\x00\x00\x49\x45\x4e" + b"\x44\xae\x42\x60\x82" +) +"""A single red pixel, in PNG format. + +Can be used in tests and in dev, when there is a need +to generate a dummy image that is considered valid nonetheless +""" + def get_start_of_semester(today: date | None = None) -> date: """Return the date of the start of the semester of the given date. diff --git a/galaxy/management/commands/generate_galaxy_test_data.py b/galaxy/management/commands/generate_galaxy_test_data.py index 0c5614d6..966697a2 100644 --- a/galaxy/management/commands/generate_galaxy_test_data.py +++ b/galaxy/management/commands/generate_galaxy_test_data.py @@ -32,17 +32,10 @@ from django.utils import timezone from club.models import Club, Membership from core.models import Group, Page, SithFile, User +from core.utils import RED_PIXEL_PNG from sas.models import Album, PeoplePictureRelation, Picture from subscription.models import Subscription -RED_PIXEL_PNG: Final[bytes] = ( - b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52" - b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90\x77\x53" - b"\xde\x00\x00\x00\x0c\x49\x44\x41\x54\x08\xd7\x63\xf8\xcf\xc0\x00" - b"\x00\x03\x01\x01\x00\x18\xdd\x8d\xb0\x00\x00\x00\x00\x49\x45\x4e" - b"\x44\xae\x42\x60\x82" -) - USER_PACK_SIZE: Final[int] = 1000 diff --git a/sas/api.py b/sas/api.py index d9e2ad2e..2d6d64ef 100644 --- a/sas/api.py +++ b/sas/api.py @@ -1,7 +1,9 @@ from django.conf import settings +from django.core.exceptions import ValidationError from django.db.models import F from django.urls import reverse -from ninja import Query +from ninja import Body, Query, UploadedFile +from ninja.errors import HttpError from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra.exceptions import NotFound, PermissionDenied from ninja_extra.pagination import PageNumberPaginationExtra @@ -9,7 +11,13 @@ from ninja_extra.permissions import IsAuthenticated from ninja_extra.schemas import PaginatedResponseSchema from pydantic import NonNegativeInt -from core.auth.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot +from core.auth.api_permissions import ( + CanAccessLookup, + CanEdit, + CanView, + IsInGroup, + IsRoot, +) from core.models import Notification, User from sas.models import Album, PeoplePictureRelation, Picture from sas.schemas import ( @@ -92,6 +100,34 @@ class PicturesController(ControllerBase): .annotate(album=F("parent__name")) ) + @route.post( + "", + permissions=[CanEdit], + response={200: None, 409: dict[str, list[str]]}, + url_name="upload_picture", + ) + def upload_picture(self, album_id: Body[int], picture: UploadedFile): + album = self.get_object_or_exception(Album, pk=album_id) + user = self.context.request.user + self_moderate = user.has_perm("sas.moderate_sasfile") + new = Picture( + parent=album, + name=picture.name, + file=picture, + owner=user, + is_moderated=self_moderate, + is_folder=False, + mime_type=picture.content_type, + ) + if self_moderate: + new.moderator = user + try: + new.generate_thumbnails() + new.full_clean() + new.save() + except ValidationError as e: + raise HttpError(status_code=409, message=str(e)) from e + @route.get( "/{picture_id}/identified", permissions=[IsAuthenticated, CanView], diff --git a/sas/tests/test_api.py b/sas/tests/test_api.py index 25014e86..2570a3c6 100644 --- a/sas/tests/test_api.py +++ b/sas/tests/test_api.py @@ -1,13 +1,16 @@ +import pytest from django.conf import settings from django.core.cache import cache +from django.core.files.uploadedfile import SimpleUploadedFile from django.db import transaction -from django.test import TestCase +from django.test import Client, TestCase from django.urls import reverse from model_bakery import baker from model_bakery.recipe import Recipe from core.baker_recipes import old_subscriber_user, subscriber_user from core.models import Group, SithFile, User +from core.utils import RED_PIXEL_PNG from sas.baker_recipes import picture_recipe from sas.models import Album, PeoplePictureRelation, Picture, PictureModerationRequest @@ -241,3 +244,25 @@ class TestAlbumSearch(TestSas): # - 1 for pagination # - 1 for the actual results self.client.get(reverse("api:search-album")) + + +@pytest.mark.django_db +def test_upload_picture(client: Client): + sas = SithFile.objects.get(pk=settings.SITH_SAS_ROOT_DIR_ID) + album = baker.make(Album, is_in_sas=True, parent=sas, name="test album") + user = baker.make(User, is_superuser=True) + client.force_login(user) + img = SimpleUploadedFile( + name="img.png", content=RED_PIXEL_PNG, content_type="image/png" + ) + res = client.post( + reverse("api:upload_picture"), {"album_id": album.id, "picture": img} + ) + assert res.status_code == 200 + picture = Picture.objects.filter(parent_id=album.id).first() + assert picture is not None + assert picture.name == "img.png" + assert picture.owner == user + assert picture.file.name == "SAS/test album/img.png" + assert picture.compressed.name == ".compressed/SAS/test album/img.webp" + assert picture.thumbnail.name == ".thumbnails/SAS/test album/img.webp" From b83fbf91e16f248d5dd63c8502ac6c1207704c0f Mon Sep 17 00:00:00 2001 From: imperosol Date: Sat, 29 Mar 2025 18:19:05 +0100 Subject: [PATCH 11/26] extract album creation form into its own fragment --- sas/forms.py | 70 ++++-------- sas/templates/sas/album.jinja | 21 +++- .../sas/fragments/album_create_form.jinja | 18 +++ sas/templates/sas/main.jinja | 17 +-- sas/urls.py | 2 + sas/views.py | 105 +++++++++--------- 6 files changed, 111 insertions(+), 122 deletions(-) create mode 100644 sas/templates/sas/fragments/album_create_form.jinja diff --git a/sas/forms.py b/sas/forms.py index d987aaf1..71dedd7d 100644 --- a/sas/forms.py +++ b/sas/forms.py @@ -1,6 +1,7 @@ from typing import Any from django import forms +from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ from core.models import User @@ -11,55 +12,28 @@ from sas.models import Album, Picture, PictureModerationRequest from sas.widgets.ajax_select import AutoCompleteSelectAlbum -class SASForm(forms.Form): - album_name = forms.CharField( - label=_("Add a new album"), max_length=Album.NAME_MAX_LENGTH, required=False - ) - images = MultipleImageField( - label=_("Upload images"), - required=False, - ) +class AlbumCreateForm(forms.ModelForm): + class Meta: + model = Album + fields = ["name", "parent"] + labels = {"name": _("Add a new album")} + widgets = {"parent": forms.HiddenInput} - def process(self, parent, owner, files, *, automodere=False): - try: - if self.cleaned_data["album_name"] != "": - album = Album( - parent=parent, - name=self.cleaned_data["album_name"], - owner=owner, - is_moderated=automodere, - ) - album.clean() - album.save() - except Exception as e: - self.add_error( - None, - _("Error creating album %(album)s: %(msg)s") - % {"album": self.cleaned_data["album_name"], "msg": repr(e)}, - ) - for f in files: - new_file = Picture( - parent=parent, - name=f.name, - file=f, - owner=owner, - mime_type=f.content_type, - size=f.size, - is_folder=False, - is_moderated=automodere, - ) - if automodere: - new_file.moderator = owner - try: - new_file.clean() - new_file.generate_thumbnails() - new_file.save() - except Exception as e: - self.add_error( - None, - _("Error uploading file %(file_name)s: %(msg)s") - % {"file_name": f, "msg": repr(e)}, - ) + def __init__(self, *args, owner: User, **kwargs): + super().__init__(*args, **kwargs) + self.instance.owner = owner + if owner.has_perm("sas.moderate_sasfile"): + self.instance.is_moderated = True + self.instance.moderator = owner + + def clean(self): + if not self.instance.owner.can_edit(self.instance.parent): + raise ValidationError(_("You do not have the permission to do that")) + return super().clean() + + +class PictureUploadForm(forms.Form): + images = MultipleImageField(label=_("Upload images"), required=False) class PictureEditForm(forms.ModelForm): diff --git a/sas/templates/sas/album.jinja b/sas/templates/sas/album.jinja index 6c2cbcf7..677552c1 100644 --- a/sas/templates/sas/album.jinja +++ b/sas/templates/sas/album.jinja @@ -110,13 +110,28 @@ {% if is_sas_admin %} -
+ {{ album_create_fragment }} + {% csrf_token %}
- {{ form.as_p() }} - +

+ + {{ upload_form.images|add_attr("x-ref=pictures") }} + {{ upload_form.images.help_text }} +

+
+
    + +
{% endif %} diff --git a/sas/templates/sas/fragments/album_create_form.jinja b/sas/templates/sas/fragments/album_create_form.jinja new file mode 100644 index 00000000..4369bff6 --- /dev/null +++ b/sas/templates/sas/fragments/album_create_form.jinja @@ -0,0 +1,18 @@ +
+ {% csrf_token %} +
+
+ + {{ form.name }} +
+ {{ form.parent }} + +
+ {{ form.non_field_errors() }} + {{ form.name.errors }} +
\ No newline at end of file diff --git a/sas/templates/sas/main.jinja b/sas/templates/sas/main.jinja index 98dc9f87..5fa82a45 100644 --- a/sas/templates/sas/main.jinja +++ b/sas/templates/sas/main.jinja @@ -61,23 +61,8 @@ {% if is_sas_admin %} -
- -
- {% csrf_token %} - -
-
- - {{ form.album_name }} -
- -
- - {{ form.non_field_errors() }} - {{ form.album_name.errors }} -
+ {{ album_create_fragment }} {% endif %} {% endif %} diff --git a/sas/urls.py b/sas/urls.py index 5fb57ccf..9a5435d2 100644 --- a/sas/urls.py +++ b/sas/urls.py @@ -16,6 +16,7 @@ from django.urls import path from sas.views import ( + AlbumCreateFragment, AlbumEditView, AlbumUploadView, AlbumView, @@ -59,4 +60,5 @@ urlpatterns = [ path( "user//pictures/", UserPicturesView.as_view(), name="user_pictures" ), + path("fragment/album-create", AlbumCreateFragment.as_view(), name="album_create"), ] diff --git a/sas/views.py b/sas/views.py index 74a34816..799258a8 100644 --- a/sas/views.py +++ b/sas/views.py @@ -36,28 +36,40 @@ from sas.forms import ( from sas.models import Album, Picture -class SASMainView(FormView): - form_class = SASForm - template_name = "sas/main.jinja" - success_url = reverse_lazy("sas:main") +class AlbumCreateFragment(FragmentMixin, CreateView): + model = Album + form_class = AlbumCreateForm + template_name = "sas/fragments/album_create_form.jinja" + reload_on_redirect = True - def post(self, request, *args, **kwargs): - self.form = self.get_form() - parent = SithFile.objects.filter(id=settings.SITH_SAS_ROOT_DIR_ID).first() - files = request.FILES.getlist("images") - root = User.objects.filter(username="root").first() - if request.user.is_authenticated and request.user.is_in_group( - pk=settings.SITH_GROUP_SAS_ADMIN_ID - ): - if self.form.is_valid(): - self.form.process( - parent=parent, owner=root, files=files, automodere=True - ) - if self.form.is_valid(): - return super().form_valid(self.form) - else: - self.form.add_error(None, _("You do not have the permission to do that")) - return self.form_invalid(self.form) + def get_form_kwargs(self): + return super().get_form_kwargs() | {"owner": self.request.user} + + def render_fragment( + self, request, owner: User | None = None, **kwargs + ) -> SafeString: + self.object = None + self.owner = owner or self.request.user + return super().render_fragment(request, **kwargs) + + def get_success_url(self): + parent = self.object.parent + parent.__class__ = Album + return parent.get_absolute_url() + + +class SASMainView(UseFragmentsMixin, TemplateView): + template_name = "sas/main.jinja" + + def get_fragments(self) -> dict[str, FragmentRenderer]: + form_init = {"parent": SithFile.objects.get(id=settings.SITH_SAS_ROOT_DIR_ID)} + return { + "album_create_fragment": AlbumCreateFragment.as_fragment(initial=form_init) + } + + def get_fragment_data(self) -> dict[str, dict[str, Any]]: + root_user = User.objects.get(pk=settings.SITH_ROOT_USER_ID) + return {"album_create_fragment": {"owner": root_user}} def get_context_data(self, **kwargs): kwargs = super().get_context_data(**kwargs) @@ -130,62 +142,45 @@ class AlbumUploadView(CanViewMixin, DetailView, FormMixin): return HttpResponse(str(self.form.errors), status=200) return HttpResponse(str(self.form.errors), status=500) - -class AlbumView(CanViewMixin, DetailView, FormMixin): +class AlbumView(CanViewMixin, UseFragmentsMixin, DetailView): model = Album - form_class = SASForm pk_url_kwarg = "album_id" template_name = "sas/album.jinja" + def get_fragments(self) -> dict[str, FragmentRenderer]: + return { + "album_create_fragment": AlbumCreateFragment.as_fragment( + initial={"parent": self.object} + ) + } + def dispatch(self, request, *args, **kwargs): try: self.asked_page = int(request.GET.get("page", 1)) except ValueError as e: raise Http404 from e - return super().dispatch(request, *args, **kwargs) - - def get(self, request, *args, **kwargs): - self.form = self.get_form() if "clipboard" not in request.session: request.session["clipboard"] = [] - return super().get(request, *args, **kwargs) + return super().dispatch(request, *args, **kwargs) def post(self, request, *args, **kwargs): self.object = self.get_object() if not self.object.file: self.object.generate_thumbnail() - self.form = self.get_form() - if "clipboard" not in request.session: - request.session["clipboard"] = [] if request.user.can_edit(self.object): # Handle the copy-paste functions FileView.handle_clipboard(request, self.object) - parent = SithFile.objects.filter(id=self.object.id).first() - files = request.FILES.getlist("images") - if request.user.is_authenticated and request.user.is_subscribed: - if self.form.is_valid(): - self.form.process( - parent=parent, - owner=request.user, - files=files, - automodere=request.user.is_in_group( - pk=settings.SITH_GROUP_SAS_ADMIN_ID - ), - ) - if self.form.is_valid(): - return super().form_valid(self.form) - else: - self.form.add_error(None, _("You do not have the permission to do that")) - return self.form_invalid(self.form) + return HttpResponseRedirect(self.request.path) - def get_success_url(self): - return reverse("sas:album", kwargs={"album_id": self.object.id}) + def get_fragment_data(self) -> dict[str, dict[str, Any]]: + return {"album_create_fragment": {"owner": self.request.user}} def get_context_data(self, **kwargs): kwargs = super().get_context_data(**kwargs) - kwargs["form"] = self.form - kwargs["clipboard"] = SithFile.objects.filter( - id__in=self.request.session["clipboard"] - ) + if ids := self.request.session.get("clipboard", None): + kwargs["clipboard"] = SithFile.objects.filter(id__in=ids) + kwargs["upload_form"] = PictureUploadForm() + # if True, the albums will be fetched with a request to the API + # if False, the section won't be displayed at all kwargs["show_albums"] = ( Album.objects.viewable_by(self.request.user) .filter(parent_id=self.object.id) From 13f417ba307d3620511f61861b14e47524295c69 Mon Sep 17 00:00:00 2001 From: imperosol Date: Sat, 29 Mar 2025 18:19:58 +0100 Subject: [PATCH 12/26] Use Alpine and the API for SAS picture upload --- core/management/commands/populate.py | 3 +- sas/models.py | 3 +- sas/static/bundled/sas/album-index.ts | 37 ++++++++ sas/templates/sas/album.jinja | 116 +------------------------- sas/urls.py | 4 - sas/views.py | 41 ++------- 6 files changed, 52 insertions(+), 152 deletions(-) diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py index f7843989..0e41cc93 100644 --- a/core/management/commands/populate.py +++ b/core/management/commands/populate.py @@ -740,8 +740,9 @@ Welcome to the wiki page! size=file.size, ) pict.file.name = p.name - pict.clean() + pict.full_clean() pict.generate_thumbnails() + pict.save() img_skia = Picture.objects.get(name="skia.jpg") img_sli = Picture.objects.get(name="sli.jpg") diff --git a/sas/models.py b/sas/models.py index e2b8867a..4f3ff21e 100644 --- a/sas/models.py +++ b/sas/models.py @@ -134,7 +134,6 @@ class Picture(SasFile): self.thumbnail.name = new_extension_name self.compressed = compressed self.compressed.name = new_extension_name - self.save() def rotate(self, degree): for attr in ["file", "compressed", "thumbnail"]: @@ -235,6 +234,8 @@ class Album(SasFile): return Album.objects.filter(parent=self) def get_absolute_url(self): + if self.id == settings.SITH_SAS_ROOT_DIR_ID: + return reverse("sas:main") return reverse("sas:album", kwargs={"album_id": self.id}) def get_download_url(self): diff --git a/sas/static/bundled/sas/album-index.ts b/sas/static/bundled/sas/album-index.ts index 6dda1ce9..b2a706d5 100644 --- a/sas/static/bundled/sas/album-index.ts +++ b/sas/static/bundled/sas/album-index.ts @@ -7,6 +7,7 @@ import { type PicturesFetchPicturesData, albumFetchAlbum, picturesFetchPictures, + picturesUploadPicture, } from "#openapi"; interface AlbumPicturesConfig { @@ -78,4 +79,40 @@ document.addEventListener("alpine:init", () => { this.loading = false; }, })); + + Alpine.data("pictureUpload", (albumId: number) => ({ + errors: [] as string[], + pictures: [], + sending: false, + progress: null as HTMLProgressElement, + + init() { + this.progress = this.$refs.progress; + }, + + async sendPictures() { + const input = this.$refs.pictures as HTMLInputElement; + const files = input.files; + this.progress.value = 0; + this.progress.max = files.length; + this.sending = true; + for (const file of files) { + await this.sendPicture(file); + } + this.sending = false; + // This should trigger a reload of the pictures of the `picture` Alpine data + this.$dispatch("pictures-upload-done"); + }, + + async sendPicture(file: File) { + const res = await picturesUploadPicture({ + // biome-ignore lint/style/useNamingConvention: api is snake_case + body: { album_id: albumId, picture: file }, + }); + if (!res.response.ok) { + this.errors.push(`${file.name} : ${res.error.detail}`); + } + this.progress.value += 1; + }, + })); }); diff --git a/sas/templates/sas/album.jinja b/sas/templates/sas/album.jinja index 677552c1..18cd6f21 100644 --- a/sas/templates/sas/album.jinja +++ b/sas/templates/sas/album.jinja @@ -73,7 +73,7 @@
{% trans %}To be moderated{% endtrans %}
- {% if edit_mode %} + {% if is_sas_admin %} {% endif %} @@ -86,7 +86,7 @@

{% trans %}Pictures{% endtrans %}


{{ download_button(_("Download album")) }} -
+