Repository: openfun/richie

Issue:
Improve meta description on each type of page
## Feature Request

**Is your feature request related to a problem or unsupported use case? Please describe.**
The meta description is filled via the "meta_description" field on the page. This field is rarely filled whereas we have some interesting information on each page that could be used to fill it.

**Describe the solution you'd like**
If the "meta_description" field is filled on a page, use it. Otherwise, use the following information:
- blogpost page : use  first few words of the "excerpt" placeholder OR "body" placeholder
- category  use first few words of the "description" placeholder
- course page : use first few words of the "course_introduction" placeholder OR "course_description" placeholder. Maybe start by the state of the course and the title of the main organization if any?
- organization page : use  first few words of the "description" placeholder
- person page : use  first few words of the "bio" placeholder OR "maincontent" placeholder
- program page : use  first few words of the "program_excerpt" OR "program_body" placeholder

**Discovery, Documentation, Adoption, Migration Strategy**
Trunk the content used for the meta description to **200 characters** (150 recommended).



Discussion:

Resolving patch:
diff --git a/src/richie/apps/courses/templatetags/extra_tags.py b/src/richie/apps/courses/templatetags/extra_tags.py
--- a/src/richie/apps/courses/templatetags/extra_tags.py
+++ b/src/richie/apps/courses/templatetags/extra_tags.py
@@ -3,6 +3,7 @@
 
 from django import template
 from django.core.exceptions import ObjectDoesNotExist
+from django.template.defaultfilters import stringfilter
 from django.template.loader import render_to_string
 from django.utils import timezone
 from django.utils.translation import get_language
@@ -273,3 +274,12 @@ def course_enrollment_widget_props(context):
             }
         }
     )
+
+
+@register.filter
+@stringfilter
+def trim(value):
+    """
+    Remove whitespaces before and after a string.
+    """
+    return value.strip()


Test patch:
diff --git a/tests/apps/courses/test_templates_blogpost_detail.py b/tests/apps/courses/test_templates_blogpost_detail.py
--- a/tests/apps/courses/test_templates_blogpost_detail.py
+++ b/tests/apps/courses/test_templates_blogpost_detail.py
@@ -7,6 +7,7 @@
 
 from django.utils import timezone
 
+from cms.api import add_plugin
 from cms.test_utils.testcases import CMSTestCase
 
 from richie.apps.core.factories import UserFactory
@@ -153,3 +154,102 @@ def test_templates_blogpost_detail_cms_published_content_opengraph(self):
         self.assertContains(
             response, '<meta property="og:image:height" content="500" />'
         )
