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

tjcsl / ion / 3858230717

pending completion
3858230717

push

github

GitHub
feat(misc): account for negative day streaks

2765 of 4672 branches covered (59.18%)

15346 of 18757 relevant lines covered (81.81%)

0.82 hits per line

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

79.2
/intranet/apps/users/models.py
1
# pylint: disable=too-many-lines; Allow more than 1000 lines
2
import logging
1✔
3
from base64 import b64encode
1✔
4
from datetime import timedelta
1✔
5
from typing import Collection, Dict, Optional, Union
1✔
6

7
from dateutil.relativedelta import relativedelta
1✔
8

9
from django.conf import settings
1✔
10
from django.contrib.auth.models import AbstractBaseUser, AnonymousUser, PermissionsMixin
1✔
11
from django.contrib.auth.models import UserManager as DjangoUserManager
1✔
12
from django.core.cache import cache
1✔
13
from django.db import models
1✔
14
from django.db.models import Count, F, Q, QuerySet
1✔
15
from django.utils import timezone
1✔
16
from django.utils.functional import cached_property
1✔
17

18
from intranet.middleware import threadlocals
1✔
19

20
from ...utils.date import get_senior_graduation_year
1✔
21
from ...utils.helpers import is_entirely_digit
1✔
22
from ..bus.models import Route
1✔
23
from ..eighth.models import EighthBlock, EighthSignup, EighthSponsor
1✔
24
from ..groups.models import Group
1✔
25
from ..polls.models import Poll
1✔
26
from ..preferences.fields import PhoneField
1✔
27

28
logger = logging.getLogger(__name__)
1✔
29

30
# TODO: this is disgusting
31
GRADE_NUMBERS = ((9, "freshman"), (10, "sophomore"), (11, "junior"), (12, "senior"), (13, "staff"))
1✔
32
# Eighth Office/Demo Student user IDs that should be excluded from teacher/student lists
33
EXTRA = [9996, 8888, 7011]
1✔
34

35

36
class UserManager(DjangoUserManager):
1✔
37
    """User model Manager for table-level User queries.
38

39
    Provides an abstraction for the User model. If a call
40
    to a method fails for this Manager, the call is deferred to the
41
    default User model manager.
42

43
    """
44

45
    def user_with_student_id(self, student_id: Union[int, str]) -> Optional["User"]:
1✔
46
        """Get a unique user object by FCPS student ID. (Ex. 1624472)"""
47
        results = User.objects.filter(student_id=str(student_id))
1✔
48
        if len(results) == 1:
1✔
49
            return results.first()
1✔
50
        return None
1✔
51

52
    def user_with_ion_id(self, student_id: Union[int, str]) -> Optional["User"]:
1✔
53
        """Get a unique user object by Ion ID. (Ex. 489)"""
54
        if isinstance(student_id, str) and not is_entirely_digit(student_id):
1!
55
            return None
×
56
        results = User.objects.filter(id=str(student_id))
1✔
57
        if len(results) == 1:
1!
58
            return results.first()
1✔
59
        return None
×
60

61
    def users_in_year(self, year: int) -> Union[Collection["User"], QuerySet]:  # pylint: disable=unsubscriptable-object
1✔
62
        """Get a list of users in a specific graduation year."""
63
        return User.objects.filter(graduation_year=year)
×
64

65
    def user_with_name(self, given_name: Optional[str] = None, last_name: Optional[str] = None) -> "User":  # pylint: disable=unsubscriptable-object
1✔
66
        """Get a unique user object by given name (first/nickname) and/or last name.
67

68
        Args:
69
            given_name: If given, users will be filtered to those who have either this first name or this nickname.
70
            last_name: If given, users will be filtered to those who have this last name.
71

72
        Returns:
73
            The unique user object returned by filtering for the given first name/nickname and/or last name. Returns ``None`` if no results were
74
            returned or if the given parameters matched more than one user.
75

76
        """
77
        results = User.objects.all()
1✔
78

79
        if last_name:
1✔
80
            results = results.filter(last_name=last_name)
1✔
81
        if given_name:
1✔
82
            results = results.filter(Q(first_name=given_name) | Q(nickname=given_name))
1✔
83

84
        try:
1✔
85
            return results.get()
1✔
86
        except (User.DoesNotExist, User.MultipleObjectsReturned):
1✔
87
            return None
1✔
88

89
    def get_students(self) -> Union[Collection["User"], QuerySet]:  # pylint: disable=unsubscriptable-object
1✔
90
        """Get user objects that are students (quickly)."""
91
        users = User.objects.filter(user_type="student", graduation_year__gte=get_senior_graduation_year())
1✔
92
        users = users.exclude(id__in=EXTRA)
1✔
93

94
        return users
1✔
95

96
    def get_teachers(self) -> Union[Collection["User"], QuerySet]:  # pylint: disable=unsubscriptable-object
1✔
97
        """Get user objects that are teachers (quickly)."""
98
        users = User.objects.filter(user_type="teacher")
1✔
99
        users = users.exclude(id__in=EXTRA)
1✔
100
        # Add possible exceptions handling here
101
        users = users | User.objects.filter(id__in=[31863, 32327, 32103, 33228])
1✔
102

103
        users = users.exclude(Q(first_name=None) | Q(first_name="") | Q(last_name=None) | Q(last_name=""))
1✔
104

105
        return users
1✔
106

107
    def get_teachers_attendance_users(self) -> "QuerySet[User]":  # noqa
1✔
108
        """Like ``get_teachers()``, but includes attendance-only users as well as
109
        teachers.
110

111
        Returns:
112
            A QuerySet of users who are either teachers or attendance-only users.
113

114
        """
115
        users = User.objects.filter(user_type__in=["teacher", "user"])
×
116
        users = users.exclude(id__in=EXTRA)
×
117
        # Add possible exceptions handling here
118
        users = users | User.objects.filter(id__in=[31863, 32327, 32103, 33228])
×
119

120
        users = users.exclude(Q(first_name=None) | Q(first_name="") | Q(last_name=None) | Q(last_name=""))
×
121

122
        return users
×
123

124
    def get_teachers_sorted(self) -> Union[Collection["User"], QuerySet]:  # pylint: disable=unsubscriptable-object
1✔
125
        """Returns a ``QuerySet`` of teachers sorted by last name, then first name.
126

127
        Returns:
128
            A ``QuerySet`` of teachers sorted by last name, then first name.
129

130
        """
131
        return self.get_teachers().order_by("last_name", "first_name")
1✔
132

133
    def get_teachers_attendance_users_sorted(self) -> "QuerySet[User]":  # noqa
1✔
134
        """Returns a ``QuerySet`` containing both teachers and attendance-only users sorted by
135
        last name, then first name.
136

137
        Returns:
138
            A ``QuerySet`` of teachers sorted by last name, then first name.
139

140
        """
141
        return self.get_teachers_attendance_users().order_by("last_name", "first_name")
×
142

143
    def get_approve_announcements_users(self) -> "QuerySet[User]":  # noqa
