pretalx person.models.user.py

user.py

  1import json
  2import random
  3from hashlib import md5
  4
  5import pytz
  6from django.conf import settings
  7from django.contrib.auth.models import AbstractBaseUser
  8from django.contrib.auth.models import BaseUserManager
  9from django.contrib.auth.models import PermissionsMixin
 10from django.contrib.contenttypes.models import ContentType
 11from django.db import models
 12from django.db import transaction
 13from django.db.models import Q
 14from django.utils.crypto import get_random_string
 15from django.utils.functional import cached_property
 16from django.utils.timezone import now
 17from django.utils.translation import get_language
 18from django.utils.translation import override
 19from django.utils.translation import ugettext_lazy as _
 20from pretalx.common.urls import build_absolute_uri
 21from rest_framework.authtoken.models import Token
 22
 23
 24class UserManager(BaseUserManager):
 25    """The user manager class."""
 26
 27    def create_user(self, password: str = None, **kwargs):
 28        user = self.model(**kwargs)
 29        user.set_password(password)
 30        user.save()
 31        return user
 32
 33    def create_superuser(self, password: str, **kwargs):
 34        user = self.create_user(password=password, **kwargs)
 35        user.is_staff = True
 36        user.is_administrator = True
 37        user.is_superuser = False
 38        user.save(update_fields=["is_staff", "is_administrator", "is_superuser"])
 39        return user
 40
 41
 42def assign_code(obj, length=6):
 43    # This omits some character pairs completely because they are hard to read even on screens (1/I and O/0)
 44    # and includes only one of two characters for some pairs because they are sometimes hard to distinguish in
 45    # handwriting (2/Z, 4/A, 5/S, 6/G).
 46    while True:
 47        code = get_random_string(length=length, allowed_chars=User.CODE_CHARSET)
 48
 49        if not User.objects.filter(code__iexact=code).exists():
 50            obj.code = code
 51            return code
 52
 53
 54class User(PermissionsMixin, AbstractBaseUser):
 55    """
 56    The pretalx user model.
 57
 58    Users describe all kinds of persons who interact with pretalx: Organisers, reviewers, submitters, speakers.
 59
 60    :param code: A user's alphanumeric code is autogenerated, may not be
 61        changed, and is the unique identifier of that user.
 62    :param name: A name fit for public display. Will be used in the user
 63        interface and for public display for all speakers in all of their
 64        events.
 65    :param password: The password is stored using Django's PasswordField. Use
 66        the ``set_password`` and ``check_password`` methods to interact with it.
 67    :param nick: The nickname field has been deprecated and is scheduled to be
 68        deleted. Use the email field instead.
 69    :param groups: Django internals, not used in pretalx.
 70    :param user_permissions: Django internals, not used in pretalx.
 71    """
 72
 73    EMAIL_FIELD = "email"
 74    USERNAME_FIELD = "email"
 75    CODE_CHARSET = list("ABCDEFGHJKLMNPQRSTUVWXYZ3789")
 76
 77    objects = UserManager()
 78
 79    code = models.CharField(max_length=16, unique=True, null=True)
 80    nick = models.CharField(max_length=60, null=True, blank=True)
 81    name = models.CharField(
 82        max_length=120,
 83        verbose_name=_("Name"),
 84        help_text=_(
 85            "Please enter the name you wish to be displayed publicly. This name will be used for all events you are participating in on this server."
 86        ),
 87    )
 88    email = models.EmailField(
 89        unique=True,
 90        verbose_name=_("E-Mail"),
 91        help_text=_(
 92            "Your email address will be used for password resets and notification about your event/submissions."
 93        ),
 94    )
 95    is_active = models.BooleanField(
 96        default=True, help_text="Inactive users are not allowed to log in."
 97    )
 98    is_staff = models.BooleanField(
 99        default=False, help_text="A default Django flag. Not in use in pretalx."
100    )
101    is_administrator = models.BooleanField(
102        default=False,
103        help_text="Should only be ``True`` for people with administrative access to the server pretalx runs on.",
104    )
105    is_superuser = models.BooleanField(
106        default=False,
107        help_text="Never set this flag to ``True``, since it short-circuits all authorization mechanisms.",
108    )
109    locale = models.CharField(
110        max_length=32,
111        default=settings.LANGUAGE_CODE,
112        choices=settings.LANGUAGES,
113        verbose_name=_("Preferred language"),
114    )
115    timezone = models.CharField(
116        choices=[(tz, tz) for tz in pytz.common_timezones], max_length=30, default="UTC"
117    )
118    avatar = models.ImageField(
119        null=True,
120        blank=True,
121        verbose_name=_("Profile picture"),
122        help_text=_("If possible, upload an image that is least 120 pixels wide."),
123    )
124    get_gravatar = models.BooleanField(
125        default=False,
126        verbose_name=_("Retrieve profile picture via gravatar"),
127        help_text=_(
128            "If you have registered with an email address that has a gravatar account, we can retrieve your profile picture from there."
129        ),
130    )
131    pw_reset_token = models.CharField(
132        null=True, max_length=160, verbose_name="Password reset token"
133    )
134    pw_reset_time = models.DateTimeField(null=True, verbose_name="Password reset time")
135
136    def __str__(self) -> str:
137        """Not for public consumption as it includes the email address."""
138        return (
139            self.name + f" <{self.email}>"
140            if self.name
141            else self.email or str(_("Unnamed user"))
142        )
143
144    def get_display_name(self) -> str:
145        """Returns a user's name or 'Unnamed user'."""
146        return self.name if self.name else str(_("Unnamed user"))
147
148    def save(self, *args, **kwargs):
149        self.email = self.email.lower().strip()
150        if not self.code:
151            assign_code(self)
152        return super().save(args, kwargs)
153
154    def event_profile(self, event):
155        """Retrieve (and/or create) the event :class:`~pretalx.person.models.profile.SpeakerProfile` for this user.
156
157        :type event: :class:`pretalx.event.models.event.Event`
158        :retval: :class:`pretalx.person.models.profile.EventProfile`
159        """
160        from pretalx.person.models.profile import SpeakerProfile
161
162        profile = self.profiles.select_related("event").filter(event=event).first()
163        if profile:
164            return profile
165        profile = SpeakerProfile(event=event, user=self)
166        if self.pk:
167            profile.save()
168        return profile
169
170    def log_action(
171        self, action: str, data: dict = None, person=None, orga: bool = False
172    ):
173        """Create a log entry for this user.
174
175        :param action: The log action that took place.
176        :param data: Addition data to be saved.
177        :param person: The person modifying this user. Defaults to this user.
178        :type person: :class:`~pretalx.person.models.user.User`
179        :param orga: Was this action initiated by a privileged user?"""
180        from pretalx.common.models import ActivityLog
181
182        if data:
183            data = json.dumps(data)
184
185        ActivityLog.objects.create(
186            person=person or self,
187            content_object=self,
188            action_type=action,
189            data=data,
190            is_orga_action=orga,
191        )
192
193    def logged_actions(self):
194        """Returns all log entries that were made about this user."""
195        from pretalx.common.models import ActivityLog
196
197        return ActivityLog.objects.filter(
198            content_type=ContentType.objects.get_for_model(type(self)),
199            object_id=self.pk,
200        )
201
202    def own_actions(self):
203        """Returns all log entries that were made by this user."""
204        from pretalx.common.models import ActivityLog
205
206        return ActivityLog.objects.filter(person=self)
207
208    def deactivate(self):
209        """Delete the user by unsetting all of their information."""
210        from pretalx.submission.models import Answer
211
212        self.email = f"deleted_user_{random.randint(0, 999)}@localhost"
213        while self.__class__.objects.filter(email__iexact=self.email).exists():
214            self.email = f"deleted_user_{random.randint(0, 999)}"
215        self.name = "Deleted User"
216        self.is_active = False
217        self.is_superuser = False
218        self.is_administrator = False
219        self.locale = "en"
220        self.timezone = "UTC"
221        self.pw_reset_token = None
222        self.pw_reset_time = None
223        self.save()
224        self.profiles.all().update(biography="")
225        Answer.objects.filter(
226            person=self, question__contains_personal_data=True
227        ).delete()
228        for team in self.teams.all():
229            team.members.remove(self)
230
231    @cached_property
232    def gravatar_parameter(self) -> str:
233        return md5(self.email.strip().encode()).hexdigest()
234
235    @cached_property
236    def has_avatar(self) -> bool:
237        return self.get_gravatar or self.has_local_avatar
238
239    @cached_property
240    def has_local_avatar(self) -> bool:
241        return self.avatar and self.avatar != "False"
242
243    def get_events_with_any_permission(self):
244        """Returns a queryset of events for which this user has any type of permission."""
245        from pretalx.event.models import Event
246
247        if self.is_administrator:
248            return Event.objects.all()
249
250        return Event.objects.filter(
251            Q(
252                organiser_id__in=self.teams.filter(all_events=True).values_list(
253                    "organiser", flat=True
254                )
255            )
256            | Q(id__in=self.teams.values_list("limit_events__id", flat=True))
257        )
258
259    def get_events_for_permission(self, **kwargs):
260        """Returns a queryset of events for which this user as all of the given permissions.
261
262        Permissions are given as named arguments, e.g. ``get_events_for_permission(is_reviewer=True)``."""
263        from pretalx.event.models import Event
264
265        if self.is_administrator:
266            return Event.objects.all()
267
268        orga_teams = self.teams.filter(**kwargs)
269        absolute = orga_teams.filter(all_events=True).values_list(
270            "organiser", flat=True
271        )
272        relative = orga_teams.filter(all_events=False).values_list(
273            "limit_events", flat=True
274        )
275        return Event.objects.filter(
276            models.Q(organiser__in=absolute) | models.Q(pk__in=relative)
277        ).distinct()
278
279    def get_permissions_for_event(self, event) -> set:
280        """Returns a set of all permission a user has for the given event.
281
282        :type event: :class:`~pretalx.event.models.event.Event`"""
283        if self.is_administrator:
284            return {
285                "can_create_events",
286                "can_change_teams",
287                "can_change_organiser_settings",
288                "can_change_event_settings",
289                "can_change_submissions",
290                "is_reviewer",
291            }
292        teams = event.teams.filter(members__in=[self])
293        if not teams:
294            return set()
295        return set().union(*[team.permission_set for team in teams])
296
297    def remaining_override_votes(self, event) -> int:
298        """Returns the amount of override votes a user may still give in reviews in the given event.
299
300        :type event: :class:`~pretalx.event.models.event.Event`
301        """
302        allowed = max(
303            event.teams.filter(members__in=[self], is_reviewer=True).values_list(
304                "review_override_votes", flat=True
305            )
306            or [0]
307        )
308        overridden = self.reviews.filter(
309            submission__event=event, override_vote__isnull=False
310        ).count()
311        return max(allowed - overridden, 0)
312
313    def regenerate_token(self) -> Token:
314        """Generates a new API access token, deleting the old one."""
315        self.log_action(action="pretalx.user.token.reset")
316        Token.objects.filter(user=self).delete()
317        return Token.objects.create(user=self)
318
319    @transaction.atomic
320    def reset_password(self, event, user=None):
321        from pretalx.mail.models import QueuedMail
322
323        self.pw_reset_token = get_random_string(32)
324        self.pw_reset_time = now()
325        self.save()
326
327        context = {
328            "name": self.name or "",
329            "url": build_absolute_uri(
330                "orga:auth.recover", kwargs={"token": self.pw_reset_token}
331            ),
332        }
333        mail_text = _(
334            """Hi {name},
335
336you have requested a new password for your pretalx account.
337To reset your password, click on the following link:
338
339  {url}
340
341If this wasn\'t you, you can just ignore this email.
342
343All the best,
344the pretalx robot"""
345        )
346
347        with override(get_language()):
348            mail = QueuedMail.objects.create(
349                subject=_("Password recovery"), text=str(mail_text).format(**context)
350            )
351            mail.to_users.add(self)
352            mail.send()
353        self.log_action(
354            action="pretalx.user.password.reset", person=user, orga=bool(user)
355        )

