diff --git a/.gitignore b/.gitignore index 8e3486b6e..82bb15025 100644 --- a/.gitignore +++ b/.gitignore @@ -74,3 +74,6 @@ google-credentials.json # vscode .vscode/ + +# mac +.DS_Store diff --git a/lego/.DS_Store b/lego/.DS_Store new file mode 100644 index 000000000..feb765f58 Binary files /dev/null and b/lego/.DS_Store differ diff --git a/lego/api/v1.py b/lego/api/v1.py index f6edd1bf0..6c92999ac 100644 --- a/lego/api/v1.py +++ b/lego/api/v1.py @@ -70,7 +70,7 @@ PasswordResetPerformViewSet, PasswordResetRequestViewSet, ) -from lego.apps.users.views.penalties import PenaltyViewSet +from lego.apps.users.views.penalties import PenaltyGroupViewSet from lego.apps.users.views.registration import UserRegistrationRequestViewSet from lego.apps.users.views.student_confirmation import ( StudentConfirmationPerformViewSet, @@ -179,7 +179,7 @@ PasswordResetRequestViewSet, basename="password-reset-request", ) -router.register(r"penalties", PenaltyViewSet) +router.register(r"penalties", PenaltyGroupViewSet) router.register(r"podcasts", PodcastViewSet, basename="podcasts") router.register(r"polls", PollViewSet, basename="polls") router.register(r"quotes", QuoteViewSet) diff --git a/lego/apps/events/models.py b/lego/apps/events/models.py index 3983e79e1..d9a50886e 100644 --- a/lego/apps/events/models.py +++ b/lego/apps/events/models.py @@ -32,7 +32,7 @@ from lego.apps.followers.models import FollowEvent from lego.apps.permissions.models import ObjectPermissionsModel from lego.apps.users.constants import AUTUMN, SPRING -from lego.apps.users.models import AbakusGroup, Penalty, User +from lego.apps.users.models import AbakusGroup, PenaltyGroup, User from lego.utils.models import BasisModel from lego.utils.youtube_validator import youtube_validator @@ -232,13 +232,15 @@ def get_earliest_registration_time( if len(pools) == 0: return None reg_time: date = min(pool.activation_date for pool in pools) + if self.heed_penalties: if penalties is None: penalties = user.number_of_penalties() - if penalties == 2: - return reg_time + timedelta(hours=12) - elif penalties == 1: - return reg_time + timedelta(hours=3) + return ( + reg_time + timedelta(hours=settings.PENALTY_DELAY_DURATION) + if penalties >= 1 + else reg_time + ) return reg_time def get_possible_pools( @@ -328,9 +330,6 @@ def register(self, registration: Registration) -> Registration: # Make the user follow the event FollowEvent.objects.get_or_create(follower=user, target=self) - if penalties >= 3: - return registration.add_to_waiting_list() - # If the event is merged or has only one pool we can skip a lot of logic if all_pools.count() == 1: return registration.add_to_pool(possible_pools[0]) @@ -390,8 +389,10 @@ def unregister( and self.heed_penalties and self.passed_unregistration_deadline ): - if not registration.user.penalties.filter(source_event=self).exists(): - Penalty.objects.create( + if not registration.user.penalty_groups.filter( + source_event=self + ).exists(): + PenaltyGroup.objects.create( user=registration.user, reason=f"Meldte seg av {self.title} for sent.", weight=1, @@ -469,8 +470,6 @@ def early_bump(self, opening_pool: Pool) -> None: for reg in self.waiting_registrations: if opening_pool.is_full: break - if self.heed_penalties and reg.user.number_of_penalties() >= 3: - continue if self.can_register(reg.user, opening_pool, future=True): reg.pool = opening_pool reg.save() @@ -491,8 +490,6 @@ def bump_on_pool_creation_or_expansion(self) -> None: for reg in self.waiting_registrations: if self.is_full or pool.is_full: break - if self.heed_penalties and reg.user.number_of_penalties() >= 3: - continue if self.can_register(reg.user, pool, future=True): reg.pool = pool reg.save() @@ -576,28 +573,9 @@ def pop_from_waiting_list( if to_pool: for registration in self.waiting_registrations: - if self.heed_penalties: - penalties: int = registration.user.number_of_penalties() - earliest_reg: Optional[date] = self.get_earliest_registration_time( - registration.user, [to_pool], penalties - ) - if penalties < 3 and earliest_reg and earliest_reg < timezone.now(): - if self.can_register(registration.user, to_pool): - return registration - elif self.can_register(registration.user, to_pool): - return registration - return None - - if self.heed_penalties: - for registration in self.waiting_registrations: - penalties = registration.user.number_of_penalties() - earliest_reg = self.get_earliest_registration_time( - registration.user, None, penalties - ) - if penalties < 3 and earliest_reg and earliest_reg < timezone.now(): + if self.can_register(registration.user, to_pool): return registration return None - return self.waiting_registrations.first() @staticmethod @@ -934,16 +912,18 @@ def handle_user_penalty(self, presence: constants.PRESENCE_CHOICES) -> None: and presence == constants.PRESENCE_CHOICES.NOT_PRESENT and self.event.penalty_weight_on_not_present ): - if not self.user.penalties.filter(source_event=self.event).exists(): - Penalty.objects.create( + if not self.user.penalty_groups.filter(source_event=self.event).exists(): + PenaltyGroup.objects.create( user=self.user, reason=f"Møtte ikke opp på {self.event.title}.", weight=self.event.penalty_weight_on_not_present, source_event=self.event, ) else: - for penalty in self.user.penalties.filter(source_event=self.event): - penalty.delete() + for penalty_group in self.user.penalty_groups.filter( + source_event=self.event + ): + penalty_group.delete() def add_to_pool(self, pool: Pool) -> Registration: allowed: bool = False diff --git a/lego/apps/events/tests/test_async_tasks.py b/lego/apps/events/tests/test_async_tasks.py index 5aaaa5ece..7c8efc167 100644 --- a/lego/apps/events/tests/test_async_tasks.py +++ b/lego/apps/events/tests/test_async_tasks.py @@ -19,8 +19,8 @@ notify_user_when_payment_soon_overdue, set_all_events_ready_and_bump, ) -from lego.apps.events.tests.utils import get_dummy_users, make_penalty_expire -from lego.apps.users.models import AbakusGroup, Penalty +from lego.apps.events.tests.utils import get_dummy_users, make_penalty_group_expire +from lego.apps.users.models import AbakusGroup, PenaltyGroup from lego.utils.test_utils import BaseAPITestCase, BaseTestCase @@ -223,36 +223,6 @@ def test_isnt_bumped_without_permission(self): self.assertEqual(self.pool_two.registrations.count(), 0) self.assertEqual(self.event.waiting_registrations.count(), 1) - def test_isnt_bumped_with_penalties(self): - """Users should not be bumped if they have 3 penalties.""" - self.event.start_time = timezone.now() + timedelta(days=1) - self.event.merge_time = timezone.now() + timedelta(hours=12) - self.event.save() - - self.pool_one.activation_date = timezone.now() - timedelta(days=1) - self.pool_one.save() - - self.pool_two.activation_date = timezone.now() + timedelta(minutes=30) - self.pool_two.save() - - users = get_dummy_users(2) - - Penalty.objects.create( - user=users[1], reason="test", weight=3, source_event=self.event - ) - - for user in users: - AbakusGroup.objects.get(name="Webkom").add_user(user) - registration = Registration.objects.get_or_create( - event=self.event, user=user - )[0] - self.event.register(registration) - - bump_waiting_users_to_new_pool() - - self.assertEqual(self.pool_two.registrations.count(), 0) - self.assertEqual(self.event.waiting_registrations.count(), 1) - def test_isnt_bumped_if_activation_is_far_into_the_future(self): """Users should not be bumped if the pool is activated more than 35 minutes in the future.""" @@ -380,7 +350,7 @@ def test_is_automatically_bumped_after_penalty_expiration(self): user = get_dummy_users(1)[0] AbakusGroup.objects.get(name="Abakus").add_user(user) - p1 = Penalty.objects.create( + penalty_group = PenaltyGroup.objects.create( user=user, reason="test", weight=3, source_event=self.event ) @@ -389,7 +359,7 @@ def test_is_automatically_bumped_after_penalty_expiration(self): ] async_register(registration.id) - make_penalty_expire(p1) + make_penalty_group_expire(penalty_group) check_events_for_registrations_with_expired_penalties.delay() self.assertIsNotNone(Registration.objects.get(id=registration.id).pool) @@ -401,10 +371,10 @@ def test_is_bumped_with_multiple_penalties(self): user = get_dummy_users(1)[0] AbakusGroup.objects.get(name="Abakus").add_user(user) - p1 = Penalty.objects.create( + p1 = PenaltyGroup.objects.create( user=user, reason="test", weight=2, source_event=self.event ) - Penalty.objects.create( + PenaltyGroup.objects.create( user=user, reason="test2", weight=2, source_event=self.event ) @@ -413,36 +383,12 @@ def test_is_bumped_with_multiple_penalties(self): ] async_register(registration.id) - make_penalty_expire(p1) + make_penalty_group_expire(p1) check_events_for_registrations_with_expired_penalties.delay() self.assertIsNotNone(Registration.objects.get(id=registration.id).pool) self.assertEqual(self.event.number_of_registrations, 1) - def test_isnt_bumped_with_too_many_penalties(self): - """Tests that a user isn't bumped when going from 4 to 3 active penalties""" - - user = get_dummy_users(1)[0] - AbakusGroup.objects.get(name="Abakus").add_user(user) - - p1 = Penalty.objects.create( - user=user, reason="test", weight=1, source_event=self.event - ) - Penalty.objects.create( - user=user, reason="test2", weight=3, source_event=self.event - ) - - registration = Registration.objects.get_or_create(event=self.event, user=user)[ - 0 - ] - async_register(registration.id) - - make_penalty_expire(p1) - check_events_for_registrations_with_expired_penalties.delay() - - self.assertIsNone(Registration.objects.get(id=registration.id).pool) - self.assertEqual(self.event.number_of_registrations, 0) - def test_isnt_bumped_when_full(self): """Tests that a user isnt bumped when the event is full when penalties expire.""" @@ -450,7 +396,7 @@ def test_isnt_bumped_when_full(self): for user in users: AbakusGroup.objects.get(name="Abakus").add_user(user) - p1 = Penalty.objects.create( + p1 = PenaltyGroup.objects.create( user=users[1], reason="test", weight=3, source_event=self.event ) @@ -460,7 +406,7 @@ def test_isnt_bumped_when_full(self): )[0] async_register(registration.id) - make_penalty_expire(p1) + make_penalty_group_expire(p1) check_events_for_registrations_with_expired_penalties.delay() self.assertIsNone(Registration.objects.get(user=users[1]).pool) @@ -476,7 +422,7 @@ def test_isnt_bumped_when_not_first_in_line(self): for user in users: AbakusGroup.objects.get(name="Abakus").add_user(user) - p1 = Penalty.objects.create( + p1 = PenaltyGroup.objects.create( user=users[2], reason="test", weight=3, source_event=self.event ) @@ -486,7 +432,7 @@ def test_isnt_bumped_when_not_first_in_line(self): )[0] async_register(registration.id) - make_penalty_expire(p1) + make_penalty_group_expire(p1) check_events_for_registrations_with_expired_penalties.delay() self.assertIsNone(Registration.objects.get(user=users[1]).pool) @@ -502,7 +448,7 @@ def test_async_bump_post_merge(self): for user in users: AbakusGroup.objects.get(name="Abakus").add_user(user) - p1 = Penalty.objects.create( + penalty_group = PenaltyGroup.objects.create( user=users[1], reason="test", weight=3, source_event=self.event ) @@ -512,7 +458,7 @@ def test_async_bump_post_merge(self): )[0] async_register(registration.id) - make_penalty_expire(p1) + make_penalty_group_expire(penalty_group) check_events_for_registrations_with_expired_penalties.delay() self.assertIsNotNone(Registration.objects.get(user=users[1]).pool) @@ -580,7 +526,7 @@ def test_initiate_payment_in_waiting_list(self, mock_initiate_payment): user = get_dummy_users(1)[0] AbakusGroup.objects.get(name="Abakus").add_user(user) - Penalty.objects.create( + PenaltyGroup.objects.create( user=user, reason="test", weight=3, source_event=self.event ) diff --git a/lego/apps/events/tests/test_events_api.py b/lego/apps/events/tests/test_events_api.py index c25348afc..9606a134b 100644 --- a/lego/apps/events/tests/test_events_api.py +++ b/lego/apps/events/tests/test_events_api.py @@ -18,11 +18,11 @@ check_events_for_registrations_with_expired_penalties, stripe_webhook_event, ) -from lego.apps.events.tests.utils import get_dummy_users, make_penalty_expire +from lego.apps.events.tests.utils import get_dummy_users, make_penalty_group_expire from lego.apps.followers.models import FollowEvent from lego.apps.surveys.models import Submission, Survey from lego.apps.users.constants import GROUP_GRADE, PHOTO_CONSENT_DOMAINS -from lego.apps.users.models import AbakusGroup, Penalty, PhotoConsent, User +from lego.apps.users.models import AbakusGroup, PenaltyGroup, PhotoConsent, User from lego.utils.test_utils import BaseAPITestCase, BaseAPITransactionTestCase _test_event_data = [ @@ -1727,7 +1727,7 @@ def test_create_payment_intent_when_bump_from_waitlist(self, mock_notify): self.client.force_authenticate(self.abakus_user_4) - p1 = Penalty.objects.create( + p1 = PenaltyGroup.objects.create( user=self.abakus_user_4, reason="test", weight=3, source_event=self.event ) @@ -1742,7 +1742,7 @@ def test_create_payment_intent_when_bump_from_waitlist(self, mock_notify): res = self.get_payment_intent() self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN) - make_penalty_expire(p1) + make_penalty_group_expire(p1) check_events_for_registrations_with_expired_penalties.delay() res = self.get_payment_intent() diff --git a/lego/apps/events/tests/test_penalties.py b/lego/apps/events/tests/test_penalties.py index f518ea092..8de4653c0 100644 --- a/lego/apps/events/tests/test_penalties.py +++ b/lego/apps/events/tests/test_penalties.py @@ -1,10 +1,11 @@ from datetime import timedelta +from django.conf import settings from django.utils import timezone from lego.apps.events import constants from lego.apps.events.models import Event, Registration -from lego.apps.users.models import AbakusGroup, Penalty +from lego.apps.users.models import AbakusGroup, PenaltyGroup from lego.utils.test_utils import BaseTestCase from .utils import get_dummy_users @@ -34,7 +35,9 @@ def test_get_earliest_registration_time_ignore_penalties(self): user = get_dummy_users(1)[0] AbakusGroup.objects.get(name="Webkom").add_user(user) - Penalty.objects.create(user=user, reason="test", weight=1, source_event=event) + PenaltyGroup.objects.create( + user=user, reason="test", weight=1, source_event=event + ) penalties = user.number_of_penalties() earliest_reg = event.get_earliest_registration_time( @@ -42,8 +45,9 @@ def test_get_earliest_registration_time_ignore_penalties(self): ) self.assertEqual(earliest_reg, current_time) - def test_get_earliest_registration_time_one_penalty(self): - """Test method calculating the earliest registration time for user with one penalty""" + def test_get_earliest_registration_time_one_or_more_penalty(self): + """Test method calculating the earliest + registration time for user with one or more penalties""" event = Event.objects.get(title="POOLS_NO_REGISTRATIONS") current_time = timezone.now() @@ -53,69 +57,34 @@ def test_get_earliest_registration_time_one_penalty(self): user = get_dummy_users(1)[0] AbakusGroup.objects.get(name="Webkom").add_user(user) - Penalty.objects.create(user=user, reason="test", weight=1, source_event=event) + PenaltyGroup.objects.create( + user=user, reason="first test penalty", weight=1, source_event=event + ) penalties = user.number_of_penalties() earliest_reg = event.get_earliest_registration_time( user, [webkom_pool], penalties ) - self.assertEqual(earliest_reg, current_time + timedelta(hours=3)) - - def test_get_earliest_registration_time_two_penalties(self): - """Test method calculating the earliest registration time for user with two penalties""" - event = Event.objects.get(title="POOLS_NO_REGISTRATIONS") - - current_time = timezone.now() - webkom_pool = event.pools.get(name="Webkom") - webkom_pool.activation_date = current_time - webkom_pool.save() - - user = get_dummy_users(1)[0] - AbakusGroup.objects.get(name="Webkom").add_user(user) - Penalty.objects.create(user=user, reason="test", weight=2, source_event=event) + self.assertEqual( + earliest_reg, + current_time + timedelta(hours=settings.PENALTY_DELAY_DURATION), + ) + PenaltyGroup.objects.create( + user=user, reason="second test penalty", weight=2, source_event=event + ) penalties = user.number_of_penalties() earliest_reg = event.get_earliest_registration_time( user, [webkom_pool], penalties ) - self.assertEqual(earliest_reg, current_time + timedelta(hours=12)) - - def test_cant_register_with_one_penalty_before_delay(self): - """Test that user can not register before (3 hour) delay when having one penalty""" - event = Event.objects.get(title="POOLS_NO_REGISTRATIONS") - - current_time = timezone.now() - abakus_pool = event.pools.get(name="Abakusmember") - abakus_pool.activation_date = current_time - abakus_pool.save() - - user = get_dummy_users(1)[0] - AbakusGroup.objects.get(name="Abakus").add_user(user) - Penalty.objects.create(user=user, reason="test", weight=1, source_event=event) - - with self.assertRaises(ValueError): - registration = Registration.objects.get_or_create(event=event, user=user)[0] - event.register(registration) - - def test_can_register_with_one_penalty_after_delay(self): - """Test that user can register after (3 hour) delay has passed having one penalty""" - event = Event.objects.get(title="POOLS_NO_REGISTRATIONS") - - current_time = timezone.now() - abakus_pool = event.pools.get(name="Abakusmember") - abakus_pool.activation_date = current_time - timedelta(hours=3) - abakus_pool.save() - - user = get_dummy_users(1)[0] - AbakusGroup.objects.get(name="Abakus").add_user(user) - Penalty.objects.create(user=user, reason="test", weight=1, source_event=event) - - registration = Registration.objects.get_or_create(event=event, user=user)[0] - event.register(registration) - self.assertEqual(event.number_of_registrations, 1) + self.assertEqual( + earliest_reg, + current_time + timedelta(hours=settings.PENALTY_DELAY_DURATION), + ) - def test_cant_register_with_two_penalties_before_delay(self): - """Test that user can not register before (12 hour) delay when having two penalties""" + def test_cant_register_with_one_or_more_penalty_before_delay(self): + """Test that user can not register + before (5 hour) delay when having one or more penalties""" event = Event.objects.get(title="POOLS_NO_REGISTRATIONS") current_time = timezone.now() @@ -125,235 +94,42 @@ def test_cant_register_with_two_penalties_before_delay(self): user = get_dummy_users(1)[0] AbakusGroup.objects.get(name="Abakus").add_user(user) - Penalty.objects.create(user=user, reason="test", weight=2, source_event=event) + PenaltyGroup.objects.create( + user=user, reason="first test penalty", weight=1, source_event=event + ) + PenaltyGroup.objects.create( + user=user, reason="second test penalty", weight=2, source_event=event + ) with self.assertRaises(ValueError): registration = Registration.objects.get_or_create(event=event, user=user)[0] event.register(registration) - def test_can_register_with_two_penalties_after_delay(self): - """Test that user can register after (12 hour) delay when having two penalties""" + def test_can_register_with_one_or_more_penalty_after_delay(self): + """Test that user can register after (5 hour) + delay has passed having one or more penalties""" event = Event.objects.get(title="POOLS_NO_REGISTRATIONS") current_time = timezone.now() abakus_pool = event.pools.get(name="Abakusmember") - abakus_pool.activation_date = current_time - timedelta(hours=12) + abakus_pool.activation_date = current_time - timedelta( + hours=settings.PENALTY_DELAY_DURATION + ) abakus_pool.save() user = get_dummy_users(1)[0] AbakusGroup.objects.get(name="Abakus").add_user(user) - Penalty.objects.create(user=user, reason="test", weight=2, source_event=event) + PenaltyGroup.objects.create( + user=user, reason="first test penalty", weight=1, source_event=event + ) + PenaltyGroup.objects.create( + user=user, reason="second test penalty", weight=2, source_event=event + ) registration = Registration.objects.get_or_create(event=event, user=user)[0] event.register(registration) self.assertEqual(event.number_of_registrations, 1) - def test_waiting_list_on_three_penalties(self): - """Test that user is registered to waiting list directly when having three penalties""" - event = Event.objects.get(title="POOLS_NO_REGISTRATIONS") - - user = get_dummy_users(1)[0] - AbakusGroup.objects.get(name="Abakus").add_user(user) - Penalty.objects.create(user=user, reason="test", weight=3, source_event=event) - - registration = Registration.objects.get_or_create(event=event, user=user)[0] - event.register(registration) - self.assertEqual(event.number_of_registrations, 0) - self.assertEqual(event.waiting_registrations.count(), 1) - - def test_waiting_list_on_more_than_three_penalties(self): - """Test that user is registered to waiting list directly having over three penalties""" - event = Event.objects.get(title="POOLS_NO_REGISTRATIONS") - - user = get_dummy_users(1)[0] - AbakusGroup.objects.get(name="Abakus").add_user(user) - Penalty.objects.create(user=user, reason="test", weight=2, source_event=event) - Penalty.objects.create(user=user, reason="test2", weight=2, source_event=event) - - registration = Registration.objects.get_or_create(event=event, user=user)[0] - event.register(registration) - self.assertEqual(event.number_of_registrations, 0) - self.assertEqual(event.waiting_registrations.count(), 1) - - def test_waiting_list_on_three_penalties_post_merge(self): - """Test that user is registered to waiting list with three penalties after merge""" - event = Event.objects.get(title="POOLS_NO_REGISTRATIONS") - event.merge_time = timezone.now() - timedelta(hours=24) - event.save() - - user = get_dummy_users(1)[0] - AbakusGroup.objects.get(name="Abakus").add_user(user) - Penalty.objects.create(user=user, reason="test", weight=3, source_event=event) - - registration = Registration.objects.get_or_create(event=event, user=user)[0] - event.register(registration) - self.assertEqual(event.number_of_registrations, 0) - self.assertEqual(event.waiting_registrations.count(), 1) - - def test_not_bumped_if_three_penalties(self): - """Test that user is not bumped on unregistration having three penalties""" - event = Event.objects.get(title="POOLS_NO_REGISTRATIONS") - - users = get_dummy_users(5) - abakus_users = users[:5] - waiting_users = users[3:5] - - for user in abakus_users: - AbakusGroup.objects.get(name="Abakus").add_user(user) - for user in users: - registration = Registration.objects.get_or_create(event=event, user=user)[0] - event.register(registration) - - self.assertIsNone(event.registrations.get(user=waiting_users[0]).pool) - self.assertIsNone(event.registrations.get(user=waiting_users[1]).pool) - - Penalty.objects.create( - user=waiting_users[0], reason="test", weight=3, source_event=event - ) - registration_to_unregister = Registration.objects.get( - event=event, user=users[0] - ) - event.unregister(registration_to_unregister) - - self.assertIsNone(event.registrations.get(user=waiting_users[0]).pool) - self.assertIsNotNone(event.registrations.get(user=waiting_users[1]).pool) - - def test_not_bumped_if_three_penalties_post_merge(self): - """Test that user is not bumped on unregistration having three penalties after merge""" - event = Event.objects.get(title="POOLS_NO_REGISTRATIONS") - - users = get_dummy_users(7) - abakus_users = users[:5] - webkom_users = users[5:7] - waiting_users = users[3:5] - - for user in abakus_users: - AbakusGroup.objects.get(name="Abakus").add_user(user) - for user in webkom_users: - AbakusGroup.objects.get(name="Webkom").add_user(user) - for user in users: - registration = Registration.objects.get_or_create(event=event, user=user)[0] - event.register(registration) - - self.assertIsNone(event.registrations.get(user=waiting_users[0]).pool) - self.assertIsNone(event.registrations.get(user=waiting_users[1]).pool) - - event.merge_time = timezone.now() - timedelta(hours=24) - event.save() - Penalty.objects.create( - user=waiting_users[0], reason="test", weight=3, source_event=event - ) - - registration_to_unregister = Registration.objects.get( - event=event, user=webkom_users[0] - ) - event.unregister(registration_to_unregister) - - self.assertIsNone(event.registrations.get(user=waiting_users[0]).pool) - self.assertIsNotNone(event.registrations.get(user=waiting_users[1]).pool) - - def test_bumped_if_penalties_expire_while_waiting(self): - """Test that user gets bumped when penalties expire while on waiting list""" - event = Event.objects.get(title="POOLS_NO_REGISTRATIONS") - - for pool in event.pools.all(): - pool.activation_date = timezone.now() - timedelta(hours=12) - pool.save() - - users = get_dummy_users(5) - penalty_one = Penalty.objects.create( - user=users[0], reason="test", weight=1, source_event=event - ) - Penalty.objects.create( - user=users[0], reason="test", weight=2, source_event=event - ) - abakus_users = users[:5] - waiting_users = [users[0], users[4]] - - for user in abakus_users: - AbakusGroup.objects.get(name="Abakus").add_user(user) - for user in users: - registration = Registration.objects.get_or_create(event=event, user=user)[0] - event.register(registration) - - self.assertIsNone(event.registrations.get(user=waiting_users[0]).pool) - self.assertIsNone(event.registrations.get(user=waiting_users[1]).pool) - - penalty_one.created_at = timezone.now() - timedelta(days=365) - penalty_one.save() - registration_to_unregister = Registration.objects.get( - event=event, user=users[1] - ) - event.unregister(registration_to_unregister) - - self.assertIsNotNone(event.registrations.get(user=waiting_users[0]).pool) - self.assertIsNone(event.registrations.get(user=waiting_users[1]).pool) - - def test_isnt_bumped_if_third_penalty_expires_but_reg_delay_is_still_active(self): - event = Event.objects.get(title="POOLS_NO_REGISTRATIONS") - for pool in event.pools.all(): - pool.activation_date = timezone.now() - timedelta(hours=6) - pool.save() - - users = get_dummy_users(5) - penalty_one = Penalty.objects.create( - user=users[0], reason="test", weight=1, source_event=event - ) - Penalty.objects.create( - user=users[0], reason="test", weight=2, source_event=event - ) - abakus_users = users[:5] - waiting_users = [users[0], users[4]] - - for user in abakus_users: - AbakusGroup.objects.get(name="Abakus").add_user(user) - for user in users: - registration = Registration.objects.get_or_create(event=event, user=user)[0] - event.register(registration) - - self.assertIsNone(event.registrations.get(user=waiting_users[0]).pool) - self.assertIsNone(event.registrations.get(user=waiting_users[1]).pool) - - penalty_one.created_at = timezone.now() - timedelta(days=365) - penalty_one.save() - registration_to_unregister = Registration.objects.get( - event=event, user=users[1] - ) - event.unregister(registration_to_unregister) - - self.assertIsNone(event.registrations.get(user=waiting_users[0]).pool) - self.assertIsNotNone(event.registrations.get(user=waiting_users[1]).pool) - - def test_no_legal_bump(self): - event = Event.objects.get(title="POOLS_NO_REGISTRATIONS") - users = get_dummy_users(5) - for pool in event.pools.all(): - pool.activation_date = timezone.now() - pool.save() - - for user in users: - AbakusGroup.objects.get(name="Abakus").add_user(user) - registration = Registration.objects.get_or_create(event=event, user=user)[0] - event.register(registration) - - self.assertIsNone(event.registrations.get(user=users[3]).pool) - self.assertIsNone(event.registrations.get(user=users[4]).pool) - - Penalty.objects.create( - user=users[3], reason="test", weight=3, source_event=event - ) - Penalty.objects.create( - user=users[4], reason="test", weight=2, source_event=event - ) - - registration_to_unregister = Registration.objects.get( - event=event, user=users[0] - ) - event.unregister(registration_to_unregister) - - self.assertIsNone(event.registrations.get(user=users[3]).pool) - self.assertIsNone(event.registrations.get(user=users[4]).pool) - def test_penalties_created_on_unregister(self): """Test that user gets penalties on unregister after limit""" event = Event.objects.get(title="POOLS_WITH_REGISTRATIONS") @@ -419,20 +195,20 @@ def test_only_correct_penalties_are_removed_on_presence_change(self): registration.set_presence(constants.PRESENCE_CHOICES.NOT_PRESENT) penalties_before = registration.user.number_of_penalties() - penalties_object_before = list(registration.user.penalties.all()) + penalties_object_before = list(registration.user.penalty_groups.all()) - Penalty.objects.create( + PenaltyGroup.objects.create( user=registration.user, reason="OTHER EVENT", weight=2, source_event=other_event, ) penalties_during = registration.user.number_of_penalties() - penalties_objects_during = list(registration.user.penalties.all()) + penalties_objects_during = list(registration.user.penalty_groups.all()) registration.set_presence(constants.PRESENCE_CHOICES.UNKNOWN) penalties_after = registration.user.number_of_penalties() - penalties_object_after = list(registration.user.penalties.all()) + penalties_object_after = list(registration.user.penalty_groups.all()) self.assertEqual(penalties_object_before[0].source_event, event) self.assertEqual(penalties_object_after[0].source_event, other_event) @@ -455,7 +231,7 @@ def test_able_to_register_when_not_heed_penalties_with_penalties(self): event.save() user = get_dummy_users(1)[0] AbakusGroup.objects.get(name="Webkom").add_user(user) - Penalty.objects.create( + PenaltyGroup.objects.create( user=user, reason="TEST", weight=3, source_event=other_event ) diff --git a/lego/apps/events/tests/utils.py b/lego/apps/events/tests/utils.py index f1f8afb23..39db1a3fe 100644 --- a/lego/apps/events/tests/utils.py +++ b/lego/apps/events/tests/utils.py @@ -4,7 +4,7 @@ import stripe -from lego.apps.users.models import AbakusGroup, User +from lego.apps.users.models import AbakusGroup, PenaltyGroup, User def get_dummy_users(n): @@ -33,6 +33,5 @@ def create_token(number, cvc, year=None): ) -def make_penalty_expire(penalty): - penalty.created_at = timezone.now() - timedelta(days=365) - penalty.save() +def make_penalty_group_expire(penalty_group: PenaltyGroup) -> None: + penalty_group.delete() diff --git a/lego/apps/users/action_handlers.py b/lego/apps/users/action_handlers.py index 3afc6af1c..a241b117b 100644 --- a/lego/apps/users/action_handlers.py +++ b/lego/apps/users/action_handlers.py @@ -1,3 +1,5 @@ +from django.utils import timezone + from lego.apps.action_handlers.handler import Handler from lego.apps.action_handlers.registry import register_handler from lego.apps.feeds.activity import Activity @@ -5,7 +7,7 @@ from lego.apps.feeds.models import NotificationFeed, PersonalFeed, UserFeed from lego.apps.feeds.verbs import GroupJoinVerb, PenaltyVerb from lego.apps.users.constants import PUBLIC_GROUPS -from lego.apps.users.models import Membership, Penalty +from lego.apps.users.models import Membership, PenaltyGroup from lego.apps.users.notifications import PenaltyNotification @@ -43,27 +45,26 @@ def get_activity(self, membership): class PenaltyHandler(Handler): - model = Penalty + model = PenaltyGroup manager = feed_manager - def get_activity(self, penalty): + def get_activity(self, penalty_group): return Activity( - actor=penalty.source_event, + actor=penalty_group.source_event, verb=PenaltyVerb, - object=penalty, - target=penalty.user, - time=penalty.created_at, - extra_context={"reason": penalty.reason, "weight": penalty.weight}, + object=penalty_group, + target=penalty_group.user, + time=penalty_group.created_at, + extra_context={ + "reason": penalty_group.reason, + "weight": penalty_group.weight, + }, ) def handle_create(self, instance, **kwargs): activity = self.get_activity(instance) self.manager.add_activity(activity, [instance.user.pk], [NotificationFeed]) - # Send Notification - notification = PenaltyNotification(instance.user, penalty=instance) - notification.notify() - def handle_update(self, instance, **kwargs): activity = self.get_activity(instance) self.manager.add_activity(activity, [instance.user.pk], [NotificationFeed]) diff --git a/lego/apps/users/filters.py b/lego/apps/users/filters.py index 052e3e6b3..9e67c8779 100644 --- a/lego/apps/users/filters.py +++ b/lego/apps/users/filters.py @@ -2,7 +2,12 @@ from django.db.models.functions import Concat from django_filters.rest_framework import CharFilter, FilterSet -from lego.apps.users.models import AbakusGroup, Membership, MembershipHistory, Penalty +from lego.apps.users.models import ( + AbakusGroup, + Membership, + MembershipHistory, + PenaltyGroup, +) class MembershipFilterSet(FilterSet): @@ -43,7 +48,7 @@ class Meta: fields = ("user", "abakus_group", "role") -class PenaltyFilterSet(FilterSet): +class PenaltyGroupFilterSet(FilterSet): class Meta: - model = Penalty + model = PenaltyGroup fields = ("user", "source_event") diff --git a/lego/apps/users/managers.py b/lego/apps/users/managers.py index f293d094b..958eab339 100644 --- a/lego/apps/users/managers.py +++ b/lego/apps/users/managers.py @@ -1,5 +1,16 @@ +from __future__ import annotations + +from datetime import timedelta +from typing import TYPE_CHECKING + from django.contrib.auth.models import UserManager -from django.db.models import Q +from django.db import models +from django.db.models import Q, QuerySet + +if TYPE_CHECKING: + from lego.apps.users.models import Penalty, PenaltyGroup + +from django.conf import settings from django.utils import timezone from mptt.managers import TreeManager @@ -46,9 +57,65 @@ def get_by_natural_key(self, username, abakus_group_name): ) -class UserPenaltyManager(PersistentModelManager): - def valid(self): +class UserPenaltyManager(PersistentModelManager["Penalty"]): + def valid(self) -> QuerySet["Penalty"]: from lego.apps.users.models import Penalty offset = Penalty.penalty_offset(timezone.now(), False) - return super().filter(created_at__gt=timezone.now() - offset) + return super().filter(activation_time__gt=timezone.now() - offset) + + +class UserPenaltyGroupManager(PersistentModelManager["PenaltyGroup"]): + def create(self, *args, **kwargs) -> PenaltyGroup: + from lego.apps.users.models import Penalty + from lego.apps.users.notifications import PenaltyNotification + + penalty_group = super().create(*args, **kwargs) + + last_active_penalty = ( + Penalty.objects.filter( + penalty_group__user=penalty_group.user, + ) + .order_by("-activation_time") + .first() + ) + + new_activation_time = ( + last_active_penalty.exact_expiration + if last_active_penalty + else penalty_group.created_at + ) + + penalties = [] + + for _ in range(kwargs["weight"]): + penalty = Penalty(penalty_group=penalty_group) + penalty.activation_time = new_activation_time + + new_activation_time = penalty.exact_expiration + + penalties.append(penalty) + + Penalty.objects.bulk_create(penalties) + + # Send Notification + notification = PenaltyNotification(penalty_group.user, penalty=penalty_group) + notification.notify() + + return penalty_group + + def valid(self) -> QuerySet["PenaltyGroup"]: + from django.db.models import F + + from lego.apps.users.models import PenaltyGroup + + filtered_result = PenaltyGroup.objects.filter( + id__in=[ + pg.id + for pg in super().annotate(penalty_count=models.Count(F("penalties"))) + if pg.activation_time + > timezone.now() - timedelta(days=settings.PENALTY_DURATION.days) + ] + ) + + return filtered_result diff --git a/lego/apps/users/migrations/0039_remove_penalty_reason_remove_penalty_source_event_and_more.py b/lego/apps/users/migrations/0039_remove_penalty_reason_remove_penalty_source_event_and_more.py new file mode 100644 index 000000000..216477cf8 --- /dev/null +++ b/lego/apps/users/migrations/0039_remove_penalty_reason_remove_penalty_source_event_and_more.py @@ -0,0 +1,144 @@ +# Generated by Django 4.0.10 on 2023-09-28 13:55 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("events", "0036_alter_registration_presence"), + ("users", "0038_alter_abakusgroup_type"), + ] + + def generate_new_penalties(apps, schema_editor): + from django.utils import timezone + from django.utils.timezone import timedelta + + PenaltyGroup = apps.get_model("users", "PenaltyGroup") + Penalty = apps.get_model("users", "Penalty") + Event = apps.get_model("events", "Event") + + users_with_penalties = Penalty.objects.filter( + created_at__gt=timezone.now() - timedelta(days=10) + ).values_list("user", flat=True) + + Penalty.objects.all().delete() + + for user in users_with_penalties: + dummy_event = Event.objects.all()[0] + PenaltyGroup.objects.create( + user=user, + reason="Dette er en sammensetting av forrige prikkene", + source_event=dummy_event, + ) + + operations = [ + migrations.CreateModel( + name="PenaltyGroup", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + models.DateTimeField( + db_index=True, default=django.utils.timezone.now, editable=False + ), + ), + ( + "updated_at", + models.DateTimeField( + default=django.utils.timezone.now, editable=False + ), + ), + ( + "deleted", + models.BooleanField(db_index=True, default=False, editable=False), + ), + ("reason", models.CharField(max_length=1000)), + ("weight", models.IntegerField(default=1)), + ( + "created_by", + models.ForeignKey( + default=None, + editable=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "source_event", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="penalty_groups", + to="events.event", + ), + ), + ( + "updated_by", + models.ForeignKey( + default=None, + editable=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="penalty_groups", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + "default_manager_name": "objects", + }, + ), + migrations.RemoveField( + model_name="penalty", + name="reason", + ), + migrations.RemoveField( + model_name="penalty", + name="source_event", + ), + migrations.RemoveField( + model_name="penalty", + name="weight", + ), + migrations.AddField( + model_name="penalty", + name="activation_time", + field=models.DateTimeField(default=None, null=True), + ), + migrations.AddField( + model_name="penalty", + name="penalty_group", + field=models.ForeignKey( + default=None, + on_delete=django.db.models.deletion.CASCADE, + related_name="penalties", + to="users.penaltygroup", + ), + ), + migrations.RunPython(generate_new_penalties), + migrations.RemoveField( + model_name="penalty", + name="user", + ), + ] diff --git a/lego/apps/users/models.py b/lego/apps/users/models.py index 87176d0f5..85fd938a2 100644 --- a/lego/apps/users/models.py +++ b/lego/apps/users/models.py @@ -1,5 +1,7 @@ -# mypy: ignore-errors +from __future__ import annotations + import operator +from typing import Tuple from django.conf import settings from django.contrib.auth.models import ( @@ -8,7 +10,7 @@ ) from django.contrib.postgres.fields import ArrayField from django.db import models, transaction -from django.db.models import Q +from django.db.models import F, Q, QuerySet from django.utils import timezone from django.utils.timezone import datetime, timedelta @@ -27,6 +29,7 @@ AbakusGroupManagerWithoutText, AbakusUserManager, MembershipManager, + UserPenaltyGroupManager, UserPenaltyManager, ) from lego.apps.users.permissions import ( @@ -445,8 +448,10 @@ def number_of_penalties(self) -> int: # Returns the total penalty weight for this user count = ( Penalty.objects.valid() - .filter(user=self) - .aggregate(models.Sum("weight"))["weight__sum"] + .filter( + penalty_group__user=self, + ) + .count() ) return count or 0 @@ -460,13 +465,13 @@ def has_registered_photo_consents_for_semester( is_consenting__isnull=True, ).exists() - def restricted_lookup(self): + def restricted_lookup(self) -> Tuple[list[User], list[None]]: """ Restricted mail """ return [self], [] - def announcement_lookup(self): + def announcement_lookup(self) -> list[User]: return [self] def unanswered_surveys(self) -> list: @@ -487,33 +492,131 @@ def unanswered_surveys(self) -> list: ) return list(unanswered_surveys.values_list("id", flat=True)) + def check_for_expirable_penalty(self) -> None: + from django.db.models import Subquery -class Penalty(BasisModel): - user = models.ForeignKey(User, related_name="penalties", on_delete=models.CASCADE) + from lego.apps.events.models import Event, Pool + + current_active_penalty = ( + Penalty.objects.valid() + .filter( + penalty_group__user=self, + activation_time__lte=timezone.now(), + activation_time__gte=timezone.now() + - timedelta(days=settings.PENALTY_DURATION.days), + ) + .first() + ) + if current_active_penalty is None: + return + + pools = Pool.objects.filter(permission_groups__in=self.all_groups).distinct() + + # TODO: i believe wrong. + number_of_eligable_passed_events = Event.objects.filter( + heed_penalties=True, + start_time__range=( + current_active_penalty.activation_time + - timedelta(hours=1) * F("unregistration_deadline_hours"), + current_active_penalty.exact_expiration + - timedelta(hours=1) * F("unregistration_deadline_hours"), + ), + pools__in=Subquery(pools.values("pk")), + ).count() + + if number_of_eligable_passed_events >= 6: + current_active_penalty.expire_and_adjust_future_activation_times() + + +class PenaltyGroup(BasisModel): + user = models.ForeignKey( + User, related_name="penalty_groups", on_delete=models.CASCADE + ) reason = models.CharField(max_length=1000) - weight = models.IntegerField(default=1) source_event = models.ForeignKey( - "events.Event", related_name="penalties", on_delete=models.CASCADE + "events.Event", related_name="penalty_groups", on_delete=models.CASCADE ) + weight = models.IntegerField(default=1, null=False, blank=False) - objects = UserPenaltyManager() # type: ignore + objects = UserPenaltyGroupManager() # type: ignore - def expires(self): - dt = Penalty.penalty_offset(self.created_at) - ( - timezone.now() - self.created_at - ) - return dt.days + @property + def exact_expiration(self) -> datetime: + last_active_penalty = self.penalties.order_by("-activation_time").first() + + return last_active_penalty.exact_expiration @property - def exact_expiration(self): + def activation_time(self) -> datetime: + first_active_penalty = self.penalties.order_by("activation_time").first() + + return first_active_penalty.activation_time + + def delete(self, *args, **kwargs) -> None: + if ( + timezone.now() > self.activation_time + and timezone.now() < self.exact_expiration + ): + new_activation_time = timezone.now() + else: + new_activation_time = self.activation_time + + penalties_to_be_changed = Penalty.objects.filter( + penalty_group__user=self.user, + activation_time__gt=self.activation_time, + ).order_by("activation_time") + + for penalty in penalties_to_be_changed: + penalty.activation_time = new_activation_time + penalty.save() + new_activation_time = penalty.exact_expiration + + for penalty in self.penalties.all(): + print("asdfasdølfkjasdf") + penalty.delete() + + super().delete(*args, **kwargs) + + +class Penalty(BasisModel): + activation_time = models.DateTimeField(null=True, default=None) + penalty_group = models.ForeignKey( + PenaltyGroup, related_name="penalties", on_delete=models.CASCADE, default=None + ) + + objects = UserPenaltyManager() # type: ignore + + @property + def exact_expiration(self) -> datetime: """Returns the exact time of expiration""" - dt = Penalty.penalty_offset(self.created_at) - ( - timezone.now() - self.created_at - ) - return timezone.now() + dt + # Shouldn't happen, but if activation_time is none, expire immediately + if not self.activation_time: + return self.created_at + return Penalty.penalty_offset(self.activation_time) + self.activation_time + + def expire_and_adjust_future_activation_times(self) -> None: + if ( + timezone.now() > self.activation_time + and timezone.now() < self.exact_expiration + ): + new_activation_time = timezone.now() + else: + new_activation_time = self.activation_time + + penalties_to_be_changed = Penalty.objects.filter( + penalty_group__user=self.penalty_group.user, + activation_time__gt=self.activation_time, + ).order_by("activation_time") + + for penalty in penalties_to_be_changed: + penalty.activation_time = new_activation_time + penalty.save() + new_activation_time = penalty.exact_expiration + + self.delete() @staticmethod - def penalty_offset(start_date, forwards=True): + def penalty_offset(start_date: datetime, forwards: bool = True) -> timedelta: remaining_days = settings.PENALTY_DURATION.days offset_days = 0 multiplier = 1 if forwards else -1 @@ -532,7 +635,7 @@ def penalty_offset(start_date, forwards=True): return timedelta(days=offset_days) @staticmethod - def ignore_date(date): + def ignore_date(date: datetime) -> bool: summer_from, summer_to = settings.PENALTY_IGNORE_SUMMER winter_from, winter_to = settings.PENALTY_IGNORE_WINTER if summer_from <= (date.month, date.day) <= summer_to: @@ -542,6 +645,11 @@ def ignore_date(date): return True +# Generated by Django 4.0.10 on 2023-05-01 16:33 + +from django.db import models + + class PhotoConsent(BasisModel): user = models.ForeignKey( User, related_name="photo_consents", on_delete=models.CASCADE @@ -555,7 +663,9 @@ class Meta: unique_together = ("semester", "year", "domain", "user") @staticmethod - def get_consents(user, *, time=None): + def get_consents( + user: User, *, time: datetime | None = None + ) -> QuerySet[PhotoConsent]: now = timezone.now() consent_time = time if time is not None else now consent_semester = PhotoConsent.get_semester(consent_time) @@ -583,5 +693,5 @@ def get_consents(user, *, time=None): return PhotoConsent.objects.filter(user=user) @staticmethod - def get_semester(time: datetime): + def get_semester(time: datetime) -> str: return constants.AUTUMN if time.month > 7 else constants.SPRING diff --git a/lego/apps/users/notifications.py b/lego/apps/users/notifications.py index f7e30161b..606e6dba3 100644 --- a/lego/apps/users/notifications.py +++ b/lego/apps/users/notifications.py @@ -21,6 +21,7 @@ def generate_mail(self): "weight": penalty.weight, "event": penalty.source_event.title, "reason": penalty.reason, + "expiration_date": penalty.exact_expiration, }, subject="Du har fått en ny prikk", plain_template="users/email/penalty.txt", @@ -32,7 +33,11 @@ def generate_push(self): return self._delay_push( template="users/push/penalty.txt", - context={"weight": penalty.weight, "event": penalty.source_event.title}, + context={ + "weight": penalty.weight, + "event": penalty.source_event.title, + "expiration_date": penalty.exact_expiration, + }, instance=penalty, ) diff --git a/lego/apps/users/serializers/penalties.py b/lego/apps/users/serializers/penalties.py index 6ceb052d4..fae599497 100644 --- a/lego/apps/users/serializers/penalties.py +++ b/lego/apps/users/serializers/penalties.py @@ -1,11 +1,11 @@ from rest_framework import serializers -from lego.apps.users.models import Penalty +from lego.apps.users.models import PenaltyGroup -class PenaltySerializer(serializers.ModelSerializer): +class PenaltyGroupSerializer(serializers.ModelSerializer): class Meta: - model = Penalty + model = PenaltyGroup fields = ( "id", "created_at", @@ -13,5 +13,6 @@ class Meta: "reason", "weight", "source_event", + "activation_time", "exact_expiration", ) diff --git a/lego/apps/users/serializers/users.py b/lego/apps/users/serializers/users.py index 8459dece9..acdfe5c04 100644 --- a/lego/apps/users/serializers/users.py +++ b/lego/apps/users/serializers/users.py @@ -4,13 +4,19 @@ from lego.apps.files.fields import ImageField from lego.apps.ical.models import ICalToken from lego.apps.users import constants -from lego.apps.users.models import AbakusGroup, Penalty, PhotoConsent, User +from lego.apps.users.models import ( + AbakusGroup, + Penalty, + PenaltyGroup, + PhotoConsent, + User, +) from lego.apps.users.serializers.abakus_groups import PublicAbakusGroupSerializer from lego.apps.users.serializers.memberships import ( MembershipSerializer, PastMembershipSerializer, ) -from lego.apps.users.serializers.penalties import PenaltySerializer +from lego.apps.users.serializers.penalties import PenaltyGroupSerializer from lego.apps.users.serializers.photo_consents import PhotoConsentSerializer from lego.utils.fields import PrimaryKeyRelatedFieldNoPKOpt @@ -27,8 +33,8 @@ class DetailedUserSerializer(serializers.ModelSerializer): ) def get_valid_penalties(self, user): - qs = Penalty.objects.valid().filter(user=user) - serializer = PenaltySerializer(instance=qs, many=True) + qs = Penalty.objects.valid().filter(penalty_group__user=user) + serializer = PenaltyGroupSerializer(instance=qs, many=True) return serializer.data class Meta: @@ -225,8 +231,8 @@ def get_user_ical_token(self, user): return ical_token.token def get_valid_penalties(self, user): - qs = Penalty.objects.valid().filter(user=user) - serializer = PenaltySerializer(instance=qs, many=True) + qs = PenaltyGroup.objects.valid().filter(user=user) + serializer = PenaltyGroupSerializer(instance=qs, many=True) return serializer.data def get_photo_consents(self, user): diff --git a/lego/apps/users/tasks.py b/lego/apps/users/tasks.py index 94877fb64..967fd16c5 100644 --- a/lego/apps/users/tasks.py +++ b/lego/apps/users/tasks.py @@ -1,7 +1,8 @@ from math import ceil from django.conf import settings -from django.db.models import Q +from django.db.models import Count, Q +from django.db.models.functions import Length from django.utils import timezone from structlog import get_logger @@ -78,3 +79,16 @@ def send_inactive_reminder_mail_and_delete_users(self, logger_context=None): for user in users_to_notifiy_montly: send_inactive_notification(user) + + +@celery_app.task(serializer="json", bind=True, base=AbakusTask) +def expire_penalties_if_six_events_has_passed(self, logger_context=None): + # go through all users with penalties and run the expire penalty function on them + + self.setup_logger(logger_context) + + users = User.objects.annotate(penalties_count=Count("penalty_groups")).filter( + penalties_count__gt=0 + ) + for user in users: + user.check_for_expirable_penalty() diff --git a/lego/apps/users/tests/test_models.py b/lego/apps/users/tests/test_models.py index 0726a54f5..fabb05ea8 100644 --- a/lego/apps/users/tests/test_models.py +++ b/lego/apps/users/tests/test_models.py @@ -4,7 +4,8 @@ from django.utils import timezone from django.utils.timezone import timedelta -from lego.apps.events.models import Event +from lego import settings +from lego.apps.events.models import Event, Pool from lego.apps.files.models import File from lego.apps.users import constants from lego.apps.users.constants import ( @@ -12,9 +13,17 @@ SOCIAL_MEDIA_DOMAIN, WEBSITE_DOMAIN, ) -from lego.apps.users.models import AbakusGroup, Membership, Penalty, PhotoConsent, User +from lego.apps.users.models import ( + AbakusGroup, + Membership, + Penalty, + PenaltyGroup, + PhotoConsent, + User, +) from lego.apps.users.registrations import Registrations -from lego.utils.test_utils import BaseTestCase, fake_time +from lego.apps.users.tasks import expire_penalties_if_six_events_has_passed +from lego.utils.test_utils import BaseAPITestCase, BaseTestCase, fake_time class AbakusGroupTestCase(BaseTestCase): @@ -222,6 +231,63 @@ def test_validate_student_confirmation_token(self): self.assertNotEqual(token["member"], False) +class AsyncTestCase(BaseAPITestCase): + fixtures = [ + "test_users.yaml", + "test_abakus_groups.yaml", + "test_companies.yaml", + "test_events.yaml", + ] + + def setUp(self): + self.test_user = User.objects.get(pk=1) + self.source = Event.objects.all().first() + + @mock.patch("django.utils.timezone.now", return_value=fake_time(2016, 10, 10)) + def test_async_expire_penalties_if_six_events_has_passed(self, mock_now): + PenaltyGroup.objects.create( + created_at=mock_now() - timedelta(days=8), + user=self.test_user, + reason="first_penalty", + weight=1, + source_event=self.source, + ) + + PenaltyGroup.objects.create( + created_at=mock_now() - timedelta(days=5), + user=self.test_user, + reason="second_penalty", + weight=2, + source_event=self.source, + ) + self.assertEqual(self.test_user.number_of_penalties(), 3) + + webkom_group = AbakusGroup.objects.get(name="Webkom") + webkom_group.add_user(self.test_user) + webkom_group.save() + self.test_user.save() + + for _i in range(6): + event = Event.objects.create( + title="AbakomEvent", + event_type=0, + start_time=mock_now(), + end_time=mock_now(), + ) + pool = Pool.objects.create( + name="Pool1", + event=event, + capacity=0, + activation_date=timezone.now() - timedelta(days=1), + ) + + pool.permission_groups.set([webkom_group]) + + expire_penalties_if_six_events_has_passed() + + self.assertEqual(self.test_user.number_of_penalties(), 2) + + class MembershipTestCase(BaseTestCase): fixtures = ["test_abakus_groups.yaml", "test_users.yaml"] @@ -264,107 +330,319 @@ def setUp(self): self.test_user = User.objects.get(pk=1) self.source = Event.objects.all().first() - def test_create_penalty(self): - penalty = Penalty.objects.create( - user=self.test_user, reason="test", weight=1, source_event=self.source + def test_create_penalty_group(self): + penalty_group = PenaltyGroup.objects.create( + user=self.test_user, reason="test", source_event=self.source, weight=1 ) + penalties = Penalty.objects.filter(penalty_group__user=self.test_user) + + self.assertEqual(len(penalties), 1) + self.assertEqual(self.test_user.number_of_penalties(), 1) - self.assertEqual(self.test_user, penalty.user) - self.assertEqual("test", penalty.reason) - self.assertEqual(1, penalty.weight) - self.assertEqual(self.source, penalty.source_event) - self.assertEqual(self.source.id, penalty.source_event.id) + self.assertEqual(self.test_user, penalty_group.user) + self.assertEqual("test", penalty_group.reason) + self.assertEqual(1, penalty_group.weight) + self.assertEqual(self.source, penalty_group.source_event) + self.assertEqual(self.source.id, penalty_group.source_event.id) def test_count_weights(self): weights = [1, 2] for weight in weights: - Penalty.objects.create( + PenaltyGroup.objects.create( user=self.test_user, reason="test", - weight=weight, source_event=self.source, + weight=weight, ) self.assertEqual(self.test_user.number_of_penalties(), sum(weights)) - @mock.patch("django.utils.timezone.now", return_value=fake_time(2016, 10, 1)) + @mock.patch("django.utils.timezone.now", return_value=fake_time(2016, 10, 10)) def test_only_count_active_penalties(self, mock_now): - Penalty.objects.create( - created_at=mock_now() - timedelta(days=20), + PenaltyGroup.objects.create( + created_at=mock_now() - timedelta(days=11), user=self.test_user, reason="test", weight=1, source_event=self.source, ) - Penalty.objects.create( - created_at=mock_now() - timedelta(days=19, hours=23, minutes=59), + + PenaltyGroup.objects.create( + created_at=mock_now() - timedelta(days=9), user=self.test_user, reason="test", weight=1, source_event=self.source, ) - self.assertEqual(self.test_user.number_of_penalties(), 1) + PenaltyGroup.objects.create( + created_at=mock_now() - timedelta(days=5), + user=self.test_user, + reason="test", + weight=1, + source_event=self.source, + ) + PenaltyGroup.objects.create( + created_at=mock_now(), + user=self.test_user, + reason="test", + weight=1, + source_event=self.source, + ) + + self.assertEqual(self.test_user.number_of_penalties(), 3) + + @mock.patch("django.utils.timezone.now", return_value=fake_time(2016, 10, 10)) + def test_penalty_group_activation_period(self, mock_now): + PenaltyGroup.objects.create( + created_at=mock_now() - timedelta(days=8), + user=self.test_user, + reason="first_penalty", + weight=1, + source_event=self.source, + ) + + PenaltyGroup.objects.create( + created_at=mock_now() - timedelta(days=2), + user=self.test_user, + reason="second_penalty", + weight=2, + source_event=self.source, + ) + + self.assertEqual( + ( + PenaltyGroup.objects.get(reason="first_penalty").activation_time.day, + PenaltyGroup.objects.get(reason="first_penalty").exact_expiration.day, + ), + (2, 12), + ) + self.assertEqual( + ( + PenaltyGroup.objects.get(reason="second_penalty").activation_time.day, + PenaltyGroup.objects.get(reason="second_penalty").exact_expiration.day, + ), + (12, 1), + ) + + @mock.patch("django.utils.timezone.now", return_value=fake_time(2016, 10, 10)) + def test_penalty_expiration_after_6_events_on_penalty_with_weight_one( + self, mock_now + ): + """Tests that The first active penalty is + expired and the rest are adjusted after 6 events""" + + self.test_user.check_for_expirable_penalty() + + PenaltyGroup.objects.create( + created_at=mock_now() - timedelta(days=8), + user=self.test_user, + reason="first_penalty", + weight=1, + source_event=self.source, + ) + + PenaltyGroup.objects.create( + created_at=mock_now() - timedelta(days=5), + user=self.test_user, + reason="second_penalty", + weight=2, + source_event=self.source, + ) + + # The timeline currently is: 1: 2-12, 2: 12-22, 3: 22-1 + self.assertEqual(self.test_user.number_of_penalties(), 3) + + webkom_group = AbakusGroup.objects.get(name="Webkom") + webkom_group.add_user(self.test_user) + webkom_group.save() + self.test_user.save() + + for _i in range(6): + event = Event.objects.create( + title="AbakomEvent", + event_type=0, + start_time=mock_now(), + end_time=mock_now(), + ) + pool = Pool.objects.create( + name="Pool1", + event=event, + capacity=0, + activation_date=timezone.now() - timedelta(days=1), + ) + + pool.permission_groups.set([webkom_group]) + + self.test_user.check_for_expirable_penalty() + # Tests that the changes happened after 6 events + # The timeline now should be is: 1: 10-20, 2: 20-30 + self.assertEqual(self.test_user.number_of_penalties(), 2) + self.assertEqual( + ( + Penalty.objects.valid()[0].activation_time.day, + Penalty.objects.valid()[0].exact_expiration.day, + Penalty.objects.valid()[1].exact_expiration.day, + ), + (10, 20, 30), + ) + + @mock.patch("django.utils.timezone.now", return_value=fake_time(2016, 10, 10)) + def test_penalty_expiration_after_6_events_on_penalty_with_weight_two( + self, mock_now + ): + """Tests that The weight of the first active penalty + is decremented and the rest are adjusted after 6 events""" + PenaltyGroup.objects.create( + created_at=mock_now() - timedelta(days=8), + user=self.test_user, + reason="first_penalty", + weight=2, + source_event=self.source, + ) + + PenaltyGroup.objects.create( + created_at=mock_now() - timedelta(days=5), + user=self.test_user, + reason="second_penalty", + weight=1, + source_event=self.source, + ) + + webkom_group = AbakusGroup.objects.get(name="Webkom") + webkom_group.add_user(self.test_user) + webkom_group.save() + self.test_user.save() + + for _i in range(5): + event = Event.objects.create( + title="AbakomEvent", + event_type=0, + start_time=mock_now() - timedelta(days=6), + end_time=mock_now() - timedelta(days=6), + ) + pool = Pool.objects.create( + name="Pool1", + event=event, + capacity=0, + activation_date=timezone.now() - timedelta(days=1), + ) + + pool.permission_groups.set([webkom_group]) + + self.test_user.check_for_expirable_penalty() + + # Tests first that nothing is changed after 5 events + self.assertEqual(self.test_user.number_of_penalties(), 3) + self.assertEqual( + ( + Penalty.objects.valid()[0].exact_expiration.day, + Penalty.objects.valid()[1].exact_expiration.day, + Penalty.objects.valid()[2].exact_expiration.day, + ), + (12, 22, 1), + ) + + event = Event.objects.create( + title="AbakomEvent", + event_type=0, + start_time=mock_now() - timedelta(days=6), + end_time=mock_now() - timedelta(days=6), + ) + pool = Pool.objects.create( + name="Pool1", + event=event, + capacity=0, + activation_date=timezone.now() - timedelta(days=1), + ) + self.test_user.check_for_expirable_penalty() + # Tests that the new event is not counted if + # the users does not have permission to the pool + self.assertEqual(self.test_user.number_of_penalties(), 3) + + pool.permission_groups.set([webkom_group]) + + self.test_user.check_for_expirable_penalty() + + # Tests that the changes happened after 6 events + self.assertEqual(self.test_user.number_of_penalties(), 2) + self.assertEqual( + ( + Penalty.objects.valid()[0].exact_expiration.day, + Penalty.objects.valid()[1].exact_expiration.day, + ), + (20, 30), + ) @override_settings(PENALTY_IGNORE_WINTER=((12, 10), (1, 10))) - @mock.patch("django.utils.timezone.now", return_value=fake_time(2016, 12, 10)) + @mock.patch("django.utils.timezone.now", return_value=fake_time(2016, 12, 20)) def test_frozen_penalties_count_as_active_winter(self, mock_now): - # This penalty is created slightly less than 20 days from the freeze-point. - # It should be counted as active. - Penalty.objects.create( - created_at=mock_now() - timedelta(days=20, hours=23, minutes=59), + penalty1 = PenaltyGroup.objects.create( + created_at=mock_now() - timedelta(days=19, hours=23, minutes=59), user=self.test_user, - reason="active", + reason="penalty1", weight=1, source_event=self.source, ) - # This penalty is created exactly 20 days from the freeze-point. - # It should be counted as inactive. - Penalty.objects.create( - created_at=mock_now() - timedelta(days=21), + penalty2 = PenaltyGroup.objects.create( + created_at=mock_now() - timedelta(days=20), user=self.test_user, - reason="inactive", + reason="penalty2", weight=1, source_event=self.source, ) - self.assertEqual(self.test_user.number_of_penalties(), 1) - self.assertEqual(self.test_user.penalties.valid().first().reason, "active") + self.assertEqual(self.test_user.number_of_penalties(), 2) + self.assertEqual( + (penalty1.activation_time.day, penalty1.activation_time.month), + (30, 11), + ) + self.assertEqual( + (penalty2.activation_time.day, penalty2.activation_time.month), + (11, 1), + ) @override_settings(PENALTY_IGNORE_SUMMER=((6, 12), (8, 15))) - @mock.patch("django.utils.timezone.now", return_value=fake_time(2016, 6, 12)) + @mock.patch("django.utils.timezone.now", return_value=fake_time(2016, 6, 22)) def test_frozen_penalties_count_as_active_summer(self, mock_now): - # This penalty is created slightly less than 20 days from the freeze-point. + # This penalty is created slightly less than 10 days from the freeze-point. # It should be counted as active. - Penalty.objects.create( - created_at=mock_now() - timedelta(days=20, hours=23, minutes=59), + penalty1 = PenaltyGroup.objects.create( + created_at=mock_now() - timedelta(days=19, hours=23, minutes=59), user=self.test_user, reason="active", weight=1, source_event=self.source, ) - # This penalty is created exactly 20 days from the freeze-point. + # This penalty is created exactly 10 days from the freeze-point. # It should be counted as inactive. - Penalty.objects.create( - created_at=mock_now() - timedelta(days=21), + penalty2 = PenaltyGroup.objects.create( + created_at=mock_now() - timedelta(days=20), user=self.test_user, reason="inactive", weight=1, source_event=self.source, ) - self.assertEqual(self.test_user.number_of_penalties(), 1) - self.assertEqual(self.test_user.penalties.valid().first().reason, "active") + self.assertEqual(self.test_user.number_of_penalties(), 2) + self.assertEqual( + (penalty1.activation_time.day, penalty1.activation_time.month), + (2, 6), + ) + self.assertEqual( + (penalty2.activation_time.day, penalty2.activation_time.month), + (16, 8), + ) @override_settings(PENALTY_IGNORE_WINTER=((12, 22), (1, 10))) @mock.patch("django.utils.timezone.now", return_value=fake_time(2019, 12, 23)) def test_penalty_offset_is_calculated_correctly(self, mock_now): # This penalty is set to expire the day before the penalty freeze # It should not be active - inactive = Penalty.objects.create( - created_at=mock_now().replace(day=1), + inactive = PenaltyGroup.objects.create( + created_at=mock_now().replace(day=10), user=self.test_user, reason="inactive", weight=1, @@ -372,21 +650,58 @@ def test_penalty_offset_is_calculated_correctly(self, mock_now): ) self.assertEqual(self.test_user.number_of_penalties(), 0) self.assertEqual( - (inactive.exact_expiration.month, inactive.exact_expiration.day), (12, 21) + (inactive.exact_expiration.month, inactive.exact_expiration.day), + (12, inactive.created_at.day + settings.PENALTY_DURATION.days), ) - # This penalty is set to expire the same day as the freeze - active = Penalty.objects.create( - created_at=mock_now().replace(day=2), + active = PenaltyGroup.objects.create( + created_at=mock_now().replace(day=15), user=self.test_user, reason="active", weight=1, source_event=self.source, ) - self.assertEqual(self.test_user.number_of_penalties(), 1) self.assertEqual( - (active.exact_expiration.month, active.exact_expiration.day), (1, 11) + (active.exact_expiration.month, active.exact_expiration.day), + (1, 19), + ) + + @mock.patch("django.utils.timezone.now", return_value=fake_time(2019, 10, 10)) + def test_penalty_group_valid_with_one_weight(self, mock_now): + PenaltyGroup.objects.create( + created_at=mock_now() - timedelta(days=10), + user=self.test_user, + reason="test", + weight=1, + source_event=self.source, + ) + PenaltyGroup.objects.create( + created_at=mock_now() - timedelta(days=9, hours=59, seconds=59), + user=self.test_user, + reason="test", + weight=1, + source_event=self.source, + ) + + self.assertEqual(len(PenaltyGroup.objects.valid()), 1) + + @mock.patch("django.utils.timezone.now", return_value=fake_time(2019, 10, 10)) + def test_penalty_group_valid_with_two_weight(self, mock_now): + PenaltyGroup.objects.create( + created_at=mock_now() - timedelta(days=20), + user=self.test_user, + reason="test", + weight=2, + source_event=self.source, + ) + PenaltyGroup.objects.create( + created_at=mock_now() - timedelta(days=15), + user=self.test_user, + reason="test", + weight=2, + source_event=self.source, ) + self.assertEqual(len(PenaltyGroup.objects.valid()), 1) class PhotoConsentTestCase(BaseTestCase): diff --git a/lego/apps/users/tests/test_users_api.py b/lego/apps/users/tests/test_users_api.py index 098a683c3..f519ee5d2 100644 --- a/lego/apps/users/tests/test_users_api.py +++ b/lego/apps/users/tests/test_users_api.py @@ -9,7 +9,7 @@ from lego.apps.files.models import File from lego.apps.users import constants from lego.apps.users.constants import AUTUMN, SOCIAL_MEDIA_DOMAIN, WEBSITE_DOMAIN -from lego.apps.users.models import AbakusGroup, Penalty, PhotoConsent, User +from lego.apps.users.models import AbakusGroup, PenaltyGroup, PhotoConsent, User from lego.apps.users.registrations import Registrations from lego.utils.test_utils import BaseAPITestCase, fake_time @@ -434,7 +434,7 @@ def test_self_authed(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual( - len(self.user.penalties.valid()), len(response.json()["penalties"]) + len(self.user.penalty_groups.valid()), len(response.json()["penalties"]) ) data = response.json() self.assertEqual(self.user.id, data["id"]) @@ -453,30 +453,31 @@ def test_self_unauthed(self): @mock.patch("django.utils.timezone.now", return_value=fake_time(2016, 10, 1)) def test_own_penalties_serializer(self, mock_now): source = Event.objects.all().first() - Penalty.objects.create( - created_at=mock_now() - timedelta(days=20), + PenaltyGroup.objects.create( + created_at=mock_now() - timedelta(days=10), user=self.user, reason="test", weight=1, source_event=source, ) - Penalty.objects.create( - created_at=mock_now() - timedelta(days=19, hours=23, minutes=59), + PenaltyGroup.objects.create( + created_at=mock_now() - timedelta(days=9, hours=23, minutes=59), user=self.user, reason="test", weight=1, source_event=source, ) + self.client.force_authenticate(user=self.user) + response = self.client.get(reverse("api:v1:user-me")) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual( - len(self.user.penalties.valid()), len(response.json()["penalties"]) + len(self.user.penalty_groups.valid()), len(response.json()["penalties"]) ) self.assertEqual(len(response.json()["penalties"]), 1) - self.assertEqual(len(response.json()["penalties"][0]), 7) class UpdatePhotoConsentTestCase(BaseAPITestCase): diff --git a/lego/apps/users/views/penalties.py b/lego/apps/users/views/penalties.py index 50e660038..a649e9c4e 100644 --- a/lego/apps/users/views/penalties.py +++ b/lego/apps/users/views/penalties.py @@ -2,18 +2,18 @@ from rest_framework.viewsets import GenericViewSet from lego.apps.permissions.api.views import AllowedPermissionsMixin -from lego.apps.users.filters import PenaltyFilterSet -from lego.apps.users.models import Penalty -from lego.apps.users.serializers.penalties import PenaltySerializer +from lego.apps.users.filters import PenaltyGroupFilterSet +from lego.apps.users.models import PenaltyGroup +from lego.apps.users.serializers.penalties import PenaltyGroupSerializer -class PenaltyViewSet( +class PenaltyGroupViewSet( AllowedPermissionsMixin, mixins.CreateModelMixin, mixins.DestroyModelMixin, mixins.ListModelMixin, GenericViewSet, ): - queryset = Penalty.objects.all() - serializer_class = PenaltySerializer - filterset_class = PenaltyFilterSet + queryset = PenaltyGroup.objects.all() + serializer_class = PenaltyGroupSerializer + filterset_class = PenaltyGroupFilterSet diff --git a/lego/settings/celery.py b/lego/settings/celery.py index 0d98bea98..6738df733 100644 --- a/lego/settings/celery.py +++ b/lego/settings/celery.py @@ -80,6 +80,10 @@ def on_setup_logging(**kwargs): "task": "lego.apps.users.tasks.send_inactive_reminder_mail_and_delete_users", "schedule": crontab(hour=7, minute=0, day_of_week=1), }, + "expire_penalties_if_possible": { + "task": "lego.apps.users.tasks.expire_penalties_if_six_events_has_passed", + "schedule": crontab(hour=2, minute=0), + }, "notify_user_of_unanswered_meeting_invitation": { "task": "lego.apps.meetings.tasks.notify_user_of_unanswered_meeting_invitation", "schedule": crontab(hour=10, minute=0), diff --git a/lego/settings/lego.py b/lego/settings/lego.py index bf0b5646a..18ff0defc 100644 --- a/lego/settings/lego.py +++ b/lego/settings/lego.py @@ -17,7 +17,8 @@ ADMINS = (("Webkom", "webkom@abakus.no"),) MANAGERS = ADMINS -PENALTY_DURATION = timedelta(days=20) +PENALTY_DURATION = timedelta(days=10) +PENALTY_DELAY_DURATION = 3 # Tuples for ignored (month, day) intervals PENALTY_IGNORE_SUMMER = ((6, 1), (8, 15)) PENALTY_IGNORE_WINTER = ((12, 1), (1, 10))