From f28894e1ce3001b6488869d642d696c11be4d0ff Mon Sep 17 00:00:00 2001 From: Sandro Costa Date: Wed, 11 Dec 2024 10:56:21 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(programs)=20add=20new=20fields=20and?= =?UTF-8?q?=20sections=20to=20programs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add effort, duration, price, team, objectives and other optional fields. --- CHANGELOG.md | 1 + src/frontend/scss/colors/_theme.scss | 2 + .../courses/cms/_program_detail.scss | 74 +++ src/frontend/scss/settings/_variables.scss | 2 + .../apps/core/templates/richie/icons.html | 8 + src/richie/apps/courses/admin.py | 19 + src/richie/apps/courses/cms_toolbars.py | 11 +- src/richie/apps/courses/factories.py | 98 +++ ...n_program_effort_program_price_and_more.py | 275 ++++++++ src/richie/apps/courses/models/program.py | 93 ++- src/richie/apps/courses/settings/__init__.py | 33 + .../templates/courses/cms/program_detail.html | 177 +++++- .../apps/courses/templatetags/extra_tags.py | 14 + src/richie/apps/demo/defaults.py | 3 + .../management/commands/create_demo_site.py | 9 + tests/apps/courses/test_models_program.py | 599 +++++++++++++++++- ...tetags_extra_tags_course_programs_count.py | 53 ++ 17 files changed, 1448 insertions(+), 23 deletions(-) create mode 100644 src/richie/apps/courses/migrations/0036_program_duration_program_effort_program_price_and_more.py create mode 100644 tests/apps/courses/test_templatetags_extra_tags_course_programs_count.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b3cd48d3b..80add17306 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Versioning](https://semver.org/spec/v2.0.0.html). - Add Additional Information section for a category and use them in a course page - Added new page extension `MainMenuEntry` +- Update Programs page with new fields ### Fixed diff --git a/src/frontend/scss/colors/_theme.scss b/src/frontend/scss/colors/_theme.scss index 8088b1e524..543d0d661c 100644 --- a/src/frontend/scss/colors/_theme.scss +++ b/src/frontend/scss/colors/_theme.scss @@ -483,6 +483,8 @@ $r-theme: ( ), program-detail: ( cover-empty-background: r-color('smoke'), + checkmark-list-decoration: url('../../richie/images/components/checkmark.svg'), + checkmark-list-decoration-color: r-color('indianred3'), ), registered-credit-card: ( title-color: r-color('charcoal'), diff --git a/src/frontend/scss/components/templates/courses/cms/_program_detail.scss b/src/frontend/scss/components/templates/courses/cms/_program_detail.scss index 6766768ee7..88f788e40b 100644 --- a/src/frontend/scss/components/templates/courses/cms/_program_detail.scss +++ b/src/frontend/scss/components/templates/courses/cms/_program_detail.scss @@ -2,6 +2,7 @@ // .program-detail { + $detail-selector: &; margin: 0 auto; padding: 0; @@ -54,6 +55,79 @@ padding-right: $grid-gutter-width; } + &__objectives { + @if r-theme-val(program-detail, checkmark-list-decoration) { + ul { + padding-left: 0.3rem; + list-style-type: none; + + li { + position: relative; + margin-top: 0.5rem; + font-size: 1rem; + padding-left: 1.5rem; + + &::before { + content: ''; + display: block; + position: absolute; + top: 0.2rem; + left: 0; + width: 0.8rem; + height: 0.8rem; + background-repeat: no-repeat; + background-color: r-theme-val(program-detail, checkmark-list-decoration-color); + -webkit-mask: r-theme-val(program-detail, checkmark-list-decoration); + mask: r-theme-val(program-detail, checkmark-list-decoration); + -webkit-mask-size: cover; + mask-size: cover; + } + } + } + } + } + + @if $r-program-aside { + &__content { + @include media-breakpoint-up(lg) { + //@include sv-flex(1, 0, #{100% - $r-program-aside}); + padding-right: 3rem; + } + } + + &__aside { + padding: 1rem 0; + + @include media-breakpoint-up(lg) { + @include sv-flex(1, 0, $r-program-aside); + padding: 3rem 1rem; + } + + #{$detail-selector}__row { + margin-bottom: 1.5rem; + } + + #{$detail-selector}__title { + @include font-size($h3-font-size); + padding-bottom: 1rem; + @if r-theme-val(course-detail, aside-title-border) { + border-bottom: $onepixel solid r-theme-val(course-detail, aside-title-border); + } + } + } + + &__main { + @include media-breakpoint-up(lg) { + @include sv-flex(1, 0, calc(100% - #{$r-program-subheader-aside})); + display: flex; + justify-content: flex-start; + align-content: flex-start; + align-items: flex-start; + flex-wrap: wrap; + } + } + } + &__courses { @include make-col-ready(); @include make-col(12); diff --git a/src/frontend/scss/settings/_variables.scss b/src/frontend/scss/settings/_variables.scss index 60d8572c05..3cd6b4cfd1 100644 --- a/src/frontend/scss/settings/_variables.scss +++ b/src/frontend/scss/settings/_variables.scss @@ -152,11 +152,13 @@ $r-footer-logo-width-lg: 11.875rem !default; // full width like common blocks. Usefull if you plan to remove course run from // template $r-course-aside: 35% !default; +$r-program-aside: 25% !default; // Subheader aside column width in course detail. Unlike, $r-course-aside this // variable cannot be null. Otherwise to homegenize layout, it should have the // same value than $r-course-aside. $r-course-subheader-aside: 35% !default; +$r-program-subheader-aside: 25% !default; // Course search page shared variables $r-search-filters-gutter: 0.2rem !default; diff --git a/src/richie/apps/core/templates/richie/icons.html b/src/richie/apps/core/templates/richie/icons.html index bf725bbb2f..556df024e2 100644 --- a/src/richie/apps/core/templates/richie/icons.html +++ b/src/richie/apps/core/templates/richie/icons.html @@ -192,6 +192,14 @@ + + + + + + + + diff --git a/src/richie/apps/courses/admin.py b/src/richie/apps/courses/admin.py index 4b7c3029ba..76748bd547 100644 --- a/src/richie/apps/courses/admin.py +++ b/src/richie/apps/courses/admin.py @@ -351,6 +351,24 @@ def title(self, obj): return obj.extended_object.get_title() +class ProgramAdmin(PageExtensionAdmin): + """Admin class for the Program model""" + + list_display = ["title"] + frontend_editable_fields = ( + "duration", + "effort", + "price", + ) + + # pylint: disable=no-self-use + def title(self, obj): + """ + Display the course title as a read-only field from the related page + """ + return obj.extended_object.get_title() + + class LicenceAdmin(TranslatableAdmin): """ Admin class for the Licence model @@ -368,3 +386,4 @@ class LicenceAdmin(TranslatableAdmin): admin.site.register(models.Organization, OrganizationAdmin) admin.site.register(models.PageRole, PageRoleAdmin) admin.site.register(models.Person, PersonAdmin) +admin.site.register(models.Program, ProgramAdmin) diff --git a/src/richie/apps/courses/cms_toolbars.py b/src/richie/apps/courses/cms_toolbars.py index 1797282c18..ff73acbc4f 100644 --- a/src/richie/apps/courses/cms_toolbars.py +++ b/src/richie/apps/courses/cms_toolbars.py @@ -13,7 +13,7 @@ from cms.utils.urlutils import admin_reverse from .defaults import PAGE_EXTENSION_TOOLBAR_ITEM_POSITION -from .models import Category, Course, MainMenuEntry, Organization, Person +from .models import Category, Course, MainMenuEntry, Organization, Person, Program class BaseExtensionToolbar(ExtensionToolbar): @@ -181,3 +181,12 @@ def populate(self): disabled=not self.toolbar.edit_mode_active, position=PAGE_EXTENSION_TOOLBAR_ITEM_POSITION, ) + + +@toolbar_pool.register +class ProgramExtensionToolbar(BaseExtensionToolbar): + """ + This extension class customizes the toolbar for the program page extension + """ + + model = Program diff --git a/src/richie/apps/courses/factories.py b/src/richie/apps/courses/factories.py index d428151442..851eb206ed 100644 --- a/src/richie/apps/courses/factories.py +++ b/src/richie/apps/courses/factories.py @@ -26,6 +26,8 @@ from . import defaults, models from .defaults import ROLE_CHOICES +# pylint: disable=too-many-lines + VideoSample = namedtuple("VideoSample", ["label", "image", "url"]) VIDEO_SAMPLE_LINKS = ( @@ -838,6 +840,87 @@ class Meta: # fields concerning the related page page_template = models.Program.PAGE["template"] + # pylint: disable=no-self-use + @factory.lazy_attribute + def duration(self): + """Generate a random duration for the course between 1 day and 10 months.""" + return [ + random.randint(1, 10), # nosec + random.choice(list(defaults.TIME_UNITS.keys())[2:]), # nosec + ] + + # pylint: disable=no-self-use + @factory.lazy_attribute + def effort(self): + """Generate a random effort for the course between 1 minute and 500 hours.""" + return [ + random.randint(1, 500), # nosec + random.choice(list(defaults.EFFORT_UNITS.keys())), # nosec + ] + + # pylint: disable=no-self-use + @factory.lazy_attribute + def price(self): + """Generate a random price for the program between 1 and 100 euros.""" + return random.randint(1, 500) # nosec + + @factory.post_generation + # pylint: disable=unused-argument + def fill_teaser(self, create, extracted, **kwargs): + """ + Add a video plugin for teaser with a random url + """ + + if create and extracted: + for language in self.extended_object.get_languages(): + placeholder = self.extended_object.placeholders.get( + slot="program_teaser" + ) + + video_sample = ( + extracted + if isinstance(extracted, VideoSample) + else random.choice(VIDEO_SAMPLE_LINKS) # nosec + ) + + add_plugin( + language=language, + placeholder=placeholder, + plugin_type="VideoPlayerPlugin", + label=video_sample.label, + embed_link=video_sample.url, + ) + + @factory.post_generation + # pylint: disable=unused-argument + def fill_categories(self, create, extracted, **kwargs): + """Add categories plugin to organization from a given list of category instances.""" + if create and extracted: + associate_extensions( + self, extracted, "program_categories", "CategoryPlugin" + ) + + @factory.post_generation + # pylint: disable=unused-argument + def fill_team(self, create, extracted, **kwargs): + """ + Add person plugin for course team from given person instance list + """ + if create and extracted: + associate_extensions(self, extracted, "program_team", "PersonPlugin") + + @factory.post_generation + # pylint: disable=unused-argument + def fill_organizations(self, create, extracted, **kwargs): + """ + Add organizations plugin to course from a given list of organization instances. + """ + + if create and extracted: + associate_extensions( + self, extracted, "program_organizations", "OrganizationPlugin" + ) + @factory.post_generation # pylint: disable=unused-argument def fill_body(self, create, extracted, **kwargs): @@ -853,6 +936,21 @@ def fill_body(self, create, extracted, **kwargs): plugin_type="TextPlugin", ) + @factory.post_generation + # pylint: disable=unused-argument + def fill_objectives(self, create, extracted, **kwargs): + """ + Add simple text plugin for objectives with a random number of paragraphs + """ + if create and extracted: + create_text_plugin( + self.extended_object, + "program_objectives", + nb_paragraphs=random.randint(2, 4), # nosec + languages=self.extended_object.get_languages(), + plugin_type="TextPlugin", + ) + @factory.post_generation # pylint: disable=unused-argument def fill_courses(self, create, extracted, **kwargs): diff --git a/src/richie/apps/courses/migrations/0036_program_duration_program_effort_program_price_and_more.py b/src/richie/apps/courses/migrations/0036_program_duration_program_effort_program_price_and_more.py new file mode 100644 index 0000000000..de9910109f --- /dev/null +++ b/src/richie/apps/courses/migrations/0036_program_duration_program_effort_program_price_and_more.py @@ -0,0 +1,275 @@ +# Generated by Django 4.2.17 on 2024-12-10 11:25 + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import richie.apps.core.fields.duration +import richie.apps.core.fields.multiselect + + +class Migration(migrations.Migration): + + dependencies = [ + ("cms", "0022_auto_20180620_1551"), + ("courses", "0035_add_menuentry"), + ] + + operations = [ + migrations.AddField( + model_name="program", + name="duration", + field=richie.apps.core.fields.duration.CompositeDurationField( + blank=True, + default_unit="hour", + help_text="The program time range.", + max_length=80, + null=True, + time_units={ + "day": ("day", "days"), + "hour": ("hour", "hours"), + "minute": ("minute", "minutes"), + "month": ("month", "months"), + "week": ("week", "weeks"), + }, + ), + ), + migrations.AddField( + model_name="program", + name="effort", + field=richie.apps.core.fields.duration.CompositeDurationField( + blank=True, + default_unit="hour", + help_text="Total amount of time to complete this program.", + max_length=80, + null=True, + time_units={"hour": ("hour", "hours"), "minute": ("minute", "minutes")}, + ), + ), + migrations.AddField( + model_name="program", + name="price", + field=models.DecimalField( + blank=True, + decimal_places=2, + default=0, + help_text="The price of the program.", + max_digits=10, + null=True, + validators=[django.core.validators.MinValueValidator(0)], + verbose_name="price", + ), + ), + migrations.AlterField( + model_name="blogpostpluginmodel", + name="cmsplugin_ptr", + field=models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + related_name="%(app_label)s_%(class)s", + serialize=False, + to="cms.cmsplugin", + ), + ), + migrations.AlterField( + model_name="categorypluginmodel", + name="cmsplugin_ptr", + field=models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + related_name="%(app_label)s_%(class)s", + serialize=False, + to="cms.cmsplugin", + ), + ), + migrations.AlterField( + model_name="coursepluginmodel", + name="cmsplugin_ptr", + field=models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + related_name="%(app_label)s_%(class)s", + serialize=False, + to="cms.cmsplugin", + ), + ), + migrations.AlterField( + model_name="courserun", + name="languages", + field=richie.apps.core.fields.multiselect.MultiSelectField( + choices=[ + ("af", "Afrikaans"), + ("ar", "Arabic"), + ("ar-dz", "Algerian Arabic"), + ("ast", "Asturian"), + ("az", "Azerbaijani"), + ("bg", "Bulgarian"), + ("be", "Belarusian"), + ("bn", "Bengali"), + ("br", "Breton"), + ("bs", "Bosnian"), + ("ca", "Catalan"), + ("ckb", "Central Kurdish (Sorani)"), + ("cs", "Czech"), + ("cy", "Welsh"), + ("da", "Danish"), + ("de", "German"), + ("dsb", "Lower Sorbian"), + ("el", "Greek"), + ("en", "English"), + ("en-au", "Australian English"), + ("en-gb", "British English"), + ("eo", "Esperanto"), + ("es", "Spanish"), + ("es-ar", "Argentinian Spanish"), + ("es-co", "Colombian Spanish"), + ("es-mx", "Mexican Spanish"), + ("es-ni", "Nicaraguan Spanish"), + ("es-ve", "Venezuelan Spanish"), + ("et", "Estonian"), + ("eu", "Basque"), + ("fa", "Persian"), + ("fi", "Finnish"), + ("fr", "French"), + ("fy", "Frisian"), + ("ga", "Irish"), + ("gd", "Scottish Gaelic"), + ("gl", "Galician"), + ("he", "Hebrew"), + ("hi", "Hindi"), + ("hr", "Croatian"), + ("hsb", "Upper Sorbian"), + ("hu", "Hungarian"), + ("hy", "Armenian"), + ("ia", "Interlingua"), + ("id", "Indonesian"), + ("ig", "Igbo"), + ("io", "Ido"), + ("is", "Icelandic"), + ("it", "Italian"), + ("ja", "Japanese"), + ("ka", "Georgian"), + ("kab", "Kabyle"), + ("kk", "Kazakh"), + ("km", "Khmer"), + ("kn", "Kannada"), + ("ko", "Korean"), + ("ky", "Kyrgyz"), + ("lb", "Luxembourgish"), + ("lt", "Lithuanian"), + ("lv", "Latvian"), + ("mk", "Macedonian"), + ("ml", "Malayalam"), + ("mn", "Mongolian"), + ("mr", "Marathi"), + ("ms", "Malay"), + ("my", "Burmese"), + ("nb", "Norwegian Bokmål"), + ("ne", "Nepali"), + ("nl", "Dutch"), + ("nn", "Norwegian Nynorsk"), + ("os", "Ossetic"), + ("pa", "Punjabi"), + ("pl", "Polish"), + ("pt", "Portuguese"), + ("pt-br", "Brazilian Portuguese"), + ("ro", "Romanian"), + ("ru", "Russian"), + ("sk", "Slovak"), + ("sl", "Slovenian"), + ("sq", "Albanian"), + ("sr", "Serbian"), + ("sr-latn", "Serbian Latin"), + ("sv", "Swedish"), + ("sw", "Swahili"), + ("ta", "Tamil"), + ("te", "Telugu"), + ("tg", "Tajik"), + ("th", "Thai"), + ("tk", "Turkmen"), + ("tr", "Turkish"), + ("tt", "Tatar"), + ("udm", "Udmurt"), + ("uk", "Ukrainian"), + ("ur", "Urdu"), + ("uz", "Uzbek"), + ("vi", "Vietnamese"), + ("zh-hans", "Simplified Chinese"), + ("zh-hant", "Traditional Chinese"), + ], + help_text="The list of languages in which the course content is available.", + max_choices=50, + max_length=255, + ), + ), + migrations.AlterField( + model_name="licencepluginmodel", + name="cmsplugin_ptr", + field=models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + related_name="%(app_label)s_%(class)s", + serialize=False, + to="cms.cmsplugin", + ), + ), + migrations.AlterField( + model_name="organizationpluginmodel", + name="cmsplugin_ptr", + field=models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + related_name="%(app_label)s_%(class)s", + serialize=False, + to="cms.cmsplugin", + ), + ), + migrations.AlterField( + model_name="organizationsbycategorypluginmodel", + name="cmsplugin_ptr", + field=models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + related_name="%(app_label)s_%(class)s", + serialize=False, + to="cms.cmsplugin", + ), + ), + migrations.AlterField( + model_name="personpluginmodel", + name="cmsplugin_ptr", + field=models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + related_name="%(app_label)s_%(class)s", + serialize=False, + to="cms.cmsplugin", + ), + ), + migrations.AlterField( + model_name="programpluginmodel", + name="cmsplugin_ptr", + field=models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + related_name="%(app_label)s_%(class)s", + serialize=False, + to="cms.cmsplugin", + ), + ), + ] diff --git a/src/richie/apps/courses/models/program.py b/src/richie/apps/courses/models/program.py index 60258ed2a3..07040e156c 100644 --- a/src/richie/apps/courses/models/program.py +++ b/src/richie/apps/courses/models/program.py @@ -2,6 +2,7 @@ Declare and configure the models for the program part """ +from django.core.validators import MinValueValidator from django.db import models from django.utils.translation import gettext_lazy as _ @@ -9,8 +10,12 @@ from cms.extensions.extension_pool import extension_pool from cms.models.pluginmodel import CMSPlugin +from ...core.fields.duration import CompositeDurationField from ...core.models import BasePageExtension -from ..defaults import PROGRAMS_PAGE +from .. import defaults +from .category import Category, CategoryPluginModel +from .organization import Organization, OrganizationPluginModel +from .person import Person, PersonPluginModel class Program(BasePageExtension): @@ -18,7 +23,36 @@ class Program(BasePageExtension): The program extension represents and records a program. """ - PAGE = PROGRAMS_PAGE + PAGE = defaults.PROGRAMS_PAGE + + duration = CompositeDurationField( + time_units=defaults.TIME_UNITS, + default_unit=defaults.DEFAULT_TIME_UNIT, + max_length=80, + blank=True, + null=True, + help_text=_("The program time range."), + ) + + effort = CompositeDurationField( + time_units=defaults.EFFORT_UNITS, + default_unit=defaults.DEFAULT_EFFORT_UNIT, + max_length=80, + blank=True, + null=True, + help_text=_("Total amount of time to complete this program."), + ) + + price = models.DecimalField( + _("price"), + max_digits=10, + decimal_places=2, + default=0, + blank=True, + null=True, + help_text=_("The price of the program."), + validators=[MinValueValidator(0)], + ) class Meta: db_table = "richie_program" @@ -32,6 +66,61 @@ def __str__(self): name = self.extended_object.get_title() return f"{model:s}: {name:s}" + @property + def pt_effort(self): + """Return effort as a PT string for schema.org metadata.""" + if not self.effort: + return "" + + (effort, effort_unit) = self.effort + unit_letter = effort_unit[0].upper() + return f"PT{effort:d}{unit_letter:s}" + + @property + def price_with_currency(self): + """Return price with currency for schema.org metadata.""" + if not self.price: + return "" + + return f"€{self.price}" + + def get_categories(self, language=None): + """ + Return the categories linked to the program via a category plugin in any of the + placeholders on the program detail page, ranked by their `path` to respect the + order in the categories tree. + """ + return self.get_direct_related_page_extensions( + Category, CategoryPluginModel, language=language + ) + + def get_organizations(self, language=None): + """ + Return the organizations linked to the course via an organization plugin in any + of the placeholders on the course detail page, ranked by their `path` to respect + the order in the organizations tree. + """ + return self.get_direct_related_page_extensions( + Organization, OrganizationPluginModel, language=language + ) + + def get_persons(self, language=None): + """ + Return the persons linked to the course via a person plugin in any of the + placeholders on the course detail page, ranked by their `path` to respect + the order in the persons tree. + """ + return self.get_direct_related_page_extensions( + Person, PersonPluginModel, language=language + ) + + def save(self, *args, **kwargs): + """ + Enforce validation each time an instance is saved + """ + self.full_clean() + super().save(*args, **kwargs) + class ProgramPluginModel(CMSPlugin): """ diff --git a/src/richie/apps/courses/settings/__init__.py b/src/richie/apps/courses/settings/__init__.py index 7250f85dd4..0e791f4b49 100644 --- a/src/richie/apps/courses/settings/__init__.py +++ b/src/richie/apps/courses/settings/__init__.py @@ -381,25 +381,58 @@ def richie_placeholder_conf(name): "child_classes": {"SectionPlugin": ["CKEditorPlugin"]}, }, # Program page detail + "courses/cms/program_detail.html program_categories": { + "name": _("Categories"), + "plugins": ["CategoryPlugin"], + }, "courses/cms/program_detail.html program_cover": { "name": _("Cover"), "plugins": ["SimplePicturePlugin"], "limits": {"SimplePicturePlugin": 1}, }, + "courses/cms/program_detail.html program_teaser": { + "name": _("Teaser"), + "plugins": ["VideoPlayerPlugin"], + "limits": {"VideoPlayerPlugin": 1}, + }, "courses/cms/program_detail.html program_excerpt": { "name": _("Excerpt"), "plugins": ["PlainTextPlugin"], "limits": {"PlainTextPlugin": 1}, }, + "courses/cms/program_detail.html program_organizations": { + "name": _("Organizations"), + "plugins": ["OrganizationPlugin"], + }, "courses/cms/program_detail.html program_body": { "name": _("Body"), "plugins": ["CKEditorPlugin"], "limits": {"CKEditorPlugin": 1}, }, + "courses/cms/program_detail.html program_objectives": { + "name": _("What you will learn"), + "plugins": ["CKEditorPlugin"], + }, "courses/cms/program_detail.html program_courses": { "name": _("Courses"), "plugins": ["CoursePlugin"], }, + "courses/cms/program_detail.html program_team": { + "name": _("Instructors"), + "plugins": ["PersonPlugin"], + }, + "courses/cms/program_detail.html program_information": { + "name": _("Complementary information"), + "plugins": ["SectionPlugin"], + "parent_classes": { + "CKEditorPlugin": ["SectionPlugin"], + "SimplePicturePlugin": ["SectionPlugin"], + "GlimpsePlugin": ["SectionPlugin"], + }, + "child_classes": { + "SectionPlugin": ["CKEditorPlugin", "SimplePicturePlugin", "GlimpsePlugin"] + }, + }, "courses/cms/program_list.html maincontent": { "name": _("Main content"), "plugins": ["SectionPlugin"], diff --git a/src/richie/apps/courses/templates/courses/cms/program_detail.html b/src/richie/apps/courses/templates/courses/cms/program_detail.html index 2efccef7d5..26e3642b9f 100644 --- a/src/richie/apps/courses/templates/courses/cms/program_detail.html +++ b/src/richie/apps/courses/templates/courses/cms/program_detail.html @@ -31,19 +31,81 @@ {% block subheader_content %}
+
+
+ {% block categories %} + {% if current_page.publisher_is_draft or not current_page|is_empty_placeholder:"program_categories" %} +
+
+ {% with category_variant="badge" is_keywords_property=True %} + {% placeholder "program_categories" or %} + + {% trans "No associated categories" %} + + {% endplaceholder %} + {% endwith %} +
+
+ {% endif %} + {% endblock categories %}

{% render_model current_page "title" %}

-
-{% endblock subheader_content %} +
+ {% placeholder "program_excerpt" or %} +
{% trans "No excerpt content" %}
+ {% endplaceholder %} - -{% block content %}{% spaceless %} -
-
- {% if current_page.publisher_is_draft %} -
- {% placeholder_as_plugins "program_cover" as plugins or %} -

{% trans "Cover" %}

- {% endplaceholder_as_plugins %} +
+ {% with program=current_page.program %} + {% if program.duration or program.effort %} +
+
    + {% if program.duration or current_page.publisher_is_draft %} +
  • + + {% trans "Duration:" %} {{ program.get_duration_display|default:"NA" }} +
  • + {% endif %} + {% if program.effort or current_page.publisher_is_draft %} +
  • + + + {% trans "Effort:" %} {{ program.get_effort_display|default:"NA" }} + +
  • + {% course_programs_count current_page as program_count %} + {% if program_count > 0 %} +
  • + + {% trans "Curriculum:" %} + {% blocktrans count counter=program_count %} {{ counter }} course {% plural %} {{ counter }} courses {% endblocktrans %} +
  • + {% endif %} + {% if program.price %} +
  • + + {% trans "Price:" %} {{ program.price_with_currency }} +
  • + {% endif %} + {% endif %} +
+
+ {% endif %} + {% endwith %} +
+ {% placeholder "program_teaser" or %} + {% get_placeholder_plugins "program_cover" as plugins or %} + {% if current_page.publisher_is_draft %} +

{% trans 'Add a teaser video or add a cover image below and it will be used as teaser image as well.' %}

+ {% endif %} + {% endget_placeholder_plugins %} {% blockplugin plugins.0 %} {% render_model current_page "title" %} alt="{% trans 'program cover image' %}" /> {% endblockplugin %} + {% endplaceholder %}
- {% endif %} +
+ +
+ {% placeholder "program_organizations" or %} +

{% trans "No organizations for this program" %}

+ {% endplaceholder %} +
+ +
+
+{% endblock subheader_content %} -
- {% placeholder "program_excerpt" or %} +{% block content %}{% spaceless %} +
+ + {% block cover %} + {% placeholder_as_plugins "program_cover" as cover_plugins %} + + {% if current_page.publisher_is_draft %} +
+
+

{% trans 'Glimpse cover' %}

+ {% if not cover_plugins %} +

{% trans 'Add an image for program cover on its glimpse.' %}

+ {% endif %} + {% blockplugin cover_plugins.0 %} + {% if instance.picture.default_alt_text %}{{ instance.picture.default_alt_text }}{% else %}{% trans 'course cover image' %}{% endif %} + {% endblockplugin %} +
+
+ {% endif %} + {% endblock cover %} + +
+
+ {% placeholder "program_body" or %}

- {% trans "No excerpt content" %} + {% trans "No body content" %}

{% endplaceholder %}
+
-
- {% placeholder "program_body" or %} + {% if current_page.publisher_is_draft or not current_page|is_empty_placeholder:"program_objectives" %} +
+
+

{% blocktrans context "program_objectives__title" %}What you will learn{% endblocktrans %}

+ {% placeholder "program_objectives" or %}

- {% trans "No body content" %} + {% trans "No program objectives" %}

{% endplaceholder %}
+ {% endif %} {% if current_page.publisher_is_draft or not current_page|is_empty_placeholder:"program_courses" %}
@@ -90,5 +199,35 @@

{% trans "Related courses" %}

{% endif %} + + {% block team %} + {% if current_page.publisher_is_draft or not current_page|is_empty_placeholder:"program_team" %} +
+
+

+ {% blocktrans context "course_detail__title" %}Program instructors{% endblocktrans %} +

+ {% with header_level=3 %} +
+ {% placeholder "program_team" page or %} +

{% trans 'Who are the instructors for this program?' %}

+ {% endplaceholder %} +
+ {% endwith %} +
+
+ {% endif %} + {% endblock team %} + + + {% block information %} + {% if current_page.publisher_is_draft or not current_page|is_empty_placeholder:"program_information" %} +
+
+ {% placeholder "program_information" %} +
+
+ {% endif %} + {% endblock information %}
-{% endspaceless %}{% endblock content %} +{% endspaceless %}{% endblock content %} \ No newline at end of file diff --git a/src/richie/apps/courses/templatetags/extra_tags.py b/src/richie/apps/courses/templatetags/extra_tags.py index cd0cff4e28..46804f8734 100644 --- a/src/richie/apps/courses/templatetags/extra_tags.py +++ b/src/richie/apps/courses/templatetags/extra_tags.py @@ -346,6 +346,20 @@ def course_runs_list_widget_props(context): ) +@register.simple_tag(takes_context=True) +def course_programs_count(context, page: Page): + """ + Return a count of the number of courses in a program page + """ + language = context["LANGUAGE_CODE"] + + return len( + page.get_placeholders() + .get(slot="program_courses") + .get_plugins_list(language=language) + ) + + @register.filter @stringfilter def trim(value): diff --git a/src/richie/apps/demo/defaults.py b/src/richie/apps/demo/defaults.py index bc1930c59c..446727a709 100644 --- a/src/richie/apps/demo/defaults.py +++ b/src/richie/apps/demo/defaults.py @@ -24,6 +24,9 @@ "blogpost_tags": 1, "programs": 6, "programs_courses": 4, + "programs_organizations": 1, + "programs_categories": 1, + "programs_persons": 2, "home_blogposts": 5, "home_courses": 7, "home_organizations": 4, diff --git a/src/richie/apps/demo/management/commands/create_demo_site.py b/src/richie/apps/demo/management/commands/create_demo_site.py index c55e28cd13..b6c9393b6f 100755 --- a/src/richie/apps/demo/management/commands/create_demo_site.py +++ b/src/richie/apps/demo/management/commands/create_demo_site.py @@ -424,6 +424,15 @@ def create_footer_link(**link_info): fill_cover=pick_image("cover"), fill_excerpt=True, fill_body=True, + fill_categories=[ + *random.sample(subjects, defaults.NB_OBJECTS["programs_categories"]) + ], + fill_organizations=[ + *random.sample(organizations, defaults.NB_OBJECTS["programs_organizations"]) + ], + fill_team=[ + *random.sample(persons, defaults.NB_OBJECTS["programs_persons"]) + ], fill_courses=[ *random.sample(courses, defaults.NB_OBJECTS["programs_courses"]) ], diff --git a/tests/apps/courses/test_models_program.py b/tests/apps/courses/test_models_program.py index 82b1b60115..6808f87fc2 100644 --- a/tests/apps/courses/test_models_program.py +++ b/tests/apps/courses/test_models_program.py @@ -2,12 +2,22 @@ Unit tests for the Program model """ +from decimal import Decimal + +from django.core.exceptions import ValidationError from django.test import TestCase +from django.test.client import RequestFactory +from django.test.utils import override_settings +from django.utils import translation -from cms.api import create_page +from cms.api import add_plugin, create_page +from richie.apps.core.factories import PageFactory +from richie.apps.courses import factories from richie.apps.courses.models import Program +# pylint: disable=too-many-public-methods + class ProgramModelsTestCase(TestCase): """ @@ -23,3 +33,590 @@ def test_models_program_str(self): program = Program(extended_object=page) with self.assertNumQueries(1): self.assertEqual(str(program), "Program: My first program") + + # Fields: price + + def test_models_program_field_price_default(self): + """The effort field should default to None.""" + program = Program.objects.create(extended_object=PageFactory()) + self.assertEqual(program.price, Decimal("0.00")) + + def test_models_program_field_price_invalid(self): + """The price should be a positive decimal number.""" + with self.assertRaises(ValidationError) as context: + factories.ProgramFactory(price=-1) + self.assertEqual( + context.exception.messages[0], + "Ensure this value is greater than or equal to 0.", + ) + + def test_models_program_field_price_null(self): + """The price field can be null.""" + program = factories.ProgramFactory(price=None) + self.assertIsNone(program.price) + self.assertEqual(program.price_with_currency, "") + + def test_models_program_field_price_with_currency(self): + """The price with currency should be a string.""" + program = factories.ProgramFactory(price=1) + self.assertEqual(program.price_with_currency, "€1.00") + + # Fields: effort + + def test_models_program_field_effort_null(self): + """The effort field can be null.""" + program = factories.ProgramFactory(effort=None) + self.assertIsNone(program.effort) + self.assertEqual(program.get_effort_display(), "") + + def test_models_program_field_effort_invalid(self): + """An effort should be a pair: number, time unit.""" + with self.assertRaises(ValidationError) as context: + factories.ProgramFactory(effort=[5]) + self.assertEqual( + context.exception.messages[0], + "A composite duration should be a pair: number and time unit.", + ) + + def test_models_program_field_effort_integer(self): + """The first value of the effort pair should be an integer.""" + for value in ["a", "1.0"]: + with self.assertRaises(ValidationError) as context: + factories.ProgramFactory(effort=[value, "hour"]) + self.assertEqual( + context.exception.messages[0], + "A composite duration should be a round number of time units.", + ) + + def test_models_program_field_effort_positive(self): + """The first value should be a positive integer.""" + with self.assertRaises(ValidationError) as context: + factories.ProgramFactory(effort=[-1, "hour"]) + self.assertEqual( + context.exception.messages[0], "A composite duration should be positive." + ) + + def test_models_program_field_effort_invalid_unit(self): + """The second value should be a valid time unit choice.""" + with self.assertRaises(ValidationError) as context: + factories.ProgramFactory(effort=[1, "invalid"]) + self.assertEqual( + context.exception.messages[0], + "invalid is not a valid choice for a time unit.", + ) + + def test_models_program_field_effort_display_singular(self): + """Validate that a value of 1 time unit is displayed as expected.""" + program = factories.ProgramFactory(effort=[1, "hour"]) + self.assertEqual(program.get_effort_display(), "1 hour") + + def test_models_program_field_effort_display_plural(self): + """Validate that a plural number of time units is displayed as expected.""" + program = factories.ProgramFactory(effort=[2, "hour"]) + self.assertEqual(program.get_effort_display(), "2 hours") + + def test_models_program_field_effort_display_request(self): + """ + When used in the `render_model` template tag, it should not break when passed a + request argument (the DjangoCMS frontend editing does it). + """ + program = factories.ProgramFactory(effort=[1, "hour"]) + request = RequestFactory().get("/") + self.assertEqual(program.get_effort_display(request), "1 hour") + + def test_models_program_field_effort_default(self): + """The effort field should default to None.""" + program = Program.objects.create(extended_object=PageFactory()) + self.assertIsNone(program.effort) + + # Fields: duration + + def test_models_program_field_duration_null(self): + """The duration field can be null.""" + program = factories.ProgramFactory(duration=None) + self.assertIsNone(program.duration) + self.assertEqual(program.get_duration_display(), "") + + def test_models_program_field_duration_invalid(self): + """The duration should be a pair: number and unit.""" + with self.assertRaises(ValidationError) as context: + factories.ProgramFactory(duration=5) + self.assertEqual( + context.exception.messages[0], + "A composite duration should be a pair: number and time unit.", + ) + + def test_models_program_field_duration_integer(self): + """The first value of the duration pair should be an integer.""" + for value in ["a", "1.0"]: + with self.assertRaises(ValidationError) as context: + factories.ProgramFactory(duration=[value, "minute"]) + self.assertEqual( + context.exception.messages[0], + "A composite duration should be a round number of time units.", + ) + + def test_models_program_field_duration_positive(self): + """The first value should be a positive integer.""" + with self.assertRaises(ValidationError) as context: + factories.ProgramFactory(duration=[-1, "day"]) + self.assertEqual( + context.exception.messages[0], "A composite duration should be positive." + ) + + def test_models_program_field_duration_invalid_unit(self): + """The second value should be a valid time unit choice.""" + with self.assertRaises(ValidationError) as context: + factories.ProgramFactory(duration=[1, "invalid"]) + self.assertEqual( + context.exception.messages[0], + "invalid is not a valid choice for a time unit.", + ) + + def test_models_program_field_duration_display_singular(self): + """Validate that a value of 1 time unit is displayed as expected.""" + program = factories.ProgramFactory(duration=[1, "day"]) + self.assertEqual(program.get_duration_display(), "1 day") + + def test_models_program_field_duration_display_plural(self): + """Validate that a plural number of time units is displayed as expected.""" + program = factories.ProgramFactory(duration=[2, "day"]) + self.assertEqual(program.get_duration_display(), "2 days") + + def test_models_program_field_duration_display_request(self): + """ + When used in the `render_model` template tag, it should not break when passed a + request argument (the DjangoCMS frontend editing does it). + """ + program = factories.ProgramFactory(duration=[1, "week"]) + request = RequestFactory().get("/") + self.assertEqual(program.get_duration_display(request), "1 week") + + def test_models_program_field_duration_default(self): + """The duration field should default to None.""" + program = Program.objects.create(extended_object=PageFactory()) + self.assertIsNone(program.duration) + + # Organizations + + def test_models_program_get_organizations_empty(self): + """ + For a course not linked to any organzation the method `get_organizations` should + return an empty query. + """ + program = factories.ProgramFactory(should_publish=True) + self.assertFalse(program.get_organizations().exists()) + self.assertFalse(program.public_extension.get_organizations().exists()) + + def test_models_program_get_organizations(self): + """ + The `get_organizations` method should return all organizations linked to a course and + should respect publication status. + """ + # The 2 first organizations are grouped in one variable name and will be linked to the + # course in the following, the third category will not be linked so we can check that + # only the organizations linked to the course are retrieved (its name starts with `_` + # because it is not used and only here for unpacking purposes) + *draft_organizations, _other_draft = factories.OrganizationFactory.create_batch( + 3 + ) + ( + *published_organizations, + _other_public, + ) = factories.OrganizationFactory.create_batch(3, should_publish=True) + program = factories.ProgramFactory( + fill_organizations=draft_organizations + published_organizations, + should_publish=True, + ) + + self.assertEqual( + list(program.get_organizations()), + draft_organizations + published_organizations, + ) + self.assertEqual( + list(program.public_extension.get_organizations()), published_organizations + ) + + def test_models_program_get_organizations_language_current(self): + """ + The `get_organizations` method should only return organizations linked to a course by + a plugin in the current language. + """ + organization_fr = factories.OrganizationFactory(page_languages=["fr"]) + organization_en = factories.OrganizationFactory(page_languages=["en"]) + + program = factories.ProgramFactory(should_publish=True) + placeholder = program.extended_object.placeholders.get( + slot="program_organizations" + ) + + add_plugin( + language="en", + placeholder=placeholder, + plugin_type="OrganizationPlugin", + page=organization_en.extended_object, + ) + add_plugin( + language="fr", + placeholder=placeholder, + plugin_type="OrganizationPlugin", + page=organization_fr.extended_object, + ) + + with translation.override("fr"): + self.assertEqual(list(program.get_organizations()), [organization_fr]) + + with translation.override("en"): + self.assertEqual(list(program.get_organizations()), [organization_en]) + + @override_settings( + LANGUAGES=(("en", "en"), ("fr", "fr"), ("de", "de")), + CMS_LANGUAGES={ + "default": { + "public": True, + "hide_untranslated": False, + "redirect_on_fallback": False, + "fallbacks": ["en", "fr", "de"], + } + }, + ) + def test_models_program_get_organizations_language_fallback(self): + """ + The `get_organizations` method should return organizations linked to a course by + a plugin in fallback language by order of falling back. + """ + ( + organization1, + organization2, + organization3, + ) = factories.OrganizationFactory.create_batch(3, should_publish=True) + program = factories.ProgramFactory(should_publish=True) + placeholder = program.extended_object.placeholders.get( + slot="program_organizations" + ) + + # Plugin lookups should fallback up to the second priority language + add_plugin( + language="de", + placeholder=placeholder, + plugin_type="OrganizationPlugin", + **{"page": organization1.extended_object}, + ) + with translation.override("en"): + self.assertEqual(list(program.get_organizations()), [organization1]) + + with translation.override("fr"): + self.assertEqual(list(program.get_organizations()), [organization1]) + + with translation.override("de"): + self.assertEqual(list(program.get_organizations()), [organization1]) + + # Plugin lookups should fallback to the first priority language if available + # and ignore the second priority language unless it is the current language + add_plugin( + language="fr", + placeholder=placeholder, + plugin_type="OrganizationPlugin", + **{"page": organization2.extended_object}, + ) + with translation.override("en"): + self.assertEqual(list(program.get_organizations()), [organization2]) + + with translation.override("fr"): + self.assertEqual(list(program.get_organizations()), [organization2]) + + with translation.override("de"): + self.assertEqual(list(program.get_organizations()), [organization1]) + + # Reverse plugin lookups should stick to the current language if available and + # ignore plugins on fallback languages + add_plugin( + language="en", + placeholder=placeholder, + plugin_type="OrganizationPlugin", + **{"page": organization3.extended_object}, + ) + with translation.override("en"): + self.assertEqual(list(program.get_organizations()), [organization3]) + + with translation.override("fr"): + self.assertEqual(list(program.get_organizations()), [organization2]) + + with translation.override("de"): + self.assertEqual(list(program.get_organizations()), [organization1]) + + # Category + + def test_models_program_get_categories_empty(self): + """ + For a course not linked to any category the method `get_categories` should + return an empty query. + """ + program = factories.ProgramFactory(should_publish=True) + self.assertFalse(program.get_categories().exists()) + self.assertFalse(program.public_extension.get_categories().exists()) + + def test_models_program_get_categories(self): + """ + The `get_categories` method should return all categories linked to a course and + should respect publication status. + """ + # The 2 first categories are grouped in one variable name and will be linked to the + # course in the following, the third category will not be linked so we can check that + # only the categories linked to the course are retrieved (its name starts with `_` + # because it is not used and only here for unpacking purposes) + *draft_categories, _other_draft = factories.CategoryFactory.create_batch(3) + *published_categories, _other_public = factories.CategoryFactory.create_batch( + 3, should_publish=True + ) + program = factories.ProgramFactory( + fill_categories=draft_categories + published_categories, should_publish=True + ) + + self.assertEqual( + list(program.get_categories()), draft_categories + published_categories + ) + self.assertEqual( + list(program.public_extension.get_categories()), published_categories + ) + + def test_models_program_get_categories_language(self): + """ + The `get_categories` method should only return categories linked to a course by + a plugin in the current language. + """ + category_fr = factories.CategoryFactory(page_languages=["fr"]) + category_en = factories.CategoryFactory(page_languages=["en"]) + + program = factories.ProgramFactory(should_publish=True) + placeholder = program.extended_object.placeholders.get( + slot="program_categories" + ) + + add_plugin( + language="en", + placeholder=placeholder, + plugin_type="CategoryPlugin", + page=category_en.extended_object, + ) + add_plugin( + language="fr", + placeholder=placeholder, + plugin_type="CategoryPlugin", + page=category_fr.extended_object, + ) + + with translation.override("fr"): + self.assertEqual(list(program.get_categories()), [category_fr]) + + with translation.override("en"): + self.assertEqual(list(program.get_categories()), [category_en]) + + @override_settings( + LANGUAGES=(("en", "en"), ("fr", "fr"), ("de", "de")), + CMS_LANGUAGES={ + "default": { + "public": True, + "hide_untranslated": False, + "redirect_on_fallback": False, + "fallbacks": ["en", "fr", "de"], + } + }, + ) + def test_models_program_get_categories_language_fallback(self): + """ + The `get_categories` method should return categories linked to a course by + a plugin in fallback language by order of falling back. + """ + category1, category2, category3 = factories.CategoryFactory.create_batch( + 3, should_publish=True + ) + program = factories.ProgramFactory(should_publish=True) + placeholder = program.extended_object.placeholders.get( + slot="program_categories" + ) + + # Plugin lookups should fallback up to the second priority language + add_plugin( + language="de", + placeholder=placeholder, + plugin_type="CategoryPlugin", + **{"page": category1.extended_object}, + ) + with translation.override("en"): + self.assertEqual(list(program.get_categories()), [category1]) + + with translation.override("fr"): + self.assertEqual(list(program.get_categories()), [category1]) + + with translation.override("de"): + self.assertEqual(list(program.get_categories()), [category1]) + + # Plugin lookups should fallback to the first priority language if available + # and ignore the second priority language unless it is the current language + add_plugin( + language="fr", + placeholder=placeholder, + plugin_type="CategoryPlugin", + **{"page": category2.extended_object}, + ) + with translation.override("en"): + self.assertEqual(list(program.get_categories()), [category2]) + + with translation.override("fr"): + self.assertEqual(list(program.get_categories()), [category2]) + + with translation.override("de"): + self.assertEqual(list(program.get_categories()), [category1]) + + # Reverse plugin lookups should stick to the current language if available and + # ignore plugins on fallback languages + add_plugin( + language="en", + placeholder=placeholder, + plugin_type="CategoryPlugin", + **{"page": category3.extended_object}, + ) + with translation.override("en"): + self.assertEqual(list(program.get_categories()), [category3]) + + with translation.override("fr"): + self.assertEqual(list(program.get_categories()), [category2]) + + with translation.override("de"): + self.assertEqual(list(program.get_categories()), [category1]) + + # Instructors + + def test_models_program_get_persons_empty(self): + """ + For a course not linked to any person the method `get_persons` should + return an empty query. + """ + program = factories.ProgramFactory(should_publish=True) + self.assertFalse(program.get_persons().exists()) + self.assertFalse(program.public_extension.get_persons().exists()) + + def test_models_program_get_persons(self): + """ + The `get_persons` method should return all persons linked to a course and + should respect publication status. + """ + # The 2 first persons are grouped in one variable name and will be linked to the + # course in the following, the third person will not be linked so we can check that + # only the persons linked to the course are retrieved (its name starts with `_` + # because it is not used and only here for unpacking purposes) + *draft_persons, _other_draft = factories.PersonFactory.create_batch(3) + *published_persons, _other_public = factories.PersonFactory.create_batch( + 3, should_publish=True + ) + program = factories.ProgramFactory( + fill_team=draft_persons + published_persons, should_publish=True + ) + + self.assertEqual(list(program.get_persons()), draft_persons + published_persons) + self.assertEqual( + list(program.public_extension.get_persons()), published_persons + ) + + def test_models_program_get_persons_language(self): + """ + The `get_persons` method should only return persons linked to a course by a plugin + in the current language. + """ + person_fr = factories.PersonFactory(page_languages=["fr"]) + person_en = factories.PersonFactory(page_languages=["en"]) + + program = factories.ProgramFactory(should_publish=True) + placeholder = program.extended_object.placeholders.get(slot="program_team") + + add_plugin( + language="en", + placeholder=placeholder, + plugin_type="PersonPlugin", + page=person_en.extended_object, + ) + add_plugin( + language="fr", + placeholder=placeholder, + plugin_type="PersonPlugin", + page=person_fr.extended_object, + ) + + with translation.override("fr"): + self.assertEqual(list(program.get_persons()), [person_fr]) + + with translation.override("en"): + self.assertEqual(list(program.get_persons()), [person_en]) + + @override_settings( + LANGUAGES=(("en", "en"), ("fr", "fr"), ("de", "de")), + CMS_LANGUAGES={ + "default": { + "public": True, + "hide_untranslated": False, + "redirect_on_fallback": False, + "fallbacks": ["en", "fr", "de"], + } + }, + ) + def test_models_program_get_persons_language_fallback(self): + """ + The `get_persons` method should return persons linked to a course by + a plugin in fallback language by order of falling back. + """ + person1, person2, person3 = factories.PersonFactory.create_batch( + 3, should_publish=True + ) + program = factories.ProgramFactory(should_publish=True) + placeholder = program.extended_object.placeholders.get(slot="program_team") + + # Plugin lookups should fallback up to the second priority language + add_plugin( + language="de", + placeholder=placeholder, + plugin_type="PersonPlugin", + **{"page": person1.extended_object}, + ) + with translation.override("en"): + self.assertEqual(list(program.get_persons()), [person1]) + + with translation.override("fr"): + self.assertEqual(list(program.get_persons()), [person1]) + + with translation.override("de"): + self.assertEqual(list(program.get_persons()), [person1]) + + # Plugin lookups should fallback to the first priority language if available + # and ignore the second priority language unless it is the current language + add_plugin( + language="fr", + placeholder=placeholder, + plugin_type="PersonPlugin", + **{"page": person2.extended_object}, + ) + with translation.override("en"): + self.assertEqual(list(program.get_persons()), [person2]) + + with translation.override("fr"): + self.assertEqual(list(program.get_persons()), [person2]) + + with translation.override("de"): + self.assertEqual(list(program.get_persons()), [person1]) + + # Reverse plugin lookups should stick to the current language if available and + # ignore plugins on fallback languages + add_plugin( + language="en", + placeholder=placeholder, + plugin_type="PersonPlugin", + **{"page": person3.extended_object}, + ) + with translation.override("en"): + self.assertEqual(list(program.get_persons()), [person3]) + + with translation.override("fr"): + self.assertEqual(list(program.get_persons()), [person2]) + + with translation.override("de"): + self.assertEqual(list(program.get_persons()), [person1]) diff --git a/tests/apps/courses/test_templatetags_extra_tags_course_programs_count.py b/tests/apps/courses/test_templatetags_extra_tags_course_programs_count.py new file mode 100644 index 0000000000..26693507b3 --- /dev/null +++ b/tests/apps/courses/test_templatetags_extra_tags_course_programs_count.py @@ -0,0 +1,53 @@ +""" +Unit tests for the `course_programs_count` template filter. +""" + +from django.test import RequestFactory + +from cms.test_utils.testcases import CMSTestCase + +from richie.apps.courses import factories +from richie.apps.courses.templatetags.extra_tags import course_programs_count + + +class CourseProgramsCountTagTestCase(CMSTestCase): + """ + Unit test suite to validate the behavior of the `course_programs_count` tag. + """ + + def test_templatetags_course_programs_count_tag_with_published_courses(self): + """ + The tag should return the number of courses available in + a program page + """ + + (*published_courses, _other_public) = factories.CourseFactory.create_batch( + 3, should_publish=True + ) + + program = factories.ProgramFactory( + fill_courses=published_courses, should_publish=True + ) + + request = RequestFactory().get("/") + request.current_page = program.extended_object + + context = {"program": program, "request": request, "LANGUAGE_CODE": "en"} + + self.assertEqual( + course_programs_count(context, request.current_page), len(published_courses) + ) + + def test_templatetags_course_programs_count_tag_without_courses(self): + """ + The tag should return 0 if there are no courses available in + a program page + """ + program = factories.ProgramFactory(should_publish=True) + + request = RequestFactory().get("/") + request.current_page = program.extended_object + + context = {"program": program, "request": request, "LANGUAGE_CODE": "en"} + + self.assertEqual(course_programs_count(context, request.current_page), 0)