Repository: openfun/richie

Issue:
Show only the dates for an archived course run
## Feature Request

**Is your feature request related to a problem or unsupported use case? Please describe.**
The support team worries that some of our courses have more than 10 course runs. They will all display on the course page...

**Describe the solution you'd like**
Don't show the archived course runs with the full details and a button. Instead, add a section to the right side bar called "Archived runs" and list all archived course runs with just a link mentioning their past dates "from Aug. 3, 2018 to Sept. 5 2018".

Same for course runs that are still open but closed for enrollment with a section "On-going runs".
Same for course runs that are not yet open for enrollment with a section "Coming soon".



Discussion:
<img src="https://user-images.githubusercontent.com/1427165/55726024-2c8da380-5a0f-11e9-8e84-baf7e1d91f68.png" width="200">

I'm testing a solution by doing the seperation only on the template:
https://github.com/openfun/richie/compare/course_run_presentation
Does this work or would you rather have the seperation done on the backend ?
I think it works like this... 
Performance will probably be an issue when you scale the number of runs... What we should probably do is subqueries to get archived, opened, coming etc. sessions for a course via a db filter instead of looping on all the runs and going through the algorithm for each of them to determine its state... (like archived course runs are runs for which end_date > now).

I'll start sorting it in the backend. I was indeed worried about performance issues.
I'll let you know when i'll have some code for you to look at.
New code. it will need some test. But so far, it looks good to me.
Have a look let me know what you think. Am i on the right track?
https://github.com/openfun/richie/compare/course_run_presentation

