-
Notifications
You must be signed in to change notification settings - Fork 146
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Delete participations of long inactive users #2329
base: main
Are you sure you want to change the base?
Changes from all commits
517af89
b36e41e
6c9f948
bdfb350
582aba5
cc3e1b2
b4cf95c
8c1a755
4e1fcf5
7dcfa62
a2db34e
0b810cf
937acad
7852f2e
60301ad
a4475e0
0a33410
5f10109
6cc1cf4
87bbf6d
b198732
d5fa9c6
c9f3a0a
51b5f99
2f5711c
74e9076
1dc1fb7
36a5a00
01d60a2
03622d6
c526f84
810a957
2ab9cff
6715f77
351fbaa
08983c3
eebef2b
cd63d80
3db7709
32a4c79
3364e5c
0b863c6
58d239d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||
---|---|---|---|---|---|---|---|---|
@@ -1,8 +1,11 @@ | ||||||||
from datetime import datetime, timedelta | ||||||||
from io import BytesIO | ||||||||
from itertools import cycle, repeat | ||||||||
from unittest.mock import MagicMock, patch | ||||||||
|
||||||||
from django.conf import settings | ||||||||
from django.contrib.auth.models import Group | ||||||||
from django.test import override_settings | ||||||||
from django.utils.html import escape | ||||||||
from model_bakery import baker | ||||||||
from openpyxl import load_workbook | ||||||||
|
@@ -19,6 +22,7 @@ | |||||||
from evap.staff.tools import ( | ||||||||
conditional_escape, | ||||||||
merge_users, | ||||||||
remove_inactive_participations, | ||||||||
remove_user_from_represented_and_ccing_users, | ||||||||
user_edit_link, | ||||||||
) | ||||||||
|
@@ -217,6 +221,73 @@ def test_do_nothing_if_test_run(self): | |||||||
self.assertEqual(len(messages), 4) | ||||||||
|
||||||||
|
||||||||
class RemoveParticipationDueToInactivityTest(TestCase): | ||||||||
@classmethod | ||||||||
def setUpTestData(cls): | ||||||||
cls.user = baker.make(UserProfile) | ||||||||
six_months_ago = datetime.today() - timedelta(days=6 * 30) | ||||||||
cls.evaluation = baker.make( | ||||||||
Evaluation, | ||||||||
state=Evaluation.State.PUBLISHED, | ||||||||
vote_start_datetime=six_months_ago - settings.PARTICIPATION_DELETION_AFTER_INACTIVE_TIME, | ||||||||
vote_end_date=six_months_ago.date(), | ||||||||
participants=[cls.user], | ||||||||
) | ||||||||
cls.evaluation.course.semester.archive() | ||||||||
|
||||||||
@override_settings(PARTICIPATION_DELETION_AFTER_INACTIVE_TIME=timedelta(6 * 30)) | ||||||||
def test_remove_user_due_to_inactivity(self): | ||||||||
self.assertTrue(self.user.evaluations_participating_in.exists()) | ||||||||
|
||||||||
messages = remove_inactive_participations(self.user) | ||||||||
|
||||||||
self.assertFalse(self.user.evaluations_participating_in.exists()) | ||||||||
self.assertTrue(self.user.can_be_marked_inactive_by_manager) | ||||||||
self.assertEqual(messages, [f"Removed {self.user.full_name} from 1 participation(s) due to inactivity."]) | ||||||||
|
||||||||
messages = remove_inactive_participations(self.user) | ||||||||
|
||||||||
self.assertEqual(messages, []) | ||||||||
|
||||||||
@patch("evap.evaluation.models.UserProfile.is_active", True) | ||||||||
@patch("evap.evaluation.models.UserProfile.can_be_marked_inactive_by_manager", True) | ||||||||
def test_do_not_remove_user_due_to_inactivity_with_recently_archived_evaluation(self): | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This relies on the default setting value, but it would be nice if the test passed even if some developers locally changed the setting. Thus, we should also
Suggested change
|
||||||||
self.assertTrue(self.user.evaluations_participating_in.exists()) | ||||||||
|
||||||||
messages = remove_inactive_participations(self.user) | ||||||||
|
||||||||
self.assertTrue(self.user.evaluations_participating_in.exists()) | ||||||||
self.assertEqual(messages, []) | ||||||||
|
||||||||
@patch("evap.evaluation.models.UserProfile.is_active", True) | ||||||||
@patch("evap.evaluation.models.UserProfile.can_be_marked_inactive_by_manager", False) | ||||||||
def test_do_not_remove_user_due_to_inactivity_with_active_evaluation(self): | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Needs an override on PARTICIPATION_DELETION_AFTER_INACTIVE_TIME to ensure we actually don't do anything because of |
||||||||
self.assertTrue(self.user.evaluations_participating_in.exists()) | ||||||||
|
||||||||
messages = remove_inactive_participations(self.user) | ||||||||
|
||||||||
self.assertTrue(self.user.evaluations_participating_in.exists()) | ||||||||
self.assertEqual(messages, []) | ||||||||
|
||||||||
@override_settings(PARTICIPATION_DELETION_AFTER_INACTIVE_TIME=timedelta(6 * 30)) | ||||||||
def test_do_nothing_if_test_run(self): | ||||||||
self.assertTrue(self.user.evaluations_participating_in.exists()) | ||||||||
|
||||||||
messages = remove_inactive_participations(self.user, test_run=True) | ||||||||
|
||||||||
self.assertTrue(self.user.evaluations_participating_in.exists()) | ||||||||
self.assertTrue(self.user.can_be_marked_inactive_by_manager) | ||||||||
self.assertEqual( | ||||||||
messages, [f"{self.user.full_name} will be removed from 1 participation(s) due to inactivity."] | ||||||||
) | ||||||||
|
||||||||
messages = remove_inactive_participations(self.user, test_run=True) | ||||||||
|
||||||||
self.assertEqual( | ||||||||
messages, [f"{self.user.full_name} will be removed from 1 participation(s) due to inactivity."] | ||||||||
) | ||||||||
|
||||||||
Comment on lines
+284
to
+289
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is just the same assertion again, duplicated? We should instead assert that the participations of the user haven't changed. |
||||||||
|
||||||||
class UserEditLinkTest(TestCase): | ||||||||
def test_user_edit_link(self): | ||||||||
user = baker.make(UserProfile) | ||||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -8,15 +8,15 @@ | |
from django.contrib.auth.models import Group | ||
from django.core.exceptions import SuspiciousOperation | ||
from django.db import transaction | ||
from django.db.models import Count | ||
from django.db.models import Count, Max | ||
from django.urls import reverse | ||
from django.utils.html import escape, format_html, format_html_join | ||
from django.utils.safestring import SafeString | ||
from django.utils.translation import gettext_lazy as _ | ||
from django.utils.translation import gettext_lazy as _, ngettext | ||
|
||
from evap.evaluation.models import Contribution, Course, Evaluation, TextAnswer, UserProfile | ||
from evap.evaluation.models_logging import LogEntry | ||
from evap.evaluation.tools import clean_email, is_external_email | ||
from evap.evaluation.tools import StrOrPromise, clean_email, is_external_email | ||
from evap.grades.models import GradeDocument | ||
from evap.results.tools import STATES_WITH_RESULTS_CACHING, cache_results | ||
|
||
|
@@ -132,12 +132,14 @@ def bulk_update_users(request, user_file_content, test_run): # noqa: PLR0912 | |
users_to_be_updated.append((matching_user, imported_email)) | ||
|
||
emails_of_non_obsolete_users = set(imported_emails) | {user.email for user, _ in users_to_be_updated} | ||
deletable_users, users_to_mark_inactive = [], [] | ||
deletable_users, users_to_mark_inactive, inactive_users = [], [], [] | ||
for user in UserProfile.objects.exclude(email__in=emails_of_non_obsolete_users): | ||
if user.can_be_deleted_by_manager: | ||
deletable_users.append(user) | ||
elif user.is_active and user.can_be_marked_inactive_by_manager: | ||
users_to_mark_inactive.append(user) | ||
elif not user.is_active: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The elif here seems suspicious to me. An inactive user that has I think the loop here was meaningful and concise before, and just handled user deletion (or deactivation, if deletion isn't possible). I'd view removal of participations as orthogonal, and try to keep these as separate as possible, i.e., first just handle all deletion/marking inactive, and then handle participation archival on the remaining users. What do you think about that? |
||
inactive_users.append(user) | ||
|
||
messages.info( | ||
request, | ||
|
@@ -195,6 +197,9 @@ def bulk_update_users(request, user_file_content, test_run): # noqa: PLR0912 | |
user, deletable_users + users_to_mark_inactive, test_run | ||
): | ||
messages.warning(request, message) | ||
for user in users_to_mark_inactive + inactive_users: | ||
for message in remove_inactive_participations(user, test_run): | ||
messages.warning(request, message) | ||
if test_run: | ||
messages.info(request, _("No data was changed in this test run.")) | ||
else: | ||
|
@@ -203,6 +208,7 @@ def bulk_update_users(request, user_file_content, test_run): # noqa: PLR0912 | |
for user in users_to_mark_inactive: | ||
user.is_active = False | ||
user.save() | ||
|
||
for user, email in users_to_be_updated: | ||
user.email = email | ||
user.save() | ||
|
@@ -375,6 +381,35 @@ def remove_user_from_represented_and_ccing_users(user, ignored_users=None, test_ | |
return remove_messages | ||
|
||
|
||
def remove_inactive_participations(user: UserProfile, test_run=False) -> list[StrOrPromise]: | ||
if user.is_active and not user.can_be_marked_inactive_by_manager: | ||
return [] | ||
last_participation = user.evaluations_participating_in.aggregate(Max("vote_end_date"))["vote_end_date__max"] | ||
if ( | ||
last_participation is None | ||
or (date.today() - last_participation) < settings.PARTICIPATION_DELETION_AFTER_INACTIVE_TIME | ||
): | ||
return [] | ||
|
||
evaluation_count = user.evaluations_participating_in.count() | ||
if test_run: | ||
return [ | ||
ngettext( | ||
"{} participation of {} would be removed due to inactivity.", | ||
"{} participations of {} would be removed due to inactivity.", | ||
evaluation_count, | ||
).format(evaluation_count, user.full_name) | ||
] | ||
user.evaluations_participating_in.clear() | ||
return [ | ||
ngettext( | ||
"{} participation of {} was removed due to inactivity.", | ||
"{} participations of {} were removed due to inactivity.", | ||
evaluation_count, | ||
).format(evaluation_count, user.full_name) | ||
] | ||
|
||
|
||
def user_edit_link(user_id): | ||
return format_html( | ||
'<a href="{}" target=_blank><span class="fas fa-user-pen"></span> {}</a>', | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The usage of
PARTICIPATION_DELETION_AFTER_INACTIVE_TIME
is misleading here, we just need some earlier start date. I think we can just use hardcoded 180/200 for the test (or any other value that we consider memorable enough, I'd also be fine withvote_end_date
being 10 or 100 days ago)