+
+    def test_templates_blogpost_detail_meta_description(self):
+        """
+        The blogpost meta description should show meta_description placeholder if defined
+        """
+        blogpost = BlogPostFactory()
+        page = blogpost.extended_object
+
+        title_obj = page.get_title_obj(language="en")
+        title_obj.meta_description = "A custom description of the blog post"
+        title_obj.save()
+
+        page.publish("en")
+
+        url = blogpost.extended_object.get_absolute_url()
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+        self.assertContains(
+            response,
+            '<meta name="description" content="A custom description of the blog post" />',
+        )
+
+    def test_templates_blogpost_detail_meta_description_excerpt(self):
+        """
+        The blogpost meta description should show the excerpt placeholder if no meta_description
+        placeholoder exists
+        """
+        blogpost = BlogPostFactory()
+        page = blogpost.extended_object
+
+        # Add an excerpt to the blogpost
+        placeholder = blogpost.extended_object.placeholders.get(slot="excerpt")
+        add_plugin(
+            language="en",
+            placeholder=placeholder,
+            plugin_type="PlainTextPlugin",
+            body="A further sub title of the blog post",
+        )
+        page.publish("en")
+
+        url = blogpost.extended_object.get_absolute_url()
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+        self.assertContains(
+            response,
+            '<meta name="description" content="A further sub title of the blog post" />',
+        )
+
+    def test_templates_blogpost_detail_meta_description_excerpt_max_length(self):
+        """
+        The blogpost meta description should be cut if it exceeds more than 160 caracters
+        """
+        blogpost = BlogPostFactory()
+        page = blogpost.extended_object
+        placeholder_value = (
+            "Long description that describes the page with a summary. "
+            "Long description that describes the page with a summary. "
+            "Long description that describes the page with a summary. "
+        )
+
+        # Add an excerpt to the blogpost
+        placeholder = blogpost.extended_object.placeholders.get(slot="excerpt")
+        add_plugin(
+            language="en",
+            placeholder=placeholder,
+            plugin_type="PlainTextPlugin",
+            body=placeholder_value,
+        )
+        page.publish("en")
+
+        url = blogpost.extended_object.get_absolute_url()
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+        cut = placeholder_value[0:160]
+        self.assertContains(
+            response,
+            f'<meta name="description" content="{cut}" />',
+        )
+
+    def test_templates_blogpost_detail_meta_description_empty(self):
+        """
+        The blogpost meta description should not be present if neither the meta_description field
+        on the page, nor the excerpt placeholder are filled
+        """
+        blogpost = BlogPostFactory()
+        page = blogpost.extended_object
+        page.publish("en")
+
+        url = blogpost.extended_object.get_absolute_url()
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+        self.assertNotContains(
+            response,
+            '<meta name="description"',
+        )
diff --git a/tests/apps/courses/test_templates_category_detail.py b/tests/apps/courses/test_templates_category_detail.py
--- a/tests/apps/courses/test_templates_category_detail.py
+++ b/tests/apps/courses/test_templates_category_detail.py
@@ -378,3 +378,102 @@ def test_template_category_detail_without_category(self):
             ),
             html=True,
         )
+
+    def test_template_category_detail_meta_description(self):
+        """
+        The category meta description should show meta_description placeholder if defined
+        """
+        category = CategoryFactory()
+        page = category.extended_object
+
+        title_obj = page.get_title_obj(language="en")
+        title_obj.meta_description = "A custom description of the category"
+        title_obj.save()
+
+        page.publish("en")
+
+        url = category.extended_object.get_absolute_url()
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+        self.assertContains(
+            response,
+            '<meta name="description" content="A custom description of the category" />',
+        )
+
+    def test_template_category_detail_meta_description_description(self):
+        """
+        The category meta description should show the description if no meta_description is
+        specified
+        """
+        category = CategoryFactory()
+        page = category.extended_object
+
+        # Add an description to the category
+        placeholder = category.extended_object.placeholders.get(slot="description")
+        add_plugin(
+            language="en",
+            placeholder=placeholder,
+            plugin_type="CKEditorPlugin",
+            body="A further <b>description</b> of the category",
+        )
+        page.publish("en")
+
+        url = category.extended_object.get_absolute_url()
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+        self.assertContains(
+            response,
+            '<meta name="description" content="A further description of the category" />',
+        )
+
+    def test_template_category_detail_meta_description_description_max_length(self):
+        """
+        The category meta description should be cut if it exceeds more than 160 caracters
+        """
+        category = CategoryFactory()
+        page = category.extended_object
+        placeholder_value = (
+            "Long description that describes the page with a summary. "
+            "Long description that describes the page with a summary. "
+            "Long description that describes the page with a summary. "
+        )
+
+        # Add an description to the category
+        placeholder = category.extended_object.placeholders.get(slot="description")
+        add_plugin(
+            language="en",
+            placeholder=placeholder,
+            plugin_type="CKEditorPlugin",
+            body=placeholder_value,
+        )
+        page.publish("en")
+
+        url = category.extended_object.get_absolute_url()
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+        cut = placeholder_value[0:160]
+        self.assertContains(
+            response,
+            f'<meta name="description" content="{cut}" />',
+        )
+
+    def test_template_category_detail_meta_description_empty(self):
+        """
+        The category meta description should not be present if neither the meta_description field
+        on the page, nor the description placeholder are filled
+        """
+        category = CategoryFactory()
+        page = category.extended_object
+        page.publish("en")
+
+        url = category.extended_object.get_absolute_url()
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+        self.assertNotContains(
+            response,
+            '<meta name="description"',
+        )
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
@@ -1267,3 +1267,108 @@ def test_templates_course_detail_view_no_richie_max_archived_course_runs(self):
             '<li class="is-hidden">',
         )
         self.assertNotContains(response, "course-detail__view-more-runs")
