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

chiefonboarding / ChiefOnboarding / 18481185920

13 Oct 2025 11:53PM UTC coverage: 89.613% (-0.006%) from 89.619%
18481185920

Pull #572

github

web-flow
Merge f5b8970dc into f01e5ecbf
Pull Request #572: Add permissions based on department

7057 of 7875 relevant lines covered (89.61%)

0.9 hits per line

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

96.81
back/users/models.py
1
from datetime import datetime, timedelta
1✔
2

3
import pytz
1✔
4
from django.conf import settings
1✔
5
from django.contrib.auth import get_user_model
1✔
6
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager
1✔
7
from django.db import models
1✔
8
from django.db.models import CheckConstraint, Q
1✔
9
from django.template import Context, Template
1✔
10
from django.template.loader import render_to_string
1✔
11
from django.urls import reverse
1✔
12
from django.utils.crypto import get_random_string
1✔
13
from django.utils.functional import cached_property, lazy
1✔
14
from django.utils.translation import gettext_lazy as _
1✔
15
from django_q.tasks import async_task
1✔
16

17
from admin.appointments.models import Appointment
1✔
18
from admin.badges.models import Badge
1✔
19
from admin.hardware.models import Hardware
1✔
20
from admin.integrations.models import Integration
1✔
21
from admin.introductions.models import Introduction
1✔
22
from admin.preboarding.models import Preboarding
1✔
23
from admin.resources.models import CourseAnswer, Resource
1✔
24
from admin.sequences.models import Condition
1✔
25
from admin.to_do.models import ToDo
1✔
26
from misc.models import File
1✔
27
from organization.models import Notification
1✔
28
from slack_bot.utils import Slack, paragraph
1✔
29

30
from .utils import CompletedFormCheck, parse_array_to_string
1✔
31

32

33
class Department(models.Model):
1✔
34
    """
35
    Department that has been attached to a user
36
    """
37

38
    name = models.CharField(max_length=255)
1✔
39

40
    class Meta:
1✔
41
        ordering = ("name",)
1✔
42

43
    def __str__(self):
1✔
44
        return "%s" % self.name
1✔
45

46

47
class CustomUserManager(BaseUserManager):
1✔
48
    def get_by_natural_key(self, email):
1✔
49
        # Make validation case sensitive
50
        return self.get(**{self.model.USERNAME_FIELD + "__iexact": email})
1✔
51

52
    def make_random_password(
1✔
53
        self,
54
        length=10,
55
        allowed_chars="abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789",
56
    ):
57
        """
58
        Generate a random password with the given length and given
59
        allowed_chars. The default value of allowed_chars does not have "I" or
60
        "O" or letters and digits that look similar -- just to avoid confusion.
61
        """
62
        return get_random_string(length, allowed_chars)
1✔
63

64

65
class ManagerSlackManager(models.Manager):
1✔
66
    def get_queryset(self):
1✔
67
        return super().get_queryset().filter(
1✔
68
            role__in=[User.Role.MANAGER, User.Role.ADMIN]
69
        ) | super().get_queryset().exclude(slack_user_id="")
70

71

72
class OffboardingManager(models.Manager):
1✔
73
    def get_queryset(self):
1✔
74
        return super().get_queryset().filter(termination_date__isnull=False)
1✔
75

76

77
class ManagerManager(models.Manager):
1✔
78
    def get_queryset(self):
1✔
79
        return (
1✔
80
            super().get_queryset().filter(role__in=[User.Role.MANAGER, User.Role.ADMIN])
81
        )
82

83
    def with_slack(self):
1✔
84
        return self.get_queryset().exclude(slack_user_id="")
1✔
85

86

87
class NewHireManager(models.Manager):
1✔
88
    def get_queryset(self):
1✔
89
        return (
1✔
90
            super()
91
            .get_queryset()
92
            .filter(role=User.Role.NEWHIRE, termination_date__isnull=True)
93
        )
94

95
    def without_slack(self):
1✔
96
        return self.get_queryset().filter(slack_user_id="")
1✔
97

98
    def with_slack(self):
1✔
99
        return self.get_queryset().exclude(slack_user_id="")
1✔
100

101
    def starting_today(self):
