Coverage for website/members/models/profile.py: 75.36%

100 statements  

« prev     ^ index     » next       coverage.py v7.6.7, created at 2025-08-14 10:31 +0000

1import logging 

2 

3from django.conf import settings 

4from django.core import validators 

5from django.core.exceptions import ValidationError 

6from django.core.files.storage import storages 

7from django.db import models 

8from django.utils import timezone 

9from django.utils.translation import gettext_lazy as _ 

10 

11from thumbnails.fields import ImageField 

12 

13from utils import countries 

14from utils.media.services import get_upload_to_function 

15 

16logger = logging.getLogger(__name__) 

17 

18_profile_image_path = get_upload_to_function("avatars", 16) 

19 

20 

21class Profile(models.Model): 

22 """This class holds extra information about a member.""" 

23 

24 # No longer yearly membership as a type, use expiration date instead. 

25 PROGRAMME_CHOICES = ( 

26 ("computingscience", _("Computing Science")), 

27 ("informationscience", _("Information Sciences")), 

28 ) 

29 

30 # Preferably this would have been a foreign key to Member instead, 

31 # but the UserAdmin requires that this is a foreign key to User. 

32 user = models.OneToOneField( 

33 settings.AUTH_USER_MODEL, 

34 on_delete=models.CASCADE, 

35 ) 

36 

37 # ----- Registration information ----- 

38 

39 programme = models.CharField( 

40 max_length=20, 

41 choices=PROGRAMME_CHOICES, 

42 verbose_name=_("Study programme"), 

43 blank=True, 

44 null=True, 

45 ) 

46 

47 student_number = models.CharField( 

48 verbose_name=_("Student number"), 

49 max_length=8, 

50 validators=[ 

51 validators.RegexValidator( 

52 regex=r"(s\d{7}|[ezu]\d{6,7})", 

53 message=_("Enter a valid student- or e/z/u-number."), 

54 ) 

55 ], 

56 blank=True, 

57 null=True, 

58 unique=True, 

59 ) 

60 

61 starting_year = models.IntegerField( 

62 verbose_name=_("Starting year"), 

63 help_text=_("The year this member started studying."), 

64 blank=True, 

65 null=True, 

66 ) 

67 

68 # ---- Address information ----- 

69 

70 address_street = models.CharField( 

71 max_length=100, 

72 validators=[ 

73 validators.RegexValidator( 

74 regex=r"^.+ \d+.*", 

75 message=_("please use the format <street> <number>"), 

76 ) 

77 ], 

78 verbose_name=_("Street and house number"), 

79 null=True, 

80 blank=True, 

81 ) 

82 

83 address_street2 = models.CharField( 

84 max_length=100, 

85 verbose_name=_("Second address line"), 

86 blank=True, 

87 null=True, 

88 ) 

89 

90 address_postal_code = models.CharField( 

91 max_length=10, 

92 verbose_name=_("Postal code"), 

93 null=True, 

94 blank=True, 

95 ) 

96 

97 address_city = models.CharField( 

98 max_length=40, 

99 verbose_name=_("City"), 

100 null=True, 

101 blank=True, 

102 ) 

103 

104 address_country = models.CharField( 

105 max_length=2, 

106 choices=countries.EUROPE, 

107 verbose_name=_("Country"), 

108 null=True, 

109 blank=True, 

110 ) 

111 

112 phone_number = models.CharField( 

113 max_length=20, 

114 verbose_name=_("Phone number"), 

115 help_text=_("Enter a phone number so Thalia may reach you"), 

116 validators=[ 

117 validators.RegexValidator( 

118 regex=r"^\+?\d+$", 

119 message=_("Please enter a valid phone number"), 

120 ) 

121 ], 

122 null=True, 

123 blank=True, 

124 ) 

125 

126 # ---- Emergency contact ---- 

127 

128 emergency_contact = models.CharField( 

129 max_length=255, 

130 verbose_name=_("Emergency contact name"), 

131 help_text=_("Who should we contact in case of emergencies"), 

132 null=True, 

133 blank=True, 

134 ) 

