We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
Thumbnails 'Source' objects are not removed when deleting an album
### Describe the bug

Currently when an album is deleted, the Source objects (from django-thumbnails) of its photos aren't deleted. Then, if an album with the same name is uploaded again, we get IntegrityErrors when trying to create photos with the same filenames that already existed. 

### How to reproduce
<!-- Steps to reproduce the behaviour -->
1. Upload album
2. Delete it
3. Upload the same album again

### Expected behaviour
The Source's are deleted, so there is no integrityerror.

### Additional context
https://thalia.sentry.io/issues/4543169553/events/239a90e83abd437e9d7116b45ec30b9a/

This issue would be solved by making photos filenames be uuid's (#3442), and just fixing this single issue won't fix all problems we have with reuploading an album, as there is another problem:
Reuploading different photos with the same name does not invalidate the cloudfront cache. So If we do that within 24 hours, facedetection would likely analyze the wrong (stale) cached files, and they would also show up to users. So we also need to either make the filenames unique, or implement cloudfront cache invalidation. Clearly, unique filenames are the easier way to go.


--- END ISSUE ---

Below are some code segments, each from a relevant file. One or more of these files may contain bugs.
--- BEGIN FILES ---
Path: `website/photos/models.py`
Content:
```
1 import hashlib
2 import logging
3 import os
4 import random
5 from secrets import token_hex
6 
7 from django.conf import settings
8 from django.core.exceptions import ValidationError
9 from django.db import models
10 from django.db.models import Count, IntegerField, Value
11 from django.db.models.functions import Coalesce
12 from django.urls import reverse
13 from django.utils.functional import cached_property
14 from django.utils.translation import gettext_lazy as _
15 
16 from queryable_properties.managers import QueryablePropertiesManager
17 from queryable_properties.properties import AnnotationProperty
18 from thumbnails.fields import ImageField
19 
20 from members.models import Member
21 
22 COVER_FILENAME = "cover.jpg"
23 
24 
25 logger = logging.getLogger(__name__)
26 
27 
28 def photo_uploadto(instance, filename):
29     ext = os.path.splitext(filename)[1]
30     return f"photos/{instance.album.dirname}/{token_hex(8)}{ext}"
31 
32 
33 class DuplicatePhotoException(Exception):
34     """Raised when a photo with the same digest already exists in a given album."""
35 
36 
37 class Photo(models.Model):
38     """Model for a Photo object."""
39 
40     objects = QueryablePropertiesManager()
41 
42     album = models.ForeignKey(
43         "Album", on_delete=models.CASCADE, verbose_name=_("album")
44     )
45 
46     file = ImageField(
47         _("file"),
48         upload_to=photo_uploadto,
49         resize_source_to="source",
50     )
51 
52     rotation = models.IntegerField(
53         verbose_name=_("rotation"),
54         default=0,
55         choices=((x, x) for x in (0, 90, 180, 270)),
56         help_text=_("This does not modify the original image file."),
57     )
58 
59     _digest = models.CharField(
60         "digest",
61         max_length=40,
62         blank=True,
63         editable=False,
64     )
65 
66     num_likes = AnnotationProperty(
67         Coalesce(Count("likes"), Value(0), output_field=IntegerField())
68     )
69 
70     def __init__(self, *args, **kwargs):
71         """Initialize Photo object and set the file if it exists."""
72         super().__init__(*args, **kwargs)
73         if self.file:
74             self.original_file = self.file.name
75         else:
76             self.original_file = ""
77 
78     def __str__(self):
79         """Return the filename of a Photo object."""
80         return os.path.basename(self.file.name)
81 
82     def clean(self):
83         if not self.file._committed:
84             hash_sha1 = hashlib.sha1()
85             for chunk in iter(lambda: self.file.read(4096), b""):
86                 hash_sha1.update(chunk)
87             digest = hash_sha1.hexdigest()
88             self._digest = digest
89 
90             if (
91                 Photo.objects.filter(album=self.album, _digest=digest)
92                 .exclude(pk=self.pk)
93                 .exists()
94             ):
95                 raise ValidationError(
96                     {"file": "This photo already exists in this album."}
97                 )
98 
99         return super().clean()
100 
101     def delete(self, using=None, keep_parents=False):
102         removed = super().delete(using, keep_parents)
103         if self.file.name:
104             self.file.delete()
105         return removed
106 
107     class Meta:
108         """Meta class for Photo."""
109 
110         # Photos are created in order of their filename.
111         ordering = ("pk",)
112 
113 
114 class Like(models.Model):
115     photo = models.ForeignKey(
116         Photo, null=False, blank=False, related_name="likes", on_delete=models.CASCADE
117     )
118     member = models.ForeignKey(
119         Member, null=True, blank=False, on_delete=models.SET_NULL
120     )
121 
122     def __str__(self):
123         return str(self.member) + " " + _("likes") + " " + str(self.photo)
124 
125     class Meta:
126         unique_together = ["photo", "member"]
127 
128 
129 class Album(models.Model):
130     """Model for Album objects."""
131 
132     title = models.CharField(
133         _("title"),
134         blank=True,
135         max_length=200,
136         help_text=_("Leave empty to take over the title of the event"),
137     )
138 
139     dirname = models.CharField(
140         verbose_name=_("directory name"),
141         max_length=200,
142     )
143 
144     date = models.DateField(
145         verbose_name=_("date"),
146         blank=True,
147         help_text=_("Leave empty to take over the date of the event"),
148     )
149 
150     slug = models.SlugField(
151         verbose_name=_("slug"),
152         unique=True,
153     )
154 
155     hidden = models.BooleanField(verbose_name=_("hidden"), default=False)
156 
157     event = models.ForeignKey(
158         "events.Event",
159         on_delete=models.SET_NULL,
160         blank=True,
161         null=True,
162     )
163 
164     _cover = models.OneToOneField(
165         Photo,
166         on_delete=models.SET_NULL,
167         blank=True,
168         null=True,
169         related_name="covered_album",
170         verbose_name=_("cover image"),
171     )
172 
173     shareable = models.BooleanField(verbose_name=_("shareable"), default=False)
174 
175     photosdir = "photos"
176     photospath = os.path.join(settings.MEDIA_ROOT, photosdir)
177 
178     @cached_property
179     def cover(self):
180         """Return cover of Album.
181 
182         If a cover is not set, return a random photo or None if there are no photos.
183         """
184         cover = None
185         if self._cover is not None:
186             return self._cover
187 
188         # Not prefetched because this should be rare and is a lot of data
189         # `exists` is faster in theory, but requires everything to be fetched later anyways
190         if self.photo_set.exists():
191             r = random.Random(self.dirname)
192             cover = r.choice(self.photo_set.all())
193         return cover
194 
195     def __str__(self):
196         """Get string representation of Album."""
197         return f"{self.date:%Y-%m-%d} {self.title}"
198 
199     def get_absolute_url(self):
200         """Get url of Album."""
201         return reverse("photos:album", args=[str(self.slug)])
202 
203     def clean(self):
204         super().clean()
205         errors = {}
206 
207         if not self.title and not self.event:
208             errors.update(
209                 {"title": _("This field is required if there is no event selected.")}
210             )
211 
212         if not self.date and not self.event:
213             errors.update(
214                 {"date": _("This field is required if there is no event selected.")}
215             )
216 
217         if errors:
218             raise ValidationError(errors)
219 
220     def save(self, **kwargs):
221         """Save album and send appropriate notifications."""
222         # dirname is only set for new objects, to avoid ever changing it
223         if self.pk is None:
224             self.dirname = self.slug
225 
226         if not self.title and self.event:
227             self.title = self.event.title
228 
229         if not self.date:
230             self.date = self.event.start.date()
231 
232         super().save(**kwargs)
233 
234     @property
235     def access_token(self):
236         """Return access token for album."""
237         return hashlib.sha256(
238             f"{settings.SECRET_KEY}album{self.pk}".encode()
239         ).hexdigest()
240 
241     class Meta:
242         """Meta class for Album."""
243 
244         ordering = ("-date", "title")
245
```

