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..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", @@ -303,14 +314,44 @@ 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"); + 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), + window.location.origin, + ).toString(), + ); + setTimeout(() => { + button.classList.remove("text-copied"); + button.classList.add("text-copied"); + button.classList.remove("text-copy"); + }, 1500); + }, + }, + }, height: "auto", locale: this.locale, initialView: this.currentView(), - headerToolbar: this.currentToolbar(), + 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 6c86cce0..49713f82 100644 --- a/com/static/com/components/ics-calendar.scss +++ b/com/static/com/components/ics-calendar.scss @@ -98,4 +98,26 @@ 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: #67AE6E !important; + transition: 500ms ease-in; + } + + button.text-copied, + button.text-copied:focus, + button.text-copied:hover { + transition: 500ms ease-out; + } + + button.text-copied[tooltip]::before { + opacity: 0; + transition: opacity 500ms ease-out; + } } \ No newline at end of file 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/admin.py b/core/admin.py index 5de89ada..eff77817 100644 --- a/core/admin.py +++ b/core/admin.py @@ -17,7 +17,16 @@ from django.contrib import admin from django.contrib.auth.models import Group as AuthGroup from django.contrib.auth.models import Permission -from core.models import BanGroup, Group, OperationLog, Page, SithFile, User, UserBan +from core.models import ( + BanGroup, + Group, + OperationLog, + Page, + QuickUploadImage, + SithFile, + User, + UserBan, +) admin.site.unregister(AuthGroup) @@ -89,3 +98,11 @@ class OperationLogAdmin(admin.ModelAdmin): list_display = ("label", "operator", "operation_type", "date") search_fields = ("label", "date", "operation_type") autocomplete_fields = ("operator",) + + +@admin.register(QuickUploadImage) +class QuickUploadImageAdmin(admin.ModelAdmin): + list_display = ("uuid", "uploader", "created_at", "name") + search_fields = ("uuid", "uploader", "name") + autocomplete_fields = ("uploader",) + readonly_fields = ("width", "height", "size") diff --git a/core/api.py b/core/api.py index e1b3bbbd..830e06e9 100644 --- a/core/api.py +++ b/core/api.py @@ -1,23 +1,25 @@ -from typing import Annotated +from typing import Annotated, Any, Literal import annotated_types from django.conf import settings from django.db.models import F from django.http import HttpResponse -from ninja import Query +from ninja import File, Query from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra.exceptions import PermissionDenied from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.schemas import PaginatedResponseSchema from club.models import Mailing -from core.auth.api_permissions import CanAccessLookup, CanView -from core.models import Group, SithFile, User +from core.auth.api_permissions import CanAccessLookup, CanView, HasPerm +from core.models import Group, QuickUploadImage, SithFile, User from core.schemas import ( FamilyGodfatherSchema, GroupSchema, MarkdownSchema, SithFileSchema, + UploadedFileSchema, + UploadedImage, UserFamilySchema, UserFilterSchema, UserProfileSchema, @@ -33,6 +35,25 @@ class MarkdownController(ControllerBase): return HttpResponse(markdown(body.text), content_type="text/html") +@api_controller("/upload") +class UploadController(ControllerBase): + @route.post( + "/image", + response={ + 200: UploadedFileSchema, + 422: dict[Literal["detail"], list[dict[str, Any]]], + 403: dict[Literal["detail"], str], + }, + permissions=[HasPerm("core.add_quickuploadimage")], + url_name="quick_upload_image", + ) + def upload_image(self, file: File[UploadedImage]): + image = QuickUploadImage.create_from_uploaded( + file, uploader=self.context.request.user + ) + return image + + @api_controller("/mailings") class MailingListController(ControllerBase): @route.get("", response=str) diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py index f7843989..6bcc0e78 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") @@ -827,6 +828,7 @@ Welcome to the wiki page! "view_peoplepicturerelation", "add_peoplepicturerelation", "add_page", + "add_quickuploadimage", ] ) ) diff --git a/core/migrations/0045_quickuploadimage.py b/core/migrations/0045_quickuploadimage.py new file mode 100644 index 00000000..9464848c --- /dev/null +++ b/core/migrations/0045_quickuploadimage.py @@ -0,0 +1,56 @@ +# Generated by Django 4.2.20 on 2025-04-10 09:34 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0044_alter_userban_options"), + ] + + operations = [ + migrations.CreateModel( + name="QuickUploadImage", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("uuid", models.UUIDField(db_index=True, unique=True)), + ("name", models.CharField(max_length=100)), + ( + "image", + models.ImageField( + height_field="height", + unique=True, + upload_to="upload/%Y/%m/%d", + width_field="width", + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="created at"), + ), + ("width", models.PositiveIntegerField(verbose_name="width")), + ("height", models.PositiveIntegerField(verbose_name="height")), + ("size", models.PositiveIntegerField(verbose_name="size")), + ( + "uploader", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="quick_uploads", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/core/models.py b/core/models.py index 7f42e83d..5c873c6c 100644 --- a/core/models.py +++ b/core/models.py @@ -17,7 +17,7 @@ # details. # # You should have received a copy of the GNU General Public License along with -# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple +# this program; if not, write to the Free Software Foundation, Inc., 59 Temple # Place - Suite 330, Boston, MA 02111-1307, USA. # # @@ -32,6 +32,7 @@ from datetime import timedelta from io import BytesIO from pathlib import Path from typing import TYPE_CHECKING, Optional, Self +from uuid import uuid4 from django.conf import settings from django.contrib.auth.models import AbstractUser, UserManager @@ -41,6 +42,8 @@ from django.contrib.staticfiles.storage import staticfiles_storage from django.core import validators from django.core.cache import cache from django.core.exceptions import PermissionDenied, ValidationError +from django.core.files import File +from django.core.files.base import ContentFile from django.core.mail import send_mail from django.db import models, transaction from django.db.models import Exists, F, OuterRef, Q @@ -51,9 +54,10 @@ from django.utils.html import escape from django.utils.timezone import localdate, now from django.utils.translation import gettext_lazy as _ from phonenumber_field.modelfields import PhoneNumberField -from PIL import Image +from PIL import Image, ImageOps if TYPE_CHECKING: + from django.core.files.uploadedfile import UploadedFile from pydantic import NonNegativeInt from club.models import Club @@ -1102,6 +1106,68 @@ class SithFile(models.Model): return reverse("core:download", kwargs={"file_id": self.id}) +class QuickUploadImage(models.Model): + """Images uploaded by user outside of the SithFile mechanism""" + + IMAGE_NAME_SIZE = 100 + MAX_IMAGE_SIZE = 600 # Maximum px on width / length + + uuid = models.UUIDField(unique=True, db_index=True) + name = models.CharField(max_length=IMAGE_NAME_SIZE, blank=False) + image = models.ImageField( + upload_to="upload/%Y/%m/%d", + width_field="width", + height_field="height", + unique=True, + ) + uploader = models.ForeignKey( + "User", + related_name="quick_uploads", + null=True, + blank=True, + on_delete=models.SET_NULL, + ) + created_at = models.DateTimeField(_("created at"), auto_now_add=True) + width = models.PositiveIntegerField(_("width")) + height = models.PositiveIntegerField(_("height")) + size = models.PositiveIntegerField(_("size")) + + def __str__(self) -> str: + return str(self.image.path) + + def get_absolute_url(self): + return self.image.url + + @classmethod + def create_from_uploaded( + cls, image: UploadedFile, uploader: User | None = None + ) -> Self: + def convert_image(file: UploadedFile) -> ContentFile: + content = BytesIO() + image = Image.open(BytesIO(file.read())) + if image.width > cls.MAX_IMAGE_SIZE or image.height > cls.MAX_IMAGE_SIZE: + image = ImageOps.contain(image, (600, 600)) + image.save(fp=content, format="webp", optimize=True) + return ContentFile(content.getvalue()) + + identifier = str(uuid4()) + name = Path(image.name).stem[: cls.IMAGE_NAME_SIZE - 1] + file = File(convert_image(image), name=f"{identifier}.webp") + width, height = Image.open(file).size + + return cls.objects.create( + uuid=identifier, + name=name, + image=file, + uploader=uploader, + size=file.size, + ) + + def delete(self, *args, **kwargs): + self.image.delete(save=False) + return super().delete(*args, **kwargs) + + class LockError(Exception): """There was a lock error on the object.""" diff --git a/core/schemas.py b/core/schemas.py index f4080c90..b5f5991f 100644 --- a/core/schemas.py +++ b/core/schemas.py @@ -1,16 +1,29 @@ from pathlib import Path -from typing import Annotated +from typing import Annotated, Any from annotated_types import MinLen from django.contrib.staticfiles.storage import staticfiles_storage from django.db.models import Q from django.urls import reverse from django.utils.text import slugify +from django.utils.translation import gettext as _ from haystack.query import SearchQuerySet -from ninja import FilterSchema, ModelSchema, Schema +from ninja import FilterSchema, ModelSchema, Schema, UploadedFile from pydantic import AliasChoices, Field +from pydantic_core.core_schema import ValidationInfo -from core.models import Group, SithFile, User +from core.models import Group, QuickUploadImage, SithFile, User +from core.utils import is_image + + +class UploadedImage(UploadedFile): + @classmethod + def _validate(cls, v: Any, info: ValidationInfo) -> Any: + super()._validate(v, info) + if not is_image(v): + msg = _("This file is not a valid image") + raise ValueError(msg) + return v class SimpleUserSchema(ModelSchema): @@ -47,6 +60,18 @@ class UserProfileSchema(ModelSchema): return reverse("core:download", kwargs={"file_id": obj.profile_pict_id}) +class UploadedFileSchema(ModelSchema): + class Meta: + model = QuickUploadImage + fields = ["uuid", "name", "width", "height", "size"] + + href: str + + @staticmethod + def resolve_href(obj: QuickUploadImage) -> str: + return obj.get_absolute_url() + + class SithFileSchema(ModelSchema): class Meta: model = SithFile diff --git a/core/static/bundled/core/components/easymde-index.ts b/core/static/bundled/core/components/easymde-index.ts index d99799a0..09f08626 100644 --- a/core/static/bundled/core/components/easymde-index.ts +++ b/core/static/bundled/core/components/easymde-index.ts @@ -6,13 +6,58 @@ import { inheritHtmlElement, registerComponent } from "#core:utils/web-component import type CodeMirror from "codemirror"; // biome-ignore lint/style/useNamingConvention: This is how they called their namespace import EasyMDE from "easymde"; -import { markdownRenderMarkdown } from "#openapi"; +import { + type UploadUploadImageErrors, + markdownRenderMarkdown, + uploadUploadImage, +} from "#openapi"; const loadEasyMde = (textarea: HTMLTextAreaElement) => { - new EasyMDE({ + const easymde = new EasyMDE({ element: textarea, spellChecker: false, autoDownloadFontAwesome: false, + uploadImage: true, + imagePathAbsolute: false, + imageUploadFunction: async (file, onSuccess, onError) => { + const response = await uploadUploadImage({ + body: { + file: file, + }, + }); + if (!response.response.ok) { + if (response.response.status === 422) { + onError( + (response.error as UploadUploadImageErrors[422]).detail + .map((err: Record<"ctx", Record<"error", string>>) => err.ctx.error) + .join(" ; "), + ); + } else if (response.response.status === 403) { + onError(gettext("You are not authorized to use this feature")); + } else { + onError(gettext("Could not upload image")); + } + return; + } + onSuccess(response.data.href); + // Workaround function to add an image name to uploaded image + // Without this, you get ![](url) instead of ![name](url) + let cursor = easymde.codemirror.getCursor(); + + easymde.codemirror.setSelection({ + line: cursor.line, + ch: cursor.ch - response.data.href.length - 3, + }); + easymde.codemirror.replaceSelection(response.data.name); + + // Move cursor at the end of the url and add a new line + cursor = easymde.codemirror.getCursor(); + easymde.codemirror.setSelection({ + line: cursor.line, + ch: cursor.ch + response.data.href.length + 3, + }); + easymde.codemirror.replaceSelection("\n"); + }, previewRender: (plainText: string, preview: MarkdownInput) => { /* This is wrapped this way to allow time for Alpine to be loaded on the page */ return Alpine.debounce((plainText: string, preview: MarkdownInput) => { @@ -30,6 +75,14 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => { }, 300)(plainText, preview); }, forceSync: true, // Avoid validation error on generic create view + imageTexts: { + sbInit: gettext("Attach files by drag and dropping or pasting from clipboard."), + sbOnDragEnter: gettext("Drop image to upload it."), + sbOnDrop: gettext("Uploading image #images_names# …"), + sbProgress: gettext("Uploading #file_name#: #progress#%"), + sbOnUploaded: gettext("Uploaded #image_name#"), + sizeUnits: gettext(" B, KB, MB"), + }, toolbar: [ { name: "heading-smaller", @@ -120,6 +173,12 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => { className: "fa-regular fa-image", title: gettext("Insert image"), }, + { + name: "upload-image", + action: EasyMDE.drawUploadedImage, + className: "fa-solid fa-file-arrow-up", + title: gettext("Upload image"), + }, { name: "table", action: EasyMDE.drawTable, diff --git a/core/static/core/header.scss b/core/static/core/header.scss index 53b6887f..348d3796 100644 --- a/core/static/core/header.scss +++ b/core/static/core/header.scss @@ -33,8 +33,8 @@ $hovered-red-text-color: #ff4d4d; flex-direction: row; gap: 10px; - > a { - color: $text-color!important; + >a { + color: $text-color !important; } &:hover>a { @@ -207,7 +207,7 @@ $hovered-red-text-color: #ff4d4d; justify-content: flex-end; } - > a { + >a { display: block; min-width: 40px; height: 40px; @@ -251,11 +251,13 @@ $hovered-red-text-color: #ff4d4d; justify-content: flex-start; } - a, button { + a, + button { font-size: 100%; margin: 0; text-align: right; color: $text-color; + margin-top: auto; &:hover { color: $hovered-text-color; @@ -266,12 +268,14 @@ $hovered-red-text-color: #ff4d4d; margin: 0; display: inline; } + #logout-form button { color: $red-text-color; &:hover { color: $hovered-red-text-color; } + background: none; border: none; cursor: pointer; 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 diff --git a/core/static/core/style.scss b/core/static/core/style.scss index f064332a..a87ed3a3 100644 --- a/core/static/core/style.scss +++ b/core/static/core/style.scss @@ -51,24 +51,55 @@ 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; + transition: opacity 500ms ease-in; +} + +[no-hover][tooltip]::before { + opacity: 1; + transition: opacity 500ms ease-in; +} + +[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 { diff --git a/core/tests/test_files.py b/core/tests/test_files.py index 2f86507a..4afc4583 100644 --- a/core/tests/test_files.py +++ b/core/tests/test_files.py @@ -1,11 +1,12 @@ from io import BytesIO from itertools import cycle +from pathlib import Path from typing import Callable from uuid import uuid4 import pytest from django.core.cache import cache -from django.core.files.uploadedfile import SimpleUploadedFile +from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile from django.test import Client, TestCase from django.urls import reverse from model_bakery import baker @@ -14,7 +15,8 @@ from PIL import Image from pytest_django.asserts import assertNumQueries from core.baker_recipes import board_user, old_subscriber_user, subscriber_user -from core.models import Group, SithFile, User +from core.models import Group, QuickUploadImage, SithFile, User +from core.utils import RED_PIXEL_PNG from sas.models import Picture from sith import settings @@ -256,3 +258,89 @@ def test_apply_rights_recursively(): ): assert set(file.view_groups.all()) == set(groups[:3]) assert set(file.edit_groups.all()) == set(groups[2:6]) + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ("user_receipe", "file", "expected_status"), + [ + ( + lambda: None, + SimpleUploadedFile( + "test.jpg", content=RED_PIXEL_PNG, content_type="image/jpg" + ), + 403, + ), + ( + lambda: baker.make(User), + SimpleUploadedFile( + "test.jpg", content=RED_PIXEL_PNG, content_type="image/jpg" + ), + 403, + ), + ( + lambda: subscriber_user.make(), + SimpleUploadedFile( + "test.jpg", content=RED_PIXEL_PNG, content_type="image/jpg" + ), + 200, + ), + ( + lambda: old_subscriber_user.make(), + SimpleUploadedFile( + "test.jpg", content=RED_PIXEL_PNG, content_type="image/jpg" + ), + 200, + ), + ( + lambda: old_subscriber_user.make(), + SimpleUploadedFile( + "ttesttesttesttesttesttesttesttesttesttesttesttesttesttesttestesttesttesttesttesttesttesttesttesttesttesttest.jpg", + content=RED_PIXEL_PNG, + content_type="image/jpg", + ), + 200, + ), # very long file name + ( + lambda: old_subscriber_user.make(), + SimpleUploadedFile( + "test.jpg", content=b"invalid", content_type="image/jpg" + ), + 422, + ), + ( + lambda: old_subscriber_user.make(), + SimpleUploadedFile( + "test.jpg", content=RED_PIXEL_PNG, content_type="invalid" + ), + 200, # PIL can guess + ), + ( + lambda: old_subscriber_user.make(), + SimpleUploadedFile("test.jpg", content=b"invalid", content_type="invalid"), + 422, + ), + ], +) +def test_quick_upload_image( + client: Client, + user_receipe: Callable[[], User | None], + file: UploadedFile | None, + expected_status: int, +): + if (user := user_receipe()) is not None: + client.force_login(user) + resp = client.post( + reverse("api:quick_upload_image"), {"file": file} if file is not None else {} + ) + + assert resp.status_code == expected_status + + if expected_status != 200: + return + + parsed = resp.json() + assert QuickUploadImage.objects.filter(uuid=parsed["uuid"]).exists() + assert ( + parsed["name"] == Path(file.name).stem[: QuickUploadImage.IMAGE_NAME_SIZE - 1] + ) diff --git a/core/utils.py b/core/utils.py index 6b72bde8..c54aad81 100644 --- a/core/utils.py +++ b/core/utils.py @@ -13,38 +13,33 @@ # # -from dataclasses import dataclass from datetime import date, timedelta # Image utils from io import BytesIO -from typing import Any +from typing import Final import PIL from django.conf import settings from django.core.files.base import ContentFile -from django.forms import BaseForm +from django.core.files.uploadedfile import UploadedFile 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 +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. -@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 - ) +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: @@ -117,6 +112,18 @@ def get_semester_code(d: date | None = None) -> str: return "P" + str(start.year)[-2:] +def is_image(file: UploadedFile): + try: + im = PIL.Image.open(file.file) + im.verify() + # go back to the start of the file, without closing it. + # Otherwise, further checks on django side will fail + file.seek(0) + except PIL.UnidentifiedImageError: + return False + return True + + def resize_image( im: Image, edge: int, img_format: str, *, optimize: bool = True ) -> ContentFile: diff --git a/core/views/files.py b/core/views/files.py index 714b505d..cd9103d9 100644 --- a/core/views/files.py +++ b/core/views/files.py @@ -86,7 +86,7 @@ def send_raw_file(path: Path) -> HttpResponse: def send_file( request: HttpRequest, - file_id: int, + file_id: int | str, file_class: type[SithFile] = SithFile, file_attr: str = "file", ) -> HttpResponse: @@ -97,7 +97,7 @@ def send_file( deal with it. In debug mode, the server will directly send the file. """ - f = get_object_or_404(file_class, id=file_id) + f = get_object_or_404(file_class, pk=file_id) if not can_view(f, request.user) and not is_logged_in_counter(request): raise PermissionDenied name = getattr(f, file_attr).name 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 cd27cbba..93b64d3b 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,7 +64,7 @@ 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 eboutic.models import Invoice from subscription.models import Subscription @@ -508,7 +509,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 @@ -526,17 +527,21 @@ class UserPreferencesView(UserTabsMixin, CanEditMixin, UpdateView): kwargs.update({"instance": pref}) return kwargs + def get_fragment_context_data(self) -> dict[str, SafeString]: + # Avoid cyclic import error + from counter.views.student_card import StudentCardFormFragment + + 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 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/docs/tutorial/install-advanced.md b/docs/tutorial/install-advanced.md index 2da2fc42..2998298f 100644 --- a/docs/tutorial/install-advanced.md +++ b/docs/tutorial/install-advanced.md @@ -224,7 +224,7 @@ server { location /static/; root /repertoire/du/projet; } - location ~ ^/data/(products|com|club_logos)/ { + location ~ ^/data/(products|com|club_logos|upload)/ { root /repertoire/du/projet; } location ~ ^/data/(SAS|profiles|users|.compressed|.thumbnails)/ { 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/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 1e944299..11100e5d 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-04-06 15:54+0200\n" +"POT-Creation-Date: 2025-04-08 16:20+0200\n" "PO-Revision-Date: 2016-07-18\n" "Last-Translator: Maréchal \n" @@ -116,7 +116,7 @@ msgstr "Vous ne pouvez pas ajouter deux fois le même utilisateur" msgid "You should specify a role" msgstr "Vous devez choisir un rôle" -#: club/forms.py sas/views.py +#: club/forms.py sas/forms.py msgid "You do not have the permission to do that" msgstr "Vous n'avez pas la permission de faire cela" @@ -1047,7 +1047,7 @@ msgid "Posters - edit" msgstr "Affiche - modifier" #: com/templates/com/poster_list.jinja com/templates/com/screen_list.jinja -#: sas/templates/sas/main.jinja +#: sas/templates/sas/fragments/album_create_form.jinja msgid "Create" msgstr "Créer" @@ -1572,6 +1572,14 @@ msgstr "Ceci n'est pas une miniature de dossier valide" msgid "You must provide a file" msgstr "Vous devez fournir un fichier" +#: core/models.py +msgid "width" +msgstr "largeur" + +#: core/models.py +msgid "height" +msgstr "hauteur" + #: core/models.py msgid "page unix name" msgstr "nom unix de la page" @@ -1644,6 +1652,10 @@ msgstr "étiquette" msgid "operation type" msgstr "type d'opération" +#: core/schemas.py +msgid "This file is not a valid image" +msgstr "Ce fichier n'est pas une image valide" + #: core/templates/core/403.jinja msgid "403, Forbidden" msgstr "403, Non autorisé" @@ -2729,7 +2741,7 @@ msgstr "Ajouter un nouveau dossier" msgid "Error creating folder %(folder_name)s: %(msg)s" msgstr "Erreur de création du dossier %(folder_name)s : %(msg)s" -#: core/views/files.py core/views/forms.py sas/forms.py +#: core/views/files.py core/views/forms.py #, python-format msgid "Error uploading file %(file_name)s: %(msg)s" msgstr "Erreur d'envoi du fichier %(file_name)s : %(msg)s" @@ -4715,11 +4727,6 @@ msgstr "Ajouter un nouvel album" msgid "Upload images" msgstr "Envoyer les images" -#: sas/forms.py -#, python-format -msgid "Error creating album %(album)s: %(msg)s" -msgstr "Erreur de création de l'album %(album)s : %(msg)s" - #: sas/forms.py msgid "You already requested moderation for this picture." msgstr "Vous avez déjà déposé une demande de retrait pour cette photo." diff --git a/locale/fr/LC_MESSAGES/djangojs.po b/locale/fr/LC_MESSAGES/djangojs.po index 7952baa4..336f9de7 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-09 23:23+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 "Link copied" +msgstr "Lien copié" + #: com/static/bundled/com/components/moderation-alert-index.ts #, javascript-format msgid "" @@ -55,6 +63,40 @@ msgstr "Vous devez taper %(number)s caractères de plus" msgid "No results found" msgstr "Aucun résultat trouvé" +#: core/static/bundled/core/components/easymde-index.ts +msgid "You are not authorized to use this feature" +msgstr "Vous n'êtes pas autorisé à utilisé cette fonctionalité" + +#: core/static/bundled/core/components/easymde-index.ts +msgid "Could not upload image" +msgstr "L'image n'a pas pu être téléversée" + +#: core/static/bundled/core/components/easymde-index.ts +msgid "Attach files by drag and dropping or pasting from clipboard." +msgstr "" +"Ajoutez des fichiez en glissant déposant ou collant depuis votre presse " +"papier." + +#: core/static/bundled/core/components/easymde-index.ts +msgid "Drop image to upload it." +msgstr "Glissez une image pour la téléverser." + +#: core/static/bundled/core/components/easymde-index.ts +msgid "Uploading image #images_names# …" +msgstr "Téléversement de l'image #images_names# …" + +#: core/static/bundled/core/components/easymde-index.ts +msgid "Uploading #file_name#: #progress#%" +msgstr "Téléversement de #file_name#: #progress#%" + +#: core/static/bundled/core/components/easymde-index.ts +msgid "Uploaded #image_name#" +msgstr "#image_name# téléversé" + +#: core/static/bundled/core/components/easymde-index.ts +msgid " B, KB, MB" +msgstr " B, KB, MB" + #: core/static/bundled/core/components/easymde-index.ts msgid "Heading" msgstr "Titre" @@ -107,6 +149,10 @@ msgstr "Insérer lien" msgid "Insert image" msgstr "Insérer image" +#: core/static/bundled/core/components/easymde-index.ts +msgid "Upload image" +msgstr "Téléverser une image" + #: core/static/bundled/core/components/easymde-index.ts msgid "Insert table" msgstr "Insérer tableau" 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: diff --git a/package-lock.json b/package-lock.json index dfa465c1..541a492d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,7 @@ "cytoscape-cxtmenu": "^3.5.0", "cytoscape-klay": "^3.1.4", "d3-force-3d": "^3.0.5", - "easymde": "^2.18.0", + "easymde": "^2.19.0", "glob": "^11.0.0", "htmx.org": "^2.0.3", "jquery": "^3.7.1", @@ -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" } @@ -3663,14 +3663,14 @@ "license": "MIT" }, "node_modules/easymde": { - "version": "2.18.0", - "resolved": "https://registry.npmjs.org/easymde/-/easymde-2.18.0.tgz", - "integrity": "sha512-IxVVUxNWIoXLeqtBU4BLc+eS/ScYhT1Dcb6yF5Wchoj1iXAV+TIIDWx+NCaZhY7RcSHqDPKllbYq7nwGKILnoA==", + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/easymde/-/easymde-2.19.0.tgz", + "integrity": "sha512-4F1aNImqse+9xIjLh9ttfpOVenecjFPxUmKbl1tGp72Z+OyIqLZPE/SgNyy88c/xU0mOy0WC3+tfbZDQ5PDWhg==", "license": "MIT", "dependencies": { - "@types/codemirror": "^5.60.4", + "@types/codemirror": "^5.60.10", "@types/marked": "^4.0.7", - "codemirror": "^5.63.1", + "codemirror": "^5.65.15", "codemirror-spell-checker": "1.1.2", "marked": "^4.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..94fa21fc 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" }, @@ -54,7 +54,7 @@ "cytoscape-cxtmenu": "^3.5.0", "cytoscape-klay": "^3.1.4", "d3-force-3d": "^3.0.5", - "easymde": "^2.18.0", + "easymde": "^2.19.0", "glob": "^11.0.0", "htmx.org": "^2.0.3", "jquery": "^3.7.1", 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/sas/api.py b/sas/api.py index d9e2ad2e..175e44c0 100644 --- a/sas/api.py +++ b/sas/api.py @@ -1,7 +1,10 @@ +from typing import Any, Literal + 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, File, Query from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra.exceptions import NotFound, PermissionDenied from ninja_extra.pagination import PageNumberPaginationExtra @@ -9,8 +12,15 @@ 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 core.schemas import UploadedImage from sas.models import Album, PeoplePictureRelation, Picture from sas.schemas import ( AlbumAutocompleteSchema, @@ -92,6 +102,38 @@ class PicturesController(ControllerBase): .annotate(album=F("parent__name")) ) + @route.post( + "", + permissions=[CanEdit], + response={ + 200: None, + 409: dict[Literal["detail"], dict[str, list[str]]], + 422: dict[Literal["detail"], list[dict[str, Any]]], + }, + url_name="upload_picture", + ) + def upload_picture(self, album_id: Body[int], picture: File[UploadedImage]): + 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: + return self.create_response({"detail": dict(e)}, status_code=409) + @route.get( "/{picture_id}/identified", permissions=[IsAuthenticated, CanView], 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/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..32f0f02f 100644 --- a/sas/static/bundled/sas/album-index.ts +++ b/sas/static/bundled/sas/album-index.ts @@ -5,8 +5,10 @@ import { type AlbumSchema, type PictureSchema, type PicturesFetchPicturesData, + type PicturesUploadPictureErrors, albumFetchAlbum, picturesFetchPictures, + picturesUploadPicture, } from "#openapi"; interface AlbumPicturesConfig { @@ -78,4 +80,49 @@ 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.errors = []; + 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) { + let msg = ""; + if (res.response.status === 422) { + msg = (res.error as PicturesUploadPictureErrors[422]).detail + .map((err: Record<"ctx", Record<"error", string>>) => err.ctx.error) + .join(" ; "); + } else { + msg = Object.values(res.error.detail).join(" ; "); + } + this.errors.push(`${file.name} : ${msg}`); + } + this.progress.value += 1; + }, + })); }); diff --git a/sas/templates/sas/album.jinja b/sas/templates/sas/album.jinja index 6c2cbcf7..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")) }} -
+