Repository: openfun/richie

Issue:
MultipleObjectsReturned error during sync course from LMS to Richie
## Bug Report

**Problematic Behavior**
Error synchronizing course from LMS to Richie after adding a course code.

**Expected behavior/code**
Richie API raises:
```
get() returned more than one Course -- it returned 2!
ERROR 2023-04-06 17:06:39,973 log 42 140255949047616 Internal Server Error: /api/v1.0/course-runs-sync/
Traceback (most recent call last):
  File "/usr/local/lib/python3.10/site-packages/django/core/handlers/exception.py", line 47, in inner
    response = get_response(request)
  File "/usr/local/lib/python3.10/site-packages/django/core/handlers/base.py", line 181, in _get_response
    response = wrapped_callback(request, *callback_args, **callback_kwargs)
  File "/usr/local/lib/python3.10/site-packages/django/views/decorators/csrf.py", line 54, in wrapped_view
    return view_func(*args, **kwargs)
  File "/usr/local/lib/python3.10/site-packages/django/views/generic/base.py", line 70, in view
    return self.dispatch(request, *args, **kwargs)
  File "/usr/local/lib/python3.10/site-packages/rest_framework/views.py", line 509, in dispatch
    response = self.handle_exception(exc)
  File "/usr/local/lib/python3.10/site-packages/rest_framework/views.py", line 469, in handle_exception
    self.raise_uncaught_exception(exc)
  File "/usr/local/lib/python3.10/site-packages/rest_framework/views.py", line 480, in raise_uncaught_exception
    raise exc
  File "/usr/local/lib/python3.10/site-packages/rest_framework/views.py", line 506, in dispatch
    response = handler(request, *args, **kwargs)
  File "/usr/local/lib/python3.10/site-packages/rest_framework/decorators.py", line 50, in handler
    return func(*args, **kwargs)
  File "/usr/local/lib/python3.10/site-packages/richie/apps/courses/api.py", line 270, in sync_course_runs_from_request
    sync_course_run(request.data)
  File "/usr/local/lib/python3.10/site-packages/richie/apps/courses/api.py", line 163, in sync_course_run
    course = Course.objects.get(
  File "/usr/local/lib/python3.10/site-packages/django/db/models/manager.py", line 85, in manager_method
    return getattr(self.get_queryset(), name)(*args, **kwargs)
  File "/usr/local/lib/python3.10/site-packages/django/db/models/query.py", line 439, in get
    raise self.model.MultipleObjectsReturned(
richie.apps.courses.models.course.Course.MultipleObjectsReturned: get() returned more than one Course -- it returned 2!
``` 

**Steps to Reproduce**
1. Create a new course
2. Add a code to the course
3. And then the bug happens!

**Environment**
- Richie version: `2.21.0`
- Platform: Linux

**Additional context/Screenshots**
When I run this on python shell - `python manage.py shell`:
```python
>>> from richie.apps.courses.models import Course
>>> course_code='FF_PP'
>>> Course.objects.get(code=course_code, extended_object__publisher_is_draft=True, extended_object__node__parent__cms_pages__course__isnull=True, )
Traceback (most recent call last):
  File "<console>", line 1, in <module>
  File "/usr/local/lib/python3.10/site-packages/django/db/models/manager.py", line 85, in manager_method
    return getattr(self.get_queryset(), name)(*args, **kwargs)
  File "/usr/local/lib/python3.10/site-packages/django/db/models/query.py", line 439, in get
    raise self.model.MultipleObjectsReturned(
richie.apps.courses.models.course.Course.MultipleObjectsReturned: get() returned more than one Course -- it returned 2!
``` 

URL: https://www.nau.edu.pt/pt/curso/primeiros-passos-em-folha-de-calculo-formulas-e-funcoes-nivel-1/

Nevertheless, I found out that the synchronization is running and the number of course enrollments have been updated automatically.


Discussion:
Hi @jbpenrath,
We found out that this issue is blocking multiple courses from synchronizing!

The code block that raised this issue is: https://github.com/openfun/richie/blob/47f9fe5/src/richie/apps/courses/api.py#L163-L168 

I've confirmed on my database that this Django query returns 2 objects, when it should only return 1:

New buggy code:
```python
>>> from richie.apps.courses.models import Course
>>> Course.objects.filter(code='AEEC', extended_object__publisher_is_draft=True, extended_object__node__parent__cms_pages__course__isnull=True, )
<PageExtensionQuerySet [<Course: Course: Arts in Educational Contexts: Southern Perspectives>, <Course: Course: Arts in Educational Contexts: Southern Perspectives>]>
```

But when I run the older query, it returns only 1, the expected output:
```python
>>> from richie.apps.courses.models import Course
>>> Course.objects.filter(code='AEEC', extended_object__publisher_is_draft=True )
<PageExtensionQuerySet [<Course: Course: Arts in Educational Contexts: Southern Perspectives>]>
```

So I think that this fix introduced the error: https://github.com/openfun/richie/commit/47f9fe58dbf8e635cc3276abc8a8c67d7dc87491

The query is returns 2 objects instead of expected 1, but they both rows returned represent the same object.