135 

136 emergency_contact_phone_number = models.CharField( 

137 max_length=20, 

138 verbose_name=_("Emergency contact phone number"), 

139 help_text=_("The phone number for the emergency contact"), 

140 validators=[ 

141 validators.RegexValidator( 

142 regex=r"^\+?\d+$", 

143 message=_("Please enter a valid phone number"), 

144 ) 

145 ], 

146 null=True, 

147 blank=True, 

148 ) 

149 

150 # ---- Personal information ------ 

151 

152 birthday = models.DateField(verbose_name=_("Birthday"), null=True, blank=True) 

153 

154 show_birthday = models.BooleanField( 

155 verbose_name=_("Display birthday"), 

156 help_text=_( 

157 "Show your birthday to other members on your profile page and " 

158 "in the birthday calendar" 

159 ), 

160 default=True, 

161 ) 

162 

163 website = models.URLField( 

164 max_length=200, 

165 verbose_name=_("Website"), 

166 help_text=_("Website to display on your profile page"), 

167 blank=True, 

168 null=True, 

169 ) 

170 

171 profile_description = models.TextField( 

172 verbose_name=_("Profile text"), 

173 help_text=_("Text to display on your profile"), 

174 blank=True, 

175 null=True, 

176 max_length=4096, 

177 ) 

178 

179 initials = models.CharField( 

180 max_length=20, 

181 verbose_name=_("Initials"), 

182 blank=True, 

183 null=True, 

184 ) 

185 

186 nickname = models.CharField( 

187 max_length=30, 

188 verbose_name=_("Nickname"), 

189 blank=True, 

190 null=True, 

191 ) 

192 

193 display_name_preference = models.CharField( 

194 max_length=10, 

195 verbose_name=_("How to display name"), 

196 choices=( 

197 ("full", _("Show full name")), 

198 ("nickname", _("Show only nickname")), 

199 ("firstname", _("Show only first name")), 

200 ("initials", _("Show initials and last name")), 

201 ("fullnick", _("Show name like \"John 'nickname' Doe\"")), 

202 ("nicklast", _("Show nickname and last name")), 

203 ), 

204 default="full", 

205 ) 

206 

207 photo = ImageField( 

208 verbose_name=_("Photo"), 

209 help_text=_( 

210 "Note that your photo may be publicly visible and indexable by search engines in some cases. This happens when you are in a committee with publicly visible members." 

211 ), 

212 resize_source_to="source", 

213 upload_to=_profile_image_path, 

214 storage=storages["public"], 

215 null=True, 

216 blank=True, 

217 ) 

218 

219 event_permissions = models.CharField( 

220 max_length=9, 

221 verbose_name=_("Which events can this member attend"), 

222 choices=( 

223 ("all", _("All events")), 

224 ("no_events", _("User may not attend events")), 

225 ("no_drinks", _("User may not attend drinks")), 

226 ("nothing", _("User may not attend anything")), 

227 ), 

228 default="all", 

229 ) 

230 

231 # --- Communication preference ---- 

232 

233 receive_optin = models.BooleanField( 

234 verbose_name=_("Receive opt-in mailings"), 

235 help_text=_( 

236 "Receive mailings about vacancies and events from Thalia's partners." 

237 ), 

238 default=True, 

239 ) 

240 

241 receive_registration_confirmation = models.BooleanField( 

242 verbose_name=_("Receive registration confirmations"), 

243 help_text=_("Receive confirmation emails when registering for events"), 

244 default=True, 

245 ) 

246 

247 receive_newsletter = models.BooleanField( 

248 verbose_name=_("Receive newsletter"), 

249 help_text=_("Receive the Thalia Newsletter"), 

250 default=True, 

251 ) 

252 

253 receive_oldmembers = models.BooleanField( 

254 verbose_name=_("Receive alumni emails"), 

255 help_text=_( 

256 "If you are a past member, receive emails about Thalia events aimed at alumni." 

257 ), 

258 default=True, 

259 ) 

