Repository: openfun/richie

Issue:
Manage general terms of sale page (CGV)
## Feature Request

**Is your feature request related to a problem or unsupported use case? Please describe.**

We want to create a page displaying the general terms of sale (CGV) in french and create a link to consult it when the user accept them in the cart page before paying.

![image](https://github.com/openfun/richie/assets/767834/bbe518c4-7c0e-4640-ac6e-af95aca73a5e)



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
@@ -13,6 +13,8 @@
 from django.middleware.csrf import get_token
 from django.utils.translation import get_language_from_request
 
+from cms.models import Page
+
 from richie.apps.core.templatetags.joanie import is_joanie_enabled
 from richie.apps.courses.models import Organization
 
@@ -42,15 +44,9 @@ def site_metas(request: HttpRequest):
             "domain": site_current.domain,
             "web_url": f"{protocol:s}://{site_current.domain:s}",
         },
-        "FRONTEND_CONTEXT": {
-            "context": {
-                "csrftoken": get_token(request),
-                "environment": getattr(settings, "ENVIRONMENT", ""),
-                "release": getattr(settings, "RELEASE", ""),
-                "sentry_dsn": getattr(settings, "SENTRY_DSN", ""),
-                **WebAnalyticsContextProcessor().frontend_context_processor(request),
-            }
-        },
+        "FRONTEND_CONTEXT": json.dumps(
+            FrontendContextProcessor().context_processor(request)
+        ),
         **WebAnalyticsContextProcessor().context_processor(request),
     }
 
@@ -100,32 +96,6 @@ def site_metas(request: HttpRequest):
             ),
         }
 
