Merge pull request #1075 from ae-utbm/taiste

SAS and markdown pictures upload improval, google calendar removal, calendar export link, css fixes and more
This commit is contained in:
thomas girod 2025-04-10 13:15:02 +02:00 committed by GitHub
commit 3c8933461a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 1424 additions and 464 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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",
]
)
)

View File

@ -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,
),
),
],
),
]

View File

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

View File

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

View File

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

View File

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

View File

@ -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%;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,8 +1,14 @@
from typing import ClassVar
import copy
import inspect
from typing import Any, ClassVar, LiteralString, Protocol, Unpack
from django.conf import settings
from django.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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,40 +1,356 @@
Pour utiliser HTMX, on a besoin de renvoyer des fragments depuis le backend.
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
<form
hx-trigger="submit" {# Lorsque le formulaire est validé... #}
hx-post="{{ url("foo:bar") }}" {# ...envoie une requête POST vers l'url donnée... #}
hx-swap="outerHTML" {# ...et remplace tout l'html du formulaire par le contenu de la réponse HTTP #}
>
{% csrf_token %}
{{ form }}
<input type="submit" value="{% trans %}Go{% endtrans %}"/>
</form>
```
C'est la majorité de ce que vous avez besoin de savoir
pour utiliser HTMX sur le site.
Bien entendu, ce n'est pas tout, il y a d'autres
options et certaines subtilités, mais pour ça,
consultez [la doc officielle d'HTMX](https://htmx.org/docs/).
## La surcouche du site
Pour faciliter et standardiser l'intégration d'HTMX
dans la base de code du site AE,
nous avons créé certains mixins à utiliser
dans les vues basées sur des classes.
### [AllowFragment][core.views.mixins.AllowFragment]
`AllowFragment` est extrêmement simple dans son
concept : il met à disposition la variable `is_fragment`,
qui permet de savoir si la vue est appelée par HTMX,
ou si elle provient d'un autre contexte.
Grâce à ça, on peut écrire des vues qui
fonctionnent dans les deux contextes.
Par exemple, supposons que nous avons
une `EditView` très simple, contenant
uniquement un formulaire.
On peut écrire la vue et le template de la manière
suivante :
=== "`views.py`"
```python
from django.views.generic import UpdateView
class FooUpdateView(UpdateView):
model = Foo
fields = ["foo", "bar"]
pk_url_kwarg = "foo_id"
template_name = "app/foo.jinja"
```
=== "`app/foo.jinja`"
```html+jinja
{% if is_fragment %}
{% extends "core/base_fragment.jinja" %}
{% else %}
{% extends "core/base.jinja" %}
{% endif %}
{% block content %}
<form hx-trigger="submit" hx-swap="outerHTML">
{% csrf_token %}
{{ form }}
<input type="submit" value="{% trans %}Go{% endtrans %}"/>
</form>
{% endblock %}
```
Lors du chargement initial de la page, le template
entier sera rendu, mais lors de la soumission du formulaire,
seul le fragment html de ce dernier sera changé.
### [FragmentMixin][core.views.mixins.FragmentMixin]
Il arrive des situations où le résultat que l'on
veut accomplir est plus complexe.
Dans ces situations, pouvoir décomposer une vue
en plusieurs vues de fragment permet de ne plus
raisonner en termes de condition, mais en termes
de composition : on n'a pas un seul template
qui peut changer les situations, on a plusieurs
templates que l'on injecte dans un template principal.
Supposons, par exemple, que nous n'avons plus un,
mais deux formulaires à afficher sur la page.
Dans ce cas, nous pouvons créer deux templates,
qui seront alors injectés.
=== "`urls.py`"
```python
from django.urls import path
from app import views
urlpatterns = [
path("", FooCompositeView.as_view(), name="main"),
path("create/", FooUpdateFragment.as_view(), name="update_foo"),
path("update/", FooCreateFragment.as_view(), name="create_foo"),
]
```
=== "`view.py`"
```python
from django.views.generic import CreateView, UpdateView, TemplateView
from core.views.mixins import FragmentMixin
class FooCreateFragment(FragmentMixin, CreateView):
model = Foo
fields = ["foo", "bar"]
template_name = "app/fragments/create_foo.jinja"
class FooUpdateFragment(FragmentMixin, UpdateView):
model = Foo
fields = ["foo", "bar"]
pk_url_kwarg = "foo_id"
template_name = "app/fragments/update_foo.jinja"
class FooCompositeFormView(TemplateView):
template_name = "app/foo.jinja"
def get_context_data(**kwargs):
return super().get_context_data(**kwargs) | {
"create_fragment": FooCreateFragment.as_fragment()(),
"update_fragment": FooUpdateFragment.as_fragment()(foo_id=1)
}
```
=== "`app/fragment/create_foo.jinja`"
```html+jinja
<form
hx-trigger="submit"
hx-post="{{ url("app:create_foo") }}"
hx-swap="outerHTML"
>
{% csrf_token %}
{{ form }}
<input type="submit" value="{% trans %}Create{% endtrans %}"/>
</form>
```
=== "`app/fragment/update_foo.jinja`"
```html+jinja
<form
hx-trigger="submit"
hx-post="{{ url("app:update_foo") }}"
hx-swap="outerHTML"
>
{% csrf_token %}
{{ form }}
<input type="submit" value="{% trans %}Update{% endtrans %}"/>
</form>
```
=== "`app/foo.jinja`"
```html+jinja
{% extends "core/base.html" %}
{% block content %}
<h2>{% trans %}Update current foo{% endtrans %}</h2>
{{ update_fragment }}
<h2>{% trans %}Create new foo{% endtrans %}</h2>
{{ create_fragment }}
{% endblock %}
```
Le résultat consistera en l'affichage d'une page
contenant deux formulaires.
Le rendu des fragments n'est pas effectué
par `FooCompositeView`, mais par les vues
des fragments elles-mêmes, en sautant
les méthodes `dispatch` et `get`/`post` de ces dernières.
À chaque validation de formulaire, la requête
sera envoyée à la vue responsable du fragment,
qui se comportera alors comme une vue normale.
#### La méthode `as_fragment`
Il est à noter que l'instantiation d'un fragment
se fait en deux étapes :
- on commence par instantier la vue en tant que renderer.
- on appelle le renderer en lui-même
Ce qui donne la syntaxe `Fragment.as_fragment()()`.
Cette conception est une manière de se rapprocher
le plus possible de l'interface déjà existante
pour la méthode `as_view` des vues.
La méthode `as_fragment` prend en argument les mêmes
paramètres que `as_view`.
Par exemple, supposons que nous voulons rajouter
des variables de contexte lors du rendu du fragment.
On peut écrire ça ainsi :
```python
fragment = Fragment.as_fragment(extra_context={"foo": "bar"})()
```
#### Personnaliser le rendu
En plus de la personnalisation permise par
`as_fragment`, on peut surcharger la méthode
`render_fragment` pour accomplir des actions
spécifiques, et ce uniquement lorsqu'on effectue
le rendu du fragment.
Supposons qu'on veuille manipuler un entier
dans la vue et que, lorsqu'on est en train
de faire le rendu du template, on veuille augmenter
la valeur de cet entier (c'est juste pour l'exemple).
On peut écrire ça ainsi :
```python
from django.views.generic import CreateView
from core.views.mixins import FragmentMixin
class FooCreateFragment(FragmentMixin, CreateView):
model = Foo
fields = ["foo", "bar"]
template_name = "app/fragments/create_foo.jinja"
def render_fragment(self, request, **kwargs):
if "foo" in kwargs:
kwargs["foo"] += 2
return super().render_fragment(request, **kwargs)
```
Et on effectuera le rendu du fragment de la manière suivante :
```python
FooCreateFragment.as_fragment()(foo=4)
```
### [UseFragmentsMixin][core.views.mixins.UseFragmentsMixin]
Lorsqu'on a plusieurs fragments, il est parfois
plus aisé des les aggréger au sein de la vue
principale en utilisant `UseFragmentsMixin`.
Elle permet de marquer de manière plus explicite
la séparation entre les fragments et le reste du contexte.
Reprenons `FooUpdateFragment` et la version modifiée
de `FooCreateFragment`.
`FooCompositeView` peut être réécrite ainsi :
```python
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 %}
<h3>{% 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)
}
```

View File

@ -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)/ {

View File

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

View File

@ -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 <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\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."

View File

@ -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 <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\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"

View File

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

20
package-lock.json generated
View File

@ -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": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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;
},
}));
});

View File

@ -73,7 +73,7 @@
<div class="text">{% trans %}To be moderated{% endtrans %}</div>
</template>
</div>
{% if edit_mode %}
{% if is_sas_admin %}
<input type="checkbox" name="file_list" :value="album.id">
{% endif %}
</a>
@ -86,7 +86,7 @@
<h4>{% trans %}Pictures{% endtrans %}</h4>
<br>
{{ download_button(_("Download album")) }}
<div class="photos" :aria-busy="loading">
<div class="photos" :aria-busy="loading" @pictures-upload-done.window="fetchPictures">
<template x-for="picture in getPage(page)">
<a :href="picture.sas_url">
<div class="photo" :class="{not_moderated: !picture.is_moderated}">
@ -110,13 +110,28 @@
{% if is_sas_admin %}
</form>
<form class="add-files" id="upload_form" action="" method="post" enctype="multipart/form-data">
{{ album_create_fragment }}
<form
class="add-files"
id="upload_form"
x-data="pictureUpload({{ album.id }})"
@submit.prevent="sendPictures()"
>
{% csrf_token %}
<div class="inputs">
{{ form.as_p() }}
<p>
<label for="{{ upload_form.images.id_for_label }}">{{ upload_form.images.label }} :</label>
{{ upload_form.images|add_attr("x-ref=pictures") }}
<span class="helptext">{{ upload_form.images.help_text }}</span>
</p>
<input type="submit" value="{% trans %}Upload{% endtrans %}" />
<progress x-ref="progress" x-show="sending"></progress>
</div>
<ul class="errorlist">
<template x-for="error in errors">
<li class="error" x-text="error"></li>
</template>
</ul>
</form>
{% endif %}
@ -126,115 +141,3 @@
{{ timezone.now() - start }}
</p>
{% endblock %}
{% block script %}
{{ super() }}
<script>
// Todo: migrate to alpine.js if we have some time
$("form#upload_form").submit(function (event) {
let formData = new FormData($(this)[0]);
if(!formData.get('album_name') && !formData.get('images').name)
return false;
if(!formData.get('images').name) {
return true;
}
event.preventDefault();
let errorList;
if((errorList = this.querySelector('#upload_form ul.errorlist.nonfield')) === null) {
errorList = document.createElement('ul');
errorList.classList.add('errorlist', 'nonfield');
this.insertBefore(errorList, this.firstElementChild);
}
while(errorList.childElementCount > 0)
errorList.removeChild(errorList.firstElementChild);
let progress;
if((progress = this.querySelector('progress')) === null) {
progress = document.createElement('progress');
progress.value = 0;
let p = document.createElement('p');
p.appendChild(progress);
this.insertBefore(p, this.lastElementChild);
}
let dataHolder;
if(formData.get('album_name')) {
dataHolder = new FormData();
dataHolder.set('csrfmiddlewaretoken', '{{ csrf_token }}');
dataHolder.set('album_name', formData.get('album_name'));
$.ajax({
method: 'POST',
url: "{{ url('sas:album_upload', album_id=object.id) }}",
data: dataHolder,
processData: false,
contentType: false,
success: onSuccess
});
}
let images = formData.getAll('images');
let imagesCount = images.length;
let completeCount = 0;
let poolSize = 1;
let imagePool = [];
while(images.length > 0 && imagePool.length < poolSize) {
let image = images.shift();
imagePool.push(image);
sendImage(image);
}
function sendImage(image) {
dataHolder = new FormData();
dataHolder.set('csrfmiddlewaretoken', '{{ csrf_token }}');
dataHolder.set('images', image);
$.ajax({
method: 'POST',
url: "{{ url('sas:album_upload', album_id=object.id) }}",
data: dataHolder,
processData: false,
contentType: false,
})
.fail(onSuccess.bind(undefined, image))
.done(onSuccess.bind(undefined, image))
.always(next.bind(undefined, image));
}
function next(image, _, __) {
let index = imagePool.indexOf(image);
let nextImage = images.shift();
if(index !== -1)
imagePool.splice(index, 1);
if(nextImage) {
imagePool.push(nextImage);
sendImage(nextImage);
}
}
function onSuccess(image, data, _, __) {
let errors = [];
if ($(data.responseText).find('.errorlist.nonfield')[0])
errors = Array.from($(data.responseText).find('.errorlist.nonfield')[0].children);
while(errors.length > 0)
errorList.appendChild(errors.shift());
progress.value = ++completeCount / imagesCount;
if(progress.value === 1 && errorList.children.length === 0)
document.location.reload()
}
});
</script>
{% endblock %}

View File

@ -0,0 +1,18 @@
<form
class="add-files"
hx-post="{{ url("sas:album_create") }}"
hx-disabled-elt="find input[type='submit']"
hx-swap="outerHTML"
>
{% csrf_token %}
<div class="inputs">
<div>
<label for="{{ form.name.id_for_label }}">{{ form.name.label }}</label>
{{ form.name }}
</div>
{{ form.parent }}
<input type="submit" value="{% trans %}Create{% endtrans %}" />
</div>
{{ form.non_field_errors() }}
{{ form.name.errors }}
</form>

View File

@ -61,23 +61,8 @@
{% if is_sas_admin %}
</form>
<br>
<form class="add-files" action="" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="inputs">
<div>
<label for="{{ form.album_name.name }}">{{ form.album_name.label }}</label>
{{ form.album_name }}
</div>
<input type="submit" value="{% trans %}Create{% endtrans %}" />
</div>
{{ form.non_field_errors() }}
{{ form.album_name.errors }}
</form>
{{ album_create_fragment }}
{% endif %}
{% endif %}
</main>

View File

@ -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,45 @@ 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"
@pytest.mark.django_db
def test_upload_invalid_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)
file = SimpleUploadedFile(
name="file.txt",
content=b"azerty",
content_type="image/png", # the server shouldn't blindly trust the content_type
)
res = client.post(
reverse("api:upload_picture"), {"album_id": album.id, "picture": file}
)
assert res.status_code == 422
assert res.json()["detail"][0]["ctx"]["error"] == (
"Ce fichier n'est pas une image valide"
)

View File

@ -16,8 +16,8 @@
from django.urls import path
from sas.views import (
AlbumCreateFragment,
AlbumEditView,
AlbumUploadView,
AlbumView,
ModerationView,
PictureAskRemovalView,
@ -35,9 +35,6 @@ urlpatterns = [
path("", SASMainView.as_view(), name="main"),
path("moderation/", ModerationView.as_view(), name="moderation"),
path("album/<int:album_id>/", AlbumView.as_view(), name="album"),
path(
"album/<int:album_id>/upload/", AlbumUploadView.as_view(), name="album_upload"
),
path("album/<int:album_id>/edit/", AlbumEditView.as_view(), name="album_edit"),
path("album/<int:album_id>/preview/", send_album, name="album_preview"),
path("picture/<int:picture_id>/", PictureView.as_view(), name="picture"),
@ -59,4 +56,5 @@ urlpatterns = [
path(
"user/<int:user_id>/pictures/", UserPicturesView.as_view(), name="user_pictures"
),
path("fragment/album-create", AlbumCreateFragment.as_view(), name="album_create"),
]

View File

@ -16,48 +16,63 @@ from typing import Any
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.http import Http404, HttpResponse, HttpResponseRedirect
from django.http import Http404, HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, TemplateView
from django.views.generic.edit import FormMixin, FormView, UpdateView
from django.urls import reverse
from django.utils.safestring import SafeString
from django.views.generic import CreateView, DetailView, TemplateView
from django.views.generic.edit import FormView, UpdateView
from core.auth.mixins import CanEditMixin, CanViewMixin
from core.models import SithFile, User
from core.views import UseFragmentsMixin
from core.views.files import FileView, send_file
from core.views.mixins import FragmentMixin, FragmentRenderer
from core.views.user import UserTabsMixin
from sas.forms import (
AlbumCreateForm,
AlbumEditForm,
PictureEditForm,
PictureModerationRequestForm,
SASForm,
PictureUploadForm,
)
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)
@ -104,88 +119,45 @@ def send_thumb(request, picture_id):
return send_file(request, picture_id, Picture, "thumbnail")
class AlbumUploadView(CanViewMixin, DetailView, FormMixin):
class AlbumView(CanViewMixin, UseFragmentsMixin, DetailView):
model = Album
form_class = SASForm
pk_url_kwarg = "album_id"
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()
parent = SithFile.objects.filter(id=self.object.id).first()
files = request.FILES.getlist("images")
if request.user.is_subscribed and 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)
or request.user.is_root
),
)
if self.form.is_valid():
return HttpResponse(str(self.form.errors), status=200)
return HttpResponse(str(self.form.errors), status=500)
class AlbumView(CanViewMixin, DetailView, FormMixin):
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)

View File

@ -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 = ["*"]

9
uv.lock generated
View File

@ -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" },