Repository: e-valuation/EvaP

Issue:
Course and Evaluation creation redesign
The creation of Courses and Evaluations should get a different user flow.

- The current dropdown button for creating a Course, Evaluation or Single Result and the import of data should be replaced with a single (light) button just for the import in the staff semester view.
- On the "Courses" tab a new (dark) button for creating a Course should be placed on the upper left with the same functionality as before.
- In each row of the table a column with one (dark) button for adding an Evaluation and one (dark) button for adding a Single Result to this Course should be added on the right.
- When clicking on these buttons, the Course field in the Evaluation form/Single Result form should be pre-filled with the respective Course.
- Additionally, each Evaluation in the "Evaluations" tab should get a "Copy" button, which, similar to the questionnaire copying, will open an Evaluation creation form with all fields (including participants and contributors) pre-filled like in the original Evaluation.


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
@@ -1,4 +1,5 @@
 from collections.abc import Iterable
+from datetime import datetime
 import logging
 
 from django import forms
@@ -326,6 +327,15 @@ def save(self, *args, **kw):
         return evaluation
 
 
+class EvaluationCopyForm(EvaluationForm):
+    def __init__(self, data=None, instance=None):
+        opts = self._meta
+        initial = forms.models.model_to_dict(instance, opts.fields, opts.exclude)
+        initial['last_modified_time'] = datetime.now()
+        initial['general_questionnaires'] = instance.general_contribution.questionnaires.all()
+        super().__init__(data=data, initial=initial, semester=instance.course.semester)
+
+
 class SingleResultForm(forms.ModelForm):
     last_modified_time_2 = forms.DateTimeField(label=_("Last modified"), required=False, localize=True, disabled=True)
     last_modified_user_2 = forms.CharField(label=_("Last modified by"), required=False, disabled=True)
@@ -458,6 +468,18 @@ def clean(self):
             self.add_error('does_not_contribute', _("Select either this option or at least one questionnaire!"))
 
 
+class ContributionCopyForm(ContributionForm):
+    def __init__(self, data=None, instance=None, evaluation=None, **kwargs):
+        initial = None
+        copied_instance = Contribution(evaluation=evaluation)
+        if instance:
+            opts = self._meta
+            initial = forms.models.model_to_dict(instance, opts.fields, opts.exclude)
+            del initial['evaluation']
+            initial['does_not_contribute'] = not instance.questionnaires.exists()
+        super().__init__(data, initial=initial, instance=copied_instance, evaluation=evaluation, **kwargs)
+
+
 class EvaluationEmailForm(forms.Form):
     recipients = forms.MultipleChoiceField(widget=forms.CheckboxSelectMultiple(), choices=EmailTemplate.Recipients.choices, label=_("Send email to"))
     subject = forms.CharField(label=_("Subject"))
@@ -636,6 +658,21 @@ def clean(self):
                 found_contributor.add(contributor)
 
 
+class ContributionCopyFormSet(ContributionFormSet):
+    def __init__(self, data, instance, new_instance):
+        # First, pass the old evaluation instance to create a ContributionCopyForm for each contribution
+        super().__init__(data, instance=instance, form_kwargs={'evaluation': new_instance})
+        # Then, use the new evaluation instance as target for validation and saving purposes
+        self.instance = new_instance
+
+    def save(self, commit=True):
+        # As the contained ContributionCopyForm have not-yet-saved instances,
+        # they’d be skipped when saving the formset.
+        # To circumvent this, explicitly note that all forms should be saved as new instance.
+        self.save_as_new = True
+        super().save(commit)
+
+
 class QuestionForm(forms.ModelForm):
     class Meta:
         model = Question
diff --git a/evap/staff/urls.py b/evap/staff/urls.py
--- a/evap/staff/urls.py
+++ b/evap/staff/urls.py
@@ -25,6 +25,7 @@
     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"),
+    path("semester/<int:semester_id>/evaluation/<int:evaluation_id>/copy", views.evaluation_copy, name="evaluation_copy"),
     path("semester/<int:semester_id>/evaluation/<int:evaluation_id>/email", views.evaluation_email, name="evaluation_email"),
     path("semester/<int:semester_id>/evaluation/<int:evaluation_id>/preview", views.evaluation_preview, name="evaluation_preview"),
     path("semester/<int:semester_id>/evaluation/<int:evaluation_id>/person_management", views.evaluation_person_management, name="evaluation_person_management"),