1✔
102
        return (
1✔
103
            self.get_queryset().filter(start_day=datetime.now().date()).order_by("id")
104
        )
105

106
    def to_introduce(self):
1✔
107
        return self.get_queryset().filter(
1✔
108
            is_introduced_to_colleagues=False, start_day__gte=datetime.now().date()
109
        )
110

111

112
class AdminManager(models.Manager):
1✔
113
    def get_queryset(self):
1✔
114
        return super().get_queryset().filter(role=get_user_model().Role.ADMIN)
1✔
115

116

117
class User(AbstractBaseUser):
1✔
118
    class Role(models.IntegerChoices):
1✔
119
        NEWHIRE = 0, _("New hire")
1✔
120
        ADMIN = 1, _("Administrator")
1✔
121
        MANAGER = 2, _("Manager")
1✔
122
        OTHER = 3, _("Other")
1✔
123

124
    first_name = models.CharField(verbose_name=_("First name"), max_length=200)
1✔
125
    last_name = models.CharField(verbose_name=_("Last name"), max_length=200)
1✔
126
    email = models.EmailField(verbose_name=_("Email"), max_length=200, unique=True)
1✔
127
    is_staff = models.BooleanField(default=False)
1✔
128
    is_superuser = models.BooleanField(default=False)
1✔
129
    date_joined = models.DateTimeField(auto_now_add=True)
1✔
130
    birthday = models.DateField(verbose_name=_("Birthday"), default=None, null=True)
1✔
131
    position = models.CharField(
1✔
132
        verbose_name=_("Position"), max_length=300, default="", blank=True
133
    )
134
    phone = models.CharField(
1✔
135
        verbose_name=_("Phone"), max_length=300, default="", blank=True
136
    )
137
    slack_user_id = models.CharField(max_length=100, default="", blank=True)
1✔
138
    slack_channel_id = models.CharField(max_length=100, default="", blank=True)
1✔
139
    message = models.TextField(verbose_name=_("Message"), default="", blank=True)
1✔
140
    profile_image = models.ForeignKey(
1✔
141
        File, verbose_name=_("Profile image"), on_delete=models.CASCADE, null=True
142
    )
143
    linkedin = models.CharField(
1✔
144
        verbose_name=_("Linkedin"), default="", max_length=100, blank=True
145
    )
146
    facebook = models.CharField(
1✔
147
        verbose_name=_("Facebook"), default="", max_length=100, blank=True
148
    )
149
    twitter = models.CharField(
1✔
150
        verbose_name=_("Twitter"), default="", max_length=100, blank=True
151
    )
152
    timezone = models.CharField(
1✔
153
        verbose_name=_("Timezone"),
154
        default="",
155
        max_length=1000,
156
        choices=[(x, x) for x in pytz.common_timezones],
157
    )
158
    departments = models.ManyToManyField(Department, blank=True, related_name="users")
1✔
159
    language = models.CharField(
1✔
160
        verbose_name=_("Language"),
161
        default="en",
162
        choices=settings.LANGUAGES,
163
        max_length=5,
164
    )
165
    role = models.IntegerField(
1✔
166
        verbose_name=_("Role"),
167
        choices=Role.choices,
168
        default=3,
169
        help_text=_(
170
            "An administrator has access to everything. A manager has no access to settings and only access to "
171
            "the items in their departments"
172
        ),
173
    )
174
    is_active = models.BooleanField(default=True)
1✔
175
    is_introduced_to_colleagues = models.BooleanField(default=False)
1✔
176
    sent_preboarding_details = models.BooleanField(default=False)
1✔
177
    seen_updates = models.DateTimeField(auto_now_add=True)
1✔
178
    # new hire specific
179
    completed_tasks = models.IntegerField(default=0)
1✔
180
    total_tasks = models.IntegerField(default=0)
1✔
181
    buddy = models.ForeignKey(
1✔
182
        "self",
183
        verbose_name=_("Buddy"),
184
        on_delete=models.SET_NULL,
185
        null=True,
186
        related_name="new_hire_buddy",
187
    )
188
    manager = models.ForeignKey(
1✔
189
        "self",
190
        verbose_name=_("Manager"),
191
        on_delete=models.SET_NULL,
192
        null=True,
193
        related_name="new_hire_manager",
194
    )
