Coverage for website/photos/models.py: 78.40%
103 statements
« prev ^ index » next coverage.py v7.6.12, created at 2026-06-21 23:59 +0000
« prev ^ index » next coverage.py v7.6.12, created at 2026-06-21 23:59 +0000
1import hashlib
2import logging
3import os
4import random
5from secrets import token_hex
7from django.conf import settings
8from django.core.exceptions import ValidationError
9from django.db import models
10from django.db.models import Count, IntegerField, Value
11from django.db.models.functions import Coalesce
12from django.urls import reverse
13from django.utils.functional import cached_property
14from django.utils.translation import gettext_lazy as _
16from queryable_properties.managers import QueryablePropertiesManager
17from queryable_properties.properties import AnnotationProperty
18from thumbnails.fields import ImageField
20from members.models import Member
22COVER_FILENAME = "cover.jpg"
25logger = logging.getLogger(__name__)
28def photo_uploadto(instance, filename):
29 ext = os.path.splitext(filename)[1]
30 position = str(instance).split("-")[0]
31 return f"photos/{instance.album.dirname}/{position}-{token_hex(8)}{ext}"
34class DuplicatePhotoException(Exception):
35 """Raised when a photo with the same digest already exists in a given album."""
38class Photo(models.Model):
39 """Model for a Photo object."""
41 objects = QueryablePropertiesManager()
43 album = models.ForeignKey(
44 "Album", on_delete=models.CASCADE, verbose_name=_("album")
45 )
47 file = ImageField(
48 _("file"),
49 upload_to=photo_uploadto,
50 resize_source_to="source",
51 pregenerated_sizes=["small", "medium", "photo_medium", "photo_large"],
52 )
54 _digest = models.CharField(
55 "digest",
56 max_length=40,
57 blank=True,
58 editable=False,
59 )
61 num_likes = AnnotationProperty(
62 Coalesce(Count("likes"), Value(0), output_field=IntegerField())
63 )
65 def __init__(self, *args, **kwargs):
66 """Initialize Photo object and set the file if it exists."""
67 super().__init__(*args, **kwargs)
68 if self.file:
69 self.original_file = self.file.name
70 else:
71 self.original_file = ""
73 def __str__(self):
74 """Return the filename of a Photo object."""
75 return os.path.basename(self.file.name)
77 def clean(self):
78 if not self.file._committed:
79 hash_sha1 = hashlib.sha1()
80 for chunk in iter(lambda: self.file.read(4096), b""):
81 hash_sha1.update(chunk)
82 digest = hash_sha1.hexdigest()
83 self._digest = digest
85 if (
86 Photo.objects.filter(album=self.album, _digest=digest)
87 .exclude(pk=self.pk)
88 .exists()
89 ):
90 raise ValidationError(
91 {"file": "This photo already exists in this album."}
92 )
94 return super().clean()
96 class Meta:
97 """Meta class for Photo."""
99 # Photos are created in order of their filename.
100 ordering = ("pk",)
103class Like(models.Model):
104 photo = models.ForeignKey(
105 Photo, null=False, blank=False, related_name="likes", on_delete=models.CASCADE
106 )
107 member = models.ForeignKey(
108 Member, null=True, blank=False, on_delete=models.SET_NULL
109 )
111 def __str__(self):
112 return str(self.member) + " " + _("likes") + " " + str(self.photo)
114 class Meta:
115 unique_together = ["photo", "member"]
118class Album(models.Model):
119 """Model for Album objects."""
121 title = models.CharField(
122 _("title"),
123 blank=True,
124 max_length=200,
125 help_text=_("Leave empty to take over the title of the event"),
126 )
128 dirname = models.CharField(
129 verbose_name=_("directory name"),
130 max_length=200,
131 )
133 date = models.DateField(
134 verbose_name=_("date"),
135 blank=True,
136 help_text=_("Leave empty to take over the date of the event"),
137 )
139 slug = models.SlugField(
140 verbose_name=_("slug"),
141 unique=True,
142 )
144 hidden = models.BooleanField(verbose_name=_("hidden"), default=False)
146 is_processing = models.BooleanField(
147 verbose_name="is processing",
148 help_text="This album is hidden until fully uploaded",
149 default=False,
150 editable=False,
151 )
153 event = models.ForeignKey(
154 "events.Event",
155 on_delete=models.SET_NULL,
156 blank=True,
157 null=True,
158 )
160 _cover = models.OneToOneField(
161 Photo,
162 on_delete=models.SET_NULL,
163 blank=True,
164 null=True,
165 related_name="covered_album",
166 verbose_name=_("cover image"),
167 )
169 shareable = models.BooleanField(verbose_name=_("shareable"), default=False)
171 photosdir = "photos"
172 photospath = os.path.join(settings.MEDIA_ROOT, photosdir)
174 @cached_property
175 def cover(self):
176 """Return cover of Album.
178 If a cover is not set, return a random photo or None if there are no photos.
179 """
180 if self._cover is not None: 180 ↛ 181line 180 didn't jump to line 181 because the condition on line 180 was never true
181 return self._cover
183 # Not prefetched because this should be rare and is a lot of data
184 r = random.Random(self.dirname)
185 try:
186 return r.choice(self.photo_set.all())
187 except IndexError:
188 return None
190 def __str__(self):
191 """Get string representation of Album."""
192 return f"{self.date:%Y-%m-%d} {self.title}"
194 def get_absolute_url(self):
195 """Get url of Album."""
196 return reverse("photos:album", args=[str(self.slug)])
198 def clean(self):
199 super().clean()
200 errors = {}
202 if not self.title and not self.event: 202 ↛ 203line 202 didn't jump to line 203 because the condition on line 202 was never true
203 errors.update(
204 {"title": _("This field is required if there is no event selected.")}
205 )
207 if not self.date and not self.event: 207 ↛ 208line 207 didn't jump to line 208 because the condition on line 207 was never true
208 errors.update(
209 {"date": _("This field is required if there is no event selected.")}
210 )
212 if errors: 212 ↛ 213line 212 didn't jump to line 213 because the condition on line 212 was never true
213 raise ValidationError(errors)
215 def save(self, **kwargs):
216 """Save album and send appropriate notifications."""
217 # dirname is only set for new objects, to avoid ever changing it
218 if self.pk is None:
219 self.dirname = self.slug
221 if not self.title and self.event: 221 ↛ 222line 221 didn't jump to line 222 because the condition on line 221 was never true
222 self.title = self.event.title
224 if not self.date: 224 ↛ 225line 224 didn't jump to line 225 because the condition on line 224 was never true
225 self.date = self.event.start.date()
227 super().save(**kwargs)
229 @property
230 def access_token(self):
231 """Return access token for album."""
232 return hashlib.sha256(
233 f"{settings.SECRET_KEY}album{self.pk}".encode()
234 ).hexdigest()
236 class Meta:
237 """Meta class for Album."""
239 ordering = ("-date", "title")