mirror of
https://github.com/ae-utbm/sith.git
synced 2026-05-03 03:46:08 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 00f7afb937 | |||
| 7fec05820c | |||
| 22e6c09c36 | |||
| 399a3813f0 | |||
| 441a016025 | |||
| 060dde78e7 | |||
| f19b3056ef | |||
| 200f71a762 | |||
| facfa1be89 | |||
| 4b9a953a20 |
@@ -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.6.0
|
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
|
||||||
with:
|
with:
|
||||||
packages: gettext
|
packages: gettext
|
||||||
version: 1.0 # increment to reset cache
|
version: 1.0 # increment to reset cache
|
||||||
@@ -23,29 +23,26 @@ 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@v6
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version-file: ".python-version"
|
python-version-file: ".python-version"
|
||||||
|
|
||||||
- name: Install uv
|
|
||||||
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
|
- name: Restore cached virtualenv
|
||||||
uses: actions/cache@v5
|
uses: actions/cache/restore@v4
|
||||||
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 --locked
|
run: uv sync
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Install Xapian
|
- name: Install Xapian
|
||||||
@@ -53,6 +50,15 @@ 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
|
||||||
|
|||||||
@@ -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@v6
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-python@v6
|
- uses: actions/setup-python@v5
|
||||||
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@v6
|
uses: actions/checkout@v4
|
||||||
- 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@v7
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: coverage-report-${{ matrix.pytest-mark }}
|
name: coverage-report-${{ matrix.pytest-mark }}
|
||||||
path: coverage_report
|
path: coverage_report
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: SSH Remote Commands
|
- name: SSH Remote Commands
|
||||||
uses: appleboy/ssh-action@v1.2.5
|
uses: appleboy/ssh-action@v1.1.0
|
||||||
with:
|
with:
|
||||||
# Proxy
|
# Proxy
|
||||||
proxy_host : ${{secrets.PROXY_HOST}}
|
proxy_host : ${{secrets.PROXY_HOST}}
|
||||||
@@ -29,6 +29,8 @@ 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}}
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ jobs:
|
|||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v4
|
||||||
- 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@v5
|
- uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
key: mkdocs-material-${{ env.cache_id }}
|
key: mkdocs-material-${{ env.cache_id }}
|
||||||
path: .cache
|
path: .cache
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: SSH Remote Commands
|
- name: SSH Remote Commands
|
||||||
uses: appleboy/ssh-action@v1.2.5
|
uses: appleboy/ssh-action@v1.1.0
|
||||||
with:
|
with:
|
||||||
# Proxy
|
# Proxy
|
||||||
proxy_host : ${{secrets.PROXY_HOST}}
|
proxy_host : ${{secrets.PROXY_HOST}}
|
||||||
@@ -28,6 +28,8 @@ 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}}
|
||||||
|
|||||||
@@ -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, User
|
from core.models import Group, Notification, SithFile, User
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@@ -18,6 +18,7 @@ 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,8 +622,7 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
pict.file.name = p.name
|
pict.file.name = p.name
|
||||||
pict.full_clean()
|
pict.full_clean()
|
||||||
pict.generate_thumbnails()
|
pict.generate_thumbnails(save=True)
|
||||||
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")
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# 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),
|
||||||
|
]
|
||||||
+3
-2
@@ -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.CASCADE,
|
on_delete=models.PROTECT,
|
||||||
)
|
)
|
||||||
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,6 +865,7 @@ 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,
|
||||||
@@ -872,7 +873,7 @@ class SithFile(models.Model):
|
|||||||
verbose_name=_("owner"),
|
verbose_name=_("owner"),
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.SET_NULL,
|
||||||
)
|
)
|
||||||
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 }}', text: '{{ message }}' },
|
{ tag: {{ message.tags|string|tojson }}, text: {{ message|string|tojson }} },
|
||||||
{%- 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,7 +33,8 @@
|
|||||||
<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 %}{{ f.date|date(DATE_FORMAT) }} {{ f.date|time(TIME_FORMAT) }}<br/>
|
{% trans %}Date: {% endtrans %}
|
||||||
|
{{ 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, User
|
from core.models import AnonymousUser, Group, SithFile, 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,6 +34,7 @@ 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,7 +25,6 @@ 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] = (
|
||||||
@@ -178,22 +177,6 @@ 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-03-23 22:21+0100\n"
|
"POT-Creation-Date: 2026-05-02 17:57+0200\n"
|
||||||
"PO-Revision-Date: 2016-07-18\n"
|
"PO-Revision-Date: 2016-07-18\n"
|
||||||
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
|
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
|
||||||
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
||||||
@@ -181,6 +181,22 @@ 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"
|
||||||
@@ -301,37 +317,22 @@ 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 "Club list"
|
|
||||||
msgstr "Liste des clubs"
|
|
||||||
|
|
||||||
#: club/templates/club/club_list.jinja
|
#: club/templates/club/club_list.jinja
|
||||||
msgid "The list of all clubs existing at UTBM."
|
msgid "The list of all clubs existing at UTBM."
|
||||||
msgstr "La liste de tous les clubs existants à l'UTBM"
|
msgstr "La liste de tous les clubs existants à l'UTBM"
|
||||||
|
|
||||||
|
#: club/templates/club/club_list.jinja
|
||||||
|
msgid "Club list"
|
||||||
|
msgstr "Liste des clubs"
|
||||||
|
|
||||||
#: 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
|
#: club/templates/club/club_list.jinja core/templates/core/base/header.jinja
|
||||||
msgid "Name"
|
#: forum/templates/forum/macros.jinja matmat/templates/matmat/search_form.jinja
|
||||||
msgstr "Nom"
|
msgid "Search"
|
||||||
|
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"
|
||||||
@@ -433,7 +434,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 sas/forms.py
|
#: rootplace/templates/rootplace/logs.jinja
|
||||||
#: trombi/templates/trombi/user_profile.jinja
|
#: trombi/templates/trombi/user_profile.jinja
|
||||||
msgid "Date"
|
msgid "Date"
|
||||||
msgstr "Date"
|
msgstr "Date"
|
||||||
@@ -1692,6 +1693,10 @@ 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é"
|
||||||
@@ -1863,11 +1868,6 @@ 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,10 +3195,6 @@ 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"
|
||||||
@@ -3828,14 +3824,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 price"
|
|
||||||
msgstr "Retirer le prix"
|
|
||||||
|
|
||||||
#: counter/templates/counter/product_form.jinja
|
#: counter/templates/counter/product_form.jinja
|
||||||
msgid "Remove this action"
|
msgid "Remove this action"
|
||||||
msgstr "Retirer cette action"
|
msgstr "Retirer cette action"
|
||||||
|
|
||||||
|
#: counter/templates/counter/product_form.jinja
|
||||||
|
msgid "Remove price"
|
||||||
|
msgstr "Retirer le prix"
|
||||||
|
|
||||||
#: 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"
|
||||||
@@ -4205,6 +4201,47 @@ 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"
|
||||||
@@ -5631,10 +5668,6 @@ 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"
|
||||||
@@ -5876,39 +5909,3 @@ 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."
|
|
||||||
|
|||||||
+2
-1
@@ -7,13 +7,14 @@ 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 User
|
from core.models import SithFile, 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),
|
||||||
|
|||||||
+14
-2
@@ -126,9 +126,8 @@ 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.save()
|
new.generate_thumbnails(save=True)
|
||||||
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)
|
||||||
|
|
||||||
@@ -177,6 +176,19 @@ 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],
|
||||||
|
|||||||
+13
-2
@@ -1,13 +1,24 @@
|
|||||||
|
from django.conf import settings
|
||||||
from model_bakery import seq
|
from model_bakery import seq
|
||||||
from model_bakery.recipe import Recipe
|
from model_bakery.recipe import Recipe, foreign_key
|
||||||
|
|
||||||
from sas.models import Picture
|
from sas.models import Album, 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.
|
||||||
|
|||||||
+39
-5
@@ -1,18 +1,25 @@
|
|||||||
|
import copy
|
||||||
import datetime
|
import datetime
|
||||||
from typing import Any
|
from pathlib import Path
|
||||||
|
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:
|
||||||
@@ -51,12 +58,9 @@ 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 = {
|
widgets = {"edit_groups": AutoCompleteSelectMultipleGroup, "date": SelectDate}
|
||||||
"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
|
||||||
@@ -71,6 +75,36 @@ 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.
|
||||||
|
|||||||
+63
-82
@@ -15,13 +15,12 @@
|
|||||||
|
|
||||||
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
|
||||||
@@ -29,7 +28,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 exif_auto_rotate, resize_image
|
from core.utils import resize_image
|
||||||
|
|
||||||
|
|
||||||
class SasFile(SithFile):
|
class SasFile(SithFile):
|
||||||
@@ -91,92 +90,75 @@ 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("sas:download", kwargs={"picture_id": self.id})
|
return reverse(
|
||||||
|
"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("sas:download_compressed", kwargs={"picture_id": self.id})
|
return reverse(
|
||||||
|
"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("sas:download_thumb", kwargs={"picture_id": self.id})
|
return reverse(
|
||||||
|
"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(self, *, overwrite=False):
|
def generate_thumbnails(
|
||||||
im = Image.open(BytesIO(self.file.read()))
|
self, *, img: Image.Image | None = None, save: bool = False
|
||||||
with contextlib.suppress(Exception):
|
):
|
||||||
im = exif_auto_rotate(im)
|
"""Generate the thumbnail and the compressed version of this picture.
|
||||||
|
|
||||||
|
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 an user)
|
# for less frequent cases (like downloading the pictures of a 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 time, which greatly hinders the UX
|
# - optimizing large images takes a lot of 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"))
|
||||||
self.file = file
|
file = resize_image(img, max(img.size), extension, optimize=False)
|
||||||
self.file.name = self.name
|
self.file.save(self.name, file, save=False)
|
||||||
self.thumbnail = thumb
|
thumbnail = resize_image(img, 200, "webp")
|
||||||
self.thumbnail.name = new_extension_name
|
self.thumbnail.save(new_extension_name, thumbnail, save=False)
|
||||||
self.compressed = compressed
|
compressed = resize_image(img, 1200, "webp")
|
||||||
self.compressed.name = new_extension_name
|
self.compressed.save(new_extension_name, compressed, save=save)
|
||||||
|
# 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):
|
def rotate(self, degree: int | float):
|
||||||
for attr in ["file", "compressed", "thumbnail"]:
|
"""Rotate this picture and update its thumbnails accordingly.
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_next(self):
|
Args:
|
||||||
if self.is_moderated:
|
degree: the rotation angle, in degree, counter-clockwise
|
||||||
pictures_qs = self.parent.children.filter(
|
"""
|
||||||
is_moderated=True,
|
img = Image.open(self.file).rotate(degree)
|
||||||
asked_for_removal=False,
|
self.generate_thumbnails(img=img, save=True)
|
||||||
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):
|
||||||
@@ -242,20 +224,19 @@ 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("sas:album_preview", kwargs={"album_id": self.id})
|
return reverse(
|
||||||
|
"sas:album_preview",
|
||||||
|
kwargs={"album_id": self.id},
|
||||||
|
query={"date": int(self.updated_at.timestamp())},
|
||||||
|
)
|
||||||
|
|
||||||
def generate_thumbnail(self):
|
def generate_thumbnail(self):
|
||||||
p = (
|
p = self.children_pictures.order_by("?").first()
|
||||||
self.children_pictures.order_by("?").first()
|
if p and p.thumbnail:
|
||||||
or self.children_albums.exclude(file=None)
|
image = ContentFile(
|
||||||
.exclude(file="")
|
name=str(Path(self.name) / "thumb.webp"), content=p.thumbnail.read()
|
||||||
.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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -70,7 +70,15 @@ class PictureFilterSchema(FilterSchema):
|
|||||||
class PictureSchema(ModelSchema):
|
class PictureSchema(ModelSchema):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Picture
|
model = Picture
|
||||||
fields = ["id", "name", "date", "size", "is_moderated", "asked_for_removal"]
|
fields = [
|
||||||
|
"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.ts";
|
import { paginated } from "#core:utils/api";
|
||||||
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.ts";
|
import type { UserAjaxSelect } from "#core:core/components/ajax-select-index";
|
||||||
import { paginated } from "#core:utils/api.ts";
|
import { paginated } from "#core:utils/api";
|
||||||
import { History } from "#core:utils/history.ts";
|
import { History } from "#core:utils/history";
|
||||||
import {
|
import {
|
||||||
type IdentifiedUserSchema,
|
type IdentifiedUserSchema,
|
||||||
type ModerationRequestSchema,
|
type ModerationRequestSchema,
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
picturesFetchPictures,
|
picturesFetchPictures,
|
||||||
picturesIdentifyUsers,
|
picturesIdentifyUsers,
|
||||||
picturesModeratePicture,
|
picturesModeratePicture,
|
||||||
|
picturesRotatePicture,
|
||||||
type UserProfileSchema,
|
type UserProfileSchema,
|
||||||
usersidentifiedDeleteRelation,
|
usersidentifiedDeleteRelation,
|
||||||
} from "#openapi";
|
} from "#openapi";
|
||||||
@@ -28,18 +29,32 @@ class PictureWithIdentifications {
|
|||||||
identificationsLoading = false;
|
identificationsLoading = false;
|
||||||
moderationLoading = false;
|
moderationLoading = false;
|
||||||
id: number;
|
id: number;
|
||||||
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
compressedUrl: string = "";
|
||||||
compressed_url: string;
|
thumbUrl: 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
|
||||||
@@ -82,12 +97,25 @@ 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.compressed_url;
|
img.src = this.compressedUrl;
|
||||||
if (!img.complete) {
|
if (!img.complete) {
|
||||||
this.imageLoading = true;
|
this.imageLoading = true;
|
||||||
img.addEventListener("load", () => {
|
img.addEventListener("load", () => {
|
||||||
@@ -140,7 +168,8 @@ 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: "",
|
||||||
date: new Date(),
|
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||||
|
created_at: new Date(),
|
||||||
identifications: [] as IdentifiedUserSchema[],
|
identifications: [] as IdentifiedUserSchema[],
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
@@ -291,10 +320,8 @@ 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,37 +235,34 @@
|
|||||||
|
|
||||||
>.tools {
|
>.tools {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
.btn {
|
||||||
|
background-color: $primary-neutral-light-color;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0;
|
||||||
|
color: black;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
font-size: 20px;
|
||||||
|
|
||||||
>div>div {
|
&:hover {
|
||||||
>a.btn {
|
background-color: #aaa;
|
||||||
background-color: $primary-neutral-light-color;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0;
|
|
||||||
color: black;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
font-size: 20px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: #aaa;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
>a.text.danger {
|
a.text.danger {
|
||||||
color: red;
|
color: red;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: darkred;
|
color: darkred;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&.buttons {
|
.buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 src = a.name %}
|
{% set alt = 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 src = picture.name %}
|
{% set alt = picture.name %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% set img = static('core/img/sas.jpg') %}
|
{% set img = static('core/img/sas.jpg') %}
|
||||||
{% set src = "sas.jpg" %}
|
{% set alt = "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="{{ src }}" loading="lazy" />
|
<img src="{{ img }}" alt="{{ alt }}" loading="lazy" />
|
||||||
{% if not a.is_moderated %}
|
{% if not a.is_moderated %}
|
||||||
<div class="overlay"> </div>
|
<div class="overlay"> </div>
|
||||||
<div class="text">{% trans %}To be moderated{% endtrans %}</div>
|
<div class="text">{% trans %}To be moderated{% endtrans %}</div>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{% extends "core/base.jinja" %}
|
{% extends "core/base.jinja" %}
|
||||||
|
|
||||||
{%- block additional_css -%}
|
{%- block additional_css -%}
|
||||||
<link defer rel="stylesheet" href="{{ static('bundled/core/components/ajax-select-index.css') }}">
|
<link rel="stylesheet" href="{{ static('bundled/core/components/ajax-select-index.css') }}">
|
||||||
<link defer rel="stylesheet" href="{{ static('core/components/ajax-select.scss') }}">
|
<link rel="stylesheet" href="{{ static('core/components/ajax-select.scss') }}">
|
||||||
<link defer rel="stylesheet" href="{{ static('sas/css/picture.scss') }}">
|
<link 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.compressed_url"
|
:src="currentPicture.compressedUrl"
|
||||||
: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(new Date(currentPicture.date))"
|
).format(Date.parse(currentPicture.date))"
|
||||||
>
|
>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,23 +115,38 @@
|
|||||||
<h5>{% trans %}Tools{% endtrans %}</h5>
|
<h5>{% trans %}Tools{% endtrans %}</h5>
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<div>
|
||||||
<a class="text" :href="currentPicture.full_size_url">
|
<a class="text" :href="currentPicture.fullSizeUrl">
|
||||||
{% 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 class="buttons">
|
<div
|
||||||
|
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"
|
||||||
x-show="{{ user.has_perm("sas.change_sasfile")|tojson }} || currentPicture.owner.id === {{ user.id }}"
|
:disabled="currentPicture.imageLoading"
|
||||||
>
|
>
|
||||||
<i class="fa-regular fa-pen-to-square edit-action"></i>
|
<i class="fa-regular fa-pen-to-square edit-action"></i>
|
||||||
</a>
|
</a>
|
||||||
<a class="btn btn-no-text" href="?rotate_left"><i class="fa-solid fa-rotate-left"></i></a>
|
<button
|
||||||
<a class="btn btn-no-text" href="?rotate_right"><i class="fa-solid fa-rotate-right"></i></a>
|
class="btn btn-no-text"
|
||||||
|
@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>
|
||||||
@@ -146,7 +161,7 @@
|
|||||||
@keyup.left.window="currentPicture = previousPicture"
|
@keyup.left.window="currentPicture = previousPicture"
|
||||||
@click="currentPicture = previousPicture"
|
@click="currentPicture = previousPicture"
|
||||||
>
|
>
|
||||||
<img :src="previousPicture.thumb_url" alt="{% trans %}Previous picture{% endtrans %}"/>
|
<img :src="previousPicture.thumbUrl" alt="{% trans %}Previous picture{% endtrans %}"/>
|
||||||
<div class="overlay">←</div>
|
<div class="overlay">←</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -157,7 +172,7 @@
|
|||||||
@keyup.right.window="currentPicture = nextPicture"
|
@keyup.right.window="currentPicture = nextPicture"
|
||||||
@click="currentPicture = nextPicture"
|
@click="currentPicture = nextPicture"
|
||||||
>
|
>
|
||||||
<img :src="nextPicture.thumb_url" alt="{% trans %}Previous picture{% endtrans %}"/>
|
<img :src="nextPicture.thumbUrl" alt="{% trans %}Previous picture{% endtrans %}"/>
|
||||||
<div class="overlay">→</div>
|
<div class="overlay">→</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
|
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
|
||||||
@@ -67,3 +72,36 @@ 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)
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
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)
|
||||||
+39
-105
@@ -12,22 +12,24 @@
|
|||||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
from typing import Callable
|
from typing import Callable, Literal
|
||||||
|
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.
|
||||||
@@ -97,6 +99,7 @@ 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)
|
||||||
@@ -164,94 +167,36 @@ class TestAlbumUpload:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestAlbumEdit:
|
class TestPictureRotation:
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sas_root(self) -> Album:
|
def picture(self) -> Picture:
|
||||||
return Album.objects.get(id=settings.SITH_SAS_ROOT_DIR_ID)
|
return picture_recipe.make(
|
||||||
|
parent_id=settings.SITH_SAS_ROOT_DIR_ID,
|
||||||
@pytest.fixture
|
file=ContentFile(name="foo.png", content=RED_PIXEL_PNG),
|
||||||
def album(self) -> Album:
|
compressed=ContentFile(name="foo.png", content=RED_PIXEL_PNG),
|
||||||
return baker.make(
|
thumbnail=ContentFile(name="foo.png", content=RED_PIXEL_PNG),
|
||||||
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,
|
self, client: Client, picture: Picture, user: Callable[[], User] | None
|
||||||
client: Client,
|
|
||||||
album: Album,
|
|
||||||
user: Callable[[], User] | None,
|
|
||||||
):
|
):
|
||||||
if user:
|
if user:
|
||||||
client.force_login(user())
|
client.force_login(user())
|
||||||
|
|
||||||
url = reverse("sas:album_edit", kwargs={"album_id": album.pk})
|
url = reverse(
|
||||||
response = client.get(url)
|
"api:rotate_picture", kwargs={"picture_id": picture.id, "direction": "left"}
|
||||||
assert response.status_code == 403
|
|
||||||
response = client.post(url)
|
|
||||||
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)
|
response = client.post(url)
|
||||||
assert response.status_code == 404
|
assert response.status_code == 403 if user else 401
|
||||||
|
|
||||||
@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",
|
||||||
@@ -262,39 +207,28 @@ class TestAlbumEdit:
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(("direction", "angle"), [("left", 90), ("right", 270)])
|
||||||
"parent",
|
def test_rotation(
|
||||||
[
|
|
||||||
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,
|
||||||
album: Album,
|
picture: Picture,
|
||||||
sas_root: Album,
|
|
||||||
user: Callable[[], User],
|
user: Callable[[], User],
|
||||||
parent: Callable[[], Album],
|
direction: Literal["left", "right"],
|
||||||
|
angle: Literal[90, 270],
|
||||||
):
|
):
|
||||||
client.force_login(user())
|
client.force_login(user())
|
||||||
expected_redirect = reverse("sas:album", kwargs={"album_id": album.pk})
|
url = reverse(
|
||||||
payload = {
|
"api:rotate_picture",
|
||||||
"name": album.name[: Album.NAME_MAX_LENGTH],
|
kwargs={"picture_id": picture.id, "direction": direction},
|
||||||
"parent": parent().id,
|
|
||||||
"date": localdate(),
|
|
||||||
"recursive": False,
|
|
||||||
}
|
|
||||||
response = client.post(
|
|
||||||
reverse("sas:album_edit", kwargs={"album_id": album.pk}), payload
|
|
||||||
)
|
)
|
||||||
assertRedirects(response, expected_redirect)
|
with (
|
||||||
album.refresh_from_db()
|
patch.object(Image.Image, "rotate") as mocked_rotate,
|
||||||
assert album.name == payload["name"]
|
patch.object(Picture, "generate_thumbnails") as mocked_thumb,
|
||||||
assert album.parent.id == payload["parent"]
|
):
|
||||||
assert localdate(album.date) == localdate()
|
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):
|
||||||
|
|||||||
+4
-12
@@ -16,6 +16,7 @@ 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
|
||||||
@@ -96,14 +97,6 @@ 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)
|
||||||
@@ -152,10 +145,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 self.object.file:
|
if not request.user.can_edit(self.object):
|
||||||
self.object.generate_thumbnail()
|
raise PermissionDenied
|
||||||
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)
|
||||||
|
|
||||||
def get_fragment_data(self) -> dict[str, dict[str, Any]]:
|
def get_fragment_data(self) -> dict[str, dict[str, Any]]:
|
||||||
|
|||||||
Reference in New Issue
Block a user