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

1import hashlib 

2import logging 

3import os 

4import random 

5from secrets import token_hex 

6 

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 _ 

15 

16from queryable_properties.managers import QueryablePropertiesManager 

17from queryable_properties.properties import AnnotationProperty 

18from thumbnails.fields import ImageField 

19 

20from members.models import Member 

21 

22COVER_FILENAME = "cover.jpg" 

23 

24 

25logger = logging.getLogger(__name__) 

26 

27 

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}" 

32 

33 

34class DuplicatePhotoException(Exception): 

35 """Raised when a photo with the same digest already exists in a given album.""" 

36 

37 

38class Photo(models.Model): 

39 """Model for a Photo object.""" 

40 

41 objects = QueryablePropertiesManager() 

42 

43 album = models.ForeignKey( 

44 "Album", on_delete=models.CASCADE, verbose_name=_("album") 

45 ) 

46 

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 ) 

53 

54 _digest = models.CharField( 

55 "digest", 

56 max_length=40, 

57 blank=True, 

58 editable=False, 

59 ) 

60 

61 num_likes = AnnotationProperty( 

62 Coalesce(Count("likes"), Value(0), output_field=IntegerField()) 

63 ) 

64 

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 = "" 

72 

73 def __str__(self): 

74 """Return the filename of a Photo object.""" 

75 return os.path.basename(self.file.name) 

76 

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 

84 

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 ) 

93 

94 return super().clean() 

95 

96 class Meta: 

97 """Meta class for Photo.""" 

98 

99 # Photos are created in order of their filename. 

100 ordering = ("pk",) 

101 

102 

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 ) 

110 

111 def __str__(self): 

112 return str(self.member) + " " + _("likes") + " " + str(self.photo) 

113 

114 class Meta: 

115 unique_together = ["photo", "member"] 

116 

117 

118class Album(models.Model): 

119 """Model for Album objects.""" 

120 

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 ) 

127 

128 dirname = models.CharField( 

129 verbose_name=_("directory name"), 

130 max_length=200, 

131 ) 

132 

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 ) 

138 

139 slug = models.SlugField( 

140 verbose_name=_("slug"), 

141 unique=True, 

142 ) 

143 

144 hidden = models.BooleanField(verbose_name=_("hidden"), default=False) 

145 

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 ) 

152 

153 event = models.ForeignKey( 

154 "events.Event", 

155 on_delete=models.SET_NULL, 

156 blank=True, 

157 null=True, 

158 ) 

159 

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 ) 

168 

169 shareable = models.BooleanField(verbose_name=_("shareable"), default=False) 

170 

171 photosdir = "photos" 

172 photospath = os.path.join(settings.MEDIA_ROOT, photosdir) 

173 

174 @cached_property 

175 def cover(self): 

176 """Return cover of Album. 

177 

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 

182 

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 

189 

190 def __str__(self): 

191 """Get string representation of Album.""" 

192 return f"{self.date:%Y-%m-%d} {self.title}" 

193 

194 def get_absolute_url(self): 

195 """Get url of Album.""" 

196 return reverse("photos:album", args=[str(self.slug)]) 

197 

198 def clean(self): 

199 super().clean() 

200 errors = {} 

201 

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 ) 

206 

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 ) 

211 

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) 

214 

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 

220 

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 

223 

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() 

226 

227 super().save(**kwargs) 

228 

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() 

235 

236 class Meta: 

237 """Meta class for Album.""" 

238 

239 ordering = ("-date", "title")