diff --git a/evap/staff/views.py b/evap/staff/views.py
--- a/evap/staff/views.py
+++ b/evap/staff/views.py
@@ -31,9 +31,11 @@
 from evap.results.views import update_template_cache_of_published_evaluations_in_course
 from evap.rewards.models import RewardPointGranting
 from evap.rewards.tools import can_reward_points_be_used_by, is_semester_activated
-from evap.staff.forms import (AtLeastOneFormSet, ContributionForm, ContributionFormSet, CourseForm, CourseTypeForm,
+from evap.staff.forms import (AtLeastOneFormSet, ContributionForm, ContributionCopyForm, ContributionFormSet,
+                              ContributionCopyFormSet, CourseForm, CourseTypeForm,
                               CourseTypeMergeSelectionForm, DegreeForm, EmailTemplateForm, EvaluationEmailForm,
-                              EvaluationForm, EvaluationParticipantCopyForm, ExportSheetForm, FaqQuestionForm,
+                              EvaluationForm, EvaluationCopyForm, EvaluationParticipantCopyForm, ExportSheetForm,
+                              FaqQuestionForm,
                               FaqSectionForm, ModelWithImportNamesFormSet, ImportForm, QuestionForm, QuestionnaireForm, QuestionnairesAssignForm,
                               RemindResponsibleForm, SemesterForm, SingleResultForm, TextAnswerForm, UserBulkUpdateForm,
                               UserForm, UserImportForm, UserMergeSelectionForm)
@@ -806,6 +808,37 @@ def evaluation_create(request, semester_id, course_id=None):
     ))
 
 
+@manager_required
+def evaluation_copy(request, semester_id, evaluation_id):
+    semester = get_object_or_404(Semester, id=semester_id)
+    evaluation = get_object_or_404(Evaluation, id=evaluation_id, course__semester=semester)
+
+    form = EvaluationCopyForm(request.POST or None, evaluation)
+
+    InlineContributionFormset = inlineformset_factory(Evaluation, Contribution, formset=ContributionCopyFormSet,
+                                                      form=ContributionCopyForm, extra=1)
+    formset = InlineContributionFormset(request.POST or None, instance=evaluation, new_instance=form.instance)
+
+    if form.is_valid() and formset.is_valid():
+        copied_evaluation = form.save()
+        copied_evaluation.set_last_modified(request.user)
+        copied_evaluation.save()
+        formset.save()
+        update_template_cache_of_published_evaluations_in_course(copied_evaluation.course)
+
+        messages.success(request, _("Successfully created evaluation."))
+        return redirect('staff:semester_view', semester_id)
+
+    return render(request, "staff_evaluation_form.html", dict(
+        semester=semester,
+        evaluation_form=form,
+        formset=formset,
+        manager=True,
+        editable=True,
+        state="",
+    ))
+
+
 @manager_required
 def single_result_create(request, semester_id, course_id=None):
     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
@@ -1,4 +1,5 @@
 from unittest.mock import patch
+from datetime import datetime
 from django.forms.models import inlineformset_factory
 from django.test import TestCase
 from model_bakery import baker
@@ -7,7 +8,8 @@
                                     Questionnaire, Semester, UserProfile)
 from evap.evaluation.tests.tools import (create_evaluation_with_responsible_and_editor, get_form_data_from_instance,
                                          to_querydict)
