Repository: e-valuation/EvaP

Issue:
Allow copying evaluation to other semester
When copying an evaluation, the copy will currently be created in the same semester as the original.
This should be changed so that the semester can be selected in a field at the top of the form, defaulting to the current semester.
Allow copying evaluation to other semester
When copying an evaluation, the copy will currently be created in the same semester as the original.
This should be changed so that the semester can be selected in a field at the top of the form, defaulting to the current semester.


Discussion:

Resolving patch:
diff --git a/evap/staff/forms.py b/evap/staff/forms.py
--- a/evap/staff/forms.py
+++ b/evap/staff/forms.py
@@ -4,6 +4,7 @@
 from django import forms
 from django.contrib.auth.models import Group
 from django.core.exceptions import SuspiciousOperation, ValidationError
+from django.db import transaction
 from django.db.models import Max, Q
 from django.forms.models import BaseInlineFormSet
 from django.forms.widgets import CheckboxSelectMultiple
@@ -147,8 +148,8 @@ class Meta:
         model = Semester
         fields = ("name_de", "name_en", "short_name_de", "short_name_en")
 
-    def save(self, *args, **kwargs):
-        semester = super().save(*args, **kwargs)
+    def save(self, commit=True):
+        semester = super().save(commit)
         if "short_name_en" in self.changed_data or "short_name_de" in self.changed_data:
             update_template_cache(semester.evaluations.filter(state__in=STATES_WITH_RESULT_TEMPLATE_CACHING))
         return semester
@@ -214,9 +215,7 @@ def clean(self):
             raise ValidationError(_("You must select two different course types."))
 
 
-class CourseForm(forms.ModelForm):
-    semester = forms.ModelChoiceField(Semester.objects.all(), disabled=True, required=False, widget=forms.HiddenInput())
-
+class CourseFormMixin:
     class Meta:
         model = Course
         fields = (
@@ -232,17 +231,11 @@ class Meta:
             "responsibles": UserModelMultipleChoiceField,
         }
 
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        self.fields["responsibles"].queryset = UserProfile.objects.exclude(is_active=False)
-        if self.instance.pk:
-            self.fields["responsibles"].queryset |= UserProfile.objects.filter(
-                pk__in=[user.pk for user in self.instance.responsibles.all()]
-            )
-
-        if not self.instance.can_be_edited_by_manager:
-            disable_all_fields(self)
+    def _set_responsibles_queryset(self, existing_course=None):
+        queryset = UserProfile.objects.exclude(is_active=False)
+        if existing_course:
+            queryset = (queryset | existing_course.responsibles.all()).distinct()
+        self.fields["responsibles"].queryset = queryset
 
     def validate_unique(self):
         super().validate_unique()
@@ -257,6 +250,98 @@ def validate_unique(self):
                     self.add_error(name_field, e)
 
 
