mirror of
https://github.com/ae-utbm/sith.git
synced 2026-06-05 07:39:21 +00:00
Migrate albums and pictures to their own tables
This commit is contained in:
+208
-18
@@ -15,6 +15,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import ClassVar, Self
|
||||
|
||||
@@ -24,22 +26,44 @@ from django.core.files.base import ContentFile
|
||||
from django.db import models
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from PIL import Image
|
||||
|
||||
from core.models import Notification, SithFile, User
|
||||
from core.models import Group, Notification, User
|
||||
from core.utils import resize_image
|
||||
|
||||
|
||||
class SasFile(SithFile):
|
||||
"""Proxy model for any file in the SAS.
|
||||
def get_directory(instance: SasFile, filename: str):
|
||||
return f"./{instance.parent_path}/{filename}"
|
||||
|
||||
May be used to have logic that should be shared by both
|
||||
|
||||
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
|
||||
|
||||
This model is used to have logic that should be shared by both
|
||||
[Picture][sas.models.Picture] and [Album][sas.models.Album].
|
||||
|
||||
Notes:
|
||||
This is an abstract model.
|
||||
[Album][sas.models.Album] and [Picture][sas.models.Picture]
|
||||
are separated tables in the database.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
proxy = True
|
||||
abstract = True
|
||||
permissions = [
|
||||
("moderate_sasfile", "Can moderate SAS files"),
|
||||
("view_unmoderated_sasfile", "Can view not moderated SAS files"),
|
||||
@@ -64,6 +88,121 @@ class SasFile(SithFile):
|
||||
def can_be_edited_by(self, user):
|
||||
return user.has_perm("sas.change_sasfile")
|
||||
|
||||
@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(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,
|
||||
)
|
||||
is_moderated = models.BooleanField(_("is moderated"), default=False)
|
||||
|
||||
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()
|
||||
|
||||
|
||||
class PictureQuerySet(models.QuerySet):
|
||||
def viewable_by(self, user: User) -> Self:
|
||||
@@ -79,16 +218,62 @@ class PictureQuerySet(models.QuerySet):
|
||||
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)
|
||||
|
||||
|
||||
class Picture(SasFile):
|
||||
class Meta:
|
||||
proxy = True
|
||||
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,
|
||||
)
|
||||
|
||||
objects = SASPictureManager.from_queryset(PictureQuerySet)()
|
||||
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,
|
||||
)
|
||||
|
||||
objects = PictureQuerySet.as_manager()
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self._state.adding:
|
||||
self.generate_thumbnails()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("sas:picture", kwargs={"picture_id": self.id})
|
||||
|
||||
def get_download_url(self):
|
||||
return reverse(
|
||||
@@ -111,8 +296,13 @@ class Picture(SasFile):
|
||||
query={"date": int(self.updated_at.timestamp())},
|
||||
)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("sas:picture", kwargs={"picture_id": self.id})
|
||||
@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, *, img: Image.Image | None = None, save: bool = False
|
||||
@@ -122,13 +312,13 @@ class Picture(SasFile):
|
||||
Args:
|
||||
img: if given, this will be used to generate
|
||||
all three images (file, compressed, thumbnail).
|
||||
Else, `self.file` will be used
|
||||
Else, `self.original` will be used
|
||||
save: if True, save the instance in database.
|
||||
"""
|
||||
img = img or Image.open(self.file)
|
||||
img = img or Image.open(self.original)
|
||||
extension = self.mime_type.split("/")[-1]
|
||||
previous_files = [
|
||||
f.name for f in (self.file, self.thumbnail, self.compressed) if f
|
||||
f.name for f in (self.original, self.thumbnail, self.compressed) if f
|
||||
]
|
||||
# convert the compressed image and the thumbnail into webp
|
||||
# The original image keeps its original type, because it's not
|
||||
|
||||
Reference in New Issue
Block a user