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 pathlib import Path
from typing import final
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.conf import settings 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.db.models import F, QuerySet
from django.http import HttpRequest
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from ical.calendar import Calendar from ical.calendar import Calendar
@ -14,7 +16,14 @@ from com.models import NewsDate
from core.models import User 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: class IcsCalendar:
_CACHE_FOLDER: Path = settings.MEDIA_ROOT / "com" / "calendars" _CACHE_FOLDER: Path = settings.MEDIA_ROOT / "com" / "calendars"
_INTERNAL_CALENDAR = _CACHE_FOLDER / "internal.ics" _INTERNAL_CALENDAR = _CACHE_FOLDER / "internal.ics"
@ -58,7 +67,9 @@ class IcsCalendar:
summary=news_date.news_title, summary=news_date.news_title,
start=news_date.start_date, start=news_date.start_date,
end=news_date.end_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) calendar.events.append(event)

View File

@ -44,7 +44,18 @@ export class IcsCalendar extends inheritHtmlElement("div") {
return this.isMobile() ? "listMonth" : "dayGridMonth"; return this.isMobile() ? "listMonth" : "dayGridMonth";
} }
currentToolbar() { currentFooterToolbar() {
if (this.isMobile()) {
return {
start: "",
center: "getCalendarLink",
end: "",
};
}
return { start: "getCalendarLink", center: "", end: "" };
}
currentHeaderToolbar() {
if (this.isMobile()) { if (this.isMobile()) {
return { return {
left: "prev,next", left: "prev,next",
@ -303,14 +314,44 @@ export class IcsCalendar extends inheritHtmlElement("div") {
this.calendar = new Calendar(this.node, { this.calendar = new Calendar(this.node, {
plugins: [dayGridPlugin, iCalendarPlugin, listPlugin], plugins: [dayGridPlugin, iCalendarPlugin, listPlugin],
locales: [frLocale, enLocale], 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", height: "auto",
locale: this.locale, locale: this.locale,
initialView: this.currentView(), initialView: this.currentView(),
headerToolbar: this.currentToolbar(), headerToolbar: this.currentHeaderToolbar(),
footerToolbar: this.currentFooterToolbar(),
eventSources: await this.getEventSources(), eventSources: await this.getEventSources(),
windowResize: () => { windowResize: () => {
this.calendar.changeView(this.currentView()); 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) => { eventClick: (event) => {
// Avoid our popup to be deleted because we clicked outside of it // Avoid our popup to be deleted because we clicked outside of it

View File

@ -98,4 +98,26 @@ ics-calendar {
background: white; 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 { #upcoming-events {
max-height: 600px; max-height: 600px;
overflow-y: scroll; overflow-y: scroll;
overflow-x: clip;
#load-more-news-button { #load-more-news-button {
text-align: center; text-align: center;
button { button {
width: 150px; width: 150px;
} }
@ -194,6 +196,7 @@
img { img {
height: 75px; height: 75px;
} }
.header_content { .header_content {
display: flex; display: flex;
flex-direction: column; 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 Group as AuthGroup
from django.contrib.auth.models import Permission 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) admin.site.unregister(AuthGroup)
@ -89,3 +98,11 @@ class OperationLogAdmin(admin.ModelAdmin):
list_display = ("label", "operator", "operation_type", "date") list_display = ("label", "operator", "operation_type", "date")
search_fields = ("label", "date", "operation_type") search_fields = ("label", "date", "operation_type")
autocomplete_fields = ("operator",) 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 import annotated_types
from django.conf import settings from django.conf import settings
from django.db.models import F from django.db.models import F
from django.http import HttpResponse 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 import ControllerBase, api_controller, paginate, route
from ninja_extra.exceptions import PermissionDenied from ninja_extra.exceptions import PermissionDenied
from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema from ninja_extra.schemas import PaginatedResponseSchema
from club.models import Mailing from club.models import Mailing
from core.auth.api_permissions import CanAccessLookup, CanView from core.auth.api_permissions import CanAccessLookup, CanView, HasPerm
from core.models import Group, SithFile, User from core.models import Group, QuickUploadImage, SithFile, User
from core.schemas import ( from core.schemas import (
FamilyGodfatherSchema, FamilyGodfatherSchema,
GroupSchema, GroupSchema,
MarkdownSchema, MarkdownSchema,
SithFileSchema, SithFileSchema,
UploadedFileSchema,
UploadedImage,
UserFamilySchema, UserFamilySchema,
UserFilterSchema, UserFilterSchema,
UserProfileSchema, UserProfileSchema,
@ -33,6 +35,25 @@ class MarkdownController(ControllerBase):
return HttpResponse(markdown(body.text), content_type="text/html") 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") @api_controller("/mailings")
class MailingListController(ControllerBase): class MailingListController(ControllerBase):
@route.get("", response=str) @route.get("", response=str)

View File

@ -740,8 +740,9 @@ Welcome to the wiki page!
size=file.size, size=file.size,
) )
pict.file.name = p.name pict.file.name = p.name
pict.clean() pict.full_clean()
pict.generate_thumbnails() pict.generate_thumbnails()
pict.save()
img_skia = Picture.objects.get(name="skia.jpg") img_skia = Picture.objects.get(name="skia.jpg")
img_sli = Picture.objects.get(name="sli.jpg") img_sli = Picture.objects.get(name="sli.jpg")
@ -827,6 +828,7 @@ Welcome to the wiki page!
"view_peoplepicturerelation", "view_peoplepicturerelation",
"add_peoplepicturerelation", "add_peoplepicturerelation",
"add_page", "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. # details.
# #
# You should have received a copy of the GNU General Public License along with # 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. # Place - Suite 330, Boston, MA 02111-1307, USA.
# #
# #
@ -32,6 +32,7 @@ from datetime import timedelta
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Optional, Self from typing import TYPE_CHECKING, Optional, Self
from uuid import uuid4
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import AbstractUser, UserManager 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 import validators
from django.core.cache import cache from django.core.cache import cache
from django.core.exceptions import PermissionDenied, ValidationError 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.core.mail import send_mail
from django.db import models, transaction from django.db import models, transaction
from django.db.models import Exists, F, OuterRef, Q 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.timezone import localdate, now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from phonenumber_field.modelfields import PhoneNumberField from phonenumber_field.modelfields import PhoneNumberField
from PIL import Image from PIL import Image, ImageOps
if TYPE_CHECKING: if TYPE_CHECKING:
from django.core.files.uploadedfile import UploadedFile
from pydantic import NonNegativeInt from pydantic import NonNegativeInt
from club.models import Club from club.models import Club
@ -1102,6 +1106,68 @@ class SithFile(models.Model):
return reverse("core:download", kwargs={"file_id": self.id}) 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): class LockError(Exception):
"""There was a lock error on the object.""" """There was a lock error on the object."""

View File

@ -1,16 +1,29 @@
from pathlib import Path from pathlib import Path
from typing import Annotated from typing import Annotated, Any
from annotated_types import MinLen from annotated_types import MinLen
from django.contrib.staticfiles.storage import staticfiles_storage from django.contrib.staticfiles.storage import staticfiles_storage
from django.db.models import Q from django.db.models import Q
from django.urls import reverse from django.urls import reverse
from django.utils.text import slugify from django.utils.text import slugify
from django.utils.translation import gettext as _
from haystack.query import SearchQuerySet 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 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): class SimpleUserSchema(ModelSchema):
@ -47,6 +60,18 @@ class UserProfileSchema(ModelSchema):
return reverse("core:download", kwargs={"file_id": obj.profile_pict_id}) 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 SithFileSchema(ModelSchema):
class Meta: class Meta:
model = SithFile model = SithFile

View File

@ -6,13 +6,58 @@ import { inheritHtmlElement, registerComponent } from "#core:utils/web-component
import type CodeMirror from "codemirror"; import type CodeMirror from "codemirror";
// biome-ignore lint/style/useNamingConvention: This is how they called their namespace // biome-ignore lint/style/useNamingConvention: This is how they called their namespace
import EasyMDE from "easymde"; import EasyMDE from "easymde";
import { markdownRenderMarkdown } from "#openapi"; import {
type UploadUploadImageErrors,
markdownRenderMarkdown,
uploadUploadImage,
} from "#openapi";
const loadEasyMde = (textarea: HTMLTextAreaElement) => { const loadEasyMde = (textarea: HTMLTextAreaElement) => {
new EasyMDE({ const easymde = new EasyMDE({
element: textarea, element: textarea,
spellChecker: false, spellChecker: false,
autoDownloadFontAwesome: 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) => { previewRender: (plainText: string, preview: MarkdownInput) => {
/* This is wrapped this way to allow time for Alpine to be loaded on the page */ /* This is wrapped this way to allow time for Alpine to be loaded on the page */
return Alpine.debounce((plainText: string, preview: MarkdownInput) => { return Alpine.debounce((plainText: string, preview: MarkdownInput) => {
@ -30,6 +75,14 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
}, 300)(plainText, preview); }, 300)(plainText, preview);
}, },
forceSync: true, // Avoid validation error on generic create view 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: [ toolbar: [
{ {
name: "heading-smaller", name: "heading-smaller",
@ -120,6 +173,12 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
className: "fa-regular fa-image", className: "fa-regular fa-image",
title: gettext("Insert image"), title: gettext("Insert image"),
}, },
{
name: "upload-image",
action: EasyMDE.drawUploadedImage,
className: "fa-solid fa-file-arrow-up",
title: gettext("Upload image"),
},
{ {
name: "table", name: "table",
action: EasyMDE.drawTable, action: EasyMDE.drawTable,

View File

@ -33,8 +33,8 @@ $hovered-red-text-color: #ff4d4d;
flex-direction: row; flex-direction: row;
gap: 10px; gap: 10px;
> a { >a {
color: $text-color!important; color: $text-color !important;
} }
&:hover>a { &:hover>a {
@ -207,7 +207,7 @@ $hovered-red-text-color: #ff4d4d;
justify-content: flex-end; justify-content: flex-end;
} }
> a { >a {
display: block; display: block;
min-width: 40px; min-width: 40px;
height: 40px; height: 40px;
@ -251,11 +251,13 @@ $hovered-red-text-color: #ff4d4d;
justify-content: flex-start; justify-content: flex-start;
} }
a, button { a,
button {
font-size: 100%; font-size: 100%;
margin: 0; margin: 0;
text-align: right; text-align: right;
color: $text-color; color: $text-color;
margin-top: auto;
&:hover { &:hover {
color: $hovered-text-color; color: $hovered-text-color;
@ -266,12 +268,14 @@ $hovered-red-text-color: #ff4d4d;
margin: 0; margin: 0;
display: inline; display: inline;
} }
#logout-form button { #logout-form button {
color: $red-text-color; color: $red-text-color;
&:hover { &:hover {
color: $hovered-red-text-color; color: $hovered-red-text-color;
} }
background: none; background: none;
border: none; border: none;
cursor: pointer; cursor: pointer;

View File

@ -15,6 +15,7 @@
ol, ol,
p { p {
line-height: 22px; line-height: 22px;
word-break: break-word;
} }
code { code {
@ -71,7 +72,8 @@
a:hover { a:hover {
text-decoration: underline; text-decoration: underline;
} }
.footnotes { .footnotes {
font-size: 85%; font-size: 85%;
} }
} }

View File

@ -51,24 +51,55 @@ body {
[tooltip]::before { [tooltip]::before {
@include shadow; @include shadow;
opacity: 0;
z-index: 1; z-index: 1;
pointer-events: none;
content: attr(tooltip); content: attr(tooltip);
background: hsl(219.6, 20.8%, 96%); left: 50%;
color: $black-color; transform: translateX(-50%);
background-color: #333;
color: #fff;
border: 0.5px solid hsl(0, 0%, 50%); border: 0.5px solid hsl(0, 0%, 50%);
;
border-radius: 5px; border-radius: 5px;
padding: 5px; padding: 5px 10px;
top: 1em;
position: absolute; position: absolute;
margin-top: 5px;
white-space: nowrap; white-space: nowrap;
opacity: 0;
transition: opacity 500ms ease-out; transition: opacity 500ms ease-out;
top: 120%; // Put the tooltip under the element
} }
[tooltip]:hover::before { [tooltip]:hover::before {
opacity: 1; 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 { .ib {

View File

@ -1,11 +1,12 @@
from io import BytesIO from io import BytesIO
from itertools import cycle from itertools import cycle
from pathlib import Path
from typing import Callable from typing import Callable
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
from django.core.cache import cache 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.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from model_bakery import baker from model_bakery import baker
@ -14,7 +15,8 @@ from PIL import Image
from pytest_django.asserts import assertNumQueries from pytest_django.asserts import assertNumQueries
from core.baker_recipes import board_user, old_subscriber_user, subscriber_user 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 sas.models import Picture
from sith import settings 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.view_groups.all()) == set(groups[:3])
assert set(file.edit_groups.all()) == set(groups[2:6]) 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 from datetime import date, timedelta
# Image utils # Image utils
from io import BytesIO from io import BytesIO
from typing import Any from typing import Final
import PIL import PIL
from django.conf import settings from django.conf import settings
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.forms import BaseForm from django.core.files.uploadedfile import UploadedFile
from django.http import HttpRequest from django.http import HttpRequest
from django.template.loader import render_to_string
from django.utils.html import SafeString
from django.utils.timezone import localdate from django.utils.timezone import localdate
from PIL import ExifTags from PIL import ExifTags
from PIL.Image import Image, Resampling from PIL.Image import Image, Resampling
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 Can be used in tests and in dev, when there is a need
class FormFragmentTemplateData[T: BaseForm]: to generate a dummy image that is considered valid nonetheless
"""Dataclass used to pre-render form fragments""" """
form: T
template: str
context: dict[str, Any]
def render(self, request: HttpRequest) -> SafeString:
# Request is needed for csrf_tokens
return render_to_string(
self.template, context={"form": self.form, **self.context}, request=request
)
def get_start_of_semester(today: date | None = None) -> date: def get_start_of_semester(today: date | None = None) -> date:
@ -117,6 +112,18 @@ def get_semester_code(d: date | None = None) -> str:
return "P" + str(start.year)[-2:] 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( def resize_image(
im: Image, edge: int, img_format: str, *, optimize: bool = True im: Image, edge: int, img_format: str, *, optimize: bool = True
) -> ContentFile: ) -> ContentFile:

View File

@ -86,7 +86,7 @@ def send_raw_file(path: Path) -> HttpResponse:
def send_file( def send_file(
request: HttpRequest, request: HttpRequest,
file_id: int, file_id: int | str,
file_class: type[SithFile] = SithFile, file_class: type[SithFile] = SithFile,
file_attr: str = "file", file_attr: str = "file",
) -> HttpResponse: ) -> HttpResponse:
@ -97,7 +97,7 @@ def send_file(
deal with it. deal with it.
In debug mode, the server will directly send the file. 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): if not can_view(f, request.user) and not is_logged_in_counter(request):
raise PermissionDenied raise PermissionDenied
name = getattr(f, file_attr).name 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.conf import settings
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.http import HttpRequest, HttpResponse
from django.template.loader import render_to_string
from django.utils.safestring import SafeString
from django.views import View from django.views import View
from django.views.generic.base import ContextMixin, TemplateResponseMixin
class TabedViewMixin(View): class TabedViewMixin(View):
@ -71,3 +77,152 @@ class AllowFragment:
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs["is_fragment"] = self.request.headers.get("HX-Request", False) kwargs["is_fragment"] = self.request.headers.get("HX-Request", False)
return super().get_context_data(**kwargs) return super().get_context_data(**kwargs)
class FragmentRenderer(Protocol):
def __call__(
self, request: HttpRequest, **kwargs: Unpack[dict[str, Any]]
) -> SafeString: ...
class FragmentMixin(TemplateResponseMixin, ContextMixin):
"""Make a view buildable as a fragment that can be embedded in a template.
Most fragments are used in two different ways :
- in the request/response cycle, like any regular view
- in templates, where the rendering is done in another view
This mixin aims to simplify the initial fragment rendering.
The rendered fragment will then be able to re-render itself
through the request/response cycle if it uses HTMX.
!!!Example
```python
class MyFragment(FragmentMixin, FormView):
template_name = "app/fragment.jinja"
form_class = MyForm
success_url = reverse_lazy("foo:bar")
# in another view :
def some_view(request):
fragment = MyFragment.as_fragment()
return render(
request,
"app/template.jinja",
context={"fragment": fragment(request)
}
# in urls.py
urlpatterns = [
path("foo/view", some_view),
path("foo/fragment", MyFragment.as_view()),
]
```
"""
reload_on_redirect: bool = False
"""If True, this fragment will trigger a full page reload on redirect."""
@classmethod
def as_fragment(cls, **initkwargs) -> FragmentRenderer:
# the following code is heavily inspired from the base View.as_view method
for key in initkwargs:
if not hasattr(cls, key):
raise TypeError(
"%s() received an invalid keyword %r. as_view "
"only accepts arguments that are already "
"attributes of the class." % (cls.__name__, key)
)
def fragment(request: HttpRequest, **kwargs) -> SafeString:
self = cls(**initkwargs)
# any POST action on the fragment will be dealt by the fragment itself.
# So, if the view that is rendering this fragment is in a POST context,
# let's pretend anyway it's a GET, in order to be sure the fragment
# won't try to do any POST action (like form validation) on initial render.
self.request = copy.copy(request)
self.request.method = "GET"
self.kwargs = kwargs
return self.render_fragment(request, **kwargs)
fragment.__doc__ = cls.__doc__
fragment.__module__ = cls.__module__
return fragment
def render_fragment(self, request, **kwargs) -> SafeString:
return render_to_string(
self.get_template_names(),
context=self.get_context_data(**kwargs),
request=request,
)
def dispatch(self, *args, **kwargs):
res: HttpResponse = super().dispatch(*args, **kwargs)
if 300 <= res.status_code < 400 and self.reload_on_redirect:
# HTMX doesn't care about redirection codes (because why not),
# so we must transform the redirection code into a 200.
res.status_code = 200
res.headers["HX-Redirect"] = res["Location"]
return res
class UseFragmentsMixin(ContextMixin):
"""Mark a view as using fragments.
This mixin is not mandatory
(you may as well render manually your fragments in the `get_context_data` method).
However, the interface of this class bring some distinction
between fragments and other context data, which may
reduce boilerplate.
!!!Example
```python
class FooFragment(FragmentMixin, FormView): ...
class BarFragment(FragmentMixin, FormView): ...
class AdminFragment(FragmentMixin, FormView): ...
class MyView(UseFragmentsMixin, TemplateView)
template_name = "app/view.jinja"
fragments = {
"foo": FooFragment
"bar": BarFragment(template_name="some_template.jinja")
}
fragments_data = {
"foo": {"some": "data"} # this will be passed to the FooFragment renderer
}
def get_fragments(self):
res = super().get_fragments()
if self.request.user.is_superuser:
res["admin_fragment"] = AdminFragment
return res
```
"""
fragments: dict[LiteralString, type[FragmentMixin] | FragmentRenderer] | None = None
fragment_data: dict[LiteralString, dict[LiteralString, Any]] | None = None
def get_fragments(self) -> dict[str, type[FragmentMixin] | FragmentRenderer]:
return self.fragments if self.fragments is not None else {}
def get_fragment_data(self) -> dict[str, dict[str, Any]]:
"""Return eventual data used to initialize the fragments."""
return self.fragment_data if self.fragment_data is not None else {}
def get_fragment_context_data(self) -> dict[str, SafeString]:
"""Return the rendered fragments as context data."""
res = {}
data = self.get_fragment_data()
for name, fragment in self.get_fragments().items():
is_cls = inspect.isclass(fragment) and issubclass(fragment, FragmentMixin)
_fragment = fragment.as_fragment() if is_cls else fragment
fragment_data = data.get(name, {})
res[name] = _fragment(self.request, **fragment_data)
return res
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs.update(self.get_fragment_context_data())
return kwargs

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -224,7 +224,7 @@ server {
location /static/; location /static/;
root /repertoire/du/projet; root /repertoire/du/projet;
} }
location ~ ^/data/(products|com|club_logos)/ { location ~ ^/data/(products|com|club_logos|upload)/ {
root /repertoire/du/projet; root /repertoire/du/projet;
} }
location ~ ^/data/(SAS|profiles|users|.compressed|.thumbnails)/ { 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 club.models import Club, Membership
from core.models import Group, Page, SithFile, User from core.models import Group, Page, SithFile, User
from core.utils import RED_PIXEL_PNG
from sas.models import Album, PeoplePictureRelation, Picture from sas.models import Album, PeoplePictureRelation, Picture
from subscription.models import Subscription 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 USER_PACK_SIZE: Final[int] = 1000

View File

@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "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" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@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" msgid "You should specify a role"
msgstr "Vous devez choisir un rôle" 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" msgid "You do not have the permission to do that"
msgstr "Vous n'avez pas la permission de faire cela" msgstr "Vous n'avez pas la permission de faire cela"
@ -1047,7 +1047,7 @@ msgid "Posters - edit"
msgstr "Affiche - modifier" msgstr "Affiche - modifier"
#: com/templates/com/poster_list.jinja com/templates/com/screen_list.jinja #: 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" msgid "Create"
msgstr "Créer" msgstr "Créer"
@ -1572,6 +1572,14 @@ msgstr "Ceci n'est pas une miniature de dossier valide"
msgid "You must provide a file" msgid "You must provide a file"
msgstr "Vous devez fournir un fichier" msgstr "Vous devez fournir un fichier"
#: core/models.py
msgid "width"
msgstr "largeur"
#: core/models.py
msgid "height"
msgstr "hauteur"
#: core/models.py #: core/models.py
msgid "page unix name" msgid "page unix name"
msgstr "nom unix de la page" msgstr "nom unix de la page"
@ -1644,6 +1652,10 @@ msgstr "étiquette"
msgid "operation type" msgid "operation type"
msgstr "type d'opération" 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 #: core/templates/core/403.jinja
msgid "403, Forbidden" msgid "403, Forbidden"
msgstr "403, Non autorisé" msgstr "403, Non autorisé"
@ -2729,7 +2741,7 @@ msgstr "Ajouter un nouveau dossier"
msgid "Error creating folder %(folder_name)s: %(msg)s" msgid "Error creating folder %(folder_name)s: %(msg)s"
msgstr "Erreur de création du dossier %(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 #, python-format
msgid "Error uploading file %(file_name)s: %(msg)s" msgid "Error uploading file %(file_name)s: %(msg)s"
msgstr "Erreur d'envoi du fichier %(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" msgid "Upload images"
msgstr "Envoyer les 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 #: sas/forms.py
msgid "You already requested moderation for this picture." msgid "You already requested moderation for this picture."
msgstr "Vous avez déjà déposé une demande de retrait pour cette photo." msgstr "Vous avez déjà déposé une demande de retrait pour cette photo."

View File

@ -7,7 +7,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "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" "PO-Revision-Date: 2024-09-17 11:54+0200\n"
"Last-Translator: Sli <antoine@bartuccio.fr>\n" "Last-Translator: Sli <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@ -33,6 +33,14 @@ msgstr "Dépublier"
msgid "Delete" msgid "Delete"
msgstr "Supprimer" 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 #: com/static/bundled/com/components/moderation-alert-index.ts
#, javascript-format #, javascript-format
msgid "" msgid ""
@ -55,6 +63,40 @@ msgstr "Vous devez taper %(number)s caractères de plus"
msgid "No results found" msgid "No results found"
msgstr "Aucun résultat trouvé" 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 #: core/static/bundled/core/components/easymde-index.ts
msgid "Heading" msgid "Heading"
msgstr "Titre" msgstr "Titre"
@ -107,6 +149,10 @@ msgstr "Insérer lien"
msgid "Insert image" msgid "Insert image"
msgstr "Insérer 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 #: core/static/bundled/core/components/easymde-index.ts
msgid "Insert table" msgid "Insert table"
msgstr "Insérer tableau" msgstr "Insérer tableau"

View File

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

20
package-lock.json generated
View File

@ -27,7 +27,7 @@
"cytoscape-cxtmenu": "^3.5.0", "cytoscape-cxtmenu": "^3.5.0",
"cytoscape-klay": "^3.1.4", "cytoscape-klay": "^3.1.4",
"d3-force-3d": "^3.0.5", "d3-force-3d": "^3.0.5",
"easymde": "^2.18.0", "easymde": "^2.19.0",
"glob": "^11.0.0", "glob": "^11.0.0",
"htmx.org": "^2.0.3", "htmx.org": "^2.0.3",
"jquery": "^3.7.1", "jquery": "^3.7.1",
@ -46,7 +46,7 @@
"@rollup/plugin-inject": "^5.0.5", "@rollup/plugin-inject": "^5.0.5",
"@types/alpinejs": "^3.13.10", "@types/alpinejs": "^3.13.10",
"@types/jquery": "^3.5.31", "@types/jquery": "^3.5.31",
"vite": "^6.2.3", "vite": "^6.2.5",
"vite-bundle-visualizer": "^1.2.1", "vite-bundle-visualizer": "^1.2.1",
"vite-plugin-static-copy": "^2.1.0" "vite-plugin-static-copy": "^2.1.0"
} }
@ -3663,14 +3663,14 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/easymde": { "node_modules/easymde": {
"version": "2.18.0", "version": "2.19.0",
"resolved": "https://registry.npmjs.org/easymde/-/easymde-2.18.0.tgz", "resolved": "https://registry.npmjs.org/easymde/-/easymde-2.19.0.tgz",
"integrity": "sha512-IxVVUxNWIoXLeqtBU4BLc+eS/ScYhT1Dcb6yF5Wchoj1iXAV+TIIDWx+NCaZhY7RcSHqDPKllbYq7nwGKILnoA==", "integrity": "sha512-4F1aNImqse+9xIjLh9ttfpOVenecjFPxUmKbl1tGp72Z+OyIqLZPE/SgNyy88c/xU0mOy0WC3+tfbZDQ5PDWhg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/codemirror": "^5.60.4", "@types/codemirror": "^5.60.10",
"@types/marked": "^4.0.7", "@types/marked": "^4.0.7",
"codemirror": "^5.63.1", "codemirror": "^5.65.15",
"codemirror-spell-checker": "1.1.2", "codemirror-spell-checker": "1.1.2",
"marked": "^4.1.0" "marked": "^4.1.0"
} }
@ -5705,9 +5705,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "6.2.3", "version": "6.2.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.3.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.5.tgz",
"integrity": "sha512-IzwM54g4y9JA/xAeBPNaDXiBF8Jsgl3VBQ2YQ/wOY6fyW3xMdSoltIV3Bo59DErdqdE6RxUfv8W69DvUorE4Eg==", "integrity": "sha512-j023J/hCAa4pRIUH6J9HemwYfjB5llR2Ps0CWeikOtdR8+pAURAk0DoJC5/mm9kd+UgdnIy7d6HE4EAvlYhPhA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@ -31,7 +31,7 @@
"@rollup/plugin-inject": "^5.0.5", "@rollup/plugin-inject": "^5.0.5",
"@types/alpinejs": "^3.13.10", "@types/alpinejs": "^3.13.10",
"@types/jquery": "^3.5.31", "@types/jquery": "^3.5.31",
"vite": "^6.2.3", "vite": "^6.2.5",
"vite-bundle-visualizer": "^1.2.1", "vite-bundle-visualizer": "^1.2.1",
"vite-plugin-static-copy": "^2.1.0" "vite-plugin-static-copy": "^2.1.0"
}, },
@ -54,7 +54,7 @@
"cytoscape-cxtmenu": "^3.5.0", "cytoscape-cxtmenu": "^3.5.0",
"cytoscape-klay": "^3.1.4", "cytoscape-klay": "^3.1.4",
"d3-force-3d": "^3.0.5", "d3-force-3d": "^3.0.5",
"easymde": "^2.18.0", "easymde": "^2.19.0",
"glob": "^11.0.0", "glob": "^11.0.0",
"htmx.org": "^2.0.3", "htmx.org": "^2.0.3",
"jquery": "^3.7.1", "jquery": "^3.7.1",

View File

@ -43,7 +43,7 @@ dependencies = [
"tomli<3.0.0,>=2.2.1", "tomli<3.0.0,>=2.2.1",
"django-honeypot<2.0.0,>=1.2.1", "django-honeypot<2.0.0,>=1.2.1",
"pydantic-extra-types<3.0.0,>=2.10.2", "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", "redis[hiredis]<6.0.0,>=5.2.0",
"environs[django]<15.0.0,>=14.1.0", "environs[django]<15.0.0,>=14.1.0",
"requests>=2.32.3", "requests>=2.32.3",

View File

@ -1,7 +1,10 @@
from typing import Any, Literal
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError
from django.db.models import F from django.db.models import F
from django.urls import reverse 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 import ControllerBase, api_controller, paginate, route
from ninja_extra.exceptions import NotFound, PermissionDenied from ninja_extra.exceptions import NotFound, PermissionDenied
from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.pagination import PageNumberPaginationExtra
@ -9,8 +12,15 @@ from ninja_extra.permissions import IsAuthenticated
from ninja_extra.schemas import PaginatedResponseSchema from ninja_extra.schemas import PaginatedResponseSchema
from pydantic import NonNegativeInt 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.models import Notification, User
from core.schemas import UploadedImage
from sas.models import Album, PeoplePictureRelation, Picture from sas.models import Album, PeoplePictureRelation, Picture
from sas.schemas import ( from sas.schemas import (
AlbumAutocompleteSchema, AlbumAutocompleteSchema,
@ -92,6 +102,38 @@ class PicturesController(ControllerBase):
.annotate(album=F("parent__name")) .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( @route.get(
"/{picture_id}/identified", "/{picture_id}/identified",
permissions=[IsAuthenticated, CanView], permissions=[IsAuthenticated, CanView],

View File

@ -1,6 +1,7 @@
from typing import Any from typing import Any
from django import forms from django import forms
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.models import User from core.models import User
@ -11,55 +12,28 @@ from sas.models import Album, Picture, PictureModerationRequest
from sas.widgets.ajax_select import AutoCompleteSelectAlbum from sas.widgets.ajax_select import AutoCompleteSelectAlbum
class SASForm(forms.Form): class AlbumCreateForm(forms.ModelForm):
album_name = forms.CharField( class Meta:
label=_("Add a new album"), max_length=Album.NAME_MAX_LENGTH, required=False model = Album
) fields = ["name", "parent"]
images = MultipleImageField( labels = {"name": _("Add a new album")}
label=_("Upload images"), widgets = {"parent": forms.HiddenInput}
required=False,
)
def process(self, parent, owner, files, *, automodere=False): def __init__(self, *args, owner: User, **kwargs):
try: super().__init__(*args, **kwargs)
if self.cleaned_data["album_name"] != "": self.instance.owner = owner
album = Album( if owner.has_perm("sas.moderate_sasfile"):
parent=parent, self.instance.is_moderated = True
name=self.cleaned_data["album_name"], self.instance.moderator = owner
owner=owner,
is_moderated=automodere, def clean(self):
) if not self.instance.owner.can_edit(self.instance.parent):
album.clean() raise ValidationError(_("You do not have the permission to do that"))
album.save() return super().clean()
except Exception as e:
self.add_error(
None, class PictureUploadForm(forms.Form):
_("Error creating album %(album)s: %(msg)s") images = MultipleImageField(label=_("Upload images"), required=False)
% {"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)},
)
class PictureEditForm(forms.ModelForm): class PictureEditForm(forms.ModelForm):

View File

@ -134,7 +134,6 @@ class Picture(SasFile):
self.thumbnail.name = new_extension_name self.thumbnail.name = new_extension_name
self.compressed = compressed self.compressed = compressed
self.compressed.name = new_extension_name self.compressed.name = new_extension_name
self.save()
def rotate(self, degree): def rotate(self, degree):
for attr in ["file", "compressed", "thumbnail"]: for attr in ["file", "compressed", "thumbnail"]:
@ -235,6 +234,8 @@ class Album(SasFile):
return Album.objects.filter(parent=self) return Album.objects.filter(parent=self)
def get_absolute_url(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}) return reverse("sas:album", kwargs={"album_id": self.id})
def get_download_url(self): def get_download_url(self):

View File

@ -5,8 +5,10 @@ import {
type AlbumSchema, type AlbumSchema,
type PictureSchema, type PictureSchema,
type PicturesFetchPicturesData, type PicturesFetchPicturesData,
type PicturesUploadPictureErrors,
albumFetchAlbum, albumFetchAlbum,
picturesFetchPictures, picturesFetchPictures,
picturesUploadPicture,
} from "#openapi"; } from "#openapi";
interface AlbumPicturesConfig { interface AlbumPicturesConfig {
@ -78,4 +80,49 @@ document.addEventListener("alpine:init", () => {
this.loading = false; 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> <div class="text">{% trans %}To be moderated{% endtrans %}</div>
</template> </template>
</div> </div>
{% if edit_mode %} {% if is_sas_admin %}
<input type="checkbox" name="file_list" :value="album.id"> <input type="checkbox" name="file_list" :value="album.id">
{% endif %} {% endif %}
</a> </a>
@ -86,7 +86,7 @@
<h4>{% trans %}Pictures{% endtrans %}</h4> <h4>{% trans %}Pictures{% endtrans %}</h4>
<br> <br>
{{ download_button(_("Download album")) }} {{ 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)"> <template x-for="picture in getPage(page)">
<a :href="picture.sas_url"> <a :href="picture.sas_url">
<div class="photo" :class="{not_moderated: !picture.is_moderated}"> <div class="photo" :class="{not_moderated: !picture.is_moderated}">
@ -110,13 +110,28 @@
{% if is_sas_admin %} {% if is_sas_admin %}
</form> </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 %} {% csrf_token %}
<div class="inputs"> <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 %}" /> <input type="submit" value="{% trans %}Upload{% endtrans %}" />
<progress x-ref="progress" x-show="sending"></progress>
</div> </div>
<ul class="errorlist">
<template x-for="error in errors">
<li class="error" x-text="error"></li>
</template>
</ul>
</form> </form>
{% endif %} {% endif %}
@ -126,115 +141,3 @@
{{ timezone.now() - start }} {{ timezone.now() - start }}
</p> </p>
{% endblock %} {% 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 %} {% if is_sas_admin %}
</form> </form>
<br> <br>
{{ album_create_fragment }}
<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>
{% endif %} {% endif %}
{% endif %} {% endif %}
</main> </main>

View File

@ -1,13 +1,16 @@
import pytest
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.core.files.uploadedfile import SimpleUploadedFile
from django.db import transaction from django.db import transaction
from django.test import TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from model_bakery import baker from model_bakery import baker
from model_bakery.recipe import Recipe from model_bakery.recipe import Recipe
from core.baker_recipes import old_subscriber_user, subscriber_user from core.baker_recipes import old_subscriber_user, subscriber_user
from core.models import Group, SithFile, User from core.models import Group, SithFile, User
from core.utils import RED_PIXEL_PNG
from sas.baker_recipes import picture_recipe from sas.baker_recipes import picture_recipe
from sas.models import Album, PeoplePictureRelation, Picture, PictureModerationRequest from sas.models import Album, PeoplePictureRelation, Picture, PictureModerationRequest
@ -241,3 +244,45 @@ class TestAlbumSearch(TestSas):
# - 1 for pagination # - 1 for pagination
# - 1 for the actual results # - 1 for the actual results
self.client.get(reverse("api:search-album")) 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 django.urls import path
from sas.views import ( from sas.views import (
AlbumCreateFragment,
AlbumEditView, AlbumEditView,
AlbumUploadView,
AlbumView, AlbumView,
ModerationView, ModerationView,
PictureAskRemovalView, PictureAskRemovalView,
@ -35,9 +35,6 @@ urlpatterns = [
path("", SASMainView.as_view(), name="main"), path("", SASMainView.as_view(), name="main"),
path("moderation/", ModerationView.as_view(), name="moderation"), path("moderation/", ModerationView.as_view(), name="moderation"),
path("album/<int:album_id>/", AlbumView.as_view(), name="album"), 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>/edit/", AlbumEditView.as_view(), name="album_edit"),
path("album/<int:album_id>/preview/", send_album, name="album_preview"), path("album/<int:album_id>/preview/", send_album, name="album_preview"),
path("picture/<int:picture_id>/", PictureView.as_view(), name="picture"), path("picture/<int:picture_id>/", PictureView.as_view(), name="picture"),
@ -59,4 +56,5 @@ urlpatterns = [
path( path(
"user/<int:user_id>/pictures/", UserPicturesView.as_view(), name="user_pictures" "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.conf import settings
from django.core.exceptions import PermissionDenied 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.shortcuts import get_object_or_404
from django.urls import reverse, reverse_lazy from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.safestring import SafeString
from django.views.generic import DetailView, TemplateView from django.views.generic import CreateView, DetailView, TemplateView
from django.views.generic.edit import FormMixin, FormView, UpdateView from django.views.generic.edit import FormView, UpdateView
from core.auth.mixins import CanEditMixin, CanViewMixin from core.auth.mixins import CanEditMixin, CanViewMixin
from core.models import SithFile, User from core.models import SithFile, User
from core.views import UseFragmentsMixin
from core.views.files import FileView, send_file from core.views.files import FileView, send_file
from core.views.mixins import FragmentMixin, FragmentRenderer
from core.views.user import UserTabsMixin from core.views.user import UserTabsMixin
from sas.forms import ( from sas.forms import (
AlbumCreateForm,
AlbumEditForm, AlbumEditForm,
PictureEditForm, PictureEditForm,
PictureModerationRequestForm, PictureModerationRequestForm,
SASForm, PictureUploadForm,
) )
from sas.models import Album, Picture from sas.models import Album, Picture
class SASMainView(FormView): class AlbumCreateFragment(FragmentMixin, CreateView):
form_class = SASForm model = Album
template_name = "sas/main.jinja" form_class = AlbumCreateForm
success_url = reverse_lazy("sas:main") template_name = "sas/fragments/album_create_form.jinja"
reload_on_redirect = True
def post(self, request, *args, **kwargs): def get_form_kwargs(self):
self.form = self.get_form() return super().get_form_kwargs() | {"owner": self.request.user}
parent = SithFile.objects.filter(id=settings.SITH_SAS_ROOT_DIR_ID).first()
files = request.FILES.getlist("images") def render_fragment(
root = User.objects.filter(username="root").first() self, request, owner: User | None = None, **kwargs
if request.user.is_authenticated and request.user.is_in_group( ) -> SafeString:
pk=settings.SITH_GROUP_SAS_ADMIN_ID self.object = None
): self.owner = owner or self.request.user
if self.form.is_valid(): return super().render_fragment(request, **kwargs)
self.form.process(
parent=parent, owner=root, files=files, automodere=True def get_success_url(self):
) parent = self.object.parent
if self.form.is_valid(): parent.__class__ = Album
return super().form_valid(self.form) return parent.get_absolute_url()
else:
self.form.add_error(None, _("You do not have the permission to do that"))
return self.form_invalid(self.form) 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): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**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") return send_file(request, picture_id, Picture, "thumbnail")
class AlbumUploadView(CanViewMixin, DetailView, FormMixin): class AlbumView(CanViewMixin, UseFragmentsMixin, DetailView):
model = Album 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" pk_url_kwarg = "album_id"
template_name = "sas/album.jinja" 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): def dispatch(self, request, *args, **kwargs):
try: try:
self.asked_page = int(request.GET.get("page", 1)) self.asked_page = int(request.GET.get("page", 1))
except ValueError as e: except ValueError as e:
raise Http404 from 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: if "clipboard" not in request.session:
request.session["clipboard"] = [] request.session["clipboard"] = []
return super().get(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()
if not self.object.file: if not self.object.file:
self.object.generate_thumbnail() 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 if request.user.can_edit(self.object): # Handle the copy-paste functions
FileView.handle_clipboard(request, self.object) FileView.handle_clipboard(request, self.object)
parent = SithFile.objects.filter(id=self.object.id).first() return HttpResponseRedirect(self.request.path)
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)
def get_success_url(self): def get_fragment_data(self) -> dict[str, dict[str, Any]]:
return reverse("sas:album", kwargs={"album_id": self.object.id}) return {"album_create_fragment": {"owner": self.request.user}}
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
kwargs["form"] = self.form if ids := self.request.session.get("clipboard", None):
kwargs["clipboard"] = SithFile.objects.filter( kwargs["clipboard"] = SithFile.objects.filter(id__in=ids)
id__in=self.request.session["clipboard"] 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"] = ( kwargs["show_albums"] = (
Album.objects.viewable_by(self.request.user) Album.objects.viewable_by(self.request.user)
.filter(parent_id=self.object.id) .filter(parent_id=self.object.id)

View File

@ -78,10 +78,12 @@ DEBUG = env.bool("SITH_DEBUG", default=False)
TESTING = "pytest" in sys.modules TESTING = "pytest" in sys.modules
INTERNAL_IPS = ["127.0.0.1"] INTERNAL_IPS = ["127.0.0.1"]
HTTPS = env.bool("HTTPS", default=True)
# force csrf tokens and cookies to be secure when in https # 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=[]) 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" X_FRAME_OPTIONS = "SAMEORIGIN"
ALLOWED_HOSTS = ["*"] ALLOWED_HOSTS = ["*"]

9
uv.lock generated
View File

@ -1,5 +1,4 @@
version = 1 version = 1
revision = 1
requires-python = ">=3.12, <4.0" requires-python = ">=3.12, <4.0"
[[package]] [[package]]
@ -640,7 +639,7 @@ wheels = [
[[package]] [[package]]
name = "ical" name = "ical"
version = "8.3.1" version = "9.1.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "pydantic" }, { name = "pydantic" },
@ -648,9 +647,9 @@ dependencies = [
{ name = "python-dateutil" }, { name = "python-dateutil" },
{ name = "tzdata" }, { 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 = [ 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]] [[package]]
@ -1640,7 +1639,7 @@ requires-dist = [
{ name = "django-simple-captcha", specifier = ">=0.6.2,<1.0.0" }, { name = "django-simple-captcha", specifier = ">=0.6.2,<1.0.0" },
{ name = "environs", extras = ["django"], specifier = ">=14.1.0,<15.0.0" }, { name = "environs", extras = ["django"], specifier = ">=14.1.0,<15.0.0" },
{ name = "honcho", specifier = ">=2.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 = "jinja2", specifier = ">=3.1.6,<4.0.0" },
{ name = "libsass", specifier = ">=0.23.0,<1.0.0" }, { name = "libsass", specifier = ">=0.23.0,<1.0.0" },
{ name = "mistune", specifier = ">=3.1.2,<4.0.0" }, { name = "mistune", specifier = ">=3.1.2,<4.0.0" },