• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

ephios-dev / ephios / 12006884757

25 Nov 2024 09:19AM UTC coverage: 85.182% (+0.02%) from 85.162%
12006884757

push

github

felixrindt
lint

2950 of 3493 branches covered (84.45%)

Branch coverage included in aggregate %.

33 of 35 new or added lines in 2 files covered. (94.29%)

45 existing lines in 6 files now uncovered.

11996 of 14053 relevant lines covered (85.36%)

0.85 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

88.89
/ephios/core/forms/users.py
1
from crispy_forms.bootstrap import FormActions
1✔
2
from crispy_forms.helper import FormHelper
1✔
3
from crispy_forms.layout import Field, Fieldset, Layout, Submit
1✔
4
from django import forms
1✔
5
from django.contrib.auth.models import Group
1✔
6
from django.core.exceptions import ValidationError
1✔
7
from django.db.models import Q
1✔
8
from django.forms import (
1✔
9
    CheckboxSelectMultiple,
10
    Form,
11
    ModelForm,
12
    ModelMultipleChoiceField,
13
    MultipleChoiceField,
14
    PasswordInput,
15
    inlineformset_factory,
16
)
17
from django.urls import reverse
1✔
18
from django.utils.translation import gettext as _
1✔
19
from django_select2.forms import Select2MultipleWidget, Select2Widget
1✔
20
from guardian.shortcuts import assign_perm, get_objects_for_group, remove_perm
1✔
21

22
from ephios.core.consequences import WorkingHoursConsequenceHandler
1✔
23
from ephios.core.models import QualificationGrant, UserProfile, WorkingHours
1✔
24
from ephios.core.models.users import IdentityProvider
1✔
25
from ephios.core.services.notifications.backends import enabled_notification_backends
1✔
26
from ephios.core.services.notifications.types import enabled_notification_types
1✔
27
from ephios.core.signals import register_group_permission_fields
1✔
28
from ephios.core.widgets import MultiUserProfileWidget
1✔
29
from ephios.extra.crispy import AbortLink
1✔
30
from ephios.extra.permissions import PermissionField, PermissionFormMixin, get_groups_with_perms
1✔
31
from ephios.extra.widgets import CustomDateInput
1✔
32
from ephios.modellogging.log import add_log_recorder
1✔
33
from ephios.modellogging.recorders import DerivedFieldsLogRecorder
1✔
34

35
PLANNING_TEST_PERMISSION = "core.add_event"
1✔
36

37
PLANNING_PERMISSIONS = [
1✔
38
    "core.add_event",
39
    "core.delete_event",
40
]
41

42
HR_TEST_PERMISSION = "core.change_userprofile"
1✔
43

44
HR_PERMISSIONS = [
1✔
45
    "core.add_userprofile",
46
    "core.change_userprofile",
47
    "core.delete_userprofile",
48
    "core.view_userprofile",
49
]
50

51
MANAGEMENT_TEST_PERMISSION = "auth.change_group"
1✔
52

53
MANAGEMENT_PERMISSIONS = [
1✔
54
    "auth.add_group",
55
    "auth.change_group",
56
    "auth.delete_group",
57
    "auth.view_group",
58
    "core.add_userprofile",
59
    "core.change_userprofile",
60
    "core.delete_userprofile",
61
    "core.view_userprofile",
62
    "core.view_event",
63
    "core.add_event",
64
    "core.change_event",
65
    "core.delete_event",
66
    "core.view_eventtype",
67
    "core.add_eventtype",
68
    "core.change_eventtype",
69
    "core.delete_eventtype",
70
    "core.view_qualification",
71
    "core.add_qualification",
72
    "core.change_qualification",
73
    "core.delete_qualification",
74
    "auth.publish_event_for_group",
75
    "modellogging.view_logentry",
76
]
77

78

79
def get_group_permission_log_fields(group):
1✔
80
    # This lives here because it is closely related to the fields on GroupForm below
81
    if not group.pk:
1✔
82
        return {}
1✔
83
    perms = set(
1✔
84
        f"{g[0]}.{g[1]}"
85
        for g in group.permissions.values_list("content_type__app_label", "codename")
86
    )
87

88
    return {
1✔
89
        _("Can add events"): PLANNING_TEST_PERMISSION in perms,
90
        _("Can edit users"): HR_TEST_PERMISSION in perms,
91
        _("Can change permissions"): MANAGEMENT_TEST_PERMISSION in perms,
92
        # force evaluation of querysets
93
        _("Can publish events for groups"): set(
94
            get_objects_for_group(group, "publish_event_for_group", klass=Group)
95
        ),
96
        _("Can decide working hours for groups"): set(
97
            get_objects_for_group(group, "decide_workinghours_for_group", klass=Group)
98
        ),
99
    }