1✔
144
        """Returns a ``QuerySet`` containing all users except simple users, tjstar presenters,
145
        alumni, service users and students.
146

147
        Returns:
148
            A ``QuerySet`` of all users except simple users, tjstar presenters, alumni,
149
            service users and students.
150

151
        """
152

153
        users = User.objects.filter(user_type__in=["user", "teacher", "counselor"])
1✔
154
        users = users.exclude(id__in=EXTRA)
1✔
155
        users = users.exclude(Q(first_name=None) | Q(first_name="") | Q(last_name=None) | Q(last_name=""))
1✔
156

157
        return users
1✔
158

159
    def get_approve_announcements_users_sorted(self) -> "QuerySet[User]":  # noqa
1✔
160
        """Returns a ``QuerySet`` containing all users except simple users, tjstar presenters,
161
        alumni, service users and students sorted by last name, then first name.
162

163
        This is used for the announcement request page.
164

165
        Returns:
166
            A ``QuerySet`` of all users except simple users, tjstar presenters, alumni,
167
            service users and students sorted by last name, then first name.
168

169
        """
170
        return self.get_approve_announcements_users().order_by("last_name", "first_name")
1✔
171

172
    def exclude_from_search(
1✔
173
        self, existing_queryset: Optional[Union[Collection["User"], QuerySet]] = None  # pylint: disable=unsubscriptable-object
174
    ) -> Union[Collection["User"], QuerySet]:  # pylint: disable=unsubscriptable-object
175
        if existing_queryset is None:
1!
176
            existing_queryset = self
1✔
177

178
        return existing_queryset.exclude(user_type="service")
1✔
179

180

181
class User(AbstractBaseUser, PermissionsMixin):
1✔
182
    """Django User model subclass"""
183

184
    TITLES = (("Mr.", "Mr."), ("Ms.", "Ms."), ("Mrs.", "Mrs."), ("Dr.", "Dr."), ("Mx.", "Mx."))
1✔
185

186
    USER_TYPES = (
1✔
187
        ("student", "Student"),
188
        ("teacher", "Teacher"),
189
        ("counselor", "Counselor"),
190
        ("user", "Attendance-Only User"),
191
        ("simple_user", "Simple User"),
192
        ("tjstar_presenter", "tjStar Presenter"),
193
        ("alum", "Alumnus"),
194
        ("service", "Service Account"),
195
    )
196

197
    GENDER = (
1✔
198
        ("male", "Male"),
199
        ("female", "Female"),
200
        ("non-binary", "Non-Binary"),
201
    )
202

203
    TITLES = (("mr", "Mr."), ("ms", "Ms."), ("mrs", "Mrs."), ("dr", "Dr."), ("mx", "Mx."))
1✔
204

205
    # Django Model Fields
206
    username = models.CharField(max_length=30, unique=True)
1✔
207

208
    # See Email model for emails
209
    # See Phone model for phone numbers
210
    # See Website model for websites
211

212
    user_locked = models.BooleanField(default=False)
1✔
213

214
    # Local internal fields
215
    first_login = models.DateTimeField(null=True)
1✔
216
    seen_welcome = models.BooleanField(default=False)
1✔
217
    last_global_logout_time = models.DateTimeField(null=True)
1✔
218

219
    # Local preference fields
220
    receive_news_emails = models.BooleanField(default=True)
1✔
221
    receive_eighth_emails = models.BooleanField(default=True)
1✔
222

223
    receive_schedule_notifications = models.BooleanField(default=False)
1✔
224

225
    student_id = models.CharField(max_length=settings.FCPS_STUDENT_ID_LENGTH, unique=True, null=True)
1✔
226
    user_type = models.CharField(max_length=30, choices=USER_TYPES, default="student")
1✔
227
    admin_comments = models.TextField(blank=True, null=True)
1✔
228
    counselor = models.ForeignKey("self", on_delete=models.SET_NULL, related_name="students", null=True)
1✔
229
    graduation_year = models.IntegerField(null=True)
1✔
230
    title = models.CharField(max_length=5, choices=TITLES, null=True, blank=True)
1✔
231
    first_name = models.CharField(max_length=35, null=True)
1✔
232
    middle_name = models.CharField(max_length=70, null=True)
1✔
233
    last_name = models.CharField(max_length=70, null=True)
1✔
234
    nickname = models.CharField(max_length=35, null=True)
1✔
235
    gender = models.CharField(max_length=35, choices=GENDER, null=True, blank=True)
1✔
236
    preferred_photo = models.OneToOneField("Photo", related_name="+", null=True, blank=True, on_delete=models.SET_NULL)
1✔
237
    primary_email = models.OneToOneField("Email", related_name="+", null=True, blank=True, on_delete=models.SET_NULL)
1✔
238
    bus_route = models.ForeignKey(Route, on_delete=models.SET_NULL, null=True)
1✔
239

240
    # Required to replace the default Django User model
241
    USERNAME_FIELD = "username"
1✔
242
    """Override default Model Manager (objects) with
×
243
    custom UserManager to add table-level functionality."""
244
    objects = UserManager()
1✔
245

246
    @staticmethod
1✔
247
    def get_signage_user() -> "User":
1✔
248
        """Returns the user used to authenticate signage displays
249

250
        Returns:
251
            The user used to authenticate signage displays
252

253
        """
254
        return User(id=99999)
1✔
255

256
    @property
1✔
257
    def address(self) -> Optional["Address"]:
1✔
258
        """Returns the ``Address`` object representing this user's address, or ``None`` if it is not
259
        set or the current user does not have permission to access it.
260

261
        Returns:
262
            The ``Address`` representing this user's address, or ``None`` if that is unavailable to
263
            the current user.
264

265
        """
266
        return self.properties.address
1✔
267

268
    @property
1✔
269
    def schedule(self) -> Optional[Union[QuerySet, Collection["Section"]]]:  # pylint: disable=unsubscriptable-object
1✔
270
        """Returns a QuerySet of the ``Section`` objects representing the classes this student is
271
        in, or ``None`` if the current user does not have permission to list this student's classes.
272

273
        Returns:
274
            Returns a QuerySet of the ``Section`` objects representing the classes this student is
275
            in, or ``None`` if the current user does not have permission to list this student's
276
            classes.
277

278
        """
279
        return self.properties.schedule
1✔
280

281
    def member_of(self, group: Union[Group, str]) -> bool:
1✔
282
        """Returns whether a user is a member of a certain group.
283

284
        Args:
285
            group: Either the name of a group or a ``Group`` object.
286

287
        Returns:
288
            Whether the user is a member of the given group.
289

290
        """
291
        if isinstance(group, Group):
1!
292
            group = group.name
×
293
        return self.groups.filter(name=group).cache(ops=["exists"], timeout=15).exists()  # pylint: disable=no-member
1✔
294

295
    def has_admin_permission(self, perm: str) -> bool:
1✔
296
        """Returns whether a user has an admin permission (explicitly, or implied by being in the
297
        "admin_all" group)
298

299
        Args:
300
            perm: The admin permission to check for.
301

302
        Returns:
303
            Whether the user has the given admin permission (either explicitly or implicitly)
304

305
        """
