Repository: e-valuation/EvaP

Issue:
Merge option for course types
On the semester overview page the FSR need an option to merge course types after importing courses and assigning questionnaires.
This makes #53 obsolete.



Discussion:
@kateyy Did you work on this issue, do you want to continue or can I remove your assignment in preparation for the next Hackday?

I think it's better to remove the assignment for now, i didn't produce any usable code for this issue.

#315 will probably make this easier and should be implemented first.


Resolving patch:
diff --git a/evap/staff/forms.py b/evap/staff/forms.py
--- a/evap/staff/forms.py
+++ b/evap/staff/forms.py
@@ -78,6 +78,15 @@ def clean(self):
             raise SuspiciousOperation("Deleting course type not allowed")
 
 
+class CourseTypeMergeSelectionForm(forms.Form, BootstrapMixin):
+    main_type = forms.ModelChoiceField(CourseType.objects.all())
+    other_type = forms.ModelChoiceField(CourseType.objects.all())
+
+    def clean(self):
+        super().clean()
+        if self.cleaned_data.get('main_type') == self.cleaned_data.get('other_type'):
+            raise ValidationError(_("You must select two different course types."))
+
 
 class CourseForm(forms.ModelForm, BootstrapMixin):
     general_questions = QuestionnaireMultipleChoiceField(Questionnaire.objects.filter(is_for_contributors=False, obsolete=False), label=_("General questions"))
diff --git a/evap/staff/urls.py b/evap/staff/urls.py
--- a/evap/staff/urls.py
+++ b/evap/staff/urls.py
@@ -43,7 +43,10 @@
     url(r"^questionnaire/update_indices$", questionnaire_update_indices, name="questionnaire_update_indices"),
 
     url(r"^degrees/$", degree_index, name="degree_index"),
+
     url(r"^course_types/$", course_type_index, name="course_type_index"),
+    url(r"^course_types/merge$", course_type_merge_selection, name="course_type_merge_selection"),
+    url(r"^course_types/(\d+)/merge/(\d+)$", course_type_merge, name="course_type_merge"),
 
     url(r"^user/$", user_index, name="user_index"),
     url(r"^user/create$", user_create, name="user_create"),
diff --git a/evap/staff/views.py b/evap/staff/views.py
--- a/evap/staff/views.py
+++ b/evap/staff/views.py
@@ -20,7 +20,7 @@
                              ImportForm, LotteryForm, QuestionForm, QuestionnaireForm, QuestionnairesAssignForm, \
                              SemesterForm, UserForm, ContributionFormSet, FaqSectionForm, FaqQuestionForm, \
                              UserImportForm, TextAnswerForm, DegreeForm, SingleResultForm, ExportSheetForm, \
-                             UserMergeSelectionForm, CourseTypeForm, UserBulkDeleteForm
+                             UserMergeSelectionForm, CourseTypeForm, UserBulkDeleteForm, CourseTypeMergeSelectionForm
 from evap.staff.importers import EnrollmentImporter, UserImporter
 from evap.staff.tools import custom_redirect, delete_navbar_cache, merge_users, bulk_delete_users
 from evap.student.views import vote_preview
@@ -854,6 +854,35 @@ def course_type_index(request):
         return render(request, "staff_course_type_index.html", dict(formset=formset))
 
 
+@staff_required
+def course_type_merge_selection(request):
+    form = CourseTypeMergeSelectionForm(request.POST or None)
+
+    if form.is_valid():
+        main_type = form.cleaned_data['main_type']
+        other_type = form.cleaned_data['other_type']
+        return redirect('staff:course_type_merge', main_type.id, other_type.id)
+    else:
+        return render(request, "staff_course_type_merge_selection.html", dict(form=form))
+
+
+@staff_required
+def course_type_merge(request, main_type_id, other_type_id):
+    main_type = get_object_or_404(CourseType, id=main_type_id)
+    other_type = get_object_or_404(CourseType, id=other_type_id)
+
+    if request.method == 'POST':
+        Course.objects.filter(type=other_type).update(type=main_type)
+        other_type.delete()
+        messages.success(request, _("Successfully merged course types."))
+        return redirect('staff:course_type_index')
+    else:
+        courses_with_other_type = Course.objects.filter(type=other_type).order_by('semester__created_at', 'name_de')
+        return render(request, "staff_course_type_merge.html",
+            dict(main_type=main_type, other_type=other_type, courses_with_other_type=courses_with_other_type))
+
+
+
 @staff_required
 def user_index(request):
     from django.db.models import  BooleanField, ExpressionWrapper, Q, Sum, Case, When, IntegerField
@@ -948,7 +977,7 @@ def user_bulk_delete(request):
 
 @staff_required
 def user_merge_selection(request):
-    form = UserMergeSelectionForm(request.POST or None, request.FILES or None)
+    form = UserMergeSelectionForm(request.POST or None)
 
     if form.is_valid():
         main_user = form.cleaned_data['main_user']


