Repository: openfun/richie

Issue:
Error on course page when run without title and enabled the `WEB_ANALYTICS_ID` setting
## Bug Report

When activating `WEB_ANALYTICS_ID` setting and with a course on a specific state, then its course page raises an error.

A course has at a course run, but the course run doesn't have any `CourseRunTranslation` instance. So when we access the field `title` on the `CourseRun` it raises an error on [this lines](https://github.com/openfun/richie/blob/89832e1502fb647d59ec39cec90753e0f759dae3/src/richie/apps/core/context_processors.py#L186-L188).


**Environment**
- Richie version: 2.8.2
- This started to happen on NAU Stage environment when we upgrade Richie from 2.7.2 to 2.8.2.

**How the application state has occurred**:
When creating a course run using the `course-runs-sync` API it doesn't create any `CourseRunTranslation` instance associated to the `CourseRun`. I think that because the `course_runs_sync` API don't assign any course run title, then any translation is created.

**Steps to reproduce**
Next I describe the steps, so it's possible to reproduce on the demo site.

1. Delete all `CourseRunTranslation` instances from DB.
```bash
docker-compose exec postgresql bash
psql --username=fun --password richie
truncate richie_course_run_translation;
```

2. Add `WEB_ANALYTICS_ID` environment variable with any value to the file `env.d/development/dev`. Eg:
```
WEB_ANALYTICS_ID=AA
```

Restart richie
```bash
make stop run
```

**Possible Solution**
I can't reproduce the problem using tests, because `CourseRunFactory` mocks a random value for the `CourseRun` `title` field.
Nevertheless I've a solution that fixes the problem when using the demo application, using the steps to reproduce this issue.
I'm going to send a pull request to at least fix this, even I couldn't figure out how to reproduce on tests.
A rapid description of the hot fix is to use `safe_translation_getter` method to access the `title` field on the `CourseRun` on the richie `context_processors.py`.

Solution to fix the data at least until a new release with the fix is launched. Run the next python script on the Django shell.
```bash
docker-compose exec -u root app bash
python manage.py shell
```

```python
from richie.apps.courses.models import CourseRun, CourseRunTranslation
for cr in CourseRun.objects.all(): 
  trans = CourseRunTranslation.objects.filter(master=cr)
  if not trans:
    CourseRunTranslation(master = cr, title=None, language_code="en").save()
```

**Error on NAU STAGE environment**
`edição do curso` == course run
```
edição do curso does not have a translation for the current language!
edição do curso ID #19, language=pt (tried fallbacks en)
Attempted to read attribute title.
```

**Stacktrace**
```
app-dev_1          | Traceback (most recent call last):
app-dev_1          |   File "/usr/local/lib/python3.7/site-packages/django/core/handlers/exception.py", line 47, in inner
app-dev_1          |     response = get_response(request)
app-dev_1          |   File "/usr/local/lib/python3.7/site-packages/django/core/handlers/base.py", line 204, in _get_response
app-dev_1          |     response = response.render()
app-dev_1          |   File "/usr/local/lib/python3.7/site-packages/django/template/response.py", line 105, in render
app-dev_1          |     self.content = self.rendered_content
app-dev_1          |   File "/usr/local/lib/python3.7/site-packages/django/template/response.py", line 83, in rendered_content
app-dev_1          |     return template.render(context, self._request)
app-dev_1          |   File "/usr/local/lib/python3.7/site-packages/django/template/backends/django.py", line 61, in render
app-dev_1          |     return self.template.render(context)
app-dev_1          |   File "/usr/local/lib/python3.7/site-packages/django/template/base.py", line 168, in render
app-dev_1          |     with context.bind_template(self):
app-dev_1          |   File "/usr/local/lib/python3.7/contextlib.py", line 112, in _enter_
app-dev_1          |     return next(self.gen)
app-dev_1          |   File "/usr/local/lib/python3.7/site-packages/django/template/context.py", line 244, in bind_template
app-dev_1          |     updates.update(processor(self.request))
app-dev_1          |   File "/usr/local/lib/python3.7/site-packages/richie/apps/core/context_processors.py", line 49, in site_metas
app-dev_1          |     **WebAnalyticsContextProcessor().context_processor(request),
app-dev_1          |   File "/usr/local/lib/python3.7/site-packages/richie/apps/core/context_processors.py", line 123, in context_processor
app-dev_1          |     context["WEB_ANALYTICS_DIMENSIONS"] = self.get_dimensions(request)
app-dev_1          |   File "/usr/local/lib/python3.7/site-packages/richie/apps/core/context_processors.py", line 182, in get_dimensions
app-dev_1          |     for course_run in course_runs
app-dev_1          |   File "/usr/local/lib/python3.7/site-packages/richie/apps/core/context_processors.py", line 183, in <listcomp>
app-dev_1          |     if course_run is not None and course_run.title is not None
app-dev_1          |   File "/usr/local/lib/python3.7/site-packages/parler/fields.py", line 133, in _get_
app-dev_1          |     translation = instance._get_translated_model(use_fallback=True, meta=meta)
app-dev_1          |   File "/usr/local/lib/python3.7/site-packages/parler/models.py", line 551, in _get_translated_model
app-dev_1          |     "{0} ID #{1}, language={2}{3}".format(self._meta.verbose_name, self.pk, language_code, fallback_msg or ''
app-dev_1          | parler.models.DoesNotExist: edição do curso does not have a translation for the current language!
app-dev_1          | edição do curso ID #19, language=pt (tried fallbacks en)
app-dev_1          | Attempted to read attribute title.
app-dev_1          | int() argument must be a string, a bytes-like object or a number, not 'NoneType'
```