260 

261 # --- Active Member preference --- 

262 email_gsuite_only = models.BooleanField( 

263 verbose_name=_("Only receive Thalia emails on G Suite-account"), 

264 help_text=_( 

265 "If you enable this option you will no longer receive " 

266 "emails sent to you by Thalia on your personal email " 

267 "address. We will only use your G Suite email address." 

268 ), 

269 default=False, 

270 ) 

271 

272 is_minimized = models.BooleanField( 

273 verbose_name="The data from this profile has been minimized", default=False 

274 ) 

275 

276 def display_name(self): 

277 pref = self.display_name_preference 

278 if pref == "nickname" and self.nickname is not None: 

279 return f"'{self.nickname}'" 

280 if pref == "firstname": 

281 return self.user.first_name 

282 if pref == "initials": 

283 if self.initials: 

284 return f"{self.initials} {self.user.last_name}" 

285 return self.user.last_name 

286 if pref == "fullnick" and self.nickname is not None: 

287 return f"{self.user.first_name} '{self.nickname}' {self.user.last_name}" 

288 if pref == "nicklast" and self.nickname is not None: 

289 return f"'{self.nickname}' {self.user.last_name}" 

290 return self.user.get_full_name() or self.user.username 

291 

292 display_name.short_description = _("Display name") 

293 

294 def short_display_name(self): 

295 pref = self.display_name_preference 

296 if self.nickname is not None and pref in ("nickname", "nicklast"): 

297 return f"'{self.nickname}'" 

298 if pref == "initials": 

299 if self.initials: 

300 return f"{self.initials} {self.user.last_name}" 

301 return self.user.last_name 

302 return self.user.first_name 

303 

304 def __init__(self, *args, **kwargs): 

305 super().__init__(*args, **kwargs) 

306 if self.photo: 306 ↛ 307line 306 didn't jump to line 307 because the condition on line 306 was never true

307 self._orig_image = self.photo.name 

308 else: 

309 self._orig_image = None 

310 

311 def clean(self): 

312 super().clean() 

313 errors = {} 

314 

315 if not self.is_minimized and not ( 

316 self.address_street 

317 or self.address_postal_code 

318 or self.address_city 

319 or self.address_country 

320 or self.birthday 

321 ): 

322 raise ValidationError("Field cannot be blank") 

323 

324 if self.display_name_preference in ("nickname", "fullnick", "nicklast"): 

325 if not self.nickname: 

326 errors.update( 

327 { 

328 "nickname": _( 

329 "You need to enter a nickname to use it as display name" 

330 ) 

331 } 

332 ) 

333 

334 if self.birthday and self.birthday > timezone.now().date(): 

335 errors.update({"birthday": _("A birthday cannot be in the future.")}) 

336 

337 if errors: 

338 raise ValidationError(errors) 

339 

340 def save(self, **kwargs): 

341 storage = self.photo.storage 

342 

343 if any( 

344 [ 

345 self.student_number, 

346 self.phone_number, 

347 self.address_street, 

348 self.address_street2, 

349 self.address_postal_code, 

350 self.address_city, 

351 self.address_country, 

352 self.birthday, 

353 self.emergency_contact_phone_number, 

354 self.emergency_contact, 

355 ] 

356 ): 

357 self.is_minimized = False 

358 super().save(**kwargs) 

359 

360 if self._orig_image and not self.photo: 360 ↛ 361line 360 didn't jump to line 361 because the condition on line 360 was never true

361 storage.delete(self._orig_image) 

362 self._orig_image = None 

363 

364 elif self.photo and self._orig_image != self.photo.name: 364 ↛ 365line 364 didn't jump to line 365 because the condition on line 364 was never true

365 super().save(**kwargs) 

366 

367 if self._orig_image: 

368 logger.info("deleting: %s", self._orig_image) 

369 storage.delete(self._orig_image) 

370 self._orig_image = self.photo.name 

371 

372 def __str__(self): 

373 return _("Profile for {}").format(self.user)