100

101

102
class GroupForm(PermissionFormMixin, ModelForm):
1✔
103
    is_planning_group = PermissionField(
1✔
104
        label=_("Can add events"),
105
        permissions=PLANNING_PERMISSIONS,
106
        required=False,
107
    )
108
    publish_event_for_group = ModelMultipleChoiceField(
1✔
109
        label=_("Can publish events for groups"),
110
        queryset=Group.objects.all(),
111
        required=False,
112
        help_text=_("Choose groups that this group can make events visible for."),
113
        widget=Select2MultipleWidget,
114
    )
115
    decide_workinghours_for_group = ModelMultipleChoiceField(
1✔
116
        label=_("Can decide working hours for groups"),
117
        queryset=Group.objects.all(),
118
        required=False,
119
        help_text=_(
120
            "Choose groups that the group you are currently editing can decide whether to grant working hours for."
121
        ),
122
        widget=Select2MultipleWidget,
123
    )
124

125
    is_hr_group = PermissionField(
1✔
126
        label=_("Can edit users"),
127
        help_text=_(
128
            "If checked, users in this group can view, add, edit and delete users. "
129
            "They can also manage group memberships for their own groups."
130
        ),
131
        permissions=HR_PERMISSIONS,
132
        required=False,
133
    )
134
    is_management_group = PermissionField(
1✔
135
        label=_("Can change permissions and manage ephios"),
136
        help_text=_(
137
            "If checked, users in this group can edit all users, change groups, their permissions and memberships "
138
            "as well as define eventtypes and qualifications."
139
        ),
140
        permissions=MANAGEMENT_PERMISSIONS,
141
        required=False,
142
    )
143

144
    users = ModelMultipleChoiceField(
1✔
145
        label=_("Users"),
146
        queryset=UserProfile.objects.all(),
147
        widget=MultiUserProfileWidget,
148
        required=False,
149
    )
150

151
    class Meta:
1✔
152
        model = Group
1✔
153
        fields = ["name"]
1✔
154

155
    def __init__(self, **kwargs):
1✔
156
        if (group := kwargs.get("instance", None)) is not None:
1✔
157
            kwargs["initial"] = {
1✔
158
                "users": group.user_set.all(),
159
                "publish_event_for_group": get_objects_for_group(
160
                    group, "publish_event_for_group", klass=Group
161
                ),
162
                "decide_workinghours_for_group": get_objects_for_group(
163
                    group, "decide_workinghours_for_group", klass=Group
164
                ),
165
                **kwargs.get("initial", {}),
166
            }
167
            self.permission_target = group
1✔
168
        extra_fields = [
1✔
169
            item for __, result in register_group_permission_fields.send(None) for item in result
170
        ]
171
        for field_name, field in extra_fields:
1✔
172
            self.base_fields[field_name] = field
1✔
173
        super().__init__(**kwargs)
1✔
174
        self.helper = FormHelper()
1✔
175
        self.helper.layout = Layout(
1✔
176
            Field("name"),
177
            Field("users"),
178
            Fieldset(
179
                _("Management"),
180
                Field("is_hr_group", title="This permission is included with the management role."),
181
                "is_management_group",
182
            ),
183
            Fieldset(
184
                _("Planning"),
185
                Field(
186
                    "is_planning_group",
187
                    title="This permission is included with the management role.",
188
                ),
189
                Field("publish_event_for_group", wrapper_class="publish-select"),
190
                "decide_workinghours_for_group",
191
            ),
192
            Fieldset(
193
                _("Other"),
194
                *(Field(entry[0]) for entry in extra_fields),
195
            ),
196
            FormActions(
197
                Submit("submit", _("Save"), css_class="float-end"),
198
                AbortLink(href=reverse("core:group_list")),
199
            ),
200
        )
201

202
    def clean_is_management_group(self):
1✔
203
        is_management_group = self.cleaned_data["is_management_group"]
1✔
204
        if self.fields["is_management_group"].initial and not is_management_group:
1✔
205
            other_management_groups = get_groups_with_perms(
1✔
206
                only_with_perms_in=MANAGEMENT_PERMISSIONS,
207
                must_have_all_perms=True,
208
            ).exclude(pk=self.instance.pk)