-from evap.staff.forms import (ContributionForm, ContributionFormSet, CourseForm, EvaluationEmailForm, EvaluationForm,
+from evap.staff.forms import (ContributionForm, ContributionCopyForm, ContributionFormSet, CourseForm,
+                              EvaluationEmailForm, EvaluationForm, EvaluationCopyForm,
                               QuestionnaireForm, SingleResultForm, UserForm)
 from evap.results.tools import collect_results
 from evap.contributor.forms import EvaluationForm as ContributorEvaluationForm
@@ -177,6 +179,52 @@ def test_single_result_form_saves_participant_and_voter_count(self):
         self.assertEqual(evaluation.num_voters, 10)
 
 
+class ContributionCopyFormTests(TestCase):
+    @classmethod
+    def setUpTestData(cls):
+        cls.evaluation = baker.make(Evaluation)
+        cls.contributor = baker.make(UserProfile)
+        cls.contribution = baker.make(
+            Contribution,
+            evaluation=cls.evaluation,
+            contributor=cls.contributor,
+            order=2,
+            role=Contribution.Role.EDITOR,
+            textanswer_visibility=Contribution.TextAnswerVisibility.GENERAL_TEXTANSWERS,
+            label='Teacher',
+        )
+        cls.questionnaires = baker.make(Questionnaire, type=Questionnaire.Type.CONTRIBUTOR, _quantity=2)
+        cls.contribution.questionnaires.set(cls.questionnaires)
+
+    def test_initial_from_original(self):
+        evaluation = Evaluation()
+        form = ContributionCopyForm(None, instance=self.contribution, evaluation=evaluation)
+        self.assertEqual(form['evaluation'].initial, None)
+        self.assertEqual(form['contributor'].initial, self.contributor.pk)
+        self.assertCountEqual(form['questionnaires'].initial, self.questionnaires)
+        self.assertEqual(form['order'].initial, 2)
+        self.assertEqual(form['role'].initial, Contribution.Role.EDITOR)
+        self.assertEqual(form['textanswer_visibility'].initial, Contribution.TextAnswerVisibility.GENERAL_TEXTANSWERS)
+        self.assertEqual(form['label'].initial, 'Teacher')
+        self.assertEqual(form.evaluation, evaluation)
+
+    def test_no_original_given(self):
+        new_evaluation = Evaluation()
+        form = ContributionCopyForm(None, instance=None, evaluation=new_evaluation)
+        self.assertEqual(form.evaluation, new_evaluation)
+
+    def test_copy_contribution(self):
+        # To simulate the life-cycle of the form, first give the form an unsaved evaluation.
+        new_evaluation = baker.prepare(Evaluation, _save_related=True)
+        form_data = get_form_data_from_instance(ContributionCopyForm, self.contribution, evaluation=new_evaluation)
+        # Just before saving the form, save the evaluation instance.
+        new_evaluation.save()
+        form = ContributionCopyForm(form_data, instance=self.contribution, evaluation=new_evaluation)
+        self.assertTrue(form.is_valid())
+        copied_contribution = form.save()
+        self.assertEqual(copied_contribution.evaluation, new_evaluation)
+
+
 class ContributionFormsetTests(TestCase):
     def test_contribution_form_set(self):
         """
@@ -706,3 +754,51 @@ def test_unused_questionnaire_visibility(self):
 
         form = EvaluationForm(instance=evaluation, semester=evaluation.course.semester)
         self.assertIn(questionnaire, form.fields["general_questionnaires"].queryset)
+
+
+class EvaluationCopyFormTests(TestCase):
+    @classmethod
+    def setUpTestData(cls):
+        cls.semester = baker.make(Semester)
+        cls.course = baker.make(Course, semester=cls.semester)
+        cls.participants = baker.make(UserProfile, _quantity=8)
+        cls.evaluation = baker.make(
+            Evaluation,
+            course=cls.course,
+            name_de="Das Original",
+            name_en="The Original",
+            last_modified_time=datetime(2020, 1, 1),
+            last_modified_user=baker.make(UserProfile),
+            participants=cls.participants,
+            voters=cls.participants[:6],
+        )
+        cls.general_questionnaires = baker.make(Questionnaire, _quantity=5)
+        cls.evaluation.general_contribution.questionnaires.set(cls.general_questionnaires)
+
+    def test_initial_from_original(self):
+        form = EvaluationCopyForm(None, self.evaluation)
+        self.assertEqual(form['course'].initial, self.course.pk)
+        self.assertCountEqual(form.fields['course'].queryset, self.semester.courses.all())
+        self.assertEqual(form['name_de'].initial, "Das Original")
+        self.assertEqual(form['name_en'].initial, "The Original")
+        self.assertCountEqual(form['participants'].initial, self.participants)
+        self.assertGreater(form['last_modified_time'].initial, self.evaluation.last_modified_time)
+        self.assertEqual(form['last_modified_user_name'].initial, None)
+        self.assertCountEqual(form['general_questionnaires'].initial, self.general_questionnaires)
+
+    def test_not_changing_name_fails(self):
+        form_data = EvaluationCopyForm(None, self.evaluation).initial
+        form = EvaluationCopyForm(form_data, self.evaluation)
+        self.assertFalse(form.is_valid())
+        self.assertEqual(form.errors['name_de'], ["Evaluation with this Course and Name (german) already exists."])
+        self.assertEqual(form.errors['name_en'], ["Evaluation with this Course and Name (english) already exists."])
+
+    def test_save_makes_a_copy(self):
+        form_data = get_form_data_from_instance(EvaluationCopyForm, self.evaluation)
+        form_data['name_de'] = "Eine Kopie"
+        form_data['name_en'] = "A Copy"
+        form = EvaluationCopyForm(form_data, self.evaluation)
+        self.assertTrue(form.is_valid())
+        copied_evaluation = form.save()
+        self.assertNotEqual(copied_evaluation, self.evaluation)
+        self.assertEqual(Evaluation.objects.count(), 2)
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
@@ -19,6 +19,7 @@
 from evap.evaluation.tests.tools import FuzzyInt, let_user_vote_for_evaluation, WebTestWith200Check
 from evap.rewards.models import SemesterActivation, RewardPointGranting
 from evap.staff.tools import generate_import_filename, ImportType
+from evap.staff.forms import ContributionCopyForm, ContributionCopyFormSet, EvaluationCopyForm
 from evap.staff.views import get_evaluations_with_prefetched_data
 
 
@@ -1282,6 +1283,11 @@ def setUpTestData(cls):
         cls.manager_user = baker.make(UserProfile, email='manager@institution.example.com', groups=[Group.objects.get(name='Manager')])
         cls.course = baker.make(Course, semester=baker.make(Semester, pk=1))
 
+    def test_course_is_prefilled(self):
+        response = self.app.get(f'{self.url}/{self.course.pk}', user=self.manager_user, status=200)
+        form = response.context['form']
+        self.assertEqual(form['course'].initial, self.course.pk)
+
     def test_single_result_create(self):
         """
             Tests the single result creation view with one valid and one invalid input dataset.
@@ -1314,6 +1320,11 @@ def setUpTestData(cls):
         cls.q1 = baker.make(Questionnaire, type=Questionnaire.Type.TOP)
         cls.q2 = baker.make(Questionnaire, type=Questionnaire.Type.CONTRIBUTOR)
 
+    def test_course_is_prefilled(self):
+        response = self.app.get(f'{self.url}/{self.course.pk}', user=self.manager_user, status=200)
+        form = response.context['evaluation_form']
+        self.assertEqual(form['course'].initial, self.course.pk)
+
     def test_evaluation_create(self):
         """
             Tests the evaluation creation view with one valid and one invalid input dataset.
@@ -1348,6 +1359,50 @@ def test_evaluation_create(self):
         self.assertEqual(Evaluation.objects.get().name_de, "lfo9e7bmxp1xi")
 
 
+class TestEvaluationCopyView(WebTest):
+    url = '/staff/semester/1/evaluation/1/copy'
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.manager = baker.make(UserProfile, email='manager@institution.example.com', groups=[Group.objects.get(name='Manager')])
+        cls.semester = baker.make(Semester, pk=1)
+        cls.course = baker.make(Course, semester=cls.semester)
+        cls.evaluation = baker.make(
+            Evaluation,
+            pk=1,
+            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)
+        for __ in range(3):
+            baker.make(
+                Contribution,
+                evaluation=cls.evaluation,
+                contributor=baker.make(UserProfile),
+            )
+
+    def test_copy_forms_are_used(self):
+        response = self.app.get(self.url, user=self.manager, status=200)
+        self.assertIsInstance(response.context['evaluation_form'], EvaluationCopyForm)
+        self.assertIsInstance(response.context['formset'], ContributionCopyFormSet)
+        self.assertTrue(issubclass(response.context['formset'].form, ContributionCopyForm))
+
+    def test_evaluation_copy(self):
+        response = self.app.get(self.url, user=self.manager, status=200)
+        form = response.forms['evaluation-form']
+        form['name_de'] = "Eine Kopie"
+        form['name_en'] = "A Copy"
+        form.submit()
+
+        # As we checked previously that the respective copy forms were used,
+        # we don’t have to check for individual attributes, as those are checked in the respective form tests
+        self.assertEqual(Evaluation.objects.count(), 2)
+        copied_evaluation = Evaluation.objects.exclude(pk=self.evaluation.pk).get()
+        self.assertEqual(copied_evaluation.contributions.count(), 4)
+
+
 class TestCourseEditView(WebTest):
     url = '/staff/semester/1/course/1/edit'