From 70d57327eb3f592067cabb2b4655088101266bcd Mon Sep 17 00:00:00 2001 From: Renzo Lucioni Date: Tue, 10 Nov 2015 13:25:35 -0500 Subject: [PATCH] Add Programs tab to Studio Extends the Programs ConfigurationModel, cleans up Programs-related utilities and corresponding tests, and corrects caching. Uses the Programs API to list programs within Studio. ECOM-2769. --- .../contentstore/tests/test_programs.py | 66 ++++ cms/djangoapps/contentstore/views/course.py | 135 ++++---- cms/envs/aws.py | 5 + cms/envs/bok_choy.env.json | 3 +- cms/envs/common.py | 11 + cms/envs/devstack.py | 3 + cms/envs/test.py | 3 - cms/lib/studio_tabs.py | 64 ---- cms/lib/tests/__init__.py | 0 cms/lib/tests/test_studio_tabs.py | 35 --- cms/static/js/index.js | 8 +- cms/static/sass/views/_dashboard.scss | 6 +- cms/templates/index.html | 69 +++- common/djangoapps/student/tests/tests.py | 27 +- common/djangoapps/student/views.py | 42 ++- common/djangoapps/terrain/stubs/programs.py | 41 +++ common/djangoapps/terrain/stubs/start.py | 2 + common/test/acceptance/fixtures/__init__.py | 3 + common/test/acceptance/fixtures/programs.py | 61 ++++ common/test/acceptance/pages/studio/index.py | 46 +++ .../tests/studio/test_studio_home.py | 86 ++++- common/test/db_fixtures/programs_client.json | 15 + lms/djangoapps/edxnotes/helpers.py | 2 +- lms/urls.py | 2 + openedx/README.rst | 16 +- .../migrations/0003_auto_20151120_1613.py | 34 ++ openedx/core/djangoapps/programs/models.py | 87 +++-- .../core/djangoapps/programs/tests/mixins.py | 210 +++++++++++-- .../djangoapps/programs/tests/test_models.py | 135 ++++---- .../djangoapps/programs/tests/test_utils.py | 131 ++++++++ .../djangoapps/programs/tests/test_views.py | 297 ------------------ openedx/core/djangoapps/programs/utils.py | 130 +++++--- openedx/core/djangoapps/programs/views.py | 100 ------ openedx/core/djangoapps/util/helpers.py | 48 --- .../core/djangoapps/util/tests/__init__.py | 0 .../djangoapps/util/tests/test_helpers.py | 45 --- openedx/core/lib/tests/test_token_utils.py | 64 ++++ openedx/core/lib/token_utils.py | 60 ++++ pavelib/utils/envs.py | 5 + 39 files changed, 1238 insertions(+), 859 deletions(-) create mode 100644 cms/djangoapps/contentstore/tests/test_programs.py delete mode 100644 cms/lib/studio_tabs.py delete mode 100644 cms/lib/tests/__init__.py delete mode 100644 cms/lib/tests/test_studio_tabs.py create mode 100644 common/djangoapps/terrain/stubs/programs.py create mode 100644 common/test/acceptance/fixtures/programs.py create mode 100644 common/test/db_fixtures/programs_client.json create mode 100644 openedx/core/djangoapps/programs/migrations/0003_auto_20151120_1613.py create mode 100644 openedx/core/djangoapps/programs/tests/test_utils.py delete mode 100644 openedx/core/djangoapps/programs/tests/test_views.py delete mode 100644 openedx/core/djangoapps/programs/views.py delete mode 100644 openedx/core/djangoapps/util/helpers.py delete mode 100644 openedx/core/djangoapps/util/tests/__init__.py delete mode 100644 openedx/core/djangoapps/util/tests/test_helpers.py create mode 100644 openedx/core/lib/tests/test_token_utils.py create mode 100644 openedx/core/lib/token_utils.py diff --git a/cms/djangoapps/contentstore/tests/test_programs.py b/cms/djangoapps/contentstore/tests/test_programs.py new file mode 100644 index 0000000000..e2af560a3f --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_programs.py @@ -0,0 +1,66 @@ +"""Tests covering the Programs listing on the Studio home.""" +from django.core.urlresolvers import reverse +import httpretty +from oauth2_provider.tests.factories import ClientFactory +from provider.constants import CONFIDENTIAL + +from openedx.core.djangoapps.programs.models import ProgramsApiConfig +from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin, ProgramsDataMixin +from student.tests.factories import UserFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase + + +class TestProgramListing(ProgramsApiConfigMixin, ProgramsDataMixin, ModuleStoreTestCase): + """Verify Program listing behavior.""" + def setUp(self): + super(TestProgramListing, self).setUp() + + ClientFactory(name=ProgramsApiConfig.OAUTH2_CLIENT_NAME, client_type=CONFIDENTIAL) + + self.user = UserFactory(is_staff=True) + self.client.login(username=self.user.username, password='test') + + self.studio_home = reverse('home') + + @httpretty.activate + def test_programs_config_disabled(self): + """Verify that the programs tab and creation button aren't rendered when config is disabled.""" + self.create_config(enable_studio_tab=False) + self.mock_programs_api() + + response = self.client.get(self.studio_home) + + self.assertNotIn("You haven't created any programs yet.", response.content) + + for program_name in self.PROGRAM_NAMES: + self.assertNotIn(program_name, response.content) + + @httpretty.activate + def test_programs_requires_staff(self): + """Verify that the programs tab and creation button aren't rendered unless the user has global staff.""" + self.user = UserFactory(is_staff=False) + self.client.login(username=self.user.username, password='test') + + self.create_config() + self.mock_programs_api() + + response = self.client.get(self.studio_home) + self.assertNotIn("You haven't created any programs yet.", response.content) + + @httpretty.activate + def test_programs_displayed(self): + """Verify that the programs tab and creation button can be rendered when config is enabled.""" + self.create_config() + + # When no data is provided, expect creation prompt. + self.mock_programs_api(data={'results': []}) + + response = self.client.get(self.studio_home) + self.assertIn("You haven't created any programs yet.", response.content) + + # When data is provided, expect a program listing. + self.mock_programs_api() + + response = self.client.get(self.studio_home) + for program_name in self.PROGRAM_NAMES: + self.assertIn(program_name, response.content) diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 473fe058d3..680e4a5945 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -2,97 +2,96 @@ Views related to operations on course objects """ import copy -from django.shortcuts import redirect import json -import random import logging -import string -from django.utils.translation import ugettext as _ -import django.utils -from django.contrib.auth.decorators import login_required +import random +import string # pylint: disable=deprecated-module + from django.conf import settings -from django.views.decorators.http import require_http_methods, require_GET +from django.contrib.auth.decorators import login_required from django.core.exceptions import PermissionDenied from django.core.urlresolvers import reverse -from django.http import HttpResponseBadRequest, HttpResponseNotFound, HttpResponse, Http404 -from util.json_request import JsonResponse, JsonResponseBadRequest -from util.date_utils import get_default_time_display -from edxmako.shortcuts import render_to_response - -from xmodule.course_module import DEFAULT_START_DATE -from xmodule.error_module import ErrorDescriptor -from xmodule.modulestore.django import modulestore -from xmodule.contentstore.content import StaticContent -from xmodule.tabs import CourseTab, CourseTabList, InvalidTabsException -from openedx.core.lib.course_tabs import CourseTabPluginManager -from openedx.core.djangoapps.credit.api import is_credit_course, get_credit_requirements -from openedx.core.djangoapps.credit.tasks import update_credit_course_requirements -from openedx.core.djangoapps.content.course_structures.api.v0 import api, errors -from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration -from xmodule.modulestore import EdxJSONEncoder -from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateCourseError -from opaque_keys import InvalidKeyError -from opaque_keys.edx.locations import Location -from opaque_keys.edx.keys import CourseKey - +from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotFound, Http404 +from django.shortcuts import redirect +import django.utils +from django.utils.translation import ugettext as _ +from django.views.decorators.http import require_http_methods, require_GET from django.views.decorators.csrf import ensure_csrf_cookie -from openedx.core.lib.js_utils import escape_json_dumps -from contentstore.course_info_model import get_course_updates, update_course_updates, delete_course_update +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey +from opaque_keys.edx.locations import Location + +from .component import ( + ADVANCED_COMPONENT_TYPES, + SPLIT_TEST_COMPONENT_TYPE, +) +from .item import create_xblock_info +from .library import LIBRARIES_ENABLED +from contentstore import utils from contentstore.course_group_config import ( + COHORT_SCHEME, GroupConfiguration, GroupConfigurationsValidationError, RANDOM_SCHEME, - COHORT_SCHEME ) +from contentstore.course_info_model import get_course_updates, update_course_updates, delete_course_update from contentstore.courseware_index import CoursewareSearchIndexer, SearchIndexingError +from contentstore.push_notification import push_notification_enabled +from contentstore.tasks import rerun_course from contentstore.utils import ( add_instructor, initialize_permissions, get_lms_link_for_item, + remove_all_instructors, reverse_course_url, reverse_library_url, reverse_usage_url, reverse_url, - remove_all_instructors, ) +from contentstore.views.entrance_exam import ( + create_entrance_exam, + delete_entrance_exam, + update_entrance_exam, +) +from course_action_state.managers import CourseActionStateItemNotFoundError +from course_action_state.models import CourseRerunState, CourseRerunUIStateManager +from course_creators.views import get_course_creator_status, add_user_with_status_unrequested +from edxmako.shortcuts import render_to_response +from microsite_configuration import microsite from models.settings.course_details import CourseDetails, CourseSettingsEncoder from models.settings.course_grading import CourseGradingModel from models.settings.course_metadata import CourseMetadata -from util.json_request import expect_json -from util.string_utils import _has_non_ascii_characters -from student.auth import has_studio_write_access, has_studio_read_access -from .component import ( - SPLIT_TEST_COMPONENT_TYPE, - ADVANCED_COMPONENT_TYPES, -) -from contentstore.tasks import rerun_course -from contentstore.views.entrance_exam import ( - create_entrance_exam, - update_entrance_exam, - delete_entrance_exam -) - -from .library import LIBRARIES_ENABLED -from .item import create_xblock_info -from contentstore.push_notification import push_notification_enabled -from course_creators.views import get_course_creator_status, add_user_with_status_unrequested -from contentstore import utils +from openedx.core.djangoapps.content.course_structures.api.v0 import api, errors +from openedx.core.djangoapps.credit.api import is_credit_course, get_credit_requirements +from openedx.core.djangoapps.credit.tasks import update_credit_course_requirements +from openedx.core.djangoapps.programs.models import ProgramsApiConfig +from openedx.core.djangoapps.programs.utils import get_programs +from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration +from openedx.core.lib.course_tabs import CourseTabPluginManager +from openedx.core.lib.js_utils import escape_json_dumps +from student import auth +from student.auth import has_course_author_access, has_studio_write_access, has_studio_read_access from student.roles import ( CourseInstructorRole, CourseStaffRole, CourseCreatorRole, GlobalStaff, UserBasedRole ) -from student import auth -from course_action_state.models import CourseRerunState, CourseRerunUIStateManager -from course_action_state.managers import CourseActionStateItemNotFoundError -from microsite_configuration import microsite -from xmodule.course_module import CourseFields -from student.auth import has_course_author_access - +from util.date_utils import get_default_time_display +from util.json_request import JsonResponse, JsonResponseBadRequest, expect_json from util.milestones_helpers import ( - set_prerequisite_courses, - is_valid_course_key, + is_entrance_exams_enabled, is_prerequisite_courses_enabled, - is_entrance_exams_enabled + is_valid_course_key, + set_prerequisite_courses, ) +from util.string_utils import _has_non_ascii_characters +from xmodule.contentstore.content import StaticContent +from xmodule.course_module import CourseFields +from xmodule.course_module import DEFAULT_START_DATE +from xmodule.error_module import ErrorDescriptor +from xmodule.modulestore import EdxJSONEncoder +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateCourseError +from xmodule.tabs import CourseTab, CourseTabList, InvalidTabsException + log = logging.getLogger(__name__) @@ -219,6 +218,7 @@ def _dismiss_notification(request, course_action_state_id): # pylint: disable=u return JsonResponse({'success': True}) +# pylint: disable=unused-argument @login_required def course_handler(request, course_key_string=None): """ @@ -422,6 +422,13 @@ def course_listing(request): courses, in_process_course_actions = get_courses_accessible_to_user(request) libraries = _accessible_libraries_list(request.user) if LIBRARIES_ENABLED else [] + programs_config = ProgramsApiConfig.current() + raw_programs = get_programs(request.user) if programs_config.is_studio_tab_enabled else [] + + # Sort programs alphabetically by name. + # TODO: Support ordering in the Programs API itself. + programs = sorted(raw_programs, key=lambda p: p['name'].lower()) + def format_in_process_course_view(uca): """ Return a dict of the data which the view requires for each unsucceeded course @@ -470,7 +477,9 @@ def course_listing(request): 'course_creator_status': _get_course_creator_status(request.user), 'rerun_creator_status': GlobalStaff().has_user(request.user), 'allow_unicode_course_id': settings.FEATURES.get('ALLOW_UNICODE_COURSE_ID', False), - 'allow_course_reruns': settings.FEATURES.get('ALLOW_COURSE_RERUNS', True) + 'allow_course_reruns': settings.FEATURES.get('ALLOW_COURSE_RERUNS', True), + 'is_programs_enabled': programs_config.is_studio_tab_enabled and request.user.is_staff, + 'programs': programs, }) @@ -805,6 +814,7 @@ def _rerun_course(request, org, number, run, fields): }) +# pylint: disable=unused-argument @login_required @ensure_csrf_cookie @require_http_methods(["GET"]) @@ -837,6 +847,7 @@ def course_info_handler(request, course_key_string): return HttpResponseBadRequest("Only supports html requests") +# pylint: disable=unused-argument @login_required @ensure_csrf_cookie @require_http_methods(("GET", "POST", "PUT", "DELETE")) diff --git a/cms/envs/aws.py b/cms/envs/aws.py index 80f2718b53..3d7093431d 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -364,3 +364,8 @@ XBLOCK_SETTINGS.setdefault("VideoModule", {})['YOUTUBE_API_KEY'] = AUTH_TOKENS.g PROCTORING_BACKEND_PROVIDER = AUTH_TOKENS.get("PROCTORING_BACKEND_PROVIDER", PROCTORING_BACKEND_PROVIDER) PROCTORING_SETTINGS = ENV_TOKENS.get("PROCTORING_SETTINGS", PROCTORING_SETTINGS) + +############################ OAUTH2 Provider ################################### + +# OpenID Connect issuer ID. Normally the URL of the authentication endpoint. +OAUTH_OIDC_ISSUER = ENV_TOKENS['OAUTH_OIDC_ISSUER'] diff --git a/cms/envs/bok_choy.env.json b/cms/envs/bok_choy.env.json index 77d00876a2..d9856eec73 100644 --- a/cms/envs/bok_choy.env.json +++ b/cms/envs/bok_choy.env.json @@ -103,5 +103,6 @@ "TECH_SUPPORT_EMAIL": "technical@example.com", "THEME_NAME": "", "TIME_ZONE": "America/New_York", - "WIKI_ENABLED": true + "WIKI_ENABLED": true, + "OAUTH_OIDC_ISSUER": "https://www.example.com/oauth2" } diff --git a/cms/envs/common.py b/cms/envs/common.py index 930ab32438..47f604f1a1 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -806,6 +806,11 @@ INSTALLED_APPS = ( # Self-paced course configuration 'openedx.core.djangoapps.self_paced', + # OAuth2 Provider + 'provider', + 'provider.oauth2', + 'oauth2_provider', + # These are apps that aren't strictly needed by Studio, but are imported by # other apps that are. Django 1.8 wants to have imported models supported # by installed apps. @@ -1112,3 +1117,9 @@ PROCTORING_BACKEND_PROVIDER = { 'options': {}, } PROCTORING_SETTINGS = {} + + +############################ OAUTH2 Provider ################################### + +# OpenID Connect issuer ID. Normally the URL of the authentication endpoint. +OAUTH_OIDC_ISSUER = 'https://www.example.com/oauth2' diff --git a/cms/envs/devstack.py b/cms/envs/devstack.py index 6634a202ff..127eb8a3a1 100644 --- a/cms/envs/devstack.py +++ b/cms/envs/devstack.py @@ -115,6 +115,9 @@ FEATURES['CERTIFICATES_HTML_VIEW'] = True # Whether to run django-require in debug mode. REQUIRE_DEBUG = DEBUG +########################### OAUTH2 ################################# +OAUTH_OIDC_ISSUER = 'http://127.0.0.1:8000/oauth2' + ############################################################################### # See if the developer has any local overrides. if os.path.isfile(join(dirname(abspath(__file__)), 'private.py')): diff --git a/cms/envs/test.py b/cms/envs/test.py index 186d34bb0c..45263bebdc 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -33,9 +33,6 @@ from lms.envs.test import ( DEFAULT_FILE_STORAGE, MEDIA_ROOT, MEDIA_URL, - # This is practically unused but needed by the oauth2_provider package, which - # some tests in common/ rely on. - OAUTH_OIDC_ISSUER, ) # mongo connection settings diff --git a/cms/lib/studio_tabs.py b/cms/lib/studio_tabs.py deleted file mode 100644 index d91f6fa9d8..0000000000 --- a/cms/lib/studio_tabs.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Studio tab plugin manager and API.""" -import abc - -from openedx.core.lib.api.plugins import PluginManager - - -class StudioTabPluginManager(PluginManager): - """Manager for all available Studio tabs. - - Examples of Studio tabs include Courses, Libraries, and Programs. All Studio - tabs should implement `StudioTab`. - """ - NAMESPACE = 'openedx.studio_tab' - - @classmethod - def get_enabled_tabs(cls): - """Returns a list of enabled Studio tabs.""" - tabs = cls.get_available_plugins() - enabled_tabs = [tab for tab in tabs.viewvalues() if tab.is_enabled()] - - return enabled_tabs - - -class StudioTab(object): - """Abstract class used to represent Studio tabs. - - Examples of Studio tabs include Courses, Libraries, and Programs. - """ - __metaclass__ = abc.ABCMeta - - @abc.abstractproperty - def tab_text(self): - """Text to display in a tab used to navigate to a list of instances of this tab. - - Should be internationalized using `ugettext_noop()` since the user won't be available in this context. - """ - pass - - @abc.abstractproperty - def button_text(self): - """Text to display in a button used to create a new instance of this tab. - - Should be internationalized using `ugettext_noop()` since the user won't be available in this context. - """ - pass - - @abc.abstractproperty - def view_name(self): - """Name of the view used to render this tab. - - Used within templates in conjuction with Django's `reverse()` to generate a URL for this tab. - """ - pass - - @abc.abstractmethod - def is_enabled(cls, user=None): # pylint: disable=no-self-argument,unused-argument - """Indicates whether this tab should be enabled. - - This is a class method; override with @classmethod. - - Keyword Arguments: - user (User): The user signed in to Studio. - """ - pass diff --git a/cms/lib/tests/__init__.py b/cms/lib/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/cms/lib/tests/test_studio_tabs.py b/cms/lib/tests/test_studio_tabs.py deleted file mode 100644 index 35140c5464..0000000000 --- a/cms/lib/tests/test_studio_tabs.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Tests for the Studio tab plugin API.""" -from django.test import TestCase -import mock - -from cms.lib.studio_tabs import StudioTabPluginManager -from openedx.core.lib.api.plugins import PluginError - - -class TestStudioTabPluginApi(TestCase): - """Unit tests for the Studio tab plugin API.""" - - @mock.patch('cms.lib.studio_tabs.StudioTabPluginManager.get_available_plugins') - def test_get_enabled_tabs(self, get_available_plugins): - """Verify that only enabled tabs are retrieved.""" - enabled_tab = self._mock_tab(is_enabled=True) - mock_tabs = { - 'disabled_tab': self._mock_tab(), - 'enabled_tab': enabled_tab, - } - - get_available_plugins.return_value = mock_tabs - - self.assertEqual(StudioTabPluginManager.get_enabled_tabs(), [enabled_tab]) - - def test_get_invalid_plugin(self): - """Verify that get_plugin fails when an invalid plugin is requested.""" - with self.assertRaises(PluginError): - StudioTabPluginManager.get_plugin('invalid_tab') - - def _mock_tab(self, is_enabled=False): - """Generate a mock tab.""" - tab = mock.Mock() - tab.is_enabled = mock.Mock(return_value=is_enabled) - - return tab diff --git a/cms/static/js/index.js b/cms/static/js/index.js index f130b73656..243c602125 100644 --- a/cms/static/js/index.js +++ b/cms/static/js/index.js @@ -141,20 +141,26 @@ define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/vie e.preventDefault(); $('.courses-tab').toggleClass('active', tab === 'courses'); $('.libraries-tab').toggleClass('active', tab === 'libraries'); + $('.programs-tab').toggleClass('active', tab === 'programs'); + // Also toggle this course-related notice shown below the course tab, if it is present: - $('.wrapper-creationrights').toggleClass('is-hidden', tab === 'libraries'); + $('.wrapper-creationrights').toggleClass('is-hidden', tab !== 'courses'); }; }; var onReady = function () { $('.new-course-button').bind('click', addNewCourse); $('.new-library-button').bind('click', addNewLibrary); + $('.dismiss-button').bind('click', ViewUtils.deleteNotificationHandler(function () { ViewUtils.reload(); })); + $('.action-reload').bind('click', ViewUtils.reload); + $('#course-index-tabs .courses-tab').bind('click', showTab('courses')); $('#course-index-tabs .libraries-tab').bind('click', showTab('libraries')); + $('#course-index-tabs .programs-tab').bind('click', showTab('programs')); }; domReady(onReady); diff --git a/cms/static/sass/views/_dashboard.scss b/cms/static/sass/views/_dashboard.scss index c81690b4a2..42de626249 100644 --- a/cms/static/sass/views/_dashboard.scss +++ b/cms/static/sass/views/_dashboard.scss @@ -55,7 +55,7 @@ } - .action-create-course, .action-create-library { + .action-create-course, .action-create-library, .action-create-program { @extend %btn-primary-green; @extend %t-action3; } @@ -318,7 +318,7 @@ } // ELEM: course listings - .courses-tab, .libraries-tab { + .courses-tab, .libraries-tab, .programs-tab { display: none; &.active { @@ -326,7 +326,7 @@ } } - .courses, .libraries { + .courses, .libraries, .programs { .title { @extend %t-title6; margin-bottom: $baseline; diff --git a/cms/templates/index.html b/cms/templates/index.html index 472156038c..7f9dc051f5 100644 --- a/cms/templates/index.html +++ b/cms/templates/index.html @@ -1,6 +1,7 @@ <%! from django.utils.translation import ugettext as _ %> <%inherit file="base.html" /> + <%def name="online_help_token()"><% return "home" %> <%block name="title">${_("{studio_name} Home").format(studio_name=settings.STUDIO_SHORT_NAME)} <%block name="bodyclass">is-signedin index view-dashboard @@ -27,10 +28,17 @@ % elif course_creator_status=='disallowed_for_this_site' and settings.FEATURES.get('STUDIO_REQUEST_EMAIL',''): ${_("Email staff to create course")} % endif + % if show_new_library_button: ${_("New Library")} % endif + + % if is_programs_enabled: + + + % endif @@ -271,12 +279,19 @@ %endif - %if libraries_enabled: + % if libraries_enabled or is_programs_enabled: - %endif + % endif %if len(courses) > 0:
@@ -485,6 +500,54 @@
%endif + % if is_programs_enabled: + % if len(programs) > 0: +
+ + +
+ + % else: +
+
+ +
+

${_("You haven't created any programs yet.")}

+
+

${_("Programs are groups of courses related to a common subject.")}

+
+
+ +
    +
  • + + +
  • +
+ +
+
+ % endif + % endif +