2017-04-24 15:51:12 +00:00
|
|
|
#
|
2023-04-04 16:39:45 +00:00
|
|
|
# Copyright 2023 © AE UTBM
|
|
|
|
# ae@utbm.fr / ae.info@utbm.fr
|
2017-04-24 15:51:12 +00:00
|
|
|
#
|
2023-04-04 16:39:45 +00:00
|
|
|
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
|
|
|
# https://ae.utbm.fr.
|
2017-04-24 15:51:12 +00:00
|
|
|
#
|
2024-09-22 23:37:25 +00:00
|
|
|
# You can find the source code of the website at https://github.com/ae-utbm/sith
|
2017-04-24 15:51:12 +00:00
|
|
|
#
|
2023-04-04 16:39:45 +00:00
|
|
|
# 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
|
2023-04-04 16:39:45 +00:00
|
|
|
# OR WITHIN THE LOCAL FILE "LICENSE"
|
2017-04-24 15:51:12 +00:00
|
|
|
#
|
|
|
|
#
|
|
|
|
|
2024-08-06 11:23:34 +00:00
|
|
|
from __future__ import annotations
|
|
|
|
|
2024-10-15 09:36:26 +00:00
|
|
|
import contextlib
|
2024-06-24 11:07:36 +00:00
|
|
|
from io import BytesIO
|
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
|
2024-08-08 11:15:19 +00:00
|
|
|
from django.db.models import Exists, OuterRef
|
2019-10-06 11:28:56 +00:00
|
|
|
from django.urls import reverse
|
2017-09-25 10:35:56 +00:00
|
|
|
from django.utils import timezone
|
2025-01-26 11:51:54 +00:00
|
|
|
from django.utils.functional import cached_property
|
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
|
|
|
|
2025-01-26 11:51:54 +00:00
|
|
|
from core.models import Group, Notification, 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
|
|
|
|
2025-01-26 11:51:54 +00:00
|
|
|
def get_directory(instance: SasFile, filename: str):
|
|
|
|
return f"./{instance.parent_path}/{filename}"
|
|
|
|
|
|
|
|
|
|
|
|
def get_compressed_directory(instance: SasFile, filename: str):
|
|
|
|
return f"./.compressed/{instance.parent_path}/{filename}"
|
|
|
|
|
|
|
|
|
|
|
|
def get_thumbnail_directory(instance: SasFile, filename: str):
|
|
|
|
if isinstance(instance, Album):
|
|
|
|
name, extension = filename.rsplit(".", 1)
|
|
|
|
filename = f"{name}/thumb.{extension}"
|
|
|
|
return f"./.thumbnails/{instance.parent_path}/{filename}"
|
|
|
|
|
|
|
|
|
|
|
|
class SasFile(models.Model):
|
|
|
|
"""Abstract model for SAS files
|
2024-08-08 11:15:19 +00:00
|
|
|
|
|
|
|
May be used to have logic that should be shared by both
|
|
|
|
[Picture][sas.models.Picture] and [Album][sas.models.Album].
|
|
|
|
"""
|
|
|
|
|
|
|
|
class Meta:
|
2025-01-26 11:51:54 +00:00
|
|
|
abstract = True
|
2024-08-08 11:15:19 +00:00
|
|
|
|
|
|
|
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)
|
|
|
|
|
2025-01-26 11:51:54 +00:00
|
|
|
@cached_property
|
|
|
|
def parent_path(self) -> str:
|
|
|
|
return "/".join(["SAS", *[p.name for p in self.parent_list]])
|
|
|
|
|
|
|
|
@cached_property
|
|
|
|
def parent_list(self) -> list[Self]:
|
|
|
|
parents = []
|
|
|
|
current = self.parent
|
|
|
|
while current is not None:
|
|
|
|
parents.append(current)
|
|
|
|
current = current.parent
|
|
|
|
return parents
|
|
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
if user.was_subscribed:
|
|
|
|
return self.filter(
|
|
|
|
Exists(Picture.objects.filter(parent=OuterRef("pk"), is_moderated=True))
|
|
|
|
)
|
|
|
|
# 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))
|
|
|
|
)
|
|
|
|
|
|
|
|
def annotate_is_moderated(self) -> Self:
|
|
|
|
# an album is moderated if it has at least one moderated photo
|
|
|
|
# if there is no photo at all, the album isn't considered as non-moderated
|
|
|
|
# (it's just empty)
|
|
|
|
return self.annotate(
|
|
|
|
is_moderated=Exists(
|
|
|
|
Picture.objects.filter(parent=OuterRef("pk"), is_moderated=True)
|
|
|
|
)
|
|
|
|
| ~Exists(Picture.objects.filter(parent=OuterRef("pk")))
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
class Album(SasFile):
|
|
|
|
NAME_MAX_LENGTH: ClassVar[int] = 50
|
|
|
|
|
|
|
|
name = models.CharField(_("name"), max_length=100)
|
|
|
|
parent = models.ForeignKey(
|
|
|
|
"self",
|
|
|
|
related_name="children",
|
|
|
|
verbose_name=_("parent"),
|
|
|
|
null=True,
|
|
|
|
blank=True,
|
|
|
|
on_delete=models.CASCADE,
|
|
|
|
)
|
|
|
|
thumbnail = models.FileField(
|
|
|
|
upload_to=get_thumbnail_directory, verbose_name=_("thumbnail"), max_length=256
|
|
|
|
)
|
|
|
|
view_groups = models.ManyToManyField(
|
|
|
|
Group, related_name="viewable_albums", verbose_name=_("view groups")
|
|
|
|
)
|
|
|
|
edit_groups = models.ManyToManyField(
|
|
|
|
Group, related_name="editable_albums", verbose_name=_("edit groups")
|
|
|
|
)
|
|
|
|
event_date = models.DateField(
|
|
|
|
_("event date"),
|
|
|
|
help_text=_("The date on which the photos in this album were taken"),
|
|
|
|
default=timezone.localdate,
|
|
|
|
)
|
|
|
|
|
|
|
|
objects = AlbumQuerySet.as_manager()
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
verbose_name = _("album")
|
|
|
|
constraints = [
|
|
|
|
models.UniqueConstraint(
|
|
|
|
fields=["name", "parent"], name="unique_album_name_if_same_parent"
|
|
|
|
)
|
|
|
|
]
|
|
|
|
|
|
|
|
def __str__(self):
|
|
|
|
return f"Album {self.name}"
|
|
|
|
|
|
|
|
def save(self, *args, **kwargs):
|
|
|
|
super().save(*args, **kwargs)
|
|
|
|
for user in User.objects.filter(
|
|
|
|
groups__id__in=[settings.SITH_GROUP_SAS_ADMIN_ID]
|
|
|
|
):
|
|
|
|
Notification(
|
|
|
|
user=user,
|
|
|
|
url=reverse("sas:moderation"),
|
|
|
|
type="SAS_MODERATION",
|
|
|
|
param="1",
|
|
|
|
).save()
|
|
|
|
|
|
|
|
def get_absolute_url(self):
|
|
|
|
return reverse("sas:album", kwargs={"album_id": self.id})
|
|
|
|
|
|
|
|
def get_download_url(self):
|
|
|
|
return reverse("sas:album_preview", kwargs={"album_id": self.id})
|
|
|
|
|
|
|
|
def generate_thumbnail(self):
|
|
|
|
p = self.pictures.order_by("?").first() or self.children.order_by("?").first()
|
|
|
|
if p and p.thumbnail:
|
|
|
|
self.thumbnail = p.thumbnail
|
|
|
|
self.thumbnail.name = f"{self.name}/thumb.webp"
|
|
|
|
self.save()
|
|
|
|
|
2024-08-08 11:15:19 +00:00
|
|
|
|
2024-08-06 10:37:50 +00:00
|
|
|
class PictureQuerySet(models.QuerySet):
|
2024-09-01 23:03:15 +00:00
|
|
|
def viewable_by(self, user: User) -> Self:
|
2024-08-06 10:37:50 +00:00
|
|
|
"""Filter the pictures that this user can view.
|
|
|
|
|
2024-09-17 21:42:05 +00:00
|
|
|
Warning:
|
2024-08-06 10:37:50 +00:00
|
|
|
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)
|
2024-08-06 10:37:50 +00:00
|
|
|
|
|
|
|
|
2025-01-26 11:51:54 +00:00
|
|
|
class Picture(SasFile):
|
|
|
|
name = models.CharField(_("file name"), max_length=256)
|
|
|
|
parent = models.ForeignKey(
|
|
|
|
Album,
|
|
|
|
related_name="pictures",
|
|
|
|
verbose_name=_("album"),
|
|
|
|
on_delete=models.CASCADE,
|
|
|
|
)
|
|
|
|
thumbnail = models.FileField(
|
|
|
|
upload_to=get_thumbnail_directory,
|
|
|
|
verbose_name=_("thumbnail"),
|
|
|
|
unique=True,
|
|
|
|
max_length=256,
|
|
|
|
)
|
|
|
|
original = models.FileField(
|
|
|
|
upload_to=get_directory,
|
|
|
|
verbose_name=_("original image"),
|
|
|
|
max_length=256,
|
|
|
|
unique=True,
|
|
|
|
)
|
|
|
|
compressed = models.FileField(
|
|
|
|
upload_to=get_compressed_directory,
|
|
|
|
verbose_name=_("compressed image"),
|
|
|
|
max_length=256,
|
|
|
|
unique=True,
|
|
|
|
)
|
|
|
|
created_at = models.DateTimeField(default=timezone.now)
|
|
|
|
owner = models.ForeignKey(
|
|
|
|
User,
|
|
|
|
related_name="owned_pictures",
|
|
|
|
verbose_name=_("owner"),
|
|
|
|
on_delete=models.PROTECT,
|
|
|
|
)
|
|
|
|
|
|
|
|
is_moderated = models.BooleanField(_("is moderated"), default=False)
|
|
|
|
asked_for_removal = models.BooleanField(_("asked for removal"), default=False)
|
|
|
|
moderator = models.ForeignKey(
|
|
|
|
User,
|
|
|
|
related_name="moderated_pictures",
|
|
|
|
null=True,
|
|
|
|
blank=True,
|
|
|
|
on_delete=models.SET_NULL,
|
|
|
|
)
|
2018-06-10 16:43:39 +00:00
|
|
|
|
2025-01-26 11:51:54 +00:00
|
|
|
objects = PictureQuerySet.as_manager()
|
2017-06-12 08:02:38 +00:00
|
|
|
|
2025-01-26 11:51:54 +00:00
|
|
|
def __str__(self):
|
|
|
|
return self.name
|
2016-10-26 17:21:19 +00:00
|
|
|
|
2025-01-26 11:51:54 +00:00
|
|
|
def save(self, *args, **kwargs):
|
|
|
|
if self._state.adding:
|
|
|
|
self.generate_thumbnails()
|
|
|
|
super().save(*args, **kwargs)
|
2017-09-25 10:34:05 +00:00
|
|
|
|
2025-01-26 11:51:54 +00:00
|
|
|
def get_absolute_url(self):
|
|
|
|
return reverse("sas:picture", kwargs={"picture_id": self.id})
|
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
|
|
|
|
2025-01-26 11:51:54 +00:00
|
|
|
@property
|
|
|
|
def is_vertical(self):
|
|
|
|
# original, compressed and thumbnail image have all three the same ratio,
|
|
|
|
# so the smallest one is used to tell if the image is vertical
|
|
|
|
im = Image.open(BytesIO(self.thumbnail.read()))
|
|
|
|
(w, h) = im.size
|
|
|
|
return w < h
|
|
|
|
|
|
|
|
def generate_thumbnails(self):
|
|
|
|
im = Image.open(self.original)
|
2024-10-15 09:36:26 +00:00
|
|
|
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
|
2024-10-02 21:15:37 +00:00
|
|
|
# the HD version of the image doesn't need to be optimized, because :
|
|
|
|
# - it isn't frequently queried
|
2025-01-26 11:51:54 +00:00
|
|
|
# - optimizing large images takes a lot of time, which greatly hinders the UX
|
2024-10-02 21:15:37 +00:00
|
|
|
# - photographers usually already optimize their images
|
2024-09-01 17:05:54 +00:00
|
|
|
thumb = resize_image(im, 200, "webp")
|
|
|
|
compressed = resize_image(im, 1200, "webp")
|
2016-11-20 23:02:40 +00:00
|
|
|
self.thumbnail = thumb
|
2025-01-26 11:51:54 +00:00
|
|
|
self.thumbnail.name = self.name
|
2016-11-20 23:02:40 +00:00
|
|
|
self.compressed = compressed
|
2025-01-26 11:51:54 +00:00
|
|
|
self.compressed.name = self.name
|
2016-11-20 23:02:40 +00:00
|
|
|
|
2016-11-20 12:39:04 +00:00
|
|
|
def rotate(self, degree):
|
2025-01-26 11:51:54 +00:00
|
|
|
for field in self.original, self.compressed, self.thumbnail:
|
|
|
|
with open(field.file, "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
|
|
|
|
2018-06-10 16:43:39 +00:00
|
|
|
|
2017-09-25 10:35:56 +00:00
|
|
|
def sas_notification_callback(notif):
|
|
|
|
count = Picture.objects.filter(is_moderated=False).count()
|
|
|
|
if count:
|
|
|
|
notif.viewed = False
|
|
|
|
else:
|
|
|
|
notif.viewed = True
|
2017-10-15 09:59:54 +00:00
|
|
|
notif.param = "%s" % count
|
|
|
|
notif.date = timezone.now()
|
2017-06-12 08:02:38 +00:00
|
|
|
|
2018-06-10 16:43:39 +00:00
|
|
|
|
2016-11-19 16:19:00 +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(
|
2019-10-05 17:05:56 +00:00
|
|
|
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",
|
2019-10-05 17:05:56 +00:00
|
|
|
on_delete=models.CASCADE,
|
2018-10-04 19:29:19 +00:00
|
|
|
)
|
2016-11-19 16:19:00 +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()}"
|