306
        return self.member_of("admin_all") or self.member_of("admin_" + perm)
1✔
307

308
    @property
1✔
309
    def full_name(self) -> str:
1✔
310
        """Return full name, e.g. Angela William.
311

312
        This is required for subclasses of User.
313

314
        Returns:
315
            The user's full name (first + " " + last).
316

317
        """
318
        return "{} {}".format(self.first_name, self.last_name)
1✔
319

320
    @property
1✔
321
    def full_name_nick(self) -> str:
1✔
322
        """If the user has a nickname, returns their name in the format "Nickname Lastname."
323
        Otherwise, this is identical to full_name.
324

325
        Returns:
326
            The user's full name, with their nickname substituted for their first name if it is set.
327

328
        """
329
        return f"{self.nickname or self.first_name} {self.last_name}"
1✔
330

331
    @property
1✔
332
    def display_name(self) -> str:
1✔
333
        """Returns ``self.full_name``.
334

335
        Returns:
336
            The user's full name.
337

338
        """
339
        return self.full_name
1✔
340

341
    @property
1✔
342
    def last_first(self) -> str:
1✔
343
        """Return a name in the format of:
344
        Lastname, Firstname [(Nickname)]
345
        """
346
        return "{}, {}".format(self.last_name, self.first_name) + (" ({})".format(self.nickname) if self.nickname else "")
1✔
347

348
    @property
1✔
349
    def last_first_id(self) -> str:
1✔
350
        """Return a name in the format of:
351
        Lastname, Firstname [(Nickname)] (Student ID/ID/Username)
352
        """
353
        return (
1✔
354
            "{}{} ".format(self.last_name, ", " + self.first_name if self.first_name else "")
355
            + ("({}) ".format(self.nickname) if self.nickname else "")
356
            + ("({})".format(self.student_id if self.is_student and self.student_id else self.username))
357
        )
358

359
    @property
1✔
360
    def last_first_initial(self) -> str:
1✔
361
        """Return a name in the format of:
362
        Lastname, F [(Nickname)]
363
        """
364
        return "{}{}".format(self.last_name, ", " + self.first_name[:1] + "." if self.first_name else "") + (
1✔
365
            " ({})".format(self.nickname) if self.nickname else ""
366
        )
367

368
    @property
1✔
369
    def short_name(self) -> str:
1✔
370
        """Return short name (first name) of a user.
371

372
        This is required for subclasses of User.
373

374
        Returns:
375
            The user's fist name.
376

377
        """
378
        return self.first_name
1✔
379

380
    def get_full_name(self) -> str:
1✔
381
        """Return full name, e.g. Angela William.
382

383
        Returns:
384
            The user's full name (see ``full_name``).
385

386
        """
387
        return self.full_name
1✔
388

389
    def get_short_name(self) -> str:
1✔
390
        """Get short (first) name of a user.
391

392
        Returns:
393
            The user's first name (see ``short_name`` and ``first_name``).
394

395
        """
396
        return self.short_name
1✔
397

398
    @property
1✔
399
    def primary_email_address(self) -> Optional[str]:
1✔
400
        try:
1✔
401
            return self.primary_email.address if self.primary_email else None
1✔
402
        except Email.DoesNotExist:
×
403
            return None
×
404

405
    @property
1✔
406
    def tj_email(self) -> str:
1✔
407
        """Get (or guess) a user's TJ email.
408

409
        If a fcps.edu or tjhsst.edu email is specified in their email
410
        list, use that. Otherwise, append the user's username to the
411
        proper email suffix, depending on whether they are a student or
412
        teacher.
413

414
        Returns:
415
            The user's found or guessed FCPS/TJ email address.
416

417
        """
418

419
        email = self.emails.filter(Q(address__iendswith="@fcps.edu") | Q(address__iendswith="@tjhsst.edu")).first()
1✔
420
        if email is not None:
1✔
421
            return email.address
1✔
422

423
        if self.is_teacher:
1✔
424
            domain = "fcps.edu"
1✔
425
        else:
426
            domain = "tjhsst.edu"
1✔
427

428
        return "{}@{}".format(self.username, domain)
1✔
429

430
    @property
1✔
431
    def non_tj_email(self) -> Optional[str]:
1✔
432
        """
433
        Returns the user's first non-TJ email found, or None if none is found.
434

435
        If a user has a primary email set and it is not their TJ email,
436
        use that. Otherwise, use the first email found that is not their
437
        TJ email.
438

439
        Returns:
440
            The first non-TJ email found for a user, or None if no such email is found.
441

442
        """
443
        tj_email = self.tj_email
1✔
444
        primary_email_address = self.primary_email_address
1✔
445

446
        if primary_email_address and primary_email_address.lower() != tj_email.lower():
1!
447
            return primary_email_address
×
448

449
        email = self.emails.exclude(address__iexact=tj_email).first()
1✔
450
        return email.address if email else None
1✔
451

452
    @property
1✔
453
    def notification_email(self) -> str:
1✔
454
        """Returns the notification email.
455

456
        If a primary email is set, use it. Otherwise, use the first
457
        email on file. If no email addresses exist, use the user's
458
        TJ email.
459

460
        Returns:
461
            A user's notification email address.
462

463
        """
464

465
        primary_email_address = self.primary_email_address
1✔
466
        if primary_email_address:
1✔
467
            return primary_email_address
1✔
468

469
        email = self.emails.first()
1✔
470
        return email.address if email and email.address else self.tj_email
1✔
471

472
    @property
1✔
473
    def default_photo(self) -> Optional[bytes]:
1✔
474
        """Returns default photo (in binary) that should be used
475

476
        Returns:
477
            The binary representation of the user's default photo.
478

479
        """
480
        preferred = self.preferred_photo
1✔
481
        if preferred is not None:
1!
482
            return preferred.binary
×
483

484
        if preferred is None:
1!
485
            if self.user_type == "teacher":
1!
486
                current_grade = 12
×
487
            else:
488
                current_grade = min(int(self.grade), 12)
1✔
489
            for i in reversed(range(9, current_grade + 1)):
1✔
490
                data = None
1✔
491
                if self.photos.filter(grade_number=i).exists():
1!
492
                    data = self.photos.filter(grade_number=i).first().binary
×
493
                if data:
1!
494
                    return data
×
495

496
        return None
1✔
497

498
    @property
1✔
499
    def grade(self) -> "Grade":
1✔
500
        """Returns the grade of a user.
501

502
        Returns:
503
            A Grade object representing the uset's current grade.
504

505
        """
506
        return Grade(self.graduation_year)
1✔
507

508
    @property
1✔
509
    def permissions(self) -> Dict[str, bool]:
1✔
510
        """Dynamically generate dictionary of privacy options.
511

512
        Returns:
513
            A dictionary mapping the name of each privacy option to a boolean indicating whether it
514
            is enabled.
515

516
        """
517
        permissions_dict = {}
1✔
518

519
        for prefix in PERMISSIONS_NAMES:
1✔
520
            permissions_dict[prefix] = {}
