mirror of
https://github.com/ae-utbm/sith.git
synced 2026-05-03 03:46:08 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 381f1ac829 |
@@ -7,7 +7,7 @@ from model_bakery import baker
|
|||||||
|
|
||||||
from com.models import News, NewsDate
|
from com.models import News, NewsDate
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
from core.models import Group, Notification, SithFile, User
|
from core.models import Group, Notification, User
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@@ -18,7 +18,6 @@ def test_notification_created():
|
|||||||
past_news = baker.make(News, is_published=False)
|
past_news = baker.make(News, is_published=False)
|
||||||
baker.make(NewsDate, news=past_news, start_date=now() - timedelta(days=1))
|
baker.make(NewsDate, news=past_news, start_date=now() - timedelta(days=1))
|
||||||
com_admin_group = Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)
|
com_admin_group = Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)
|
||||||
SithFile.objects.filter(owner__in=com_admin_group.users.all()).delete()
|
|
||||||
com_admin_group.users.all().delete()
|
com_admin_group.users.all().delete()
|
||||||
Notification.objects.all().delete()
|
Notification.objects.all().delete()
|
||||||
com_admin = baker.make(User, groups=[com_admin_group])
|
com_admin = baker.make(User, groups=[com_admin_group])
|
||||||
|
|||||||
@@ -622,7 +622,8 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
pict.file.name = p.name
|
pict.file.name = p.name
|
||||||
pict.full_clean()
|
pict.full_clean()
|
||||||
pict.generate_thumbnails(save=True)
|
pict.generate_thumbnails()
|
||||||
|
pict.save()
|
||||||
|
|
||||||
img_skia = Picture.objects.get(name="skia.jpg")
|
img_skia = Picture.objects.get(name="skia.jpg")
|
||||||
img_sli = Picture.objects.get(name="sli.jpg")
|
img_sli = Picture.objects.get(name="sli.jpg")
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
# Generated by Django 5.2.12 on 2026-05-01 08:59
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
from django.db.migrations.state import StateApps
|
|
||||||
from django.db.models import F
|
|
||||||
|
|
||||||
|
|
||||||
def set_updated_at(apps: StateApps, schema_editor):
|
|
||||||
SithFile = apps.get_model("core", "SithFile")
|
|
||||||
SithFile.objects.update(updated_at=F("date"))
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [("core", "0049_user_whitelisted_users")]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="sithfile",
|
|
||||||
name="moderator",
|
|
||||||
field=models.ForeignKey(
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
on_delete=django.db.models.deletion.SET_NULL,
|
|
||||||
related_name="moderated_files",
|
|
||||||
to=settings.AUTH_USER_MODEL,
|
|
||||||
verbose_name="owner",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="sithfile",
|
|
||||||
name="owner",
|
|
||||||
field=models.ForeignKey(
|
|
||||||
on_delete=django.db.models.deletion.PROTECT,
|
|
||||||
related_name="owned_files",
|
|
||||||
to=settings.AUTH_USER_MODEL,
|
|
||||||
verbose_name="owner",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="sithfile",
|
|
||||||
name="updated_at",
|
|
||||||
field=models.DateTimeField(auto_now=True, verbose_name="updated at"),
|
|
||||||
),
|
|
||||||
migrations.RunPython(set_updated_at, reverse_code=migrations.RunPython.noop),
|
|
||||||
]
|
|
||||||
+2
-3
@@ -853,7 +853,7 @@ class SithFile(models.Model):
|
|||||||
User,
|
User,
|
||||||
related_name="owned_files",
|
related_name="owned_files",
|
||||||
verbose_name=_("owner"),
|
verbose_name=_("owner"),
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.CASCADE,
|
||||||
)
|
)
|
||||||
edit_groups = models.ManyToManyField(
|
edit_groups = models.ManyToManyField(
|
||||||
Group, related_name="editable_files", verbose_name=_("edit group"), blank=True
|
Group, related_name="editable_files", verbose_name=_("edit group"), blank=True
|
||||||
@@ -865,7 +865,6 @@ class SithFile(models.Model):
|
|||||||
mime_type = models.CharField(_("mime type"), max_length=30)
|
mime_type = models.CharField(_("mime type"), max_length=30)
|
||||||
size = models.IntegerField(_("size"), default=0)
|
size = models.IntegerField(_("size"), default=0)
|
||||||
date = models.DateTimeField(_("date"), default=timezone.now)
|
date = models.DateTimeField(_("date"), default=timezone.now)
|
||||||
updated_at = models.DateTimeField(_("updated at"), auto_now=True)
|
|
||||||
is_moderated = models.BooleanField(_("is moderated"), default=False)
|
is_moderated = models.BooleanField(_("is moderated"), default=False)
|
||||||
moderator = models.ForeignKey(
|
moderator = models.ForeignKey(
|
||||||
User,
|
User,
|
||||||
@@ -873,7 +872,7 @@ class SithFile(models.Model):
|
|||||||
verbose_name=_("owner"),
|
verbose_name=_("owner"),
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.CASCADE,
|
||||||
)
|
)
|
||||||
asked_for_removal = models.BooleanField(_("asked for removal"), default=False)
|
asked_for_removal = models.BooleanField(_("asked for removal"), default=False)
|
||||||
is_in_sas = models.BooleanField(
|
is_in_sas = models.BooleanField(
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<div id="quick-notifications"
|
<div id="quick-notifications"
|
||||||
x-data='{
|
x-data="{
|
||||||
messages: [
|
messages: [
|
||||||
{%- for message in messages -%}
|
{%- for message in messages -%}
|
||||||
{%- if not message.extra_tags -%}
|
{%- if not message.extra_tags -%}
|
||||||
{ tag: {{ message.tags|string|tojson }}, text: {{ message|string|tojson }} },
|
{ tag: '{{ message.tags }}', text: '{{ message }}' },
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
{%- endfor -%}
|
{%- endfor -%}
|
||||||
]
|
]
|
||||||
}'
|
}"
|
||||||
@quick-notification-add="(e) => messages.push(e?.detail)"
|
@quick-notification-add="(e) => messages.push(e?.detail)"
|
||||||
@quick-notification-delete="messages = []">
|
@quick-notification-delete="messages = []">
|
||||||
<template x-for="(message, index) in messages">
|
<template x-for="(message, index) in messages">
|
||||||
|
|||||||
@@ -33,8 +33,7 @@
|
|||||||
<a href="{{ url("core:file_detail", file_id=f.id) }}">{{ f.name }}</a><br/>
|
<a href="{{ url("core:file_detail", file_id=f.id) }}">{{ f.name }}</a><br/>
|
||||||
{% trans %}Full name: {% endtrans %}{{ f.get_parent_path()+'/'+f.name }}<br/>
|
{% trans %}Full name: {% endtrans %}{{ f.get_parent_path()+'/'+f.name }}<br/>
|
||||||
{% trans %}Owner: {% endtrans %}{{ f.owner.get_display_name() }}<br/>
|
{% trans %}Owner: {% endtrans %}{{ f.owner.get_display_name() }}<br/>
|
||||||
{% trans %}Date: {% endtrans %}
|
{% trans %}Date: {% endtrans %}{{ f.date|date(DATE_FORMAT) }} {{ f.date|time(TIME_FORMAT) }}<br/>
|
||||||
{{ f.date|date(DATE_FORMAT) }} {{ f.date|time(TIME_FORMAT) }}<br/>
|
|
||||||
</p>
|
</p>
|
||||||
<p><button
|
<p><button
|
||||||
hx-get="{{ url('core:file_moderate', file_id=f.id) }}"
|
hx-get="{{ url('core:file_moderate', file_id=f.id) }}"
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from core.baker_recipes import (
|
|||||||
subscriber_user,
|
subscriber_user,
|
||||||
very_old_subscriber_user,
|
very_old_subscriber_user,
|
||||||
)
|
)
|
||||||
from core.models import AnonymousUser, Group, SithFile, User
|
from core.models import AnonymousUser, Group, User
|
||||||
from core.views import UserTabsMixin
|
from core.views import UserTabsMixin
|
||||||
from counter.baker_recipes import sale_recipe
|
from counter.baker_recipes import sale_recipe
|
||||||
from counter.models import Counter, Customer, Permanency, Refilling, Selling
|
from counter.models import Counter, Customer, Permanency, Refilling, Selling
|
||||||
@@ -34,7 +34,6 @@ class TestSearchUsers(TestCase):
|
|||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
# News.author has on_delete=PROTECT, so news must be deleted beforehand
|
# News.author has on_delete=PROTECT, so news must be deleted beforehand
|
||||||
News.objects.all().delete()
|
News.objects.all().delete()
|
||||||
SithFile.objects.all().delete()
|
|
||||||
User.objects.all().delete()
|
User.objects.all().delete()
|
||||||
user_recipe = Recipe(
|
user_recipe = Recipe(
|
||||||
User,
|
User,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from django.core.files.base import ContentFile
|
|||||||
from django.core.files.uploadedfile import UploadedFile
|
from django.core.files.uploadedfile import UploadedFile
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from django.utils.timezone import localdate
|
from django.utils.timezone import localdate
|
||||||
|
from PIL import ExifTags
|
||||||
from PIL.Image import Image, Resampling
|
from PIL.Image import Image, Resampling
|
||||||
|
|
||||||
RED_PIXEL_PNG: Final[bytes] = (
|
RED_PIXEL_PNG: Final[bytes] = (
|
||||||
@@ -177,6 +178,22 @@ def resize_image_explicit(
|
|||||||
return ContentFile(content.getvalue())
|
return ContentFile(content.getvalue())
|
||||||
|
|
||||||
|
|
||||||
|
def exif_auto_rotate(image):
|
||||||
|
for orientation in ExifTags.TAGS:
|
||||||
|
if ExifTags.TAGS[orientation] == "Orientation":
|
||||||
|
break
|
||||||
|
exif = dict(image._getexif().items())
|
||||||
|
|
||||||
|
if exif[orientation] == 3:
|
||||||
|
image = image.rotate(180, expand=True)
|
||||||
|
elif exif[orientation] == 6:
|
||||||
|
image = image.rotate(270, expand=True)
|
||||||
|
elif exif[orientation] == 8:
|
||||||
|
image = image.rotate(90, expand=True)
|
||||||
|
|
||||||
|
return image
|
||||||
|
|
||||||
|
|
||||||
def get_client_ip(request: HttpRequest) -> str | None:
|
def get_client_ip(request: HttpRequest) -> str | None:
|
||||||
headers = (
|
headers = (
|
||||||
"X_FORWARDED_FOR", # Common header for proxies
|
"X_FORWARDED_FOR", # Common header for proxies
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-05-02 17:57+0200\n"
|
"POT-Creation-Date: 2026-03-23 22:21+0100\n"
|
||||||
"PO-Revision-Date: 2016-07-18\n"
|
"PO-Revision-Date: 2016-07-18\n"
|
||||||
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
|
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
|
||||||
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
||||||
@@ -181,22 +181,6 @@ msgstr "Vous devez être cotisant pour faire partie d'un club"
|
|||||||
msgid "You are already a member of this club"
|
msgid "You are already a member of this club"
|
||||||
msgstr "Vous êtes déjà membre de ce club."
|
msgstr "Vous êtes déjà membre de ce club."
|
||||||
|
|
||||||
#: club/forms.py
|
|
||||||
msgid "Club status"
|
|
||||||
msgstr "État du club"
|
|
||||||
|
|
||||||
#: club/forms.py
|
|
||||||
msgid "Active"
|
|
||||||
msgstr "Actif"
|
|
||||||
|
|
||||||
#: club/forms.py
|
|
||||||
msgid "Inactive"
|
|
||||||
msgstr "Inactif"
|
|
||||||
|
|
||||||
#: club/forms.py
|
|
||||||
msgid "All clubs"
|
|
||||||
msgstr "Tous les clubs"
|
|
||||||
|
|
||||||
#: club/models.py
|
#: club/models.py
|
||||||
msgid "slug name"
|
msgid "slug name"
|
||||||
msgstr "nom slug"
|
msgstr "nom slug"
|
||||||
@@ -317,22 +301,37 @@ msgstr "Cet email est déjà abonné à cette mailing"
|
|||||||
msgid "Unregistered user"
|
msgid "Unregistered user"
|
||||||
msgstr "Utilisateur non enregistré"
|
msgstr "Utilisateur non enregistré"
|
||||||
|
|
||||||
#: club/templates/club/club_list.jinja
|
|
||||||
msgid "The list of all clubs existing at UTBM."
|
|
||||||
msgstr "La liste de tous les clubs existants à l'UTBM"
|
|
||||||
|
|
||||||
#: club/templates/club/club_list.jinja
|
#: club/templates/club/club_list.jinja
|
||||||
msgid "Club list"
|
msgid "Club list"
|
||||||
msgstr "Liste des clubs"
|
msgstr "Liste des clubs"
|
||||||
|
|
||||||
|
#: club/templates/club/club_list.jinja
|
||||||
|
msgid "The list of all clubs existing at UTBM."
|
||||||
|
msgstr "La liste de tous les clubs existants à l'UTBM"
|
||||||
|
|
||||||
#: club/templates/club/club_list.jinja
|
#: club/templates/club/club_list.jinja
|
||||||
msgid "Filters"
|
msgid "Filters"
|
||||||
msgstr "Filtres"
|
msgstr "Filtres"
|
||||||
|
|
||||||
#: club/templates/club/club_list.jinja core/templates/core/base/header.jinja
|
#: club/templates/club/club_list.jinja
|
||||||
#: forum/templates/forum/macros.jinja matmat/templates/matmat/search_form.jinja
|
msgid "Name"
|
||||||
msgid "Search"
|
msgstr "Nom"
|
||||||
msgstr "Recherche"
|
|
||||||
|
#: club/templates/club/club_list.jinja
|
||||||
|
msgid "Club state"
|
||||||
|
msgstr "Etat du club"
|
||||||
|
|
||||||
|
#: club/templates/club/club_list.jinja
|
||||||
|
msgid "Active"
|
||||||
|
msgstr "Actif"
|
||||||
|
|
||||||
|
#: club/templates/club/club_list.jinja
|
||||||
|
msgid "Inactive"
|
||||||
|
msgstr "Inactif"
|
||||||
|
|
||||||
|
#: club/templates/club/club_list.jinja
|
||||||
|
msgid "All clubs"
|
||||||
|
msgstr "Tous les clubs"
|
||||||
|
|
||||||
#: club/templates/club/club_list.jinja core/templates/core/user_tools.jinja
|
#: club/templates/club/club_list.jinja core/templates/core/user_tools.jinja
|
||||||
msgid "New club"
|
msgid "New club"
|
||||||
@@ -434,7 +433,7 @@ msgstr "Bénéfice : "
|
|||||||
#: counter/templates/counter/cash_summary_list.jinja
|
#: counter/templates/counter/cash_summary_list.jinja
|
||||||
#: counter/templates/counter/last_ops.jinja
|
#: counter/templates/counter/last_ops.jinja
|
||||||
#: counter/templates/counter/refilling_list.jinja
|
#: counter/templates/counter/refilling_list.jinja
|
||||||
#: rootplace/templates/rootplace/logs.jinja
|
#: rootplace/templates/rootplace/logs.jinja sas/forms.py
|
||||||
#: trombi/templates/trombi/user_profile.jinja
|
#: trombi/templates/trombi/user_profile.jinja
|
||||||
msgid "Date"
|
msgid "Date"
|
||||||
msgstr "Date"
|
msgstr "Date"
|
||||||
@@ -1693,10 +1692,6 @@ msgstr "taille"
|
|||||||
msgid "date"
|
msgid "date"
|
||||||
msgstr "date"
|
msgstr "date"
|
||||||
|
|
||||||
#: core/models.py counter/models.py
|
|
||||||
msgid "updated at"
|
|
||||||
msgstr "mis à jour le"
|
|
||||||
|
|
||||||
#: core/models.py
|
#: core/models.py
|
||||||
msgid "asked for removal"
|
msgid "asked for removal"
|
||||||
msgstr "retrait demandé"
|
msgstr "retrait demandé"
|
||||||
@@ -1868,6 +1863,11 @@ msgstr "Connexion"
|
|||||||
msgid "Register"
|
msgid "Register"
|
||||||
msgstr "Inscription"
|
msgstr "Inscription"
|
||||||
|
|
||||||
|
#: core/templates/core/base/header.jinja forum/templates/forum/macros.jinja
|
||||||
|
#: matmat/templates/matmat/search_form.jinja
|
||||||
|
msgid "Search"
|
||||||
|
msgstr "Recherche"
|
||||||
|
|
||||||
#: core/templates/core/base/header.jinja
|
#: core/templates/core/base/header.jinja
|
||||||
msgid "Logout"
|
msgid "Logout"
|
||||||
msgstr "Déconnexion"
|
msgstr "Déconnexion"
|
||||||
@@ -3195,6 +3195,10 @@ msgstr "groupe d'achat"
|
|||||||
msgid "archived"
|
msgid "archived"
|
||||||
msgstr "archivé"
|
msgstr "archivé"
|
||||||
|
|
||||||
|
#: counter/models.py
|
||||||
|
msgid "updated at"
|
||||||
|
msgstr "mis à jour le"
|
||||||
|
|
||||||
#: counter/models.py eboutic/models.py
|
#: counter/models.py eboutic/models.py
|
||||||
msgid "product"
|
msgid "product"
|
||||||
msgstr "produit"
|
msgstr "produit"
|
||||||
@@ -3824,14 +3828,14 @@ msgstr ""
|
|||||||
"votre cotisation. Si vous ne renouvelez pas votre cotisation, il n'y aura "
|
"votre cotisation. Si vous ne renouvelez pas votre cotisation, il n'y aura "
|
||||||
"aucune conséquence autre que le retrait de l'argent de votre compte."
|
"aucune conséquence autre que le retrait de l'argent de votre compte."
|
||||||
|
|
||||||
#: counter/templates/counter/product_form.jinja
|
|
||||||
msgid "Remove this action"
|
|
||||||
msgstr "Retirer cette action"
|
|
||||||
|
|
||||||
#: counter/templates/counter/product_form.jinja
|
#: counter/templates/counter/product_form.jinja
|
||||||
msgid "Remove price"
|
msgid "Remove price"
|
||||||
msgstr "Retirer le prix"
|
msgstr "Retirer le prix"
|
||||||
|
|
||||||
|
#: counter/templates/counter/product_form.jinja
|
||||||
|
msgid "Remove this action"
|
||||||
|
msgstr "Retirer cette action"
|
||||||
|
|
||||||
#: counter/templates/counter/product_form.jinja
|
#: counter/templates/counter/product_form.jinja
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Edit product %(name)s"
|
msgid "Edit product %(name)s"
|
||||||
@@ -4201,47 +4205,6 @@ msgstr ""
|
|||||||
msgid "this page"
|
msgid "this page"
|
||||||
msgstr "cette page"
|
msgstr "cette page"
|
||||||
|
|
||||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
|
||||||
msgid "Eurockéennes 2025 partnership"
|
|
||||||
msgstr "Partenariat Eurockéennes 2025"
|
|
||||||
|
|
||||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
|
||||||
msgid ""
|
|
||||||
"Our partner uses Weezevent to sell tickets. Weezevent may collect user info "
|
|
||||||
"according to its own privacy policy. By clicking the accept button you "
|
|
||||||
"consent to their terms of services."
|
|
||||||
msgstr ""
|
|
||||||
"Notre partenaire utilises Wezevent pour vendre ses billets. Weezevent peut "
|
|
||||||
"collecter des informations utilisateur conformément à sa propre politique de "
|
|
||||||
"confidentialité. En cliquant sur le bouton d'acceptation vous consentez à "
|
|
||||||
"leurs termes de service."
|
|
||||||
|
|
||||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
|
||||||
msgid "Privacy policy"
|
|
||||||
msgstr "Politique de confidentialité"
|
|
||||||
|
|
||||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
|
||||||
#: trombi/templates/trombi/comment_moderation.jinja
|
|
||||||
msgid "Accept"
|
|
||||||
msgstr "Accepter"
|
|
||||||
|
|
||||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
|
||||||
msgid ""
|
|
||||||
"You must be subscribed to benefit from the partnership with the Eurockéennes."
|
|
||||||
msgstr ""
|
|
||||||
"Vous devez être cotisant pour bénéficier du partenariat avec les "
|
|
||||||
"Eurockéennes."
|
|
||||||
|
|
||||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
|
||||||
#, python-format
|
|
||||||
msgid ""
|
|
||||||
"This partnership offers a discount of up to 33%% on tickets for Friday, "
|
|
||||||
"Saturday and Sunday, as well as the 3-day package from Friday to Sunday."
|
|
||||||
msgstr ""
|
|
||||||
"Ce partenariat permet de profiter d'une réduction jusqu'à 33%% sur les "
|
|
||||||
"billets du vendredi, du samedi et du dimanche, ainsi qu'au forfait 3 jours, "
|
|
||||||
"du vendredi au dimanche."
|
|
||||||
|
|
||||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||||
msgid "There are no items available for sale"
|
msgid "There are no items available for sale"
|
||||||
msgstr "Aucun article n'est disponible à la vente"
|
msgstr "Aucun article n'est disponible à la vente"
|
||||||
@@ -5668,6 +5631,10 @@ msgstr "fin"
|
|||||||
msgid "Moderate Trombi comments"
|
msgid "Moderate Trombi comments"
|
||||||
msgstr "Modérer les commentaires du Trombi"
|
msgstr "Modérer les commentaires du Trombi"
|
||||||
|
|
||||||
|
#: trombi/templates/trombi/comment_moderation.jinja
|
||||||
|
msgid "Accept"
|
||||||
|
msgstr "Accepter"
|
||||||
|
|
||||||
#: trombi/templates/trombi/comment_moderation.jinja
|
#: trombi/templates/trombi/comment_moderation.jinja
|
||||||
msgid "Reject"
|
msgid "Reject"
|
||||||
msgstr "Refuser"
|
msgstr "Refuser"
|
||||||
@@ -5909,3 +5876,39 @@ msgstr "Vous ne pouvez plus écrire de commentaires, la date est passée."
|
|||||||
#, python-format
|
#, python-format
|
||||||
msgid "Maximum characters: %(max_length)s"
|
msgid "Maximum characters: %(max_length)s"
|
||||||
msgstr "Nombre de caractères max: %(max_length)s"
|
msgstr "Nombre de caractères max: %(max_length)s"
|
||||||
|
|
||||||
|
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||||
|
msgid "Eurockéennes 2025 partnership"
|
||||||
|
msgstr "Partenariat Eurockéennes 2025"
|
||||||
|
|
||||||
|
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||||
|
msgid ""
|
||||||
|
"Our partner uses Weezevent to sell tickets. Weezevent may collect user info "
|
||||||
|
"according to its own privacy policy. By clicking the accept button you "
|
||||||
|
"consent to their terms of services."
|
||||||
|
msgstr ""
|
||||||
|
"Notre partenaire utilises Wezevent pour vendre ses billets. Weezevent peut "
|
||||||
|
"collecter des informations utilisateur conformément à sa propre politique de "
|
||||||
|
"confidentialité. En cliquant sur le bouton d'acceptation vous consentez à "
|
||||||
|
"leurs termes de service."
|
||||||
|
|
||||||
|
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||||
|
msgid "Privacy policy"
|
||||||
|
msgstr "Politique de confidentialité"
|
||||||
|
|
||||||
|
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||||
|
msgid ""
|
||||||
|
"You must be subscribed to benefit from the partnership with the Eurockéennes."
|
||||||
|
msgstr ""
|
||||||
|
"Vous devez être cotisant pour bénéficier du partenariat avec les "
|
||||||
|
"Eurockéennes."
|
||||||
|
|
||||||
|
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||||
|
#, python-format
|
||||||
|
msgid ""
|
||||||
|
"This partnership offers a discount of up to 33%% on tickets for Friday, "
|
||||||
|
"Saturday and Sunday, as well as the 3-day package from Friday to Sunday."
|
||||||
|
msgstr ""
|
||||||
|
"Ce partenariat permet de profiter d'une réduction jusqu'à 33%% sur les "
|
||||||
|
"billets du vendredi, du samedi et du dimanche, ainsi qu'au forfait 3 jours, "
|
||||||
|
"du vendredi au dimanche."
|
||||||
|
|||||||
+1
-2
@@ -7,14 +7,13 @@ from model_bakery import baker
|
|||||||
|
|
||||||
from com.models import News
|
from com.models import News
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
from core.models import SithFile, User
|
from core.models import User
|
||||||
|
|
||||||
|
|
||||||
class TestMatmatronch(TestCase):
|
class TestMatmatronch(TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
News.objects.all().delete()
|
News.objects.all().delete()
|
||||||
SithFile.objects.all().delete()
|
|
||||||
User.objects.all().delete()
|
User.objects.all().delete()
|
||||||
users = [
|
users = [
|
||||||
baker.prepare(User, promo=17),
|
baker.prepare(User, promo=17),
|
||||||
|
|||||||
Generated
-64
@@ -43,7 +43,6 @@
|
|||||||
"@babel/preset-env": "^7.29.2",
|
"@babel/preset-env": "^7.29.2",
|
||||||
"@biomejs/biome": "^2.4.13",
|
"@biomejs/biome": "^2.4.13",
|
||||||
"@hey-api/openapi-ts": "^0.94.5",
|
"@hey-api/openapi-ts": "^0.94.5",
|
||||||
"@rollup/plugin-inject": "^5.0.5",
|
|
||||||
"@types/alpinejs": "^3.13.11",
|
"@types/alpinejs": "^3.13.11",
|
||||||
"@types/cytoscape-cxtmenu": "^3.4.5",
|
"@types/cytoscape-cxtmenu": "^3.4.5",
|
||||||
"@types/cytoscape-klay": "^3.1.5",
|
"@types/cytoscape-klay": "^3.1.5",
|
||||||
@@ -2383,52 +2382,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/plugin-inject": {
|
|
||||||
"version": "5.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-inject/-/plugin-inject-5.0.5.tgz",
|
|
||||||
"integrity": "sha512-2+DEJbNBoPROPkgTDNe8/1YXWcqxbN5DTjASVIOx8HS+pITXushyNiBV56RB08zuptzz8gT3YfkqriTBVycepg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@rollup/pluginutils": "^5.0.1",
|
|
||||||
"estree-walker": "^2.0.2",
|
|
||||||
"magic-string": "^0.30.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"rollup": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rollup/pluginutils": {
|
|
||||||
"version": "5.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
|
|
||||||
"integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/estree": "^1.0.0",
|
|
||||||
"estree-walker": "^2.0.2",
|
|
||||||
"picomatch": "^4.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"rollup": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry-internal/browser-utils": {
|
"node_modules/@sentry-internal/browser-utils": {
|
||||||
"version": "10.51.0",
|
"version": "10.51.0",
|
||||||
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.51.0.tgz",
|
||||||
@@ -3378,13 +3331,6 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/estree-walker": {
|
|
||||||
"version": "2.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
|
||||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/esutils": {
|
"node_modules/esutils": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||||
@@ -4075,16 +4021,6 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/magic-string": {
|
|
||||||
"version": "0.30.21",
|
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
|
||||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/marked": {
|
"node_modules/marked": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz",
|
||||||
|
|||||||
@@ -28,7 +28,6 @@
|
|||||||
"@babel/preset-env": "^7.29.2",
|
"@babel/preset-env": "^7.29.2",
|
||||||
"@biomejs/biome": "^2.4.13",
|
"@biomejs/biome": "^2.4.13",
|
||||||
"@hey-api/openapi-ts": "^0.94.5",
|
"@hey-api/openapi-ts": "^0.94.5",
|
||||||
"@rollup/plugin-inject": "^5.0.5",
|
|
||||||
"@types/alpinejs": "^3.13.11",
|
"@types/alpinejs": "^3.13.11",
|
||||||
"@types/cytoscape-cxtmenu": "^3.4.5",
|
"@types/cytoscape-cxtmenu": "^3.4.5",
|
||||||
"@types/cytoscape-klay": "^3.1.5",
|
"@types/cytoscape-klay": "^3.1.5",
|
||||||
|
|||||||
+2
-14
@@ -126,8 +126,9 @@ class PicturesController(ControllerBase):
|
|||||||
if self_moderate:
|
if self_moderate:
|
||||||
new.moderator = user
|
new.moderator = user
|
||||||
try:
|
try:
|
||||||
|
new.generate_thumbnails()
|
||||||
new.full_clean()
|
new.full_clean()
|
||||||
new.generate_thumbnails(save=True)
|
new.save()
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
return self.create_response({"detail": dict(e)}, status_code=409)
|
return self.create_response({"detail": dict(e)}, status_code=409)
|
||||||
|
|
||||||
@@ -176,19 +177,6 @@ class PicturesController(ControllerBase):
|
|||||||
def delete_picture(self, picture_id: int):
|
def delete_picture(self, picture_id: int):
|
||||||
self.get_object_or_exception(Picture, pk=picture_id).delete()
|
self.get_object_or_exception(Picture, pk=picture_id).delete()
|
||||||
|
|
||||||
@route.post(
|
|
||||||
"/{picture_id}/rotate/{direction}",
|
|
||||||
permissions=[IsSasAdmin],
|
|
||||||
response=PictureSchema,
|
|
||||||
url_name="rotate_picture",
|
|
||||||
)
|
|
||||||
def rotate_picture(self, picture_id: int, direction: Literal["left", "right"]):
|
|
||||||
"""Rotate the given picture and returns its edited data."""
|
|
||||||
angle = 90 if direction == "left" else 270
|
|
||||||
picture = self.get_object_or_exception(Picture, pk=picture_id)
|
|
||||||
picture.rotate(angle)
|
|
||||||
return picture
|
|
||||||
|
|
||||||
@route.patch(
|
@route.patch(
|
||||||
"/{picture_id}/moderation",
|
"/{picture_id}/moderation",
|
||||||
permissions=[IsSasAdmin],
|
permissions=[IsSasAdmin],
|
||||||
|
|||||||
+2
-13
@@ -1,24 +1,13 @@
|
|||||||
from django.conf import settings
|
|
||||||
from model_bakery import seq
|
from model_bakery import seq
|
||||||
from model_bakery.recipe import Recipe, foreign_key
|
from model_bakery.recipe import Recipe
|
||||||
|
|
||||||
from sas.models import Album, Picture
|
from sas.models import Picture
|
||||||
|
|
||||||
album_recipe = Recipe(
|
|
||||||
Album,
|
|
||||||
is_in_sas=True,
|
|
||||||
is_folder=True,
|
|
||||||
is_moderated=True,
|
|
||||||
parent_id=settings.SITH_SAS_ROOT_DIR_ID,
|
|
||||||
name=seq("Album "),
|
|
||||||
)
|
|
||||||
|
|
||||||
picture_recipe = Recipe(
|
picture_recipe = Recipe(
|
||||||
Picture,
|
Picture,
|
||||||
is_in_sas=True,
|
is_in_sas=True,
|
||||||
is_folder=False,
|
is_folder=False,
|
||||||
is_moderated=True,
|
is_moderated=True,
|
||||||
parent=foreign_key(album_recipe),
|
|
||||||
name=seq("Picture "),
|
name=seq("Picture "),
|
||||||
)
|
)
|
||||||
"""A SAS Picture fixture.
|
"""A SAS Picture fixture.
|
||||||
|
|||||||
+68
-57
@@ -15,6 +15,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import ClassVar, Self
|
from typing import ClassVar, Self
|
||||||
|
|
||||||
@@ -28,7 +30,7 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from core.models import Notification, SithFile, User
|
from core.models import Notification, SithFile, User
|
||||||
from core.utils import resize_image
|
from core.utils import exif_auto_rotate, resize_image
|
||||||
|
|
||||||
|
|
||||||
class SasFile(SithFile):
|
class SasFile(SithFile):
|
||||||
@@ -90,75 +92,88 @@ class Picture(SasFile):
|
|||||||
|
|
||||||
objects = SASPictureManager.from_queryset(PictureQuerySet)()
|
objects = SASPictureManager.from_queryset(PictureQuerySet)()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_vertical(self):
|
||||||
|
with open(settings.MEDIA_ROOT / self.file.name, "rb") as f:
|
||||||
|
im = Image.open(BytesIO(f.read()))
|
||||||
|
(w, h) = im.size
|
||||||
|
return (w / h) < 1
|
||||||
|
|
||||||
def get_download_url(self):
|
def get_download_url(self):
|
||||||
return reverse(
|
return reverse("sas:download", kwargs={"picture_id": self.id})
|
||||||
"sas:download",
|
|
||||||
kwargs={"picture_id": self.id},
|
|
||||||
query={"date": int(self.updated_at.timestamp())},
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_download_compressed_url(self):
|
def get_download_compressed_url(self):
|
||||||
return reverse(
|
return reverse("sas:download_compressed", kwargs={"picture_id": self.id})
|
||||||
"sas:download_compressed",
|
|
||||||
kwargs={"picture_id": self.id},
|
|
||||||
query={"date": int(self.updated_at.timestamp())},
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_download_thumb_url(self):
|
def get_download_thumb_url(self):
|
||||||
return reverse(
|
return reverse("sas:download_thumb", kwargs={"picture_id": self.id})
|
||||||
"sas:download_thumb",
|
|
||||||
kwargs={"picture_id": self.id},
|
|
||||||
query={"date": int(self.updated_at.timestamp())},
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("sas:picture", kwargs={"picture_id": self.id})
|
return reverse("sas:picture", kwargs={"picture_id": self.id})
|
||||||
|
|
||||||
def generate_thumbnails(
|
def generate_thumbnails(self):
|
||||||
self, *, img: Image.Image | None = None, save: bool = False
|
im = Image.open(BytesIO(self.file.read()))
|
||||||
):
|
with contextlib.suppress(Exception):
|
||||||
"""Generate the thumbnail and the compressed version of this picture.
|
im = exif_auto_rotate(im)
|
||||||
|
|
||||||
Args:
|
|
||||||
img: if given, this will be used to generate
|
|
||||||
all three images (file, compressed, thumbnail).
|
|
||||||
Else, `self.file` will be used
|
|
||||||
save: if True, save the instance in database.
|
|
||||||
"""
|
|
||||||
img = img or Image.open(self.file)
|
|
||||||
extension = self.mime_type.split("/")[-1]
|
|
||||||
previous_files = [
|
|
||||||
f.name for f in (self.file, self.thumbnail, self.compressed) if f
|
|
||||||
]
|
|
||||||
# convert the compressed image and the thumbnail into webp
|
# convert the compressed image and the thumbnail into webp
|
||||||
# The original image keeps its original type, because it's not
|
# The original image keeps its original type, because it's not
|
||||||
# meant to be shown on the website, but rather to keep the real image
|
# meant to be shown on the website, but rather to keep the real image
|
||||||
# for less frequent cases (like downloading the pictures of a user)
|
# for less frequent cases (like downloading the pictures of an user)
|
||||||
|
extension = self.mime_type.split("/")[-1]
|
||||||
# the HD version of the image doesn't need to be optimized, because :
|
# the HD version of the image doesn't need to be optimized, because :
|
||||||
# - it isn't frequently queried
|
# - it isn't frequently queried
|
||||||
# - optimizing large images takes a lot of time, which greatly hinders the UX
|
# - optimizing large images takes a lot time, which greatly hinders the UX
|
||||||
# - photographers usually already optimize their images
|
# - photographers usually already optimize their images
|
||||||
|
file = resize_image(im, max(im.size), extension, optimize=False)
|
||||||
|
thumb = resize_image(im, 200, "webp")
|
||||||
|
compressed = resize_image(im, 1200, "webp")
|
||||||
new_extension_name = str(Path(self.name).with_suffix(".webp"))
|
new_extension_name = str(Path(self.name).with_suffix(".webp"))
|
||||||
file = resize_image(img, max(img.size), extension, optimize=False)
|
self.file = file
|
||||||
self.file.save(self.name, file, save=False)
|
self.file.name = self.name
|
||||||
thumbnail = resize_image(img, 200, "webp")
|
self.thumbnail = thumb
|
||||||
self.thumbnail.save(new_extension_name, thumbnail, save=False)
|
self.thumbnail.name = new_extension_name
|
||||||
compressed = resize_image(img, 1200, "webp")
|
self.compressed = compressed
|
||||||
self.compressed.save(new_extension_name, compressed, save=save)
|
self.compressed.name = new_extension_name
|
||||||
# once the new images have been saved, delete the previous ones.
|
|
||||||
# The deletion of old files is done after, so that if anything goes
|
|
||||||
# during the whole process, no data will be lost.
|
|
||||||
for filename in previous_files:
|
|
||||||
self.file.storage.delete(filename)
|
|
||||||
|
|
||||||
def rotate(self, degree: int | float):
|
def rotate(self, degree):
|
||||||
"""Rotate this picture and update its thumbnails accordingly.
|
for attr in ["file", "compressed", "thumbnail"]:
|
||||||
|
name = self.__getattribute__(attr).name
|
||||||
|
with open(settings.MEDIA_ROOT / name, "r+b") as file:
|
||||||
|
if file:
|
||||||
|
im = Image.open(BytesIO(file.read()))
|
||||||
|
file.seek(0)
|
||||||
|
im = im.rotate(degree, expand=True)
|
||||||
|
im.save(
|
||||||
|
fp=file,
|
||||||
|
format=self.mime_type.split("/")[-1].upper(),
|
||||||
|
quality=90,
|
||||||
|
optimize=True,
|
||||||
|
progressive=True,
|
||||||
|
)
|
||||||
|
|
||||||
Args:
|
def get_next(self):
|
||||||
degree: the rotation angle, in degree, counter-clockwise
|
if self.is_moderated:
|
||||||
"""
|
pictures_qs = self.parent.children.filter(
|
||||||
img = Image.open(self.file).rotate(degree)
|
is_moderated=True,
|
||||||
self.generate_thumbnails(img=img, save=True)
|
asked_for_removal=False,
|
||||||
|
is_folder=False,
|
||||||
|
id__gt=self.id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
pictures_qs = Picture.objects.filter(id__gt=self.id, is_moderated=False)
|
||||||
|
return pictures_qs.order_by("id").first()
|
||||||
|
|
||||||
|
def get_previous(self):
|
||||||
|
if self.is_moderated:
|
||||||
|
pictures_qs = self.parent.children.filter(
|
||||||
|
is_moderated=True,
|
||||||
|
asked_for_removal=False,
|
||||||
|
is_folder=False,
|
||||||
|
id__lt=self.id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
pictures_qs = Picture.objects.filter(id__lt=self.id, is_moderated=False)
|
||||||
|
return pictures_qs.order_by("-id").first()
|
||||||
|
|
||||||
|
|
||||||
class AlbumQuerySet(models.QuerySet):
|
class AlbumQuerySet(models.QuerySet):
|
||||||
@@ -224,11 +239,7 @@ class Album(SasFile):
|
|||||||
return reverse("sas:album", kwargs={"album_id": self.id})
|
return reverse("sas:album", kwargs={"album_id": self.id})
|
||||||
|
|
||||||
def get_download_url(self):
|
def get_download_url(self):
|
||||||
return reverse(
|
return reverse("sas:album_preview", kwargs={"album_id": self.id})
|
||||||
"sas:album_preview",
|
|
||||||
kwargs={"album_id": self.id},
|
|
||||||
query={"date": int(self.updated_at.timestamp())},
|
|
||||||
)
|
|
||||||
|
|
||||||
def generate_thumbnail(self):
|
def generate_thumbnail(self):
|
||||||
p = self.children_pictures.order_by("?").first()
|
p = self.children_pictures.order_by("?").first()
|
||||||
|
|||||||
+1
-9
@@ -70,15 +70,7 @@ class PictureFilterSchema(FilterSchema):
|
|||||||
class PictureSchema(ModelSchema):
|
class PictureSchema(ModelSchema):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Picture
|
model = Picture
|
||||||
fields = [
|
fields = ["id", "name", "date", "size", "is_moderated", "asked_for_removal"]
|
||||||
"id",
|
|
||||||
"name",
|
|
||||||
"date",
|
|
||||||
"updated_at",
|
|
||||||
"size",
|
|
||||||
"is_moderated",
|
|
||||||
"asked_for_removal",
|
|
||||||
]
|
|
||||||
|
|
||||||
owner: UserProfileSchema
|
owner: UserProfileSchema
|
||||||
sas_url: str
|
sas_url: str
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { paginated } from "#core:utils/api";
|
import { paginated } from "#core:utils/api.ts";
|
||||||
import {
|
import {
|
||||||
type PictureSchema,
|
type PictureSchema,
|
||||||
type PicturesFetchPicturesData,
|
type PicturesFetchPicturesData,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type TomSelect from "tom-select";
|
import type TomSelect from "tom-select";
|
||||||
import type { UserAjaxSelect } from "#core:core/components/ajax-select-index";
|
import type { UserAjaxSelect } from "#core:core/components/ajax-select-index.ts";
|
||||||
import { paginated } from "#core:utils/api";
|
import { paginated } from "#core:utils/api.ts";
|
||||||
import { History } from "#core:utils/history";
|
import { History } from "#core:utils/history.ts";
|
||||||
import {
|
import {
|
||||||
type IdentifiedUserSchema,
|
type IdentifiedUserSchema,
|
||||||
type ModerationRequestSchema,
|
type ModerationRequestSchema,
|
||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
picturesFetchPictures,
|
picturesFetchPictures,
|
||||||
picturesIdentifyUsers,
|
picturesIdentifyUsers,
|
||||||
picturesModeratePicture,
|
picturesModeratePicture,
|
||||||
picturesRotatePicture,
|
|
||||||
type UserProfileSchema,
|
type UserProfileSchema,
|
||||||
usersidentifiedDeleteRelation,
|
usersidentifiedDeleteRelation,
|
||||||
} from "#openapi";
|
} from "#openapi";
|
||||||
@@ -29,32 +28,18 @@ class PictureWithIdentifications {
|
|||||||
identificationsLoading = false;
|
identificationsLoading = false;
|
||||||
moderationLoading = false;
|
moderationLoading = false;
|
||||||
id: number;
|
id: number;
|
||||||
compressedUrl: string = "";
|
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||||
thumbUrl: string = "";
|
compressed_url: string;
|
||||||
fullSizeUrl: string = "";
|
|
||||||
moderationRequests: ModerationRequestSchema[] = null;
|
moderationRequests: ModerationRequestSchema[] = null;
|
||||||
|
|
||||||
constructor(picture: PictureSchema) {
|
constructor(picture: PictureSchema) {
|
||||||
Object.assign(this, picture);
|
Object.assign(this, picture);
|
||||||
this.compressedUrl = picture.compressed_url;
|
|
||||||
this.thumbUrl = picture.thumb_url;
|
|
||||||
this.fullSizeUrl = picture.full_size_url;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static fromPicture(picture: PictureSchema): PictureWithIdentifications {
|
static fromPicture(picture: PictureSchema): PictureWithIdentifications {
|
||||||
return new PictureWithIdentifications(picture);
|
return new PictureWithIdentifications(picture);
|
||||||
}
|
}
|
||||||
|
|
||||||
rebuildUrls(date: Date) {
|
|
||||||
const buildUrl = (url: string) => {
|
|
||||||
const base = url.split("?", 1)[0];
|
|
||||||
return `${base}?date=${date.getTime().toString()}`;
|
|
||||||
};
|
|
||||||
this.compressedUrl = buildUrl(this.compressedUrl);
|
|
||||||
this.thumbUrl = buildUrl(this.thumbUrl);
|
|
||||||
this.fullSizeUrl = buildUrl(this.fullSizeUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If not already done, fetch the users identified on this picture and
|
* If not already done, fetch the users identified on this picture and
|
||||||
* populate the identifications field
|
* populate the identifications field
|
||||||
@@ -97,25 +82,12 @@ class PictureWithIdentifications {
|
|||||||
this.moderationLoading = false;
|
this.moderationLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async rotate(direction: "left" | "right") {
|
|
||||||
this.imageLoading = true;
|
|
||||||
const res = await picturesRotatePicture({
|
|
||||||
// biome-ignore lint/style/useNamingConvention: api is snake case
|
|
||||||
path: { picture_id: this.id, direction: direction },
|
|
||||||
});
|
|
||||||
// urls returned by the api include a timestamp for cache busting
|
|
||||||
this.fullSizeUrl = res.data.full_size_url;
|
|
||||||
this.compressedUrl = res.data.compressed_url;
|
|
||||||
this.thumbUrl = res.data.thumb_url;
|
|
||||||
this.imageLoading = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Preload the photo and the identifications
|
* Preload the photo and the identifications
|
||||||
*/
|
*/
|
||||||
async preload(): Promise<void> {
|
async preload(): Promise<void> {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.src = this.compressedUrl;
|
img.src = this.compressed_url;
|
||||||
if (!img.complete) {
|
if (!img.complete) {
|
||||||
this.imageLoading = true;
|
this.imageLoading = true;
|
||||||
img.addEventListener("load", () => {
|
img.addEventListener("load", () => {
|
||||||
@@ -168,8 +140,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||||
full_size_url: "",
|
full_size_url: "",
|
||||||
owner: "",
|
owner: "",
|
||||||
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
date: new Date(),
|
||||||
created_at: new Date(),
|
|
||||||
identifications: [] as IdentifiedUserSchema[],
|
identifications: [] as IdentifiedUserSchema[],
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
@@ -320,8 +291,10 @@ document.addEventListener("alpine:init", () => {
|
|||||||
async submitIdentification(): Promise<void> {
|
async submitIdentification(): Promise<void> {
|
||||||
const widget: TomSelect = this.selector.widget;
|
const widget: TomSelect = this.selector.widget;
|
||||||
await picturesIdentifyUsers({
|
await picturesIdentifyUsers({
|
||||||
|
path: {
|
||||||
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||||
path: { picture_id: this.currentPicture.id },
|
picture_id: this.currentPicture.id,
|
||||||
|
},
|
||||||
body: widget.items.map((i: string) => Number.parseInt(i, 10)),
|
body: widget.items.map((i: string) => Number.parseInt(i, 10)),
|
||||||
});
|
});
|
||||||
// refresh the identified users list
|
// refresh the identified users list
|
||||||
|
|||||||
@@ -235,7 +235,9 @@
|
|||||||
|
|
||||||
>.tools {
|
>.tools {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
.btn {
|
|
||||||
|
>div>div {
|
||||||
|
>a.btn {
|
||||||
background-color: $primary-neutral-light-color;
|
background-color: $primary-neutral-light-color;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -251,7 +253,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
a.text.danger {
|
>a.text.danger {
|
||||||
color: red;
|
color: red;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -259,10 +261,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.buttons {
|
&.buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
{% extends "core/base.jinja" %}
|
{% extends "core/base.jinja" %}
|
||||||
|
|
||||||
{%- block additional_css -%}
|
{%- block additional_css -%}
|
||||||
<link rel="stylesheet" href="{{ static('bundled/core/components/ajax-select-index.css') }}">
|
<link defer rel="stylesheet" href="{{ static('bundled/core/components/ajax-select-index.css') }}">
|
||||||
<link rel="stylesheet" href="{{ static('core/components/ajax-select.scss') }}">
|
<link defer rel="stylesheet" href="{{ static('core/components/ajax-select.scss') }}">
|
||||||
<link rel="stylesheet" href="{{ static('sas/css/picture.scss') }}">
|
<link defer rel="stylesheet" href="{{ static('sas/css/picture.scss') }}">
|
||||||
{%- endblock -%}
|
{%- endblock -%}
|
||||||
|
|
||||||
{%- block additional_js -%}
|
{%- block additional_js -%}
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
<div class="main">
|
<div class="main">
|
||||||
<div class="photo" :aria-busy="currentPicture.imageLoading">
|
<div class="photo" :aria-busy="currentPicture.imageLoading">
|
||||||
<img
|
<img
|
||||||
:src="currentPicture.compressedUrl"
|
:src="currentPicture.compressed_url"
|
||||||
:alt="currentPicture.name"
|
:alt="currentPicture.name"
|
||||||
id="main-picture"
|
id="main-picture"
|
||||||
x-ref="mainPicture"
|
x-ref="mainPicture"
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
<span
|
<span
|
||||||
x-text="Intl.DateTimeFormat(
|
x-text="Intl.DateTimeFormat(
|
||||||
'{{ LANGUAGE_CODE }}', {dateStyle: 'long'}
|
'{{ LANGUAGE_CODE }}', {dateStyle: 'long'}
|
||||||
).format(Date.parse(currentPicture.date))"
|
).format(new Date(currentPicture.date))"
|
||||||
>
|
>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,38 +115,23 @@
|
|||||||
<h5>{% trans %}Tools{% endtrans %}</h5>
|
<h5>{% trans %}Tools{% endtrans %}</h5>
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<div>
|
||||||
<a class="text" :href="currentPicture.fullSizeUrl">
|
<a class="text" :href="currentPicture.full_size_url">
|
||||||
{% trans %}HD version{% endtrans %}
|
{% trans %}HD version{% endtrans %}
|
||||||
</a>
|
</a>
|
||||||
<a class="text danger " :href="currentPicture.report_url">
|
<a class="text danger " :href="currentPicture.report_url">
|
||||||
{% trans %}Ask for removal{% endtrans %}
|
{% trans %}Ask for removal{% endtrans %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="buttons">
|
||||||
class="buttons"
|
|
||||||
x-show="{{ user.has_perm("sas.change_sasfile")|tojson }} || currentPicture.owner.id === {{ user.id }}"
|
|
||||||
>
|
|
||||||
<a
|
<a
|
||||||
class="btn btn-no-text"
|
class="btn btn-no-text"
|
||||||
:href="currentPicture.edit_url"
|
:href="currentPicture.edit_url"
|
||||||
:disabled="currentPicture.imageLoading"
|
x-show="{{ user.has_perm("sas.change_sasfile")|tojson }} || currentPicture.owner.id === {{ user.id }}"
|
||||||
>
|
>
|
||||||
<i class="fa-regular fa-pen-to-square edit-action"></i>
|
<i class="fa-regular fa-pen-to-square edit-action"></i>
|
||||||
</a>
|
</a>
|
||||||
<button
|
<a class="btn btn-no-text" href="?rotate_left"><i class="fa-solid fa-rotate-left"></i></a>
|
||||||
class="btn btn-no-text"
|
<a class="btn btn-no-text" href="?rotate_right"><i class="fa-solid fa-rotate-right"></i></a>
|
||||||
@click="currentPicture.rotate('left')"
|
|
||||||
:disabled="currentPicture.imageLoading"
|
|
||||||
>
|
|
||||||
<i class="fa-solid fa-rotate-left"></i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn btn-no-text"
|
|
||||||
@click="currentPicture.rotate('right')"
|
|
||||||
:disabled="currentPicture.imageLoading"
|
|
||||||
>
|
|
||||||
<i class="fa-solid fa-rotate-right"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -161,7 +146,7 @@
|
|||||||
@keyup.left.window="currentPicture = previousPicture"
|
@keyup.left.window="currentPicture = previousPicture"
|
||||||
@click="currentPicture = previousPicture"
|
@click="currentPicture = previousPicture"
|
||||||
>
|
>
|
||||||
<img :src="previousPicture.thumbUrl" alt="{% trans %}Previous picture{% endtrans %}"/>
|
<img :src="previousPicture.thumb_url" alt="{% trans %}Previous picture{% endtrans %}"/>
|
||||||
<div class="overlay">←</div>
|
<div class="overlay">←</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -172,7 +157,7 @@
|
|||||||
@keyup.right.window="currentPicture = nextPicture"
|
@keyup.right.window="currentPicture = nextPicture"
|
||||||
@click="currentPicture = nextPicture"
|
@click="currentPicture = nextPicture"
|
||||||
>
|
>
|
||||||
<img :src="nextPicture.thumbUrl" alt="{% trans %}Previous picture{% endtrans %}"/>
|
<img :src="nextPicture.thumb_url" alt="{% trans %}Previous picture{% endtrans %}"/>
|
||||||
<div class="overlay">→</div>
|
<div class="overlay">→</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
from io import BytesIO
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.core.files.base import ContentFile
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
from core.baker_recipes import old_subscriber_user, subscriber_user
|
from core.baker_recipes import old_subscriber_user, subscriber_user
|
||||||
from core.models import User
|
from core.models import User
|
||||||
@@ -72,36 +67,3 @@ def test_identifications_viewable_by_user():
|
|||||||
assert list(picture.people.viewable_by(identifications[1].user)) == [
|
assert list(picture.people.viewable_by(identifications[1].user)) == [
|
||||||
identifications[1]
|
identifications[1]
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
@pytest.mark.parametrize("save", [True, False])
|
|
||||||
@pytest.mark.parametrize("initially_saved", [True, False])
|
|
||||||
@pytest.mark.parametrize("pass_img_kwarg", [True, False])
|
|
||||||
def test_generate_thumbnail(save, initially_saved, pass_img_kwarg):
|
|
||||||
"""Test that Picture.generate_thumbnails works properly"""
|
|
||||||
image = Image.new("RGB", (2, 1))
|
|
||||||
image.putdata([(255, 0, 0), (0, 255, 0)])
|
|
||||||
buffer = BytesIO()
|
|
||||||
image.save(buffer, format="PNG")
|
|
||||||
file = ContentFile(buffer.getvalue(), "img.png")
|
|
||||||
picture: Picture = picture_recipe.prepare(
|
|
||||||
file=file,
|
|
||||||
name=file.name,
|
|
||||||
mime_type="image/png",
|
|
||||||
_save_related=True,
|
|
||||||
)
|
|
||||||
if initially_saved:
|
|
||||||
picture.save()
|
|
||||||
picture.generate_thumbnails(img=image if pass_img_kwarg else None, save=save)
|
|
||||||
storage = picture.file.storage
|
|
||||||
for f in picture.file, picture.compressed, picture.thumbnail:
|
|
||||||
# the tested picture is alone in its album,
|
|
||||||
# so there should be a single file in each folder
|
|
||||||
assert storage.exists(f.name)
|
|
||||||
_dirs, files = storage.listdir(str(Path(f.path).parent))
|
|
||||||
assert files == [Path(f.name).name]
|
|
||||||
new_img = Image.open(picture.file)
|
|
||||||
assert new_img.get_flattened_data() == image.get_flattened_data()
|
|
||||||
assert Image.open(picture.thumbnail).size == (200, 100)
|
|
||||||
assert Image.open(picture.compressed).size == (1200, 600)
|
|
||||||
|
|||||||
+1
-70
@@ -12,23 +12,19 @@
|
|||||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
from typing import Callable, Literal
|
from typing import Callable
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.core.files.base import ContentFile
|
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from PIL import Image
|
|
||||||
from pytest_django.asserts import assertHTMLEqual, assertInHTML, assertRedirects
|
from pytest_django.asserts import assertHTMLEqual, assertInHTML, assertRedirects
|
||||||
|
|
||||||
from core.baker_recipes import old_subscriber_user, subscriber_user
|
from core.baker_recipes import old_subscriber_user, subscriber_user
|
||||||
from core.models import Group, User
|
from core.models import Group, User
|
||||||
from core.utils import RED_PIXEL_PNG
|
|
||||||
from sas.baker_recipes import picture_recipe
|
from sas.baker_recipes import picture_recipe
|
||||||
from sas.models import Album, Picture
|
from sas.models import Album, Picture
|
||||||
|
|
||||||
@@ -166,71 +162,6 @@ class TestAlbumUpload:
|
|||||||
assert not album.children.exists()
|
assert not album.children.exists()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
class TestPictureRotation:
|
|
||||||
@pytest.fixture
|
|
||||||
def picture(self) -> Picture:
|
|
||||||
return picture_recipe.make(
|
|
||||||
parent_id=settings.SITH_SAS_ROOT_DIR_ID,
|
|
||||||
file=ContentFile(name="foo.png", content=RED_PIXEL_PNG),
|
|
||||||
compressed=ContentFile(name="foo.png", content=RED_PIXEL_PNG),
|
|
||||||
thumbnail=ContentFile(name="foo.png", content=RED_PIXEL_PNG),
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"user",
|
|
||||||
[
|
|
||||||
None,
|
|
||||||
lambda: baker.make(User),
|
|
||||||
subscriber_user.make,
|
|
||||||
old_subscriber_user.make,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_permission_denied(
|
|
||||||
self, client: Client, picture: Picture, user: Callable[[], User] | None
|
|
||||||
):
|
|
||||||
if user:
|
|
||||||
client.force_login(user())
|
|
||||||
|
|
||||||
url = reverse(
|
|
||||||
"api:rotate_picture", kwargs={"picture_id": picture.id, "direction": "left"}
|
|
||||||
)
|
|
||||||
response = client.post(url)
|
|
||||||
assert response.status_code == 403 if user else 401
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"user",
|
|
||||||
[
|
|
||||||
lambda: baker.make(User, is_superuser=True),
|
|
||||||
lambda: baker.make(
|
|
||||||
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@pytest.mark.parametrize(("direction", "angle"), [("left", 90), ("right", 270)])
|
|
||||||
def test_rotation(
|
|
||||||
self,
|
|
||||||
client: Client,
|
|
||||||
picture: Picture,
|
|
||||||
user: Callable[[], User],
|
|
||||||
direction: Literal["left", "right"],
|
|
||||||
angle: Literal[90, 270],
|
|
||||||
):
|
|
||||||
client.force_login(user())
|
|
||||||
url = reverse(
|
|
||||||
"api:rotate_picture",
|
|
||||||
kwargs={"picture_id": picture.id, "direction": direction},
|
|
||||||
)
|
|
||||||
with (
|
|
||||||
patch.object(Image.Image, "rotate") as mocked_rotate,
|
|
||||||
patch.object(Picture, "generate_thumbnails") as mocked_thumb,
|
|
||||||
):
|
|
||||||
response = client.post(url)
|
|
||||||
assert response.status_code == 200
|
|
||||||
mocked_rotate.assert_called_once_with(angle)
|
|
||||||
mocked_thumb.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
class TestSasModeration(TestCase):
|
class TestSasModeration(TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
|
|||||||
@@ -97,6 +97,14 @@ class PictureView(CanViewMixin, DetailView):
|
|||||||
pk_url_kwarg = "picture_id"
|
pk_url_kwarg = "picture_id"
|
||||||
template_name = "sas/picture.jinja"
|
template_name = "sas/picture.jinja"
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
self.object = self.get_object()
|
||||||
|
if "rotate_right" in request.GET:
|
||||||
|
self.object.rotate(270)
|
||||||
|
if "rotate_left" in request.GET:
|
||||||
|
self.object.rotate(90)
|
||||||
|
return super().get(request, *args, **kwargs)
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
return super().get_context_data(**kwargs) | {
|
return super().get_context_data(**kwargs) | {
|
||||||
"album": Album.objects.get(children=self.object)
|
"album": Album.objects.get(children=self.object)
|
||||||
|
|||||||
+2
-9
@@ -1,5 +1,4 @@
|
|||||||
import { parse, resolve } from "node:path";
|
import { parse, resolve } from "node:path";
|
||||||
import inject from "@rollup/plugin-inject";
|
|
||||||
import { glob } from "glob";
|
import { glob } from "glob";
|
||||||
import { visualizer } from "rollup-plugin-visualizer";
|
import { visualizer } from "rollup-plugin-visualizer";
|
||||||
import {
|
import {
|
||||||
@@ -81,14 +80,8 @@ export default defineConfig((config: UserConfig) => {
|
|||||||
resolve: {
|
resolve: {
|
||||||
alias: getAliases(),
|
alias: getAliases(),
|
||||||
},
|
},
|
||||||
|
|
||||||
plugins: [
|
|
||||||
inject({
|
|
||||||
// biome-ignore lint/style/useNamingConvention: that's how it's called
|
// biome-ignore lint/style/useNamingConvention: that's how it's called
|
||||||
Alpine: "alpinejs",
|
inject: { Alpine: "alpinejs", htmx: "htmx.org" },
|
||||||
htmx: "htmx.org",
|
plugins: [visualizer({ filename: ".bundle-size-report.html" }) as PluginOption],
|
||||||
}),
|
|
||||||
visualizer({ filename: ".bundle-size-report.html" }) as PluginOption,
|
|
||||||
],
|
|
||||||
} satisfies UserConfig;
|
} satisfies UserConfig;
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user