diff --git a/evap/settings.py b/evap/settings.py index 5a7cd7b61..06722d538 100644 --- a/evap/settings.py +++ b/evap/settings.py @@ -11,6 +11,7 @@ import logging import os import sys +from datetime import timedelta from fractions import Fraction from typing import Any @@ -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) diff --git a/evap/staff/tests/test_tools.py b/evap/staff/tests/test_tools.py index 0666f667a..759123a90 100644 --- a/evap/staff/tests/test_tools.py +++ b/evap/staff/tests/test_tools.py @@ -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): + 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): + 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."] + ) + + class UserEditLinkTest(TestCase): def test_user_edit_link(self): user = baker.make(UserProfile) diff --git a/evap/staff/tools.py b/evap/staff/tools.py index 23b10fe42..13801bf05 100644 --- a/evap/staff/tools.py +++ b/evap/staff/tools.py @@ -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: + 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( ' {}',