1✔
521
            for suffix in PERMISSIONS_NAMES[prefix]:
1✔
522
                permissions_dict[prefix][suffix] = getattr(self.properties, prefix + "_" + suffix)
1✔
523

524
        return permissions_dict
1✔
525

526
    def _current_user_override(self) -> bool:
1✔
527
        """Return whether the currently logged in user is a teacher or eighth admin, and can view
528
        all of a student's information regardless of their privacy settings.
529

530
        Returns:
531
            Whether the user has permissions to view all of their information regardless of their
532
            privacy settings.
533

534
        """
535
        try:
×
536
            # threadlocals is a module, not an actual thread locals object
537
            request = threadlocals.request()
×
538
            if request is None:
×
539
                return False
×
540
            requesting_user = request.user
×
541
            if isinstance(requesting_user, AnonymousUser) or not requesting_user.is_authenticated:
×
542
                return False
×
543
            can_view_anyway = requesting_user and (requesting_user.is_teacher or requesting_user.is_eighthoffice or requesting_user.is_eighth_admin)
×
544
        except (AttributeError, KeyError) as e:
×
545
            logger.error("Could not check teacher/eighth override: %s", e)
×
546
            can_view_anyway = False
×
547
        return can_view_anyway
×
548

549
    @property
1✔
550
    def ion_username(self) -> str:
1✔
551
        """Returns this user's username.
552

553
        Returns:
554
            This user's username (see ``username``).
555

556
        """
557
        return self.username
1✔
558

559
    @property
1✔
560
    def grade_number(self) -> int:
1✔
561
        """Returns the number of the grade this user is currently in (9, 10, 11, or 12 for
562
        students).
563

564
        Returns:
565
            The number of the grade this user is currently in.
566

567
        """
568
        return self.grade.number
1✔
569

570
    @property
1✔
571
    def sex(self) -> str:
1✔
572
        """Returns the gender of this user (male, female, or non-binary).
573

574
        Returns:
575
            The gender of this user (male, female, or non-binary).
576

577
        """
578
        return self.gender or ""
1✔
579

580
    @property
1✔
581
    def is_male(self) -> bool:
1✔
582
        """Returns whether the user is male.
583

584
        Returns:
585
            Whether this user is male.
586

587
        """
588
        return self.gender == "male"
1✔
589

590
    @property
1✔
591
    def is_female(self) -> bool:
1✔
592
        """Returns whether the user is female.
593

594
        Returns:
595
            Whether this user is female.
596

597
        """
598
        return self.gender == "female"
1✔
599

600
    @property
1✔
601
    def is_nonbinary(self) -> bool:
1✔
602
        """Returns whether the user is non-binary.
603

604
        Returns:
605
            Whether this user is non-binary.
606

607
        """
608
        return self.gender == "non-binary"
1✔
609

610
    @property
1✔
611
    def can_view_eighth(self) -> bool:
1✔
612
        """Checks if a user has the show_eighth permission.
613

614
        Returns:
615
            Whether this user has made their eighth period signups public.
616

617
        """
618

619
        return self.properties.attribute_is_visible("show_eighth")
1✔
620

621
    @property
1✔
622
    def can_view_phone(self) -> bool:
1✔
623
        """Checks if a user has the show_telephone permission.
624

625
        Returns:
626
            Whether this user has made their phone number public.
627

628
        """
629

630
        return self.properties.attribute_is_visible("show_telephone")
×
631

632
    @property
1✔
633
    def is_eighth_admin(self) -> bool:
1✔
634
        """Checks if user is an eighth period admin.
635

636
        Returns:
637
            Whether this user is an eighth period admin.
638

639
        """
640

641
        return self.has_admin_permission("eighth")
1✔
642

643
    @property
1✔
644
    def has_print_permission(self) -> bool:
1✔
645
        """Checks if user has the admin permission 'printing'.
646

647
        Returns:
648
            Whether this user is a printing administrator.
649

650
        """
651

652
        return self.has_admin_permission("printing")
1✔
653

654
    @property
1✔
655
    def is_parking_admin(self) -> bool:
1✔
656
        """Checks if user has the admin permission 'parking'.
657

658
        Returns:
659
            Whether this user is a parking administrator.
660

661
        """
662

663
        return self.has_admin_permission("parking")
1✔
664

665
    @property
1✔
666
    def is_bus_admin(self) -> bool:
1✔
667
        """Returns whether the user has the ``bus`` admin permission.
668

669
        Returns:
670
            Whether the user has the ``bus`` admin permission.
671

672
        """
673
        return self.has_admin_permission("bus")
×
674

675
    @property
1✔
676
    def can_request_parking(self) -> bool:
1✔
677
        """Checks if user can view the parking interface.
678

679
        Returns:
680
            Whether this user can view the parking interface and request a parking spot.
681

682
        """
683
        return self.grade_number >= 11 or self.is_parking_admin
1✔
684

685
    @property
1✔
686
    def is_announcements_admin(self) -> bool:
1✔
687
        """Checks if user is an announcements admin.
688

689
        Returns:
690
            Whether this user is an announcement admin.
691

692
        """
693

694
        return self.has_admin_permission("announcements")
1✔
695

696
    @property
1✔
697
    def is_schedule_admin(self) -> bool:
1✔
698
        """Checks if user is a schedule admin.
699

700
        Returns:
701
            Whether this user is a schedule admin.
702

703
        """
704

705
        return self.has_admin_permission("schedule")
1✔
706

707
    @property
1✔
708
    def is_board_admin(self) -> bool:
1✔
709
        """Checks if user is a board admin.
710

711
        Returns:
712
            Whether this user is a board admin.
713

714
        """
715

716
        return self.has_admin_permission("board")
×
717

718
    def can_manage_group(self, group: Union[Group, str]) -> bool:
1✔
719
        """Checks whether this user has permission to edit/manage the given group (either
720
        a Group or a group name).
721

722
        WARNING: Granting permission to edit/manage "admin_" groups gives that user control
723
        over nearly all data on Ion!
724

725
        Args:
726
            group: The group to check permissions for.
727

728
        Returns:
729
            Whether this user has permission to edit/manage the given group.
730

731
        """
732

733
        if isinstance(group, Group):
1✔
734
            group = group.name
1✔
735

736
        if group.startswith("admin_"):
1!
737
            return self.is_superuser
×
738

739
        return self.is_eighth_admin
1✔
740

741
    @property
1✔
742
    def is_teacher(self) -> bool:
1✔
743
        """Checks if user is a teacher.
744

745
        Returns:
746
            Whether this user is a teacher.
747

748
        """
749
        return self.user_type in ("teacher", "counselor")
1✔
750

751
    @property
1✔
752
    def is_student(self) -> bool:
1✔
753
        """Checks if user is a student.
754

755
        Returns:
756
            Whether this user is a student.
757

758
        """
759
        return self.user_type == "student"
1✔
760

761
    @property
1✔
762
    def is_alum(self) -> bool:
1✔
763
        """Checks if user is an alumnus.
764

765
        Returns:
766
            Whether this user is an alumnus.
767

768
        """
