1 Commits

Author SHA1 Message Date
imperosol 5c17337595 update CI 2026-04-30 19:02:19 +02:00
29 changed files with 393 additions and 701 deletions
+16 -22
View File
@@ -12,7 +12,7 @@ runs:
steps: steps:
- name: Install apt packages - name: Install apt packages
if: ${{ inputs.full == 'true' }} if: ${{ inputs.full == 'true' }}
uses: awalsh128/cache-apt-pkgs-action@v1.4.3 uses: awalsh128/cache-apt-pkgs-action@v1.6.0
with: with:
packages: gettext packages: gettext
version: 1.0 # increment to reset cache version: 1.0 # increment to reset cache
@@ -23,26 +23,29 @@ runs:
with: with:
redis-version: "7.x" redis-version: "7.x"
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
version: "0.5.14"
enable-cache: true
cache-dependency-glob: "uv.lock"
- name: "Set up Python" - name: "Set up Python"
uses: actions/setup-python@v5 uses: actions/setup-python@v6
with: with:
python-version-file: ".python-version" python-version-file: ".python-version"
- name: Restore cached virtualenv - name: Install uv
uses: actions/cache/restore@v4 uses: astral-sh/setup-uv@v8.1.0
with:
version: "0.11.8"
enable-cache: false
cache-dependency-glob: "uv.lock"
- name: Restore cached virtualenv
uses: actions/cache@v5
with: with:
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
path: .venv path: .venv
key: uv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
restore-keys: |
uv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
uv-${{ runner.os }}
- name: Install dependencies - name: Install dependencies
run: uv sync run: uv sync --locked
shell: bash shell: bash
- name: Install Xapian - name: Install Xapian
@@ -50,15 +53,6 @@ runs:
run: uv run ./manage.py install_xapian run: uv run ./manage.py install_xapian
shell: bash shell: bash
# compiling xapian accounts for almost the entirety of the virtualenv setup,
# so we save the virtual environment only on workflows where it has been installed
- name: Save cached virtualenv
if: ${{ inputs.full == 'true' }}
uses: actions/cache/save@v4
with:
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
path: .venv
- name: Compile gettext messages - name: Compile gettext messages
if: ${{ inputs.full == 'true' }} if: ${{ inputs.full == 'true' }}
run: uv run ./manage.py compilemessages run: uv run ./manage.py compilemessages
+4 -4
View File
@@ -18,8 +18,8 @@ jobs:
name: Launch pre-commits checks (ruff) name: Launch pre-commits checks (ruff)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- uses: actions/setup-python@v5 - uses: actions/setup-python@v6
with: with:
python-version-file: ".python-version" python-version-file: ".python-version"
- uses: pre-commit/action@v3.0.1 - uses: pre-commit/action@v3.0.1
@@ -35,7 +35,7 @@ jobs:
pytest-mark: [not slow] pytest-mark: [not slow]
steps: steps:
- name: Check out repository - name: Check out repository
uses: actions/checkout@v4 uses: actions/checkout@v6
- uses: ./.github/actions/setup_project - uses: ./.github/actions/setup_project
with: with:
full: true full: true
@@ -49,7 +49,7 @@ jobs:
uv run coverage report uv run coverage report
uv run coverage html uv run coverage html
- name: Archive code coverage results - name: Archive code coverage results
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: coverage-report-${{ matrix.pytest-mark }} name: coverage-report-${{ matrix.pytest-mark }}
path: coverage_report path: coverage_report
+1 -3
View File
@@ -14,7 +14,7 @@ jobs:
steps: steps:
- name: SSH Remote Commands - name: SSH Remote Commands
uses: appleboy/ssh-action@v1.1.0 uses: appleboy/ssh-action@v1.2.5
with: with:
# Proxy # Proxy
proxy_host : ${{secrets.PROXY_HOST}} proxy_host : ${{secrets.PROXY_HOST}}
@@ -29,8 +29,6 @@ jobs:
username : ${{secrets.USER}} username : ${{secrets.USER}}
key: ${{secrets.KEY}} key: ${{secrets.KEY}}
script_stop: true
# See https://github.com/ae-utbm/sith/wiki/GitHub-Actions#deployment-action # See https://github.com/ae-utbm/sith/wiki/GitHub-Actions#deployment-action
script: | script: |
cd ${{secrets.SITH_PATH}} cd ${{secrets.SITH_PATH}}
+2 -2
View File
@@ -9,10 +9,10 @@ jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- uses: ./.github/actions/setup_project - uses: ./.github/actions/setup_project
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
- uses: actions/cache@v3 - uses: actions/cache@v5
with: with:
key: mkdocs-material-${{ env.cache_id }} key: mkdocs-material-${{ env.cache_id }}
path: .cache path: .cache
+1 -3
View File
@@ -13,7 +13,7 @@ jobs:
steps: steps:
- name: SSH Remote Commands - name: SSH Remote Commands
uses: appleboy/ssh-action@v1.1.0 uses: appleboy/ssh-action@v1.2.5
with: with:
# Proxy # Proxy
proxy_host : ${{secrets.PROXY_HOST}} proxy_host : ${{secrets.PROXY_HOST}}
@@ -28,8 +28,6 @@ jobs:
username : ${{secrets.USER}} username : ${{secrets.USER}}
key: ${{secrets.KEY}} key: ${{secrets.KEY}}
script_stop: true
# See https://github.com/ae-utbm/sith/wiki/GitHub-Actions#deployment-action # See https://github.com/ae-utbm/sith/wiki/GitHub-Actions#deployment-action
script: | script: |
cd ${{secrets.SITH_PATH}} cd ${{secrets.SITH_PATH}}
+1 -2
View File
@@ -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])
+2 -1
View File
@@ -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
View File
@@ -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(
+3 -3
View File
@@ -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">
+1 -2
View File
@@ -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) }}"
+1 -2
View File
@@ -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,
+17
View File
@@ -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
+78 -75
View File
@@ -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
View File
@@ -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),
+2 -14
View File
@@ -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
View File
@@ -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.
+5 -39
View File
@@ -1,25 +1,18 @@
import copy
import datetime import datetime
from pathlib import Path from typing import Any
from typing import TYPE_CHECKING, Any
from django import forms from django import forms
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils.timezone import get_current_timezone from django.utils.timezone import get_current_timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from PIL import Image
from core.models import User from core.models import User
from core.utils import resize_image
from core.views import MultipleImageField from core.views import MultipleImageField
from core.views.forms import SelectDate from core.views.forms import SelectDate
from core.views.widgets.ajax_select import AutoCompleteSelectMultipleGroup from core.views.widgets.ajax_select import AutoCompleteSelectMultipleGroup
from sas.models import Album, Picture, PictureModerationRequest from sas.models import Album, Picture, PictureModerationRequest
from sas.widgets.ajax_select import AutoCompleteSelectAlbum from sas.widgets.ajax_select import AutoCompleteSelectAlbum
if TYPE_CHECKING:
from django.db.models.fields.files import FieldFile
class AlbumCreateForm(forms.ModelForm): class AlbumCreateForm(forms.ModelForm):
class Meta: class Meta:
@@ -58,9 +51,12 @@ class AlbumEditForm(forms.ModelForm):
class Meta: class Meta:
model = Album model = Album
fields = ["name", "date", "file", "parent", "edit_groups"] fields = ["name", "date", "file", "parent", "edit_groups"]
widgets = {"edit_groups": AutoCompleteSelectMultipleGroup, "date": SelectDate} widgets = {
"edit_groups": AutoCompleteSelectMultipleGroup,
}
name = forms.CharField(max_length=Album.NAME_MAX_LENGTH, label=_("file name")) name = forms.CharField(max_length=Album.NAME_MAX_LENGTH, label=_("file name"))
date = forms.DateField(label=_("Date"), widget=SelectDate, required=True)
recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False) recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False)
parent = forms.ModelChoiceField( parent = forms.ModelChoiceField(
Album.objects.all(), required=True, widget=AutoCompleteSelectAlbum Album.objects.all(), required=True, widget=AutoCompleteSelectAlbum
@@ -75,36 +71,6 @@ class AlbumEditForm(forms.ModelForm):
tzinfo=get_current_timezone(), tzinfo=get_current_timezone(),
) )
def clean_file(self):
# if a file was given in the form, resize it
f: FieldFile = self.cleaned_data["file"]
if self.errors or not f or "file" not in self.changed_data:
return f
f.file = resize_image(Image.open(f.file), 200, "WEBP")
return f
def save(self, commit=True): # noqa: FBT002
initial_file = copy.copy(self.initial["file"])
if not self.cleaned_data["file"]:
# if no file is in the form, it can mean either :
# - there was a file initially, but the deletion box was checked
# - there was no file initially, and there still isn't
# in both cases, we procedurally generate the thumbnail
self.instance.generate_thumbnail()
elif "file" in self.changed_data:
# the file was either added or modified
self.instance.file.name = str(Path(self.instance.name) / "thumb.webp")
res = super().save(commit=commit)
if initial_file and (
not self.instance.file or initial_file.path != self.instance.file.path
):
# The initial file must be removed from storage
# AFTER the new one has been dealt with,
# in order to be sure that django will generate a different filename.
# Otherwise, the client cache wouldn't be properly busted.
initial_file.delete(save=False)
return res
class PictureModerationRequestForm(forms.ModelForm): class PictureModerationRequestForm(forms.ModelForm):
"""Form to create a PictureModerationRequest. """Form to create a PictureModerationRequest.
+81 -62
View File
@@ -15,12 +15,13 @@
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
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.db import models from django.db import models
from django.db.models import Exists, OuterRef, Q from django.db.models import Exists, OuterRef, Q
from django.urls import reverse from django.urls import reverse
@@ -28,7 +29,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 +91,92 @@ 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, *, overwrite=False):
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")
if overwrite:
self.file.delete()
self.thumbnail.delete()
self.compressed.delete()
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,19 +242,20 @@ 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 = (
if p and p.thumbnail: self.children_pictures.order_by("?").first()
image = ContentFile( or self.children_albums.exclude(file=None)
name=str(Path(self.name) / "thumb.webp"), content=p.thumbnail.read() .exclude(file="")
.order_by("?")
.first()
) )
if p and p.file:
image = resize_image(Image.open(BytesIO(p.file.read())), 200, "webp")
self.file = image self.file = image
self.file.name = f"{self.name}/thumb.webp"
self.save() self.save()
+1 -9
View File
@@ -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,
+10 -37
View File
@@ -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
+6 -3
View File
@@ -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;
} }
} }
}
} }
+4 -4
View File
@@ -2,19 +2,19 @@
<a href="{{ url('sas:album', album_id=a.id) }}"> <a href="{{ url('sas:album', album_id=a.id) }}">
{% if a.file %} {% if a.file %}
{% set img = a.get_download_url() %} {% set img = a.get_download_url() %}
{% set alt = a.name %} {% set src = a.name %}
{% elif a.children.filter(is_folder=False, is_moderated=True).exists() %} {% elif a.children.filter(is_folder=False, is_moderated=True).exists() %}
{% set picture = a.children.filter(is_folder=False).first().as_picture %} {% set picture = a.children.filter(is_folder=False).first().as_picture %}
{% set img = picture.get_download_thumb_url() %} {% set img = picture.get_download_thumb_url() %}
{% set alt = picture.name %} {% set src = picture.name %}
{% else %} {% else %}
{% set img = static('core/img/sas.jpg') %} {% set img = static('core/img/sas.jpg') %}
{% set alt = "sas.jpg" %} {% set src = "sas.jpg" %}
{% endif %} {% endif %}
<div <div
class="album{% if not a.is_moderated %} not_moderated{% endif %}" class="album{% if not a.is_moderated %} not_moderated{% endif %}"
> >
<img src="{{ img }}" alt="{{ alt }}" loading="lazy" /> <img src="{{ img }}" alt="{{ src }}" loading="lazy" />
{% if not a.is_moderated %} {% if not a.is_moderated %}
<div class="overlay">&nbsp;</div> <div class="overlay">&nbsp;</div>
<div class="text">{% trans %}To be moderated{% endtrans %}</div> <div class="text">{% trans %}To be moderated{% endtrans %}</div>
+12 -27
View File
@@ -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>
-38
View File
@@ -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)
-218
View File
@@ -1,218 +0,0 @@
import random
import string
from pathlib import Path
from typing import Callable
from unittest.mock import patch
import pytest
from django.conf import settings
from django.core.files.base import ContentFile
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client
from django.urls import reverse
from django.utils.datastructures import MultiValueDict
from django.utils.timezone import localdate
from model_bakery import baker
from PIL import Image
from pytest_django.asserts import assertInHTML, assertRedirects
from core.baker_recipes import subscriber_user
from core.models import Group, User
from core.utils import RED_PIXEL_PNG
from sas.baker_recipes import picture_recipe
from sas.forms import AlbumEditForm
from sas.models import Album
@pytest.fixture
def sas_root(db) -> Album:
return Album.objects.get(id=settings.SITH_SAS_ROOT_DIR_ID)
@pytest.fixture
def album(db) -> Album:
name = "".join(
random.choice(string.ascii_letters) for _ in range(Album.NAME_MAX_LENGTH)
)
return baker.make(
Album, name=name, parent_id=settings.SITH_SAS_ROOT_DIR_ID, is_moderated=True
)
@pytest.mark.parametrize("user", [None, lambda: baker.make(User), subscriber_user.make])
@pytest.mark.django_db
def test_permission_denied(
client: Client, album: Album, user: Callable[[], User] | None
):
if user:
client.force_login(user())
url = reverse("sas:album_edit", kwargs={"album_id": album.pk})
for method in client.get, client.post:
assert method(url).status_code == 403
@pytest.mark.django_db
def test_sas_root_read_only(client: Client, sas_root: Album):
moderator = baker.make(
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
)
client.force_login(moderator)
url = reverse("sas:album_edit", kwargs={"album_id": sas_root.pk})
for method in client.get, client.post:
assert method(url).status_code == 404
@pytest.mark.parametrize(
("excluded", "is_valid"),
[
("name", False),
("date", False),
("file", True),
("parent", False),
("edit_groups", True),
("recursive", True),
],
)
@pytest.mark.django_db
def test_form_required(album: Album, excluded: str, is_valid: bool): # noqa: FBT001
data = {
"name": album.name,
"parent": baker.make(Album, parent=album.parent, is_moderated=True).pk,
"date": localdate(),
"file": "/random/path",
"edit_groups": [settings.SITH_GROUP_SAS_ADMIN_ID],
"recursive": False,
}
del data[excluded]
assert AlbumEditForm(data=data).is_valid() == is_valid
@pytest.mark.django_db
def test_form_album_name(album: Album):
data = {
"name": "a" * Album.NAME_MAX_LENGTH,
"parent": album.pk,
"date": localdate(),
}
assert AlbumEditForm(data=data).is_valid()
data["name"] = "a" * (Album.NAME_MAX_LENGTH + 1)
assert not AlbumEditForm(data=data).is_valid()
@pytest.mark.django_db
def test_update_recursive_parent(client: Client, album: Album):
client.force_login(baker.make(User, is_superuser=True))
payload = {"name": album.name, "parent": album.pk, "date": localdate()}
response = client.post(
reverse("sas:album_edit", kwargs={"album_id": album.pk}), payload
)
assertInHTML("<li>Boucle dans l'arborescence des dossiers</li>", response.text)
assert response.status_code == 200
@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(
"parent",
[
lambda: baker.make(
Album, parent_id=settings.SITH_SAS_ROOT_DIR_ID, is_moderated=True
),
lambda: Album.objects.get(id=settings.SITH_SAS_ROOT_DIR_ID),
],
)
@pytest.mark.django_db
def test_update(
client: Client,
album: Album,
sas_root: Album,
user: Callable[[], User],
parent: Callable[[], Album],
):
client.force_login(user())
expected_redirect = reverse("sas:album", kwargs={"album_id": album.pk})
payload = {
"name": "foo",
"parent": parent().id,
"date": localdate(),
"recursive": False,
}
response = client.post(
reverse("sas:album_edit", kwargs={"album_id": album.pk}), payload
)
assertRedirects(response, expected_redirect)
album.refresh_from_db()
assert album.name == "foo"
assert album.parent.id == payload["parent"]
assert localdate(album.date) == localdate()
class TestAlbumThumbnail:
@pytest.fixture
def files(self):
return MultiValueDict(
{"file": [SimpleUploadedFile(name="foo.png", content=RED_PIXEL_PNG)]}
)
def test_thumbnail_resized(self, album, files):
"""Test that album thumbnails are resized to the correct dimensions."""
form = AlbumEditForm(
data={"name": album.name, "date": localdate(), "parent": album.parent.id},
files=files,
instance=album,
)
assert form.is_valid()
form.save()
album.refresh_from_db()
assert album.file.name == f"SAS/{album.name}/thumb.webp"
assert Image.open(album.file).size == (200, 200)
def test_thumbnail_removed(self, album):
"""Test the case where the user checks the box to remove the thumbnail"""
album.file = ContentFile(name="foo.png", content=RED_PIXEL_PNG)
album.save()
previous_filename = album.file.name
form = AlbumEditForm(
data={
"name": "foo",
"date": localdate(),
"parent": album.parent.id,
"file-clear": True,
},
instance=album,
)
# as there is now no picture, a thumbnail should be generated
with patch.object(Album, "generate_thumbnail") as mock:
assert form.is_valid()
form.save()
album.refresh_from_db()
assert album.file.storage.exists(album.file.name)
assert not album.file.storage.exists(previous_filename)
mock.assert_called_once()
def test_generate_thumbnail(self, album):
"""Test that if no image is given and the album has pictures,
the thumbnail is automatically generated.
"""
picture = picture_recipe.make(
parent=album, thumbnail=ContentFile(name="foo.png", content=RED_PIXEL_PNG)
)
form = AlbumEditForm(
data={"name": "foo", "date": localdate(), "parent": album.parent.id},
instance=album,
)
assert form.is_valid()
form.save()
album.refresh_from_db()
assert Path(album.file.name) == Path("SAS/foo/thumb.webp")
assert album.file.storage.exists(album.file.name)
assert Image.open(album.file) == Image.open(picture.thumbnail)
+106 -40
View File
@@ -12,24 +12,22 @@
# 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 django.utils.timezone import localdate
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.forms import AlbumEditForm
from sas.models import Album, Picture from sas.models import Album, Picture
# Create your tests here. # Create your tests here.
@@ -99,7 +97,6 @@ def test_main_page_content_anonymous(client: Client):
@pytest.mark.django_db @pytest.mark.django_db
def test_album_access_non_subscriber(client: Client): def test_album_access_non_subscriber(client: Client):
"""Test that non-subscribers can only access albums where they are identified.""" """Test that non-subscribers can only access albums where they are identified."""
cache.clear()
album = baker.make(Album, parent_id=settings.SITH_SAS_ROOT_DIR_ID) album = baker.make(Album, parent_id=settings.SITH_SAS_ROOT_DIR_ID)
user = baker.make(User) user = baker.make(User)
client.force_login(user) client.force_login(user)
@@ -167,36 +164,94 @@ class TestAlbumUpload:
@pytest.mark.django_db @pytest.mark.django_db
class TestPictureRotation: class TestAlbumEdit:
@pytest.fixture @pytest.fixture
def picture(self) -> Picture: def sas_root(self) -> Album:
return picture_recipe.make( return Album.objects.get(id=settings.SITH_SAS_ROOT_DIR_ID)
parent_id=settings.SITH_SAS_ROOT_DIR_ID,
file=ContentFile(name="foo.png", content=RED_PIXEL_PNG), @pytest.fixture
compressed=ContentFile(name="foo.png", content=RED_PIXEL_PNG), def album(self) -> Album:
thumbnail=ContentFile(name="foo.png", content=RED_PIXEL_PNG), return baker.make(
Album, parent_id=settings.SITH_SAS_ROOT_DIR_ID, is_moderated=True
) )
@pytest.mark.parametrize( @pytest.mark.parametrize(
"user", "user",
[ [None, lambda: baker.make(User), subscriber_user.make],
None,
lambda: baker.make(User),
subscriber_user.make,
old_subscriber_user.make,
],
) )
def test_permission_denied( def test_permission_denied(
self, client: Client, picture: Picture, user: Callable[[], User] | None self,
client: Client,
album: Album,
user: Callable[[], User] | None,
): ):
if user: if user:
client.force_login(user()) client.force_login(user())
url = reverse( url = reverse("sas:album_edit", kwargs={"album_id": album.pk})
"api:rotate_picture", kwargs={"picture_id": picture.id, "direction": "left"} response = client.get(url)
) assert response.status_code == 403
response = client.post(url) response = client.post(url)
assert response.status_code == 403 if user else 401 assert response.status_code == 403
def test_sas_root_read_only(self, client: Client, sas_root: Album):
moderator = baker.make(
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
)
client.force_login(moderator)
url = reverse("sas:album_edit", kwargs={"album_id": sas_root.pk})
response = client.get(url)
assert response.status_code == 404
response = client.post(url)
assert response.status_code == 404
@pytest.mark.parametrize(
("excluded", "is_valid"),
[
("name", False),
("date", False),
("file", True),
("parent", False),
("edit_groups", True),
("recursive", True),
],
)
def test_form_required(self, album: Album, excluded: str, is_valid: bool): # noqa: FBT001
data = {
"name": album.name[: Album.NAME_MAX_LENGTH],
"parent": baker.make(Album, parent=album.parent, is_moderated=True).pk,
"date": localdate(),
"file": "/random/path",
"edit_groups": [settings.SITH_GROUP_SAS_ADMIN_ID],
"recursive": False,
}
del data[excluded]
assert AlbumEditForm(data=data).is_valid() == is_valid
def test_form_album_name(self, album: Album):
data = {
"name": album.name[: Album.NAME_MAX_LENGTH],
"parent": album.pk,
"date": localdate(),
}
assert AlbumEditForm(data=data).is_valid()
data["name"] = album.name[: Album.NAME_MAX_LENGTH + 1]
assert not AlbumEditForm(data=data).is_valid()
def test_update_recursive_parent(self, client: Client, album: Album):
client.force_login(baker.make(User, is_superuser=True))
payload = {
"name": album.name[: Album.NAME_MAX_LENGTH],
"parent": album.pk,
"date": localdate(),
}
response = client.post(
reverse("sas:album_edit", kwargs={"album_id": album.pk}), payload
)
assertInHTML("<li>Boucle dans l'arborescence des dossiers</li>", response.text)
assert response.status_code == 200
@pytest.mark.parametrize( @pytest.mark.parametrize(
"user", "user",
@@ -207,28 +262,39 @@ class TestPictureRotation:
), ),
], ],
) )
@pytest.mark.parametrize(("direction", "angle"), [("left", 90), ("right", 270)]) @pytest.mark.parametrize(
def test_rotation( "parent",
[
lambda: baker.make(
Album, parent_id=settings.SITH_SAS_ROOT_DIR_ID, is_moderated=True
),
lambda: Album.objects.get(id=settings.SITH_SAS_ROOT_DIR_ID),
],
)
def test_update(
self, self,
client: Client, client: Client,
picture: Picture, album: Album,
sas_root: Album,
user: Callable[[], User], user: Callable[[], User],
direction: Literal["left", "right"], parent: Callable[[], Album],
angle: Literal[90, 270],
): ):
client.force_login(user()) client.force_login(user())
url = reverse( expected_redirect = reverse("sas:album", kwargs={"album_id": album.pk})
"api:rotate_picture", payload = {
kwargs={"picture_id": picture.id, "direction": direction}, "name": album.name[: Album.NAME_MAX_LENGTH],
"parent": parent().id,
"date": localdate(),
"recursive": False,
}
response = client.post(
reverse("sas:album_edit", kwargs={"album_id": album.pk}), payload
) )
with ( assertRedirects(response, expected_redirect)
patch.object(Image.Image, "rotate") as mocked_rotate, album.refresh_from_db()
patch.object(Picture, "generate_thumbnails") as mocked_thumb, assert album.name == payload["name"]
): assert album.parent.id == payload["parent"]
response = client.post(url) assert localdate(album.date) == localdate()
assert response.status_code == 200
mocked_rotate.assert_called_once_with(angle)
mocked_thumb.assert_called_once()
class TestSasModeration(TestCase): class TestSasModeration(TestCase):
+11 -3
View File
@@ -16,7 +16,6 @@ from typing import Any
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.exceptions import PermissionDenied
from django.db.models import Count, OuterRef, Subquery from django.db.models import Count, OuterRef, Subquery
from django.http import Http404, HttpResponseRedirect from django.http import Http404, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
@@ -97,6 +96,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)
@@ -145,8 +152,9 @@ class AlbumView(CanViewMixin, UseFragmentsMixin, DetailView):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()
if not request.user.can_edit(self.object): if not self.object.file:
raise PermissionDenied self.object.generate_thumbnail()
if request.user.can_edit(self.object): # Handle the copy-paste functions
FileView.handle_clipboard(request, self.object) FileView.handle_clipboard(request, self.object)
return HttpResponseRedirect(self.request.path) return HttpResponseRedirect(self.request.path)