209
            if not other_management_groups.exists():
1✔
210
                raise ValidationError(
1✔
211
                    _(
212
                        "At least one group with management permissions must exist. "
213
                        "Please promote another group before demoting this one."
214
                    )
215
                )
216
        if is_management_group:
1✔
217
            self.cleaned_data["is_hr_group"] = True
1✔
218
        return is_management_group
1✔
219

220
    def save(self, commit=True):
1✔
221
        add_log_recorder(self.instance, DerivedFieldsLogRecorder(get_group_permission_log_fields))
1✔
222
        group = super().save(commit)
1✔
223

224
        group.user_set.set(self.cleaned_data["users"])
1✔
225

226
        remove_perm("publish_event_for_group", group, Group.objects.all())
1✔
227
        if group.permissions.filter(codename="add_event").exists():
1✔
228
            assign_perm(
1✔
229
                "publish_event_for_group", group, self.cleaned_data["publish_event_for_group"]
230
            )
231

232
        if "decide_workinghours_for_group" in self.changed_data:
1!
233
            remove_perm("decide_workinghours_for_group", group, Group.objects.all())
×
234
            assign_perm(
×
235
                "decide_workinghours_for_group",
236
                group,
237
                self.cleaned_data["decide_workinghours_for_group"],
238
            )
239

240
        group.save()  # logging
1✔
241
        return group
1✔
242

243

244
class UserProfileForm(PermissionFormMixin, ModelForm):
1✔
245
    groups = ModelMultipleChoiceField(
1✔
246
        label=_("Groups"),
247
        queryset=Group.objects.all(),
248
        widget=Select2MultipleWidget,
249
        required=False,
250
        disabled=True,  # explicitly enable for users with `change_group` permission
251
    )
252
    is_staff = PermissionField(
1✔
253
        label=_("Administrator"),
254
        help_text=_(
255
            "If checked, this user can change technical ephios settings as well as edit all user profiles, "
256
            "groups, qualifications, events and event types."
257
        ),
258
        permissions=MANAGEMENT_PERMISSIONS,
259
    )
260

261
    def __init__(self, *args, **kwargs):
1✔
262
        request = kwargs.pop("request")
1✔
263
        super().__init__(*args, **kwargs)
1✔
264
        self.locked_groups = set()
1✔
265
        if request.user.has_perm("auth.change_group"):
1✔
266
            self.fields["groups"].disabled = False
1✔
267
        elif allowed_groups := request.user.groups:
1!
268
            self.fields["groups"].disabled = False
1✔
269
            self.fields["groups"].queryset = allowed_groups
1✔
270
            if self.instance.pk:
1✔
271
                self.locked_groups = set(self.instance.groups.exclude(id__in=allowed_groups.all()))
1✔
272
            if self.locked_groups:
1✔
273
                self.fields["groups"].help_text = _(
1✔
274
                    "The user is also member of <b>{groups}</b>, but you are not allowed to manage memberships for those groups."
275
                ).format(groups=", ".join((group.name for group in self.locked_groups)))
276
        if not request.user.is_staff:
1✔
277
            self.fields["is_staff"].disabled = True
1✔
278
            self.fields["is_staff"].help_text += " " + _(
1✔
279
                "Only other technical administrators can change this."
280
            )
281

282
        # email change can be used for account takeover, so only allow that in specific cases
283
        if self.instance.pk is not None and not (
1✔
284
            request.user.is_staff  # staff user can change email
285
            or self.instance == request.user  # user can change own email
286
            or not self.instance.is_staff
287
            and (  # if modifying a non-staff user
288
                # users that can modify groups can change email
289
                request.user.has_perm("auth.change_group")
290
                # or the modified user is not in a group that the modifying user is not in
291
                or set(request.user.groups.all()) >= set(self.instance.groups.all())
292
            )
293
        ):
294
            self.fields["email"].disabled = True
1✔
295
            self.fields["email"].help_text = _(
1✔
296
                "You are not allowed to change the email address of this user."
297
            )
298

299
    @property
1✔
300
    def permission_target(self):
1✔
301
        return self.instance
1✔
302

303
    field_order = [
1✔
304
        "email",
305
        "display_name",
306
        "date_of_birth",
307
        "phone",
308
        "groups",
309
        "is_active",
310
        "is_staff",
311
    ]
312

313
    class Meta:
1✔
314
        model = UserProfile