769
        return self.user_type == "alum"
×
770

771
    @property
1✔
772
    def is_senior(self) -> bool:
1✔
773
        """Checks if user is a student in Grade 12.
774

775
        Returns:
776
            Whether this user is a senior.
777

778
        """
779
        return self.is_student and self.grade_number == 12
1✔
780

781
    @property
1✔
782
    def is_eighthoffice(self) -> bool:
1✔
783
        """Checks if user is an Eighth Period office user.
784

785
        This is currently hardcoded, but is meant to be used instead
786
        of user.id == 9999 or user.username == "eighthoffice".
787

788
        Returns:
789
            Whether this user is an Eighth Period office user.
790

791
        """
792
        return self.id == 9999
1✔
793

794
    @property
1✔
795
    def is_active(self) -> bool:
1✔
796
        """Checks if the user is active.
797

798
        This is currently used to catch invalid logins.
799

800
        Returns:
801
            Whether the user is "active" -- i.e. their account is not locked.
802

803
        """
804

805
        return not self.username.startswith("INVALID_USER") and not self.user_locked
1✔
806

807
    @property
1✔
808
    def is_restricted(self) -> bool:
1✔
809
        """Checks if user needs the restricted view of Ion
810

811
        This applies to users that are user_type 'user', user_type 'alum'
812
        or user_type 'service'
813

814
        Returns:
815
            Whether this user should see a restricted view of Ion.
816

817
        """
818

819
        return self.user_type in ["user", "alum", "service"]
1✔
820

821
    @property
1✔
822
    def is_staff(self) -> bool:
1✔
823
        """Checks if a user should have access to the Django Admin interface.
824

825
        This has nothing to do with staff at TJ - `is_staff`
826
        has to be overridden to make this a valid user model.
827

828
        Returns:
829
            Whether the user should have access to the Django Admin interface.
830

831
        """
832

833
        return self.is_superuser or self.has_admin_permission("staff")
1✔
834

835
    @property
1✔
836
    def is_attendance_user(self) -> bool:
1✔
837
        """Checks if user is an attendance-only user.
838

839
        Returns:
840
            Whether this user is an attendance-only user.
841

842
        """
843
        return self.user_type == "user"
1✔
844

845
    @property
1✔
846
    def is_simple_user(self) -> bool:
1✔
847
        """Checks if user is a simple user (e.g. eighth office user)
848

849
        Returns:
850
            Whether this user is a simple user (e.g. eighth office user).
851

852
        """
853
        return self.user_type == "simple_user"
×
854

855
    @property
1✔
856
    def has_senior(self) -> bool:
1✔
857
        """Checks if a ``Senior`` model (see ``intranet.apps.seniors.models.Senior`` exists for the
858
        current user.
859

860
        Returns:
861
            Whether a ``Senior`` model (see ``intranet.apps.seniors.models.Senior`` exists for the
862
            current user.
863

864
        """
865
        try:
1✔
866
            self.senior
1✔
867
        except AttributeError:
1✔
868
            return False
1✔
869
        return True
×
870

871
    @property
1✔
872
    def is_attendance_taker(self) -> bool:
1✔
873
        """Checks if this user can take attendance for an eighth activity.
874

875
        Returns:
876
            Whether this  user can take attendance for an eighth activity.
877

878
        """
879
        return self.is_eighth_admin or self.is_teacher or self.is_attendance_user
1✔
880

881
    @property
1✔
882
    def is_eighth_sponsor(self) -> bool:
1✔
883
        """Determine whether the given user is associated with an.
884

885
        :class:`intranet.apps.eighth.models.EighthSponsor` and, therefore, should view activity
886
        sponsoring information.
887

888
        Returns:
889
            Whether this user is an eighth period sponsor.
890

891
        """
892
        return EighthSponsor.objects.filter(user=self).exists()
1✔
893

894
    @property
1✔
895
    def frequent_signups(self):
1✔
896
        """Return a QuerySet of activity id's and counts for the activities that a given user
897
        has signed up for more than `settings.SIMILAR_THRESHOLD` times"""
898
        key = "{}:frequent_signups".format(self.username)
×
899
        cached = cache.get(key)
×
900
        if cached:
×
901
            return cached
×
902
        freq_signups = (
×
903
            self.eighthsignup_set.exclude(scheduled_activity__activity__administrative=True)
904
            .exclude(scheduled_activity__activity__special=True)
905
            .exclude(scheduled_activity__activity__restricted=True)
906
            .exclude(scheduled_activity__activity__deleted=True)
907
            .values("scheduled_activity__activity")
908
            .annotate(count=Count("scheduled_activity__activity"))
909
            .filter(count__gte=settings.SIMILAR_THRESHOLD)
910
            .order_by("-count")
911
        )
912
        cache.set(key, freq_signups, timeout=60 * 60 * 24 * 7)
×
913
        return freq_signups
×
914

915
    @property
1✔
916
    def recommended_activities(self):
1✔
917
        key = "{}:recommended_activities".format(self.username)
1✔
918
        cached = cache.get(key)
1✔
919
        if cached is not None:
1!
920
            return cached
×
921
        acts = set()
1✔
922
        for signup in (
1✔
923
            self.eighthsignup_set.exclude(scheduled_activity__activity__administrative=True)
924
            .exclude(scheduled_activity__activity__special=True)
925
            .exclude(scheduled_activity__activity__restricted=True)
926
            .exclude(scheduled_activity__activity__deleted=True)
927
            .exclude(scheduled_activity__block__date__lte=(timezone.localtime() + relativedelta(months=-6)))
928
        ):
929
            acts.add(signup.scheduled_activity.activity)
1✔
930
        close_acts = set()
1✔
931
        for act in acts:
1✔
932
            sim = act.similarities.order_by("-weighted").first()
1✔
933
            if sim and sim.weighted > 1:
1!
934
                close_acts.add(sim.activity_set.exclude(id=act.id).first())
×
935
        cache.set(key, close_acts, timeout=60 * 60 * 24 * 7)
1✔
936
        return close_acts
1✔
937

938
    def archive_admin_comments(self):
1✔
939
        current_year = timezone.localdate().year
1✔
940
        previous_year = current_year - 1
1✔
941
        self.admin_comments = "\n=== {}-{} comments ===\n{}".format(previous_year, current_year, self.admin_comments)
1✔
942
        self.save(update_fields=["admin_comments"])
1✔
943

944
    def get_eighth_sponsor(self):
1✔
945
        """Return the ``EighthSponsor`` that this user is associated with.
946

947
        Returns:
948
            The ``EighthSponsor`` that this user is associated with.
949

950
        """
951
        try:
1✔
952
            sp = EighthSponsor.objects.get(user=self)
1✔
953
        except EighthSponsor.DoesNotExist:
1✔
954
            return False
1✔
955

956
        return sp
1✔
957

958
    def has_unvoted_polls(self) -> bool:
1✔
959
        """Returns whether there are open polls thet this user has not yet voted in.
960

961
        Returns:
962
            Whether there are open polls thet this user has not yet voted in.
963

964
        """