test_user_model.py

 1import pytest
 2from pretalx.person.models.user import User
 3from pretalx.submission.models.question import Answer
 4
 5
 6@pytest.mark.parametrize(
 7    "email,expected",
 8    (
 9        ("one@two.com", "ac5be7f974137dc75bacee19b94fe0f8"),
10        ("a_very_long.email@orga.org", "79bd022bbbd718d8e30f730169067b2a"),
11    ),
12)
13def test_gravatar_parameter(email, expected):
14    user = User(email=email)
15    assert user.gravatar_parameter == expected
16
17
18@pytest.mark.django_db
19def test_user_deactivate(speaker, personal_answer, impersonal_answer, other_speaker):
20    assert Answer.objects.count() == 2
21    count = speaker.own_actions().count()
22    name = speaker.name
23    email = speaker.email
24    organiser = speaker.submissions.first().event.organiser
25    team = organiser.teams.first()
26    team.members.add(speaker)
27    team.save()
28    team_members = team.members.count()
29    speaker.deactivate()
30    speaker.refresh_from_db()
31    assert speaker.own_actions().count() == count
32    assert speaker.profiles.first().biography == ""
33    assert speaker.name != name
34    assert speaker.email != email
35    assert Answer.objects.count() == 1
36    assert Answer.objects.first().question.contains_personal_data is False
37    assert team.members.count() == team_members - 1
38    assert "deleted" in str(speaker).lower()
39    assert speaker.get_permissions_for_event(Answer.objects.first().event) == set()
40
41
42@pytest.mark.django_db
43def test_administrator_permissions(event):
44    user = User(email="one@two.com", is_administrator=True)
45    permission_set = {
46        "can_create_events",
47        "can_change_teams",
48        "can_change_organiser_settings",
49        "can_change_event_settings",
50        "can_change_submissions",
51        "is_reviewer",
52    }
53    assert user.get_permissions_for_event("randomthing") == permission_set
54    assert user.get_permissions_for_event(event) == permission_set
55    assert list(user.get_events_for_permission(can_change_submissions=True)) == [event]
56    assert event in user.get_events_with_any_permission()
57
58
59@pytest.mark.django_db
60def test_organizer_permissions(event, orga_user):
61    assert list(orga_user.get_events_with_any_permission()) == [event]
62    assert list(orga_user.get_events_for_permission(can_change_submissions=True)) == [
63        event
64    ]
65    permission_set = {
66        "can_create_events",
67        "can_change_teams",
68        "can_change_organiser_settings",
69        "can_change_event_settings",
70        "can_change_submissions",
71    }
72    assert orga_user.get_permissions_for_event(event) == permission_set