Coverage for website/photos/models.py: 78.23%
102 statements
« prev ^ index » next coverage.py v7.6.7, created at 2025-08-14 10:31 +0000
« prev ^ index » next coverage.py v7.6.7, created at 2025-08-14 10:31 +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 return f"photos/{instance.album.dirname}/{token_hex(8)}{ext}"
33class DuplicatePhotoException(Exception):
34 """Raised when a photo with the same digest already exists in a given album."""
37class Photo(models.Model):
38 """Model for a Photo object."""
40 objects = QueryablePropertiesManager()
42 album = models.ForeignKey(
43 "Album", on_delete=models.CASCADE, verbose_name=_("album")
44 )
46 file = ImageField(
47 _("file"),
48 upload_to=photo_uploadto,
49 resize_source_to="source",
50 pregenerated_sizes=["small", "medium", "photo_medium", "photo_large"],
51 )
53 _digest = models.CharField(
54 "digest",
55 max_length=40,
56 blank=True,
57 editable=False,
58 )
60 num_likes = AnnotationProperty(
61 Coalesce(Count("likes"), Value(0), output_field=IntegerField())
62 )
64 def __init__(self, *args, **kwargs):
65 """Initialize Photo object and set the file if it exists."""
66 super().__init__(*args, **kwargs)
67 if self.file:
68 self.original_file = self.file.name
69 else:
70 self.original_file = ""
72 def __str__(self):
73 """Return the filename of a Photo object."""
74 return os.path.basename(self.file.name)
76 def clean(self):
77 if not self.file._committed:
78 hash_sha1 = hashlib.sha1()
79 for chunk in iter(lambda: self.file.read(4096), b""):
80 hash_sha1.update(chunk)
81 digest = hash_sha1.hexdigest()
82 self._digest = digest
84 if (
85 Photo.objects.filter(album=self.album, _digest=digest)
86 .exclude(pk=self.pk)
87 .exists()
88 ):
89 raise ValidationError(
90 {"file": "This photo already exists in this album."}
91 )
93 return super().clean()
95 class Meta:
96 """Meta class for Photo."""
98 # Photos are created in order of their filename.
99 ordering = ("pk",)
102class Like(models.Model):
103 photo = models.ForeignKey(
104 Photo, null=False, blank=False, related_name="likes", on_delete=models.CASCADE
105 )
106 member = models.ForeignKey(
107 Member, null=True, blank=False, on_delete=models.SET_NULL
108 )
110 def __str__(self):
111 return str(self.member) + " " + _("likes") + " " + str(self.photo)
113 class Meta:
114 unique_together = ["photo", "member"]
117class Album(models.Model):
118 """Model for Album objects."""
120 title = models.CharField(
121 _("title"),
122 blank=True,
123 max_length=200,
124 help_text=_("Leave empty to take over the title of the event"),
125 )
127 dirname = models.CharField(
128 verbose_name=_("directory name"),
129 max_length=200,
130 )
132 date = models.DateField(
133 verbose_name=_("date"),
134 blank=True,
135 help_text=_("Leave empty to take over the date of the event"),
136 )
138 slug = models.SlugField(
139 verbose_name=_("slug"),
140 unique=True,
141 )
143 hidden = models.BooleanField(verbose_name=_("hidden"), default=False)
145 is_processing = models.BooleanField(
146 verbose_name="is processing",
147 help_text="This album is hidden until fully uploaded",
148 default=False,
149 editable=False,
150 )
152 event = models.ForeignKey(
153 "events.Event",
154 on_delete=models.SET_NULL,
155 blank=True,
156 null=True,
157 )
159 _cover = models.OneToOneField(
160 Photo,
161 on_delete=models.SET_NULL,
162 blank=True,
163 null=True,
164 related_name="covered_album",
165 verbose_name=_("cover image"),
166 )
168 shareable = models.BooleanField(verbose_name=_("shareable"), default=False)
170 photosdir = "photos"
171 photospath = os.path.join(settings.MEDIA_ROOT, photosdir)
173 @cached_property
174 def cover(self):
175 """Return cover of Album.
177 If a cover is not set, return a random photo or None if there are no photos.
178 """
179 if self._cover is not None: 179 ↛ 180line 179 didn't jump to line 180 because the condition on line 179 was never true
180 return self._cover
182 # Not prefetched because this should be rare and is a lot of data
183 r = random.Random(self.dirname)
184 try:
185 return r.choice(self.photo_set.all())
186 except IndexError:
187 return None
189 def __str__(self):
190 """Get string representation of Album."""
191 return f"{self.date:%Y-%m-%d} {self.title}"
193 def get_absolute_url(self):
194 """Get url of Album."""
195 return reverse("photos:album", args=[str(self.slug)])
197 def clean(self):
198 super().clean()
199 errors = {}
201 if not self.title and not self.event: 201 ↛ 202line 201 didn't jump to line 202 because the condition on line 201 was never true
202 errors.update(
203 {"title": _("This field is required if there is no event selected.")}
204 )
206 if not self.date and not self.event: 206 ↛ 207line 206 didn't jump to line 207 because the condition on line 206 was never true
207 errors.update(
208 {"date": _("This field is required if there is no event selected.")}
209 )
211 if errors: 211 ↛ 212line 211 didn't jump to line 212 because the condition on line 211 was never true
212 raise ValidationError(errors)
214 def save(self, **kwargs):
215 """Save album and send appropriate notifications."""
216 # dirname is only set for new objects, to avoid ever changing it
217 if self.pk is None:
218 self.dirname = self.slug
220 if not self.title and self.event: 220 ↛ 221line 220 didn't jump to line 221 because the condition on line 220 was never true
221 self.title = self.event.title
223 if not self.date: 223 ↛ 224line 223 didn't jump to line 224 because the condition on line 223 was never true
224 self.date = self.event.start.date()
226 super().save(**kwargs)
228 @property
229 def access_token(self):
230 """Return access token for album."""
231 return hashlib.sha256(
232 f"{settings.SECRET_KEY}album{self.pk}".encode()
233 ).hexdigest()
235 class Meta:
236 """Meta class for Album."""
238 ordering = ("-date", "title")