965
        now = timezone.localtime()
1✔
966
        return Poll.objects.visible_to_user(self).filter(start_time__lt=now, end_time__gt=now).exclude(question__answer__user=self).exists()
1✔
967

968
    def signed_up_today(self) -> bool:
1✔
969
        """If the user is a student, returns whether they are signed up for an activity during
970
        all eighth period blocks that are scheduled today. Otherwise, returns ``True``.
971

972
        Returns:
973
            If the user is a student, returns whether they are signed up for an activity during
974
            all eighth period blocks that are scheduled today. Otherwise, returns ``True``.
975

976
        """
977
        if not self.is_student:
1!
978
            return True
×
979

980
        return not EighthBlock.objects.get_blocks_today().exclude(eighthscheduledactivity__eighthsignup_set__user=self).exists()
1✔
981

982
    def signed_up_next_few_days(self, *, num_days: int = 3) -> bool:
1✔
983
        """If the user is a student, returns whether they are signed up for an activity during
984
        all eighth period blocks in the next ``num_days`` days. Otherwise, returns ``True``.
985

986
        Today is counted as a day, so ``signed_up_few_next_day(num_days=1)`` is equivalent to
987
        ``signed_up_today()``.
988

989
        Args:
990
            num_days: The number of days (including today) on which to search for blocks during
991
                which the user is signed up.
992

993
        Returns:
994
            If the user is a student, returns whether they are signed up for an activity during
995
            all eighth period blocks in the next ``num_days`` days. Otherwise, returns ``True``.
996

997
        """
998
        if not self.is_student:
1✔
999
            return True
1✔
1000

1001
        today = timezone.localdate()
1✔
1002
        end_date = today + timedelta(days=num_days - 1)
1✔
1003

1004
        return (
1✔
1005
            not EighthBlock.objects.filter(date__gte=today, date__lte=end_date).exclude(eighthscheduledactivity__eighthsignup_set__user=self).exists()
1006
        )
1007

1008
    def absence_count(self) -> int:
1✔
1009
        """Return the user's absence count.
1010

1011
        If the user has no absences or is not a signup user, returns 0.
1012

1013
        Returns:
1014
            The number of absences this user has.
1015

1016
        """
1017
        return EighthSignup.objects.filter(user=self, was_absent=True, scheduled_activity__attendance_taken=True).count()
1✔
1018

1019
    def absence_info(self):
1✔
1020
        """Returns a ``QuerySet`` of the ``EighthSignup``s for which this user was absent.
1021

1022
        Returns:
1023
            A ``QuerySet`` of the ``EighthSignup``s for which this user was absent.
1024

1025
        """
1026
        return EighthSignup.objects.filter(user=self, was_absent=True, scheduled_activity__attendance_taken=True)
1✔
1027

1028
    def handle_delete(self):
1✔
1029
        """Handle a graduated user being deleted."""
1030
        from intranet.apps.eighth.models import EighthScheduledActivity  # pylint: disable=import-outside-toplevel
1✔
1031

1032
        EighthScheduledActivity.objects.filter(eighthsignup_set__user=self).update(archived_member_count=F("archived_member_count") + 1)
1✔
1033

1034
    def __getattr__(self, name):
1✔
1035
        if name == "properties":
1✔
1036
            return UserProperties.objects.get_or_create(user=self)[0]
1✔
1037
        elif name == "dark_mode_properties":
1✔
1038
            return UserDarkModeProperties.objects.get_or_create(user=self)[0]
1✔
1039
        raise AttributeError("{!r} object has no attribute {!r}".format(type(self).__name__, name))
1✔
1040

1041
    def __str__(self):
1✔
1042
        return self.username or self.ion_username or str(self.id)
1✔
1043

1044
    def __int__(self):
1✔
1045
        return self.id
×
1046

1047

1048
class UserProperties(models.Model):
1✔
1049
    user = models.OneToOneField(settings.AUTH_USER_MODEL, related_name="properties", on_delete=models.CASCADE)
1✔
1050

1051
    _address = models.OneToOneField("Address", null=True, blank=True, on_delete=models.SET_NULL)
1✔
1052
    _schedule = models.ManyToManyField("Section", related_name="_students")
1✔
1053
    """ User preference permissions (privacy options)
×
1054
        When setting permissions, use set_permission(permission, value , parent=False)
1055
        The permission attribute should be the part after "self_" or "parent_"
1056
            e.g. show_pictures
1057
        If you're setting permission of the student, but the parent permission is false,
1058
        the method will fail and return False.
1059

1060
        To define a new permission, just create two new BooleanFields in the same
1061
        pattern as below.
1062
    """
1063
    self_show_pictures = models.BooleanField(default=False)
1✔
1064
    parent_show_pictures = models.BooleanField(default=False)
1✔
1065

1066
    self_show_address = models.BooleanField(default=False)
1✔
1067
    parent_show_address = models.BooleanField(default=False)
1✔
1068

1069
    self_show_telephone = models.BooleanField(default=False)
1✔
1070
    parent_show_telephone = models.BooleanField(default=False)
1✔
1071

1072
    self_show_eighth = models.BooleanField(default=False)
1✔
1073
    parent_show_eighth = models.BooleanField(default=False)
1✔
1074

1075
    self_show_schedule = models.BooleanField(default=False)
1✔
1076
    parent_show_schedule = models.BooleanField(default=False)
1✔
1077

1078
    def __getattr__(self, name):
1✔
1079
        if name.startswith("self") or name.startswith("parent"):
1✔
1080
            return object.__getattribute__(self, name)
1✔
1081
        if name == "address":
1✔
1082
            return self._address if self.attribute_is_visible("show_address") else None
1✔
1083
        if name == "schedule":
1✔
1084
            return self._schedule if self.attribute_is_visible("show_schedule") else None
1✔
1085
        raise AttributeError("{!r} object has no attribute {!r}".format(type(self).__name__, name))
1✔
1086

1087
    def __setattr__(self, name, value):
1✔
1088
        if name == "address":
1!
1089
            if self.attribute_is_visible("show_address"):
×
1090
                self._address = value
×
1091
        super().__setattr__(name, value)  # pylint: disable=no-member; Pylint is wrong
1✔
1092

1093
    def __str__(self):
1✔
1094
        return self.user.__str__()
×
1095

1096
    def set_permission(self, permission: str, value: bool, parent: bool = False, admin: bool = False) -> bool:
1✔
1097
        """Sets permission for personal information.
1098

1099
        Returns False silently if unable to set permission. Returns True if successful.
1100

1101
        Args:
1102
            permission: The name of the permission to set.
1103
            value: The value to set the permission to.
1104
            parent: Whether to set the parent's permission instead of the student's permission. If
1105
                ``parent`` is ``True`` and ``value`` is ``False``, both the parent and the student's
1106
                permissions will be set to ``False``.
1107
            admin: If set to ``True``, this will allow changing the student's permission even if the
1108
                parent's permission is set to ``False`` (normally, this causes an error).
1109

1110
        """
1111
        try:
