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

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 return f"photos/{instance.album.dirname}/{token_hex(8)}{ext}" 

31 

32 

33class DuplicatePhotoException(Exception): 

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

35 

36 

37class 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 pregenerated_sizes=["small", "medium", "photo_medium", "photo_large"], 

51 ) 

52 

53 _digest = models.CharField( 

54 "digest", 

55 max_length=40, 

56 blank=True, 

57 editable=False, 

58 ) 

59 

60 num_likes = AnnotationProperty( 

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

62 ) 

63 

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

71 

72 def __str__(self): 

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

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

75 

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 

83 

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 ) 

92 

93 return super().clean() 

94 

95 class Meta: 

96 """Meta class for Photo.""" 

97 

98 # Photos are created in order of their filename. 

99 ordering = ("pk",) 

100 

101 

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 ) 

109 

110 def __str__(self): 

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

112 

113 class Meta: 

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

115 

116 

117class Album(models.Model): 

118 """Model for Album objects.""" 

119 

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 ) 

126 

127 dirname = models.CharField( 

128 verbose_name=_("directory name"), 

129 max_length=200, 

130 ) 

131 

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 ) 

137 

138 slug = models.SlugField( 

139 verbose_name=_("slug"), 

140 unique=True, 

141 ) 

142 

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

144 

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 ) 

151 

152 event = models.ForeignKey( 

153 "events.Event", 

154 on_delete=models.SET_NULL, 

155 blank=True, 

156 null=True, 

157 ) 

158 

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 ) 

167 

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

169 

170 photosdir = "photos" 

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

172 

173 @cached_property 

174 def cover(self): 

175 """Return cover of Album. 

176 

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 

181 

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 

188 

189 def __str__(self): 

190 """Get string representation of Album.""" 

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

192 

193 def get_absolute_url(self): 

194 """Get url of Album.""" 

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

196 

197 def clean(self): 

198 super().clean() 

199 errors = {} 

200 

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 ) 

205 

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 ) 

210 

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) 

213 

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 

219 

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 

222 

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

225 

226 super().save(**kwargs) 

227 

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

234 

235 class Meta: 

236 """Meta class for Album.""" 

237 

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