Test patch:
diff --git a/evap/staff/fixtures/minimal_test_data.json b/evap/staff/fixtures/minimal_test_data.json
--- a/evap/staff/fixtures/minimal_test_data.json
+++ b/evap/staff/fixtures/minimal_test_data.json
@@ -1319,6 +1319,14 @@
     "name_en": "Master project"
   }
 },
+{
+  "pk": 3,
+  "model": "evaluation.coursetype",
+  "fields": {
+    "name_de": "Obsoleter Kurstyp",
+    "name_en": "Obsolete course type"
+  }
+},
 {
   "pk": 1,
   "model": "evaluation.course",
@@ -1527,7 +1535,7 @@
         "participants": [5],
         "vote_end_date": "2099-11-20",
         "name_en": "dadas",
-        "type": 2,
+        "type": 3,
         "vote_start_date": "2014-11-07"
     }
 },
diff --git a/evap/staff/tests/tests_general.py b/evap/staff/tests/tests_general.py
--- a/evap/staff/tests/tests_general.py
+++ b/evap/staff/tests/tests_general.py
@@ -332,18 +332,21 @@ def test_all_urls(self):
             ("test_staff_faq", "/staff/faq/", "evap"),
             ("test_staff_faq_x", "/staff/faq/1", "evap"),
             # rewards
-            ("rewards_index", "/rewards/", "student"),
-            ("reward_points_redemption_events", "/rewards/reward_point_redemption_events/", "evap"),
-            ("reward_points_redemption_event_create", "/rewards/reward_point_redemption_event/create", "evap"),
-            ("reward_points_redemption_event_edit", "/rewards/reward_point_redemption_event/1/edit", "evap"),
-            ("reward_points_redemption_event_export", "/rewards/reward_point_redemption_event/1/export", "evap"),
-            ("reward_points_semester_activation", "/rewards/reward_semester_activation/1/on", "evap"),
-            ("reward_points_semester_deactivation", "/rewards/reward_semester_activation/1/off", "evap"),
-            ("reward_points_semester_overview", "/rewards/semester/1/reward_points", "evap"),
+            ("test_staff_rewards_index", "/rewards/", "student"),
+            ("test_staff_reward_points_redemption_events", "/rewards/reward_point_redemption_events/", "evap"),
+            ("test_staff_reward_points_redemption_event_create", "/rewards/reward_point_redemption_event/create", "evap"),
+            ("test_staff_reward_points_redemption_event_edit", "/rewards/reward_point_redemption_event/1/edit", "evap"),
+            ("test_staff_reward_points_redemption_event_export", "/rewards/reward_point_redemption_event/1/export", "evap"),
+            ("test_staff_reward_points_semester_activation", "/rewards/reward_semester_activation/1/on", "evap"),
+            ("test_staff_reward_points_semester_deactivation", "/rewards/reward_semester_activation/1/off", "evap"),
+            ("test_staff_reward_points_semester_overview", "/rewards/semester/1/reward_points", "evap"),
             # degrees
-            ("degree_index", "/staff/degrees/", "evap"),
+            ("test_staff_degree_index", "/staff/degrees/", "evap"),
             # course types
-            ("course_type_index", "/staff/course_types/", "evap")]
+            ("test_staff_course_type_index", "/staff/course_types/", "evap"),
+            ("test_staff_course_type_merge", "/staff/course_types/merge", "evap"),
+            ("test_staff_course_type_x_merge_x", "/staff/course_types/2/merge/3", "evap"),
+        ]
         for _, url, user in tests:
             self.get_assert_200(url, user)
 
@@ -381,6 +384,7 @@ def test_failing_forms(self):
             ("/staff/questionnaire/create", "evap"),
             ("/staff/user/create", "evap"),
             ("/staff/user/merge", "evap"),
+            ("/staff/course_types/merge", "evap"),
         ]
         for form in forms:
             response = self.get_submit_assert_200(form[0], form[1])
@@ -727,6 +731,26 @@ def test_degree_form(self):
 
         self.assertTrue(Degree.objects.filter(name_de="Test", name_en="Test").exists())
 
+    def test_course_type_merge(self):
+        """
+            Tests that the merging of course types works as expected.
+        """
+        main_type = CourseType.objects.get(name_en="Master project")
+        other_type = CourseType.objects.get(name_en="Obsolete course type")
+        num_courses_with_main_type = Course.objects.filter(type=main_type).count()
+        courses_with_other_type = Course.objects.filter(type=other_type)
+        self.assertGreater(courses_with_other_type.count(), 0)
+
+        page = self.get_assert_200("/staff/course_types/" + str(main_type.pk) + "/merge/" + str(other_type.pk), user="evap")
+        form = lastform(page)
+        response = form.submit()
+        self.assertIn("Successfully", str(response))
+
+        self.assertFalse(CourseType.objects.filter(name_en="Obsolete course type").exists())
+        self.assertEqual(Course.objects.filter(type=main_type).count(), num_courses_with_main_type + 1)
+        for course in courses_with_other_type:
+            self.assertTrue(course.type == main_type)
+
 
 class ArchivingTests(WebTest):