1✔
315
        fields = [
1✔
316
            "email",
317
            "display_name",
318
            "date_of_birth",
319
            "phone",
320
            "is_active",
321
            "is_staff",
322
        ]
323
        widgets = {"date_of_birth": CustomDateInput}
1✔
324
        help_texts = {
1✔
325
            "is_active": _("Inactive users cannot log in and do not get any notifications.")
326
        }
327
        labels = {"is_active": _("Active user")}
1✔
328

329
    def clean_is_staff(self):
1✔
330
        if self.initial.get("is_staff", False) and not self.cleaned_data["is_staff"]:
1✔
331
            other_staff = UserProfile.objects.filter(is_staff=True).exclude(pk=self.instance.pk)
1✔
332
            if not other_staff.exists():
1!
333
                raise ValidationError(
1✔
334
                    _(
335
                        "At least one user must be technical administrator. Please promote another user before demoting this one."
336
                    )
337
                )
338
        return self.cleaned_data["is_staff"]
1✔
339

340
    def save(self, commit=True):
1✔
341
        userprofile = super().save(commit)
1✔
342
        userprofile.groups.set(
1✔
343
            Group.objects.filter(
344
                Q(id__in=self.cleaned_data["groups"]) | Q(id__in=(g.id for g in self.locked_groups))
345
            )
346
        )
347
        # if the user is re-activated after the email has been deemed invalid, reset the flag
348
        if userprofile.is_active and userprofile.email_invalid:
1!
349
            userprofile.email_invalid = False
×
350
        userprofile.save()
1✔
351
        return userprofile
1✔
352

353

354
class DeleteUserProfileForm(Form):
1✔
355
    def __init__(self, *args, **kwargs):
1✔
356
        self.instance = kwargs.pop("instance")
1✔
357
        super().__init__(*args, **kwargs)
1✔
358

359
    def clean(self):
1✔
360
        other_staff = UserProfile.objects.filter(is_staff=True).exclude(pk=self.instance.pk)
1✔
361
        if self.instance.is_staff and not other_staff.exists():
1✔
362
            raise ValidationError(
1✔
363
                _(
364
                    "At least one user must be technical administrator. "
365
                    "Please promote another user before deleting this one."
366
                )
367
            )
368

369

370
class DeleteGroupForm(Form):
1✔
371
    def __init__(self, *args, **kwargs):
1✔
372
        self.instance = kwargs.pop("instance")
1✔
373
        super().__init__(*args, **kwargs)
1✔
374

375
    def clean(self):
1✔
376
        management_groups = get_groups_with_perms(
1✔
377
            only_with_perms_in=MANAGEMENT_PERMISSIONS, must_have_all_perms=True
378
        )
379

380
        if (
1✔
381
            self.instance in management_groups
382
            and not management_groups.exclude(pk=self.instance.pk).exists()
383
        ):
384
            raise ValidationError(
1✔
385
                _(
386
                    "At least one group with management permissions must exist. "
387
                    "Please promote another group before deleting this one."
388
                )
389
            )
390

391

392
class QualificationGrantForm(ModelForm):
1✔
393
    model = QualificationGrant
1✔
394

395
    class Meta:
1✔
396
        fields = ["qualification", "expires"]
1✔
397
        widgets = {"qualification": Select2Widget}
1✔
398

399
    def __init__(self, *args, **kwargs):
1✔
400
        super().__init__(*args, **kwargs)
1✔
401
        if hasattr(self, "instance") and self.instance.pk:
1!
402
            # Hide the field and simply display the qualification name in the template
403
            self.fields["qualification"].disabled = True
×
404
            self.fields["qualification"].widget = forms.HiddenInput()
×
405
            self.fields["qualification"].title = self.instance.qualification.title
×
406

407

408
QualificationGrantFormset = inlineformset_factory(
1✔
409
    UserProfile,
410
    QualificationGrant,
411
    form=QualificationGrantForm,
412
    extra=0,
413
)
414

415

416
class QualificationGrantFormsetHelper(FormHelper):
1✔
417
    def __init__(self, *args, **kwargs):
1✔
418
        super().__init__(*args, **kwargs)
×
419
        self.label_class = "col-md-4"
×
420
        self.field_class = "col-md-8"
×
421

422

423
class WorkingHourRequestForm(ModelForm):
1✔
424
    date = forms.DateField(widget=CustomDateInput)
1✔
425

426
    class Meta:
1✔
427
        model = WorkingHours
1✔
428
        fields = ["date", "hours", "reason"]
1✔
429

