Repository: e-valuation/EvaP

Issue:
Handle user profile sorting with incomplete names
Sorting user profiles by first and last name currently fails when not both are specified. This happens for special users such as, e.g., "Student Representatives" who do not have a first and last name specified.

For example, when opening a semester's grade reminder page, loading the page currently fails when at least one of the responsible users of a course listed on the page does not have a first and last name specified (`AttributeError at /staff/semester/<id>/grade_reminder 'NoneType' object has no attribute 'lower'`).

As a solution, if only a last name is specified, the first name can be ignored. If both values are None, the email must be used for sorting.

These places need to be checked and updated:
- `evaluation.models.responsibles_names`
- `staff.views.semester_preparation_reminder`
- `staff.views.semester_grade_reminder`


Discussion:

Resolving patch:
diff --git a/evap/evaluation/migrations/0131_userprofile_ordering.py b/evap/evaluation/migrations/0131_userprofile_ordering.py
new file mode 100644
--- /dev/null
+++ b/evap/evaluation/migrations/0131_userprofile_ordering.py
@@ -0,0 +1,26 @@
+# Generated by Django 4.1.1 on 2022-09-12 19:53
+
+from django.db import migrations
+import django.db.models.functions.text
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("evaluation", "0130_alter_textanswer_review_decision"),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name="userprofile",
+            options={
+                "ordering": [
+                    django.db.models.functions.text.Lower("last_name"),
+                    django.db.models.functions.text.Lower("first_name"),
+                    django.db.models.functions.text.Lower("email"),
+                ],
+                "verbose_name": "user",
+                "verbose_name_plural": "users",
+            },
+        ),
+    ]
diff --git a/evap/evaluation/models.py b/evap/evaluation/models.py
--- a/evap/evaluation/models.py
+++ b/evap/evaluation/models.py
@@ -16,7 +16,7 @@
 from django.core.mail import EmailMultiAlternatives
 from django.db import IntegrityError, models, transaction
 from django.db.models import Count, Manager, OuterRef, Q, Subquery
-from django.db.models.functions import Coalesce
+from django.db.models.functions import Coalesce, Lower
 from django.dispatch import Signal, receiver
 from django.template import Context, Template
 from django.template.defaultfilters import linebreaksbr
@@ -356,10 +356,7 @@ def midterm_grade_documents(self):
 
     @cached_property
     def responsibles_names(self):
-        ordered_responsibles = sorted(
-            self.responsibles.all(), key=lambda responsible: (responsible.last_name, responsible.full_name)
-        )
-        return ", ".join([responsible.full_name for responsible in ordered_responsibles])
+        return ", ".join(responsible.full_name for responsible in self.responsibles.all())
 
     @property
     def has_external_responsible(self):
@@ -1547,7 +1544,7 @@ class UserProfile(AbstractBaseUser, PermissionsMixin):
     is_active = models.BooleanField(default=True, verbose_name=_("active"))
 
     class Meta:
-        ordering = ["last_name", "first_name", "email"]
+        ordering = [Lower("last_name"), Lower("first_name"), Lower("email")]
         verbose_name = _("user")
         verbose_name_plural = _("users")
 
diff --git a/evap/staff/views.py b/evap/staff/views.py
--- a/evap/staff/views.py
+++ b/evap/staff/views.py
@@ -820,10 +820,7 @@ def semester_preparation_reminder(request, semester_id):
     ).prefetch_related("course__degrees")
 
     prepared_evaluations = semester.evaluations.filter(state=Evaluation.State.PREPARED)
-    responsibles = list(
-        set(responsible for evaluation in prepared_evaluations for responsible in evaluation.course.responsibles.all())
-    )
-    responsibles.sort(key=lambda responsible: (responsible.last_name, responsible.first_name))
+    responsibles = UserProfile.objects.filter(courses_responsible_for__evaluations__in=prepared_evaluations).distinct()
 
     responsible_list = [
         (
@@ -859,8 +856,7 @@ def semester_grade_reminder(request, semester_id):
     courses = [course for course in courses if not course.final_grade_documents.exists()]
     courses.sort(key=lambda course: course.name)
 
-    responsibles = list(set(responsible for course in courses for responsible in course.responsibles.all()))
-    responsibles.sort(key=lambda responsible: (responsible.last_name.lower(), responsible.first_name.lower()))
+    responsibles = UserProfile.objects.filter(courses_responsible_for=courses).distinct()
 
     responsible_list = [
         (responsible, [course for course in courses if responsible in course.responsibles.all()])


Test patch:
diff --git a/evap/evaluation/tests/test_models.py b/evap/evaluation/tests/test_models.py
--- a/evap/evaluation/tests/test_models.py
+++ b/evap/evaluation/tests/test_models.py
@@ -670,6 +670,18 @@ def test_get_sorted_due_evaluations(self):
         sorted_evaluations = student.get_sorted_due_evaluations()
         self.assertEqual(sorted_evaluations, [(evaluations[1], 0), (evaluations[0], 0), (evaluations[2], 1)])
 
+    def test_correct_sorting(self):
+        baker.make(
+            UserProfile,
+            last_name=iter(["Y", "x", None, None]),
+            first_name=iter(["x", "x", "a", None]),
+            email=iter(["3xy@example.com", "4xx@example.com", "2a@example.com", "1unnamed@example.com"]),
+            _quantity=4,
+            _bulk_create=True,
+        )
+        email_list = [user.email for user in UserProfile.objects.all()]
+        self.assertEqual(email_list, ["4xx@example.com", "3xy@example.com", "2a@example.com", "1unnamed@example.com"])
+
 
 class ParticipationArchivingTests(TestCase):
     @classmethod