195
    start_day = models.DateField(
1✔
196
        verbose_name=_("Start date"),
197
        null=True,
198
        blank=True,
199
        help_text=_("First working day"),
200
    )
201
    termination_date = models.DateField(
1✔
202
        verbose_name=_("Termination date"),
203
        null=True,
204
        blank=True,
205
        help_text=_("Last day of working"),
206
    )
207
    ran_integrations_condition = models.BooleanField(default=False)
1✔
208
    unique_url = models.CharField(max_length=250, unique=True)
1✔
209
    extra_fields = models.JSONField(default=dict)
1✔
210

211
    to_do = models.ManyToManyField(ToDo, through="ToDoUser", related_name="user_todos")
1✔
212
    introductions = models.ManyToManyField(
1✔
213
        Introduction, related_name="user_introductions"
214
    )
215
    resources = models.ManyToManyField(
1✔
216
        Resource, through="ResourceUser", related_name="user_resources"
217
    )
218
    appointments = models.ManyToManyField(Appointment, related_name="user_appointments")
1✔
219
    preboarding = models.ManyToManyField(
1✔
220
        Preboarding, through="PreboardingUser", related_name="user_preboardings"
221
    )
222
    badges = models.ManyToManyField(Badge, related_name="user_introductions")
1✔
223
    hardware = models.ManyToManyField(Hardware, related_name="user_hardware")
1✔
224
    integrations = models.ManyToManyField(
1✔
225
        "integrations.Integration",
226
        through="IntegrationUser",
227
        related_name="user_integrations",
228
    )
229

230
    # Conditions copied over from chosen sequences
231
    conditions = models.ManyToManyField(Condition)
1✔
232

233
    USERNAME_FIELD = "email"
1✔
234
    REQUIRED_FIELDS = ["first_name", "last_name"]
1✔
235

236
    objects = CustomUserManager()
1✔
237
    managers_and_admins = ManagerManager()
1✔
238
    managers_and_admins_or_slack_users = ManagerSlackManager()
1✔
239
    new_hires = NewHireManager()
1✔
240
    admins = AdminManager()
1✔
241
    offboarding = OffboardingManager()
1✔
242
    ordering = ("first_name",)
1✔
243

244
    class Meta:
1✔
245
        constraints = [
1✔
246
            CheckConstraint(
247
                condition=~Q(unique_url=""),
248
                name="unique_url_not_empty",
249
            )
250
        ]
251

252
    @property
1✔
253
    def name(self):
1✔
254
        # used for search
255
        return self.full_name
1✔
256

257
    @cached_property
1✔
258
    def full_name(self):
1✔
259
        return f"{self.first_name} {self.last_name}".strip()
1✔
260

261
    @property
1✔
262
    def update_url(self):
1✔
263
        if self.role == User.Role.NEWHIRE:
1✔
264
            return reverse("people:new_hire", args=[self.id])
×
265
        else:
266
            return reverse("people:colleague", args=[self.id])
1✔
267

268
    def get_icon_template(self):
1✔
269
        return render_to_string("_user_icon.html")
1✔
270

271
    @cached_property
1✔
272
    def progress(self):
1✔
273
        if self.completed_tasks == 0 or self.total_tasks == 0:
1✔
274
            return 0
1✔
275
        return (self.completed_tasks / self.total_tasks) * 100
×
276

277
    @cached_property
1✔
278
    def initials(self):
1✔
279
        initial_characters = ""
1✔
280
        if len(self.first_name):
1✔
281
            initial_characters += self.first_name[0]
1✔
282
        if len(self.last_name):
1✔
283
            initial_characters += self.last_name[0]
1✔
284
        return initial_characters
1✔
285

286
    @property
1✔
287
    def is_offboarding(self):
1✔
288
        return self.termination_date is not None
1✔
289

290
    @property
1✔
291
    def has_slack_account(self):
1✔
292
        return self.slack_user_id != ""
1✔
293

294
    @cached_property
1✔
295
    def has_new_hire_notifications(self):
1✔
296
        # Notification bell badge on new hire pages
297
        last_notification = self.notification_receivers.latest("created")
1✔
298
        if last_notification is not None:
1✔
299
            return last_notification.created > self.seen_updates
