Skip to content
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

Open
wants to merge 43 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
517af89
Add PARTICIPATION_DELETION_AFTER_INACTIVE_MONTHS to settings.py with …
Redflashx12 Oct 21, 2024
b36e41e
Add bulk delete of inactive users from their evaluations
Redflashx12 Oct 21, 2024
6c9f948
Update to use timedelta and just mentioning months instead of convert…
Redflashx12 Oct 21, 2024
bdfb350
Filter users which do not have any evaluations in which they are inac…
Redflashx12 Oct 21, 2024
582aba5
change unit to days since months is not supported and add small comment
Redflashx12 Oct 21, 2024
cc3e1b2
fix typo
Redflashx12 Oct 21, 2024
b4cf95c
fix correct date subtraction
Redflashx12 Oct 21, 2024
8c1a755
Add default value for test_run
Redflashx12 Oct 21, 2024
4e1fcf5
WIP test for inactivity
Redflashx12 Oct 21, 2024
7dcfa62
Refactor functionality to dedicated function
Redflashx12 Nov 4, 2024
a2db34e
Fix test by archiving the semester and using settings value in test f…
Redflashx12 Nov 4, 2024
0b810cf
Remove unneeded +1 and brackets, add evaluation needed to not be mark…
Redflashx12 Nov 4, 2024
937acad
refactor and split into two tests
Redflashx12 Nov 4, 2024
7852f2e
Fix indentation
Redflashx12 Nov 4, 2024
60301ad
Forgot to remove factor 30 for months since it's already included, ne…
Redflashx12 Nov 4, 2024
a4475e0
black formatting
Redflashx12 Nov 4, 2024
0a33410
isort formatting
Redflashx12 Nov 4, 2024
5f10109
increase test coverage by adding test case for test run
Redflashx12 Nov 4, 2024
6cc1cf4
Fix django import of settings
Redflashx12 Nov 11, 2024
87bbf6d
fix to use pythonic way of checking len
Redflashx12 Nov 11, 2024
b198732
Use timedelta instead of numeric value for days
Redflashx12 Nov 11, 2024
d5fa9c6
Fix logic
Redflashx12 Nov 11, 2024
c9f3a0a
Add inactive users to be removed
Redflashx12 Nov 11, 2024
51b5f99
Change django magic query to look and check latest vote_end_date
Redflashx12 Nov 11, 2024
2f5711c
Refactor test to use django's setUpTestData
Redflashx12 Nov 11, 2024
74e9076
"Fix" type hint to use | None instead of Optional
Redflashx12 Nov 11, 2024
1dc1fb7
reorder imports
Redflashx12 Nov 11, 2024
36a5a00
linting
Redflashx12 Nov 11, 2024
01d60a2
Add more edge cases to tests
Redflashx12 Nov 11, 2024
03622d6
Remove assignment of never used variable
Redflashx12 Nov 11, 2024
c526f84
Merge branch 'main' into main
Redflashx12 Nov 11, 2024
810a957
refactor `remove_inactive_participations`
niklasmohrin Nov 18, 2024
2ab9cff
Rename variable
Redflashx12 Nov 18, 2024
6715f77
Implement feedback
Redflashx12 Nov 18, 2024
351fbaa
Fix logic error and wrong type
Redflashx12 Nov 18, 2024
08983c3
Fix missing rename of variable in @override_settings
Redflashx12 Nov 18, 2024
eebef2b
Extend to test for user.evaluations_participating_in.exists() and rep…
Redflashx12 Nov 18, 2024
cd63d80
Use mock.patch decorator instead of creating new Evaluations with use…
Redflashx12 Nov 18, 2024
3db7709
Remove unused import
Redflashx12 Nov 18, 2024
32a4c79
ruff fix
Redflashx12 Nov 18, 2024
3364e5c
Use proper plural formatting
Redflashx12 Jan 6, 2025
0b863c6
Use proper plural formatting
Redflashx12 Jan 6, 2025
58d239d
Fix missing import of ngettext
Redflashx12 Jan 6, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions evap/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import logging
import os
import sys
from datetime import timedelta
from fractions import Fraction
from typing import Any

Expand All @@ -36,6 +37,7 @@
VOTER_COUNT_NEEDED_FOR_PUBLISHING_RATING_RESULTS = 2
VOTER_PERCENTAGE_NEEDED_FOR_PUBLISHING_AVERAGE_GRADE = 0.2
SMALL_COURSE_SIZE = 5 # up to which number of participants the evaluation gets additional warnings about anonymity
PARTICIPATION_DELETION_AFTER_INACTIVE_TIME = timedelta(days=18 * 30)

# a warning is shown next to results where less than RESULTS_WARNING_COUNT answers were given
# or the number of answers is less than RESULTS_WARNING_PERCENTAGE times the median number of answers (for this question in this evaluation)
Expand Down
71 changes: 71 additions & 0 deletions evap/staff/tests/test_tools.py
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
Expand All @@ -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,
)
Expand Down Expand Up @@ -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(),
Comment on lines +228 to +233
Copy link
Member

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 with vote_end_date being 10 or 100 days ago)

Suggested change
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(),
cls.evaluation = baker.make(
Evaluation,
state=Evaluation.State.PUBLISHED,
vote_start_datetime=datetime.today() - timedelta(days=200),
vote_end_date=date.today() - timedelta(days=180),

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):
Copy link
Member

Choose a reason for hiding this comment

The 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
def test_do_not_remove_user_due_to_inactivity_with_recently_archived_evaluation(self):
@override_settings(PARTICIPATION_DELETION_AFTER_INACTIVE_TIME=timedelta(360))
def test_do_not_remove_user_due_to_inactivity_with_recently_archived_evaluation(self):

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):
Copy link
Member

Choose a reason for hiding this comment

The 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 can_be_marked_inactive_by_manager, and not because the time simply hasn't passed.

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
Copy link
Member

Choose a reason for hiding this comment

The 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)
Expand Down
43 changes: 39 additions & 4 deletions evap/staff/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The elif here seems suspicious to me. An inactive user that has can_be_marked_inactive_by_manager == False wouldn't end up in inactive_users, and thus we wouldn't call remove_inactive_participations. I don't know whether such a user can exist, but I'd prefer to not have to think about this.

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,
Expand Down Expand Up @@ -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:
Expand All @@ -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()
Expand Down Expand Up @@ -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>',
Expand Down
Loading