1✔
1112
            if not getattr(self, "parent_{}".format(permission)) and not parent and not admin:
1!
1113
                return False
×
1114
            level = "parent" if parent else "self"
1✔
1115
            setattr(self, "{}_{}".format(level, permission), value)
1✔
1116

1117
            update_fields = ["{}_{}".format(level, permission)]
1✔
1118

1119
            # Set student permission to false if parent sets permission to false.
1120
            if parent and not value:
1!
1121
                setattr(self, "self_{}".format(permission), False)
×
1122
                update_fields.append("self_{}".format(permission))
×
1123

1124
            self.save(update_fields=update_fields)
1✔
1125
            return True
1✔
1126
        except Exception as e:
1✔
1127
            logger.error("Error occurred setting permission %s to %s: %s", permission, value, e)
1✔
1128
            return False
1✔
1129

1130
    def _current_user_override(self) -> bool:
1✔
1131
        """Return whether the currently logged in user is a teacher, and can view all of a student's
1132
        information regardless of their privacy settings.
1133

1134
        Returns:
1135
            Whether the currently logged in user can view all of a student's information regardless
1136
            of their privacy settings.
1137

1138
        """
1139
        try:
1✔
1140
            # threadlocals is a module, not an actual thread locals object
1141
            request = threadlocals.request()
1✔
1142
            if request is None:
1!
1143
                return False
×
1144
            requesting_user = request.user
1✔
1145
            if isinstance(requesting_user, AnonymousUser) or not requesting_user.is_authenticated:
1!
1146
                return False
×
1147
            can_view_anyway = requesting_user and (requesting_user.is_teacher or requesting_user.is_eighthoffice or requesting_user.is_eighth_admin)
1✔
1148
        except (AttributeError, KeyError) as e:
×
1149
            logger.error("Could not check teacher/eighth override: %s", e)
×
1150
            can_view_anyway = False
×
1151
        return can_view_anyway
1✔
1152

1153
    def is_http_request_sender(self) -> bool:
1✔
1154
        """Checks if a user the HTTP request sender (accessing own info)
1155

1156
        Used primarily to load private personal information from the
1157
        cache. (A student should see all info on his or her own profile
1158
        regardless of how the permissions are set.)
1159

1160
        Returns:
1161
            Whether the user is the sender of the current HTTP request.
1162

1163
        """
1164
        try:
1✔
1165
            # threadlocals is a module, not an actual thread locals object
1166
            request = threadlocals.request()
1✔
1167
            if request and request.user and request.user.is_authenticated:
1!
1168
                requesting_user_id = request.user.id
1✔
1169
                return str(requesting_user_id) == str(self.user.id)
1✔
1170
        except (AttributeError, KeyError) as e:
×
1171
            logger.error("Could not check request sender: %s", e)
×
1172
            return False
×
1173

1174
        return False
×
1175

1176
    def attribute_is_visible(self, permission: str) -> bool:
1✔
1177
        """Checks privacy options to see if an attribute is visible to the user sending the current
1178
        HTTP request.
1179

1180
        Args:
1181
            permission: The name of the permission to check.
1182

1183
        Returns:
1184
            Whether the user sending the current HTTP request has permission to view the given
1185
            permission.
1186

1187
        """
1188
        try:
1✔
1189
            parent = getattr(self, "parent_{}".format(permission))
1✔
1190
            student = getattr(self, "self_{}".format(permission))
1✔
1191
        except Exception:
×
1192
            logger.error("Could not retrieve permissions for %s", permission)
×
1193

1194
        return (parent and student) or (self.is_http_request_sender() or self._current_user_override())
1✔
1195

1196
    def attribute_is_public(self, permission: str) -> bool:
1✔
1197
        """Checks if attribute is visible to public (ignoring whether the user sending the HTTP
1198
        request has permission to access it).
1199

1200
        Args:
1201
            permission: The name of the permission to check.
1202

1203
        Returns:
1204
            Whether the given permission is public.
1205

1206
        """
1207
        try:
×
1208
            parent = getattr(self, "parent_{}".format(permission))
×
1209
            student = getattr(self, "self_{}".format(permission))
×
1210
        except Exception:
×
1211
            logger.error("Could not retrieve permissions for %s", permission)
×
1212

1213
        return parent and student
×
1214

1215

1216
PERMISSIONS_NAMES = {
1✔
1217
    prefix: [name[len(prefix) + 1:] for name in dir(UserProperties) if name.startswith(prefix + "_")] for prefix in ["self", "parent"]
1218
}
1219

1220

1221
class UserDarkModeProperties(models.Model):
1✔
1222
    """
1223
    Contains user properties relating to dark mode
1224
    """
1225

1226
    user = models.OneToOneField(settings.AUTH_USER_MODEL, related_name="dark_mode_properties", on_delete=models.CASCADE)
1✔
1227
    dark_mode_enabled = models.BooleanField(default=False)
1✔
1228

1229
    def __str__(self):
1✔
1230
        return str(self.user)
×
1231

1232

1233
class Email(models.Model):
1✔
1234
    """Represents an email address"""
1235

1236
    address = models.EmailField()
1✔
1237
    user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="emails", on_delete=models.CASCADE)
1✔
1238

1239
    def __str__(self):
1✔
1240
        return self.address
1✔
1241

1242
    class Meta:
1✔
1243
        unique_together = ("user", "address")
1✔
1244

1245

1246
class Phone(models.Model):
1✔
1247
    """Represents a phone number"""
1248

1249
    PURPOSES = (("h", "Home Phone"), ("m", "Mobile Phone"), ("o", "Other Phone"))
1✔
1250

1251
    purpose = models.CharField(max_length=1, choices=PURPOSES, default="o")
1✔
1252
    user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="phones", on_delete=models.CASCADE)
1✔
1253
    _number = PhoneField()  # validators should be a list
1✔
1254

1255
    def __setattr__(self, name, value):
1✔
1256
        if name == "number":
1!
1257
            if self.user.properties.attribute_is_visible("show_telephone"):
×
1258
                self._number = value
×
1259
                self.save(update_fields=["_number"])
×
1260
        else:
1261
            super().__setattr__(name, value)  # pylint: disable=no-member; Pylint is wrong
1✔
1262

1263
    def __getattr__(self, name):
1✔
1264
        if name == "number":
×
1265
            return self._number if self.user.properties.attribute_is_visible("show_telephone") else None
×
1266
        raise AttributeError("{!r} object has no attribute {!r}".format(type(self).__name__, name))
×
1267

1268
    def __str__(self):
1✔
1269
        return "{}: {}".format(self.get_purpose_display(), self.number)
×
1270

1271
    class Meta:
1✔
1272
        unique_together = ("user", "_number")
1✔
1273

1274

1275
class Website(models.Model):
1✔
1276
    """Represents a user's website"""
1277

1278
    url = models.URLField()
1✔
1279
    user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="websites", on_delete=models.CASCADE)
1✔
1280

1281
    def __str__(self):
1✔
1282
        return self.url
×
1283

1284
    class Meta:
