Migrate albums and pictures to their own tables

This commit is contained in:
imperosol
2025-01-26 12:51:54 +01:00
parent 99d85e0361
commit dc8a678e39
23 changed files with 1490 additions and 137 deletions
+208 -18
View File
@@ -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