-        context["FRONTEND_CONTEXT"]["context"]["authentication"] = {
-            "endpoint": authentication_delegation["BASE_URL"],
-            "backend": authentication_delegation["BACKEND"],
-        }
-
-    if is_joanie_enabled():
-        context["FRONTEND_CONTEXT"]["context"]["joanie_backend"] = {
-            "endpoint": settings.JOANIE_BACKEND["BASE_URL"],
-        }
-
-    if getattr(settings, "RICHIE_LMS_BACKENDS", None):
-        context["FRONTEND_CONTEXT"]["context"]["lms_backends"] = [
-            {
-                "endpoint": lms["BASE_URL"],
-                "backend": lms["JS_BACKEND"],
-                "course_regexp": lms["JS_COURSE_REGEX"],
-            }
-            for lms in getattr(settings, "RICHIE_LMS_BACKENDS", [])
-        ]
-
-    context["FRONTEND_CONTEXT"]["context"]["features"] = getattr(
-        settings, "FEATURES", {}
-    )
-
-    context["FRONTEND_CONTEXT"] = json.dumps(context["FRONTEND_CONTEXT"])
-
     if getattr(settings, "RICHIE_MINIMUM_COURSE_RUNS_ENROLLMENT_COUNT", None):
         context["RICHIE_MINIMUM_COURSE_RUNS_ENROLLMENT_COUNT"] = (
             settings.RICHIE_MINIMUM_COURSE_RUNS_ENROLLMENT_COUNT
@@ -222,3 +192,80 @@ def get_dimensions(self, request: HttpRequest) -> dict:
 
         dimensions["page_title"] = [page.get_title() if page else ""]
         return dimensions
+
+
+class FrontendContextProcessor:
+    """
+    Context processor to add all information required by react application.
+    """
+
+    def get_authentication_context(self):
+        """Get the authentication context if there is."""
+        if authentication_delegation := getattr(
+            settings, "RICHIE_AUTHENTICATION_DELEGATION", None
+        ):
+            return {
+                "endpoint": authentication_delegation["BASE_URL"],
+                "backend": authentication_delegation["BACKEND"],
+            }
+
+        return None
+
+    def get_joanie_context(self):
+        """Get the joanie context if it is enabled."""
+        if is_joanie_enabled():
+            return {
+                "endpoint": settings.JOANIE_BACKEND["BASE_URL"],
+            }
+
+        return None
+
+    def get_lms_context(self):
+        """Get lms backends context if there are."""
+        if getattr(settings, "RICHIE_LMS_BACKENDS", None):
+            return [
+                {
+                    "endpoint": lms["BASE_URL"],
+                    "backend": lms["JS_BACKEND"],
+                    "course_regexp": lms["JS_COURSE_REGEX"],
+                }
+                for lms in getattr(settings, "RICHIE_LMS_BACKENDS", [])
+            ]
+
+        return None
+
+    def get_site_urls(self, request: HttpRequest):
+        """Get the site urls context that must be passed down to react application."""
+
+        def get_page_url(reverse_id):
+            try:
+                page = Page.objects.get(publisher_is_draft=False, reverse_id=reverse_id)
+            except Page.DoesNotExist:
+                return None
+
+            return page.get_public_url()
+
+        return {"terms_and_conditions": get_page_url("annex__terms_and_conditions")}
+
+    def context_processor(self, request: HttpRequest) -> dict:
+        """Get the frontend context processor."""
+        context = {
+            "csrftoken": get_token(request),
+            "environment": getattr(settings, "ENVIRONMENT", ""),
+            "release": getattr(settings, "RELEASE", ""),
+            "sentry_dsn": getattr(settings, "SENTRY_DSN", ""),
+            "features": getattr(settings, "FEATURES", {}),
+            "site_urls": self.get_site_urls(request),
+            **WebAnalyticsContextProcessor().frontend_context_processor(request),
+        }
+
+        if authentication_context := self.get_authentication_context():
+            context["authentication"] = authentication_context
+
+        if joanie_context := self.get_joanie_context():
+            context["joanie_backend"] = joanie_context
+
+        if lms_context := self.get_lms_context():
+            context["lms_backends"] = lms_context
+
+        return {"context": context}


Test patch:
diff --git a/src/frontend/js/utils/test/factories/richie.ts b/src/frontend/js/utils/test/factories/richie.ts
--- a/src/frontend/js/utils/test/factories/richie.ts
+++ b/src/frontend/js/utils/test/factories/richie.ts
@@ -181,6 +181,9 @@ export const RichieContextFactory = factory<CommonDataProps['context']>(() => ({
   release: faker.system.semver(),
   sentry_dsn: null,
   web_analytics_providers: null,
+  site_urls: {
+    terms_and_conditions: null,
+  },
 }));
 
 export const CourseLightFactory = factory<Course>(() => {
diff --git a/tests/apps/core/test_pages.py b/tests/apps/core/test_pages.py
--- a/tests/apps/core/test_pages.py
+++ b/tests/apps/core/test_pages.py
@@ -100,6 +100,7 @@ def test_page_includes_frontend_context(self):
         ⚠️ an impact on frontend and update frontend accordingly.
         """
         page = PageFactory(should_publish=True, template="richie/single_column.html")
+
         response = self.client.get(page.get_public_url())
         self.assertContains(
             response, r"\u0022environment\u0022: \u0022test_pages\u0022"
@@ -130,3 +131,71 @@ def test_page_includes_frontend_context(self):
             response,
             r"\u0022features\u0022: {\u0022FEATURE_FLAG\u0022: true}",  # noqa pylint: disable=line-too-long
         )
+
+        self.assertContains(
+            response,
+            r"\u0022site_urls\u0022: {\u0022terms_and_conditions\u0022: null",
+        )
+
+    def test_page_includes_frontend_context_site_urls(self):
+        """
+        The frontend context should contains site urls to pass down to react application
+        Indeed, terms and conditions page should be returned if it exists and
+        is published in the request language.
+        """
+
+        page = PageFactory(should_publish=True, template="richie/single_column.html")
+
+        response = self.client.get(page.get_public_url())
+
+        # - No page exists yet, so terms and conditions should be null
+        self.assertContains(
+            response,
+            r"\u0022site_urls\u0022: {\u0022terms_and_conditions\u0022: null",
+        )
+
+        # - Create a terms and conditions page
+        terms_page = create_i18n_page(
+            {"fr": "Conditions générales de vente", "en": "Terms and conditions"},
+            template="richie/single_column.html",
+            reverse_id="annex__terms_and_conditions",
+        )
+
+        response = self.client.get(page.get_public_url())
+
+        # - The page exists but is not published, so terms and conditions should be null
+        self.assertContains(
+            response,
+            r"\u0022site_urls\u0022: {\u0022terms_and_conditions\u0022: null",
+        )
+
+        # - Publish the page in English
+        terms_page.publish("en")
+
+        # - Request english page should return terms and conditions english page
+        response = self.client.get(page.get_public_url())
+        self.assertContains(
+            response,
+            r"\u0022site_urls\u0022: {\u0022terms_and_conditions\u0022: "
+            r"\u0022/en/terms\u002Dand\u002Dconditions/\u0022",
+        )
+
+        # - Request french page should return terms and conditions page in English as the
+        #   french version is not published
+        response = self.client.get(page.get_public_url("fr"))
+        self.assertContains(
+            response,
+            r"\u0022site_urls\u0022: {\u0022terms_and_conditions\u0022: "
+            r"\u0022/fr/terms\u002Dand\u002Dconditions/\u0022",
+        )
+
+        # - Publish the page in French
+        terms_page.publish("fr")
+
+        # - Now request french page should return terms and conditions page in French
+        response = self.client.get(page.get_public_url("fr"))
+        self.assertContains(
+            response,
+            r"\u0022site_urls\u0022: {\u0022terms_and_conditions\u0022: "
+            r"\u0022/fr/conditions\u002Dgenerales\u002Dde\u002Dvente/\u0022",
+        )
diff --git a/tests/apps/courses/test_templatetags_category_tags_get_related_categories.py b/tests/apps/courses/test_templatetags_category_tags_get_related_categories.py
--- a/tests/apps/courses/test_templatetags_category_tags_get_related_categories.py
+++ b/tests/apps/courses/test_templatetags_category_tags_get_related_categories.py
@@ -81,7 +81,7 @@ def test_templatetags_get_related_category_pages_draft(self):
         # 1. Test categories present on the draft page
 
         # - Linked with one of the pages
-        with self.assertNumQueries(1):
+        with self.assertNumQueries(2):
             output = self.render_template_obj(
                 template, {"current_page": page_main, "pages": [page1]}, request
             )
@@ -89,7 +89,7 @@ def test_templatetags_get_related_category_pages_draft(self):
         self.assertEqual(output, "".join([str(c.id) for c in expected]))
 
         # - Linked with either of the 2 pages
-        with self.assertNumQueries(1):
+        with self.assertNumQueries(2):
             output = self.render_template_obj(
                 template, {"current_page": page_main, "pages": [page1, page2]}, request
             )
@@ -102,7 +102,7 @@ def test_templatetags_get_related_category_pages_draft(self):
         self.assertEqual(output, "".join([str(c.id) for c in expected]))
 
         # - Linked with a page in a different publication status
-        with self.assertNumQueries(1):
+        with self.assertNumQueries(2):
             output = self.render_template_obj(
                 template,
                 {"current_page": page_main, "pages": [page1.get_public_object()]},
@@ -115,7 +115,7 @@ def test_templatetags_get_related_category_pages_draft(self):
         current_page = page_main.get_public_object()
 
         # - Linked with one of the pages
-        with self.assertNumQueries(1):
+        with self.assertNumQueries(2):
             output = self.render_template_obj(
                 template,
                 {"current_page": current_page, "pages": [page1.get_public_object()]},
@@ -126,7 +126,7 @@ def test_templatetags_get_related_category_pages_draft(self):
         )
 
         # - Linked with either of the 2 pages
-        with self.assertNumQueries(1):
+        with self.assertNumQueries(2):
             output = self.render_template_obj(
                 template,
                 {
@@ -142,7 +142,7 @@ def test_templatetags_get_related_category_pages_draft(self):
         self.assertEqual(output, "".join([str(c.id) for c in expected]))
 
         # - Linked with a page in a different publication status
-        with self.assertNumQueries(1):
+        with self.assertNumQueries(2):
             output = self.render_template_obj(
                 template,
                 {"current_page": current_page, "pages": [page1]},
diff --git a/tests/apps/courses/test_templatetags_extra_tags_block_plugin.py b/tests/apps/courses/test_templatetags_extra_tags_block_plugin.py
--- a/tests/apps/courses/test_templatetags_extra_tags_block_plugin.py
+++ b/tests/apps/courses/test_templatetags_extra_tags_block_plugin.py
@@ -41,7 +41,7 @@ def test_templatetags_blockplugin(self):
             "{% endblockplugin %}"
         )
 
-        with self.assertNumQueries(1):
+        with self.assertNumQueries(2):
             output = self.render_template_obj(
                 template, {"plugin": plugin}, request
             ).replace("\n", "")
@@ -70,7 +70,7 @@ def test_templatetags_blockplugin_edit(self):
             "{% endblockplugin %}"
         )
 
-        with self.assertNumQueries(1):
+        with self.assertNumQueries(2):
             output = self.render_template_obj(
                 template, {"plugin": plugin}, request
             ).replace("\n", "")