1✔
1285
        unique_together = ("user", "url")
1✔
1286

1287

1288
class Address(models.Model):
1✔
1289
    """Represents a user's address.
1290

1291
    Attributes:
1292
        street
1293
            The street name of the address.
1294
        city
1295
            The city name of the address.
1296
        state
1297
            The state name of the address.
1298
        postal_code
1299
            The zip code of the address.
1300

1301
    """
1302

1303
    street = models.CharField(max_length=255)
1✔
1304
    city = models.CharField(max_length=40)
1✔
1305
    state = models.CharField(max_length=20)
1✔
1306
    postal_code = models.CharField(max_length=20)
1✔
1307

1308
    def __str__(self):
1✔
1309
        """Returns full address string."""
1310
        return "{}\n{}, {} {}".format(self.street, self.city, self.state, self.postal_code)
1✔
1311

1312

1313
class Photo(models.Model):
1✔
1314
    """Represents a user photo"""
1315

1316
    grade_number = models.IntegerField(choices=GRADE_NUMBERS)
1✔
1317
    _binary = models.BinaryField()
1✔
1318
    user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="photos", on_delete=models.CASCADE)
1✔
1319

1320
    def __setattr__(self, name, value):
1✔
1321
        if name == "binary":
1!
1322
            if self.user.properties.attribute_is_visible("show_pictures"):
×
1323
                self._binary = value
×
1324
                self.save(update_fields=["_binary"])
×
1325
        else:
1326
            super().__setattr__(name, value)  # pylint: disable=no-member; Pylint is wrong
1✔
1327

1328
    def __getattr__(self, name):
1✔
1329
        if name == "binary":
×
1330
            return self._binary if self.user.properties.attribute_is_visible("show_pictures") else None
×
1331
        raise AttributeError("{!r} object has no attribute {!r}".format(type(self).__name__, name))
×
1332

1333
    @cached_property
1✔
1334
    def base64(self) -> Optional[bytes]:
1✔
1335
        """Returns base64 encoded binary data for a user's picture.
1336

1337
        Returns:
1338
           Base 64-encoded binary data for a user's picture.
1339

1340
        """
1341

1342
        binary = self.binary
×
1343
        if binary:
×
1344
            return b64encode(binary)
×
1345
        return None
×
1346

1347

1348
class Grade:
1✔
1349
    """Represents a user's grade."""
1350

1351
    names = [elem[1] for elem in GRADE_NUMBERS]
1✔
1352

1353
    def __init__(self, graduation_year):
1✔
1354
        """Initialize the Grade object.
1355

1356
        Args:
1357
            graduation_year
1358
                The numerical graduation year of the user
1359

1360
        """
1361
        if graduation_year is None:
1✔
1362
            self._number = 13
1✔
1363
        else:
1364
            self._number = get_senior_graduation_year() - int(graduation_year) + 12
1✔
1365

1366
        if 9 <= self._number <= 12:
1✔
1367
            self._name = [elem[1] for elem in GRADE_NUMBERS if elem[0] == self._number][0]
1✔
1368
        else:
1369
            self._name = "graduate"
1✔
1370

1371
    @property
1✔
1372
    def number(self) -> int:
1✔
1373
        """Return the grade as a number (9-12).
1374

1375
        For use in templates since there is no nice integer casting.
1376
        In Python code you can also use int() on a Grade object.
1377

1378
        """
1379
        return self._number
1✔
1380

1381
    @property
1✔
1382
    def name(self) -> str:
1✔
1383
        """Return the grade's name (e.g. senior)"""
1384
        return self._name
1✔
1385

1386
    @property
1✔
1387
    def name_plural(self) -> str:
1✔
1388
        """Return the grade's plural name (e.g. freshmen)"""
1389
        return "freshmen" if (self._number and self._number == 9) else "{}s".format(self._name) if self._name else ""
1✔
1390

1391
    @property
1✔
1392
    def text(self) -> str:
1✔
1393
        """Return the grade's number as a string (e.g. Grade 12, Graduate)"""
1394
        if 9 <= self._number <= 12:
×
1395
            return "Grade {}".format(self._number)
×
1396
        else:
1397
            return self._name
×
1398

1399
    @staticmethod
1✔
1400
    def number_from_name(name: str) -> Optional[int]:
1✔
1401
        if name in Grade.names:
×
1402
            return Grade.names.index(name) + 9
×
1403
        return None
×
1404

1405
    @classmethod
1✔
1406
    def grade_from_year(cls, graduation_year: int) -> int:
1✔
1407
        today = timezone.localdate()
×
1408
        if today.month >= settings.YEAR_TURNOVER_MONTH:
×
1409
            current_senior_year = today.year + 1
×
1410
        else:
1411
            current_senior_year = today.year
×
1412

1413
        return current_senior_year - graduation_year + 12
×
1414

1415
    @classmethod
1✔
1416
    def year_from_grade(cls, grade: int) -> int:
1✔
1417
        today = timezone.localdate()
1✔
1418
        if today.month > settings.YEAR_TURNOVER_MONTH:
1!
1419
            current_senior_year = today.year + 1
×
1420
        else:
1421
            current_senior_year = today.year
1✔
1422

1423
        return current_senior_year + 12 - grade
1✔
1424

1425
    def __int__(self):
1✔
1426
        """Return the grade as a number (9-12)."""
1427
        return self._number
1✔
1428

1429
    def __str__(self):
1✔
1430
        """Return name of the grade."""
1431
        return self._name
1✔
1432

1433

1434
class Course(models.Model):
1✔
1435
    """Represents a course at TJ (not to be confused with section)"""
1436

1437
    name = models.CharField(max_length=50)
1✔
1438
    course_id = models.CharField(max_length=12, unique=True)
1✔
1439

1440
    def __str__(self):
1✔
1441
        return "{} ({})".format(self.name, self.course_id)
1✔
1442

1443
    class Meta:
1✔
1444
        ordering = ("name", "course_id")
1✔
1445

1446

1447
class Section(models.Model):
1✔
1448
    """Represents a section - a class with teacher, period, and room assignments"""
1449

1450
    course = models.ForeignKey(Course, related_name="sections", on_delete=models.CASCADE)
1✔
1451
    teacher = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL)
1✔
1452
    room = models.CharField(max_length=16)
1✔
1453
    period = models.IntegerField()
1✔
1454
    section_id = models.CharField(max_length=16, unique=True)
1✔
1455
    sem = models.CharField(max_length=2)
1✔
1456

1457
    def __str__(self):
1✔
1458
        return "{} ({}) - {} Pd. {}".format(self.course.name, self.section_id, self.teacher.full_name if self.teacher else "Unknown", self.period)
1✔
1459

1460
    def __getattr__(self, name):
1✔
1461
        if name == "students":
1✔
1462
            return [s.user for s in self._students.all() if s.attribute_is_visible("show_schedule")]
1✔
1463
        raise AttributeError("{!r} object has no attribute {!r}".format(type(self).__name__, name))
1✔
1464

1465
    class Meta:
1✔
1466
        ordering = ("section_id", "period")
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