mirror of
https://github.com/ae-utbm/sith.git
synced 2025-04-15 02:20:20 +00:00
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:
commit
3c8933461a
@ -1,9 +1,11 @@
|
||||
from pathlib import Path
|
||||
from typing import final
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.conf import settings
|
||||
from django.contrib.sites.models import Site
|
||||
from django.contrib.syndication.views import add_domain
|
||||
from django.db.models import F, QuerySet
|
||||
from django.http import HttpRequest
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from ical.calendar import Calendar
|
||||
@ -14,7 +16,14 @@ from com.models import NewsDate
|
||||
from core.models import User
|
||||
|
||||
|
||||
@final
|
||||
def as_absolute_url(url: str, request: HttpRequest | None = None) -> str:
|
||||
return add_domain(
|
||||
Site.objects.get_current(request=request),
|
||||
url,
|
||||
secure=request.is_secure() if request is not None else settings.HTTPS,
|
||||
)
|
||||
|
||||
|
||||
class IcsCalendar:
|
||||
_CACHE_FOLDER: Path = settings.MEDIA_ROOT / "com" / "calendars"
|
||||
_INTERNAL_CALENDAR = _CACHE_FOLDER / "internal.ics"
|
||||
@ -58,7 +67,9 @@ class IcsCalendar:
|
||||
summary=news_date.news_title,
|
||||
start=news_date.start_date,
|
||||
end=news_date.end_date,
|
||||
url=reverse("com:news_detail", kwargs={"news_id": news_date.news.id}),
|
||||
url=as_absolute_url(
|
||||
reverse("com:news_detail", kwargs={"news_id": news_date.news.id})
|
||||
),
|
||||
)
|
||||
calendar.events.append(event)
|
||||
|
||||
|
@ -44,7 +44,18 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
||||
return this.isMobile() ? "listMonth" : "dayGridMonth";
|
||||
}
|
||||
|
||||
currentToolbar() {
|
||||
currentFooterToolbar() {
|
||||
if (this.isMobile()) {
|
||||
return {
|
||||
start: "",
|
||||
center: "getCalendarLink",
|
||||
end: "",
|
||||
};
|
||||
}
|
||||
return { start: "getCalendarLink", center: "", end: "" };
|
||||
}
|
||||
|
||||
currentHeaderToolbar() {
|
||||
if (this.isMobile()) {
|
||||
return {
|
||||
left: "prev,next",
|
||||
@ -303,14 +314,44 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
||||
this.calendar = new Calendar(this.node, {
|
||||
plugins: [dayGridPlugin, iCalendarPlugin, listPlugin],
|
||||
locales: [frLocale, enLocale],
|
||||
customButtons: {
|
||||
getCalendarLink: {
|
||||
text: gettext("Copy calendar link"),
|
||||
click: async (event: Event) => {
|
||||
const button = event.target as HTMLButtonElement;
|
||||
button.classList.add("text-copy");
|
||||
if (!button.hasAttribute("position")) {
|
||||
button.setAttribute("tooltip", gettext("Link copied"));
|
||||
button.setAttribute("position", "top");
|
||||
button.setAttribute("no-hover", "");
|
||||
}
|
||||
if (button.classList.contains("text-copied")) {
|
||||
button.classList.remove("text-copied");
|
||||
}
|
||||
navigator.clipboard.writeText(
|
||||
new URL(
|
||||
await makeUrl(calendarCalendarInternal),
|
||||
window.location.origin,
|
||||
).toString(),
|
||||
);
|
||||
setTimeout(() => {
|
||||
button.classList.remove("text-copied");
|
||||
button.classList.add("text-copied");
|
||||
button.classList.remove("text-copy");
|
||||
}, 1500);
|
||||
},
|
||||
},
|
||||
},
|
||||
height: "auto",
|
||||
locale: this.locale,
|
||||
initialView: this.currentView(),
|
||||
headerToolbar: this.currentToolbar(),
|
||||
headerToolbar: this.currentHeaderToolbar(),
|
||||
footerToolbar: this.currentFooterToolbar(),
|
||||
eventSources: await this.getEventSources(),
|
||||
windowResize: () => {
|
||||
this.calendar.changeView(this.currentView());
|
||||
this.calendar.setOption("headerToolbar", this.currentToolbar());
|
||||
this.calendar.setOption("headerToolbar", this.currentHeaderToolbar());
|
||||
this.calendar.setOption("footerToolbar", this.currentFooterToolbar());
|
||||
},
|
||||
eventClick: (event) => {
|
||||
// Avoid our popup to be deleted because we clicked outside of it
|
||||
|
@ -98,4 +98,26 @@ ics-calendar {
|
||||
background: white;
|
||||
}
|
||||
}
|
||||
|
||||
.fc .fc-toolbar.fc-footer-toolbar {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
button.text-copy,
|
||||
button.text-copy:focus,
|
||||
button.text-copy:hover {
|
||||
background-color: #67AE6E !important;
|
||||
transition: 500ms ease-in;
|
||||
}
|
||||
|
||||
button.text-copied,
|
||||
button.text-copied:focus,
|
||||
button.text-copied:hover {
|
||||
transition: 500ms ease-out;
|
||||
}
|
||||
|
||||
button.text-copied[tooltip]::before {
|
||||
opacity: 0;
|
||||
transition: opacity 500ms ease-out;
|
||||
}
|
||||
}
|
@ -56,9 +56,11 @@
|
||||
#upcoming-events {
|
||||
max-height: 600px;
|
||||
overflow-y: scroll;
|
||||
overflow-x: clip;
|
||||
|
||||
#load-more-news-button {
|
||||
text-align: center;
|
||||
|
||||
button {
|
||||
width: 150px;
|
||||
}
|
||||
@ -194,6 +196,7 @@
|
||||
img {
|
||||
height: 75px;
|
||||
}
|
||||
|
||||
.header_content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
@ -17,7 +17,16 @@ from django.contrib import admin
|
||||
from django.contrib.auth.models import Group as AuthGroup
|
||||
from django.contrib.auth.models import Permission
|
||||
|
||||
from core.models import BanGroup, Group, OperationLog, Page, SithFile, User, UserBan
|
||||
from core.models import (
|
||||
BanGroup,
|
||||
Group,
|
||||
OperationLog,
|
||||
Page,
|
||||
QuickUploadImage,
|
||||
SithFile,
|
||||
User,
|
||||
UserBan,
|
||||
)
|
||||
|
||||
admin.site.unregister(AuthGroup)
|
||||
|
||||
@ -89,3 +98,11 @@ class OperationLogAdmin(admin.ModelAdmin):
|
||||
list_display = ("label", "operator", "operation_type", "date")
|
||||
search_fields = ("label", "date", "operation_type")
|
||||
autocomplete_fields = ("operator",)
|
||||
|
||||
|
||||
@admin.register(QuickUploadImage)
|
||||
class QuickUploadImageAdmin(admin.ModelAdmin):
|
||||
list_display = ("uuid", "uploader", "created_at", "name")
|
||||
search_fields = ("uuid", "uploader", "name")
|
||||
autocomplete_fields = ("uploader",)
|
||||
readonly_fields = ("width", "height", "size")
|
||||
|
29
core/api.py
29
core/api.py
@ -1,23 +1,25 @@
|
||||
from typing import Annotated
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
import annotated_types
|
||||
from django.conf import settings
|
||||
from django.db.models import F
|
||||
from django.http import HttpResponse
|
||||
from ninja import Query
|
||||
from ninja import File, Query
|
||||
from ninja_extra import ControllerBase, api_controller, paginate, route
|
||||
from ninja_extra.exceptions import PermissionDenied
|
||||
from ninja_extra.pagination import PageNumberPaginationExtra
|
||||
from ninja_extra.schemas import PaginatedResponseSchema
|
||||
|
||||
from club.models import Mailing
|
||||
from core.auth.api_permissions import CanAccessLookup, CanView
|
||||
from core.models import Group, SithFile, User
|
||||
from core.auth.api_permissions import CanAccessLookup, CanView, HasPerm
|
||||
from core.models import Group, QuickUploadImage, SithFile, User
|
||||
from core.schemas import (
|
||||
FamilyGodfatherSchema,
|
||||
GroupSchema,
|
||||
MarkdownSchema,
|
||||
SithFileSchema,
|
||||
UploadedFileSchema,
|
||||
UploadedImage,
|
||||
UserFamilySchema,
|
||||
UserFilterSchema,
|
||||
UserProfileSchema,
|
||||
@ -33,6 +35,25 @@ class MarkdownController(ControllerBase):
|
||||
return HttpResponse(markdown(body.text), content_type="text/html")
|
||||
|
||||
|
||||
@api_controller("/upload")
|
||||
class UploadController(ControllerBase):
|
||||
@route.post(
|
||||
"/image",
|
||||
response={
|
||||
200: UploadedFileSchema,
|
||||
422: dict[Literal["detail"], list[dict[str, Any]]],
|
||||
403: dict[Literal["detail"], str],
|
||||
},
|
||||
permissions=[HasPerm("core.add_quickuploadimage")],
|
||||
url_name="quick_upload_image",
|
||||
)
|
||||
def upload_image(self, file: File[UploadedImage]):
|
||||
image = QuickUploadImage.create_from_uploaded(
|
||||
file, uploader=self.context.request.user
|
||||
)
|
||||
return image
|
||||
|
||||
|
||||
@api_controller("/mailings")
|
||||
class MailingListController(ControllerBase):
|
||||
@route.get("", response=str)
|
||||
|
@ -740,8 +740,9 @@ Welcome to the wiki page!
|
||||
size=file.size,
|
||||
)
|
||||
pict.file.name = p.name
|
||||
pict.clean()
|
||||
pict.full_clean()
|
||||
pict.generate_thumbnails()
|
||||
pict.save()
|
||||
|
||||
img_skia = Picture.objects.get(name="skia.jpg")
|
||||
img_sli = Picture.objects.get(name="sli.jpg")
|
||||
@ -827,6 +828,7 @@ Welcome to the wiki page!
|
||||
"view_peoplepicturerelation",
|
||||
"add_peoplepicturerelation",
|
||||
"add_page",
|
||||
"add_quickuploadimage",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
56
core/migrations/0045_quickuploadimage.py
Normal file
56
core/migrations/0045_quickuploadimage.py
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
@ -17,7 +17,7 @@
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along with
|
||||
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
|
||||
# this program; if not, write to the Free Software Foundation, Inc., 59 Temple
|
||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||
#
|
||||
#
|
||||
@ -32,6 +32,7 @@ from datetime import timedelta
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Optional, Self
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AbstractUser, UserManager
|
||||
@ -41,6 +42,8 @@ from django.contrib.staticfiles.storage import staticfiles_storage
|
||||
from django.core import validators
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.core.files import File
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.mail import send_mail
|
||||
from django.db import models, transaction
|
||||
from django.db.models import Exists, F, OuterRef, Q
|
||||
@ -51,9 +54,10 @@ from django.utils.html import escape
|
||||
from django.utils.timezone import localdate, now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from phonenumber_field.modelfields import PhoneNumberField
|
||||
from PIL import Image
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from pydantic import NonNegativeInt
|
||||
|
||||
from club.models import Club
|
||||
@ -1102,6 +1106,68 @@ class SithFile(models.Model):
|
||||
return reverse("core:download", kwargs={"file_id": self.id})
|
||||
|
||||
|
||||
class QuickUploadImage(models.Model):
|
||||
"""Images uploaded by user outside of the SithFile mechanism"""
|
||||
|
||||
IMAGE_NAME_SIZE = 100
|
||||
MAX_IMAGE_SIZE = 600 # Maximum px on width / length
|
||||
|
||||
uuid = models.UUIDField(unique=True, db_index=True)
|
||||
name = models.CharField(max_length=IMAGE_NAME_SIZE, blank=False)
|
||||
image = models.ImageField(
|
||||
upload_to="upload/%Y/%m/%d",
|
||||
width_field="width",
|
||||
height_field="height",
|
||||
unique=True,
|
||||
)
|
||||
uploader = models.ForeignKey(
|
||||
"User",
|
||||
related_name="quick_uploads",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
)
|
||||
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
|
||||
width = models.PositiveIntegerField(_("width"))
|
||||
height = models.PositiveIntegerField(_("height"))
|
||||
size = models.PositiveIntegerField(_("size"))
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self.image.path)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return self.image.url
|
||||
|
||||
@classmethod
|
||||
def create_from_uploaded(
|
||||
cls, image: UploadedFile, uploader: User | None = None
|
||||
) -> Self:
|
||||
def convert_image(file: UploadedFile) -> ContentFile:
|
||||
content = BytesIO()
|
||||
image = Image.open(BytesIO(file.read()))
|
||||
if image.width > cls.MAX_IMAGE_SIZE or image.height > cls.MAX_IMAGE_SIZE:
|
||||
image = ImageOps.contain(image, (600, 600))
|
||||
image.save(fp=content, format="webp", optimize=True)
|
||||
return ContentFile(content.getvalue())
|
||||
|
||||
identifier = str(uuid4())
|
||||
name = Path(image.name).stem[: cls.IMAGE_NAME_SIZE - 1]
|
||||
file = File(convert_image(image), name=f"{identifier}.webp")
|
||||
width, height = Image.open(file).size
|
||||
|
||||
return cls.objects.create(
|
||||
uuid=identifier,
|
||||
name=name,
|
||||
image=file,
|
||||
uploader=uploader,
|
||||
size=file.size,
|
||||
)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
self.image.delete(save=False)
|
||||
return super().delete(*args, **kwargs)
|
||||
|
||||
|
||||
class LockError(Exception):
|
||||
"""There was a lock error on the object."""
|
||||
|
||||
|
@ -1,16 +1,29 @@
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
from typing import Annotated, Any
|
||||
|
||||
from annotated_types import MinLen
|
||||
from django.contrib.staticfiles.storage import staticfiles_storage
|
||||
from django.db.models import Q
|
||||
from django.urls import reverse
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import gettext as _
|
||||
from haystack.query import SearchQuerySet
|
||||
from ninja import FilterSchema, ModelSchema, Schema
|
||||
from ninja import FilterSchema, ModelSchema, Schema, UploadedFile
|
||||
from pydantic import AliasChoices, Field
|
||||
from pydantic_core.core_schema import ValidationInfo
|
||||
|
||||
from core.models import Group, SithFile, User
|
||||
from core.models import Group, QuickUploadImage, SithFile, User
|
||||
from core.utils import is_image
|
||||
|
||||
|
||||
class UploadedImage(UploadedFile):
|
||||
@classmethod
|
||||
def _validate(cls, v: Any, info: ValidationInfo) -> Any:
|
||||
super()._validate(v, info)
|
||||
if not is_image(v):
|
||||
msg = _("This file is not a valid image")
|
||||
raise ValueError(msg)
|
||||
return v
|
||||
|
||||
|
||||
class SimpleUserSchema(ModelSchema):
|
||||
@ -47,6 +60,18 @@ class UserProfileSchema(ModelSchema):
|
||||
return reverse("core:download", kwargs={"file_id": obj.profile_pict_id})
|
||||
|
||||
|
||||
class UploadedFileSchema(ModelSchema):
|
||||
class Meta:
|
||||
model = QuickUploadImage
|
||||
fields = ["uuid", "name", "width", "height", "size"]
|
||||
|
||||
href: str
|
||||
|
||||
@staticmethod
|
||||
def resolve_href(obj: QuickUploadImage) -> str:
|
||||
return obj.get_absolute_url()
|
||||
|
||||
|
||||
class SithFileSchema(ModelSchema):
|
||||
class Meta:
|
||||
model = SithFile
|
||||
|
@ -6,13 +6,58 @@ import { inheritHtmlElement, registerComponent } from "#core:utils/web-component
|
||||
import type CodeMirror from "codemirror";
|
||||
// biome-ignore lint/style/useNamingConvention: This is how they called their namespace
|
||||
import EasyMDE from "easymde";
|
||||
import { markdownRenderMarkdown } from "#openapi";
|
||||
import {
|
||||
type UploadUploadImageErrors,
|
||||
markdownRenderMarkdown,
|
||||
uploadUploadImage,
|
||||
} from "#openapi";
|
||||
|
||||
const loadEasyMde = (textarea: HTMLTextAreaElement) => {
|
||||
new EasyMDE({
|
||||
const easymde = new EasyMDE({
|
||||
element: textarea,
|
||||
spellChecker: false,
|
||||
autoDownloadFontAwesome: false,
|
||||
uploadImage: true,
|
||||
imagePathAbsolute: false,
|
||||
imageUploadFunction: async (file, onSuccess, onError) => {
|
||||
const response = await uploadUploadImage({
|
||||
body: {
|
||||
file: file,
|
||||
},
|
||||
});
|
||||
if (!response.response.ok) {
|
||||
if (response.response.status === 422) {
|
||||
onError(
|
||||
(response.error as UploadUploadImageErrors[422]).detail
|
||||
.map((err: Record<"ctx", Record<"error", string>>) => err.ctx.error)
|
||||
.join(" ; "),
|
||||
);
|
||||
} else if (response.response.status === 403) {
|
||||
onError(gettext("You are not authorized to use this feature"));
|
||||
} else {
|
||||
onError(gettext("Could not upload image"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
onSuccess(response.data.href);
|
||||
// Workaround function to add an image name to uploaded image
|
||||
// Without this, you get  instead of 
|
||||
let cursor = easymde.codemirror.getCursor();
|
||||
|
||||
easymde.codemirror.setSelection({
|
||||
line: cursor.line,
|
||||
ch: cursor.ch - response.data.href.length - 3,
|
||||
});
|
||||
easymde.codemirror.replaceSelection(response.data.name);
|
||||
|
||||
// Move cursor at the end of the url and add a new line
|
||||
cursor = easymde.codemirror.getCursor();
|
||||
easymde.codemirror.setSelection({
|
||||
line: cursor.line,
|
||||
ch: cursor.ch + response.data.href.length + 3,
|
||||
});
|
||||
easymde.codemirror.replaceSelection("\n");
|
||||
},
|
||||
previewRender: (plainText: string, preview: MarkdownInput) => {
|
||||
/* This is wrapped this way to allow time for Alpine to be loaded on the page */
|
||||
return Alpine.debounce((plainText: string, preview: MarkdownInput) => {
|
||||
@ -30,6 +75,14 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
|
||||
}, 300)(plainText, preview);
|
||||
},
|
||||
forceSync: true, // Avoid validation error on generic create view
|
||||
imageTexts: {
|
||||
sbInit: gettext("Attach files by drag and dropping or pasting from clipboard."),
|
||||
sbOnDragEnter: gettext("Drop image to upload it."),
|
||||
sbOnDrop: gettext("Uploading image #images_names# …"),
|
||||
sbProgress: gettext("Uploading #file_name#: #progress#%"),
|
||||
sbOnUploaded: gettext("Uploaded #image_name#"),
|
||||
sizeUnits: gettext(" B, KB, MB"),
|
||||
},
|
||||
toolbar: [
|
||||
{
|
||||
name: "heading-smaller",
|
||||
@ -120,6 +173,12 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
|
||||
className: "fa-regular fa-image",
|
||||
title: gettext("Insert image"),
|
||||
},
|
||||
{
|
||||
name: "upload-image",
|
||||
action: EasyMDE.drawUploadedImage,
|
||||
className: "fa-solid fa-file-arrow-up",
|
||||
title: gettext("Upload image"),
|
||||
},
|
||||
{
|
||||
name: "table",
|
||||
action: EasyMDE.drawTable,
|
||||
|
@ -33,8 +33,8 @@ $hovered-red-text-color: #ff4d4d;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
|
||||
> a {
|
||||
color: $text-color!important;
|
||||
>a {
|
||||
color: $text-color !important;
|
||||
}
|
||||
|
||||
&:hover>a {
|
||||
@ -207,7 +207,7 @@ $hovered-red-text-color: #ff4d4d;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
> a {
|
||||
>a {
|
||||
display: block;
|
||||
min-width: 40px;
|
||||
height: 40px;
|
||||
@ -251,11 +251,13 @@ $hovered-red-text-color: #ff4d4d;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
a, button {
|
||||
a,
|
||||
button {
|
||||
font-size: 100%;
|
||||
margin: 0;
|
||||
text-align: right;
|
||||
color: $text-color;
|
||||
margin-top: auto;
|
||||
|
||||
&:hover {
|
||||
color: $hovered-text-color;
|
||||
@ -266,12 +268,14 @@ $hovered-red-text-color: #ff4d4d;
|
||||
margin: 0;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
#logout-form button {
|
||||
color: $red-text-color;
|
||||
|
||||
&:hover {
|
||||
color: $hovered-red-text-color;
|
||||
}
|
||||
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
@ -15,6 +15,7 @@
|
||||
ol,
|
||||
p {
|
||||
line-height: 22px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
code {
|
||||
@ -71,7 +72,8 @@
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.footnotes {
|
||||
font-size: 85%;
|
||||
}
|
||||
}
|
||||
}
|
@ -51,24 +51,55 @@ body {
|
||||
|
||||
[tooltip]::before {
|
||||
@include shadow;
|
||||
opacity: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
content: attr(tooltip);
|
||||
background: hsl(219.6, 20.8%, 96%);
|
||||
color: $black-color;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: #333;
|
||||
color: #fff;
|
||||
border: 0.5px solid hsl(0, 0%, 50%);
|
||||
;
|
||||
border-radius: 5px;
|
||||
padding: 5px;
|
||||
top: 1em;
|
||||
padding: 5px 10px;
|
||||
position: absolute;
|
||||
margin-top: 5px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
transition: opacity 500ms ease-out;
|
||||
top: 120%; // Put the tooltip under the element
|
||||
}
|
||||
|
||||
[tooltip]:hover::before {
|
||||
opacity: 1;
|
||||
transition: opacity 500ms ease-in;
|
||||
}
|
||||
|
||||
[no-hover][tooltip]::before {
|
||||
opacity: 1;
|
||||
transition: opacity 500ms ease-in;
|
||||
}
|
||||
|
||||
[position="top"][tooltip]::before {
|
||||
top: initial;
|
||||
bottom: 120%;
|
||||
}
|
||||
|
||||
[position="bottom"][tooltip]::before {
|
||||
top: 120%;
|
||||
bottom: initial;
|
||||
}
|
||||
|
||||
[position="left"][tooltip]::before {
|
||||
top: initial;
|
||||
bottom: 0%;
|
||||
left: initial;
|
||||
right: 65%;
|
||||
}
|
||||
|
||||
[position="right"][tooltip]::before {
|
||||
top: initial;
|
||||
bottom: 0%;
|
||||
left: 150%;
|
||||
right: initial;
|
||||
}
|
||||
|
||||
.ib {
|
||||
|
@ -1,11 +1,12 @@
|
||||
from io import BytesIO
|
||||
from itertools import cycle
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from django.core.cache import cache
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
from model_bakery import baker
|
||||
@ -14,7 +15,8 @@ from PIL import Image
|
||||
from pytest_django.asserts import assertNumQueries
|
||||
|
||||
from core.baker_recipes import board_user, old_subscriber_user, subscriber_user
|
||||
from core.models import Group, SithFile, User
|
||||
from core.models import Group, QuickUploadImage, SithFile, User
|
||||
from core.utils import RED_PIXEL_PNG
|
||||
from sas.models import Picture
|
||||
from sith import settings
|
||||
|
||||
@ -256,3 +258,89 @@ def test_apply_rights_recursively():
|
||||
):
|
||||
assert set(file.view_groups.all()) == set(groups[:3])
|
||||
assert set(file.edit_groups.all()) == set(groups[2:6])
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
("user_receipe", "file", "expected_status"),
|
||||
[
|
||||
(
|
||||
lambda: None,
|
||||
SimpleUploadedFile(
|
||||
"test.jpg", content=RED_PIXEL_PNG, content_type="image/jpg"
|
||||
),
|
||||
403,
|
||||
),
|
||||
(
|
||||
lambda: baker.make(User),
|
||||
SimpleUploadedFile(
|
||||
"test.jpg", content=RED_PIXEL_PNG, content_type="image/jpg"
|
||||
),
|
||||
403,
|
||||
),
|
||||
(
|
||||
lambda: subscriber_user.make(),
|
||||
SimpleUploadedFile(
|
||||
"test.jpg", content=RED_PIXEL_PNG, content_type="image/jpg"
|
||||
),
|
||||
200,
|
||||
),
|
||||
(
|
||||
lambda: old_subscriber_user.make(),
|
||||
SimpleUploadedFile(
|
||||
"test.jpg", content=RED_PIXEL_PNG, content_type="image/jpg"
|
||||
),
|
||||
200,
|
||||
),
|
||||
(
|
||||
lambda: old_subscriber_user.make(),
|
||||
SimpleUploadedFile(
|
||||
"ttesttesttesttesttesttesttesttesttesttesttesttesttesttesttestesttesttesttesttesttesttesttesttesttesttesttest.jpg",
|
||||
content=RED_PIXEL_PNG,
|
||||
content_type="image/jpg",
|
||||
),
|
||||
200,
|
||||
), # very long file name
|
||||
(
|
||||
lambda: old_subscriber_user.make(),
|
||||
SimpleUploadedFile(
|
||||
"test.jpg", content=b"invalid", content_type="image/jpg"
|
||||
),
|
||||
422,
|
||||
),
|
||||
(
|
||||
lambda: old_subscriber_user.make(),
|
||||
SimpleUploadedFile(
|
||||
"test.jpg", content=RED_PIXEL_PNG, content_type="invalid"
|
||||
),
|
||||
200, # PIL can guess
|
||||
),
|
||||
(
|
||||
lambda: old_subscriber_user.make(),
|
||||
SimpleUploadedFile("test.jpg", content=b"invalid", content_type="invalid"),
|
||||
422,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_quick_upload_image(
|
||||
client: Client,
|
||||
user_receipe: Callable[[], User | None],
|
||||
file: UploadedFile | None,
|
||||
expected_status: int,
|
||||
):
|
||||
if (user := user_receipe()) is not None:
|
||||
client.force_login(user)
|
||||
resp = client.post(
|
||||
reverse("api:quick_upload_image"), {"file": file} if file is not None else {}
|
||||
)
|
||||
|
||||
assert resp.status_code == expected_status
|
||||
|
||||
if expected_status != 200:
|
||||
return
|
||||
|
||||
parsed = resp.json()
|
||||
assert QuickUploadImage.objects.filter(uuid=parsed["uuid"]).exists()
|
||||
assert (
|
||||
parsed["name"] == Path(file.name).stem[: QuickUploadImage.IMAGE_NAME_SIZE - 1]
|
||||
)
|
||||
|
@ -13,38 +13,33 @@
|
||||
#
|
||||
#
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, timedelta
|
||||
|
||||
# Image utils
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
from typing import Final
|
||||
|
||||
import PIL
|
||||
from django.conf import settings
|
||||
from django.core.files.base import ContentFile
|
||||
from django.forms import BaseForm
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.http import HttpRequest
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import SafeString
|
||||
from django.utils.timezone import localdate
|
||||
from PIL import ExifTags
|
||||
from PIL.Image import Image, Resampling
|
||||
|
||||
RED_PIXEL_PNG: Final[bytes] = (
|
||||
b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52"
|
||||
b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90\x77\x53"
|
||||
b"\xde\x00\x00\x00\x0c\x49\x44\x41\x54\x08\xd7\x63\xf8\xcf\xc0\x00"
|
||||
b"\x00\x03\x01\x01\x00\x18\xdd\x8d\xb0\x00\x00\x00\x00\x49\x45\x4e"
|
||||
b"\x44\xae\x42\x60\x82"
|
||||
)
|
||||
"""A single red pixel, in PNG format.
|
||||
|
||||
@dataclass
|
||||
class FormFragmentTemplateData[T: BaseForm]:
|
||||
"""Dataclass used to pre-render form fragments"""
|
||||
|
||||
form: T
|
||||
template: str
|
||||
context: dict[str, Any]
|
||||
|
||||
def render(self, request: HttpRequest) -> SafeString:
|
||||
# Request is needed for csrf_tokens
|
||||
return render_to_string(
|
||||
self.template, context={"form": self.form, **self.context}, request=request
|
||||
)
|
||||
Can be used in tests and in dev, when there is a need
|
||||
to generate a dummy image that is considered valid nonetheless
|
||||
"""
|
||||
|
||||
|
||||
def get_start_of_semester(today: date | None = None) -> date:
|
||||
@ -117,6 +112,18 @@ def get_semester_code(d: date | None = None) -> str:
|
||||
return "P" + str(start.year)[-2:]
|
||||
|
||||
|
||||
def is_image(file: UploadedFile):
|
||||
try:
|
||||
im = PIL.Image.open(file.file)
|
||||
im.verify()
|
||||
# go back to the start of the file, without closing it.
|
||||
# Otherwise, further checks on django side will fail
|
||||
file.seek(0)
|
||||
except PIL.UnidentifiedImageError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def resize_image(
|
||||
im: Image, edge: int, img_format: str, *, optimize: bool = True
|
||||
) -> ContentFile:
|
||||
|
@ -86,7 +86,7 @@ def send_raw_file(path: Path) -> HttpResponse:
|
||||
|
||||
def send_file(
|
||||
request: HttpRequest,
|
||||
file_id: int,
|
||||
file_id: int | str,
|
||||
file_class: type[SithFile] = SithFile,
|
||||
file_attr: str = "file",
|
||||
) -> HttpResponse:
|
||||
@ -97,7 +97,7 @@ def send_file(
|
||||
deal with it.
|
||||
In debug mode, the server will directly send the file.
|
||||
"""
|
||||
f = get_object_or_404(file_class, id=file_id)
|
||||
f = get_object_or_404(file_class, pk=file_id)
|
||||
if not can_view(f, request.user) and not is_logged_in_counter(request):
|
||||
raise PermissionDenied
|
||||
name = getattr(f, file_attr).name
|
||||
|
@ -1,8 +1,14 @@
|
||||
from typing import ClassVar
|
||||
import copy
|
||||
import inspect
|
||||
from typing import Any, ClassVar, LiteralString, Protocol, Unpack
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.safestring import SafeString
|
||||
from django.views import View
|
||||
from django.views.generic.base import ContextMixin, TemplateResponseMixin
|
||||
|
||||
|
||||
class TabedViewMixin(View):
|
||||
@ -71,3 +77,152 @@ class AllowFragment:
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs["is_fragment"] = self.request.headers.get("HX-Request", False)
|
||||
return super().get_context_data(**kwargs)
|
||||
|
||||
|
||||
class FragmentRenderer(Protocol):
|
||||
def __call__(
|
||||
self, request: HttpRequest, **kwargs: Unpack[dict[str, Any]]
|
||||
) -> SafeString: ...
|
||||
|
||||
|
||||
class FragmentMixin(TemplateResponseMixin, ContextMixin):
|
||||
"""Make a view buildable as a fragment that can be embedded in a template.
|
||||
|
||||
Most fragments are used in two different ways :
|
||||
- in the request/response cycle, like any regular view
|
||||
- in templates, where the rendering is done in another view
|
||||
|
||||
This mixin aims to simplify the initial fragment rendering.
|
||||
The rendered fragment will then be able to re-render itself
|
||||
through the request/response cycle if it uses HTMX.
|
||||
|
||||
!!!Example
|
||||
```python
|
||||
class MyFragment(FragmentMixin, FormView):
|
||||
template_name = "app/fragment.jinja"
|
||||
form_class = MyForm
|
||||
success_url = reverse_lazy("foo:bar")
|
||||
|
||||
# in another view :
|
||||
def some_view(request):
|
||||
fragment = MyFragment.as_fragment()
|
||||
return render(
|
||||
request,
|
||||
"app/template.jinja",
|
||||
context={"fragment": fragment(request)
|
||||
}
|
||||
|
||||
# in urls.py
|
||||
urlpatterns = [
|
||||
path("foo/view", some_view),
|
||||
path("foo/fragment", MyFragment.as_view()),
|
||||
]
|
||||
```
|
||||
"""
|
||||
|
||||
reload_on_redirect: bool = False
|
||||
"""If True, this fragment will trigger a full page reload on redirect."""
|
||||
|
||||
@classmethod
|
||||
def as_fragment(cls, **initkwargs) -> FragmentRenderer:
|
||||
# the following code is heavily inspired from the base View.as_view method
|
||||
for key in initkwargs:
|
||||
if not hasattr(cls, key):
|
||||
raise TypeError(
|
||||
"%s() received an invalid keyword %r. as_view "
|
||||
"only accepts arguments that are already "
|
||||
"attributes of the class." % (cls.__name__, key)
|
||||
)
|
||||
|
||||
def fragment(request: HttpRequest, **kwargs) -> SafeString:
|
||||
self = cls(**initkwargs)
|
||||
# any POST action on the fragment will be dealt by the fragment itself.
|
||||
# So, if the view that is rendering this fragment is in a POST context,
|
||||
# let's pretend anyway it's a GET, in order to be sure the fragment
|
||||
# won't try to do any POST action (like form validation) on initial render.
|
||||
self.request = copy.copy(request)
|
||||
self.request.method = "GET"
|
||||
self.kwargs = kwargs
|
||||
return self.render_fragment(request, **kwargs)
|
||||
|
||||
fragment.__doc__ = cls.__doc__
|
||||
fragment.__module__ = cls.__module__
|
||||
return fragment
|
||||
|
||||
def render_fragment(self, request, **kwargs) -> SafeString:
|
||||
return render_to_string(
|
||||
self.get_template_names(),
|
||||
context=self.get_context_data(**kwargs),
|
||||
request=request,
|
||||
)
|
||||
|
||||
def dispatch(self, *args, **kwargs):
|
||||
res: HttpResponse = super().dispatch(*args, **kwargs)
|
||||
if 300 <= res.status_code < 400 and self.reload_on_redirect:
|
||||
# HTMX doesn't care about redirection codes (because why not),
|
||||
# so we must transform the redirection code into a 200.
|
||||
res.status_code = 200
|
||||
res.headers["HX-Redirect"] = res["Location"]
|
||||
return res
|
||||
|
||||
|
||||
class UseFragmentsMixin(ContextMixin):
|
||||
"""Mark a view as using fragments.
|
||||
|
||||
This mixin is not mandatory
|
||||
(you may as well render manually your fragments in the `get_context_data` method).
|
||||
However, the interface of this class bring some distinction
|
||||
between fragments and other context data, which may
|
||||
reduce boilerplate.
|
||||
|
||||
!!!Example
|
||||
```python
|
||||
class FooFragment(FragmentMixin, FormView): ...
|
||||
|
||||
class BarFragment(FragmentMixin, FormView): ...
|
||||
|
||||
class AdminFragment(FragmentMixin, FormView): ...
|
||||
|
||||
class MyView(UseFragmentsMixin, TemplateView)
|
||||
template_name = "app/view.jinja"
|
||||
fragments = {
|
||||
"foo": FooFragment
|
||||
"bar": BarFragment(template_name="some_template.jinja")
|
||||
}
|
||||
fragments_data = {
|
||||
"foo": {"some": "data"} # this will be passed to the FooFragment renderer
|
||||
}
|
||||
|
||||
def get_fragments(self):
|
||||
res = super().get_fragments()
|
||||
if self.request.user.is_superuser:
|
||||
res["admin_fragment"] = AdminFragment
|
||||
return res
|
||||
```
|
||||
"""
|
||||
|
||||
fragments: dict[LiteralString, type[FragmentMixin] | FragmentRenderer] | None = None
|
||||
fragment_data: dict[LiteralString, dict[LiteralString, Any]] | None = None
|
||||
|
||||
def get_fragments(self) -> dict[str, type[FragmentMixin] | FragmentRenderer]:
|
||||
return self.fragments if self.fragments is not None else {}
|
||||
|
||||
def get_fragment_data(self) -> dict[str, dict[str, Any]]:
|
||||
"""Return eventual data used to initialize the fragments."""
|
||||
return self.fragment_data if self.fragment_data is not None else {}
|
||||
|
||||
def get_fragment_context_data(self) -> dict[str, SafeString]:
|
||||
"""Return the rendered fragments as context data."""
|
||||
res = {}
|
||||
data = self.get_fragment_data()
|
||||
for name, fragment in self.get_fragments().items():
|
||||
is_cls = inspect.isclass(fragment) and issubclass(fragment, FragmentMixin)
|
||||
_fragment = fragment.as_fragment() if is_cls else fragment
|
||||
fragment_data = data.get(name, {})
|
||||
res[name] = _fragment(self.request, **fragment_data)
|
||||
return res
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
kwargs.update(self.get_fragment_context_data())
|
||||
return kwargs
|
||||
|
@ -41,6 +41,7 @@ from django.template.loader import render_to_string
|
||||
from django.template.response import TemplateResponse
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.safestring import SafeString
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import (
|
||||
CreateView,
|
||||
@ -63,7 +64,7 @@ from core.views.forms import (
|
||||
UserGroupsForm,
|
||||
UserProfileForm,
|
||||
)
|
||||
from core.views.mixins import QuickNotifMixin, TabedViewMixin
|
||||
from core.views.mixins import QuickNotifMixin, TabedViewMixin, UseFragmentsMixin
|
||||
from counter.models import Counter, Refilling, Selling
|
||||
from eboutic.models import Invoice
|
||||
from subscription.models import Subscription
|
||||
@ -508,7 +509,7 @@ class UserClubView(UserTabsMixin, CanViewMixin, DetailView):
|
||||
current_tab = "clubs"
|
||||
|
||||
|
||||
class UserPreferencesView(UserTabsMixin, CanEditMixin, UpdateView):
|
||||
class UserPreferencesView(UserTabsMixin, UseFragmentsMixin, CanEditMixin, UpdateView):
|
||||
"""Edit a user's preferences."""
|
||||
|
||||
model = User
|
||||
@ -526,17 +527,21 @@ class UserPreferencesView(UserTabsMixin, CanEditMixin, UpdateView):
|
||||
kwargs.update({"instance": pref})
|
||||
return kwargs
|
||||
|
||||
def get_fragment_context_data(self) -> dict[str, SafeString]:
|
||||
# Avoid cyclic import error
|
||||
from counter.views.student_card import StudentCardFormFragment
|
||||
|
||||
res = super().get_fragment_context_data()
|
||||
if hasattr(self.object, "customer"):
|
||||
res["student_card_fragment"] = StudentCardFormFragment.as_fragment()(
|
||||
self.request, customer=self.object.customer
|
||||
)
|
||||
return res
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
|
||||
if not hasattr(self.object, "trombi_user"):
|
||||
kwargs["trombi_form"] = UserTrombiForm()
|
||||
if hasattr(self.object, "customer"):
|
||||
from counter.views.student_card import StudentCardFormView
|
||||
|
||||
kwargs["student_card_fragment"] = StudentCardFormView.get_template_data(
|
||||
self.object.customer
|
||||
).render(self.request)
|
||||
return kwargs
|
||||
|
||||
|
||||
|
@ -56,7 +56,7 @@ from counter.views.home import (
|
||||
CounterMain,
|
||||
)
|
||||
from counter.views.invoice import InvoiceCallView
|
||||
from counter.views.student_card import StudentCardDeleteView, StudentCardFormView
|
||||
from counter.views.student_card import StudentCardDeleteView, StudentCardFormFragment
|
||||
|
||||
urlpatterns = [
|
||||
path("<int:counter_id>/", CounterMain.as_view(), name="details"),
|
||||
@ -83,7 +83,7 @@ urlpatterns = [
|
||||
path("eticket/<int:selling_id>/pdf/", EticketPDFView.as_view(), name="eticket_pdf"),
|
||||
path(
|
||||
"customer/<int:customer_id>/card/add/",
|
||||
StudentCardFormView.as_view(),
|
||||
StudentCardFormFragment.as_view(),
|
||||
name="add_student_card",
|
||||
),
|
||||
path(
|
||||
|
@ -26,7 +26,8 @@ from django.forms import (
|
||||
)
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404, redirect, resolve_url
|
||||
from django.urls import reverse_lazy
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import SafeString
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import FormView
|
||||
from django.views.generic.detail import SingleObjectMixin
|
||||
@ -34,7 +35,7 @@ from ninja.main import HttpRequest
|
||||
|
||||
from core.auth.mixins import CanViewMixin
|
||||
from core.models import User
|
||||
from core.utils import FormFragmentTemplateData
|
||||
from core.views.mixins import FragmentMixin, UseFragmentsMixin
|
||||
from counter.forms import RefillForm
|
||||
from counter.models import (
|
||||
Counter,
|
||||
@ -45,7 +46,7 @@ from counter.models import (
|
||||
)
|
||||
from counter.utils import is_logged_in_counter
|
||||
from counter.views.mixins import CounterTabsMixin
|
||||
from counter.views.student_card import StudentCardFormView
|
||||
from counter.views.student_card import StudentCardFormFragment
|
||||
|
||||
|
||||
def get_operator(request: HttpRequest, counter: Counter, customer: Customer) -> User:
|
||||
@ -163,7 +164,9 @@ BasketForm = formset_factory(
|
||||
)
|
||||
|
||||
|
||||
class CounterClick(CounterTabsMixin, CanViewMixin, SingleObjectMixin, FormView):
|
||||
class CounterClick(
|
||||
CounterTabsMixin, UseFragmentsMixin, CanViewMixin, SingleObjectMixin, FormView
|
||||
):
|
||||
"""The click view
|
||||
This is a detail view not to have to worry about loading the counter
|
||||
Everything is made by hand in the post method.
|
||||
@ -304,6 +307,18 @@ class CounterClick(CounterTabsMixin, CanViewMixin, SingleObjectMixin, FormView):
|
||||
def get_success_url(self):
|
||||
return resolve_url(self.object)
|
||||
|
||||
def get_fragment_context_data(self) -> dict[str, SafeString]:
|
||||
res = super().get_fragment_context_data()
|
||||
if self.object.type == "BAR":
|
||||
res["student_card_fragment"] = StudentCardFormFragment.as_fragment()(
|
||||
self.request, customer=self.customer
|
||||
)
|
||||
if self.object.can_refill():
|
||||
res["refilling_fragment"] = RefillingCreateView.as_fragment()(
|
||||
self.request, customer=self.customer
|
||||
)
|
||||
return res
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add customer to the context."""
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
@ -321,39 +336,15 @@ class CounterClick(CounterTabsMixin, CanViewMixin, SingleObjectMixin, FormView):
|
||||
kwargs["form_errors"] = [
|
||||
list(field_error.values()) for field_error in kwargs["form"].errors
|
||||
]
|
||||
if self.object.type == "BAR":
|
||||
kwargs["student_card_fragment"] = StudentCardFormView.get_template_data(
|
||||
self.customer
|
||||
).render(self.request)
|
||||
|
||||
if self.object.can_refill():
|
||||
kwargs["refilling_fragment"] = RefillingCreateView.get_template_data(
|
||||
self.customer
|
||||
).render(self.request)
|
||||
|
||||
return kwargs
|
||||
|
||||
|
||||
class RefillingCreateView(FormView):
|
||||
class RefillingCreateView(FragmentMixin, FormView):
|
||||
"""This is a fragment only view which integrates with counter_click.jinja"""
|
||||
|
||||
form_class = RefillForm
|
||||
template_name = "counter/fragments/create_refill.jinja"
|
||||
|
||||
@classmethod
|
||||
def get_template_data(
|
||||
cls, customer: Customer, *, form_instance: form_class | None = None
|
||||
) -> FormFragmentTemplateData[form_class]:
|
||||
return FormFragmentTemplateData(
|
||||
form=form_instance if form_instance else cls.form_class(),
|
||||
template=cls.template_name,
|
||||
context={
|
||||
"action": reverse_lazy(
|
||||
"counter:refilling_create", kwargs={"customer_id": customer.pk}
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
self.customer: Customer = get_object_or_404(Customer, pk=kwargs["customer_id"])
|
||||
if not self.customer.can_buy:
|
||||
@ -373,6 +364,10 @@ class RefillingCreateView(FormView):
|
||||
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def render_fragment(self, request, **kwargs) -> SafeString:
|
||||
self.customer = kwargs.pop("customer")
|
||||
return super().render_fragment(request, **kwargs)
|
||||
|
||||
def form_valid(self, form):
|
||||
res = super().form_valid(form)
|
||||
form.clean()
|
||||
@ -383,10 +378,11 @@ class RefillingCreateView(FormView):
|
||||
return res
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
data = self.get_template_data(self.customer, form_instance=context["form"])
|
||||
context.update(data.context)
|
||||
return context
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
kwargs["action"] = reverse(
|
||||
"counter:refilling_create", kwargs={"customer_id": self.customer.pk}
|
||||
)
|
||||
return kwargs
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
return self.request.path
|
||||
|
@ -13,16 +13,16 @@
|
||||
#
|
||||
#
|
||||
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import Http404, HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import SafeString
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic.edit import DeleteView, FormView
|
||||
|
||||
from core.auth.mixins import can_edit
|
||||
from core.utils import FormFragmentTemplateData
|
||||
from core.views.mixins import FragmentMixin
|
||||
from counter.forms import StudentCardForm
|
||||
from counter.models import Customer, StudentCard
|
||||
from counter.utils import is_logged_in_counter
|
||||
@ -62,28 +62,12 @@ class StudentCardDeleteView(DeleteView):
|
||||
)
|
||||
|
||||
|
||||
class StudentCardFormView(FormView):
|
||||
"""Add a new student card. This is a fragment view !"""
|
||||
class StudentCardFormFragment(FragmentMixin, FormView):
|
||||
"""Add a new student card."""
|
||||
|
||||
form_class = StudentCardForm
|
||||
template_name = "counter/fragments/create_student_card.jinja"
|
||||
|
||||
@classmethod
|
||||
def get_template_data(
|
||||
cls, customer: Customer, *, form_instance: form_class | None = None
|
||||
) -> FormFragmentTemplateData[form_class]:
|
||||
"""Get necessary data to pre-render the fragment"""
|
||||
return FormFragmentTemplateData(
|
||||
form=form_instance if form_instance else cls.form_class(),
|
||||
template=cls.template_name,
|
||||
context={
|
||||
"action": reverse(
|
||||
"counter:add_student_card", kwargs={"customer_id": customer.pk}
|
||||
),
|
||||
"customer": customer,
|
||||
},
|
||||
)
|
||||
|
||||
def dispatch(self, request: HttpRequest, *args, **kwargs):
|
||||
self.customer = get_object_or_404(
|
||||
Customer.objects.select_related("student_card"), pk=kwargs["customer_id"]
|
||||
@ -96,6 +80,10 @@ class StudentCardFormView(FormView):
|
||||
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def render_fragment(self, request, **kwargs) -> SafeString:
|
||||
self.customer = kwargs.pop("customer")
|
||||
return super().render_fragment(request, **kwargs)
|
||||
|
||||
def form_valid(self, form: StudentCardForm) -> HttpResponse:
|
||||
data = form.clean()
|
||||
StudentCard.objects.update_or_create(
|
||||
@ -104,10 +92,12 @@ class StudentCardFormView(FormView):
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
data = self.get_template_data(self.customer, form_instance=context["form"])
|
||||
context.update(data.context)
|
||||
return context
|
||||
return super().get_context_data(**kwargs) | {
|
||||
"action": reverse(
|
||||
"counter:add_student_card", kwargs={"customer_id": self.customer.pk}
|
||||
),
|
||||
"customer": self.customer,
|
||||
}
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
return self.request.path
|
||||
|
10
docs/reference/core/mixins.md
Normal file
10
docs/reference/core/mixins.md
Normal file
@ -0,0 +1,10 @@
|
||||
::: core.views.mixins
|
||||
handler: python
|
||||
options:
|
||||
heading_level: 3
|
||||
members:
|
||||
- TabedViewMixin
|
||||
- QuickNotifMixin
|
||||
- AllowFragment
|
||||
- FragmentMixin
|
||||
- UseFragmentsMixin
|
@ -1,40 +1,356 @@
|
||||
Pour utiliser HTMX, on a besoin de renvoyer des fragments depuis le backend.
|
||||
Le truc, c'est que tout est optimisé pour utiliser `base.jinja` qui est assez gros.
|
||||
## Qu'est-ce qu'un fragment
|
||||
|
||||
Dans beaucoup de scénario, on veut pouvoir renvoyer soit la vue complète, soit
|
||||
juste le fragment. En particulier quand on utilise l'attribut `hx-history` de htmx.
|
||||
Une application web django traditionnelle suit en général
|
||||
le schéma suivant :
|
||||
|
||||
Pour remédier à cela, il existe le mixin [AllowFragment][core.views.AllowFragment].
|
||||
1. l'utilisateur envoie une requête au serveur
|
||||
2. le serveur renvoie une page HTML,
|
||||
qui contient en général des liens et/ou des formulaires
|
||||
3. lorsque l'utilisateur clique sur un lien ou valide
|
||||
un formulaire, on retourne à l'étape 1
|
||||
|
||||
Une fois ajouté à une vue Django, il ajoute le boolean `is_fragment` dans les
|
||||
templates jinja. Sa valeur est `True` uniquement si HTMX envoie la requête.
|
||||
Il est ensuite très simple de faire un if/else pour hériter de
|
||||
`core/base_fragment.jinja` au lieu de `core/base.jinja` dans cette situation.
|
||||
C'est un processus qui marche, mais qui est lourd :
|
||||
générer une page entière demande du travail au serveur
|
||||
et effectuer le rendu de cette page en demande également
|
||||
beaucoup au client.
|
||||
Or, des temps de chargement plus longs et des
|
||||
rechargements complets de page peuvent nuire
|
||||
à l'expérience utilisateur, en particulier
|
||||
lorsqu'ils interviennent lors d'opérations simples.
|
||||
|
||||
Exemple d'utilisation d'une vue avec fragment:
|
||||
Pour éviter ce genre de rechargement complet,
|
||||
on peut utiliser AlpineJS pour rendre la page
|
||||
interactive et effectuer des appels à l'API.
|
||||
Cette technique fonctionne particulièrement bien
|
||||
lorsqu'on veut afficher des objets ou des listes
|
||||
d'objets de manière dynamique.
|
||||
|
||||
En revanche, elle est moins efficace pour certaines
|
||||
opérations, telles que la validation de formulaire.
|
||||
En effet, valider un formulaire demande beaucoup
|
||||
de travail de nettoyage des données et d'affichage
|
||||
des messages d'erreur appropriés.
|
||||
Or, tout ce travail existe déjà dans django.
|
||||
|
||||
On veut donc, dans ces cas-là, ne pas demander
|
||||
toute une page HTML au serveur, mais uniquement
|
||||
une toute petite partie, que l'on utilisera
|
||||
pour remplacer la partie qui correspond sur la page actuelle.
|
||||
Ce sont des fragments.
|
||||
|
||||
|
||||
## HTMX
|
||||
|
||||
Toutes les fonctionnalités d'interaction avec les
|
||||
fragments, côté client, s'appuient sur la librairie htmx.
|
||||
|
||||
L'usage qui en est fait est en général assez simple
|
||||
et ressemblera souvent à ça :
|
||||
|
||||
```html+jinja
|
||||
<form
|
||||
hx-trigger="submit" {# Lorsque le formulaire est validé... #}
|
||||
hx-post="{{ url("foo:bar") }}" {# ...envoie une requête POST vers l'url donnée... #}
|
||||
hx-swap="outerHTML" {# ...et remplace tout l'html du formulaire par le contenu de la réponse HTTP #}
|
||||
>
|
||||
{% csrf_token %}
|
||||
{{ form }}
|
||||
<input type="submit" value="{% trans %}Go{% endtrans %}"/>
|
||||
</form>
|
||||
```
|
||||
|
||||
C'est la majorité de ce que vous avez besoin de savoir
|
||||
pour utiliser HTMX sur le site.
|
||||
|
||||
Bien entendu, ce n'est pas tout, il y a d'autres
|
||||
options et certaines subtilités, mais pour ça,
|
||||
consultez [la doc officielle d'HTMX](https://htmx.org/docs/).
|
||||
|
||||
## La surcouche du site
|
||||
|
||||
Pour faciliter et standardiser l'intégration d'HTMX
|
||||
dans la base de code du site AE,
|
||||
nous avons créé certains mixins à utiliser
|
||||
dans les vues basées sur des classes.
|
||||
|
||||
### [AllowFragment][core.views.mixins.AllowFragment]
|
||||
|
||||
`AllowFragment` est extrêmement simple dans son
|
||||
concept : il met à disposition la variable `is_fragment`,
|
||||
qui permet de savoir si la vue est appelée par HTMX,
|
||||
ou si elle provient d'un autre contexte.
|
||||
|
||||
Grâce à ça, on peut écrire des vues qui
|
||||
fonctionnent dans les deux contextes.
|
||||
|
||||
Par exemple, supposons que nous avons
|
||||
une `EditView` très simple, contenant
|
||||
uniquement un formulaire.
|
||||
On peut écrire la vue et le template de la manière
|
||||
suivante :
|
||||
|
||||
=== "`views.py`"
|
||||
|
||||
```python
|
||||
from django.views.generic import UpdateView
|
||||
|
||||
|
||||
class FooUpdateView(UpdateView):
|
||||
model = Foo
|
||||
fields = ["foo", "bar"]
|
||||
pk_url_kwarg = "foo_id"
|
||||
template_name = "app/foo.jinja"
|
||||
```
|
||||
|
||||
=== "`app/foo.jinja`"
|
||||
|
||||
```html+jinja
|
||||
{% if is_fragment %}
|
||||
{% extends "core/base_fragment.jinja" %}
|
||||
{% else %}
|
||||
{% extends "core/base.jinja" %}
|
||||
{% endif %}
|
||||
|
||||
{% block content %}
|
||||
<form hx-trigger="submit" hx-swap="outerHTML">
|
||||
{% csrf_token %}
|
||||
{{ form }}
|
||||
<input type="submit" value="{% trans %}Go{% endtrans %}"/>
|
||||
</form>
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
Lors du chargement initial de la page, le template
|
||||
entier sera rendu, mais lors de la soumission du formulaire,
|
||||
seul le fragment html de ce dernier sera changé.
|
||||
|
||||
### [FragmentMixin][core.views.mixins.FragmentMixin]
|
||||
|
||||
Il arrive des situations où le résultat que l'on
|
||||
veut accomplir est plus complexe.
|
||||
Dans ces situations, pouvoir décomposer une vue
|
||||
en plusieurs vues de fragment permet de ne plus
|
||||
raisonner en termes de condition, mais en termes
|
||||
de composition : on n'a pas un seul template
|
||||
qui peut changer les situations, on a plusieurs
|
||||
templates que l'on injecte dans un template principal.
|
||||
|
||||
Supposons, par exemple, que nous n'avons plus un,
|
||||
mais deux formulaires à afficher sur la page.
|
||||
Dans ce cas, nous pouvons créer deux templates,
|
||||
qui seront alors injectés.
|
||||
|
||||
=== "`urls.py`"
|
||||
|
||||
```python
|
||||
from django.urls import path
|
||||
|
||||
from app import views
|
||||
|
||||
urlpatterns = [
|
||||
path("", FooCompositeView.as_view(), name="main"),
|
||||
path("create/", FooUpdateFragment.as_view(), name="update_foo"),
|
||||
path("update/", FooCreateFragment.as_view(), name="create_foo"),
|
||||
]
|
||||
```
|
||||
|
||||
=== "`view.py`"
|
||||
|
||||
```python
|
||||
from django.views.generic import CreateView, UpdateView, TemplateView
|
||||
from core.views.mixins import FragmentMixin
|
||||
|
||||
|
||||
class FooCreateFragment(FragmentMixin, CreateView):
|
||||
model = Foo
|
||||
fields = ["foo", "bar"]
|
||||
template_name = "app/fragments/create_foo.jinja"
|
||||
|
||||
|
||||
class FooUpdateFragment(FragmentMixin, UpdateView):
|
||||
model = Foo
|
||||
fields = ["foo", "bar"]
|
||||
pk_url_kwarg = "foo_id"
|
||||
template_name = "app/fragments/update_foo.jinja"
|
||||
|
||||
|
||||
class FooCompositeFormView(TemplateView):
|
||||
template_name = "app/foo.jinja"
|
||||
|
||||
def get_context_data(**kwargs):
|
||||
return super().get_context_data(**kwargs) | {
|
||||
"create_fragment": FooCreateFragment.as_fragment()(),
|
||||
"update_fragment": FooUpdateFragment.as_fragment()(foo_id=1)
|
||||
}
|
||||
```
|
||||
|
||||
=== "`app/fragment/create_foo.jinja`"
|
||||
|
||||
```html+jinja
|
||||
<form
|
||||
hx-trigger="submit"
|
||||
hx-post="{{ url("app:create_foo") }}"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
{% csrf_token %}
|
||||
{{ form }}
|
||||
<input type="submit" value="{% trans %}Create{% endtrans %}"/>
|
||||
</form>
|
||||
```
|
||||
|
||||
=== "`app/fragment/update_foo.jinja`"
|
||||
|
||||
```html+jinja
|
||||
<form
|
||||
hx-trigger="submit"
|
||||
hx-post="{{ url("app:update_foo") }}"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
{% csrf_token %}
|
||||
{{ form }}
|
||||
<input type="submit" value="{% trans %}Update{% endtrans %}"/>
|
||||
</form>
|
||||
```
|
||||
|
||||
=== "`app/foo.jinja`"
|
||||
|
||||
```html+jinja
|
||||
{% extends "core/base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h2>{% trans %}Update current foo{% endtrans %}</h2>
|
||||
{{ update_fragment }}
|
||||
|
||||
<h2>{% trans %}Create new foo{% endtrans %}</h2>
|
||||
{{ create_fragment }}
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
Le résultat consistera en l'affichage d'une page
|
||||
contenant deux formulaires.
|
||||
Le rendu des fragments n'est pas effectué
|
||||
par `FooCompositeView`, mais par les vues
|
||||
des fragments elles-mêmes, en sautant
|
||||
les méthodes `dispatch` et `get`/`post` de ces dernières.
|
||||
À chaque validation de formulaire, la requête
|
||||
sera envoyée à la vue responsable du fragment,
|
||||
qui se comportera alors comme une vue normale.
|
||||
|
||||
#### La méthode `as_fragment`
|
||||
|
||||
Il est à noter que l'instantiation d'un fragment
|
||||
se fait en deux étapes :
|
||||
|
||||
- on commence par instantier la vue en tant que renderer.
|
||||
- on appelle le renderer en lui-même
|
||||
|
||||
Ce qui donne la syntaxe `Fragment.as_fragment()()`.
|
||||
|
||||
Cette conception est une manière de se rapprocher
|
||||
le plus possible de l'interface déjà existante
|
||||
pour la méthode `as_view` des vues.
|
||||
La méthode `as_fragment` prend en argument les mêmes
|
||||
paramètres que `as_view`.
|
||||
|
||||
Par exemple, supposons que nous voulons rajouter
|
||||
des variables de contexte lors du rendu du fragment.
|
||||
On peut écrire ça ainsi :
|
||||
|
||||
```python
|
||||
fragment = Fragment.as_fragment(extra_context={"foo": "bar"})()
|
||||
```
|
||||
|
||||
#### Personnaliser le rendu
|
||||
|
||||
En plus de la personnalisation permise par
|
||||
`as_fragment`, on peut surcharger la méthode
|
||||
`render_fragment` pour accomplir des actions
|
||||
spécifiques, et ce uniquement lorsqu'on effectue
|
||||
le rendu du fragment.
|
||||
|
||||
Supposons qu'on veuille manipuler un entier
|
||||
dans la vue et que, lorsqu'on est en train
|
||||
de faire le rendu du template, on veuille augmenter
|
||||
la valeur de cet entier (c'est juste pour l'exemple).
|
||||
On peut écrire ça ainsi :
|
||||
|
||||
```python
|
||||
from django.views.generic import CreateView
|
||||
from core.views.mixins import FragmentMixin
|
||||
|
||||
|
||||
class FooCreateFragment(FragmentMixin, CreateView):
|
||||
model = Foo
|
||||
fields = ["foo", "bar"]
|
||||
template_name = "app/fragments/create_foo.jinja"
|
||||
|
||||
def render_fragment(self, request, **kwargs):
|
||||
if "foo" in kwargs:
|
||||
kwargs["foo"] += 2
|
||||
return super().render_fragment(request, **kwargs)
|
||||
```
|
||||
|
||||
Et on effectuera le rendu du fragment de la manière suivante :
|
||||
|
||||
```python
|
||||
FooCreateFragment.as_fragment()(foo=4)
|
||||
```
|
||||
|
||||
### [UseFragmentsMixin][core.views.mixins.UseFragmentsMixin]
|
||||
|
||||
Lorsqu'on a plusieurs fragments, il est parfois
|
||||
plus aisé des les aggréger au sein de la vue
|
||||
principale en utilisant `UseFragmentsMixin`.
|
||||
|
||||
Elle permet de marquer de manière plus explicite
|
||||
la séparation entre les fragments et le reste du contexte.
|
||||
|
||||
Reprenons `FooUpdateFragment` et la version modifiée
|
||||
de `FooCreateFragment`.
|
||||
`FooCompositeView` peut être réécrite ainsi :
|
||||
|
||||
```python
|
||||
from django.views.generic import TemplateView
|
||||
from core.views import AllowFragment
|
||||
from core.views.mixins import UseFragmentsMixin
|
||||
|
||||
class FragmentView(AllowFragment, TemplateView):
|
||||
template_name = "my_template.jinja"
|
||||
|
||||
class FooCompositeFormView(UseFragmentsMixin, TemplateView):
|
||||
fragments = {
|
||||
"create_fragment": FooCreateFragment,
|
||||
"update_fragment": FooUpdateFragment
|
||||
}
|
||||
fragment_data = {
|
||||
"update_fragment": {"foo": 4}
|
||||
}
|
||||
template_name = "app/foo.jinja"
|
||||
```
|
||||
|
||||
Exemple de template (`my_template.jinja`)
|
||||
```jinja
|
||||
{% if is_fragment %}
|
||||
{% extends "core/base_fragment.jinja" %}
|
||||
{% else %}
|
||||
{% extends "core/base.jinja" %}
|
||||
{% endif %}
|
||||
Le résultat sera alors strictement le même.
|
||||
|
||||
Pour personnaliser le rendu de tous les fragments,
|
||||
on peut également surcharger la méthode
|
||||
`get_fragment_context_data`.
|
||||
Cette méthode remplit les mêmes objectifs
|
||||
que `get_context_data`, mais uniquement pour les fragments.
|
||||
Il s'agit simplement d'un utilitaire pour séparer les responsabilités.
|
||||
|
||||
```python
|
||||
from django.views.generic import TemplateView
|
||||
from core.views.mixins import UseFragmentsMixin
|
||||
|
||||
|
||||
{% block title %}
|
||||
{% trans %}My view with a fragment{% endtrans %}
|
||||
{% endblock %}
|
||||
class FooCompositeFormView(UseFragmentsMixin, TemplateView):
|
||||
fragments = {
|
||||
"create_fragment": FooCreateFragment
|
||||
}
|
||||
template_name = "app/foo.jinja"
|
||||
|
||||
{% block content %}
|
||||
<h3>{% trans %}This will be a fragment when is_fragment is True{% endtrans %}
|
||||
{% endblock %}
|
||||
def get_fragment_context_data(self):
|
||||
# let's render the update fragment here
|
||||
# instead of using the class variables
|
||||
return super().get_fragment_context_data() | {
|
||||
"create_fragment": FooUpdateFragment.as_fragment()(foo=4)
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
@ -224,7 +224,7 @@ server {
|
||||
location /static/;
|
||||
root /repertoire/du/projet;
|
||||
}
|
||||
location ~ ^/data/(products|com|club_logos)/ {
|
||||
location ~ ^/data/(products|com|club_logos|upload)/ {
|
||||
root /repertoire/du/projet;
|
||||
}
|
||||
location ~ ^/data/(SAS|profiles|users|.compressed|.thumbnails)/ {
|
||||
|
@ -32,17 +32,10 @@ from django.utils import timezone
|
||||
|
||||
from club.models import Club, Membership
|
||||
from core.models import Group, Page, SithFile, User
|
||||
from core.utils import RED_PIXEL_PNG
|
||||
from sas.models import Album, PeoplePictureRelation, Picture
|
||||
from subscription.models import Subscription
|
||||
|
||||
RED_PIXEL_PNG: Final[bytes] = (
|
||||
b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52"
|
||||
b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90\x77\x53"
|
||||
b"\xde\x00\x00\x00\x0c\x49\x44\x41\x54\x08\xd7\x63\xf8\xcf\xc0\x00"
|
||||
b"\x00\x03\x01\x01\x00\x18\xdd\x8d\xb0\x00\x00\x00\x00\x49\x45\x4e"
|
||||
b"\x44\xae\x42\x60\x82"
|
||||
)
|
||||
|
||||
USER_PACK_SIZE: Final[int] = 1000
|
||||
|
||||
|
||||
|
@ -6,7 +6,7 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-04-06 15:54+0200\n"
|
||||
"POT-Creation-Date: 2025-04-08 16:20+0200\n"
|
||||
"PO-Revision-Date: 2016-07-18\n"
|
||||
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
|
||||
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
||||
@ -116,7 +116,7 @@ msgstr "Vous ne pouvez pas ajouter deux fois le même utilisateur"
|
||||
msgid "You should specify a role"
|
||||
msgstr "Vous devez choisir un rôle"
|
||||
|
||||
#: club/forms.py sas/views.py
|
||||
#: club/forms.py sas/forms.py
|
||||
msgid "You do not have the permission to do that"
|
||||
msgstr "Vous n'avez pas la permission de faire cela"
|
||||
|
||||
@ -1047,7 +1047,7 @@ msgid "Posters - edit"
|
||||
msgstr "Affiche - modifier"
|
||||
|
||||
#: com/templates/com/poster_list.jinja com/templates/com/screen_list.jinja
|
||||
#: sas/templates/sas/main.jinja
|
||||
#: sas/templates/sas/fragments/album_create_form.jinja
|
||||
msgid "Create"
|
||||
msgstr "Créer"
|
||||
|
||||
@ -1572,6 +1572,14 @@ msgstr "Ceci n'est pas une miniature de dossier valide"
|
||||
msgid "You must provide a file"
|
||||
msgstr "Vous devez fournir un fichier"
|
||||
|
||||
#: core/models.py
|
||||
msgid "width"
|
||||
msgstr "largeur"
|
||||
|
||||
#: core/models.py
|
||||
msgid "height"
|
||||
msgstr "hauteur"
|
||||
|
||||
#: core/models.py
|
||||
msgid "page unix name"
|
||||
msgstr "nom unix de la page"
|
||||
@ -1644,6 +1652,10 @@ msgstr "étiquette"
|
||||
msgid "operation type"
|
||||
msgstr "type d'opération"
|
||||
|
||||
#: core/schemas.py
|
||||
msgid "This file is not a valid image"
|
||||
msgstr "Ce fichier n'est pas une image valide"
|
||||
|
||||
#: core/templates/core/403.jinja
|
||||
msgid "403, Forbidden"
|
||||
msgstr "403, Non autorisé"
|
||||
@ -2729,7 +2741,7 @@ msgstr "Ajouter un nouveau dossier"
|
||||
msgid "Error creating folder %(folder_name)s: %(msg)s"
|
||||
msgstr "Erreur de création du dossier %(folder_name)s : %(msg)s"
|
||||
|
||||
#: core/views/files.py core/views/forms.py sas/forms.py
|
||||
#: core/views/files.py core/views/forms.py
|
||||
#, python-format
|
||||
msgid "Error uploading file %(file_name)s: %(msg)s"
|
||||
msgstr "Erreur d'envoi du fichier %(file_name)s : %(msg)s"
|
||||
@ -4715,11 +4727,6 @@ msgstr "Ajouter un nouvel album"
|
||||
msgid "Upload images"
|
||||
msgstr "Envoyer les images"
|
||||
|
||||
#: sas/forms.py
|
||||
#, python-format
|
||||
msgid "Error creating album %(album)s: %(msg)s"
|
||||
msgstr "Erreur de création de l'album %(album)s : %(msg)s"
|
||||
|
||||
#: sas/forms.py
|
||||
msgid "You already requested moderation for this picture."
|
||||
msgstr "Vous avez déjà déposé une demande de retrait pour cette photo."
|
||||
|
@ -7,7 +7,7 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-04-06 15:47+0200\n"
|
||||
"POT-Creation-Date: 2025-04-09 23:23+0200\n"
|
||||
"PO-Revision-Date: 2024-09-17 11:54+0200\n"
|
||||
"Last-Translator: Sli <antoine@bartuccio.fr>\n"
|
||||
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
||||
@ -33,6 +33,14 @@ msgstr "Dépublier"
|
||||
msgid "Delete"
|
||||
msgstr "Supprimer"
|
||||
|
||||
#: com/static/bundled/com/components/ics-calendar-index.ts
|
||||
msgid "Copy calendar link"
|
||||
msgstr "Copier le lien du calendrier"
|
||||
|
||||
#: com/static/bundled/com/components/ics-calendar-index.ts
|
||||
msgid "Link copied"
|
||||
msgstr "Lien copié"
|
||||
|
||||
#: com/static/bundled/com/components/moderation-alert-index.ts
|
||||
#, javascript-format
|
||||
msgid ""
|
||||
@ -55,6 +63,40 @@ msgstr "Vous devez taper %(number)s caractères de plus"
|
||||
msgid "No results found"
|
||||
msgstr "Aucun résultat trouvé"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts
|
||||
msgid "You are not authorized to use this feature"
|
||||
msgstr "Vous n'êtes pas autorisé à utilisé cette fonctionalité"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts
|
||||
msgid "Could not upload image"
|
||||
msgstr "L'image n'a pas pu être téléversée"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts
|
||||
msgid "Attach files by drag and dropping or pasting from clipboard."
|
||||
msgstr ""
|
||||
"Ajoutez des fichiez en glissant déposant ou collant depuis votre presse "
|
||||
"papier."
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts
|
||||
msgid "Drop image to upload it."
|
||||
msgstr "Glissez une image pour la téléverser."
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts
|
||||
msgid "Uploading image #images_names# …"
|
||||
msgstr "Téléversement de l'image #images_names# …"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts
|
||||
msgid "Uploading #file_name#: #progress#%"
|
||||
msgstr "Téléversement de #file_name#: #progress#%"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts
|
||||
msgid "Uploaded #image_name#"
|
||||
msgstr "#image_name# téléversé"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts
|
||||
msgid " B, KB, MB"
|
||||
msgstr " B, KB, MB"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts
|
||||
msgid "Heading"
|
||||
msgstr "Titre"
|
||||
@ -107,6 +149,10 @@ msgstr "Insérer lien"
|
||||
msgid "Insert image"
|
||||
msgstr "Insérer image"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts
|
||||
msgid "Upload image"
|
||||
msgstr "Téléverser une image"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts
|
||||
msgid "Insert table"
|
||||
msgstr "Insérer tableau"
|
||||
|
@ -66,7 +66,7 @@ nav:
|
||||
- Structure du projet: tutorial/structure.md
|
||||
- Gestion des permissions: tutorial/perms.md
|
||||
- Gestion des groupes: tutorial/groups.md
|
||||
- Créer des fragments: tutorial/fragments.md
|
||||
- Les fragments: tutorial/fragments.md
|
||||
- Etransactions: tutorial/etransaction.md
|
||||
- How-to:
|
||||
- L'ORM de Django: howto/querysets.md
|
||||
@ -94,6 +94,7 @@ nav:
|
||||
- reference/core/models.md
|
||||
- Champs de modèle: reference/core/model_fields.md
|
||||
- reference/core/views.md
|
||||
- reference/core/mixins.md
|
||||
- reference/core/schemas.md
|
||||
- reference/core/auth.md
|
||||
- counter:
|
||||
|
20
package-lock.json
generated
20
package-lock.json
generated
@ -27,7 +27,7 @@
|
||||
"cytoscape-cxtmenu": "^3.5.0",
|
||||
"cytoscape-klay": "^3.1.4",
|
||||
"d3-force-3d": "^3.0.5",
|
||||
"easymde": "^2.18.0",
|
||||
"easymde": "^2.19.0",
|
||||
"glob": "^11.0.0",
|
||||
"htmx.org": "^2.0.3",
|
||||
"jquery": "^3.7.1",
|
||||
@ -46,7 +46,7 @@
|
||||
"@rollup/plugin-inject": "^5.0.5",
|
||||
"@types/alpinejs": "^3.13.10",
|
||||
"@types/jquery": "^3.5.31",
|
||||
"vite": "^6.2.3",
|
||||
"vite": "^6.2.5",
|
||||
"vite-bundle-visualizer": "^1.2.1",
|
||||
"vite-plugin-static-copy": "^2.1.0"
|
||||
}
|
||||
@ -3663,14 +3663,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/easymde": {
|
||||
"version": "2.18.0",
|
||||
"resolved": "https://registry.npmjs.org/easymde/-/easymde-2.18.0.tgz",
|
||||
"integrity": "sha512-IxVVUxNWIoXLeqtBU4BLc+eS/ScYhT1Dcb6yF5Wchoj1iXAV+TIIDWx+NCaZhY7RcSHqDPKllbYq7nwGKILnoA==",
|
||||
"version": "2.19.0",
|
||||
"resolved": "https://registry.npmjs.org/easymde/-/easymde-2.19.0.tgz",
|
||||
"integrity": "sha512-4F1aNImqse+9xIjLh9ttfpOVenecjFPxUmKbl1tGp72Z+OyIqLZPE/SgNyy88c/xU0mOy0WC3+tfbZDQ5PDWhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/codemirror": "^5.60.4",
|
||||
"@types/codemirror": "^5.60.10",
|
||||
"@types/marked": "^4.0.7",
|
||||
"codemirror": "^5.63.1",
|
||||
"codemirror": "^5.65.15",
|
||||
"codemirror-spell-checker": "1.1.2",
|
||||
"marked": "^4.1.0"
|
||||
}
|
||||
@ -5705,9 +5705,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.3.tgz",
|
||||
"integrity": "sha512-IzwM54g4y9JA/xAeBPNaDXiBF8Jsgl3VBQ2YQ/wOY6fyW3xMdSoltIV3Bo59DErdqdE6RxUfv8W69DvUorE4Eg==",
|
||||
"version": "6.2.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.5.tgz",
|
||||
"integrity": "sha512-j023J/hCAa4pRIUH6J9HemwYfjB5llR2Ps0CWeikOtdR8+pAURAk0DoJC5/mm9kd+UgdnIy7d6HE4EAvlYhPhA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
@ -31,7 +31,7 @@
|
||||
"@rollup/plugin-inject": "^5.0.5",
|
||||
"@types/alpinejs": "^3.13.10",
|
||||
"@types/jquery": "^3.5.31",
|
||||
"vite": "^6.2.3",
|
||||
"vite": "^6.2.5",
|
||||
"vite-bundle-visualizer": "^1.2.1",
|
||||
"vite-plugin-static-copy": "^2.1.0"
|
||||
},
|
||||
@ -54,7 +54,7 @@
|
||||
"cytoscape-cxtmenu": "^3.5.0",
|
||||
"cytoscape-klay": "^3.1.4",
|
||||
"d3-force-3d": "^3.0.5",
|
||||
"easymde": "^2.18.0",
|
||||
"easymde": "^2.19.0",
|
||||
"glob": "^11.0.0",
|
||||
"htmx.org": "^2.0.3",
|
||||
"jquery": "^3.7.1",
|
||||
|
@ -43,7 +43,7 @@ dependencies = [
|
||||
"tomli<3.0.0,>=2.2.1",
|
||||
"django-honeypot<2.0.0,>=1.2.1",
|
||||
"pydantic-extra-types<3.0.0,>=2.10.2",
|
||||
"ical<9.0.0,>=8.3.1",
|
||||
"ical<10.0.0,>=9.1.0",
|
||||
"redis[hiredis]<6.0.0,>=5.2.0",
|
||||
"environs[django]<15.0.0,>=14.1.0",
|
||||
"requests>=2.32.3",
|
||||
|
46
sas/api.py
46
sas/api.py
@ -1,7 +1,10 @@
|
||||
from typing import Any, Literal
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import F
|
||||
from django.urls import reverse
|
||||
from ninja import Query
|
||||
from ninja import Body, File, Query
|
||||
from ninja_extra import ControllerBase, api_controller, paginate, route
|
||||
from ninja_extra.exceptions import NotFound, PermissionDenied
|
||||
from ninja_extra.pagination import PageNumberPaginationExtra
|
||||
@ -9,8 +12,15 @@ from ninja_extra.permissions import IsAuthenticated
|
||||
from ninja_extra.schemas import PaginatedResponseSchema
|
||||
from pydantic import NonNegativeInt
|
||||
|
||||
from core.auth.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot
|
||||
from core.auth.api_permissions import (
|
||||
CanAccessLookup,
|
||||
CanEdit,
|
||||
CanView,
|
||||
IsInGroup,
|
||||
IsRoot,
|
||||
)
|
||||
from core.models import Notification, User
|
||||
from core.schemas import UploadedImage
|
||||
from sas.models import Album, PeoplePictureRelation, Picture
|
||||
from sas.schemas import (
|
||||
AlbumAutocompleteSchema,
|
||||
@ -92,6 +102,38 @@ class PicturesController(ControllerBase):
|
||||
.annotate(album=F("parent__name"))
|
||||
)
|
||||
|
||||
@route.post(
|
||||
"",
|
||||
permissions=[CanEdit],
|
||||
response={
|
||||
200: None,
|
||||
409: dict[Literal["detail"], dict[str, list[str]]],
|
||||
422: dict[Literal["detail"], list[dict[str, Any]]],
|
||||
},
|
||||
url_name="upload_picture",
|
||||
)
|
||||
def upload_picture(self, album_id: Body[int], picture: File[UploadedImage]):
|
||||
album = self.get_object_or_exception(Album, pk=album_id)
|
||||
user = self.context.request.user
|
||||
self_moderate = user.has_perm("sas.moderate_sasfile")
|
||||
new = Picture(
|
||||
parent=album,
|
||||
name=picture.name,
|
||||
file=picture,
|
||||
owner=user,
|
||||
is_moderated=self_moderate,
|
||||
is_folder=False,
|
||||
mime_type=picture.content_type,
|
||||
)
|
||||
if self_moderate:
|
||||
new.moderator = user
|
||||
try:
|
||||
new.generate_thumbnails()
|
||||
new.full_clean()
|
||||
new.save()
|
||||
except ValidationError as e:
|
||||
return self.create_response({"detail": dict(e)}, status_code=409)
|
||||
|
||||
@route.get(
|
||||
"/{picture_id}/identified",
|
||||
permissions=[IsAuthenticated, CanView],
|
||||
|
70
sas/forms.py
70
sas/forms.py
@ -1,6 +1,7 @@
|
||||
from typing import Any
|
||||
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.models import User
|
||||
@ -11,55 +12,28 @@ from sas.models import Album, Picture, PictureModerationRequest
|
||||
from sas.widgets.ajax_select import AutoCompleteSelectAlbum
|
||||
|
||||
|
||||
class SASForm(forms.Form):
|
||||
album_name = forms.CharField(
|
||||
label=_("Add a new album"), max_length=Album.NAME_MAX_LENGTH, required=False
|
||||
)
|
||||
images = MultipleImageField(
|
||||
label=_("Upload images"),
|
||||
required=False,
|
||||
)
|
||||
class AlbumCreateForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Album
|
||||
fields = ["name", "parent"]
|
||||
labels = {"name": _("Add a new album")}
|
||||
widgets = {"parent": forms.HiddenInput}
|
||||
|
||||
def process(self, parent, owner, files, *, automodere=False):
|
||||
try:
|
||||
if self.cleaned_data["album_name"] != "":
|
||||
album = Album(
|
||||
parent=parent,
|
||||
name=self.cleaned_data["album_name"],
|
||||
owner=owner,
|
||||
is_moderated=automodere,
|
||||
)
|
||||
album.clean()
|
||||
album.save()
|
||||
except Exception as e:
|
||||
self.add_error(
|
||||
None,
|
||||
_("Error creating album %(album)s: %(msg)s")
|
||||
% {"album": self.cleaned_data["album_name"], "msg": repr(e)},
|
||||
)
|
||||
for f in files:
|
||||
new_file = Picture(
|
||||
parent=parent,
|
||||
name=f.name,
|
||||
file=f,
|
||||
owner=owner,
|
||||
mime_type=f.content_type,
|
||||
size=f.size,
|
||||
is_folder=False,
|
||||
is_moderated=automodere,
|
||||
)
|
||||
if automodere:
|
||||
new_file.moderator = owner
|
||||
try:
|
||||
new_file.clean()
|
||||
new_file.generate_thumbnails()
|
||||
new_file.save()
|
||||
except Exception as e:
|
||||
self.add_error(
|
||||
None,
|
||||
_("Error uploading file %(file_name)s: %(msg)s")
|
||||
% {"file_name": f, "msg": repr(e)},
|
||||
)
|
||||
def __init__(self, *args, owner: User, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.instance.owner = owner
|
||||
if owner.has_perm("sas.moderate_sasfile"):
|
||||
self.instance.is_moderated = True
|
||||
self.instance.moderator = owner
|
||||
|
||||
def clean(self):
|
||||
if not self.instance.owner.can_edit(self.instance.parent):
|
||||
raise ValidationError(_("You do not have the permission to do that"))
|
||||
return super().clean()
|
||||
|
||||
|
||||
class PictureUploadForm(forms.Form):
|
||||
images = MultipleImageField(label=_("Upload images"), required=False)
|
||||
|
||||
|
||||
class PictureEditForm(forms.ModelForm):
|
||||
|
@ -134,7 +134,6 @@ class Picture(SasFile):
|
||||
self.thumbnail.name = new_extension_name
|
||||
self.compressed = compressed
|
||||
self.compressed.name = new_extension_name
|
||||
self.save()
|
||||
|
||||
def rotate(self, degree):
|
||||
for attr in ["file", "compressed", "thumbnail"]:
|
||||
@ -235,6 +234,8 @@ class Album(SasFile):
|
||||
return Album.objects.filter(parent=self)
|
||||
|
||||
def get_absolute_url(self):
|
||||
if self.id == settings.SITH_SAS_ROOT_DIR_ID:
|
||||
return reverse("sas:main")
|
||||
return reverse("sas:album", kwargs={"album_id": self.id})
|
||||
|
||||
def get_download_url(self):
|
||||
|
@ -5,8 +5,10 @@ import {
|
||||
type AlbumSchema,
|
||||
type PictureSchema,
|
||||
type PicturesFetchPicturesData,
|
||||
type PicturesUploadPictureErrors,
|
||||
albumFetchAlbum,
|
||||
picturesFetchPictures,
|
||||
picturesUploadPicture,
|
||||
} from "#openapi";
|
||||
|
||||
interface AlbumPicturesConfig {
|
||||
@ -78,4 +80,49 @@ document.addEventListener("alpine:init", () => {
|
||||
this.loading = false;
|
||||
},
|
||||
}));
|
||||
|
||||
Alpine.data("pictureUpload", (albumId: number) => ({
|
||||
errors: [] as string[],
|
||||
pictures: [],
|
||||
sending: false,
|
||||
progress: null as HTMLProgressElement,
|
||||
|
||||
init() {
|
||||
this.progress = this.$refs.progress;
|
||||
},
|
||||
|
||||
async sendPictures() {
|
||||
const input = this.$refs.pictures as HTMLInputElement;
|
||||
const files = input.files;
|
||||
this.errors = [];
|
||||
this.progress.value = 0;
|
||||
this.progress.max = files.length;
|
||||
this.sending = true;
|
||||
for (const file of files) {
|
||||
await this.sendPicture(file);
|
||||
}
|
||||
this.sending = false;
|
||||
// This should trigger a reload of the pictures of the `picture` Alpine data
|
||||
this.$dispatch("pictures-upload-done");
|
||||
},
|
||||
|
||||
async sendPicture(file: File) {
|
||||
const res = await picturesUploadPicture({
|
||||
// biome-ignore lint/style/useNamingConvention: api is snake_case
|
||||
body: { album_id: albumId, picture: file },
|
||||
});
|
||||
if (!res.response.ok) {
|
||||
let msg = "";
|
||||
if (res.response.status === 422) {
|
||||
msg = (res.error as PicturesUploadPictureErrors[422]).detail
|
||||
.map((err: Record<"ctx", Record<"error", string>>) => err.ctx.error)
|
||||
.join(" ; ");
|
||||
} else {
|
||||
msg = Object.values(res.error.detail).join(" ; ");
|
||||
}
|
||||
this.errors.push(`${file.name} : ${msg}`);
|
||||
}
|
||||
this.progress.value += 1;
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
@ -73,7 +73,7 @@
|
||||
<div class="text">{% trans %}To be moderated{% endtrans %}</div>
|
||||
</template>
|
||||
</div>
|
||||
{% if edit_mode %}
|
||||
{% if is_sas_admin %}
|
||||
<input type="checkbox" name="file_list" :value="album.id">
|
||||
{% endif %}
|
||||
</a>
|
||||
@ -86,7 +86,7 @@
|
||||
<h4>{% trans %}Pictures{% endtrans %}</h4>
|
||||
<br>
|
||||
{{ download_button(_("Download album")) }}
|
||||
<div class="photos" :aria-busy="loading">
|
||||
<div class="photos" :aria-busy="loading" @pictures-upload-done.window="fetchPictures">
|
||||
<template x-for="picture in getPage(page)">
|
||||
<a :href="picture.sas_url">
|
||||
<div class="photo" :class="{not_moderated: !picture.is_moderated}">
|
||||
@ -110,13 +110,28 @@
|
||||
|
||||
{% if is_sas_admin %}
|
||||
</form>
|
||||
<form class="add-files" id="upload_form" action="" method="post" enctype="multipart/form-data">
|
||||
{{ album_create_fragment }}
|
||||
<form
|
||||
class="add-files"
|
||||
id="upload_form"
|
||||
x-data="pictureUpload({{ album.id }})"
|
||||
@submit.prevent="sendPictures()"
|
||||
>
|
||||
{% csrf_token %}
|
||||
<div class="inputs">
|
||||
{{ form.as_p() }}
|
||||
|
||||
<p>
|
||||
<label for="{{ upload_form.images.id_for_label }}">{{ upload_form.images.label }} :</label>
|
||||
{{ upload_form.images|add_attr("x-ref=pictures") }}
|
||||
<span class="helptext">{{ upload_form.images.help_text }}</span>
|
||||
</p>
|
||||
<input type="submit" value="{% trans %}Upload{% endtrans %}" />
|
||||
<progress x-ref="progress" x-show="sending"></progress>
|
||||
</div>
|
||||
<ul class="errorlist">
|
||||
<template x-for="error in errors">
|
||||
<li class="error" x-text="error"></li>
|
||||
</template>
|
||||
</ul>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
@ -126,115 +141,3 @@
|
||||
{{ timezone.now() - start }}
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
{{ super() }}
|
||||
<script>
|
||||
// Todo: migrate to alpine.js if we have some time
|
||||
$("form#upload_form").submit(function (event) {
|
||||
let formData = new FormData($(this)[0]);
|
||||
|
||||
if(!formData.get('album_name') && !formData.get('images').name)
|
||||
return false;
|
||||
|
||||
if(!formData.get('images').name) {
|
||||
return true;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
let errorList;
|
||||
if((errorList = this.querySelector('#upload_form ul.errorlist.nonfield')) === null) {
|
||||
errorList = document.createElement('ul');
|
||||
errorList.classList.add('errorlist', 'nonfield');
|
||||
this.insertBefore(errorList, this.firstElementChild);
|
||||
}
|
||||
|
||||
while(errorList.childElementCount > 0)
|
||||
errorList.removeChild(errorList.firstElementChild);
|
||||
|
||||
let progress;
|
||||
if((progress = this.querySelector('progress')) === null) {
|
||||
progress = document.createElement('progress');
|
||||
progress.value = 0;
|
||||
let p = document.createElement('p');
|
||||
p.appendChild(progress);
|
||||
this.insertBefore(p, this.lastElementChild);
|
||||
}
|
||||
|
||||
let dataHolder;
|
||||
|
||||
if(formData.get('album_name')) {
|
||||
dataHolder = new FormData();
|
||||
dataHolder.set('csrfmiddlewaretoken', '{{ csrf_token }}');
|
||||
dataHolder.set('album_name', formData.get('album_name'));
|
||||
$.ajax({
|
||||
method: 'POST',
|
||||
url: "{{ url('sas:album_upload', album_id=object.id) }}",
|
||||
data: dataHolder,
|
||||
processData: false,
|
||||
contentType: false,
|
||||
success: onSuccess
|
||||
});
|
||||
}
|
||||
|
||||
let images = formData.getAll('images');
|
||||
let imagesCount = images.length;
|
||||
let completeCount = 0;
|
||||
|
||||
let poolSize = 1;
|
||||
let imagePool = [];
|
||||
|
||||
while(images.length > 0 && imagePool.length < poolSize) {
|
||||
let image = images.shift();
|
||||
imagePool.push(image);
|
||||
sendImage(image);
|
||||
}
|
||||
|
||||
function sendImage(image) {
|
||||
dataHolder = new FormData();
|
||||
dataHolder.set('csrfmiddlewaretoken', '{{ csrf_token }}');
|
||||
dataHolder.set('images', image);
|
||||
|
||||
$.ajax({
|
||||
method: 'POST',
|
||||
url: "{{ url('sas:album_upload', album_id=object.id) }}",
|
||||
data: dataHolder,
|
||||
processData: false,
|
||||
contentType: false,
|
||||
})
|
||||
.fail(onSuccess.bind(undefined, image))
|
||||
.done(onSuccess.bind(undefined, image))
|
||||
.always(next.bind(undefined, image));
|
||||
}
|
||||
|
||||
function next(image, _, __) {
|
||||
let index = imagePool.indexOf(image);
|
||||
let nextImage = images.shift();
|
||||
|
||||
if(index !== -1)
|
||||
imagePool.splice(index, 1);
|
||||
|
||||
if(nextImage) {
|
||||
imagePool.push(nextImage);
|
||||
sendImage(nextImage);
|
||||
}
|
||||
}
|
||||
|
||||
function onSuccess(image, data, _, __) {
|
||||
let errors = [];
|
||||
|
||||
if ($(data.responseText).find('.errorlist.nonfield')[0])
|
||||
errors = Array.from($(data.responseText).find('.errorlist.nonfield')[0].children);
|
||||
|
||||
while(errors.length > 0)
|
||||
errorList.appendChild(errors.shift());
|
||||
|
||||
progress.value = ++completeCount / imagesCount;
|
||||
if(progress.value === 1 && errorList.children.length === 0)
|
||||
document.location.reload()
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
18
sas/templates/sas/fragments/album_create_form.jinja
Normal file
18
sas/templates/sas/fragments/album_create_form.jinja
Normal 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>
|
@ -61,23 +61,8 @@
|
||||
|
||||
{% if is_sas_admin %}
|
||||
</form>
|
||||
|
||||
<br>
|
||||
|
||||
<form class="add-files" action="" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="inputs">
|
||||
<div>
|
||||
<label for="{{ form.album_name.name }}">{{ form.album_name.label }}</label>
|
||||
{{ form.album_name }}
|
||||
</div>
|
||||
<input type="submit" value="{% trans %}Create{% endtrans %}" />
|
||||
</div>
|
||||
|
||||
{{ form.non_field_errors() }}
|
||||
{{ form.album_name.errors }}
|
||||
</form>
|
||||
{{ album_create_fragment }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</main>
|
||||
|
@ -1,13 +1,16 @@
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.db import transaction
|
||||
from django.test import TestCase
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
from model_bakery import baker
|
||||
from model_bakery.recipe import Recipe
|
||||
|
||||
from core.baker_recipes import old_subscriber_user, subscriber_user
|
||||
from core.models import Group, SithFile, User
|
||||
from core.utils import RED_PIXEL_PNG
|
||||
from sas.baker_recipes import picture_recipe
|
||||
from sas.models import Album, PeoplePictureRelation, Picture, PictureModerationRequest
|
||||
|
||||
@ -241,3 +244,45 @@ class TestAlbumSearch(TestSas):
|
||||
# - 1 for pagination
|
||||
# - 1 for the actual results
|
||||
self.client.get(reverse("api:search-album"))
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_upload_picture(client: Client):
|
||||
sas = SithFile.objects.get(pk=settings.SITH_SAS_ROOT_DIR_ID)
|
||||
album = baker.make(Album, is_in_sas=True, parent=sas, name="test album")
|
||||
user = baker.make(User, is_superuser=True)
|
||||
client.force_login(user)
|
||||
img = SimpleUploadedFile(
|
||||
name="img.png", content=RED_PIXEL_PNG, content_type="image/png"
|
||||
)
|
||||
res = client.post(
|
||||
reverse("api:upload_picture"), {"album_id": album.id, "picture": img}
|
||||
)
|
||||
assert res.status_code == 200
|
||||
picture = Picture.objects.filter(parent_id=album.id).first()
|
||||
assert picture is not None
|
||||
assert picture.name == "img.png"
|
||||
assert picture.owner == user
|
||||
assert picture.file.name == "SAS/test album/img.png"
|
||||
assert picture.compressed.name == ".compressed/SAS/test album/img.webp"
|
||||
assert picture.thumbnail.name == ".thumbnails/SAS/test album/img.webp"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_upload_invalid_picture(client: Client):
|
||||
sas = SithFile.objects.get(pk=settings.SITH_SAS_ROOT_DIR_ID)
|
||||
album = baker.make(Album, is_in_sas=True, parent=sas, name="test album")
|
||||
user = baker.make(User, is_superuser=True)
|
||||
client.force_login(user)
|
||||
file = SimpleUploadedFile(
|
||||
name="file.txt",
|
||||
content=b"azerty",
|
||||
content_type="image/png", # the server shouldn't blindly trust the content_type
|
||||
)
|
||||
res = client.post(
|
||||
reverse("api:upload_picture"), {"album_id": album.id, "picture": file}
|
||||
)
|
||||
assert res.status_code == 422
|
||||
assert res.json()["detail"][0]["ctx"]["error"] == (
|
||||
"Ce fichier n'est pas une image valide"
|
||||
)
|
||||
|
@ -16,8 +16,8 @@
|
||||
from django.urls import path
|
||||
|
||||
from sas.views import (
|
||||
AlbumCreateFragment,
|
||||
AlbumEditView,
|
||||
AlbumUploadView,
|
||||
AlbumView,
|
||||
ModerationView,
|
||||
PictureAskRemovalView,
|
||||
@ -35,9 +35,6 @@ urlpatterns = [
|
||||
path("", SASMainView.as_view(), name="main"),
|
||||
path("moderation/", ModerationView.as_view(), name="moderation"),
|
||||
path("album/<int:album_id>/", AlbumView.as_view(), name="album"),
|
||||
path(
|
||||
"album/<int:album_id>/upload/", AlbumUploadView.as_view(), name="album_upload"
|
||||
),
|
||||
path("album/<int:album_id>/edit/", AlbumEditView.as_view(), name="album_edit"),
|
||||
path("album/<int:album_id>/preview/", send_album, name="album_preview"),
|
||||
path("picture/<int:picture_id>/", PictureView.as_view(), name="picture"),
|
||||
@ -59,4 +56,5 @@ urlpatterns = [
|
||||
path(
|
||||
"user/<int:user_id>/pictures/", UserPicturesView.as_view(), name="user_pictures"
|
||||
),
|
||||
path("fragment/album-create", AlbumCreateFragment.as_view(), name="album_create"),
|
||||
]
|
||||
|
146
sas/views.py
146
sas/views.py
@ -16,48 +16,63 @@ from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import Http404, HttpResponse, HttpResponseRedirect
|
||||
from django.http import Http404, HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import DetailView, TemplateView
|
||||
from django.views.generic.edit import FormMixin, FormView, UpdateView
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import SafeString
|
||||
from django.views.generic import CreateView, DetailView, TemplateView
|
||||
from django.views.generic.edit import FormView, UpdateView
|
||||
|
||||
from core.auth.mixins import CanEditMixin, CanViewMixin
|
||||
from core.models import SithFile, User
|
||||
from core.views import UseFragmentsMixin
|
||||
from core.views.files import FileView, send_file
|
||||
from core.views.mixins import FragmentMixin, FragmentRenderer
|
||||
from core.views.user import UserTabsMixin
|
||||
from sas.forms import (
|
||||
AlbumCreateForm,
|
||||
AlbumEditForm,
|
||||
PictureEditForm,
|
||||
PictureModerationRequestForm,
|
||||
SASForm,
|
||||
PictureUploadForm,
|
||||
)
|
||||
from sas.models import Album, Picture
|
||||
|
||||
|
||||
class SASMainView(FormView):
|
||||
form_class = SASForm
|
||||
template_name = "sas/main.jinja"
|
||||
success_url = reverse_lazy("sas:main")
|
||||
class AlbumCreateFragment(FragmentMixin, CreateView):
|
||||
model = Album
|
||||
form_class = AlbumCreateForm
|
||||
template_name = "sas/fragments/album_create_form.jinja"
|
||||
reload_on_redirect = True
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.form = self.get_form()
|
||||
parent = SithFile.objects.filter(id=settings.SITH_SAS_ROOT_DIR_ID).first()
|
||||
files = request.FILES.getlist("images")
|
||||
root = User.objects.filter(username="root").first()
|
||||
if request.user.is_authenticated and request.user.is_in_group(
|
||||
pk=settings.SITH_GROUP_SAS_ADMIN_ID
|
||||
):
|
||||
if self.form.is_valid():
|
||||
self.form.process(
|
||||
parent=parent, owner=root, files=files, automodere=True
|
||||
)
|
||||
if self.form.is_valid():
|
||||
return super().form_valid(self.form)
|
||||
else:
|
||||
self.form.add_error(None, _("You do not have the permission to do that"))
|
||||
return self.form_invalid(self.form)
|
||||
def get_form_kwargs(self):
|
||||
return super().get_form_kwargs() | {"owner": self.request.user}
|
||||
|
||||
def render_fragment(
|
||||
self, request, owner: User | None = None, **kwargs
|
||||
) -> SafeString:
|
||||
self.object = None
|
||||
self.owner = owner or self.request.user
|
||||
return super().render_fragment(request, **kwargs)
|
||||
|
||||
def get_success_url(self):
|
||||
parent = self.object.parent
|
||||
parent.__class__ = Album
|
||||
return parent.get_absolute_url()
|
||||
|
||||
|
||||
class SASMainView(UseFragmentsMixin, TemplateView):
|
||||
template_name = "sas/main.jinja"
|
||||
|
||||
def get_fragments(self) -> dict[str, FragmentRenderer]:
|
||||
form_init = {"parent": SithFile.objects.get(id=settings.SITH_SAS_ROOT_DIR_ID)}
|
||||
return {
|
||||
"album_create_fragment": AlbumCreateFragment.as_fragment(initial=form_init)
|
||||
}
|
||||
|
||||
def get_fragment_data(self) -> dict[str, dict[str, Any]]:
|
||||
root_user = User.objects.get(pk=settings.SITH_ROOT_USER_ID)
|
||||
return {"album_create_fragment": {"owner": root_user}}
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
@ -104,88 +119,45 @@ def send_thumb(request, picture_id):
|
||||
return send_file(request, picture_id, Picture, "thumbnail")
|
||||
|
||||
|
||||
class AlbumUploadView(CanViewMixin, DetailView, FormMixin):
|
||||
class AlbumView(CanViewMixin, UseFragmentsMixin, DetailView):
|
||||
model = Album
|
||||
form_class = SASForm
|
||||
pk_url_kwarg = "album_id"
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
if not self.object.file:
|
||||
self.object.generate_thumbnail()
|
||||
self.form = self.get_form()
|
||||
parent = SithFile.objects.filter(id=self.object.id).first()
|
||||
files = request.FILES.getlist("images")
|
||||
if request.user.is_subscribed and self.form.is_valid():
|
||||
self.form.process(
|
||||
parent=parent,
|
||||
owner=request.user,
|
||||
files=files,
|
||||
automodere=(
|
||||
request.user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID)
|
||||
or request.user.is_root
|
||||
),
|
||||
)
|
||||
if self.form.is_valid():
|
||||
return HttpResponse(str(self.form.errors), status=200)
|
||||
return HttpResponse(str(self.form.errors), status=500)
|
||||
|
||||
|
||||
class AlbumView(CanViewMixin, DetailView, FormMixin):
|
||||
model = Album
|
||||
form_class = SASForm
|
||||
pk_url_kwarg = "album_id"
|
||||
template_name = "sas/album.jinja"
|
||||
|
||||
def get_fragments(self) -> dict[str, FragmentRenderer]:
|
||||
return {
|
||||
"album_create_fragment": AlbumCreateFragment.as_fragment(
|
||||
initial={"parent": self.object}
|
||||
)
|
||||
}
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
try:
|
||||
self.asked_page = int(request.GET.get("page", 1))
|
||||
except ValueError as e:
|
||||
raise Http404 from e
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.form = self.get_form()
|
||||
if "clipboard" not in request.session:
|
||||
request.session["clipboard"] = []
|
||||
return super().get(request, *args, **kwargs)
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
if not self.object.file:
|
||||
self.object.generate_thumbnail()
|
||||
self.form = self.get_form()
|
||||
if "clipboard" not in request.session:
|
||||
request.session["clipboard"] = []
|
||||
if request.user.can_edit(self.object): # Handle the copy-paste functions
|
||||
FileView.handle_clipboard(request, self.object)
|
||||
parent = SithFile.objects.filter(id=self.object.id).first()
|
||||
files = request.FILES.getlist("images")
|
||||
if request.user.is_authenticated and request.user.is_subscribed:
|
||||
if self.form.is_valid():
|
||||
self.form.process(
|
||||
parent=parent,
|
||||
owner=request.user,
|
||||
files=files,
|
||||
automodere=request.user.is_in_group(
|
||||
pk=settings.SITH_GROUP_SAS_ADMIN_ID
|
||||
),
|
||||
)
|
||||
if self.form.is_valid():
|
||||
return super().form_valid(self.form)
|
||||
else:
|
||||
self.form.add_error(None, _("You do not have the permission to do that"))
|
||||
return self.form_invalid(self.form)
|
||||
return HttpResponseRedirect(self.request.path)
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse("sas:album", kwargs={"album_id": self.object.id})
|
||||
def get_fragment_data(self) -> dict[str, dict[str, Any]]:
|
||||
return {"album_create_fragment": {"owner": self.request.user}}
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
kwargs["form"] = self.form
|
||||
kwargs["clipboard"] = SithFile.objects.filter(
|
||||
id__in=self.request.session["clipboard"]
|
||||
)
|
||||
if ids := self.request.session.get("clipboard", None):
|
||||
kwargs["clipboard"] = SithFile.objects.filter(id__in=ids)
|
||||
kwargs["upload_form"] = PictureUploadForm()
|
||||
# if True, the albums will be fetched with a request to the API
|
||||
# if False, the section won't be displayed at all
|
||||
kwargs["show_albums"] = (
|
||||
Album.objects.viewable_by(self.request.user)
|
||||
.filter(parent_id=self.object.id)
|
||||
|
@ -78,10 +78,12 @@ DEBUG = env.bool("SITH_DEBUG", default=False)
|
||||
TESTING = "pytest" in sys.modules
|
||||
INTERNAL_IPS = ["127.0.0.1"]
|
||||
|
||||
HTTPS = env.bool("HTTPS", default=True)
|
||||
|
||||
# force csrf tokens and cookies to be secure when in https
|
||||
CSRF_COOKIE_SECURE = env.bool("HTTPS", default=True)
|
||||
CSRF_COOKIE_SECURE = HTTPS
|
||||
CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=[])
|
||||
SESSION_COOKIE_SECURE = env.bool("HTTPS", default=True)
|
||||
SESSION_COOKIE_SECURE = HTTPS
|
||||
X_FRAME_OPTIONS = "SAMEORIGIN"
|
||||
|
||||
ALLOWED_HOSTS = ["*"]
|
||||
|
9
uv.lock
generated
9
uv.lock
generated
@ -1,5 +1,4 @@
|
||||
version = 1
|
||||
revision = 1
|
||||
requires-python = ">=3.12, <4.0"
|
||||
|
||||
[[package]]
|
||||
@ -640,7 +639,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ical"
|
||||
version = "8.3.1"
|
||||
version = "9.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
@ -648,9 +647,9 @@ dependencies = [
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "tzdata" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/56/f6/f623166c503de1c3eff70a54e1e8a4cc83aca5de0c4d108ec4f6d97251b3/ical-8.3.1.tar.gz", hash = "sha256:4d3c8c215230c18b1544d11f8e3342301943b5c6fc1b9980442735b84f8015cd", size = 117126 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/67/ed/3b23916c730d136d0a96366079de9c9ac619a0dbb85a1c1a4dcfb8ca25e8/ical-9.1.0.tar.gz", hash = "sha256:0d27946eec356536f4addacb63f8b9016b3b06160d77dc2a46981aa55519a2a7", size = 118297 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/ba/4abd303bd8f32bf482ab1f2f090f4a59edcfa8897bd68779b6853428a87a/ical-8.3.1-py3-none-any.whl", hash = "sha256:22e07acd9d36ef0ccb3970a47d5efd5ff29a833fe541145d6f1d3a7676334fa3", size = 117317 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/41/e28a56f0ffd255f93195d3ce3b8b0aac59f00668dd0bfbae43b9ff9f08e3/ical-9.1.0-py3-none-any.whl", hash = "sha256:f931b22d2dbeae69b463784dc6e7ec26887554e5bab41251767d91067125f63b", size = 118736 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1640,7 +1639,7 @@ requires-dist = [
|
||||
{ name = "django-simple-captcha", specifier = ">=0.6.2,<1.0.0" },
|
||||
{ name = "environs", extras = ["django"], specifier = ">=14.1.0,<15.0.0" },
|
||||
{ name = "honcho", specifier = ">=2.0.0" },
|
||||
{ name = "ical", specifier = ">=8.3.1,<9.0.0" },
|
||||
{ name = "ical", specifier = ">=9.1.0,<10.0.0" },
|
||||
{ name = "jinja2", specifier = ">=3.1.6,<4.0.0" },
|
||||
{ name = "libsass", specifier = ">=0.23.0,<1.0.0" },
|
||||
{ name = "mistune", specifier = ">=3.1.2,<4.0.0" },
|
||||
|
Loading…
x
Reference in New Issue
Block a user