Discussion:

Resolving patch:
diff --git a/src/richie/apps/core/context_processors.py b/src/richie/apps/core/context_processors.py
--- a/src/richie/apps/core/context_processors.py
+++ b/src/richie/apps/core/context_processors.py
@@ -185,7 +185,7 @@ def get_dimensions(self, request: HttpRequest) -> dict:
         dimensions["course_runs_titles"] = [
             course_run.title
             for course_run in course_runs
-            if course_run is not None and course_run.title is not None
+            if course_run is not None and course_run.safe_title is not None
         ]
 
         dimensions["course_runs_resource_links"] = map(
diff --git a/src/richie/apps/courses/models/course.py b/src/richie/apps/courses/models/course.py
--- a/src/richie/apps/courses/models/course.py
+++ b/src/richie/apps/courses/models/course.py
@@ -6,7 +6,7 @@
 from datetime import MAXYEAR, datetime
 
 from django.conf import settings
-from django.core.exceptions import ValidationError
+from django.core.exceptions import ObjectDoesNotExist, ValidationError
 from django.db import models
 from django.db.models import Q, Sum
 from django.urls import reverse
@@ -875,6 +875,16 @@ def get_course(self):
             extended_object__publisher_is_draft=is_draft,
         ).distinct()[0]
 
+    @property
+    def safe_title(self):
+        """
+        Access the `title` translatable field from the `CourseRunTranslation` on a safe way.
+        """
+        try:
+            return self.title
+        except ObjectDoesNotExist:
+            return None
+
 
 class CourseRunTranslation(TranslatedFieldsModel):
     """


Test patch:
diff --git a/tests/apps/core/test_web_analytics.py b/tests/apps/core/test_web_analytics.py
--- a/tests/apps/core/test_web_analytics.py
+++ b/tests/apps/core/test_web_analytics.py
@@ -2,10 +2,12 @@
 Test suite for the Web Analytics
 """
 from django.conf import settings
+from django.core.cache import cache
 from django.test import RequestFactory
 from django.test.utils import override_settings
 
 from cms.test_utils.testcases import CMSTestCase
+from parler.models import TranslationDoesNotExist
 
 from richie.apps.core.context_processors import WebAnalyticsContextProcessor
 from richie.apps.courses.factories import (
@@ -13,6 +15,7 @@
     CourseRunFactory,
     OrganizationFactory,
 )
+from richie.apps.courses.models.course import CourseRun, CourseRunTranslation
 
 
 class WebAnalyticsTestCase(CMSTestCase):
@@ -231,3 +234,52 @@ def test_web_analytics_with_only_id(self):
             "YYY-ZZZ-WWW",
             msg_prefix="Page should include the Web Analytics id",
         )
+
+    @override_settings(
+        WEB_ANALYTICS_ID="UA-XXXXXXXXX-X", WEB_ANALYTICS_PROVIDER="google_analytics"
+    )
+    def test_web_analytics_course_page_course_run_without_title(self):
+        """
+        Test Web Analytics with a course page that has a course run without a title translation
+        """
+        course = CourseFactory()
+
+        course_run = CourseRun(
+            direct_course=course,
+            languages=["fr"],
+            title=None,
+        )
+        course_run.save()
+
+        CourseRunTranslation.objects.all().delete()
+        course_run.refresh_from_db()
+
+        self.assertFalse(CourseRunTranslation.objects.exists())
+        self.assertEqual(course_run.title, None)
+
+        cache.clear()
+
+        # reread course run from database
+        course_run = CourseRun.objects.first()
+
+        self.assertIsNone(
+            course_run.safe_title,
+            msg="Course run without a title should return None on safe_title property",
+        )
+
+        with self.assertRaises(
+            TranslationDoesNotExist,
+            msg="Verify that the 'title' method unfortunately is raising an error",
+        ):
+            course_run.title  # pylint: disable=pointless-statement
+
+        page = course.extended_object
+        page.publish(settings.LANGUAGE_CODE)
+        url = page.get_absolute_url(language=settings.LANGUAGE_CODE)
+        response = self.client.get(url)
+
+        self.assertContains(
+            response,
+            "dimension3': ''",
+            msg_prefix="Should include an empty course run title on the 3rd custom dimension",
+        )