+
+    def test_templates_course_detail_meta_description(self):
+        """
+        The course meta description should show meta_description placeholder if defined
+        """
+        course = CourseFactory()
+        page = course.extended_object
+
+        title_obj = page.get_title_obj(language="en")
+        title_obj.meta_description = "A custom description of the course"
+        title_obj.save()
+
+        page.publish("en")
+
+        url = course.extended_object.get_absolute_url()
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+        self.assertContains(
+            response,
+            '<meta name="description" content="A custom description of the course" />',
+        )
+
+    def test_templates_course_detail_meta_description_course_introduction(self):
+        """
+        The course meta description should show the course_introduction if no meta_description is
+        specified
+        """
+        course = CourseFactory()
+        page = course.extended_object
+
+        # Add an course_introduction to the course
+        placeholder = course.extended_object.placeholders.get(
+            slot="course_introduction"
+        )
+        add_plugin(
+            language="en",
+            placeholder=placeholder,
+            plugin_type="PlainTextPlugin",
+            body="A further course introduction of the course",
+        )
+        page.publish("en")
+
+        url = course.extended_object.get_absolute_url()
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+        self.assertContains(
+            response,
+            '<meta name="description" content="A further course introduction of the course" />',
+        )
+
+    def test_templates_course_detail_meta_description_course_introduction_max_length(
+        self,
+    ):
+        """
+        The course meta description should be cut if it exceeds more than 160 caracters
+        """
+        course = CourseFactory()
+        page = course.extended_object
+        placeholder_value = (
+            "Long description that describes the page with a summary. "
+            "Long description that describes the page with a summary. "
+            "Long description that describes the page with a summary. "
+        )
+
+        # Add an course_introduction to the course
+        placeholder = course.extended_object.placeholders.get(
+            slot="course_introduction"
+        )
+        add_plugin(
+            language="en",
+            placeholder=placeholder,
+            plugin_type="PlainTextPlugin",
+            body=placeholder_value,
+        )
+        page.publish("en")
+
+        url = course.extended_object.get_absolute_url()
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+        cut = placeholder_value[0:160]
+        self.assertContains(
+            response,
+            f'<meta name="description" content="{cut}" />',
+        )
+
+    def test_templates_course_detail_meta_description_empty(self):
+        """
+        The course meta description should not be present if neither the meta_description field
+        on the page, nor the `course_introduction` placeholder are filled
+        """
+        course = CourseFactory()
+        page = course.extended_object
+        page.publish("en")
+
+        url = course.extended_object.get_absolute_url()
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+        self.assertNotContains(
+            response,
+            '<meta name="description"',
+        )
diff --git a/tests/apps/courses/test_templates_organization_detail.py b/tests/apps/courses/test_templates_organization_detail.py
--- a/tests/apps/courses/test_templates_organization_detail.py
+++ b/tests/apps/courses/test_templates_organization_detail.py
@@ -377,3 +377,102 @@ def test_templates_organization_detail_cms_published_content_opengraph(self):
         self.assertContains(
             response, '<meta property="og:image:height" content="113" />'
         )