1✔
300
        return False
×
301

302
    @cached_property
1✔
303
    def missing_extra_info(self):
1✔
304
        extra_info = self.conditions.filter(
1✔
305
            integration_configs__isnull=False,
306
            integration_configs__integration__manifest__extra_user_info__isnull=False,
307
        ).values_list(
308
            "integration_configs__integration__manifest__extra_user_info", flat=True
309
        )
310

311
        # We now have arrays within extra_info: [[{..}], [{..}, {..}]]. Let's make one
312
        # array with all items: [{..}, {..}, {..}] and remove the duplicates.
313
        # Loop through both arrays and then add it, if it doesn't exist already.
314
        # Do the check on the ID, so other props could be different, but it still
315
        # wouldn't show it.
316
        extra_user_info = []
1✔
317
        for requested_info_arr in extra_info:
1✔
318
            for requested_info in requested_info_arr:
1✔
319
                if requested_info["id"] not in [item["id"] for item in extra_user_info]:
1✔
320
                    extra_user_info.append(requested_info)
1✔
321

322
        # Only return what we still need
323
        return [
1✔
324
            item
325
            for item in extra_user_info
326
            if item["id"] not in self.extra_fields.keys()
327
        ]
328

329
    def requires_manager_or_buddy(self):
1✔
330
        has_buddy = self.buddy_id is not None
1✔
331
        has_manager = self.manager_id is not None
1✔
332
        # end early if both are already filled
333
        if has_buddy and has_manager:
1✔
334
            return {"manager": False, "buddy": False}
1✔
335
        # not all items have to be checked. Introductions for example, doesn't have a
336
        # content field.
337
        to_check = [
1✔
338
            "to_do",
339
            "resources",
340
            "appointments",
341
            "badges",
342
            "hardware",
343
            "external_messages",
344
            "admin_tasks",
345
            "preboarding",
346
            "integration_configs",
347
        ]
348
        requires_manager = False
1✔
349
        requires_buddy = False
1✔
350
        for item in self.conditions.prefetched().prefetch_related(
1✔
351
            "integration_configs__integration"
352
        ):
353
            for field in to_check:
1✔
354
                condition_attribute = getattr(item, field)
1✔
355
                for i in condition_attribute.all():
1✔
356
                    if (
1✔
357
                        field == "integration_configs"
358
                        and i.integration.manifest_type
359
                        == Integration.ManifestType.WEBHOOK
360
                    ):
361
                        (
1✔
362
                            item_requires_manager,
363
                            item_requires_buddy,
364
                        ) = i.integration.requires_assigned_manager_or_buddy
365
                    else:
366
                        (
1✔
367
                            item_requires_manager,
368
                            item_requires_buddy,
369
                        ) = i.requires_assigned_manager_or_buddy
370

371
                    if item_requires_manager and not has_manager:
1✔
372
                        requires_manager = True
1✔
373
                    if item_requires_buddy and not has_buddy:
1✔
374
                        requires_buddy = True
1✔
375

376
                    # stop if we either have a user or if assigned user is required
377
                    if (requires_manager or has_manager) and (
1✔
378
                        requires_buddy or has_buddy
379
                    ):
380
                        break
1✔
381

382
        return {"manager": requires_manager, "buddy": requires_buddy}
1✔
383

384
    def update_progress(self):
1✔
385
        all_to_do_ids = list(
1✔
386
            self.conditions.values_list("to_do__id", flat=True)
387
        ) + list(self.to_do.values_list("id", flat=True))
388
        all_course_ids = list(
1✔
389
            self.conditions.filter(resources__course=True).values_list(
390
                "resources__id", flat=True
391
            )
392
        ) + list(self.resources.filter(course=True).values_list("id", flat=True))
393

394
        # remove duplicates
395
        all_to_do_ids = list(dict.fromkeys(all_to_do_ids))
1✔
396
        all_course_ids = list(dict.fromkeys(all_course_ids))
1✔
397

398
        completed_to_dos = ToDoUser.objects.filter(user=self, completed=True).count()
1✔
399
        completed_courses = ResourceUser.objects.filter(
1✔
400
            resource__course=True, user=self, completed_course=True
401
        ).count()
402

