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
« prev ^ index » next coverage.py v7.6.7, created at 2025-08-14 10:31 +0000
1import logging
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 _
11from thumbnails.fields import ImageField
13from utils import countries
14from utils.media.services import get_upload_to_function
16logger = logging.getLogger(__name__)
18_profile_image_path = get_upload_to_function("avatars", 16)
21class Profile(models.Model):
22 """This class holds extra information about a member."""
24 # No longer yearly membership as a type, use expiration date instead.
25 PROGRAMME_CHOICES = (
26 ("computingscience", _("Computing Science")),
27 ("informationscience", _("Information Sciences")),
28 )
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 )
37 # ----- Registration information -----
39 programme = models.CharField(
40 max_length=20,
41 choices=PROGRAMME_CHOICES,
42 verbose_name=_("Study programme"),
43 blank=True,
44 null=True,
45 )
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 )
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 )
68 # ---- Address information -----
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 )
83 address_street2 = models.CharField(
84 max_length=100,
85 verbose_name=_("Second address line"),
86 blank=True,
87 null=True,
88 )
90 address_postal_code = models.CharField(
91 max_length=10,
92 verbose_name=_("Postal code"),
93 null=True,
94 blank=True,
95 )
97 address_city = models.CharField(
98 max_length=40,
99 verbose_name=_("City"),
100 null=True,
101 blank=True,
102 )
104 address_country = models.CharField(
105 max_length=2,
106 choices=countries.EUROPE,
107 verbose_name=_("Country"),
108 null=True,
109 blank=True,
110 )
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 )
126 # ---- Emergency contact ----
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 )
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 )
150 # ---- Personal information ------
152 birthday = models.DateField(verbose_name=_("Birthday"), null=True, blank=True)
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 )
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 )
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 )
179 initials = models.CharField(
180 max_length=20,
181 verbose_name=_("Initials"),
182 blank=True,
183 null=True,
184 )
186 nickname = models.CharField(
187 max_length=30,
188 verbose_name=_("Nickname"),
189 blank=True,
190 null=True,
191 )
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 )
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 )
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 )
231 # --- Communication preference ----
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 )
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 )
247 receive_newsletter = models.BooleanField(
248 verbose_name=_("Receive newsletter"),
249 help_text=_("Receive the Thalia Newsletter"),
250 default=True,
251 )
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 )
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 )
272 is_minimized = models.BooleanField(
273 verbose_name="The data from this profile has been minimized", default=False
274 )
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
292 display_name.short_description = _("Display name")
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
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
311 def clean(self):
312 super().clean()
313 errors = {}
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")
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 )
334 if self.birthday and self.birthday > timezone.now().date():
335 errors.update({"birthday": _("A birthday cannot be in the future.")})
337 if errors:
338 raise ValidationError(errors)
340 def save(self, **kwargs):
341 storage = self.photo.storage
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)
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
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)
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
372 def __str__(self):
373 return _("Profile for {}").format(self.user)