+class CourseForm(CourseFormMixin, forms.ModelForm):
+    semester = forms.ModelChoiceField(Semester.objects.all(), disabled=True, required=False, widget=forms.HiddenInput())
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self._set_responsibles_queryset(self.instance if self.instance.pk else None)
+        if not self.instance.can_be_edited_by_manager:
+            disable_all_fields(self)
+
+
+class CourseCopyForm(CourseFormMixin, forms.ModelForm):
+    semester = forms.ModelChoiceField(Semester.objects.all())
+    vote_start_datetime = forms.DateTimeField(label=_("Start of evaluations"), localize=True)
+    vote_end_date = forms.DateField(label=_("Last day of evaluations"), localize=True)
+
+    field_order = ["semester"]
+
+    def __init__(self, data=None, instance: Course = None):
+        self.old_course = instance
+        opts = self._meta
+        initial = forms.models.model_to_dict(instance, opts.fields, opts.exclude)
+        super().__init__(data=data, initial=initial)
+        self._set_responsibles_queryset(instance)
+
+    # To ensure we don't forget about copying a relevant field, we explicitly list copied and ignored fields and test against that
+    EVALUATION_COPIED_FIELDS = {
+        "name_de",
+        "name_en",
+        "weight",
+        "is_single_result",
+        "is_rewarded",
+        "is_midterm_evaluation",
+        "allow_editors_to_edit",
+        "wait_for_grade_upload_before_publishing",
+    }
+
+    EVALUATION_EXCLUDED_FIELDS = {
+        "id",
+        "course",
+        "vote_start_datetime",
+        "vote_end_date",
+        "participants",
+        "contributions",
+        "state",
+        "can_publish_text_results",
+        "_participant_count",
+        "_voter_count",
+        "voters",
+    }
+
+    CONTRIBUTION_COPIED_FIELDS = {
+        "contributor",
+        "role",
+        "textanswer_visibility",
+        "label",
+        "order",
+    }
+
+    CONTRIBUTION_EXCLUDED_FIELDS = {
+        "id",
+        "evaluation",
+        "ratinganswercounter_set",
+        "textanswer_set",
+        "questionnaires",
+    }
+
+    @transaction.atomic()
+    def save(self, commit=True):
+        new_course: Course = super().save()
+        # we need to create copies of evaluations and their participation as well
+        for old_evaluation in self.old_course.evaluations.exclude(is_single_result=True):
+            new_evaluation = Evaluation(
+                **{field: getattr(old_evaluation, field) for field in self.EVALUATION_COPIED_FIELDS},
+                can_publish_text_results=False,
+                course=new_course,
+                vote_start_datetime=self.cleaned_data["vote_start_datetime"],
+                vote_end_date=self.cleaned_data["vote_end_date"],
+            )
+            new_evaluation.save()
+
+            new_evaluation.contributions.all().delete()  # delete default general contribution
+            for old_contribution in old_evaluation.contributions.all():
+                new_contribution = Contribution(
+                    **{field: getattr(old_contribution, field) for field in self.CONTRIBUTION_COPIED_FIELDS},
+                    evaluation=new_evaluation,
+                )
+                new_contribution.save()
+                new_contribution.questionnaires.set(old_contribution.questionnaires.all())
+
+        return new_course
+
+
 class EvaluationForm(forms.ModelForm):
     general_questionnaires = forms.ModelMultipleChoiceField(
         Questionnaire.objects.general_questionnaires().exclude(visibility=Questionnaire.Visibility.HIDDEN),
@@ -581,7 +666,6 @@ def send(self, request):
 
 
 class RemindResponsibleForm(forms.Form):
-
     to = UserModelChoiceField(None, required=False, disabled=True, label=_("To"))
     cc = UserModelMultipleChoiceField(None, required=False, disabled=True, label=_("CC"))
     subject = forms.CharField(label=_("Subject"))
diff --git a/evap/staff/urls.py b/evap/staff/urls.py
--- a/evap/staff/urls.py
+++ b/evap/staff/urls.py
@@ -21,6 +21,7 @@
     path("semester/<int:semester_id>/grade_reminder", views.semester_grade_reminder, name="semester_grade_reminder"),
     path("semester/<int:semester_id>/course/create", views.course_create, name="course_create"),
     path("semester/<int:semester_id>/course/<int:course_id>/edit", views.course_edit, name="course_edit"),
+    path("semester/<int:semester_id>/course/<int:course_id>/copy", views.course_copy, name="course_copy"),
     path("semester/<int:semester_id>/evaluation/create", views.evaluation_create, name="evaluation_create"),
     path("semester/<int:semester_id>/evaluation/create/<int:course_id>", views.evaluation_create, name="evaluation_create"),
     path("semester/<int:semester_id>/evaluation/<int:evaluation_id>/edit", views.evaluation_edit, name="evaluation_edit"),
diff --git a/evap/staff/views.py b/evap/staff/views.py
--- a/evap/staff/views.py
+++ b/evap/staff/views.py
@@ -56,6 +56,7 @@
     ContributionCopyFormSet,
     ContributionForm,
     ContributionFormSet,
+    CourseCopyForm,
     CourseForm,
     CourseTypeForm,
     CourseTypeMergeSelectionForm,
@@ -950,6 +951,45 @@ def course_create(request, semester_id):
     return render(request, "staff_course_form.html", dict(semester=semester, course_form=course_form, editable=True))
 
 
+@manager_required
+def course_copy(request, semester_id, course_id):
+    semester = get_object_or_404(Semester, id=semester_id)
+    course = get_object_or_404(Course, id=course_id, semester=semester)
+    course_form = CourseCopyForm(request.POST or None, instance=course)
+
+    if course_form.is_valid():
+        copied_course = course_form.save()
+        messages.success(request, _("Successfully copied course."))
+
+        inactive_users = UserProfile.objects.filter(
+            Q(contributions__evaluation__course=copied_course, is_active=False)
+            | Q(courses_responsible_for=copied_course, is_active=False)
+        ).distinct()
+        if inactive_users:
+            messages.warning(
+                request,
+                _("The accounts of the following contributors were reactivated:")
+                + " {accounts}".format(accounts=", ".join(user.full_name for user in inactive_users)),
+            )
+            inactive_users.update(is_active=True)
+
+        return redirect("staff:semester_view", copied_course.semester_id)
+
+    evaluations = sorted(course.evaluations.exclude(is_single_result=True), key=lambda cr: cr.full_name)
+    return render(
+        request,
+        "staff_course_copyform.html",
+        dict(
+            course=course,
+            evaluations=evaluations,
+            semester=semester,
+            course_form=course_form,
+            editable=True,
+            disable_breadcrumb_course=True,
+        ),
+    )
+
+
 @manager_required
 def course_edit(request, semester_id, course_id):
     semester = get_object_or_404(Semester, id=semester_id)


Test patch:
diff --git a/evap/staff/tests/test_forms.py b/evap/staff/tests/test_forms.py
--- a/evap/staff/tests/test_forms.py
+++ b/evap/staff/tests/test_forms.py
@@ -29,6 +29,7 @@
     ContributionCopyForm,
     ContributionForm,
     ContributionFormSet,
+    CourseCopyForm,
     CourseForm,
     EvaluationCopyForm,
     EvaluationEmailForm,
@@ -711,6 +712,22 @@ def test_handle_multivaluedicts(self):
         self.assertEqual(Questionnaire.objects.filter(contributions=self.contribution2).count(), 2)
 
 
+class CourseCopyFormTests(TestCase):
+    @staticmethod
+    def test_all_evaluation_attributes_covered():
+        for field in Evaluation._meta.get_fields():
+            assert field.name in (
+                CourseCopyForm.EVALUATION_COPIED_FIELDS | CourseCopyForm.EVALUATION_EXCLUDED_FIELDS
+            ), "evaluation field {} is not considered by CourseCopyForm".format(field.name)
+
+    @staticmethod
+    def test_all_contribution_attributes_covered():
+        for field in Contribution._meta.get_fields():
+            assert field.name in (
+                CourseCopyForm.CONTRIBUTION_COPIED_FIELDS | CourseCopyForm.CONTRIBUTION_EXCLUDED_FIELDS
+            ), "contribution field {} is not considered by CourseCopyForm".format(field.name)
+
+
 class CourseFormTests(TestCase):
     def test_course_form_same_name(self):
         """
diff --git a/evap/staff/tests/test_views.py b/evap/staff/tests/test_views.py
--- a/evap/staff/tests/test_views.py
+++ b/evap/staff/tests/test_views.py
@@ -39,7 +39,7 @@
 )
 from evap.results.tools import cache_results, get_results
 from evap.rewards.models import RewardPointGranting, SemesterActivation
-from evap.staff.forms import ContributionCopyForm, ContributionCopyFormSet, EvaluationCopyForm
+from evap.staff.forms import ContributionCopyForm, ContributionCopyFormSet, CourseCopyForm, EvaluationCopyForm
 from evap.staff.tests.utils import (
     WebTestStaffMode,
     WebTestStaffModeWith200Check,
@@ -1653,6 +1653,70 @@ def test_evaluation_copy(self):
         self.assertEqual(copied_evaluation.contributions.count(), 4)
 
 
+class TestCourseCopyView(WebTestStaffMode):
+    @classmethod
+    def setUpTestData(cls):
+        cls.manager = make_manager()
+        cls.semester = baker.make(Semester)
+        cls.other_semester = baker.make(Semester)
+        degree = baker.make(Degree)
+        cls.responsibles = [
+            baker.make(UserProfile, last_name="Muller"),
+            baker.make(UserProfile, is_active=False, last_name="Wolf"),
+        ]
+        cls.course = baker.make(
+            Course,
+            name_en="Some name",
+            semester=cls.semester,
+            degrees=[degree],
+            responsibles=cls.responsibles,
+        )
+        cls.evaluation = baker.make(
+            Evaluation,
+            course=cls.course,
+            name_de="Das Original",
+            name_en="The Original",
+        )
+        cls.general_questionnaires = baker.make(Questionnaire, _quantity=5)
+        cls.evaluation.general_contribution.questionnaires.set(cls.general_questionnaires)
+        baker.make(
+            Contribution,
+            evaluation=cls.evaluation,
+            _quantity=3,
+            _fill_optional=["contributor"],
+        )
+        cls.url = f"/staff/semester/{cls.semester.id}/course/{cls.course.id}/copy"
+
+    def test_copy_forms_are_used(self):
+        response = self.app.get(self.url, user=self.manager, status=200)
+        self.assertIsInstance(response.context["course_form"], CourseCopyForm)
+
+    def test_course_copy(self):
+        response = self.app.get(self.url, user=self.manager, status=200)
+        form = response.forms["course-form"]
+        form["semester"] = self.other_semester.pk
+        form["vote_start_datetime"] = datetime.datetime(2099, 1, 1, 0, 0)
+        form["vote_end_date"] = datetime.date(2099, 12, 31)
+
+        # check that the user activation is mentioned
+        self.assertFalse(self.responsibles[1].is_active)
+        response = form.submit().follow()
+        self.assertIn(self.responsibles[1].full_name, response)
+
+        self.assertEqual(Course.objects.count(), 2)
+        copied_course = Course.objects.exclude(pk=self.course.pk).get()
+        self.assertEqual(copied_course.evaluations.count(), 1)
+        self.assertEqual(set(copied_course.responsibles.all()), set(self.responsibles))
+
+        copied_evaluation = copied_course.evaluations.get()
+        self.assertEqual(copied_evaluation.weight, self.evaluation.weight)
+        self.assertEqual(
+            set(copied_evaluation.general_contribution.questionnaires.all()),
+            set(self.evaluation.general_contribution.questionnaires.all()),
+        )
+        self.assertFalse(copied_course.responsibles.filter(is_active=False).exists())
+
+
 class TestCourseEditView(WebTestStaffMode):
     url = "/staff/semester/1/course/1/edit"