403
        self.total_tasks = len(all_to_do_ids + all_course_ids)
1✔
404
        self.completed_tasks = completed_to_dos + completed_courses
1✔
405
        self.save()
1✔
406

407
    def has_perm(self, perm, obj=None):
1✔
408
        return self.is_staff
×
409

410
    def has_module_perms(self, app_label):
1✔
411
        return self.is_superuser
×
412

413
    def save(self, *args, **kwargs):
1✔
414
        self.email = self.email.lower()
1✔
415
        if not self.pk:
1✔
416
            while True:
1✔
417
                unique_string = get_random_string(length=8)
1✔
418
                if not User.objects.filter(unique_url=unique_string).exists():
1✔
419
                    break
1✔
420
            self.unique_url = unique_string
1✔
421
        super(User, self).save(*args, **kwargs)
1✔
422

423
    def add_sequences(self, sequences):
1✔
424
        for sequence in sequences:
1✔
425
            sequence.assign_to_user(self)
1✔
426
            Notification.objects.create(
1✔
427
                notification_type=Notification.Type.ADDED_SEQUENCE,
428
                item_id=sequence.id,
429
                created_for=self,
430
                extra_text=sequence.name,
431
            )
432

433
    def remove_sequence(self, sequence):
1✔
434
        sequence.remove_from_user(self)
1✔
435

436
    @cached_property
1✔
437
    def workday(self):
1✔
438
        start_day = self.start_day
1✔
439
        local_day = self.get_local_time().date()
1✔
440

441
        if start_day > local_day:
1✔
442
            return 0
1✔
443

444
        amount_of_workdays = 1
1✔
445
        while local_day != start_day:
1✔
446
            start_day += timedelta(days=1)
1✔
447
            if start_day.weekday() not in [5, 6]:
1✔
448
                amount_of_workdays += 1
1✔
449

450
        return amount_of_workdays
1✔
451

452
    def workday_to_datetime(self, workdays):
1✔
453
        start_day = self.start_day
1✔
454
        if workdays == 0:
1✔
455
            return None
×
456

457
        start = 1
1✔
458
        while start != workdays:
1✔
459
            start_day += timedelta(days=1)
1✔
460
            if start_day.weekday() not in [5, 6]:
1✔
461
                start += 1
1✔
462
        return start_day
1✔
463

464
    def offboarding_workday_to_date(self, workdays):
1✔
465
        # Converts the workday (before the end date) to the actual date on which it
466
        # triggers. This will skip any weekends.
467
        base_date = self.termination_date
1✔
468

469
        while workdays > 0:
1✔
470
            base_date -= timedelta(days=1)
1✔
471
            if base_date.weekday() not in [5, 6]:
1✔
472
                workdays -= 1
1✔
473

474
        return base_date
1✔
475

476
    @cached_property
1✔
477
    def days_before_termination_date(self):
1✔
478
        # Checks how many workdays we are away from the employee's last day.
479
        # This will skip any weekends.
480
        date = self.get_local_time().date()
1✔
481

482
        termination_date = self.termination_date
1✔
483
        if termination_date < date:
1✔
484
            # passed the termination date
485
            return -1
1✔
486

487
        days = 0
1✔
488
        while termination_date != date:
1✔
489
            date += timedelta(days=1)
1✔
490
            if date.weekday() not in [5, 6]:
1✔
491
                days += 1
1✔
492
        return days
1✔
493

494
    @cached_property
1✔
495
    def days_before_starting(self):
1✔
496
        # not counting workdays here
497
        if self.start_day <= self.get_local_time().date():
1✔
498
            return 0
1✔
499
        return (self.start_day - self.get_local_time().date()).days
1✔
500

501
    def get_local_time(self, date=None):
1✔
502
        from organization.models import Organization
1✔
503

504
        if date is not None:
1✔
505
            date = date.replace(tzinfo=None)
1✔
506

507
        local_tz = pytz.timezone("UTC")
1✔
508
        org = Organization.object.get()
1✔
509
        us_tz = (
1✔
510
            pytz.timezone(org.timezone)
511
            if self.timezone == ""
512
            else pytz.timezone(self.timezone)
513
        )
514
        local = (
1✔
515
            local_tz.localize(datetime.now())
516
            if date is None
517
            else local_tz.localize(date)
518
        )