+
+    def test_templates_organization_detail_meta_description(self):
+        """
+        The organization meta description should show meta_description placeholder if defined
+        """
+        organization = OrganizationFactory()
+        page = organization.extended_object
+
+        title_obj = page.get_title_obj(language="en")
+        title_obj.meta_description = "A longer organization description"
+        title_obj.save()
+
+        page.publish("en")
+
+        url = organization.extended_object.get_absolute_url()
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+        self.assertContains(
+            response,
+            '<meta name="description" content="A longer organization description" />',
+        )
+
+    def test_templates_organization_detail_meta_description_description(self):
+        """
+        The organization meta description should show the description placeholder if no
+        meta_description placeholoder exists
+        """
+        organization = OrganizationFactory()
+        page = organization.extended_object
+
+        # Add an excerpt to the organization
+        placeholder = organization.extended_object.placeholders.get(slot="description")
+        add_plugin(
+            language="en",
+            placeholder=placeholder,
+            plugin_type="CKEditorPlugin",
+            body="A <b>longer</b> organization description",
+        )
+        page.publish("en")
+
+        url = organization.extended_object.get_absolute_url()
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+        self.assertContains(
+            response,
+            '<meta name="description" content="A longer organization description" />',
+        )
+
+    def test_templates_organization_detail_meta_description_excerpt_max_length(self):
+        """
+        The organization meta description should be cut if it exceeds more than 160 caracters
+        """
+        organization = OrganizationFactory()
+        page = organization.extended_object
+        placeholder_value = (
+            "Long description that describes the page with a summary. "
+            "Long description that describes the page with a summary. "
+            "Long description that describes the page with a summary. "
+        )
+
+        # Add an excerpt to the organization
+        placeholder = organization.extended_object.placeholders.get(slot="description")
+        add_plugin(
+            language="en",
+            placeholder=placeholder,
+            plugin_type="PlainTextPlugin",
+            body=placeholder_value,
+        )
+        page.publish("en")
+
+        url = organization.extended_object.get_absolute_url()
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+        cut = placeholder_value[0:160]
+        self.assertContains(
+            response,
+            f'<meta name="description" content="{cut}" />',
+        )
+
+    def test_templates_organization_detail_meta_description_empty(self):
+        """
+        The organization meta description should not be present if neither the meta_description
+        field on the page, nor the excerpt placeholder are filled
+        """
+        organization = OrganizationFactory()
+        page = organization.extended_object
+        page.publish("en")
+
+        url = organization.extended_object.get_absolute_url()
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+        self.assertNotContains(
+            response,
+            '<meta name="description"',
+        )
diff --git a/tests/apps/courses/test_templates_person_detail.py b/tests/apps/courses/test_templates_person_detail.py
--- a/tests/apps/courses/test_templates_person_detail.py
+++ b/tests/apps/courses/test_templates_person_detail.py
@@ -472,3 +472,102 @@ def test_templates_person_detail_cms_published_content_opengraph(self):
         self.assertContains(
             response, '<meta property="og:image:height" content="200" />'
         )
