Repository: dotkom/onlineweb4

Issue:
Prikk and delayed signup doesn’t work as expected
<!-- If this is a security issue or information leakage (having access to something you (probably) shouldn't), please send an email rather than opening a public issue. -->

## What kind of an issue is this?

- Bug report


## What is the expected behaviour?
The prikk timer starts after the delayed signup has ended

## What is the current behaviour?

<!-- if this is a bug report -->
The prikk-timer and delayed signup timer starts at the same time (or something similar)


## How do you reproduce this problem? 

<!-- if this is a bug report -->
<!-- provide steps to reproduce this problem, preferably in a bullet point list -->


## Other information

<!-- include screenshots if appropriate -->
<!-- add labels you see fit. please do not exaggerate. -->



Discussion:

Resolving patch:
diff --git a/apps/events/models.py b/apps/events/models.py
--- a/apps/events/models.py
+++ b/apps/events/models.py
@@ -4,6 +4,7 @@
 from collections import OrderedDict
 from datetime import datetime, timedelta
 from random import choice as random_choice
+from typing import List
 
 from django.conf import settings
 from django.contrib.auth.models import Group
@@ -34,6 +35,8 @@
 from apps.marks.models import get_expiration_date
 from apps.payment import status as payment_status
 
+logger = logging.getLogger(__name__)
+
 User = settings.AUTH_USER_MODEL
 
 # Managers
@@ -304,65 +307,83 @@ class Rule(models.Model):
     Super class for a rule object
     """
 
+    success_code = 213
+    not_opened_code = 402
+    delayed_signup_code = 423
+    delayed_singup_message = _("Du har utsatt påmelding")
+    not_satisfied_code = 413
+    not_satisfied_message = _("Du har ikke tilgang til å melde deg på arrangementet")
+
     offset = models.PositiveSmallIntegerField(
         _("utsettelse"), help_text=_("utsettelse oppgis i timer"), default=0
     )
 
-    def get_offset_time(self, time):
+    def get_offset_time(self, time: timezone.datetime):
         if type(time) is not datetime:
             raise TypeError("time must be a datetime, not %s" % type(time))
         else:
             return time + timedelta(hours=self.offset)
 
-    def satisfied(self, user):
-        """ Checks if a user satisfies the rules """
+    def satisfies_constraint(self, user: User) -> bool:
         return True
 
-    def __str__(self):
-        return "Rule"
-
-    class Meta:
-        permissions = (("view_rule", "View Rule"),)
-        default_permissions = ("add", "change", "delete")
-
-
-class FieldOfStudyRule(Rule):
-    field_of_study = models.SmallIntegerField(
-        _("studieretning"), choices=FieldOfStudyType.ALL_CHOICES
-    )
-
-    def satisfied(self, user, registration_start):
+    def satisfied(self, user: User, registration_start: timezone.datetime):
         """ Override method """
 
-        # If the user has the same FOS as this rule
-        if self.field_of_study == user.field_of_study:
+        if self.satisfies_constraint(user):
             offset_datetime = self.get_offset_time(registration_start)
             # If the offset is in the past, it means you can attend even with the offset
             if offset_datetime < timezone.now():
-                return {"status": True, "message": None, "status_code": 210}
+                return {
+                    "status": True,
+                    "message": None,
+                    "status_code": self.success_code,
+                }
             # If there is no offset, the signup just hasn't started yet
             elif self.offset == 0:
                 return {
                     "status": False,
                     "message": _("Påmeldingen har ikke åpnet enda."),
-                    "status_code": 402,
+                    "status_code": self.not_opened_code,
                 }
             # In the last case there is a delayed signup
             else:
                 return {
                     "status": False,
-                    "message": _("Din studieretning har utsatt påmelding."),
+                    "message": self.delayed_singup_message,
                     "offset": offset_datetime,
-                    "status_code": 420,
+                    "status_code": self.delayed_signup_code,
                 }
         return {
             "status": False,
-            "message": _(
-                "Din studieretning er en annen enn de som har tilgang til dette arrangementet."
-            ),
-            "status_code": 410,
+            "message": self.not_satisfied_message,
+            "status_code": self.not_satisfied_code,
         }
 
+    def __str__(self):
+        return "Rule"
+
+    class Meta:
+        permissions = (("view_rule", "View Rule"),)
+        default_permissions = ("add", "change", "delete")
+
+
+class FieldOfStudyRule(Rule):
+    success_code = 210
+    delayed_signup_code = 420
+    delayed_singup_message = _("Din studieretning har utsatt påmelding.")
+    not_satisfied_code = 410
+    not_satisfied_message = _(
+        "Din studieretning er en annen enn de som har tilgang til dette arrangementet."
+    )
+
+    field_of_study = models.SmallIntegerField(
+        _("studieretning"), choices=FieldOfStudyType.ALL_CHOICES
+    )
+
+    def satisfies_constraint(self, user: User) -> bool:
+        return self.field_of_study == user.field_of_study
+
     def __str__(self):
         if self.offset > 0:
             time_unit = _("timer") if self.offset > 1 else _("time")
@@ -379,37 +400,18 @@ class Meta:
 
 
 class GradeRule(Rule):
+    success_code = 211
+    delayed_signup_code = 421
+    delayed_singup_message = _("Ditt klassetrinn har utsatt påmelding.")
+    not_satisfied_code = 411
+    not_satisfied_message = _(
+        "Ditt klassetrinn har ikke tilgang til dette arrangementet.."
+    )
+
     grade = models.SmallIntegerField(_("klassetrinn"), null=False)
 
-    def satisfied(self, user, registration_start):
-        """ Override method """
-
-        # If the user has the same FOS as this rule
-        if self.grade == user.year:
-            offset_datetime = self.get_offset_time(registration_start)
-            # If the offset is in the past, it means you can attend even with the offset
-            if offset_datetime < timezone.now():
-                return {"status": True, "message": None, "status_code": 211}
-            # If there is no offset, the signup just hasn't started yet
-            elif self.offset == 0:
-                return {
-                    "status": False,
-                    "message": _("Påmeldingen har ikke åpnet enda."),
-                    "status_code": 402,
-                }
-            # In the last case there is a delayed signup
-            else:
-                return {
-                    "status": False,
-                    "message": _("Ditt klassetrinn har utsatt påmelding."),
-                    "offset": offset_datetime,
-                    "status_code": 421,
-                }
-        return {
-            "status": False,
-            "message": _("Ditt klassetrinn har ikke tilgang til dette arrangementet."),
-            "status_code": 411,
-        }
+    def satisfies_constraint(self, user: User) -> bool:
+        return self.grade == user.year
 
     def __str__(self):
         if self.offset > 0:
@@ -423,35 +425,18 @@ class Meta:
 
 
 class UserGroupRule(Rule):
+    success_code = 212
+    delayed_signup_code = 422
+    delayed_singup_message = _("Brukergruppene dine har utsatt påmelding")
+    not_satisfied_code = 412
+    not_satisfied_message = _(
+        "Din brukergruppe har ikke tilgang til dette arrangementet."
+    )
+
     group = models.ForeignKey(Group, blank=False, null=False, on_delete=models.CASCADE)
 
-    def satisfied(self, user, registration_start):
-        """ Override method """
-        if self.group in user.groups.all():
-            offset_datetime = self.get_offset_time(registration_start)
-            # If the offset is in the past, it means you can attend even with the offset
-            if offset_datetime < timezone.now():
-                return {"status": True, "message": None, "status_code": 212}
-            # If there is no offset, the signup just hasn't started yet
-            elif self.offset == 0:
-                return {
-                    "status": False,
-                    "message": _("Påmeldingen er ikke åpnet enda."),
-                    "status_code": 402,
-                }
-            # In the last case there is a delayed signup
-            else:
-                return {
-                    "status": False,
-                    "message": _("%s har utsatt påmelding.") % self.group,
-                    "offset": offset_datetime,
-                    "status_code": 422,
-                }
-        return {
-            "status": False,
-            "message": _("Din brukergruppe har ikke tilgang til dette arrangementet."),
-            "status_code": 412,
-        }
+    def satisfies_constraint(self, user: User) -> bool:
+        return self.group in user.groups.all()
 
     def __str__(self):
         if self.offset > 0:
@@ -477,7 +462,7 @@ class RuleBundle(models.Model):
     user_group_rules = models.ManyToManyField(UserGroupRule, blank=True)
 
     def get_all_rules(self):
-        rules = []
+        rules: List[Rule] = []
         rules.extend(self.field_of_study_rules.all())
         rules.extend(self.grade_rules.all())
         rules.extend(self.user_group_rules.all())
@@ -500,6 +485,17 @@ def satisfied(self, user, registration_start):
 
         return errors
 
+    def get_minimum_offset_for_user(self, user: User):
+        offsets = [
+            rule.offset
+            for rule in self.get_all_rules()
+            if rule.satisfies_constraint(user)
+        ]
+        if len(offsets) == 0:
+            return timezone.timedelta(hours=0)
+        offsets.sort()
+        return timezone.timedelta(hours=offsets[0])
+
     def __str__(self):
         if self.description:
             return self.description
@@ -834,6 +830,7 @@ def is_eligible_for_signup(self, user):
         # Are there any rules preventing me from attending?
         # This should be checked last of the offsets, because it can completely deny you access.
         response = self.rules_satisfied(user)
+
         if not response["status"]:
             if "offset" not in response:
                 return response
@@ -878,12 +875,25 @@ def is_eligible_for_signup(self, user):
         response["status"] = True
         return response
 
-    def _check_marks(self, response, user):
+    def get_minimum_rule_offset_for_user(self, user: User):
+        offsets_deltas = [
+            rule_bundle.get_minimum_offset_for_user(user)
+            for rule_bundle in self.rule_bundles.all()
+        ]
+        if len(offsets_deltas) == 0:
+            return timezone.timedelta(hours=0)
+        offsets_deltas.sort()
+        return offsets_deltas[0]
+
+    def _check_marks(self, response: dict, user: User):
         expiry_date = get_expiration_date(user)
         if expiry_date and expiry_date > timezone.now().date():
             # Offset is currently 1 day if you have marks, regardless of amount.
             mark_offset = timedelta(days=1)
-            postponed_registration_start = self.registration_start + mark_offset
+            rule_offset = self.get_minimum_rule_offset_for_user(user)
+            postponed_registration_start = (
+                self.registration_start + rule_offset + mark_offset
+            )
 
             before_expiry = self.registration_start.date() < expiry_date
 
@@ -985,9 +995,6 @@ def get_payment(self):
         except ObjectDoesNotExist:
             return None
         except MultipleObjectsReturned:
-            import logging
-
-            logger = logging.getLogger(__name__)
             logger.warning(
                 "Multiple payment objects connected to attendance event #%s." % self.pk
             )
@@ -1178,7 +1185,6 @@ def is_on_waitlist(self):
 
     # Unattend user from event
     def unattend(self, admin_user):
-        logger = logging.getLogger(__name__)
         logger.info(
             'User %s was removed from event "%s" by %s on %s'
             % (self.user.get_full_name(), self.event, admin_user, datetime.now())


Test patch:
diff --git a/apps/events/tests/model_tests.py b/apps/events/tests/model_tests.py
--- a/apps/events/tests/model_tests.py
+++ b/apps/events/tests/model_tests.py
@@ -48,9 +48,11 @@ def test_company_event_method_returns_company_events(self):
 
 class AttendanceEventModelTest(TestCase):
     def setUp(self):
-        self.event = G(Event, title="Sjakkturnering")
-        self.attendance_event = G(AttendanceEvent, event=self.event, max_capacity=2)
-        self.user = G(
+        self.event: Event = G(Event, title="Sjakkturnering")
+        self.attendance_event: AttendanceEvent = G(
+            AttendanceEvent, event=self.event, max_capacity=2
+        )
+        self.user: User = G(
             User,
             username="ola123",
             ntnu_username="ola123ntnu",
@@ -64,18 +66,18 @@ def setUp(self):
         )
         self.attendance_event.registration_end = self.now + datetime.timedelta(days=7)
         # Making the user a member.
-        self.allowed_username = G(
+        self.allowed_username: AllowedUsername = G(
             AllowedUsername,
             username="ola123ntnu",
             expiration_date=self.now + datetime.timedelta(weeks=1),
         )
 
     def create_attendance_event(self):
-        event = G(Event)
+        event: Event = G(Event)
         return G(AttendanceEvent, event=event)
 
-    def add_feedback(self, attendance_event):
-        feedback = G(Feedback)
+    def add_feedback(self, attendance_event: AttendanceEvent) -> FeedbackRelation:
+        feedback: Feedback = G(Feedback)
         return G(
             FeedbackRelation,
             feedback=feedback,
@@ -83,10 +85,10 @@ def add_feedback(self, attendance_event):
             content_type=ContentType.objects.get_for_model(Event),
         )
 
-    def add_reservation(self, attendance_event):
+    def add_reservation(self, attendance_event: AttendanceEvent) -> Reservation:
         return G(Reservation, attendance_event=attendance_event)
 
-    def test_attendanceEventUnicode_IsCorrect(self):
+    def test_attendance_event_unicode_is_correct(self):
         self.assertEqual(self.attendance_event.__str__(), "Sjakkturnering")
 
     def test_get_feedback(self):
@@ -304,6 +306,53 @@ def test_user_group_rule(self):
         self.assertTrue(response["status"])
         self.assertEqual(212, response["status_code"])
 
+    def test_rule_offset_and_mark_offset(self):
+        group: Group = G(Group, name="Testgroup")
+        group_rule: GradeRule = G(UserGroupRule, group=group, offset=24)
+        rule_bundle: RuleBundle = G(RuleBundle, description="")
+        rule_bundle.user_group_rules.add(group_rule)
+        self.attendance_event.rule_bundles.add(rule_bundle)
+        # Registration just opened
+        self.attendance_event.registration_start = timezone.now() - timezone.timedelta(
+            minutes=1
+        )
+
+        # User should not be able to register because of the group rule offset
+        self.user.groups.add(group)
+        response = self.attendance_event.is_eligible_for_signup(self.user)
+        self.assertFalse(response["status"])
+        self.assertEqual(422, response["status_code"])
+
+        # Simulate a day passing for the offset to not have an effect anymore
+        self.attendance_event.registration_start -= timezone.timedelta(hours=24)
+        self.attendance_event.save()
+
+        # User should be able to register for event since offset has passed
+        response = self.attendance_event.is_eligible_for_signup(self.user)
+        self.assertTrue(response["status"])
+        self.assertEqual(212, response["status_code"])
+
+        mark: Mark = G(Mark, title="Test mark")
+        G(
+            MarkUser,
+            user=self.user,
+            mark=mark,
+            expiration_date=self.now + datetime.timedelta(days=DURATION),
+        )
+
+        # User should not be eligible for signup anymore because they have a mark
+        response = self.attendance_event.is_eligible_for_signup(self.user)
+        self.assertFalse(response["status"])
+        self.assertEqual(401, response["status_code"])
+
+        # Set registration to be as far into the past as both the offset from the rule and the mark
+        self.attendance_event.registration_start -= timezone.timedelta(hours=24)
+        self.attendance_event.save()
+
+        response = self.attendance_event.is_eligible_for_signup(self.user)
+        self.assertTrue(response["status"])
+        self.assertEqual(212, response["status_code"])
+
     def test_future_access_trumps_offset(self):
         # Create two different rules, and verify that the response is false, but without offset
         # Group rule