519
        return us_tz.normalize(local.astimezone(us_tz))
1✔
520

521
    def personalize(self, text, extra_values=None):
1✔
522
        if extra_values is None:
1✔
523
            extra_values = {}
1✔
524
        t = Template(text)
1✔
525
        department = parse_array_to_string(
1✔
526
            self.departments.values_list("name", flat=True)
527
        )
528
        manager = ""
1✔
529
        manager_email = ""
1✔
530
        buddy = ""
1✔
531
        buddy_email = ""
1✔
532
        if self.manager is not None:
1✔
533
            manager = self.manager.full_name
1✔
534
            manager_email = self.manager.email
1✔
535
        if self.buddy is not None:
1✔
536
            buddy = self.buddy.full_name
1✔
537
            buddy_email = self.buddy.email
1✔
538
        new_hire_context = {
1✔
539
            "manager": manager,
540
            "buddy": buddy,
541
            "position": self.position,
542
            "last_name": self.last_name,
543
            "first_name": self.first_name,
544
            "email": self.email,
545
            "start": self.start_day,
546
            "buddy_email": buddy_email,
547
            "manager_email": manager_email,
548
            "access_overview": lazy(self.get_access_overview, str),
549
            "department": department,
550
        }
551

552
        text = t.render(Context(new_hire_context | extra_values))
1✔
553
        # Remove non breakable space html code (if any). These could show up in the
554
        # Slack bot.
555
        text = text.replace("&nbsp;", " ")
1✔
556
        return text
1✔
557

558
    def get_access_overview(self):
1✔
559
        all_access = []
1✔
560
        for integration, access in self.check_integration_access().items():
1✔
561
            if access is None:
1✔
562
                access_str = _("(unknown)")
1✔
563
            elif access:
1✔
564
                access_str = _("(has access)")
1✔
565
            else:
566
                access_str = _("(no access)")
1✔
567

568
            all_access.append(f"{integration} {access_str}")
1✔
569

570
        return ", ".join(all_access)
1✔
571

572
    def check_integration_access(self):
1✔
573
        items = {}
1✔
574
        for integration_user in IntegrationUser.objects.filter(user=self):
1✔
575
            items[integration_user.integration.name] = not integration_user.revoked
1✔
576

577
        for integration in Integration.objects.filter(manifest__exists__isnull=False):
1✔
578
            items[integration.name] = integration.user_exists(self)
1✔
579

580
        return items
1✔
581

582
    @property
1✔
583
    def is_admin_or_manager(self):
1✔
584
        return self.role in [User.Role.ADMIN, User.Role.MANAGER]
1✔
585

586
    @property
1✔
587
    def is_manager(self):
1✔
588
        return self.role == User.Role.MANAGER
1✔
589

590
    @property
1✔
591
    def is_admin(self):
1✔
592
        return self.role == User.Role.ADMIN
1✔
593

594
    @property
1✔
595
    def is_new_hire(self):
1✔
596
        return self.role == User.Role.NEWHIRE
1✔
597

598
    def __str__(self):
1✔
599
        return "%s" % self.full_name
1✔
600

601

602
class ToDoUserManager(models.Manager):
1✔
603
    def all_to_do(self, user):
1✔
604
        return super().get_queryset().filter(user=user, completed=False)
1✔
605

606
    def overdue(self, user):
1✔
607
        return (
1✔
608
            super()
609
            .get_queryset()
610
            .filter(user=user, completed=False, to_do__due_on_day__lt=user.workday)
611
            .exclude(to_do__due_on_day=0)
612
        )
613

614
    def due_today(self, user):
1✔
615
        return (
1✔
616
            super()
617
            .get_queryset()
618
            .filter(user=user, completed=False, to_do__due_on_day=user.workday)
619
        )
620

621

622
class ToDoUser(CompletedFormCheck, models.Model):
1✔
623
    user = models.ForeignKey(
1✔
624
        get_user_model(), related_name="to_do_new_hire", on_delete=models.CASCADE
625
    )
626
    to_do = models.ForeignKey(ToDo, related_name="to_do", on_delete=models.CASCADE)
1✔
627
    completed = models.BooleanField(default=False)
1✔
628
    form = models.JSONField(default=list)