+
+    def test_templates_person_detail_meta_description(self):
+        """
+        The person meta description should show meta_description placeholder if defined
+        """
+        person = PersonFactory()
+        page = person.extended_object
+
+        title_obj = page.get_title_obj(language="en")
+        title_obj.meta_description = "A custom description of the person"
+        title_obj.save()
+
+        page.publish("en")
+
+        url = person.extended_object.get_absolute_url()
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+        self.assertContains(
+            response,
+            '<meta name="description" content="A custom description of the person" />',
+        )
+
+    def test_templates_person_detail_meta_description_bio(self):
+        """
+        The person meta description should show the bio if no meta_description is
+        specified
+        """
+        person = PersonFactory()
+        page = person.extended_object
+
+        # Add a bio to a person
+        placeholder = person.extended_object.placeholders.get(slot="bio")
+        add_plugin(
+            language="en",
+            placeholder=placeholder,
+            plugin_type="PlainTextPlugin",
+            body="A biographic description of the person",
+        )
+        page.publish("en")
+
+        url = person.extended_object.get_absolute_url()
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+        self.assertContains(
+            response,
+            '<meta name="description" content="A biographic description of the person" />',
+        )
+
+    def test_templates_person_detail_meta_description_bio_exceeds_max_length(self):
+        """
+        The person meta description should be cut if it exceeds more than 160 caracters
+        """
+        person = PersonFactory()
+        page = person.extended_object
+        placeholder_value = (
+            "Long description that describes the page with a summary. "
+            "Long description that describes the page with a summary. "
+            "Long description that describes the page with a summary. "
+        )
+
+        # Add a bio to a person
+        placeholder = person.extended_object.placeholders.get(slot="bio")
+        add_plugin(
+            language="en",
+            placeholder=placeholder,
+            plugin_type="PlainTextPlugin",
+            body=placeholder_value,
+        )
+        page.publish("en")
+
+        url = person.extended_object.get_absolute_url()
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+        cut = placeholder_value[0:160]
+        self.assertContains(
+            response,
+            f'<meta name="description" content="{cut}" />',
+        )
+
+    def test_templates_person_detail_meta_description_empty(self):
+        """
+        The person meta description should not be present if neither the meta_description field
+        on the page, nor the `bio` placeholder are filled
+        """
+        person = PersonFactory()
+        page = person.extended_object
+        page.publish("en")
+
+        url = person.extended_object.get_absolute_url()
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+        self.assertNotContains(
+            response,
+            '<meta name="description"',
+        )
diff --git a/tests/apps/courses/test_templates_program_detail.py b/tests/apps/courses/test_templates_program_detail.py
--- a/tests/apps/courses/test_templates_program_detail.py
+++ b/tests/apps/courses/test_templates_program_detail.py
@@ -3,6 +3,7 @@
 """
 import re
 
+from cms.api import add_plugin
 from cms.test_utils.testcases import CMSTestCase
 
 from richie.apps.core.factories import UserFactory
@@ -183,3 +184,105 @@ def test_templates_program_detail_cms_published_content_opengraph(self):
         self.assertContains(
             response, '<meta property="og:image:height" content="630" />'
         )
+
+    def test_templates_program_detail_meta_description(self):
+        """
+        The program meta description should show meta_description placeholder if defined
+        """
+        program = ProgramFactory()
+        page = program.extended_object
+
+        title_obj = page.get_title_obj(language="en")
+        title_obj.meta_description = "A custom description of the program"
+        title_obj.save()
+
+        page.publish("en")
+
+        url = program.extended_object.get_absolute_url()
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+        self.assertContains(
+            response,
+            '<meta name="description" content="A custom description of the program" />',
+        )
+
+    def test_templates_program_detail_meta_description_program_excerpt(self):
+        """
+        The program meta description should show the program_excerpt if no meta_description is
+        specified
+        """
+        program = ProgramFactory()
+        page = program.extended_object
+
+        # Add a excerpt to a program
+        placeholder = program.extended_object.placeholders.get(slot="program_excerpt")
+        add_plugin(
+            language="en",
+            placeholder=placeholder,
+            plugin_type="PlainTextPlugin",
+            body="A program excerpt description",
+        )
+        page.publish("en")
+
+        url = program.extended_object.get_absolute_url()
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+        self.assertContains(
+            response,
+            '<meta name="description" content="A program excerpt description" />',
+        )
+
+    def test_templates_program_detail_meta_description_program_excerpt_exceeds_max_length(
+        self,
+    ):
+        """
+        The program meta description should show the program_excerpt if no meta_description is
+        specified
+        """
+        program = ProgramFactory()
+        page = program.extended_object
+        placeholder_value = (
+            "Long description that describes the page with a summary. "
+            "Long description that describes the page with a summary. "
+            "Long description that describes the page with a summary. "
+        )
+
+        # Add a excerpt to a program
+        placeholder = program.extended_object.placeholders.get(slot="program_excerpt")
+        add_plugin(
+            language="en",
+            placeholder=placeholder,
+            plugin_type="PlainTextPlugin",
+            body=placeholder_value,
+        )
+        page.publish("en")
+
+        url = program.extended_object.get_absolute_url()
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+        cut = placeholder_value[0:160]
+        self.assertContains(
+            response,
+            f'<meta name="description" content="{cut}" />',
+        )
+
+    def test_templates_program_detail_meta_description_empty(self):
+        """
+        The program meta description should not be present if neither the meta_description field
+        on the page, nor the `program_excerpt` placeholder are filled
+        """
+        program = ProgramFactory()
+        page = program.extended_object
+        page.publish("en")
+
+        url = program.extended_object.get_absolute_url()
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+        self.assertNotContains(
+            response,
+            '<meta name="description"',
+        )