Script that confirm that both rows has the same Course id:
```python
>>> from richie.apps.courses.models import Course
>>> Course.objects.filter(code='AEEC', extended_object__publisher_is_draft=True, extended_object__node__parent__cms_pages__course__isnull=True, )[0].id
224
>>> Course.objects.filter(code='AEEC', extended_object__publisher_is_draft=True, extended_object__node__parent__cms_pages__course__isnull=True, )[1].id
224
``` 

So I think is an ORM issue. In NAU we use MySQL v8 instead of PostgreSQL.
I'm going to try the simulate the problem in MySQL v8.
I think adding a `distinct` fixes the MySQL problem.
```
>>> from richie.apps.courses.models import Course
>>> print(Course.objects.filter(code='AEEC', extended_object__publisher_is_draft=True, extended_object__node__parent__cms_pages__course__isnull=True, ).distinct())
<PageExtensionQuerySet [<Course: Course: Arts in Educational Contexts: Southern Perspectives>]>
```

Resolving patch:
diff --git a/src/richie/apps/courses/api.py b/src/richie/apps/courses/api.py
--- a/src/richie/apps/courses/api.py
+++ b/src/richie/apps/courses/api.py
@@ -160,7 +160,7 @@ def sync_course_run(data):
     # Look for the course targeted by the resource link
     course_code = normalize_code(lms.extract_course_code(data))
     try:
-        course = Course.objects.get(
+        course = Course.objects.distinct().get(
             code=course_code,
             extended_object__publisher_is_draft=True,
             # Exclude snapshots


Test patch:
diff --git a/tests/apps/courses/test_api_course_run_sync.py b/tests/apps/courses/test_api_course_run_sync.py
--- a/tests/apps/courses/test_api_course_run_sync.py
+++ b/tests/apps/courses/test_api_course_run_sync.py
@@ -12,6 +12,7 @@
 from cms.signals import post_publish
 from cms.test_utils.testcases import CMSTestCase
 
+from richie.apps.core.helpers import create_i18n_page
 from richie.apps.courses.factories import CourseFactory, CourseRunFactory
 from richie.apps.courses.models import Course, CourseRun
 from richie.apps.courses.serializers import SyncCourseRunSerializer
@@ -1258,3 +1259,82 @@ def test_api_course_run_sync_create_course_run_on_course_page_with_snapshot(
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.json(), {"success": True})
         self.assertEqual(CourseRun.objects.count(), 1)
+
+    def test_api_course_run_sync_create_course_run_on_published_course_page(
+        self, mock_signal
+    ):
+        """
+        When a course run is created on a published course page, the course run should
+        be created successfully.
+        """
+        CourseFactory(code="DemoX", should_publish=True)
+
+        # Two courses with the same code should be created
+        Course.objects.get(code="DEMOX", extended_object__publisher_is_draft=True)
+        Course.objects.get(code="DEMOX", extended_object__publisher_is_draft=False)
+        self.assertEqual(CourseRun.objects.count(), 0)
+
+        data = {
+            "resource_link": "http://example.edx:8073/courses/course-v1:edX+DemoX+01/course/",
+            "start": "2020-12-09T09:31:59.417817Z",
+            "end": "2021-03-14T09:31:59.417895Z",
+            "enrollment_start": "2020-11-09T09:31:59.417936Z",
+            "enrollment_end": "2020-12-24T09:31:59.417972Z",
+            "languages": ["en", "fr"],
+            "enrollment_count": 46782,
+            "catalog_visibility": "course_and_search",
+        }
+
+        mock_signal.reset_mock()
+
+        authorization = (
+            "SIG-HMAC-SHA256 "
+            "5bdfb326b35fccaef9961e03cf617c359c86ffbb6c64e0f7e074aa011e8af9d6"
+        )
+        response = self.client.post(
+            "/api/v1.0/course-runs-sync",
+            data,
+            content_type="application/json",
+            HTTP_AUTHORIZATION=authorization,
+        )
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json(), {"success": True})
+        self.assertEqual(CourseRun.objects.count(), 2)
+
+    def test_api_course_with_parent_courses_page(self, mock_signal):
+        """
+        Verify that a course is updated when it has a parent courses page.
+        """
+        mock_signal.reset_mock()
+        courses_page = create_i18n_page(
+            "Courses page",
+            published=True,
+            reverse_id="courses",
+        )
+        CourseFactory(code="DemoX", page_parent=courses_page, should_publish=False)
+
+        data = {
+            "resource_link": "http://example.edx:8073/courses/course-v1:edX+DemoX+01/course/",
+            "start": "2020-12-09T09:31:59.417817Z",
+            "end": "2021-03-14T09:31:59.417895Z",
+            "enrollment_start": "2020-11-09T09:31:59.417936Z",
+            "enrollment_end": "2020-12-24T09:31:59.417972Z",
+            "languages": ["en", "fr"],
+            "enrollment_count": 46782,
+            "catalog_visibility": "course_and_search",
+        }
+
+        authorization = (
+            "SIG-HMAC-SHA256 "
+            "5bdfb326b35fccaef9961e03cf617c359c86ffbb6c64e0f7e074aa011e8af9d6"
+        )
+        response = self.client.post(
+            "/api/v1.0/course-runs-sync",
+            data,
+            content_type="application/json",
+            HTTP_AUTHORIZATION=authorization,
+        )
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json(), {"success": True})