1✔
629
    reminded = models.DateTimeField(null=True)
1✔
630

631
    objects = ToDoUserManager()
1✔
632

633
    @cached_property
1✔
634
    def object_name(self):
1✔
635
        return self.to_do.name
1✔
636

637
    def mark_completed(self):
1✔
638
        from admin.sequences.tasks import process_condition
1✔
639

640
        self.completed = True
1✔
641
        self.save()
1✔
642

643
        # Get conditions with this to do item as (part of the) condition
644
        conditions = self.user.conditions.filter(condition_to_do=self.to_do)
1✔
645

646
        # Send answers back to slack channel?
647
        if self.to_do.send_back:
1✔
648
            blocks = [
×
649
                paragraph(
650
                    _("*Our new hire %(name)s just answered some questions:*")
651
                    % {"name": self.user.full_name}
652
                )
653
            ]
654
            for question in self.form:
×
655
                # For some reason, Slack adds a \n to the name, which messes up
656
                # formatting.
657
                title = question["data"]["text"].replace("\n", "")
×
658
                blocks.append(paragraph(f"*{title}*\n{question['answer']}"))
×
659

660
            Slack().send_message(
×
661
                blocks=blocks,
662
                text=(
663
                    _("Our new hire %(name)s just answered some questions:")
664
                    % {"name": self.user.full_name}
665
                ),
666
                channel=self.to_do.slack_channel.name,
667
            )
668

669
        for condition in conditions:
1✔
670
            condition_to_do_ids = condition.condition_to_do.values_list("id", flat=True)
1✔
671

672
            # Check if all to do items already have been added to new hire and are
673
            # completed. If not, then we know it should not be triggered yet
674
            to_do_user = ToDoUser.objects.filter(
1✔
675
                to_do__in=condition_to_do_ids, user=self.user, completed=True
676
            )
677

678
            # If the amount matches, then we should process it
679
            if to_do_user.count() == len(condition_to_do_ids):
1✔
680
                # Send notification only if user has a slack account
681
                process_condition(
1✔
682
                    condition.id, self.user.id, self.user.has_slack_account
683
                )
684

685

686
class PreboardingUser(CompletedFormCheck, models.Model):
1✔
687
    user = models.ForeignKey(
1✔
688
        get_user_model(), related_name="new_hire_preboarding", on_delete=models.CASCADE
689
    )
690
    preboarding = models.ForeignKey(
1✔
691
        Preboarding, related_name="preboarding_new_hire", on_delete=models.CASCADE
692
    )
693
    form = models.JSONField(default=list)
1✔
694
    completed = models.BooleanField(default=False)
1✔
695
    order = models.IntegerField(default=0)
1✔
696

697
    def save(self, *args, **kwargs):
1✔
698
        # Adding order number when record is not created yet (always the last item
699
        # in the list)
700
        if not self.pk:
1✔
701
            self.order = PreboardingUser.objects.filter(
1✔
702
                user=self.user, preboarding=self.preboarding
703
            ).count()
704
        super(PreboardingUser, self).save(*args, **kwargs)
1✔
705

706

707
class ResourceUser(models.Model):
1✔
708
    user = models.ForeignKey(
1✔
709
        get_user_model(), related_name="new_hire_resource", on_delete=models.CASCADE
710
    )
711
    resource = models.ForeignKey(
1✔
712
        Resource, related_name="resource_new_hire", on_delete=models.CASCADE
713
    )
714
    step = models.IntegerField(default=0)
1✔
715
    answers = models.ManyToManyField(CourseAnswer)
1✔
716
    reminded = models.DateTimeField(null=True)
1✔
717
    completed_course = models.BooleanField(default=False)
1✔
718

719
    def add_step(self):
1✔
720
        self.step += 1
1✔
721
        self.save()
1✔
722

723
        # Check if we are already past the max amount of steps
724
        # Avoid race conditions
725
        chapters = self.resource.chapters
1✔
726
        if self.step > chapters.count():
1✔
727
            return None
×
728

729
        # Check if that's the last one and wrap up if so
730
        if self.step == chapters.count():
1✔
731
            self.completed_course = True
1✔
732
            self.save()
1✔
733

734
            # Up one for completed stat in user