Path: `website/photos/signals.py`
Content:
```
1 from django.db.models.signals import pre_delete
2 
3 from utils.models.signals import suspendingreceiver
4 
5 
6 @suspendingreceiver(
7     pre_delete, sender="photos.Photo", dispatch_uid="photos_photo_delete"
8 )
9 def pre_photo_delete(sender, instance, **kwargs):
10     """Remove main photo file on deletion."""
11     instance.file.delete()
12
```
--- END FILES ---

Please first localize the bug based on the issue statement, and then generate a patch according to the `git diff` format fenced by three backticks.

Here is an example:

```diff
diff --git a/examples/server_async.py b/examples/server_async.py
--- a/examples/server_async.py
+++ b/examples/server_async.py
@@ -313,4 +313,4 @@
 
 
 if __name__ == "__main__":
-    asyncio.run(run_async_server("."), debug=True)
+    asyncio.run(run_async_server(), debug=True)
diff --git a/examples/server_sync.py b/examples/server_sync.py
--- a/examples/server_sync.py
+++ b/examples/server_sync.py
@@ -313,5 +313,5 @@
 
 
 if __name__ == "__main__":
-    server = run_sync_server(".")
+    server = run_sync_server()
     server.shutdown()

```

diff --git a/website/photos/models.py b/website/photos/models.py
--- a/website/photos/models.py
+++ b/website/photos/models.py
@@ -98,12 +98,6 @@
 
         return super().clean()
 
