Repository: apluslms/a-plus

Issue:
Protect against buggy participants reports from Aalto SISU API
Course enrollments can optionally be fetched from Student Information System such as SISU in Aalto. SISU responds with list of student numbers. Based on the list, student is enrolled in A+ if she did not exist on the course yet. On the other hand, if the student number is not on the list, the A+ enrollment is removed, if it happened as a result of this SISU message.

It has turned out that Aalto's SISU API gateway occasionally returns an empty list for all courses queried using this API because of some malfunction. This causes all students to be removed from course enrollments (and a major hassle as a result). Therefore we should add a check to the code to protect against this situation: if the response contains an empty participant list, it should be ignored.

The relevant code is in [enroll_from_sis method](https://github.com/apluslms/a-plus/blob/8ab057c5e05f1bea2320a6c61e6bee5c296faae5/course/models.py#L918).

Aalto-specific SISU plugin is used to carry out the API messaging in Aalto. The plugin is in [separate repository](https://github.com/apluslms/aalto-sisu). As a side note, It was noted that when SISU API returns an error code in the plugin, it would ne nice if the error handling reported the error code more clearly in logs.



Discussion:

Resolving patch:
diff --git a/course/models.py b/course/models.py
--- a/course/models.py
+++ b/course/models.py
@@ -24,6 +24,7 @@
 from django.utils.text import format_lazy
 from django.utils.translation import gettext_lazy as _
 from django_colortag.models import ColorTag
+from requests.exceptions import HTTPError
 
 from apps.models import BaseTab, BasePlugin
 from authorization.models import JWTAccessible
@@ -935,9 +936,11 @@ def enroll_from_sis(self) -> Tuple[int, int]:
         delcount = 0
         try:
             participants = sis.get_participants(self.sis_id)
+        except HTTPError as exc:
+            logger.exception("%s: Error %d when getting participants from SIS.", self, exc.response.status_code)
+            return -1, -1
         except Exception:
-            # pylint: disable-next=logging-fstring-interpolation
-            logger.exception(f"{self}: Error in getting participants from SIS.")
+            logger.exception("%s: Error in getting participants from SIS.", self)
             return -1, -1
 
         from exercise.models import LearningObject # pylint: disable=import-outside-toplevel
@@ -954,19 +957,23 @@ def enroll_from_sis(self) -> Tuple[int, int]:
                 # yet logged in to A+, then the user profile does not exist yet.
                 pass
 
-        # Remove SIS-enrolled students who are not anymore in SIS participants,
-        # for example, because they have first enrolled in SIS, but then
-        # unenrolled themselves.
-        students = self.all_students.filter(enrollment__from_sis=True)
-        to_remove = students.exclude(student_id__in=participants)
-        qs = Enrollment.objects.filter(user_profile__in=to_remove, course_instance=self)
-        qs.update(status=Enrollment.ENROLLMENT_STATUS.REMOVED)
-        for e in qs:
-            invalidate_content(Enrollment, e)
-            delcount += 1
-
-        # pylint: disable-next=logging-fstring-interpolation
-        logger.info(f"{self}: enrolled {addcount}, removed {delcount} students based on SIS")
+        # Ignore empty participants list caused by a rare SIS API gateway malfunction
+        if participants:
+            # Remove SIS-enrolled students who are not anymore in SIS participants,
+            # for example, because they have first enrolled in SIS, but then
+            # unenrolled themselves.
+            students = self.all_students.filter(enrollment__from_sis=True)
+            to_remove = students.exclude(student_id__in=participants)
+            qs = Enrollment.objects.filter(user_profile__in=to_remove, course_instance=self)
+            qs.update(status=Enrollment.ENROLLMENT_STATUS.REMOVED)
+            for e in qs:
+                invalidate_content(Enrollment, e)
+                delcount += 1
+        else:
+            logger.warning("%s: Received an empty participants list from SIS.", self)
+            return 0, 0
+
+        logger.info("%s: enrolled %d, removed %d students based on SIS", self, addcount, delcount)
         return addcount, delcount
 
     def set_users_with_role(self, users, role, remove_others_with_role=False):


Test patch: