Skip to content

Commit ee3fa50

Browse files
Grant reward points on evaluation deletion (#2358)
1 parent b393327 commit ee3fa50

File tree

8 files changed

+161
-19
lines changed

8 files changed

+161
-19
lines changed

evap/evaluation/tests/test_tools.py

+15-2
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,18 @@
33

44
from django.core import management
55
from django.core.exceptions import SuspiciousOperation
6+
from django.db import transaction
67
from django.db.models import Model, prefetch_related_objects
78
from django.http import Http404
89
from django.utils import translation
910
from model_bakery import baker
1011

1112
from evap.evaluation.models import Contribution, Course, Evaluation, TextAnswer, UserProfile
12-
from evap.evaluation.tests.tools import TestCase, WebTest
13+
from evap.evaluation.tests.tools import SimpleTestCase, TestCase, WebTest
1314
from evap.evaluation.tools import (
1415
discard_cached_related_objects,
1516
get_object_from_dict_pk_entry_or_logged_40x,
17+
inside_transaction,
1618
is_prefetched,
1719
)
1820

@@ -58,7 +60,7 @@ def test_log_exceptions_decorator(self, mock_logger, __):
5860
self.assertIn("failed. Traceback follows:", mock_logger.call_args[0][0])
5961

6062

61-
class TestHelperMethods(WebTest):
63+
class TestHelperMethods(TestCase):
6264
def test_is_prefetched(self):
6365
evaluation = baker.make(Evaluation, voters=[baker.make(UserProfile)])
6466
baker.make(Contribution, evaluation=evaluation)
@@ -196,3 +198,14 @@ def test_get_object_from_dict_pk_entry_or_logged_40x_for_uuids(self):
196198

197199
answer = baker.make(TextAnswer)
198200
self.assertEqual(get_object_from_dict_pk_entry_or_logged_40x(TextAnswer, {"pk": str(answer.pk)}, "pk"), answer)
201+
202+
203+
class TestHelperMethodsWithoutTransaction(SimpleTestCase):
204+
# WARNING: Do not execute any queries modifying the database. Changes will not be rolled back
205+
databases = {"default"}
206+
207+
def test_inside_transaction(self):
208+
self.assertFalse(inside_transaction())
209+
210+
with transaction.atomic():
211+
self.assertTrue(inside_transaction())

evap/evaluation/tests/tools.py

+4
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ class TestCase(ResetLanguageOnTearDownMixin, django.test.TestCase):
5555
pass
5656

5757

58+
class SimpleTestCase(ResetLanguageOnTearDownMixin, django.test.SimpleTestCase):
59+
pass
60+
61+
5862
class WebTest(ResetLanguageOnTearDownMixin, django_webtest.WebTest):
5963
pass
6064

evap/evaluation/tools.py

+6
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import xlwt
99
from django.conf import settings
1010
from django.core.exceptions import SuspiciousOperation, ValidationError
11+
from django.db import DEFAULT_DB_ALIAS, connections
1112
from django.db.models import Model
1213
from django.forms.formsets import BaseFormSet
1314
from django.http import HttpRequest, HttpResponse
@@ -87,6 +88,11 @@ def discard_cached_related_objects(instance: M) -> M:
8788
return instance
8889

8990

91+
def inside_transaction() -> bool:
92+
# https://github.com/django/django/blob/402ae37873974afa5093e6d6149175a118979cd9/django/db/transaction.py#L208
93+
return connections[DEFAULT_DB_ALIAS].in_atomic_block
94+
95+
9096
def is_external_email(email: str) -> bool:
9197
return not any(email.endswith("@" + domain) for domain in settings.INSTITUTION_EMAIL_DOMAINS)
9298

evap/rewards/models.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ class RewardPointGranting(models.Model):
4040
granting_time = models.DateTimeField(verbose_name=_("granting time"), auto_now_add=True)
4141
value = models.IntegerField(verbose_name=_("value"), validators=[MinValueValidator(1)])
4242

43-
granted_by_removal = Signal()
43+
granted_by_participation_removal = Signal()
44+
granted_by_evaluation_deletion = Signal()
4445

4546

4647
class RewardPointRedemption(models.Model):

evap/rewards/tests/test_tools.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,12 @@ def setUpTestData(cls):
9797

9898
def test_participant_removed_from_evaluation(self):
9999
self.evaluation.participants.remove(self.student)
100-
101100
self.assertEqual(reward_points_of_user(self.student), 3)
102101

103102
def test_evaluation_removed_from_participant(self):
104103
self.student.evaluations_participating_in.remove(self.evaluation)
104+
self.assertEqual(reward_points_of_user(self.student), 3)
105105

106+
def test_evaluation_deleted(self):
107+
self.evaluation.delete()
106108
self.assertEqual(reward_points_of_user(self.student), 3)

evap/rewards/tools.py

+46-11
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,26 @@
1+
import logging
2+
13
from django.conf import settings
24
from django.contrib import messages
35
from django.db import models
46
from django.db.models import Sum
57
from django.dispatch import receiver
8+
from django.http import HttpRequest
69
from django.utils.translation import gettext as _
710
from django.utils.translation import ngettext
811

912
from evap.evaluation.models import Evaluation, Semester, UserProfile
13+
from evap.evaluation.tools import inside_transaction
1014
from evap.rewards.models import RewardPointGranting, RewardPointRedemption, SemesterActivation
1115

16+
logger = logging.getLogger(__name__)
17+
1218

13-
def can_reward_points_be_used_by(user):
19+
def can_reward_points_be_used_by(user: UserProfile) -> bool:
1420
return not user.is_external and user.is_participant
1521

1622

17-
def reward_points_of_user(user):
23+
def reward_points_of_user(user: UserProfile) -> int:
1824
count = 0
1925
for granting in user.reward_point_grantings.all():
2026
count += granting.value
@@ -24,15 +30,19 @@ def reward_points_of_user(user):
2430
return count
2531

2632

27-
def redeemed_points_of_user(user):
33+
def redeemed_points_of_user(user: UserProfile) -> int:
2834
return RewardPointRedemption.objects.filter(user_profile=user).aggregate(Sum("value"))["value__sum"] or 0
2935

3036

31-
def is_semester_activated(semester):
37+
def is_semester_activated(semester: Semester) -> bool:
3238
return SemesterActivation.objects.filter(semester=semester, is_active=True).exists()
3339

3440

35-
def grant_reward_points_if_eligible(user, semester):
41+
def deactivate_semester(semester: Semester) -> None:
42+
SemesterActivation.objects.filter(semester=semester).update(is_active=False)
43+
44+
45+
def grant_reward_points_if_eligible(user: UserProfile, semester: Semester) -> tuple[RewardPointGranting | None, bool]:
3646
if not can_reward_points_be_used_by(user):
3747
return None, False
3848
if not is_semester_activated(semester):
@@ -57,7 +67,7 @@ def grant_reward_points_if_eligible(user, semester):
5767
return None, False
5868

5969

60-
def grant_eligible_reward_points_for_semester(request, semester):
70+
def grant_eligible_reward_points_for_semester(request: HttpRequest, semester: Semester) -> None:
6171
users = UserProfile.objects.filter(evaluations_voted_for__course__semester=semester)
6272
reward_point_sum = 0
6373
for user in users:
@@ -77,7 +87,9 @@ def grant_eligible_reward_points_for_semester(request, semester):
7787

7888

7989
@receiver(Evaluation.evaluation_evaluated)
80-
def grant_reward_points_after_evaluate(request, semester, **_kwargs):
90+
def grant_reward_points_after_evaluate(request: HttpRequest, semester: Semester, **_kwargs) -> None:
91+
assert isinstance(request.user, UserProfile)
92+
8193
granting, completed_evaluation = grant_reward_points_if_eligible(request.user, semester)
8294
if granting:
8395
message = ngettext(
@@ -100,21 +112,21 @@ def grant_reward_points_after_evaluate(request, semester, **_kwargs):
100112

101113

102114
@receiver(models.signals.m2m_changed, sender=Evaluation.participants.through)
103-
def grant_reward_points_after_delete(instance, action, reverse, pk_set, **_kwargs):
115+
def grant_reward_points_on_participation_change(instance, action: str, reverse: bool, pk_set, **_kwargs) -> None:
104116
# if users do not need to evaluate anymore, they may have earned reward points
105117
if action == "post_remove":
106118
grantings = []
107119

108120
if reverse:
109-
# an evaluation got removed from a participant
121+
# one or more evaluations got removed from a participant
110122
user = instance
111123

112124
for semester in Semester.objects.filter(courses__evaluations__pk__in=pk_set):
113125
granting, __ = grant_reward_points_if_eligible(user, semester)
114126
if granting:
115127
grantings = [granting]
116128
else:
117-
# a participant got removed from an evaluation
129+
# one or more participants got removed from an evaluation
118130
evaluation = instance
119131

120132
for user in UserProfile.objects.filter(pk__in=pk_set):
@@ -123,4 +135,27 @@ def grant_reward_points_after_delete(instance, action, reverse, pk_set, **_kwarg
123135
grantings.append(granting)
124136

125137
if grantings:
126-
RewardPointGranting.granted_by_removal.send(sender=RewardPointGranting, grantings=grantings)
138+
RewardPointGranting.granted_by_participation_removal.send(sender=RewardPointGranting, grantings=grantings)
139+
140+
141+
@receiver(models.signals.pre_delete, sender=Evaluation)
142+
def grant_reward_points_on_evaluation_delete(instance: Evaluation, **_kwargs) -> None:
143+
if not inside_transaction():
144+
# This will always be true in a django TestCase, so our tests can't meaningfully catch calls that are not
145+
# wrapped in a transaction. Requiring a transaction is a precaution so that an (unlikely) failing .delete()
146+
# execution afterwards doesn't leave us in half-deleted state. Chances are, if deletion fails, staff users
147+
# will still want to delete the instance.
148+
# Currently, only staff:evaluation_delete and staff:semester_delete call .delete()
149+
logger.error("Called while not inside transaction")
150+
151+
grantings = []
152+
153+
participants = list(instance.participants.all())
154+
instance.participants.clear()
155+
for user in participants:
156+
granting, __ = grant_reward_points_if_eligible(user, instance.course.semester)
157+
if granting:
158+
grantings.append(granting)
159+
160+
if grantings:
161+
RewardPointGranting.granted_by_evaluation_deletion.send(sender=RewardPointGranting, grantings=grantings)

evap/staff/tests/test_views.py

+67-1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from django.conf import settings
1111
from django.contrib.auth.models import Group
1212
from django.core import mail
13+
from django.db import transaction
1314
from django.db.models import Model
1415
from django.http import HttpResponse
1516
from django.test import override_settings
@@ -50,6 +51,7 @@
5051
from evap.grades.models import GradeDocument
5152
from evap.results.tools import TextResult, cache_results, get_results
5253
from evap.rewards.models import RewardPointGranting, SemesterActivation
54+
from evap.rewards.tools import reward_points_of_user
5355
from evap.staff.forms import ContributionCopyForm, ContributionCopyFormset, CourseCopyForm, EvaluationCopyForm
5456
from evap.staff.tests.utils import (
5557
WebTestStaffMode,
@@ -302,7 +304,7 @@ def test_inactive_edit(self, mock_remove):
302304
mock_remove.assert_called_with(self.testuser)
303305
self.assertIn(mock_remove.return_value[0], response)
304306

305-
def test_reward_points_granting_message(self):
307+
def test_reward_point_granting(self):
306308
evaluation = baker.make(Evaluation, course__semester__is_active=True)
307309
already_evaluated = baker.make(Evaluation, course=baker.make(Course, semester=evaluation.course.semester))
308310
SemesterActivation.objects.create(semester=evaluation.course.semester, is_active=True)
@@ -317,6 +319,8 @@ def test_reward_points_granting_message(self):
317319
form = page.forms["user-form"]
318320
form["evaluations_participating_in"] = [already_evaluated.pk]
319321

322+
self.assertEqual(reward_points_of_user(student), 0)
323+
320324
page = form.submit().follow()
321325
# fetch the user name, which became lowercased
322326
student.refresh_from_db()
@@ -328,6 +332,8 @@ def test_reward_points_granting_message(self):
328332
page,
329333
)
330334

335+
self.assertEqual(reward_points_of_user(student), 3)
336+
331337

332338
class TestUserDeleteView(DeleteViewTestMixin, WebTestStaffMode):
333339
url = reverse("staff:user_delete")
@@ -895,6 +901,36 @@ def test_success_with_data(self):
895901

896902
self.assertFalse(Semester.objects.filter(pk=self.instance.pk).exists())
897903

904+
@override_settings(REWARD_POINTS=[(1, 3)])
905+
def test_does_not_grant_redemption_points(self):
906+
student = baker.make(UserProfile, email="[email protected]")
907+
evaluation = baker.make(
908+
Evaluation, participants=[student], state=Evaluation.State.PUBLISHED, course__semester=self.instance
909+
)
910+
SemesterActivation.objects.update_or_create(semester=self.instance, defaults={"is_active": True})
911+
912+
# student must be participant in at least one other evaluation
913+
baker.make(
914+
Evaluation,
915+
participants=[student],
916+
voters=[student],
917+
state=Evaluation.State.PUBLISHED,
918+
course__semester=self.instance,
919+
)
920+
921+
self.instance.archive()
922+
self.instance.delete_grade_documents()
923+
self.instance.archive_results()
924+
925+
# just deleting the evaluation would grant redemption point -- ensures setup above is sufficient
926+
with transaction.atomic():
927+
Evaluation.objects.get(pk=evaluation.pk).delete()
928+
assert reward_points_of_user(student) == 3
929+
transaction.set_rollback(True)
930+
931+
self.app.post(self.url, params=self.post_params, user=self.user)
932+
self.assertEqual(reward_points_of_user(student), 0)
933+
898934

899935
class TestSemesterAssignView(WebTestStaffMode):
900936
@classmethod
@@ -2370,6 +2406,36 @@ def test_invalid_deletion(self):
23702406
self.app.post(self.url, user=self.manager, params=self.post_params, status=400)
23712407
self.assertTrue(Evaluation.objects.filter(pk=self.evaluation.pk).exists())
23722408

2409+
@patch.object(Evaluation, "can_be_deleted_by_manager", True)
2410+
def test_reward_point_granting(self):
2411+
already_evaluated = baker.make(Evaluation, course__semester=self.evaluation.course.semester)
2412+
SemesterActivation.objects.create(semester=self.evaluation.course.semester, is_active=True)
2413+
2414+
# does not get reward points as it was the only evaluation in this semester
2415+
student = baker.make(
2416+
UserProfile, email="[email protected]", evaluations_participating_in=[self.evaluation]
2417+
)
2418+
2419+
# get reward points
2420+
baker.make(
2421+
UserProfile,
2422+
email=iter(f"{name}@institution.example.com" for name in ["a", "b", "c", "d", "e"]),
2423+
evaluations_participating_in=[self.evaluation, already_evaluated],
2424+
evaluations_voted_for=[already_evaluated],
2425+
_quantity=5,
2426+
)
2427+
2428+
self.assertEqual(reward_points_of_user(student), 0)
2429+
2430+
with patch("evap.staff.views.logger") as mock:
2431+
self.app.post(self.url, user=self.manager, params=self.post_params, status=200)
2432+
2433+
self.assertEqual(mock.info.call_args_list[0][1]["extra"]["num_grantings"], 5)
2434+
self.assertEqual(reward_points_of_user(student), 0)
2435+
2436+
user_a = UserProfile.objects.get(email="[email protected]")
2437+
self.assertEqual(reward_points_of_user(user_a), 3)
2438+
23732439

23742440
class TestSingleResultEditView(WebTestStaffModeWith200Check):
23752441
@classmethod

0 commit comments

Comments
 (0)