430
    def __init__(self, *args, **kwargs):
1✔
431
        self.can_grant = kwargs.pop("can_grant", False)
1✔
432
        self.user = kwargs.pop("user")
1✔
433
        super().__init__(*args, **kwargs)
1✔
434
        self.helper = FormHelper(self)
1✔
435
        self.helper.layout = Layout(
1✔
436
            Field("date"),
437
            Field("hours"),
438
            Field("reason"),
439
            FormActions(
440
                Submit("submit", _("Save"), css_class="float-end"),
441
            ),
442
        )
443

444
    def create_consequence(self):
1✔
445
        WorkingHoursConsequenceHandler.create(
1✔
446
            user=self.user,
447
            when=self.cleaned_data["date"],
448
            hours=float(self.cleaned_data["hours"]),
449
            reason=self.cleaned_data["reason"],
450
        )
451

452

453
class UserNotificationPreferenceForm(Form):
1✔
454
    def __init__(self, *args, **kwargs):
1✔
455
        self.user = kwargs.pop("user")
1✔
456
        super().__init__(*args, **kwargs)
1✔
457

458
        self.all_backends = {backend.slug for backend in enabled_notification_backends()}
1✔
459
        for notification_type in enabled_notification_types():
1✔
460
            if notification_type.unsubscribe_allowed:
1✔
461
                self.fields[notification_type.slug] = MultipleChoiceField(
1✔
462
                    label=notification_type.title,
463
                    choices=[
464
                        (backend.slug, backend.title) for backend in enabled_notification_backends()
465
                    ],
466
                    initial=list(
467
                        self.all_backends
468
                        - {
469
                            backend
470
                            for backend, notificaton_type in self.user.disabled_notifications
471
                            if notificaton_type == notification_type.slug
472
                        }
473
                    ),
474
                    widget=CheckboxSelectMultiple,
475
                    required=False,
476
                )
477

478
    def save_preferences(self):
1✔
479
        disabled_notifications = []
1✔
480
        for notification_type, preferred_backends in self.cleaned_data.items():
1✔
481
            for backend in self.all_backends - set(preferred_backends):
1✔
482
                disabled_notifications.append([backend, notification_type])
1✔
483
        self.user.disabled_notifications = disabled_notifications
1✔
484
        self.user.save()
1✔
485

486

487
class UserOwnDataForm(ModelForm):
1✔
488
    class Meta:
1✔
489
        model = UserProfile
1✔
490
        fields = ["preferred_language"]
1✔
491

492

493
class OIDCDiscoveryForm(Form):
1✔
494
    url = forms.URLField(
1✔
495
        label=_("OIDC Provider URL"), help_text=_("The base URL of the OIDC provider.")
496
    )
497

498
    def clean_url(self):
1✔
499
        url = self.cleaned_data["url"]
×
500
        if not url.endswith("/"):
×
501
            url += "/"
×
502
        return url
×
503

504

505
class IdentityProviderForm(ModelForm):
1✔
506
    class Meta:
1✔
507
        model = IdentityProvider
1✔
508
        fields = [
1✔
509
            "internal_name",
510
            "label",
511
            "client_id",
512
            "client_secret",
513
            "scopes",
514
            "default_groups",
515
            "group_claim",
516
            "create_missing_groups",
517
            "qualification_claim",
518
            "qualification_codename_to_uuid",
519
            "authorization_endpoint",
520
            "token_endpoint",
521
            "userinfo_endpoint",
522
            "end_session_endpoint",
523
            "jwks_uri",
524
        ]
525
        widgets = {
1✔
526
            "default_groups": Select2MultipleWidget,
527
            "qualification_codename_to_uuid": forms.Textarea(attrs={"rows": 1}),
528
        }
529

530
    def __init__(self, *args, **kwargs):
1✔
UNCOV
531
        super().__init__(*args, **kwargs)
×
UNCOV
532
        if self.instance.pk:
×
UNCOV
533
            self.fields["client_secret"] = forms.CharField(
×
534
                widget=PasswordInput(attrs={"placeholder": "********"}),
535
                required=False,
536
                label=_("Client secret"),
537
                help_text=_("Leave empty to keep the current secret."),
538
            )
539

540
    def clean_client_secret(self):
1✔
UNCOV
541
        client_secret = self.cleaned_data["client_secret"]
×
UNCOV
542
        if self.instance.pk and client_secret == "":
×
UNCOV
543
            return self.instance.client_secret
×
UNCOV
544
        return client_secret
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc