Repository: openfun/richie

Issue:
Add new placeholders on course detail page (syllabus)
**Is your feature request related to a problem or unsupported use case? Please describe.**  
With the new courses (department FormaPro) scheduled to be available on FUN, we are required to include the following information on the course presentation page: **required materials** and **accessibility**

**Describe the solution you'd like** 
Add two new placeholders with simple text plugins: 
- `Required Equipment` / `Matériel requis`(after Prerequisites) 

![image (3)](https://github.com/openfun/richie/assets/138883136/d999e7dc-1f4e-4908-a43c-e5ca2a0af9b7)


-  `Accessibility` / `Accessibilité` (before the course plan)

![image](https://github.com/openfun/richie/assets/138883136/73ae66fc-d353-44e9-b5e5-7ddaed434b1e)



**Describe alternatives you've considered** 
Integrate this information using the additional information section. The problem is that this information would end up at the bottom of the page and these fields could benefit all sites ?  - they will not be displayed if they are not filled in (to be discussed).







Discussion:
You should also add RDFa tagging.
`Required material` could be an `about` property of type `Thing` containing a `name` and `description` (Check format placeholder for example).
`Accessibility` could be `accessibilitySummary`

Resolving patch:
diff --git a/src/richie/apps/courses/factories.py b/src/richie/apps/courses/factories.py
--- a/src/richie/apps/courses/factories.py
+++ b/src/richie/apps/courses/factories.py
@@ -6,6 +6,7 @@
 from collections import namedtuple
 from datetime import datetime, timedelta
 
+from django.conf import settings
 from django.utils import timezone
 from django.utils.translation import gettext_lazy as _
 
@@ -318,6 +319,40 @@ def fill_organizations(self, create, extracted, **kwargs):
                 self, extracted, "course_organizations", "OrganizationPlugin"
             )
 
+    @factory.post_generation
+    # pylint: disable=unused-argument
+    def fill_course_required_equipment(self, create, extracted, **kwargs):
+        """
+        Add course required equipment plugin to course.
+        """
+
+        if create and extracted:
+            add_plugin(
+                language=settings.LANGUAGE_CODE,
+                placeholder=self.extended_object.placeholders.get(
+                    slot="course_required_equipment"
+                ),
+                plugin_type="PlainTextPlugin",
+                body="".join(extracted),
+            )
+
+    @factory.post_generation
+    # pylint: disable=unused-argument
+    def fill_course_accessibility(self, create, extracted, **kwargs):
+        """
+        Add course required equipment plugin to course.
+        """
+
+        if create and extracted:
+            add_plugin(
+                language=settings.LANGUAGE_CODE,
+                placeholder=self.extended_object.placeholders.get(
+                    slot="course_accessibility"
+                ),
+                plugin_type="PlainTextPlugin",
+                body="".join(extracted),
+            )
+
     @factory.post_generation
     # pylint: disable=unused-argument
     def fill_licences(self, create, extracted, **kwargs):
diff --git a/src/richie/apps/courses/settings/__init__.py b/src/richie/apps/courses/settings/__init__.py
--- a/src/richie/apps/courses/settings/__init__.py
+++ b/src/richie/apps/courses/settings/__init__.py
@@ -246,6 +246,14 @@ def richie_placeholder_conf(name):
         "name": _("Assessment and Certification"),
         "plugins": ["CKEditorPlugin"],
     },
+    "courses/cms/course_detail.html course_required_equipment": {
+        "name": _("Required equipment"),
+        "plugins": ["CKEditorPlugin"],
+    },
+    "courses/cms/course_detail.html course_accessibility": {
+        "name": _("Accessibility"),
+        "plugins": ["CKEditorPlugin"],
+    },
     # Organization detail
     "courses/cms/organization_detail.html banner": {
         "name": _("Banner"),
@@ -519,7 +527,13 @@ def richie_placeholder_conf(name):
         "max_length": 5000,
     },
     {
-        "placeholders": ["course_assessment", "course_format", "course_prerequisites"],
+        "placeholders": [
+            "course_assessment",
+            "course_format",
+            "course_prerequisites",
+            "course_accessibility",
+            "course_required_equipment",
+        ],
         "ckeditor": "CKEDITOR_BASIC_CONFIGURATION",
     },
 ]


Test patch:
diff --git a/tests/apps/courses/test_templates_course_detail_rdfa.py b/tests/apps/courses/test_templates_course_detail_rdfa.py
--- a/tests/apps/courses/test_templates_course_detail_rdfa.py
+++ b/tests/apps/courses/test_templates_course_detail_rdfa.py
@@ -168,6 +168,28 @@ def test_templates_course_detail_rdfa(self):
                     variant=ACCORDION,
                 )
 
+        # Add required equipment
+        placeholder = course.extended_object.placeholders.get(
+            slot="course_required_equipment"
+        )
+        add_plugin(
+            language="en",
+            placeholder=placeholder,
+            plugin_type="PlainTextPlugin",
+            body="Required equipment section content",
+        )
+
+        # Add accessibility
+        placeholder = course.extended_object.placeholders.get(
+            slot="course_accessibility"
+        )
+        add_plugin(
+            language="en",
+            placeholder=placeholder,
+            plugin_type="PlainTextPlugin",
+            body="Accessibility section content",
+        )
+
         # Create an ongoing open course run that will be published (created before
         # publishing the page)
         now = datetime(2030, 6, 15, tzinfo=timezone.utc)
@@ -210,7 +232,7 @@ def test_templates_course_detail_rdfa(self):
         # Retrieve the course top node (body)
         (subject,) = graph.subjects(RDF.type, SDO.Course, unique=True)
 
-        self.assertEqual(len(list(graph.triples((subject, None, None)))), 39)
+        self.assertEqual(len(list(graph.triples((subject, None, None)))), 41)
 
         # Opengraph
         self.assertTrue(
@@ -297,8 +319,12 @@ def test_templates_course_detail_rdfa(self):
         pattern = r"A valuable certificate"
         self.assertIsNotNone(re.search(pattern, str(assessment_value)))
 
+        (accessibility_value,) = graph.objects(subject, SDO.accessibilitySummary)
+        pattern = r"Accessibility section content"
+        self.assertIsNotNone(re.search(pattern, str(accessibility_value)))
+
         abouts = list(graph.objects(subject, SDO.about))
-        self.assertEqual(len(abouts), 2)
+        self.assertEqual(len(abouts), 3)
 
         (format_subject,) = graph.subjects(SDO.name, Literal("Format"))
         self.assertTrue((subject, SDO.about, format_subject) in graph)
@@ -314,6 +340,20 @@ def test_templates_course_detail_rdfa(self):
         pattern = r"Skill 1, Skill 2"
         self.assertIsNotNone(re.search(pattern, str(description_value)))
 
+        (required_equipment_subject,) = graph.subjects(
+            SDO.name, Literal("Required Equipment")
+        )
+        self.assertTrue((subject, SDO.about, required_equipment_subject) in graph)
+        self.assertTrue((required_equipment_subject, RDF.type, SDO.Thing) in graph)
+        (description_value,) = graph.objects(
+            required_equipment_subject, SDO.description
+        )
+        pattern = r"Required equipment section content"
+        self.assertIsNotNone(re.search(pattern, str(description_value)))
+
+        self.assertTrue((subject, SDO.about, format_subject) in graph)
+        self.assertTrue((format_subject, RDF.type, SDO.Thing) in graph)
+
         sections = list(graph.objects(subject, SDO.syllabusSections))
         self.assertEqual(len(sections), 1)
 
@@ -464,7 +504,6 @@ def test_templates_course_detail_rdfa(self):
         # - Team (Person)
         contributor_subjects = list(graph.objects(subject, SDO.contributor))
         self.assertEqual(len(contributor_subjects), 2)
-
         self.assertTrue(
             (
                 contributor_subjects[0],
diff --git a/tests/apps/courses/test_templates_course_detail_rendering.py b/tests/apps/courses/test_templates_course_detail_rendering.py
--- a/tests/apps/courses/test_templates_course_detail_rendering.py
+++ b/tests/apps/courses/test_templates_course_detail_rendering.py
@@ -40,7 +40,7 @@ class TemplatesCourseDetailRenderingCMSTestCase(CMSTestCase):
     hidden from published page so common users can not see them.
     """
 
-    # pylint: disable=too-many-locals
+    # pylint: disable=too-many-locals, too-many-statements
     def test_templates_course_detail_cms_published_content(self):
         """
         Validate that the important elements are displayed on a published course page
@@ -56,6 +56,9 @@ def test_templates_course_detail_cms_published_content(self):
             fill_organizations=organizations,
             fill_categories=categories,
             fill_icons=icons,
+            fill_texts={},
+            fill_course_required_equipment="Required equipment section content",
+            fill_course_accessibility="Accessibility section content",
         )
         page = course.extended_object
         # Create an ongoing open course run that will be published (created before
@@ -196,6 +199,26 @@ def test_templates_course_detail_cms_published_content(self):
                 response, organization.extended_object.get_title(), html=True
             )
 
+        self.assertContains(
+            response,
+            '<h2 class="course-detail__title" property="name">Required Equipment</h2>',
+            html=True,
+        )
+        self.assertContains(
+            response,
+            "Required equipment section content",
+        )
+
+        self.assertContains(
+            response,
+            '<h2 class="course-detail__title">Accessibility</h2>',
+            html=True,
+        )
+        self.assertContains(
+            response,
+            "Accessibility section content",
+        )
+
         # Only the published course run should be in response content
         self.assertEqual(CourseRun.objects.count(), 3)
         self.assertContains(