735
            self.user.completed_tasks += 1
1✔
736
            self.user.save()
1✔
737
            return None
1✔
738

739
        # Skip over any folders
740
        # This is safe, as a folder can never be the last type
741
        while (
1✔
742
            chapters.filter(order=self.step).exists()
743
            and chapters.get(order=self.step).type == 1
744
        ):
745
            self.step += 1
×
746
            self.save()
×
747

748
        # Return next chapter
749
        return chapters.get(order=self.step)
1✔
750

751
    @property
1✔
752
    def object_name(self):
1✔
753
        return self.resource.name
1✔
754

755
    @property
1✔
756
    def amount_chapters_in_course(self):
1✔
757
        return self.resource.chapters.count()
1✔
758

759
    @cached_property
1✔
760
    def percentage_completed(self):
1✔
761
        return self.step / self.resource.chapters.count() * 100
1✔
762

763
    @property
1✔
764
    def is_course(self):
1✔
765
        # used to determine if item should show up as course or as article
766
        return self.resource.course and not self.completed_course
1✔
767

768
    @cached_property
1✔
769
    def get_rating(self):
1✔
770
        if not self.answers.exists():
1✔
771
            return "n/a"
1✔
772

773
        amount_of_questions = 0
1✔
774
        amount_of_correct_answers = 0
1✔
775
        for question_page in self.answers.all():
1✔
776
            amount_of_questions += len(question_page.chapter.content["blocks"])
1✔
777
            for idx, answer in enumerate(question_page.chapter.content["blocks"]):
1✔
778
                if question_page.answers[f"item-{idx}"] == answer["answer"]:
1✔
779
                    amount_of_correct_answers += 1
1✔
780
        return _(
1✔
781
            "%(amount_of_correct_answers)s correct answers out of "
782
            "%(amount_of_questions)s questions"
783
        ) % {
784
            "amount_of_correct_answers": amount_of_correct_answers,
785
            "amount_of_questions": amount_of_questions,
786
        }
787

788
    def get_user_answer_by_chapter(self, chapter):
1✔
789
        if not self.answers.filter(chapter=chapter).exists():
1✔
790
            return None
1✔
791
        return self.answers.get(chapter=chapter)
1✔
792

793

794
class NewHireWelcomeMessage(models.Model):
1✔
795
    # messages placed through the slack bot
796
    new_hire = models.ForeignKey(
1✔
797
        get_user_model(), related_name="welcome_new_hire", on_delete=models.CASCADE
798
    )
799
    colleague = models.ForeignKey(
1✔
800
        get_user_model(), related_name="welcome_colleague", on_delete=models.CASCADE
801
    )
802
    message = models.TextField()
1✔
803

804

805
class IntegrationUser(models.Model):
1✔
806
    # logging when an integration was enabled and revoked
807
    user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
1✔
808
    integration = models.ForeignKey(
1✔
809
        "integrations.Integration", on_delete=models.CASCADE
810
    )
811
    revoked = models.BooleanField(default=False)
1✔
812

813
    class Meta:
1✔
814
        unique_together = ["user", "integration"]
1✔
815

816
    def save(self, *args, **kwargs):
1✔
817
        integration_user = super().save(*args, **kwargs)
1✔
818
        user = self.user
1✔
819
        if (
1✔
820
            user.is_offboarding
821
            and not user.ran_integrations_condition
822
            and user.conditions.filter(
823
                condition_type=Condition.Type.INTEGRATIONS_REVOKED
824
            ).exists()
825
            and not IntegrationUser.objects.filter(user=user, revoked=False).exists()
826
        ):
827
            from admin.sequences.tasks import process_condition
1✔
828

829
            integration_revoked_condition = user.conditions.get(
1✔
830
                condition_type=Condition.Type.INTEGRATIONS_REVOKED
831
            )
832
            async_task(
1✔
833
                process_condition,
834
                integration_revoked_condition.id,
835
                user.id,
836
                task_name=(
837
                    f"Process condition: {integration_revoked_condition.id} for "
838
                    f"{user.full_name} - all integrations revoked"
839
                ),
840
            )
841
            user.ran_integrations_condition = True
1✔
842
            user.save()
1✔
843

844
        return integration_user
1✔
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