Sith/sas/models.py

324 lines
11 KiB
Python
Raw Permalink Normal View History

#
# Copyright 2023 © AE UTBM
# ae@utbm.fr / ae.info@utbm.fr
#
# This file is part of the website of the UTBM Student Association (AE UTBM),
# https://ae.utbm.fr.
#
# You can find the source code of the website at https://github.com/ae-utbm/sith
#
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
2024-09-23 08:25:27 +00:00
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
2024-08-06 11:23:34 +00:00
from __future__ import annotations
import contextlib
2024-06-24 11:07:36 +00:00
from io import BytesIO
2024-09-13 16:05:42 +00:00
from pathlib import Path
2024-09-08 11:29:33 +00:00
from typing import ClassVar, Self
2024-06-24 11:07:36 +00:00
from django.conf import settings
from django.core.cache import cache
2016-10-26 17:21:19 +00:00
from django.db import models
from django.db.models import Exists, OuterRef
from django.urls import reverse
from django.utils import timezone
2024-06-24 11:07:36 +00:00
from django.utils.translation import gettext_lazy as _
2016-11-20 22:53:41 +00:00
from PIL import Image
2016-10-26 17:21:19 +00:00
from core.models import SithFile, User
2024-06-24 11:07:36 +00:00
from core.utils import exif_auto_rotate, resize_image
2016-10-26 17:21:19 +00:00
2018-06-10 16:43:39 +00:00
class SasFile(SithFile):
"""Proxy model for any file in the SAS.
May be used to have logic that should be shared by both
[Picture][sas.models.Picture] and [Album][sas.models.Album].
"""
class Meta:
proxy = True
def can_be_viewed_by(self, user):
if user.is_anonymous:
return False
cache_key = (
f"sas:{self._meta.model_name}_viewable_by_{user.id}_in_{self.parent_id}"
)
viewable: list[int] | None = cache.get(cache_key)
if viewable is None:
viewable = list(
self.__class__.objects.filter(parent_id=self.parent_id)
.viewable_by(user)
.values_list("pk", flat=True)
)
cache.set(cache_key, viewable, timeout=10)
return self.id in viewable
def can_be_edited_by(self, user):
return user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID)
class PictureQuerySet(models.QuerySet):
def viewable_by(self, user: User) -> Self:
"""Filter the pictures that this user can view.
Warning:
Calling this queryset method may add several additional requests.
"""
if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
return self.all()
if user.was_subscribed:
return self.filter(is_moderated=True)
2024-08-06 11:23:34 +00:00
return self.filter(people__user_id=user.id, is_moderated=True)
class SASPictureManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(is_in_sas=True, is_folder=False)
2018-06-10 16:43:39 +00:00
2017-06-12 08:02:38 +00:00
class Picture(SasFile):
2016-10-26 17:21:19 +00:00
class Meta:
proxy = True
objects = SASPictureManager.from_queryset(PictureQuerySet)()
2016-11-20 22:53:41 +00:00
@property
def is_vertical(self):
2024-07-26 13:14:37 +00:00
with open(settings.MEDIA_ROOT / self.file.name, "rb") as f:
2016-11-29 10:35:31 +00:00
im = Image.open(BytesIO(f.read()))
(w, h) = im.size
return (w / h) < 1
2016-11-20 22:53:41 +00:00
2016-10-26 17:21:19 +00:00
def get_download_url(self):
2018-10-04 19:29:19 +00:00
return reverse("sas:download", kwargs={"picture_id": self.id})
2016-10-26 17:21:19 +00:00
2016-11-20 10:56:33 +00:00
def get_download_compressed_url(self):
2018-10-04 19:29:19 +00:00
return reverse("sas:download_compressed", kwargs={"picture_id": self.id})
2016-11-20 10:56:33 +00:00
def get_download_thumb_url(self):
2018-10-04 19:29:19 +00:00
return reverse("sas:download_thumb", kwargs={"picture_id": self.id})
2016-11-20 10:56:33 +00:00
2016-11-25 12:47:09 +00:00
def get_absolute_url(self):
2018-10-04 19:29:19 +00:00
return reverse("sas:picture", kwargs={"picture_id": self.id})
2016-11-25 12:47:09 +00:00
2024-06-27 12:57:40 +00:00
def generate_thumbnails(self, *, overwrite=False):
2016-11-20 23:02:40 +00:00
im = Image.open(BytesIO(self.file.read()))
with contextlib.suppress(Exception):
2016-11-20 23:02:40 +00:00
im = exif_auto_rotate(im)
2024-09-01 17:05:54 +00:00
# convert the compressed image and the thumbnail into webp
# 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
# 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 :
# - it isn't frequently queried
# - optimizing large images takes a lot time, which greatly hinders the UX
# - photographers usually already optimize their images
file = resize_image(im, max(im.size), extension, optimize=False)
2024-09-01 17:05:54 +00:00
thumb = resize_image(im, 200, "webp")
compressed = resize_image(im, 1200, "webp")
if overwrite:
self.file.delete()
self.thumbnail.delete()
self.compressed.delete()
2024-09-13 16:05:42 +00:00
new_extension_name = str(Path(self.name).with_suffix(".webp"))
2016-11-20 23:02:40 +00:00
self.file = file
self.file.name = self.name
self.thumbnail = thumb
2024-09-01 17:05:54 +00:00
self.thumbnail.name = new_extension_name
2016-11-20 23:02:40 +00:00
self.compressed = compressed
2024-09-01 17:05:54 +00:00
self.compressed.name = new_extension_name
2016-11-20 23:02:40 +00:00
self.save()
2016-11-20 12:39:04 +00:00
def rotate(self, degree):
2018-10-04 19:29:19 +00:00
for attr in ["file", "compressed", "thumbnail"]:
2016-11-30 08:19:09 +00:00
name = self.__getattribute__(attr).name
2024-07-26 13:14:37 +00:00
with open(settings.MEDIA_ROOT / name, "r+b") as file:
2016-11-30 08:19:09 +00:00
if file:
im = Image.open(BytesIO(file.read()))
file.seek(0)
im = im.rotate(degree, expand=True)
2018-10-04 19:29:19 +00:00
im.save(
fp=file,
format=self.mime_type.split("/")[-1].upper(),
quality=90,
optimize=True,
progressive=True,
)
2016-11-20 12:39:04 +00:00
def get_next(self):
if self.is_moderated:
pictures_qs = self.parent.children.filter(
is_moderated=True,
asked_for_removal=False,
is_folder=False,
id__gt=self.id,
2018-10-04 19:29:19 +00:00
)
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,
2018-10-04 19:29:19 +00:00
)
else:
pictures_qs = Picture.objects.filter(id__lt=self.id, is_moderated=False)
return pictures_qs.order_by("-id").first()
2017-06-12 08:02:38 +00:00
class AlbumQuerySet(models.QuerySet):
def viewable_by(self, user: User) -> Self:
"""Filter the albums that this user can view.
Warning:
Calling this queryset method may add several additional requests.
"""
if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
return self.all()
2024-08-09 16:06:58 +00:00
if user.was_subscribed:
2024-08-09 22:43:15 +00:00
return self.filter(is_moderated=True)
2024-08-09 16:06:58 +00:00
# known bug : if all children of an album are also albums
# then this album is excluded, even if one of the sub-albums should be visible.
# The fs-like navigation is likely to be half-broken for non-subscribers,
# but that's ok, since non-subscribers are expected to see only the albums
# containing pictures on which they have been identified (hence, very few).
# Most, if not all, of their albums will be displayed on the
# `latest albums` section of the SAS.
# Moreover, they will still see all of their picture in their profile.
return self.filter(
Exists(Picture.objects.filter(parent_id=OuterRef("pk")).viewable_by(user))
)
class SASAlbumManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(is_in_sas=True, is_folder=True)
class Album(SasFile):
2024-09-08 11:29:33 +00:00
NAME_MAX_LENGTH: ClassVar[int] = 50
"""Maximum length of an album's name.
[SithFile][core.models.SithFile] have a maximum length
of 256 characters.
However, this limit is too high for albums.
Names longer than 50 characters are harder to read
and harder to display on the SAS page.
It is to be noted, though, that this does not
add or modify any db behaviour.
It's just a constant to be used in views and forms.
"""
2016-10-26 17:21:19 +00:00
class Meta:
proxy = True
objects = SASAlbumManager.from_queryset(AlbumQuerySet)()
@property
def children_pictures(self):
return Picture.objects.filter(parent=self)
@property
def children_albums(self):
return Album.objects.filter(parent=self)
2016-10-26 17:21:19 +00:00
def get_absolute_url(self):
2018-10-04 19:29:19 +00:00
return reverse("sas:album", kwargs={"album_id": self.id})
2016-10-26 17:21:19 +00:00
2016-11-30 08:19:09 +00:00
def get_download_url(self):
2018-10-04 19:29:19 +00:00
return reverse("sas:album_preview", kwargs={"album_id": self.id})
2016-11-30 08:19:09 +00:00
2016-12-18 17:34:48 +00:00
def generate_thumbnail(self):
2018-10-04 19:29:19 +00:00
p = (
self.children_pictures.order_by("?").first()
or self.children_albums.exclude(file=None)
.exclude(file="")
.order_by("?")
.first()
)
2016-12-18 17:34:48 +00:00
if p and p.file:
2024-09-01 17:05:54 +00:00
image = resize_image(Image.open(BytesIO(p.file.read())), 200, "webp")
self.file = image
self.file.name = f"{self.name}/thumb.webp"
2016-12-18 17:34:48 +00:00
self.save()
2018-06-10 16:43:39 +00:00
def sas_notification_callback(notif):
count = Picture.objects.filter(is_moderated=False).count()
if count:
notif.viewed = False
else:
notif.viewed = True
notif.param = "%s" % count
notif.date = timezone.now()
2017-06-12 08:02:38 +00:00
2018-06-10 16:43:39 +00:00
class PeoplePictureRelation(models.Model):
2024-07-12 07:34:16 +00:00
"""The PeoplePictureRelation class makes the connection between User and Picture."""
2018-10-04 19:29:19 +00:00
user = models.ForeignKey(
User,
verbose_name=_("user"),
related_name="pictures",
on_delete=models.CASCADE,
2018-10-04 19:29:19 +00:00
)
picture = models.ForeignKey(
Picture,
verbose_name=_("picture"),
related_name="people",
on_delete=models.CASCADE,
2018-10-04 19:29:19 +00:00
)
class Meta:
2018-10-04 19:29:19 +00:00
unique_together = ["user", "picture"]
2016-11-25 12:47:09 +00:00
def __str__(self):
2024-10-10 16:53:49 +00:00
return f"Moderation request by {self.user.get_short_name()} - {self.picture}"
class PictureModerationRequest(models.Model):
"""A request to remove a Picture from the SAS."""
created_at = models.DateTimeField(auto_now_add=True)
author = models.ForeignKey(
User,
verbose_name=_("Author"),
related_name="moderation_requests",
on_delete=models.CASCADE,
)
picture = models.ForeignKey(
Picture,
verbose_name=_("Picture"),
related_name="moderation_requests",
on_delete=models.CASCADE,
)
reason = models.TextField(
verbose_name=_("Reason"),
default="",
help_text=_("Why do you want this image to be removed ?"),
)
class Meta:
verbose_name = _("Picture moderation request")
verbose_name_plural = _("Picture moderation requests")
constraints = [
models.UniqueConstraint(
fields=["author", "picture"], name="one_request_per_user_per_picture"
)
]
def __str__(self):
return f"Moderation request by {self.author.get_short_name()}"