Resolving patch:
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
@@ -40,7 +40,7 @@ class CourseState(Mapping):
     STATE_CALLS_TO_ACTION = (
         _("enroll now"),
         _("enroll now"),
-        _("see details"),
+        None,
         None,
         None,
         None,
@@ -398,6 +398,16 @@ def get_course_runs_for_language(self, language=None):
 
         return course_runs
 
+    def get_course_runs_dict(self):
+        """Returns a dict of course runs grouped by their state."""
+        course_runs_dict = {
+            i: [] for i in range(len(CourseState.STATE_CALLS_TO_ACTION))
+        }
+        for run in self.get_course_runs_for_language():
+            course_runs_dict[run.state["priority"]].append(run)
+
+        return dict(course_runs_dict)
+
     @property
     def state(self):
         """


Test patch:
diff --git a/tests/apps/courses/test_models_course_run.py b/tests/apps/courses/test_models_course_run.py
--- a/tests/apps/courses/test_models_course_run.py
+++ b/tests/apps/courses/test_models_course_run.py
@@ -351,7 +351,7 @@ def test_models_course_run_state_coming(self):
             {
                 "priority": 2,
                 "text": "starting on",
-                "call_to_action": "see details",
+                "call_to_action": None,
                 "datetime": self.now + timedelta(hours=3),
             },
         )
diff --git a/tests/apps/courses/test_models_course_state.py b/tests/apps/courses/test_models_course_state.py
--- a/tests/apps/courses/test_models_course_state.py
+++ b/tests/apps/courses/test_models_course_state.py
@@ -27,6 +27,15 @@ def setUp(self):
         super().setUp()
         self.now = timezone.now()
 
+    def create_run_ongoing_open(self, course):
+        """Create an on-going course run that is open for enrollment."""
+        return CourseRunFactory(
+            page_parent=course.extended_object,
+            start=self.now - timedelta(hours=1),
+            end=self.now + timedelta(hours=2),
+            enrollment_end=self.now + timedelta(hours=1),
+        )
+
     def create_run_ongoing_closed(self, course):
         """Create an on-going course run that is closed for enrollment."""
         return CourseRunFactory(
@@ -159,12 +168,7 @@ def test_models_course_state_ongoing_open(self):
         Confirm course state when there is an on-going course run open for enrollment.
         """
         course = CourseFactory()
-        course_run = CourseRunFactory(
-            page_parent=course.extended_object,
-            start=self.now - timedelta(hours=1),
-            end=self.now + timedelta(hours=2),
-            enrollment_end=self.now + timedelta(hours=1),
-        )
+        course_run = self.create_run_ongoing_open(course)
         with self.assertNumQueries(2):
             state = course.state
         expected_state = CourseState(0, course_run.enrollment_end)
diff --git a/tests/apps/courses/test_templates_course_detail.py b/tests/apps/courses/test_templates_course_detail.py
--- a/tests/apps/courses/test_templates_course_detail.py
+++ b/tests/apps/courses/test_templates_course_detail.py
@@ -2,10 +2,11 @@
 End-to-end tests for the course detail view
 """
 import re
-from unittest import mock
+from datetime import timedelta
 
-from django.utils import timezone
+from django.utils import dateformat, timezone
 
+import pytz
 from cms.test_utils.testcases import CMSTestCase
 
 from richie.apps.core.factories import UserFactory
@@ -15,7 +16,6 @@
     CourseRunFactory,
     OrganizationFactory,
 )
-from richie.apps.courses.models import CourseRun, CourseState
 
 
 class CourseCMSTestCase(CMSTestCase):
@@ -42,8 +42,15 @@ def test_templates_course_detail_cms_published_content(self):
             fill_icons=icons,
         )
         page = course.extended_object
+        # Create 2 ongoing open course runs
+        now = timezone.now()
         course_run1, _course_run2 = CourseRunFactory.create_batch(
-            2, page_parent=course.extended_object, languages=["en", "fr"]
+            2,
+            page_parent=course.extended_object,
+            start=now - timedelta(hours=1),
+            end=now + timedelta(hours=2),
+            enrollment_end=now + timedelta(hours=1),
+            languages=["en", "fr"],
         )
         self.assertFalse(course_run1.extended_object.publish("en"))
 
@@ -163,8 +170,14 @@ def test_templates_course_detail_cms_draft_content(self):
             fill_categories=categories,
         )
         page = course.extended_object
+        now = timezone.now()
         course_run1, _course_run2 = CourseRunFactory.create_batch(
-            2, page_parent=course.extended_object, languages=["en", "fr"]
+            2,
+            page_parent=course.extended_object,
+            start=now - timedelta(hours=1),
+            end=now + timedelta(hours=2),
+            enrollment_end=now + timedelta(hours=1),
+            languages=["en", "fr"],
         )
 
         # Publish only 1 of the course runs
@@ -313,66 +326,305 @@ def test_templates_course_detail_no_index(self):
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, '<meta name="robots" content="noindex">')
 
-    def prepare_to_test_state(self, state):
+    def test_templates_course_detail_organization_main_logo(self):
+        """The main organization logo should be present on the page with a link."""
+        user = UserFactory(is_staff=True, is_superuser=True)
+        self.client.login(username=user.username, password="password")
+
+        organizations = OrganizationFactory.create_batch(
+            2, fill_logo=True, should_publish=True
+        )
+        course = CourseFactory(fill_organizations=organizations)
+
+        response = self.client.get(course.extended_object.get_absolute_url())
+
+        self.assertEqual(response.status_code, 200)
+        pattern = (
+            r'<a href="{url:s}" title="{title:s}" class="course-detail__aside__main-org-logo">'
+            r'<img src="/media/filer_public_thumbnails/filer_public/.*logo\.jpg__200x113'
+        ).format(
+            url=organizations[0].extended_object.get_absolute_url(),
+            title=organizations[0].extended_object.get_title(),
+        )
+        self.assertIsNotNone(re.search(pattern, str(response.content)))
+
+
+class RunsCourseCMSTestCase(CMSTestCase):
+    """
+    End-to-end test suite to validate the display of course runs on the course detail view.
+    """
+
+    def setUp(self):
+        super().setUp()
+        self.now = timezone.now()
+
+    def create_run_ongoing_open(self, course):
         """
-        Not a test.
-        Create objects and mock to help testing the impact of the state on template rendering.
+        Not a test. Create an on-going course run that is open for enrollment.
         """
-        course = CourseFactory(page_title="my course", should_publish=True)
-        CourseRunFactory(
+        return CourseRunFactory(
+            page_parent=course.extended_object,
+            page_title="my course run",
+            start=self.now - timedelta(hours=1),
+            end=self.now + timedelta(hours=2),
+            enrollment_start=self.now - timedelta(hours=1),
+            enrollment_end=self.now + timedelta(hours=1),
+            should_publish=True,
+        )
+
+    def create_run_future_open(self, course):
+        """
+        Not a test. Create a course run in the future and open for enrollment.
+        """
+        return CourseRunFactory(
+            page_parent=course.extended_object,
+            page_title="my course run",
+            start=self.now + timedelta(hours=1),
+            enrollment_start=self.now - timedelta(hours=1),
+            enrollment_end=self.now + timedelta(hours=1),
+            should_publish=True,
+        )
+
+    def create_run_future_not_yet_open(self, course):
+        """
+        Not a test. Create a course run in the future and not yet open for enrollment.
+        """
+        return CourseRunFactory(
+            page_parent=course.extended_object,
+            page_title="my course run",
+            start=self.now + timedelta(hours=2),
+            enrollment_start=self.now + timedelta(hours=1),
+            should_publish=True,
+        )
+
+    def create_run_future_closed(self, course):
+        """
+        Not a test. Create a course run in the future and already closed for enrollment.
+        """
+        return CourseRunFactory(
             page_parent=course.extended_object,
             page_title="my course run",
+            start=self.now + timedelta(hours=1),
+            enrollment_start=self.now - timedelta(hours=2),
+            enrollment_end=self.now - timedelta(hours=1),
             should_publish=True,
         )
 
+    def create_run_ongoing_closed(self, course):
+        """
+        Not a test. Create an on-going course run that is closed for enrollment.
+        """
+        return CourseRunFactory(
+            page_parent=course.extended_object,
+            page_title="my course run",
+            start=self.now - timedelta(hours=1),
+            end=self.now + timedelta(hours=1),
+            enrollment_end=self.now,
+            should_publish=True,
+        )
+
+    def create_run_archived(self, course):
+        """
+        Not a test. Create an archived course run.
+        """
+        return CourseRunFactory(
+            page_parent=course.extended_object,
+            page_title="my course run",
+            start=self.now - timedelta(hours=1),
+            end=self.now,
+            should_publish=True,
+        )
+
+    def test_templates_course_detail_runs_ongoing_open(self):
+        """
+        Priority 0: a course run open and on-going should always show up.
+        """
+        course = CourseFactory(page_title="my course", should_publish=True)
+        self.create_run_ongoing_open(course)
+
         url = course.extended_object.get_absolute_url()
-        with mock.patch.object(
-            CourseRun, "state", new_callable=mock.PropertyMock, return_value=state
-        ):
-            response = self.client.get(url)
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+        self.assertNotContains(response, "No open course runs")
+        self.assertContains(
+            response,
+            '<a href="/en/my-course/my-course-run/">Enroll now</a>',
+            html=True,
+        )
+        self.assertNotContains(
+            response, '<h3 class="course-detail__aside__runs__block__title">'
+        )
 
+    def test_templates_course_detail_runs_future_open(self):
+        """
+        Priority 1: an upcoming open course run should show in a separate section.
+        """
+        course = CourseFactory(page_title="my course", should_publish=True)
+        self.create_run_future_open(course)
+
+        url = course.extended_object.get_absolute_url()
+        response = self.client.get(url)
         self.assertEqual(response.status_code, 200)
-        return response
 
-    def test_templates_course_detail_state_with_cta(self):
-        """A course run in a state with a call to action should include a link and the CTA."""
-        response = self.prepare_to_test_state(CourseState(0, timezone.now()))
+        self.assertNotContains(response, "No open course runs")
         self.assertContains(
             response,
-            '<a class="course-detail__aside__runs__block__cta" '
-            'href="/en/my-course/my-course-run/">Enroll now</a>',
+            '<a href="/en/my-course/my-course-run/">Enroll now</a>',
             html=True,
         )
+        self.assertNotContains(
+            response, '<h3 class="course-detail__aside__runs__block__title">'
+        )
 
-    def test_templates_course_detail_state_without_cta(self):
-        """A course run in a state without a call to action should include a state button."""
-        response = self.prepare_to_test_state(CourseState(6))
+    @timezone.override(pytz.utc)
+    def test_templates_course_detail_runs_future_not_yet_open(self):
+        """
+        Priority 2: a future not yet open course run should show in a separate section.
+        """
+        course = CourseFactory(page_title="my course", should_publish=True)
+        course_run = self.create_run_future_not_yet_open(course)
+
+        url = course.extended_object.get_absolute_url()
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+        self.assertContains(response, "No open course runs")
+        self.assertContains(
+            response,
+            '<h3 class="course-detail__aside__runs__block__title">Upcoming</h3>',
+            html=True,
+        )
         self.assertContains(
             response,
-            '<a class="course-detail__aside__runs__block__cta '
-            'course-detail__aside__runs__block__cta--projected" '
-            'href="/en/my-course/my-course-run/">To be scheduled</a>',
+            '<ul class="course-detail__aside__runs__block__list">'
+            '<li><a href="/en/my-course/my-course-run/">'
+            "My course run, from {:s} to {:s}</a></li></ul>".format(
+                dateformat.format(course_run.start, "N j, Y"),
+                dateformat.format(course_run.end, "N j, Y"),
+            ),
             html=True,
         )
 
-    def test_templates_course_detail_organization_main_logo(self):
-        """The main organization logo should be present on the page with a link."""
-        user = UserFactory(is_staff=True, is_superuser=True)
-        self.client.login(username=user.username, password="password")
+    @timezone.override(pytz.utc)
+    def test_templates_course_detail_runs_future_closed(self):
+        """
+        Priority 3: a future and closed course run should show in a separate section.
+        """
+        course = CourseFactory(page_title="my course", should_publish=True)
+        course_run = self.create_run_future_closed(course)
 
-        organizations = OrganizationFactory.create_batch(
-            2, fill_logo=True, should_publish=True
+        url = course.extended_object.get_absolute_url()
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+        self.assertContains(response, "No open course runs")
+        self.assertContains(
+            response,
+            '<h3 class="course-detail__aside__runs__block__title">Ongoing</h3>',
+            html=True,
+        )
+        self.assertContains(
+            response,
+            '<ul class="course-detail__aside__runs__block__list">'
+            '<li><a href="/en/my-course/my-course-run/">'
+            "My course run, from {:s} to {:s}</a></li></ul>".format(
+                dateformat.format(course_run.start, "N j, Y"),
+                dateformat.format(course_run.end, "N j, Y"),
+            ),
+            html=True,
         )
-        course = CourseFactory(fill_organizations=organizations)
 
-        response = self.client.get(course.extended_object.get_absolute_url())
+    @timezone.override(pytz.utc)
+    def test_templates_course_detail_runs_ongoing_closed(self):
+        """
+        Priority 4: an ongoing and closed course run should show in a separate section.
+        """
+        course = CourseFactory(page_title="my course", should_publish=True)
+        course_run = self.create_run_ongoing_closed(course)
 
+        url = course.extended_object.get_absolute_url()
+        response = self.client.get(url)
         self.assertEqual(response.status_code, 200)
-        pattern = (
-            r'<a href="{url:s}" title="{title:s}" class="course-detail__aside__main-org-logo">'
-            r'<img src="/media/filer_public_thumbnails/filer_public/.*logo\.jpg__200x113'
-        ).format(
-            url=organizations[0].extended_object.get_absolute_url(),
-            title=organizations[0].extended_object.get_title(),
+
+        self.assertContains(response, "No open course runs")
+        self.assertContains(
+            response,
+            '<h3 class="course-detail__aside__runs__block__title">Ongoing</h3>',
+            html=True,
+        )
+        self.assertContains(
+            response,
+            '<ul class="course-detail__aside__runs__block__list">'
+            '<li><a href="/en/my-course/my-course-run/">'
+            "My course run, from {:s} to {:s}</a></li></ul>".format(
+                dateformat.format(course_run.start, "N j, Y"),
+                dateformat.format(course_run.end, "N j, Y"),
+            ),
+            html=True,
+        )
+
+    @timezone.override(pytz.utc)
+    def test_templates_course_detail_runs_archived(self):
+        """
+        Priority 5: an archived course run should show in a separate section.
+        """
+        course = CourseFactory(page_title="my course", should_publish=True)
+        course_run = self.create_run_archived(course)
+
+        url = course.extended_object.get_absolute_url()
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+        self.assertContains(response, "No open course runs")
+        self.assertContains(
+            response,
+            '<h3 class="course-detail__aside__runs__block__title">Archived</h3>',
+            html=True,
+        )
+        self.assertContains(
+            response,
+            '<ul class="course-detail__aside__runs__block__list">'
+            '<li><a href="/en/my-course/my-course-run/">'
+            "My course run, from {:s} to {:s}</a></li></ul>".format(
+                dateformat.format(course_run.start, "N j, Y"),
+                dateformat.format(course_run.end, "N j, Y"),
+            ),
+            html=True,
+        )
+
+    def test_templates_course_detail_runs_to_be_scheduled(self):
+        """
+        Priority 6: a course run with no date is only visible to staff users.
+        """
+        course = CourseFactory(page_title="my course", should_publish=True)
+        CourseRunFactory(
+            page_parent=course.extended_object,
+            page_title="my course run",
+            start=None,
+            should_publish=True,
+        )
+
+        # Anonymous users should not see the course run
+        url = course.extended_object.get_absolute_url()
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+        # Staff users should see the course run
+        user = UserFactory(is_staff=True, is_superuser=True)
+        self.client.login(username=user.username, password="password")
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+        self.assertContains(response, "No open course runs")
+        self.assertContains(
+            response,
+            '<h3 class="course-detail__aside__runs__block__title">To be scheduled</h3>',
+            html=True,
+        )
+        self.assertContains(
+            response,
+            '<ul class="course-detail__aside__runs__block__list">'
+            '<li><a href="/en/my-course/my-course-run/">My course run</a></li></ul>',
+            html=True,
         )
-        self.assertIsNotNone(re.search(pattern, str(response.content)))
diff --git a/tests/apps/search/test_query_courses.py b/tests/apps/search/test_query_courses.py
--- a/tests/apps/search/test_query_courses.py
+++ b/tests/apps/search/test_query_courses.py
@@ -605,7 +605,7 @@ def test_query_courses_match_all_grouped_course_runs(self, *_):
                     "text": "starting on",
                 },
                 {
-                    "call_to_action": "see details",
+                    "call_to_action": None,
                     "datetime": COURSE_RUNS["D"]["start"]
                     .isoformat()
                     .replace("+00:00", "Z"),
@@ -804,7 +804,7 @@ def test_query_courses_course_runs_filter_language_facets(self, *_):
                     "text": "closing on",
                 },
                 {
-                    "call_to_action": "see details",
+                    "call_to_action": None,
                     "datetime": COURSE_RUNS["D"]["start"]
                     .isoformat()
                     .replace("+00:00", "Z"),