-    def delete(self, using=None, keep_parents=False):
-        removed = super().delete(using, keep_parents)
-        if self.file.name:
-            self.file.delete()
-        return removed
-
     class Meta:
         """Meta class for Photo."""
 
diff --git a/website/photos/signals.py b/website/photos/signals.py
--- a/website/photos/signals.py
+++ b/website/photos/signals.py
@@ -7,5 +7,9 @@
     pre_delete, sender="photos.Photo", dispatch_uid="photos_photo_delete"
 )
 def pre_photo_delete(sender, instance, **kwargs):
-    """Remove main photo file on deletion."""
-    instance.file.delete()
+    """Remove main photo file and thumbnails on deletion."""
+    name = instance.file.name  # First get the name, it is removed by the next line.
+    instance.file.delete()  # Delete the file and its thumbnails.
+
+    # Clean up the source metadata, django-thumbnails does not do this.
+    instance.file.metadata_backend.delete_source(name)

{"golden_diff": "diff --git a/website/photos/models.py b/website/photos/models.py\n--- a/website/photos/models.py\n+++ b/website/photos/models.py\n@@ -98,12 +98,6 @@\n \n         return super().clean()\n \n-    def delete(self, using=None, keep_parents=False):\n-        removed = super().delete(using, keep_parents)\n-        if self.file.name:\n-            self.file.delete()\n-        return removed\n-\n     class Meta:\n         \"\"\"Meta class for Photo.\"\"\"\n \ndiff --git a/website/photos/signals.py b/website/photos/signals.py\n--- a/website/photos/signals.py\n+++ b/website/photos/signals.py\n@@ -7,5 +7,9 @@\n     pre_delete, sender=\"photos.Photo\", dispatch_uid=\"photos_photo_delete\"\n )\n def pre_photo_delete(sender, instance, **kwargs):\n-    \"\"\"Remove main photo file on deletion.\"\"\"\n-    instance.file.delete()\n+    \"\"\"Remove main photo file and thumbnails on deletion.\"\"\"\n+    name = instance.file.name  # First get the name, it is removed by the next line.\n+    instance.file.delete()  # Delete the file and its thumbnails.\n+\n+    # Clean up the source metadata, django-thumbnails does not do this.\n+    instance.file.metadata_backend.delete_source(name)\n", "issue": "Thumbnails 'Source' objects are not removed when deleting an album\n### Describe the bug\r\n\r\nCurrently when an album is deleted, the Source objects (from django-thumbnails) of its photos aren't deleted. Then, if an album with the same name is uploaded again, we get IntegrityErrors when trying to create photos with the same filenames that already existed. \r\n\r\n### How to reproduce\r\n<!-- Steps to reproduce the behaviour -->\r\n1. Upload album\r\n2. Delete it\r\n3. Upload the same album again\r\n\r\n### Expected behaviour\r\nThe Source's are deleted, so there is no integrityerror.\r\n\r\n### Additional context\r\nhttps://thalia.sentry.io/issues/4543169553/events/239a90e83abd437e9d7116b45ec30b9a/\r\n\r\nThis issue would be solved by making photos filenames be uuid's (#3442), and just fixing this single issue won't fix all problems we have with reuploading an album, as there is another problem:\r\nReuploading different photos with the same name does not invalidate the cloudfront cache. So If we do that within 24 hours, facedetection would likely analyze the wrong (stale) cached files, and they would also show up to users. So we also need to either make the filenames unique, or implement cloudfront cache invalidation. Clearly, unique filenames are the easier way to go.\r\n\n", "before_files": [{"content": "import hashlib\nimport logging\nimport os\nimport random\nfrom secrets import token_hex\n\nfrom django.conf import settings\nfrom django.core.exceptions import ValidationError\nfrom django.db import models\nfrom django.db.models import Count, IntegerField, Value\nfrom django.db.models.functions import Coalesce\nfrom django.urls import reverse\nfrom django.utils.functional import cached_property\nfrom django.utils.translation import gettext_lazy as _\n\nfrom queryable_properties.managers import QueryablePropertiesManager\nfrom queryable_properties.properties import AnnotationProperty\nfrom thumbnails.fields import ImageField\n\nfrom members.models import Member\n\nCOVER_FILENAME = \"cover.jpg\"\n\n\nlogger = logging.getLogger(__name__)\n\n\ndef photo_uploadto(instance, filename):\n    ext = os.path.splitext(filename)[1]\n    return f\"photos/{instance.album.dirname}/{token_hex(8)}{ext}\"\n\n\nclass DuplicatePhotoException(Exception):\n    \"\"\"Raised when a photo with the same digest already exists in a given album.\"\"\"\n\n\nclass Photo(models.Model):\n    \"\"\"Model for a Photo object.\"\"\"\n\n    objects = QueryablePropertiesManager()\n\n    album = models.ForeignKey(\n        \"Album\", on_delete=models.CASCADE, verbose_name=_(\"album\")\n    )\n\n    file = ImageField(\n        _(\"file\"),\n        upload_to=photo_uploadto,\n        resize_source_to=\"source\",\n    )\n\n    rotation = models.IntegerField(\n        verbose_name=_(\"rotation\"),\n        default=0,\n        choices=((x, x) for x in (0, 90, 180, 270)),\n        help_text=_(\"This does not modify the original image file.\"),\n    )\n\n    _digest = models.CharField(\n        \"digest\",\n        max_length=40,\n        blank=True,\n        editable=False,\n    )\n\n    num_likes = AnnotationProperty(\n        Coalesce(Count(\"likes\"), Value(0), output_field=IntegerField())\n    )\n\n    def __init__(self, *args, **kwargs):\n        \"\"\"Initialize Photo object and set the file if it exists.\"\"\"\n        super().__init__(*args, **kwargs)\n        if self.file:\n            self.original_file = self.file.name\n        else:\n            self.original_file = \"\"\n\n    def __str__(self):\n        \"\"\"Return the filename of a Photo object.\"\"\"\n        return os.path.basename(self.file.name)\n\n    def clean(self):\n        if not self.file._committed:\n            hash_sha1 = hashlib.sha1()\n            for chunk in iter(lambda: self.file.read(4096), b\"\"):\n                hash_sha1.update(chunk)\n            digest = hash_sha1.hexdigest()\n            self._digest = digest\n\n            if (\n                Photo.objects.filter(album=self.album, _digest=digest)\n                .exclude(pk=self.pk)\n                .exists()\n            ):\n                raise ValidationError(\n                    {\"file\": \"This photo already exists in this album.\"}\n                )\n\n        return super().clean()\n\n    def delete(self, using=None, keep_parents=False):\n        removed = super().delete(using, keep_parents)\n        if self.file.name:\n            self.file.delete()\n        return removed\n\n    class Meta:\n        \"\"\"Meta class for Photo.\"\"\"\n\n        # Photos are created in order of their filename.\n        ordering = (\"pk\",)\n\n\nclass Like(models.Model):\n    photo = models.ForeignKey(\n        Photo, null=False, blank=False, related_name=\"likes\", on_delete=models.CASCADE\n    )\n    member = models.ForeignKey(\n        Member, null=True, blank=False, on_delete=models.SET_NULL\n    )\n\n    def __str__(self):\n        return str(self.member) + \" \" + _(\"likes\") + \" \" + str(self.photo)\n\n    class Meta:\n        unique_together = [\"photo\", \"member\"]\n\n\nclass Album(models.Model):\n    \"\"\"Model for Album objects.\"\"\"\n\n    title = models.CharField(\n        _(\"title\"),\n        blank=True,\n        max_length=200,\n        help_text=_(\"Leave empty to take over the title of the event\"),\n    )\n\n    dirname = models.CharField(\n        verbose_name=_(\"directory name\"),\n        max_length=200,\n    )\n\n    date = models.DateField(\n        verbose_name=_(\"date\"),\n        blank=True,\n        help_text=_(\"Leave empty to take over the date of the event\"),\n    )\n\n    slug = models.SlugField(\n        verbose_name=_(\"slug\"),\n        unique=True,\n    )\n\n    hidden = models.BooleanField(verbose_name=_(\"hidden\"), default=False)\n\n    event = models.ForeignKey(\n        \"events.Event\",\n        on_delete=models.SET_NULL,\n        blank=True,\n        null=True,\n    )\n\n    _cover = models.OneToOneField(\n        Photo,\n        on_delete=models.SET_NULL,\n        blank=True,\n        null=True,\n        related_name=\"covered_album\",\n        verbose_name=_(\"cover image\"),\n    )\n\n    shareable = models.BooleanField(verbose_name=_(\"shareable\"), default=False)\n\n    photosdir = \"photos\"\n    photospath = os.path.join(settings.MEDIA_ROOT, photosdir)\n\n    @cached_property\n    def cover(self):\n        \"\"\"Return cover of Album.\n\n        If a cover is not set, return a random photo or None if there are no photos.\n        \"\"\"\n        cover = None\n        if self._cover is not None:\n            return self._cover\n\n        # Not prefetched because this should be rare and is a lot of data\n        # `exists` is faster in theory, but requires everything to be fetched later anyways\n        if self.photo_set.exists():\n            r = random.Random(self.dirname)\n            cover = r.choice(self.photo_set.all())\n        return cover\n\n    def __str__(self):\n        \"\"\"Get string representation of Album.\"\"\"\n        return f\"{self.date:%Y-%m-%d} {self.title}\"\n\n    def get_absolute_url(self):\n        \"\"\"Get url of Album.\"\"\"\n        return reverse(\"photos:album\", args=[str(self.slug)])\n\n    def clean(self):\n        super().clean()\n        errors = {}\n\n        if not self.title and not self.event:\n            errors.update(\n                {\"title\": _(\"This field is required if there is no event selected.\")}\n            )\n\n        if not self.date and not self.event:\n            errors.update(\n                {\"date\": _(\"This field is required if there is no event selected.\")}\n            )\n\n        if errors:\n            raise ValidationError(errors)\n\n    def save(self, **kwargs):\n        \"\"\"Save album and send appropriate notifications.\"\"\"\n        # dirname is only set for new objects, to avoid ever changing it\n        if self.pk is None:\n            self.dirname = self.slug\n\n        if not self.title and self.event:\n            self.title = self.event.title\n\n        if not self.date:\n            self.date = self.event.start.date()\n\n        super().save(**kwargs)\n\n    @property\n    def access_token(self):\n        \"\"\"Return access token for album.\"\"\"\n        return hashlib.sha256(\n            f\"{settings.SECRET_KEY}album{self.pk}\".encode()\n        ).hexdigest()\n\n    class Meta:\n        \"\"\"Meta class for Album.\"\"\"\n\n        ordering = (\"-date\", \"title\")\n", "path": "website/photos/models.py"}, {"content": "from django.db.models.signals import pre_delete\n\nfrom utils.models.signals import suspendingreceiver\n\n\n@suspendingreceiver(\n    pre_delete, sender=\"photos.Photo\", dispatch_uid=\"photos_photo_delete\"\n)\ndef pre_photo_delete(sender, instance, **kwargs):\n    \"\"\"Remove main photo file on deletion.\"\"\"\n    instance.file.delete()\n", "path": "website/photos/signals.py"}], "after_files": [{"content": "import hashlib\nimport logging\nimport os\nimport random\nfrom secrets import token_hex\n\nfrom django.conf import settings\nfrom django.core.exceptions import ValidationError\nfrom django.db import models\nfrom django.db.models import Count, IntegerField, Value\nfrom django.db.models.functions import Coalesce\nfrom django.urls import reverse\nfrom django.utils.functional import cached_property\nfrom django.utils.translation import gettext_lazy as _\n\nfrom queryable_properties.managers import QueryablePropertiesManager\nfrom queryable_properties.properties import AnnotationProperty\nfrom thumbnails.fields import ImageField\n\nfrom members.models import Member\n\nCOVER_FILENAME = \"cover.jpg\"\n\n\nlogger = logging.getLogger(__name__)\n\n\ndef photo_uploadto(instance, filename):\n    ext = os.path.splitext(filename)[1]\n    return f\"photos/{instance.album.dirname}/{token_hex(8)}{ext}\"\n\n\nclass DuplicatePhotoException(Exception):\n    \"\"\"Raised when a photo with the same digest already exists in a given album.\"\"\"\n\n\nclass Photo(models.Model):\n    \"\"\"Model for a Photo object.\"\"\"\n\n    objects = QueryablePropertiesManager()\n\n    album = models.ForeignKey(\n        \"Album\", on_delete=models.CASCADE, verbose_name=_(\"album\")\n    )\n\n    file = ImageField(\n        _(\"file\"),\n        upload_to=photo_uploadto,\n        resize_source_to=\"source\",\n    )\n\n    rotation = models.IntegerField(\n        verbose_name=_(\"rotation\"),\n        default=0,\n        choices=((x, x) for x in (0, 90, 180, 270)),\n        help_text=_(\"This does not modify the original image file.\"),\n    )\n\n    _digest = models.CharField(\n        \"digest\",\n        max_length=40,\n        blank=True,\n        editable=False,\n    )\n\n    num_likes = AnnotationProperty(\n        Coalesce(Count(\"likes\"), Value(0), output_field=IntegerField())\n    )\n\n    def __init__(self, *args, **kwargs):\n        \"\"\"Initialize Photo object and set the file if it exists.\"\"\"\n        super().__init__(*args, **kwargs)\n        if self.file:\n            self.original_file = self.file.name\n        else:\n            self.original_file = \"\"\n\n    def __str__(self):\n        \"\"\"Return the filename of a Photo object.\"\"\"\n        return os.path.basename(self.file.name)\n\n    def clean(self):\n        if not self.file._committed:\n            hash_sha1 = hashlib.sha1()\n            for chunk in iter(lambda: self.file.read(4096), b\"\"):\n                hash_sha1.update(chunk)\n            digest = hash_sha1.hexdigest()\n            self._digest = digest\n\n            if (\n                Photo.objects.filter(album=self.album, _digest=digest)\n                .exclude(pk=self.pk)\n                .exists()\n            ):\n                raise ValidationError(\n                    {\"file\": \"This photo already exists in this album.\"}\n                )\n\n        return super().clean()\n\n    class Meta:\n        \"\"\"Meta class for Photo.\"\"\"\n\n        # Photos are created in order of their filename.\n        ordering = (\"pk\",)\n\n\nclass Like(models.Model):\n    photo = models.ForeignKey(\n        Photo, null=False, blank=False, related_name=\"likes\", on_delete=models.CASCADE\n    )\n    member = models.ForeignKey(\n        Member, null=True, blank=False, on_delete=models.SET_NULL\n    )\n\n    def __str__(self):\n        return str(self.member) + \" \" + _(\"likes\") + \" \" + str(self.photo)\n\n    class Meta:\n        unique_together = [\"photo\", \"member\"]\n\n\nclass Album(models.Model):\n    \"\"\"Model for Album objects.\"\"\"\n\n    title = models.CharField(\n        _(\"title\"),\n        blank=True,\n        max_length=200,\n        help_text=_(\"Leave empty to take over the title of the event\"),\n    )\n\n    dirname = models.CharField(\n        verbose_name=_(\"directory name\"),\n        max_length=200,\n    )\n\n    date = models.DateField(\n        verbose_name=_(\"date\"),\n        blank=True,\n        help_text=_(\"Leave empty to take over the date of the event\"),\n    )\n\n    slug = models.SlugField(\n        verbose_name=_(\"slug\"),\n        unique=True,\n    )\n\n    hidden = models.BooleanField(verbose_name=_(\"hidden\"), default=False)\n\n    event = models.ForeignKey(\n        \"events.Event\",\n        on_delete=models.SET_NULL,\n        blank=True,\n        null=True,\n    )\n\n    _cover = models.OneToOneField(\n        Photo,\n        on_delete=models.SET_NULL,\n        blank=True,\n        null=True,\n        related_name=\"covered_album\",\n        verbose_name=_(\"cover image\"),\n    )\n\n    shareable = models.BooleanField(verbose_name=_(\"shareable\"), default=False)\n\n    photosdir = \"photos\"\n    photospath = os.path.join(settings.MEDIA_ROOT, photosdir)\n\n    @cached_property\n    def cover(self):\n        \"\"\"Return cover of Album.\n\n        If a cover is not set, return a random photo or None if there are no photos.\n        \"\"\"\n        cover = None\n        if self._cover is not None:\n            return self._cover\n\n        # Not prefetched because this should be rare and is a lot of data\n        # `exists` is faster in theory, but requires everything to be fetched later anyways\n        if self.photo_set.exists():\n            r = random.Random(self.dirname)\n            cover = r.choice(self.photo_set.all())\n        return cover\n\n    def __str__(self):\n        \"\"\"Get string representation of Album.\"\"\"\n        return f\"{self.date:%Y-%m-%d} {self.title}\"\n\n    def get_absolute_url(self):\n        \"\"\"Get url of Album.\"\"\"\n        return reverse(\"photos:album\", args=[str(self.slug)])\n\n    def clean(self):\n        super().clean()\n        errors = {}\n\n        if not self.title and not self.event:\n            errors.update(\n                {\"title\": _(\"This field is required if there is no event selected.\")}\n            )\n\n        if not self.date and not self.event:\n            errors.update(\n                {\"date\": _(\"This field is required if there is no event selected.\")}\n            )\n\n        if errors:\n            raise ValidationError(errors)\n\n    def save(self, **kwargs):\n        \"\"\"Save album and send appropriate notifications.\"\"\"\n        # dirname is only set for new objects, to avoid ever changing it\n        if self.pk is None:\n            self.dirname = self.slug\n\n        if not self.title and self.event:\n            self.title = self.event.title\n\n        if not self.date:\n            self.date = self.event.start.date()\n\n        super().save(**kwargs)\n\n    @property\n    def access_token(self):\n        \"\"\"Return access token for album.\"\"\"\n        return hashlib.sha256(\n            f\"{settings.SECRET_KEY}album{self.pk}\".encode()\n        ).hexdigest()\n\n    class Meta:\n        \"\"\"Meta class for Album.\"\"\"\n\n        ordering = (\"-date\", \"title\")\n", "path": "website/photos/models.py"}, {"content": "from django.db.models.signals import pre_delete\n\nfrom utils.models.signals import suspendingreceiver\n\n\n@suspendingreceiver(\n    pre_delete, sender=\"photos.Photo\", dispatch_uid=\"photos_photo_delete\"\n)\ndef pre_photo_delete(sender, instance, **kwargs):\n    \"\"\"Remove main photo file and thumbnails on deletion.\"\"\"\n    name = instance.file.name  # First get the name, it is removed by the next line.\n    instance.file.delete()  # Delete the file and its thumbnails.\n\n    # Clean up the source metadata, django-thumbnails does not do this.\n    instance.file.metadata_backend.delete_source(name)\n", "path": "website/photos/signals.py"}]}