diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3b2f533e9e17..68beeec8448c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -3,7 +3,7 @@ # default code owner -* @omarithawi +* @bryanlandia # default set of reviewers -* @omarithawi @melvinsoft @thraxil @shadinaif +* @amirtds @xscrio diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index ccd1c9b49c28..cbad39a52923 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -18,7 +18,7 @@ jobs: PULL_REQUEST_BODY: | This is an automated pull request from branch `hawthorn/main` into `hawthorn/prod` (production). Please review the changes and merge this pull request _before_ running the Tahoe production Cloud Build deployment. - PULL_REQUEST_REVIEWERS: "johnbaldwin amirtds bryanlandia" + PULL_REQUEST_REVIEWERS: "amirtds bryanlandia xscrio" hawthorn-to-juniper-sync: name: PullRequestAction runs-on: ubuntu-latest @@ -35,5 +35,5 @@ jobs: This is meant for making sure all of our Hawthorn changes gets merge into Juniper otherwise Juniper would stall. If tests passes merge this pull request. If there are merge conflicts, it needs to be resolved manually in a seperate pull request. - PULL_REQUEST_REVIEWERS: "melvinsoft shadinaif OmarIthawi thraxil" + PULL_REQUEST_REVIEWERS: "bryanlandia amirtds xscrio" diff --git a/.github/workflows/report_conflicts.yml b/.github/workflows/report_conflicts.yml index 1239066f1f87..96ff1955f55d 100644 --- a/.github/workflows/report_conflicts.yml +++ b/.github/workflows/report_conflicts.yml @@ -2,13 +2,13 @@ on: [pull_request] name: 'Merge conflicts' jobs: - report_master: - name: 'koa, lilac, maple, nutmeg and master' + report: + name: 'nutmeg and master' uses: appsembler/action-conflict-counter/.github/workflows/report-via-comment.yml@main with: local_base_branch: ${{ github.base_ref }} - upstream_repo: 'https://github.com/edx/edx-platform.git' - upstream_branches: 'open-release/koa.master,open-release/lilac.master,open-release/maple.master,open-release/nutmeg.master,master' + upstream_repo: 'https://github.com/openedx/edx-platform.git' + upstream_branches: 'open-release/nutmeg.master,master' exclude_paths: 'cms/static/js/,conf/locale/,lms/static/js/,package.json,package-lock.json,.github/' secrets: custom_github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/sync_nutmeg_with_juniper.yml b/.github/workflows/sync_nutmeg_with_juniper.yml index f92283f378d1..c1dbb1130b64 100644 --- a/.github/workflows/sync_nutmeg_with_juniper.yml +++ b/.github/workflows/sync_nutmeg_with_juniper.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - name: pull-request-action - uses: vsoch/pull-request-action@1.0.19 + uses: vsoch/pull-request-action@1.1.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} BRANCH_PREFIX: "main" diff --git a/.github/workflows/sync_prod_with_main.yml b/.github/workflows/sync_prod_with_main.yml index 4927eb46f65d..358f6072bf33 100644 --- a/.github/workflows/sync_prod_with_main.yml +++ b/.github/workflows/sync_prod_with_main.yml @@ -9,10 +9,10 @@ jobs: runs-on: ubuntu-latest steps: - name: pull-request-action - uses: vsoch/pull-request-action@1.0.19 + uses: vsoch/pull-request-action@1.1.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} BRANCH_PREFIX: "main" PULL_REQUEST_BRANCH: "prod" PULL_REQUEST_TITLE: "Update from `main` (production)" - PULL_REQUEST_REVIEWERS: "melvinsoft OmarIthawi thraxil shadinaif" + PULL_REQUEST_REVIEWERS: "VladyslavTy daniilly" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a110bf2f8fd2..b96fe22dde49 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,6 +24,8 @@ jobs: - lms-1 - lms-2 - mte + - legacy-amc-tests + - db-migrations - studio steps: @@ -32,6 +34,8 @@ jobs: uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} + env: + PIP_TRUSTED_HOST: "pypi.python.org pypi.org files.pythonhosted.org" - name: Install dependencies # TODO: Remove tox-pip-version once we upgrade to Koa+, or whenever we have addressed pip 20.3 strict issues. run: | diff --git a/Dockerfile.tutor b/Dockerfile.tutor index deb29f5c1b9f..fe47d6ea685d 100644 --- a/Dockerfile.tutor +++ b/Dockerfile.tutor @@ -17,23 +17,25 @@ RUN pip install -r ./requirements/edx/base.txt \ # Sync with `edx-configs` `appsembler/tahoe/us/juniper/prod/files/server-vars.yml` RUN echo "Installing pip packages:" \ - && pip install openedx-scorm-xblock==10.4.0 \ - && pip install xblock-launchcontainer==2.3.1 \ + && pip install xblock-launchcontainer==4.0.0 \ && pip install xblock-prismjs==0.1.4 \ && pip install xblock-problem-builder==4.1.9 \ && echo \ + && pip install https://github.com/appsembler/openedx-scorm-xblock/archive/refs/tags/v15.1.0-appsembler-tahoe-compat.tar.gz \ && pip install https://github.com/appsembler/pdfXBlock/archive/v0.3.1.tar.gz \ && pip install https://github.com/edx/xblock-free-text-response/archive/4149cc450.tar.gz \ && pip install https://github.com/pmitros/FeedbackXBlock/archive/v1.1.tar.gz \ && pip install https://github.com/ubc/ubcpi/archive/1.0.0.tar.gz \ && echo \ - && pip install course-access-groups==0.5.4 \ - && pip install figures==0.4.1 \ + && pip install course-access-groups==0.6.1 \ + && pip install figures==0.4.4 \ && pip install tahoe-figures-plugins==0.1.1 \ && pip install tahoe-lti==0.3.0 \ - && pip install tahoe-scorm==0.1.2 \ + && pip install tahoe-scorm==0.1.4 \ + && pip install xblock-grade-fetcher==0.5.0 \ + && pip install django-manage-admins==0.1.0 \ && echo \ - && pip install https://github.com/appsembler/openedx-completion-aggregator/archive/3.0.3-2021-may-18-bug-fixes.tar.gz \ + && pip install https://github.com/appsembler/openedx-completion-aggregator/archive/3.0.3-2023-mar-27-revert-use-of-task-track.tar.gz \ && echo "Finished installing pip packages." EXPOSE 8000 diff --git a/cms/djangoapps/appsembler/apps.py b/cms/djangoapps/appsembler/apps.py new file mode 100644 index 000000000000..1f7c35a3b84c --- /dev/null +++ b/cms/djangoapps/appsembler/apps.py @@ -0,0 +1,14 @@ +""" +Appsembler CMS App Configuration +""" + + +from django.apps import AppConfig + + +class CMSAppsemblerConfig(AppConfig): + """ + Application Configuration for Badges. + """ + name = u'appsembler' + plugin_app = {} diff --git a/test_root/log/.gitkeep b/cms/djangoapps/appsembler/management/__init__.py similarity index 100% rename from test_root/log/.gitkeep rename to cms/djangoapps/appsembler/management/__init__.py diff --git a/cms/djangoapps/appsembler/management/commands/__init__.py b/cms/djangoapps/appsembler/management/commands/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/cms/djangoapps/appsembler/management/commands/cms_remove_stray_courses.py b/cms/djangoapps/appsembler/management/commands/cms_remove_stray_courses.py new file mode 100644 index 000000000000..2c6897fff6da --- /dev/null +++ b/cms/djangoapps/appsembler/management/commands/cms_remove_stray_courses.py @@ -0,0 +1,100 @@ +""" +Command to remove courses without associated organization. + +This command is intended as a follow-up step after `remove_site` but can be run independently. +""" + +from django.core.management.base import BaseCommand, CommandError +from django.conf import settings +from opaque_keys.edx.keys import CourseKey + +from xmodule.contentstore.django import contentstore +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore + +from contentstore.utils import delete_course + +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from openedx.core.djangoapps.appsembler.sites.deletion_utils import ( + confirm_deletion, +) + + +def get_deletable_course_keys_from_mongo(): + """ + Get keys of courses without active organization. + """ + mongodb_course_keys = {str(mongodb_course.id) for mongodb_course in modulestore().get_course_summaries()} + mysql_course_keys = {str(mysql_course_key) for mysql_course_key in CourseOverview.get_all_course_keys()} + return list(mongodb_course_keys - mysql_course_keys) + + +def delete_course_and_assets(course_key): + """ + Delete all courses without active organization. + """ + course_key_obj = CourseKey.from_string(course_key) + delete_course(course_key_obj, ModuleStoreEnum.UserID.mgmt_command, keep_instructors=False) + contentstore().delete_all_course_assets(course_key_obj) + + +def cms_remove_stray_courses(commit, limit): + """ + Remove all courses from mongodb that has no CourseOverview entry in MySQL. + """ + course_keys = get_deletable_course_keys_from_mongo() + if limit: + course_keys = course_keys[:limit] + + if not course_keys: + raise CommandError('No courses found to delete.') + + str_course_list = [str(course_key) for course_key in course_keys] + print('Preparing to delete:') + print('\n'.join(str_course_list)) + commit = confirm_deletion( + question='Do you confirm to delete the courses from CMS?', + commit=commit, + ) + + for course_key in course_keys: + if commit: + print('Deleting course: {}'.format(course_key)) + delete_course_and_assets(course_key) + else: + print('[Dry run] deleting course: {}'.format(course_key)) + + print('Finished removing deletable courses') + + +class Command(BaseCommand): + help = "Delete courses that don't belong to organization in `get_active_organizations()`." + + def add_arguments(self, parser): + parser.add_argument( + '--limit', + dest='limit', + default=1, + type=int, + help='Max courses to delete, use 0 to delete all courses.', + ) + + parser.add_argument( + '--commit', + dest='commit', + action='store_true', + help='Remove courses, otherwise only the log will be printed.', + ) + + parser.add_argument( + '--dry-run', + dest='commit', + action='store_false', + help='Do not remove courses, only print the logs.', + ) + + def handle(self, *args, **options): + if settings.ROOT_URLCONF != 'cms.urls': + raise CommandError('This command can only be run in CMS.') + + cms_remove_stray_courses(commit=options.get('commit'), limit=options['limit']) diff --git a/cms/djangoapps/appsembler/tests/test_deletion_command.py b/cms/djangoapps/appsembler/tests/test_deletion_command.py new file mode 100644 index 000000000000..cf95a801ffa2 --- /dev/null +++ b/cms/djangoapps/appsembler/tests/test_deletion_command.py @@ -0,0 +1,35 @@ +""" + +""" + +from django.core.management import call_command, CommandError + +from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + + +class DeletionCommandTestCase(ModuleStoreTestCase): + def test_cms_remove_stray_courses_command_no_courses(self): + """ + Raise CommandError if there's no courses to delete. + """ + with self.assertRaises(CommandError): + call_command('cms_remove_stray_courses') + + def test_cms_remove_stray_courses_command(self): + """ + Removes all courses that has only MongoDB entry. + """ + CourseFactory.create() + call_command('cms_remove_stray_courses') + + def test_cms_remove_stray_courses_command_non_to_delete(self): + """ + Should not remove courses from MongoDB if it has a MySQL CourseOverview entry. + """ + course = CourseFactory.create() + CourseOverviewFactory.create(id=course.id) + + with self.assertRaises(CommandError): + call_command('cms_remove_stray_courses') diff --git a/cms/djangoapps/appsembler/tests/test_studio_logout_view.py b/cms/djangoapps/appsembler/tests/test_studio_logout_view.py new file mode 100644 index 000000000000..d6c243cf13f2 --- /dev/null +++ b/cms/djangoapps/appsembler/tests/test_studio_logout_view.py @@ -0,0 +1,145 @@ +""" +Tests for APPSEMBLER_MULTI_TENANT_EMAILS in Studio logout. + +Special note: + +This test module needs to patch `cms.urls.urlpatterns` to include urlpatterns +from `cms.djangoapps.appsembler.urls`. This works by overriding the +`doango.conf.settings.ROOT_URLCONF` with `django.test.utils.override_settings` +at the TestCase class level with the `urlpatterns` list declared in the module +containing the TestCase class. + +For this test module, we've added a `urlpatterns` module level variable and +assigned it the value of `cms.urls.urlpatterns` then appended the conditionally +included urlpatterns we need to run the tests. + +Then we add `@override_settings(ROOT_URLCONF=__name__)` to the TestClass + +There are other ways to do this. However, this is simple and does not require +our code to explicitly hack `sys.modules` reloading +""" +from unittest.mock import Mock, patch + +from django.conf import settings +from django.conf.urls import include, url +from django.contrib import auth +from django.contrib.auth.models import AnonymousUser +from django.urls import reverse +from django.test import RequestFactory, TestCase +from django.test.utils import override_settings +from rest_framework import status +from tahoe_sites.api import add_user_to_organization, create_tahoe_site + +from student.tests.factories import UserFactory +import cms.urls +from cms.djangoapps.appsembler.views import get_logout_redirect_url + + +# Set the urlpatterns we want to use for our tests in this module only +urlpatterns = cms.urls.urlpatterns + [ + url(r'', include('cms.djangoapps.appsembler.urls')) +] + + +@override_settings(ROOT_URLCONF=__name__) # the module that contains `urlpatterns` +@override_settings(LOGOUT_REDIRECT_URL='home') # ensure that we have a value for LOGOUT_REDIRECT_URL +@patch.dict('django.conf.settings.FEATURES', {'TAHOE_STUDIO_LOCAL_LOGIN': True}) +class TestStudioLogoutView(TestCase): + """ + Testing the APPSEMBLER_MULTI_TENANT_EMAILS feature when enabled in Studio. + """ + BLUE = 'blue1' + EMAIL = 'customer@example.com' + PASSWORD = 'xyz' + DOMAIN = 'testdomain.com' + SHORT_NAME = 'testdomain' + + def setUp(self): + super(TestStudioLogoutView, self).setUp() + self.url = reverse('logout') + self.user = UserFactory.create(email=self.EMAIL, password=self.PASSWORD) + add_user_to_organization( + user=self.user, + organization=create_tahoe_site(domain=self.DOMAIN, short_name=self.SHORT_NAME)['organization'] + ) + self.request = RequestFactory() + self.request.is_secure = Mock(return_value=False) + self.lms_url = 'http://{site_domain}/logout'.format(site_domain=self.DOMAIN) + + def test_logout_must_be_authenticated(self): + """ + Test logout from studio must be authenticated + """ + response = self.client.get(self.url) + assert response.status_code == status.HTTP_302_FOUND + assert not response.content + assert '?next=/logout' in response.url + + def test_logout_normal_user(self): + """ + Test logout from studio for normal users (meaning that they are linked to an organization). It is expected + that a logout then redirect to LMS will be performed + """ + self.client.login(username=self.user.username, password=self.PASSWORD) + assert auth.get_user(self.client).is_authenticated + + response = self.client.get(self.url) + assert not auth.get_user(self.client).is_authenticated + + assert response.status_code == status.HTTP_302_FOUND + assert not response.content + assert response.url == self.lms_url + + def test_logout_staff_user(self): + """ + Test logout from studio for staff users (meaning that they are not linked to any organization). It is expected + that a logout then a redirect to settings.LOGOUT_REDIRECT_URL will be performed + """ + # Not necessary to set the user as staff, the thing we need to test is when it lacks a link to an organization + user = UserFactory.create(email=self.EMAIL, password=self.PASSWORD) + + self.client.login(username=user.username, password=self.PASSWORD) + assert auth.get_user(self.client).is_authenticated + + response = self.client.get(self.url) + assert not auth.get_user(self.client).is_authenticated + + assert response.status_code == status.HTTP_302_FOUND + assert not response.content + assert response.url == reverse(settings.LOGOUT_REDIRECT_URL) + + def test_get_logout_redirect_url_no_request(self): + """ + Verify that get_logout_redirect_url will return settings.LOGOUT_REDIRECT_URL if the request is None + """ + assert get_logout_redirect_url(request=None) == reverse(settings.LOGOUT_REDIRECT_URL) + + def test_get_logout_redirect_url_no_user(self): + """ + Verify that get_logout_redirect_url will return settings.LOGOUT_REDIRECT_URL if no user is logged in + """ + assert not hasattr(self.request, 'user') + assert get_logout_redirect_url(request=self.request) == reverse(settings.LOGOUT_REDIRECT_URL) + + def test_get_logout_redirect_url_anonymous(self): + """ + Verify that get_logout_redirect_url will return settings.LOGOUT_REDIRECT_URL if the user is anonymous + """ + self.request.user = AnonymousUser() + assert get_logout_redirect_url(request=self.request) == reverse(settings.LOGOUT_REDIRECT_URL) + + def test_get_logout_redirect_url_user(self): + """ + Verify that get_logout_redirect_url will return the LMS URL related to the user + """ + self.request.user = self.user + assert get_logout_redirect_url(request=self.request) == self.lms_url + + def test_get_logout_redirect_url_staff(self): + """ + Verify that get_logout_redirect_url will return settings.LOGOUT_REDIRECT_URL if the user is not linked to + any organization (staff users and superusers) + """ + user = UserFactory.create(email=self.EMAIL, password=self.PASSWORD) + self.request.user = user + assert get_logout_redirect_url(request=self.request) == reverse(settings.LOGOUT_REDIRECT_URL) diff --git a/cms/djangoapps/appsembler/urls.py b/cms/djangoapps/appsembler/urls.py index 200913eeb344..6562882ef50c 100644 --- a/cms/djangoapps/appsembler/urls.py +++ b/cms/djangoapps/appsembler/urls.py @@ -5,13 +5,10 @@ We have this code in the Appsembler CMS app to help isolate custom code """ -from django.conf import settings from django.urls import path -from django.contrib.auth.views import LogoutView -from .views import LoginView +from .views import LoginView, StudioLogoutView urlpatterns = [ path('login/', LoginView.as_view(), name='login'), - path('logout/', LogoutView.as_view( - next_page=settings.LOGOUT_REDIRECT_URL), name='logout'), + path('logout/', StudioLogoutView.as_view(), name='logout'), ] diff --git a/cms/djangoapps/appsembler/views.py b/cms/djangoapps/appsembler/views.py index c3faf55bc4f0..77297ca02e9f 100644 --- a/cms/djangoapps/appsembler/views.py +++ b/cms/djangoapps/appsembler/views.py @@ -9,6 +9,9 @@ import logging from django.conf import settings from django.contrib.auth import authenticate, get_user_model, login +from django.contrib.auth.decorators import login_required +from django.contrib.auth.views import LogoutView as DjangoLogoutView +from django.core.exceptions import ObjectDoesNotExist from django.db.models import Q from django.http import HttpResponseServerError from django.shortcuts import redirect @@ -18,7 +21,11 @@ from django.views import View from django.views.decorators.clickjacking import xframe_options_deny from django.views.decorators.csrf import csrf_protect -from tahoe_sites.api import deprecated_get_admin_users_queryset_by_email +from tahoe_sites.api import ( + deprecated_get_admin_users_queryset_by_email, + get_organization_for_user, + get_site_by_organization, +) from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.user_authn.utils import is_safe_login_or_logout_redirect @@ -274,3 +281,39 @@ def log_multiple_objects_returned(self): def render_login_page_with_error(self, error_code): return render_login_page( login_error_message=self.error_messages[error_code]) + + +def get_logout_redirect_url(request): + """ + Return logout redirect url using the site related to the given user if possible. + Otherwise, return settings.LOGOUT_REDIRECT_URL + + :return: logout redirect url or settings.LOGOUT_REDIRECT_URL + """ + user = getattr(request, 'user', None) + if not user or not user.is_authenticated: + return reverse(settings.LOGOUT_REDIRECT_URL) + + try: + organization = get_organization_for_user(user=user) + except ObjectDoesNotExist: + return reverse(settings.LOGOUT_REDIRECT_URL) + site = get_site_by_organization(organization=organization) + + return '{protocol}://{site_domain}/logout'.format( + protocol='https' if request.is_secure() else 'http', + site_domain=site.domain + ) + + +class StudioLogoutView(View): + """ + Studio Logout View + """ + @method_decorator(csrf_protect) + @method_decorator(login_required) + def get(self, request): + """ + Perform logout from studio, and redirect to LMS home page + """ + return DjangoLogoutView.as_view(next_page=get_logout_redirect_url(request))(request) diff --git a/cms/djangoapps/contentstore/tasks.py b/cms/djangoapps/contentstore/tasks.py index 636c3f22e2b9..98fd0e931c66 100644 --- a/cms/djangoapps/contentstore/tasks.py +++ b/cms/djangoapps/contentstore/tasks.py @@ -311,7 +311,7 @@ def async_migrate_transcript_subtask(*args, **kwargs): # pylint: disable=unused command_run=command_run, edx_video_id=edx_video_id, language_code=language_code, - transcript_content=transcript_content, + transcript_content=transcript_content.encode(), file_format=Transcript.SJSON, force_update=force_update, ) diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 5a8f75c76621..976c27b98103 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -708,7 +708,7 @@ def get_courses_accessible_to_user(request, org=None): return courses, in_process_course_actions -def _process_courses_list(courses_iter, in_process_course_actions, split_archived=False): +def _process_courses_list(courses_iter, in_process_course_actions, split_archived=False, ignore_archived=False): """ Iterates over the list of courses to be displayed to the user, and: @@ -738,6 +738,22 @@ def format_course_for_view(course): archived_courses = [] for course in courses_iter: + # BEGIN temp fix for Sounds Write + + # TODO: if a user has tons of courses like Sounds Write (571), some pages will time out. + # This is a temporary patch to prevent Sounds-Write requets timing out. The right fix + # is to optimize the DB queries. + + # This skipping step can be potentially kept since it's just used for the "prerequisite" course list + # where we do not need to list archived courses. + if course.has_ended() and ignore_archived: + continue + + if course.has_ended() and course.location.org == "sounds-write": + continue + + # END temp fix for Sounds Write + if isinstance(course, ErrorDescriptor) or (course.id in in_process_action_course_keys): continue @@ -1114,11 +1130,11 @@ def settings_handler(request, course_key_string): 'upgrade_deadline': upgrade_deadline, } if is_prerequisite_courses_enabled(): - courses, in_process_course_actions = get_courses_accessible_to_user(request) + courses, in_process_course_actions = get_courses_accessible_to_user(request, course_module.location.org) # exclude current course from the list of available courses courses = (course for course in courses if course.id != course_key) if courses: - courses, __ = _process_courses_list(courses, in_process_course_actions) + courses, __ = _process_courses_list(courses, in_process_course_actions, ignore_archived=True) settings_context.update({'possible_pre_requisite_courses': list(courses)}) if credit_eligibility_enabled: @@ -1799,3 +1815,4 @@ def _get_course_creator_status(user): course_creator_status = 'granted' return course_creator_status + diff --git a/cms/djangoapps/contentstore/views/course.py.edited b/cms/djangoapps/contentstore/views/course.py.edited new file mode 100644 index 000000000000..9a4557447021 --- /dev/null +++ b/cms/djangoapps/contentstore/views/course.py.edited @@ -0,0 +1,1804 @@ +""" +Views related to operations on course objects +""" + + +import copy +import json +import logging +import random +import re +import string +from collections import defaultdict + +import django.utils +import six +from ccx_keys.locator import CCXLocator +from django.conf import settings +from django.contrib.auth.decorators import login_required +from django.core.exceptions import PermissionDenied, ValidationError +from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotFound +from django.shortcuts import redirect +from django.urls import reverse +from django.utils.translation import ugettext as _ +from django.views.decorators.csrf import ensure_csrf_cookie +from django.views.decorators.http import require_GET, require_http_methods +from milestones import api as milestones_api +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey +from opaque_keys.edx.locator import BlockUsageLocator +from six import text_type +from six.moves import filter + +from contentstore.course_group_config import ( + COHORT_SCHEME, + ENROLLMENT_SCHEME, + RANDOM_SCHEME, + GroupConfiguration, + GroupConfigurationsValidationError +) +from contentstore.course_info_model import delete_course_update, get_course_updates, update_course_updates +from contentstore.courseware_index import CoursewareSearchIndexer, SearchIndexingError +from contentstore.tasks import rerun_course as rerun_course_task +from contentstore.utils import ( + add_instructor, + get_lms_link_for_item, + initialize_permissions, + remove_all_instructors, + reverse_course_url, + reverse_library_url, + reverse_url, + reverse_usage_url +) +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 add_user_with_status_unrequested, get_course_creator_status +from course_modes.models import CourseMode +from edxmako.shortcuts import render_to_response +from models.settings.course_grading import CourseGradingModel +from models.settings.course_metadata import CourseMetadata +from models.settings.encoder import CourseSettingsEncoder +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from openedx.core.djangoapps.credit.api import get_credit_requirements, is_credit_course +from openedx.core.djangoapps.credit.tasks import update_credit_course_requirements +from openedx.core.djangoapps.models.course_details import CourseDetails +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace +from openedx.core.djangolib.js_utils import dump_js_escaped_json +from openedx.core.lib.course_tabs import CourseTabPluginManager +from openedx.core.lib.courses import course_image_url +from openedx.features.content_type_gating.models import ContentTypeGatingConfig +from openedx.features.content_type_gating.partitions import CONTENT_TYPE_GATING_SCHEME +from openedx.features.course_experience.waffle import ENABLE_COURSE_ABOUT_SIDEBAR_HTML +from openedx.features.course_experience.waffle import waffle as course_experience_waffle +from student import auth +from student.auth import has_course_author_access, has_studio_read_access, has_studio_write_access +from student.roles import CourseCreatorRole, CourseInstructorRole, CourseStaffRole, GlobalStaff, UserBasedRole +from util.course import get_link_for_about_page +from util.date_utils import get_default_time_display +from util.json_request import JsonResponse, JsonResponseBadRequest, expect_json +from util.milestones_helpers import ( + is_entrance_exams_enabled, + is_prerequisite_courses_enabled, + is_valid_course_key, + remove_prerequisite_course, + set_prerequisite_courses, + get_namespace_choices, + generate_milestone_namespace +) +from util.organizations_helpers import add_organization_course, get_organization_by_short_name, organizations_enabled +from util.string_utils import _has_non_ascii_characters +from xblock_django.api import deprecated_xblocks +from xmodule.contentstore.content import StaticContent +from xmodule.course_module import DEFAULT_START_DATE, CourseFields +from xmodule.error_module import ErrorDescriptor +from xmodule.modulestore import EdxJSONEncoder +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.exceptions import DuplicateCourseError, ItemNotFoundError +from xmodule.partitions.partitions import UserPartition +from xmodule.tabs import CourseTab, CourseTabList, InvalidTabsException + +from .component import ADVANCED_COMPONENT_TYPES +from .item import create_xblock_info +from .library import LIBRARIES_ENABLED, get_library_creator_status + +log = logging.getLogger(__name__) + + +__all__ = ['course_info_handler', 'course_handler', 'course_listing', + 'course_info_update_handler', 'course_search_index_handler', + 'course_rerun_handler', + 'settings_handler', + 'grading_handler', + 'advanced_settings_handler', + 'course_notifications_handler', + 'textbooks_list_handler', 'textbooks_detail_handler', + 'group_configurations_list_handler', 'group_configurations_detail_handler'] + +WAFFLE_NAMESPACE = 'studio_home' + + +class AccessListFallback(Exception): + """ + An exception that is raised whenever we need to `fall back` to fetching *all* courses + available to a user, rather than using a shorter method (i.e. fetching by group) + """ + pass + + +def get_course_and_check_access(course_key, user, depth=0): + """ + Internal method used to calculate and return the locator and course module + for the view functions in this file. + """ + if not has_studio_read_access(user, course_key): + raise PermissionDenied() + course_module = modulestore().get_course(course_key, depth=depth) + return course_module + + +def reindex_course_and_check_access(course_key, user): + """ + Internal method used to restart indexing on a course. + """ + if not has_course_author_access(user, course_key): + raise PermissionDenied() + return CoursewareSearchIndexer.do_course_reindex(modulestore(), course_key) + + +@login_required +def course_notifications_handler(request, course_key_string=None, action_state_id=None): + """ + Handle incoming requests for notifications in a RESTful way. + + course_key_string and action_state_id must both be set; else a HttpBadResponseRequest is returned. + + For each of these operations, the requesting user must have access to the course; + else a PermissionDenied error is returned. + + GET + json: return json representing information about the notification (action, state, etc) + DELETE + json: return json repressing success or failure of dismissal/deletion of the notification + PUT + Raises a NotImplementedError. + POST + Raises a NotImplementedError. + """ + # ensure that we have a course and an action state + if not course_key_string or not action_state_id: + return HttpResponseBadRequest() + + response_format = request.GET.get('format') or request.POST.get('format') or 'html' + + course_key = CourseKey.from_string(course_key_string) + + if response_format == 'json' or 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'): + if not has_studio_write_access(request.user, course_key): + raise PermissionDenied() + if request.method == 'GET': + return _course_notifications_json_get(action_state_id) + elif request.method == 'DELETE': + # we assume any delete requests dismiss actions from the UI + return _dismiss_notification(request, action_state_id) + elif request.method == 'PUT': + raise NotImplementedError() + elif request.method == 'POST': + raise NotImplementedError() + else: + return HttpResponseBadRequest() + else: + return HttpResponseNotFound() + + +def _course_notifications_json_get(course_action_state_id): + """ + Return the action and the action state for the given id + """ + try: + action_state = CourseRerunState.objects.find_first(id=course_action_state_id) + except CourseActionStateItemNotFoundError: + return HttpResponseBadRequest() + + action_state_info = { + 'action': action_state.action, + 'state': action_state.state, + 'should_display': action_state.should_display + } + return JsonResponse(action_state_info) + + +def _dismiss_notification(request, course_action_state_id): + """ + Update the display of the course notification + """ + try: + action_state = CourseRerunState.objects.find_first(id=course_action_state_id) + + except CourseActionStateItemNotFoundError: + # Can't dismiss a notification that doesn't exist in the first place + return HttpResponseBadRequest() + + if action_state.state == CourseRerunUIStateManager.State.FAILED: + # We remove all permissions for this course key at this time, since + # no further access is required to a course that failed to be created. + remove_all_instructors(action_state.course_key) + + # The CourseRerunState is no longer needed by the UI; delete + action_state.delete() + + return JsonResponse({'success': True}) + + +@login_required +def course_handler(request, course_key_string=None): + """ + The restful handler for course specific requests. + It provides the course tree with the necessary information for identifying and labeling the parts. The root + will typically be a 'course' object but may not be especially as we support modules. + + GET + html: return course listing page if not given a course id + html: return html page overview for the given course if given a course id + json: return json representing the course branch's index entry as well as dag w/ all of the children + replaced w/ json docs where each doc has {'_id': , 'display_name': , 'children': } + POST + json: create a course, return resulting json + descriptor (same as in GET course/...). Leaving off /branch/draft would imply create the course w/ default + branches. Cannot change the structure contents ('_id', 'display_name', 'children') but can change the + index entry. + PUT + json: update this course (index entry not xblock) such as repointing head, changing display name, org, + course, run. Return same json as above. + DELETE + json: delete this branch from this course (leaving off /branch/draft would imply delete the course) + """ + try: + response_format = request.GET.get('format') or request.POST.get('format') or 'html' + if response_format == 'json' or 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'): + if request.method == 'GET': + course_key = CourseKey.from_string(course_key_string) + with modulestore().bulk_operations(course_key): + course_module = get_course_and_check_access(course_key, request.user, depth=None) + return JsonResponse(_course_outline_json(request, course_module)) + elif request.method == 'POST': # not sure if this is only post. If one will have ids, it goes after access + return _create_or_rerun_course(request) + elif not has_studio_write_access(request.user, CourseKey.from_string(course_key_string)): + raise PermissionDenied() + elif request.method == 'PUT': + raise NotImplementedError() + elif request.method == 'DELETE': + raise NotImplementedError() + else: + return HttpResponseBadRequest() + elif request.method == 'GET': # assume html + if course_key_string is None: + return redirect(reverse('home')) + else: + return course_index(request, CourseKey.from_string(course_key_string)) + else: + return HttpResponseNotFound() + except InvalidKeyError: + raise Http404 + + +@login_required +@ensure_csrf_cookie +@require_http_methods(["GET"]) +def course_rerun_handler(request, course_key_string): + """ + The restful handler for course reruns. + GET + html: return html page with form to rerun a course for the given course id + """ + # Only global staff (PMs) are able to rerun courses during the soft launch + # Appsembler: Also course staff can do reruns + course_key = CourseKey.from_string(course_key_string) + if not CourseStaffRole(course_key).has_user(request.user): + if not CourseInstructorRole(course_key).has_user(request.user): + if not GlobalStaff().has_user(request.user): + raise PermissionDenied() + with modulestore().bulk_operations(course_key): + course_module = get_course_and_check_access(course_key, request.user, depth=3) + if request.method == 'GET': + return render_to_response('course-create-rerun.html', { + 'source_course_key': course_key, + 'display_name': course_module.display_name, + 'user': request.user, + 'course_creator_status': _get_course_creator_status(request.user), + 'allow_unicode_course_id': settings.FEATURES.get('ALLOW_UNICODE_COURSE_ID', False) + }) + + +@login_required +@ensure_csrf_cookie +@require_GET +def course_search_index_handler(request, course_key_string): + """ + The restful handler for course indexing. + GET + html: return status of indexing task + json: return status of indexing task + """ + # Only global staff (PMs) are able to index courses + if not GlobalStaff().has_user(request.user): + raise PermissionDenied() + course_key = CourseKey.from_string(course_key_string) + content_type = request.META.get('CONTENT_TYPE', None) + if content_type is None: + content_type = "application/json; charset=utf-8" + with modulestore().bulk_operations(course_key): + try: + reindex_course_and_check_access(course_key, request.user) + except SearchIndexingError as search_err: + return HttpResponse(dump_js_escaped_json({ + "user_message": search_err.error_list + }), content_type=content_type, status=500) + return HttpResponse(dump_js_escaped_json({ + "user_message": _("Course has been successfully reindexed.") + }), content_type=content_type, status=200) + + +def _course_outline_json(request, course_module): + """ + Returns a JSON representation of the course module and recursively all of its children. + """ + is_concise = request.GET.get('format') == 'concise' + include_children_predicate = lambda xblock: not xblock.category == 'vertical' + if is_concise: + include_children_predicate = lambda xblock: xblock.has_children + return create_xblock_info( + course_module, + include_child_info=True, + course_outline=False if is_concise else True, + include_children_predicate=include_children_predicate, + is_concise=is_concise, + user=request.user + ) + + +def get_in_process_course_actions(request): + """ + Get all in-process course actions + """ + return [ + course for course in + CourseRerunState.objects.find_all( + exclude_args={'state': CourseRerunUIStateManager.State.SUCCEEDED}, + should_display=True, + ) + if has_studio_read_access(request.user, course.course_key) + ] + + +def _accessible_courses_summary_iter(request, org=None): + """ + List all courses available to the logged in user by iterating through all the courses + + Arguments: + request: the request object + org (string): if not None, this value will limit the courses returned. An empty + string will result in no courses, and otherwise only courses with the + specified org will be returned. The default value is None. + """ + def course_filter(course_summary): + """ + Filter out unusable and inaccessible courses + """ + # TODO remove this condition when templates purged from db + if course_summary.location.course == 'templates': + return False + + return has_studio_read_access(request.user, course_summary.id) + if org is not None: + courses_summary = [] if org == '' else CourseOverview.get_all_courses(orgs=[org]) + else: + courses_summary = modulestore().get_course_summaries() + courses_summary = six.moves.filter(course_filter, courses_summary) + in_process_course_actions = get_in_process_course_actions(request) + return courses_summary, in_process_course_actions + + +def _accessible_courses_iter(request): + """ + List all courses available to the logged in user by iterating through all the courses. + """ + def course_filter(course): + """ + Filter out unusable and inaccessible courses + """ + if isinstance(course, ErrorDescriptor): + return False + + # Custom Courses for edX (CCX) is an edX feature for re-using course content. + # CCXs cannot be edited in Studio (aka cms) and should not be shown in this dashboard. + if isinstance(course.id, CCXLocator): + return False + + # TODO remove this condition when templates purged from db + if course.location.course == 'templates': + return False + + return has_studio_read_access(request.user, course.id) + + courses = six.moves.filter(course_filter, modulestore().get_courses()) + + in_process_course_actions = get_in_process_course_actions(request) + return courses, in_process_course_actions + + +def _accessible_courses_iter_for_tests(request): + """ + List all courses available to the logged in user by iterating through all the courses. + CourseSummary objects are used for listing purposes. + This method is only used by tests. + """ + def course_filter(course): + """ + Filter out unusable and inaccessible courses + """ + + # Custom Courses for edX (CCX) is an edX feature for re-using course content. + # CCXs cannot be edited in Studio (aka cms) and should not be shown in this dashboard. + if isinstance(course.id, CCXLocator): + return False + + # TODO remove this condition when templates purged from db + if course.location.course == 'templates': + return False + + return has_studio_read_access(request.user, course.id) + + courses = six.moves.filter(course_filter, modulestore().get_course_summaries()) + + in_process_course_actions = get_in_process_course_actions(request) + return courses, in_process_course_actions + + +def _accessible_courses_list_from_groups(request): + """ + List all courses available to the logged in user by reversing access group names + """ + def filter_ccx(course_access): + """ CCXs cannot be edited in Studio and should not be shown in this dashboard """ + return not isinstance(course_access.course_id, CCXLocator) + + instructor_courses = UserBasedRole(request.user, CourseInstructorRole.ROLE).courses_with_role() + staff_courses = UserBasedRole(request.user, CourseStaffRole.ROLE).courses_with_role() + all_courses = list(filter(filter_ccx, instructor_courses | staff_courses)) + courses_list = [] + course_keys = {} + + for course_access in all_courses: + if course_access.course_id is None: + raise AccessListFallback + course_keys[course_access.course_id] = course_access.course_id + + course_keys = list(course_keys.values()) + + if course_keys: + courses_list = modulestore().get_course_summaries(course_keys=course_keys) + + return courses_list, [] + + +def _accessible_libraries_iter(user, org=None): + """ + List all libraries available to the logged in user by iterating through all libraries. + + org (string): if not None, this value will limit the libraries returned. An empty + string will result in no libraries, and otherwise only libraries with the + specified org will be returned. The default value is None. + """ + if org is not None: + libraries = [] if org == '' else modulestore().get_libraries(org=org) + else: + libraries = modulestore().get_library_summaries() + # No need to worry about ErrorDescriptors - split's get_libraries() never returns them. + return (lib for lib in libraries if has_studio_read_access(user, lib.location.library_key)) + + +@login_required +@ensure_csrf_cookie +def course_listing(request): + """ + List all courses and libraries available to the logged in user + """ + + optimization_enabled = GlobalStaff().has_user(request.user) and \ + WaffleSwitchNamespace(name=WAFFLE_NAMESPACE).is_enabled(u'enable_global_staff_optimization') + + org = request.GET.get('org', '') if optimization_enabled else None + courses_iter, in_process_course_actions = get_courses_accessible_to_user(request, org) + user = request.user + libraries = _accessible_libraries_iter(request.user, org) if LIBRARIES_ENABLED else [] + + def format_in_process_course_view(uca): + """ + Return a dict of the data which the view requires for each unsucceeded course + """ + return { + u'display_name': uca.display_name, + u'course_key': six.text_type(uca.course_key), + u'org': uca.course_key.org, + u'number': uca.course_key.course, + u'run': uca.course_key.run, + u'is_failed': True if uca.state == CourseRerunUIStateManager.State.FAILED else False, + u'is_in_progress': True if uca.state == CourseRerunUIStateManager.State.IN_PROGRESS else False, + u'dismiss_link': reverse_course_url( + u'course_notifications_handler', + uca.course_key, + kwargs={ + u'action_state_id': uca.id, + }, + ) if uca.state == CourseRerunUIStateManager.State.FAILED else u'' + } + + def format_library_for_view(library): + """ + Return a dict of the data which the view requires for each library + """ + + return { + u'display_name': library.display_name, + u'library_key': six.text_type(library.location.library_key), + u'url': reverse_library_url(u'library_handler', six.text_type(library.location.library_key)), + u'org': library.display_org_with_default, + u'number': library.display_number_with_default, + u'can_edit': has_studio_write_access(request.user, library.location.library_key), + } + + split_archived = settings.FEATURES.get(u'ENABLE_SEPARATE_ARCHIVED_COURSES', False) + active_courses, archived_courses = _process_courses_list(courses_iter, in_process_course_actions, split_archived) + in_process_course_actions = [format_in_process_course_view(uca) for uca in in_process_course_actions] + + return render_to_response(u'index.html', { + u'courses': active_courses, + u'archived_courses': archived_courses, + u'in_process_course_actions': in_process_course_actions, + u'libraries_enabled': LIBRARIES_ENABLED, + u'libraries': [format_library_for_view(lib) for lib in libraries], + u'show_new_library_button': get_library_creator_status(user), + u'user': user, + u'request_course_creator_url': reverse('request_course_creator'), + u'course_creator_status': _get_course_creator_status(user), + # Appsembler: Using the course creator status instead of `GlobalStaff().has_user(user)` + # to match the Tahoe multi-tenant requirements. + u'rerun_creator_status': _get_course_creator_status(user) == 'granted', + u'allow_unicode_course_id': settings.FEATURES.get(u'ALLOW_UNICODE_COURSE_ID', False), + u'allow_course_reruns': settings.FEATURES.get(u'ALLOW_COURSE_RERUNS', True), + u'optimization_enabled': optimization_enabled + }) + + +def _get_rerun_link_for_item(course_key): + """ Returns the rerun link for the given course key. """ + return reverse_course_url('course_rerun_handler', course_key) + + +def _deprecated_blocks_info(course_module, deprecated_block_types): + """ + Returns deprecation information about `deprecated_block_types` + + Arguments: + course_module (CourseDescriptor): course object + deprecated_block_types (list): list of deprecated blocks types + + Returns: + Dict with following keys: + deprecated_enabled_block_types (list): list containing all deprecated blocks types enabled on this course + blocks (list): List of `deprecated_enabled_block_types` instances and their parent's url + advance_settings_url (str): URL to advance settings page + """ + data = { + 'deprecated_enabled_block_types': [ + block_type for block_type in course_module.advanced_modules if block_type in deprecated_block_types + ], + 'blocks': [], + 'advance_settings_url': reverse_course_url('advanced_settings_handler', course_module.id) + } + + deprecated_blocks = modulestore().get_items( + course_module.id, + qualifiers={ + 'category': re.compile('^' + '$|^'.join(deprecated_block_types) + '$') + } + ) + + for block in deprecated_blocks: + data['blocks'].append([ + reverse_usage_url('container_handler', block.parent), + block.display_name + ]) + + return data + + +@login_required +@ensure_csrf_cookie +def course_index(request, course_key): + """ + Display an editable course overview. + + org, course, name: Attributes of the Location for the item to edit + """ + # A depth of None implies the whole course. The course outline needs this in order to compute has_changes. + # A unit may not have a draft version, but one of its components could, and hence the unit itself has changes. + with modulestore().bulk_operations(course_key): + course_module = get_course_and_check_access(course_key, request.user, depth=None) + if not course_module: + raise Http404 + lms_link = get_lms_link_for_item(course_module.location) + reindex_link = None + if settings.FEATURES.get('ENABLE_COURSEWARE_INDEX', False): + if GlobalStaff().has_user(request.user): + reindex_link = "/course/{course_id}/search_reindex".format(course_id=six.text_type(course_key)) + sections = course_module.get_children() + course_structure = _course_outline_json(request, course_module) + locator_to_show = request.GET.get('show', None) + + course_release_date = ( + get_default_time_display(course_module.start) + if course_module.start != DEFAULT_START_DATE + else _("Set Date") + ) + + settings_url = reverse_course_url('settings_handler', course_key) + + try: + current_action = CourseRerunState.objects.find_first(course_key=course_key, should_display=True) + except (ItemNotFoundError, CourseActionStateItemNotFoundError): + current_action = None + + deprecated_block_names = [block.name for block in deprecated_xblocks()] + deprecated_blocks_info = _deprecated_blocks_info(course_module, deprecated_block_names) + + frontend_app_publisher_url = configuration_helpers.get_value_for_org( + course_module.location.org, + 'FRONTEND_APP_PUBLISHER_URL', + settings.FEATURES.get('FRONTEND_APP_PUBLISHER_URL', False) + ) + + return render_to_response('course_outline.html', { + 'language_code': request.LANGUAGE_CODE, + 'context_course': course_module, + 'lms_link': lms_link, + 'sections': sections, + 'course_structure': course_structure, + 'initial_state': course_outline_initial_state(locator_to_show, course_structure) if locator_to_show else None, + 'rerun_notification_id': current_action.id if current_action else None, + 'course_release_date': course_release_date, + 'settings_url': settings_url, + 'reindex_link': reindex_link, + 'deprecated_blocks_info': deprecated_blocks_info, + 'notification_dismiss_url': reverse_course_url( + 'course_notifications_handler', + current_action.course_key, + kwargs={ + 'action_state_id': current_action.id, + }, + ) if current_action else None, + 'frontend_app_publisher_url': frontend_app_publisher_url, + }) + + +def get_courses_accessible_to_user(request, org=None): + """ + Try to get all courses by first reversing django groups and fallback to old method if it fails + Note: overhead of pymongo reads will increase if getting courses from django groups fails + + Arguments: + request: the request object + org (string): for global staff users ONLY, this value will be used to limit + the courses returned. A value of None will have no effect (all courses + returned), an empty string will result in no courses, and otherwise only courses with the + specified org will be returned. The default value is None. + """ + if GlobalStaff().has_user(request.user): + # user has global access so no need to get courses from django groups + courses, in_process_course_actions = _accessible_courses_summary_iter(request, org) + else: + try: + courses, in_process_course_actions = _accessible_courses_list_from_groups(request) + except AccessListFallback: + # user have some old groups or there was some error getting courses from django groups + # so fallback to iterating through all courses + courses, in_process_course_actions = _accessible_courses_summary_iter(request) + return courses, in_process_course_actions + + +def _process_courses_list(courses_iter, in_process_course_actions, split_archived=False): + """ + Iterates over the list of courses to be displayed to the user, and: + + * Removes any in-process courses from the courses list. "In-process" refers to courses + that are in the process of being generated for re-run. + * If split_archived=True, removes any archived courses and returns them in a separate list. + Archived courses have has_ended() == True. + * Formats the returned courses (in both lists) to prepare them for rendering to the view. + """ + def format_course_for_view(course): + """ + Return a dict of the data which the view requires for each course + """ + return { + 'display_name': course.display_name, + 'course_key': six.text_type(course.location.course_key), + 'url': reverse_course_url('course_handler', course.id), + 'lms_link': get_lms_link_for_item(course.location), + 'rerun_link': _get_rerun_link_for_item(course.id), + 'org': course.display_org_with_default, + 'number': course.display_number_with_default, + 'run': course.location.run + } + + in_process_action_course_keys = {uca.course_key for uca in in_process_course_actions} + active_courses = [] + archived_courses = [] + + for course in courses_iter: + # if course.has_ended(): + # continue + + if isinstance(course, ErrorDescriptor) or (course.id in in_process_action_course_keys): + continue + + formatted_course = format_course_for_view(course) + if split_archived and course.has_ended(): + archived_courses.append(formatted_course) + else: + active_courses.append(formatted_course) + + return active_courses, archived_courses + + +def course_outline_initial_state(locator_to_show, course_structure): + """ + Returns the desired initial state for the course outline view. If the 'show' request parameter + was provided, then the view's initial state will be to have the desired item fully expanded + and to scroll to see the new item. + """ + def find_xblock_info(xblock_info, locator): + """ + Finds the xblock info for the specified locator. + """ + if xblock_info['id'] == locator: + return xblock_info + children = xblock_info['child_info']['children'] if xblock_info.get('child_info', None) else None + if children: + for child_xblock_info in children: + result = find_xblock_info(child_xblock_info, locator) + if result: + return result + return None + + def collect_all_locators(locators, xblock_info): + """ + Collect all the locators for an xblock and its children. + """ + locators.append(xblock_info['id']) + children = xblock_info['child_info']['children'] if xblock_info.get('child_info', None) else None + if children: + for child_xblock_info in children: + collect_all_locators(locators, child_xblock_info) + + selected_xblock_info = find_xblock_info(course_structure, locator_to_show) + if not selected_xblock_info: + return None + expanded_locators = [] + collect_all_locators(expanded_locators, selected_xblock_info) + return { + 'locator_to_show': locator_to_show, + 'expanded_locators': expanded_locators + } + + +@expect_json +def _create_or_rerun_course(request): + """ + To be called by requests that create a new destination course (i.e., create_new_course and rerun_course) + Returns the destination course_key and overriding fields for the new course. + Raises DuplicateCourseError and InvalidKeyError + """ + if not auth.user_has_role(request.user, CourseCreatorRole()): + raise PermissionDenied() + + try: + org = request.json.get('org') + course = request.json.get('number', request.json.get('course')) + display_name = request.json.get('display_name') + # force the start date for reruns and allow us to override start via the client + start = request.json.get('start', CourseFields.start.default) + run = request.json.get('run') + + # allow/disable unicode characters in course_id according to settings + if not settings.FEATURES.get('ALLOW_UNICODE_COURSE_ID'): + if _has_non_ascii_characters(org) or _has_non_ascii_characters(course) or _has_non_ascii_characters(run): + return JsonResponse( + {'error': _('Special characters not allowed in organization, course number, and course run.')}, + status=400 + ) + + fields = {'start': start} + if display_name is not None: + fields['display_name'] = display_name + + # Set a unique wiki_slug for newly created courses. To maintain active wiki_slugs for + # existing xml courses this cannot be changed in CourseDescriptor. + # # TODO get rid of defining wiki slug in this org/course/run specific way and reconcile + # w/ xmodule.course_module.CourseDescriptor.__init__ + wiki_slug = u"{0}.{1}.{2}".format(org, course, run) + definition_data = {'wiki_slug': wiki_slug} + fields.update(definition_data) + + source_course_key = request.json.get('source_course_key') + if source_course_key: + source_course_key = CourseKey.from_string(source_course_key) + destination_course_key = rerun_course(request.user, source_course_key, org, course, run, fields) + return JsonResponse({ + 'url': reverse_url('course_handler'), + 'destination_course_key': six.text_type(destination_course_key) + }) + else: + try: + new_course = create_new_course(request.user, org, course, run, fields) + return JsonResponse({ + 'url': reverse_course_url('course_handler', new_course.id), + 'course_key': six.text_type(new_course.id), + }) + except ValidationError as ex: + return JsonResponse({'error': text_type(ex)}, status=400) + except DuplicateCourseError: + return JsonResponse({ + 'ErrMsg': _( + 'There is already a course defined with the same ' + 'organization and course number. Please ' + 'change either organization or course number to be unique.' + ), + 'OrgErrMsg': _( + 'Please change either the organization or ' + 'course number so that it is unique.'), + 'CourseErrMsg': _( + 'Please change either the organization or ' + 'course number so that it is unique.'), + }) + except InvalidKeyError as error: + return JsonResponse({ + "ErrMsg": _(u"Unable to create course '{name}'.\n\n{err}").format(name=display_name, err=text_type(error))} + ) + + +def create_new_course(user, org, number, run, fields): + """ + Create a new course run. + + Raises: + DuplicateCourseError: Course run already exists. + """ + org_data = get_organization_by_short_name(org) + if not org_data and organizations_enabled(): + raise ValidationError(_('You must link this course to an organization in order to continue. Organization ' + 'you selected does not exist in the system, you will need to add it to the system')) + store_for_new_course = modulestore().default_modulestore.get_modulestore_type() + new_course = create_new_course_in_store(store_for_new_course, user, org, number, run, fields) + add_organization_course(org_data, new_course.id) + return new_course + + +def create_new_course_in_store(store, user, org, number, run, fields): + """ + Create course in store w/ handling instructor enrollment, permissions, and defaulting the wiki slug. + Separated out b/c command line course creation uses this as well as the web interface. + """ + + # Set default language from settings and enable web certs + fields.update({ + 'language': getattr(settings, 'DEFAULT_COURSE_LANGUAGE', 'en'), + 'cert_html_view_enabled': True, + }) + + with modulestore().default_store(store): + # Creating the course raises DuplicateCourseError if an existing course with this org/name is found + new_course = modulestore().create_course( + org, + number, + run, + user.id, + fields=fields, + ) + + # Make sure user has instructor and staff access to the new course + add_instructor(new_course.id, user, user) + + # Initialize permissions for user in the new course + initialize_permissions(new_course.id, user) + return new_course + + +def rerun_course(user, source_course_key, org, number, run, fields, background=True): + """ + Rerun an existing course. + """ + # verify user has access to the original course + if not has_studio_write_access(user, source_course_key): + raise PermissionDenied() + + # create destination course key + store = modulestore() + with store.default_store('split'): + destination_course_key = store.make_course_key(org, number, run) + + # verify org course and run don't already exist + if store.has_course(destination_course_key, ignore_case=True): + raise DuplicateCourseError(source_course_key, destination_course_key) + + # Make sure user has instructor and staff access to the destination course + # so the user can see the updated status for that course + add_instructor(destination_course_key, user, user) + + # Mark the action as initiated + CourseRerunState.objects.initiated(source_course_key, destination_course_key, user, fields['display_name']) + + # Clear the fields that must be reset for the rerun + fields['advertised_start'] = None + fields['enrollment_start'] = None + fields['enrollment_end'] = None + fields['video_upload_pipeline'] = {} + + json_fields = json.dumps(fields, cls=EdxJSONEncoder) + args = [six.text_type(source_course_key), six.text_type(destination_course_key), user.id, json_fields] + + if background: + rerun_course_task.delay(*args) + else: + rerun_course_task(*args) + + return destination_course_key + + +@login_required +@ensure_csrf_cookie +@require_http_methods(["GET"]) +def course_info_handler(request, course_key_string): + """ + GET + html: return html for editing the course info handouts and updates. + """ + try: + course_key = CourseKey.from_string(course_key_string) + except InvalidKeyError: + raise Http404 + + with modulestore().bulk_operations(course_key): + course_module = get_course_and_check_access(course_key, request.user) + if not course_module: + raise Http404 + if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'): + return render_to_response( + 'course_info.html', + { + 'context_course': course_module, + 'updates_url': reverse_course_url('course_info_update_handler', course_key), + 'handouts_locator': course_key.make_usage_key('course_info', 'handouts'), + 'base_asset_url': StaticContent.get_base_url_path_for_course_assets(course_module.id), + } + ) + else: + return HttpResponseBadRequest("Only supports html requests") + + +@login_required +@ensure_csrf_cookie +@require_http_methods(("GET", "POST", "PUT", "DELETE")) +@expect_json +def course_info_update_handler(request, course_key_string, provided_id=None): + """ + restful CRUD operations on course_info updates. + provided_id should be none if it's new (create) and index otherwise. + GET + json: return the course info update models + POST + json: create an update + PUT or DELETE + json: change an existing update + """ + if 'application/json' not in request.META.get('HTTP_ACCEPT', 'application/json'): + return HttpResponseBadRequest("Only supports json requests") + + course_key = CourseKey.from_string(course_key_string) + usage_key = course_key.make_usage_key('course_info', 'updates') + if provided_id == '': + provided_id = None + + # check that logged in user has permissions to this item (GET shouldn't require this level?) + if not has_studio_write_access(request.user, usage_key.course_key): + raise PermissionDenied() + + if request.method == 'GET': + course_updates = get_course_updates(usage_key, provided_id, request.user.id) + if isinstance(course_updates, dict) and course_updates.get('error'): + return JsonResponse(course_updates, course_updates.get('status', 400)) + else: + return JsonResponse(course_updates) + elif request.method == 'DELETE': + try: + return JsonResponse(delete_course_update(usage_key, request.json, provided_id, request.user)) + except: + return HttpResponseBadRequest( + "Failed to delete", + content_type="text/plain" + ) + # can be either and sometimes django is rewriting one to the other: + elif request.method in ('POST', 'PUT'): + try: + return JsonResponse(update_course_updates(usage_key, request.json, provided_id, request.user)) + except: + return HttpResponseBadRequest( + "Failed to save", + content_type="text/plain" + ) + + +@login_required +@ensure_csrf_cookie +@require_http_methods(("GET", "PUT", "POST")) +@expect_json +def settings_handler(request, course_key_string): + """ + Course settings for dates and about pages + GET + html: get the page + json: get the CourseDetails model + PUT + json: update the Course and About xblocks through the CourseDetails model + """ + course_key = CourseKey.from_string(course_key_string) + credit_eligibility_enabled = settings.FEATURES.get('ENABLE_CREDIT_ELIGIBILITY', False) + with modulestore().bulk_operations(course_key): + course_module = get_course_and_check_access(course_key, request.user) + if 'text/html' in request.META.get('HTTP_ACCEPT', '') and request.method == 'GET': + upload_asset_url = reverse_course_url('assets_handler', course_key) + + # see if the ORG of this course can be attributed to a defined configuration . In that case, the + # course about page should be editable in Studio + publisher_enabled = configuration_helpers.get_value_for_org( + course_module.location.org, + 'ENABLE_PUBLISHER', + settings.FEATURES.get('ENABLE_PUBLISHER', False) + ) + marketing_enabled = configuration_helpers.get_value_for_org( + course_module.location.org, + 'ENABLE_MKTG_SITE', + settings.FEATURES.get('ENABLE_MKTG_SITE', False) + ) + enable_extended_course_details = configuration_helpers.get_value_for_org( + course_module.location.org, + 'ENABLE_EXTENDED_COURSE_DETAILS', + settings.FEATURES.get('ENABLE_EXTENDED_COURSE_DETAILS', False) + ) + + about_page_editable = not publisher_enabled + enrollment_end_editable = GlobalStaff().has_user(request.user) or not publisher_enabled + short_description_editable = configuration_helpers.get_value_for_org( + course_module.location.org, + 'EDITABLE_SHORT_DESCRIPTION', + settings.FEATURES.get('EDITABLE_SHORT_DESCRIPTION', True) + ) + sidebar_html_enabled = course_experience_waffle().is_enabled(ENABLE_COURSE_ABOUT_SIDEBAR_HTML) + # self_paced_enabled = SelfPacedConfiguration.current().enabled + + verified_mode = CourseMode.verified_mode_for_course(course_key, include_expired=True) + upgrade_deadline = (verified_mode and verified_mode.expiration_datetime and + verified_mode.expiration_datetime.isoformat()) + + settings_context = { + 'context_course': course_module, + 'course_locator': course_key, + 'lms_link_for_about_page': get_link_for_about_page(course_module), + 'course_image_url': course_image_url(course_module, 'course_image'), + 'banner_image_url': course_image_url(course_module, 'banner_image'), + 'video_thumbnail_image_url': course_image_url(course_module, 'video_thumbnail_image'), + 'details_url': reverse_course_url('settings_handler', course_key), + 'about_page_editable': about_page_editable, + 'marketing_enabled': marketing_enabled, + 'short_description_editable': short_description_editable, + 'sidebar_html_enabled': sidebar_html_enabled, + 'upload_asset_url': upload_asset_url, + 'course_handler_url': reverse_course_url('course_handler', course_key), + 'language_options': settings.ALL_LANGUAGES, + 'credit_eligibility_enabled': credit_eligibility_enabled, + 'is_credit_course': False, + 'show_min_grade_warning': False, + 'enrollment_end_editable': enrollment_end_editable, + 'is_prerequisite_courses_enabled': is_prerequisite_courses_enabled(), + 'is_entrance_exams_enabled': is_entrance_exams_enabled(), + 'enable_extended_course_details': enable_extended_course_details, + 'upgrade_deadline': upgrade_deadline, + } + if is_prerequisite_courses_enabled(): + courses, in_process_course_actions = get_courses_accessible_to_user(request, course_module.location.org) + # exclude current course from the list of available courses + courses = (course for course in courses if course.id != course_key) + if courses: + courses, __ = _process_courses_list(courses, in_process_course_actions) + settings_context.update({'possible_pre_requisite_courses': list(courses)}) + + if credit_eligibility_enabled: + if is_credit_course(course_key): + # get and all credit eligibility requirements + credit_requirements = get_credit_requirements(course_key) + # pair together requirements with same 'namespace' values + paired_requirements = {} + for requirement in credit_requirements: + namespace = requirement.pop("namespace") + paired_requirements.setdefault(namespace, []).append(requirement) + + # if 'minimum_grade_credit' of a course is not set or 0 then + # show warning message to course author. + show_min_grade_warning = False if course_module.minimum_grade_credit > 0 else True + settings_context.update( + { + 'is_credit_course': True, + 'credit_requirements': paired_requirements, + 'show_min_grade_warning': show_min_grade_warning, + } + ) + + return render_to_response('settings.html', settings_context) + elif 'application/json' in request.META.get('HTTP_ACCEPT', ''): + if request.method == 'GET': + course_details = CourseDetails.fetch(course_key) + return JsonResponse( + course_details, + # encoder serializes dates, old locations, and instances + encoder=CourseSettingsEncoder + ) + # For every other possible method type submitted by the caller... + else: + # if pre-requisite course feature is enabled set pre-requisite course + if is_prerequisite_courses_enabled(): + prerequisite_course_keys = request.json.get('pre_requisite_courses', []) + if prerequisite_course_keys: + if not all(is_valid_course_key(course_key) for course_key in prerequisite_course_keys): + return JsonResponseBadRequest({"error": _("Invalid prerequisite course key")}) + set_prerequisite_courses(course_key, prerequisite_course_keys) + else: + # None is chosen, so remove the course prerequisites + course_milestones = milestones_api.get_course_milestones(course_key=course_key, relationship="requires") + for milestone in course_milestones: + ee_milestone_namespace = generate_milestone_namespace( + get_namespace_choices().get('ENTRANCE_EXAM'), + course_key + ) + if not milestone["namespace"] == ee_milestone_namespace: + remove_prerequisite_course(course_key, milestone) + + # If the entrance exams feature has been enabled, we'll need to check for some + # feature-specific settings and handle them accordingly + # We have to be careful that we're only executing the following logic if we actually + # need to create or delete an entrance exam from the specified course + if is_entrance_exams_enabled(): + course_entrance_exam_present = course_module.entrance_exam_enabled + entrance_exam_enabled = request.json.get('entrance_exam_enabled', '') == 'true' + ee_min_score_pct = request.json.get('entrance_exam_minimum_score_pct', None) + # If the entrance exam box on the settings screen has been checked... + if entrance_exam_enabled: + # Load the default minimum score threshold from settings, then try to override it + entrance_exam_minimum_score_pct = float(settings.ENTRANCE_EXAM_MIN_SCORE_PCT) + if ee_min_score_pct: + entrance_exam_minimum_score_pct = float(ee_min_score_pct) + if entrance_exam_minimum_score_pct.is_integer(): + entrance_exam_minimum_score_pct = entrance_exam_minimum_score_pct / 100 + # If there's already an entrance exam defined, we'll update the existing one + if course_entrance_exam_present: + exam_data = { + 'entrance_exam_minimum_score_pct': entrance_exam_minimum_score_pct + } + update_entrance_exam(request, course_key, exam_data) + # If there's no entrance exam defined, we'll create a new one + else: + create_entrance_exam(request, course_key, entrance_exam_minimum_score_pct) + + # If the entrance exam box on the settings screen has been unchecked, + # and the course has an entrance exam attached... + elif not entrance_exam_enabled and course_entrance_exam_present: + delete_entrance_exam(request, course_key) + + # Perform the normal update workflow for the CourseDetails model + return JsonResponse( + CourseDetails.update_from_json(course_key, request.json, request.user), + encoder=CourseSettingsEncoder + ) + + +@login_required +@ensure_csrf_cookie +@require_http_methods(("GET", "POST", "PUT", "DELETE")) +@expect_json +def grading_handler(request, course_key_string, grader_index=None): + """ + Course Grading policy configuration + GET + html: get the page + json no grader_index: get the CourseGrading model (graceperiod, cutoffs, and graders) + json w/ grader_index: get the specific grader + PUT + json no grader_index: update the Course through the CourseGrading model + json w/ grader_index: create or update the specific grader (create if index out of range) + """ + course_key = CourseKey.from_string(course_key_string) + with modulestore().bulk_operations(course_key): + course_module = get_course_and_check_access(course_key, request.user) + + if 'text/html' in request.META.get('HTTP_ACCEPT', '') and request.method == 'GET': + course_details = CourseGradingModel.fetch(course_key) + + return render_to_response('settings_graders.html', { + 'context_course': course_module, + 'course_locator': course_key, + 'course_details': course_details, + 'grading_url': reverse_course_url('grading_handler', course_key), + 'is_credit_course': is_credit_course(course_key), + }) + elif 'application/json' in request.META.get('HTTP_ACCEPT', ''): + if request.method == 'GET': + if grader_index is None: + return JsonResponse( + CourseGradingModel.fetch(course_key), + # encoder serializes dates, old locations, and instances + encoder=CourseSettingsEncoder + ) + else: + return JsonResponse(CourseGradingModel.fetch_grader(course_key, grader_index)) + elif request.method in ('POST', 'PUT'): # post or put, doesn't matter. + # update credit course requirements if 'minimum_grade_credit' + # field value is changed + if 'minimum_grade_credit' in request.json: + update_credit_course_requirements.delay(six.text_type(course_key)) + + # None implies update the whole model (cutoffs, graceperiod, and graders) not a specific grader + if grader_index is None: + return JsonResponse( + CourseGradingModel.update_from_json(course_key, request.json, request.user), + encoder=CourseSettingsEncoder + ) + else: + return JsonResponse( + CourseGradingModel.update_grader_from_json(course_key, request.json, request.user) + ) + elif request.method == "DELETE" and grader_index is not None: + CourseGradingModel.delete_grader(course_key, grader_index, request.user) + return JsonResponse() + + +def _refresh_course_tabs(request, course_module): + """ + Automatically adds/removes tabs if changes to the course require them. + + Raises: + InvalidTabsException: raised if there's a problem with the new version of the tabs. + """ + + def update_tab(tabs, tab_type, tab_enabled): + """ + Adds or removes a course tab based upon whether it is enabled. + """ + tab_panel = { + "type": tab_type.type, + } + has_tab = tab_panel in tabs + if tab_enabled and not has_tab: + tabs.append(CourseTab.from_json(tab_panel)) + elif not tab_enabled and has_tab: + tabs.remove(tab_panel) + + course_tabs = copy.copy(course_module.tabs) + + # Additionally update any tabs that are provided by non-dynamic course views + for tab_type in CourseTabPluginManager.get_tab_types(): + if not tab_type.is_dynamic and tab_type.is_default: + tab_enabled = tab_type.is_enabled(course_module, user=request.user) + update_tab(course_tabs, tab_type, tab_enabled) + + CourseTabList.validate_tabs(course_tabs) + + # Save the tabs into the course if they have been changed + if course_tabs != course_module.tabs: + course_module.tabs = course_tabs + + +@login_required +@ensure_csrf_cookie +@require_http_methods(("GET", "POST", "PUT")) +@expect_json +def advanced_settings_handler(request, course_key_string): + """ + Course settings configuration + GET + html: get the page + json: get the model + PUT, POST + json: update the Course's settings. The payload is a json rep of the + metadata dicts. + """ + course_key = CourseKey.from_string(course_key_string) + with modulestore().bulk_operations(course_key): + course_module = get_course_and_check_access(course_key, request.user) + + advanced_dict = CourseMetadata.fetch(course_module) + if settings.FEATURES.get('DISABLE_MOBILE_COURSE_AVAILABLE', False): + advanced_dict.get('mobile_available')['deprecated'] = True + + if 'text/html' in request.META.get('HTTP_ACCEPT', '') and request.method == 'GET': + publisher_enabled = configuration_helpers.get_value_for_org( + course_module.location.org, + 'ENABLE_PUBLISHER', + settings.FEATURES.get('ENABLE_PUBLISHER', False) + ) + + return render_to_response('settings_advanced.html', { + 'context_course': course_module, + 'advanced_dict': advanced_dict, + 'advanced_settings_url': reverse_course_url('advanced_settings_handler', course_key), + 'publisher_enabled': publisher_enabled, + + }) + elif 'application/json' in request.META.get('HTTP_ACCEPT', ''): + if request.method == 'GET': + return JsonResponse(CourseMetadata.fetch(course_module)) + else: + try: + # validate data formats and update the course module. + # Note: don't update mongo yet, but wait until after any tabs are changed + is_valid, errors, updated_data = CourseMetadata.validate_and_update_from_json( + course_module, + request.json, + user=request.user, + ) + + if is_valid: + try: + # update the course tabs if required by any setting changes + _refresh_course_tabs(request, course_module) + except InvalidTabsException as err: + log.exception(text_type(err)) + response_message = [ + { + 'message': _('An error occurred while trying to save your tabs'), + 'model': {'display_name': _('Tabs Exception')} + } + ] + return JsonResponseBadRequest(response_message) + + # now update mongo + modulestore().update_item(course_module, request.user.id) + + return JsonResponse(updated_data) + else: + return JsonResponseBadRequest(errors) + + # Handle all errors that validation doesn't catch + except (TypeError, ValueError, InvalidTabsException) as err: + return HttpResponseBadRequest( + django.utils.html.escape(text_type(err)), + content_type="text/plain" + ) + + +class TextbookValidationError(Exception): + "An error thrown when a textbook input is invalid" + pass + + +def validate_textbooks_json(text): + """ + Validate the given text as representing a single PDF textbook + """ + if isinstance(text, (bytes, bytearray)): # data appears as bytes + text = text.decode('utf-8') + try: + textbooks = json.loads(text) + except ValueError: + raise TextbookValidationError("invalid JSON") + if not isinstance(textbooks, (list, tuple)): + raise TextbookValidationError("must be JSON list") + for textbook in textbooks: + validate_textbook_json(textbook) + # check specified IDs for uniqueness + all_ids = [textbook["id"] for textbook in textbooks if "id" in textbook] + unique_ids = set(all_ids) + if len(all_ids) > len(unique_ids): + raise TextbookValidationError("IDs must be unique") + return textbooks + + +def validate_textbook_json(textbook): + """ + Validate the given text as representing a list of PDF textbooks + """ + if isinstance(textbook, (bytes, bytearray)): # data appears as bytes + textbook = textbook.decode('utf-8') + if isinstance(textbook, six.string_types): + try: + textbook = json.loads(textbook) + except ValueError: + raise TextbookValidationError("invalid JSON") + if not isinstance(textbook, dict): + raise TextbookValidationError("must be JSON object") + if not textbook.get("tab_title"): + raise TextbookValidationError("must have tab_title") + tid = six.text_type(textbook.get("id", "")) + if tid and not tid[0].isdigit(): + raise TextbookValidationError("textbook ID must start with a digit") + return textbook + + +def assign_textbook_id(textbook, used_ids=()): + """ + Return an ID that can be assigned to a textbook + and doesn't match the used_ids + """ + tid = BlockUsageLocator.clean(textbook["tab_title"]) + if not tid[0].isdigit(): + # stick a random digit in front + tid = random.choice(string.digits) + tid + while tid in used_ids: + # add a random ASCII character to the end + tid = tid + random.choice(string.ascii_lowercase) + return tid + + +@require_http_methods(("GET", "POST", "PUT")) +@login_required +@ensure_csrf_cookie +def textbooks_list_handler(request, course_key_string): + """ + A RESTful handler for textbook collections. + + GET + html: return textbook list page (Backbone application) + json: return JSON representation of all textbooks in this course + POST + json: create a new textbook for this course + PUT + json: overwrite all textbooks in the course with the given list + """ + course_key = CourseKey.from_string(course_key_string) + store = modulestore() + with store.bulk_operations(course_key): + course = get_course_and_check_access(course_key, request.user) + + if "application/json" not in request.META.get('HTTP_ACCEPT', 'text/html'): + # return HTML page + upload_asset_url = reverse_course_url('assets_handler', course_key) + textbook_url = reverse_course_url('textbooks_list_handler', course_key) + return render_to_response('textbooks.html', { + 'context_course': course, + 'textbooks': course.pdf_textbooks, + 'upload_asset_url': upload_asset_url, + 'textbook_url': textbook_url, + }) + + # from here on down, we know the client has requested JSON + if request.method == 'GET': + return JsonResponse(course.pdf_textbooks) + elif request.method == 'PUT': + try: + textbooks = validate_textbooks_json(request.body) + except TextbookValidationError as err: + return JsonResponse({"error": text_type(err)}, status=400) + + tids = set(t["id"] for t in textbooks if "id" in t) + for textbook in textbooks: + if "id" not in textbook: + tid = assign_textbook_id(textbook, tids) + textbook["id"] = tid + tids.add(tid) + + if not any(tab['type'] == 'pdf_textbooks' for tab in course.tabs): + course.tabs.append(CourseTab.load('pdf_textbooks')) + course.pdf_textbooks = textbooks + store.update_item(course, request.user.id) + return JsonResponse(course.pdf_textbooks) + elif request.method == 'POST': + # create a new textbook for the course + try: + textbook = validate_textbook_json(request.body) + except TextbookValidationError as err: + return JsonResponse({"error": text_type(err)}, status=400) + if not textbook.get("id"): + tids = set(t["id"] for t in course.pdf_textbooks if "id" in t) + textbook["id"] = assign_textbook_id(textbook, tids) + existing = course.pdf_textbooks + existing.append(textbook) + course.pdf_textbooks = existing + if not any(tab['type'] == 'pdf_textbooks' for tab in course.tabs): + course.tabs.append(CourseTab.load('pdf_textbooks')) + store.update_item(course, request.user.id) + resp = JsonResponse(textbook, status=201) + resp["Location"] = reverse_course_url( + 'textbooks_detail_handler', + course.id, + kwargs={'textbook_id': textbook["id"]} + ) + return resp + + +@login_required +@ensure_csrf_cookie +@require_http_methods(("GET", "POST", "PUT", "DELETE")) +def textbooks_detail_handler(request, course_key_string, textbook_id): + """ + JSON API endpoint for manipulating a textbook via its internal ID. + Used by the Backbone application. + + GET + json: return JSON representation of textbook + POST or PUT + json: update textbook based on provided information + DELETE + json: remove textbook + """ + course_key = CourseKey.from_string(course_key_string) + store = modulestore() + with store.bulk_operations(course_key): + course_module = get_course_and_check_access(course_key, request.user) + matching_id = [tb for tb in course_module.pdf_textbooks + if six.text_type(tb.get("id")) == six.text_type(textbook_id)] + if matching_id: + textbook = matching_id[0] + else: + textbook = None + + if request.method == 'GET': + if not textbook: + return JsonResponse(status=404) + return JsonResponse(textbook) + elif request.method in ('POST', 'PUT'): # can be either and sometimes django is rewriting one to the other + try: + new_textbook = validate_textbook_json(request.body) + except TextbookValidationError as err: + return JsonResponse({"error": text_type(err)}, status=400) + new_textbook["id"] = textbook_id + if textbook: + i = course_module.pdf_textbooks.index(textbook) + new_textbooks = course_module.pdf_textbooks[0:i] + new_textbooks.append(new_textbook) + new_textbooks.extend(course_module.pdf_textbooks[i + 1:]) + course_module.pdf_textbooks = new_textbooks + else: + course_module.pdf_textbooks.append(new_textbook) + store.update_item(course_module, request.user.id) + return JsonResponse(new_textbook, status=201) + elif request.method == 'DELETE': + if not textbook: + return JsonResponse(status=404) + i = course_module.pdf_textbooks.index(textbook) + remaining_textbooks = course_module.pdf_textbooks[0:i] + remaining_textbooks.extend(course_module.pdf_textbooks[i + 1:]) + course_module.pdf_textbooks = remaining_textbooks + store.update_item(course_module, request.user.id) + return JsonResponse() + + +def remove_content_or_experiment_group(request, store, course, configuration, group_configuration_id, group_id=None): + """ + Remove content group or experiment group configuration only if it's not in use. + """ + configuration_index = course.user_partitions.index(configuration) + if configuration.scheme.name == RANDOM_SCHEME: + usages = GroupConfiguration.get_content_experiment_usage_info(store, course) + used = int(group_configuration_id) in usages + + if used: + return JsonResponse( + {"error": _("This group configuration is in use and cannot be deleted.")}, + status=400 + ) + course.user_partitions.pop(configuration_index) + elif configuration.scheme.name == COHORT_SCHEME: + if not group_id: + return JsonResponse(status=404) + + group_id = int(group_id) + usages = GroupConfiguration.get_partitions_usage_info(store, course) + used = group_id in usages[configuration.id] + + if used: + return JsonResponse( + {"error": _("This content group is in use and cannot be deleted.")}, + status=400 + ) + + matching_groups = [group for group in configuration.groups if group.id == group_id] + if matching_groups: + group_index = configuration.groups.index(matching_groups[0]) + configuration.groups.pop(group_index) + else: + return JsonResponse(status=404) + + course.user_partitions[configuration_index] = configuration + + store.update_item(course, request.user.id) + return JsonResponse(status=204) + + +@require_http_methods(("GET", "POST")) +@login_required +@ensure_csrf_cookie +def group_configurations_list_handler(request, course_key_string): + """ + A RESTful handler for Group Configurations + + GET + html: return Group Configurations list page (Backbone application) + POST + json: create new group configuration + """ + course_key = CourseKey.from_string(course_key_string) + store = modulestore() + with store.bulk_operations(course_key): + course = get_course_and_check_access(course_key, request.user) + + if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'): + group_configuration_url = reverse_course_url('group_configurations_list_handler', course_key) + course_outline_url = reverse_course_url('course_handler', course_key) + should_show_experiment_groups = are_content_experiments_enabled(course) + if should_show_experiment_groups: + experiment_group_configurations = GroupConfiguration.get_split_test_partitions_with_usage(store, course) + else: + experiment_group_configurations = None + + all_partitions = GroupConfiguration.get_all_user_partition_details(store, course) + should_show_enrollment_track = False + has_content_groups = False + displayable_partitions = [] + for partition in all_partitions: + partition['read_only'] = getattr(UserPartition.get_scheme(partition['scheme']), 'read_only', False) + + if partition['scheme'] == COHORT_SCHEME: + has_content_groups = True + displayable_partitions.append(partition) + elif partition['scheme'] == CONTENT_TYPE_GATING_SCHEME: + # Add it to the front of the list if it should be shown. + if ContentTypeGatingConfig.current(course_key=course_key).studio_override_enabled: + displayable_partitions.append(partition) + elif partition['scheme'] == ENROLLMENT_SCHEME: + should_show_enrollment_track = len(partition['groups']) > 1 + + # Add it to the front of the list if it should be shown. + if should_show_enrollment_track: + displayable_partitions.insert(0, partition) + elif partition['scheme'] != RANDOM_SCHEME: + # Experiment group configurations are handled explicitly above. We don't + # want to display their groups twice. + displayable_partitions.append(partition) + + # Set the sort-order. Higher numbers sort earlier + scheme_priority = defaultdict(lambda: -1, { + ENROLLMENT_SCHEME: 1, + CONTENT_TYPE_GATING_SCHEME: 0 + }) + displayable_partitions.sort(key=lambda p: scheme_priority[p['scheme']], reverse=True) + # Add empty content group if there is no COHORT User Partition in the list. + # This will add ability to add new groups in the view. + if not has_content_groups: + displayable_partitions.append(GroupConfiguration.get_or_create_content_group(store, course)) + + return render_to_response('group_configurations.html', { + 'context_course': course, + 'group_configuration_url': group_configuration_url, + 'course_outline_url': course_outline_url, + 'experiment_group_configurations': experiment_group_configurations, + 'should_show_experiment_groups': should_show_experiment_groups, + 'all_group_configurations': displayable_partitions, + 'should_show_enrollment_track': should_show_enrollment_track + }) + elif "application/json" in request.META.get('HTTP_ACCEPT'): + if request.method == 'POST': + # create a new group configuration for the course + try: + new_configuration = GroupConfiguration(request.body, course).get_user_partition() + except GroupConfigurationsValidationError as err: + return JsonResponse({"error": text_type(err)}, status=400) + + course.user_partitions.append(new_configuration) + response = JsonResponse(new_configuration.to_json(), status=201) + + response["Location"] = reverse_course_url( + 'group_configurations_detail_handler', + course.id, + kwargs={'group_configuration_id': new_configuration.id} + ) + store.update_item(course, request.user.id) + return response + else: + return HttpResponse(status=406) + + +@login_required +@ensure_csrf_cookie +@require_http_methods(("POST", "PUT", "DELETE")) +def group_configurations_detail_handler(request, course_key_string, group_configuration_id, group_id=None): + """ + JSON API endpoint for manipulating a group configuration via its internal ID. + Used by the Backbone application. + + POST or PUT + json: update group configuration based on provided information + """ + course_key = CourseKey.from_string(course_key_string) + store = modulestore() + with store.bulk_operations(course_key): + course = get_course_and_check_access(course_key, request.user) + matching_id = [p for p in course.user_partitions + if six.text_type(p.id) == six.text_type(group_configuration_id)] + if matching_id: + configuration = matching_id[0] + else: + configuration = None + + if request.method in ('POST', 'PUT'): # can be either and sometimes django is rewriting one to the other + try: + new_configuration = GroupConfiguration(request.body, course, group_configuration_id).get_user_partition() + except GroupConfigurationsValidationError as err: + return JsonResponse({"error": text_type(err)}, status=400) + + if configuration: + index = course.user_partitions.index(configuration) + course.user_partitions[index] = new_configuration + else: + course.user_partitions.append(new_configuration) + store.update_item(course, request.user.id) + configuration = GroupConfiguration.update_usage_info(store, course, new_configuration) + return JsonResponse(configuration, status=201) + + elif request.method == "DELETE": + if not configuration: + return JsonResponse(status=404) + + return remove_content_or_experiment_group( + request=request, + store=store, + course=course, + configuration=configuration, + group_configuration_id=group_configuration_id, + group_id=group_id + ) + + +def are_content_experiments_enabled(course): + """ + Returns True if content experiments have been enabled for the course. + """ + return ( + 'split_test' in ADVANCED_COMPONENT_TYPES and + 'split_test' in course.advanced_modules + ) + + +def _get_course_creator_status(user): + """ + Helper method for returning the course creator status for a particular user, + taking into account the values of DISABLE_COURSE_CREATION and ENABLE_CREATOR_GROUP. + + If the user passed in has not previously visited the index page, it will be + added with status 'unrequested' if the course creator group is in use. + """ + + if user.is_staff: + course_creator_status = 'granted' + elif settings.FEATURES.get('DISABLE_COURSE_CREATION', False): + course_creator_status = 'disallowed_for_this_site' + elif settings.FEATURES.get('ENABLE_CREATOR_GROUP', False): + course_creator_status = get_course_creator_status(user) + if course_creator_status is None: + # User not grandfathered in as an existing user, has not previously visited the dashboard page. + # Add the user to the course creator admin table with status 'unrequested'. + add_user_with_status_unrequested(user) + course_creator_status = get_course_creator_status(user) + else: + course_creator_status = 'granted' + + return course_creator_status diff --git a/cms/djangoapps/contentstore/views/course.py.original b/cms/djangoapps/contentstore/views/course.py.original new file mode 100644 index 000000000000..bd194177c10f --- /dev/null +++ b/cms/djangoapps/contentstore/views/course.py.original @@ -0,0 +1,1801 @@ +""" +Views related to operations on course objects +""" + + +import copy +import json +import logging +import random +import re +import string +from collections import defaultdict + +import django.utils +import six +from ccx_keys.locator import CCXLocator +from django.conf import settings +from django.contrib.auth.decorators import login_required +from django.core.exceptions import PermissionDenied, ValidationError +from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotFound +from django.shortcuts import redirect +from django.urls import reverse +from django.utils.translation import ugettext as _ +from django.views.decorators.csrf import ensure_csrf_cookie +from django.views.decorators.http import require_GET, require_http_methods +from milestones import api as milestones_api +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey +from opaque_keys.edx.locator import BlockUsageLocator +from six import text_type +from six.moves import filter + +from contentstore.course_group_config import ( + COHORT_SCHEME, + ENROLLMENT_SCHEME, + RANDOM_SCHEME, + GroupConfiguration, + GroupConfigurationsValidationError +) +from contentstore.course_info_model import delete_course_update, get_course_updates, update_course_updates +from contentstore.courseware_index import CoursewareSearchIndexer, SearchIndexingError +from contentstore.tasks import rerun_course as rerun_course_task +from contentstore.utils import ( + add_instructor, + get_lms_link_for_item, + initialize_permissions, + remove_all_instructors, + reverse_course_url, + reverse_library_url, + reverse_url, + reverse_usage_url +) +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 add_user_with_status_unrequested, get_course_creator_status +from course_modes.models import CourseMode +from edxmako.shortcuts import render_to_response +from models.settings.course_grading import CourseGradingModel +from models.settings.course_metadata import CourseMetadata +from models.settings.encoder import CourseSettingsEncoder +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from openedx.core.djangoapps.credit.api import get_credit_requirements, is_credit_course +from openedx.core.djangoapps.credit.tasks import update_credit_course_requirements +from openedx.core.djangoapps.models.course_details import CourseDetails +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace +from openedx.core.djangolib.js_utils import dump_js_escaped_json +from openedx.core.lib.course_tabs import CourseTabPluginManager +from openedx.core.lib.courses import course_image_url +from openedx.features.content_type_gating.models import ContentTypeGatingConfig +from openedx.features.content_type_gating.partitions import CONTENT_TYPE_GATING_SCHEME +from openedx.features.course_experience.waffle import ENABLE_COURSE_ABOUT_SIDEBAR_HTML +from openedx.features.course_experience.waffle import waffle as course_experience_waffle +from student import auth +from student.auth import has_course_author_access, has_studio_read_access, has_studio_write_access +from student.roles import CourseCreatorRole, CourseInstructorRole, CourseStaffRole, GlobalStaff, UserBasedRole +from util.course import get_link_for_about_page +from util.date_utils import get_default_time_display +from util.json_request import JsonResponse, JsonResponseBadRequest, expect_json +from util.milestones_helpers import ( + is_entrance_exams_enabled, + is_prerequisite_courses_enabled, + is_valid_course_key, + remove_prerequisite_course, + set_prerequisite_courses, + get_namespace_choices, + generate_milestone_namespace +) +from util.organizations_helpers import add_organization_course, get_organization_by_short_name, organizations_enabled +from util.string_utils import _has_non_ascii_characters +from xblock_django.api import deprecated_xblocks +from xmodule.contentstore.content import StaticContent +from xmodule.course_module import DEFAULT_START_DATE, CourseFields +from xmodule.error_module import ErrorDescriptor +from xmodule.modulestore import EdxJSONEncoder +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.exceptions import DuplicateCourseError, ItemNotFoundError +from xmodule.partitions.partitions import UserPartition +from xmodule.tabs import CourseTab, CourseTabList, InvalidTabsException + +from .component import ADVANCED_COMPONENT_TYPES +from .item import create_xblock_info +from .library import LIBRARIES_ENABLED, get_library_creator_status + +log = logging.getLogger(__name__) + + +__all__ = ['course_info_handler', 'course_handler', 'course_listing', + 'course_info_update_handler', 'course_search_index_handler', + 'course_rerun_handler', + 'settings_handler', + 'grading_handler', + 'advanced_settings_handler', + 'course_notifications_handler', + 'textbooks_list_handler', 'textbooks_detail_handler', + 'group_configurations_list_handler', 'group_configurations_detail_handler'] + +WAFFLE_NAMESPACE = 'studio_home' + + +class AccessListFallback(Exception): + """ + An exception that is raised whenever we need to `fall back` to fetching *all* courses + available to a user, rather than using a shorter method (i.e. fetching by group) + """ + pass + + +def get_course_and_check_access(course_key, user, depth=0): + """ + Internal method used to calculate and return the locator and course module + for the view functions in this file. + """ + if not has_studio_read_access(user, course_key): + raise PermissionDenied() + course_module = modulestore().get_course(course_key, depth=depth) + return course_module + + +def reindex_course_and_check_access(course_key, user): + """ + Internal method used to restart indexing on a course. + """ + if not has_course_author_access(user, course_key): + raise PermissionDenied() + return CoursewareSearchIndexer.do_course_reindex(modulestore(), course_key) + + +@login_required +def course_notifications_handler(request, course_key_string=None, action_state_id=None): + """ + Handle incoming requests for notifications in a RESTful way. + + course_key_string and action_state_id must both be set; else a HttpBadResponseRequest is returned. + + For each of these operations, the requesting user must have access to the course; + else a PermissionDenied error is returned. + + GET + json: return json representing information about the notification (action, state, etc) + DELETE + json: return json repressing success or failure of dismissal/deletion of the notification + PUT + Raises a NotImplementedError. + POST + Raises a NotImplementedError. + """ + # ensure that we have a course and an action state + if not course_key_string or not action_state_id: + return HttpResponseBadRequest() + + response_format = request.GET.get('format') or request.POST.get('format') or 'html' + + course_key = CourseKey.from_string(course_key_string) + + if response_format == 'json' or 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'): + if not has_studio_write_access(request.user, course_key): + raise PermissionDenied() + if request.method == 'GET': + return _course_notifications_json_get(action_state_id) + elif request.method == 'DELETE': + # we assume any delete requests dismiss actions from the UI + return _dismiss_notification(request, action_state_id) + elif request.method == 'PUT': + raise NotImplementedError() + elif request.method == 'POST': + raise NotImplementedError() + else: + return HttpResponseBadRequest() + else: + return HttpResponseNotFound() + + +def _course_notifications_json_get(course_action_state_id): + """ + Return the action and the action state for the given id + """ + try: + action_state = CourseRerunState.objects.find_first(id=course_action_state_id) + except CourseActionStateItemNotFoundError: + return HttpResponseBadRequest() + + action_state_info = { + 'action': action_state.action, + 'state': action_state.state, + 'should_display': action_state.should_display + } + return JsonResponse(action_state_info) + + +def _dismiss_notification(request, course_action_state_id): + """ + Update the display of the course notification + """ + try: + action_state = CourseRerunState.objects.find_first(id=course_action_state_id) + + except CourseActionStateItemNotFoundError: + # Can't dismiss a notification that doesn't exist in the first place + return HttpResponseBadRequest() + + if action_state.state == CourseRerunUIStateManager.State.FAILED: + # We remove all permissions for this course key at this time, since + # no further access is required to a course that failed to be created. + remove_all_instructors(action_state.course_key) + + # The CourseRerunState is no longer needed by the UI; delete + action_state.delete() + + return JsonResponse({'success': True}) + + +@login_required +def course_handler(request, course_key_string=None): + """ + The restful handler for course specific requests. + It provides the course tree with the necessary information for identifying and labeling the parts. The root + will typically be a 'course' object but may not be especially as we support modules. + + GET + html: return course listing page if not given a course id + html: return html page overview for the given course if given a course id + json: return json representing the course branch's index entry as well as dag w/ all of the children + replaced w/ json docs where each doc has {'_id': , 'display_name': , 'children': } + POST + json: create a course, return resulting json + descriptor (same as in GET course/...). Leaving off /branch/draft would imply create the course w/ default + branches. Cannot change the structure contents ('_id', 'display_name', 'children') but can change the + index entry. + PUT + json: update this course (index entry not xblock) such as repointing head, changing display name, org, + course, run. Return same json as above. + DELETE + json: delete this branch from this course (leaving off /branch/draft would imply delete the course) + """ + try: + response_format = request.GET.get('format') or request.POST.get('format') or 'html' + if response_format == 'json' or 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'): + if request.method == 'GET': + course_key = CourseKey.from_string(course_key_string) + with modulestore().bulk_operations(course_key): + course_module = get_course_and_check_access(course_key, request.user, depth=None) + return JsonResponse(_course_outline_json(request, course_module)) + elif request.method == 'POST': # not sure if this is only post. If one will have ids, it goes after access + return _create_or_rerun_course(request) + elif not has_studio_write_access(request.user, CourseKey.from_string(course_key_string)): + raise PermissionDenied() + elif request.method == 'PUT': + raise NotImplementedError() + elif request.method == 'DELETE': + raise NotImplementedError() + else: + return HttpResponseBadRequest() + elif request.method == 'GET': # assume html + if course_key_string is None: + return redirect(reverse('home')) + else: + return course_index(request, CourseKey.from_string(course_key_string)) + else: + return HttpResponseNotFound() + except InvalidKeyError: + raise Http404 + + +@login_required +@ensure_csrf_cookie +@require_http_methods(["GET"]) +def course_rerun_handler(request, course_key_string): + """ + The restful handler for course reruns. + GET + html: return html page with form to rerun a course for the given course id + """ + # Only global staff (PMs) are able to rerun courses during the soft launch + # Appsembler: Also course staff can do reruns + course_key = CourseKey.from_string(course_key_string) + if not CourseStaffRole(course_key).has_user(request.user): + if not CourseInstructorRole(course_key).has_user(request.user): + if not GlobalStaff().has_user(request.user): + raise PermissionDenied() + with modulestore().bulk_operations(course_key): + course_module = get_course_and_check_access(course_key, request.user, depth=3) + if request.method == 'GET': + return render_to_response('course-create-rerun.html', { + 'source_course_key': course_key, + 'display_name': course_module.display_name, + 'user': request.user, + 'course_creator_status': _get_course_creator_status(request.user), + 'allow_unicode_course_id': settings.FEATURES.get('ALLOW_UNICODE_COURSE_ID', False) + }) + + +@login_required +@ensure_csrf_cookie +@require_GET +def course_search_index_handler(request, course_key_string): + """ + The restful handler for course indexing. + GET + html: return status of indexing task + json: return status of indexing task + """ + # Only global staff (PMs) are able to index courses + if not GlobalStaff().has_user(request.user): + raise PermissionDenied() + course_key = CourseKey.from_string(course_key_string) + content_type = request.META.get('CONTENT_TYPE', None) + if content_type is None: + content_type = "application/json; charset=utf-8" + with modulestore().bulk_operations(course_key): + try: + reindex_course_and_check_access(course_key, request.user) + except SearchIndexingError as search_err: + return HttpResponse(dump_js_escaped_json({ + "user_message": search_err.error_list + }), content_type=content_type, status=500) + return HttpResponse(dump_js_escaped_json({ + "user_message": _("Course has been successfully reindexed.") + }), content_type=content_type, status=200) + + +def _course_outline_json(request, course_module): + """ + Returns a JSON representation of the course module and recursively all of its children. + """ + is_concise = request.GET.get('format') == 'concise' + include_children_predicate = lambda xblock: not xblock.category == 'vertical' + if is_concise: + include_children_predicate = lambda xblock: xblock.has_children + return create_xblock_info( + course_module, + include_child_info=True, + course_outline=False if is_concise else True, + include_children_predicate=include_children_predicate, + is_concise=is_concise, + user=request.user + ) + + +def get_in_process_course_actions(request): + """ + Get all in-process course actions + """ + return [ + course for course in + CourseRerunState.objects.find_all( + exclude_args={'state': CourseRerunUIStateManager.State.SUCCEEDED}, + should_display=True, + ) + if has_studio_read_access(request.user, course.course_key) + ] + + +def _accessible_courses_summary_iter(request, org=None): + """ + List all courses available to the logged in user by iterating through all the courses + + Arguments: + request: the request object + org (string): if not None, this value will limit the courses returned. An empty + string will result in no courses, and otherwise only courses with the + specified org will be returned. The default value is None. + """ + def course_filter(course_summary): + """ + Filter out unusable and inaccessible courses + """ + # TODO remove this condition when templates purged from db + if course_summary.location.course == 'templates': + return False + + return has_studio_read_access(request.user, course_summary.id) + if org is not None: + courses_summary = [] if org == '' else CourseOverview.get_all_courses(orgs=[org]) + else: + courses_summary = modulestore().get_course_summaries() + courses_summary = six.moves.filter(course_filter, courses_summary) + in_process_course_actions = get_in_process_course_actions(request) + return courses_summary, in_process_course_actions + + +def _accessible_courses_iter(request): + """ + List all courses available to the logged in user by iterating through all the courses. + """ + def course_filter(course): + """ + Filter out unusable and inaccessible courses + """ + if isinstance(course, ErrorDescriptor): + return False + + # Custom Courses for edX (CCX) is an edX feature for re-using course content. + # CCXs cannot be edited in Studio (aka cms) and should not be shown in this dashboard. + if isinstance(course.id, CCXLocator): + return False + + # TODO remove this condition when templates purged from db + if course.location.course == 'templates': + return False + + return has_studio_read_access(request.user, course.id) + + courses = six.moves.filter(course_filter, modulestore().get_courses()) + + in_process_course_actions = get_in_process_course_actions(request) + return courses, in_process_course_actions + + +def _accessible_courses_iter_for_tests(request): + """ + List all courses available to the logged in user by iterating through all the courses. + CourseSummary objects are used for listing purposes. + This method is only used by tests. + """ + def course_filter(course): + """ + Filter out unusable and inaccessible courses + """ + + # Custom Courses for edX (CCX) is an edX feature for re-using course content. + # CCXs cannot be edited in Studio (aka cms) and should not be shown in this dashboard. + if isinstance(course.id, CCXLocator): + return False + + # TODO remove this condition when templates purged from db + if course.location.course == 'templates': + return False + + return has_studio_read_access(request.user, course.id) + + courses = six.moves.filter(course_filter, modulestore().get_course_summaries()) + + in_process_course_actions = get_in_process_course_actions(request) + return courses, in_process_course_actions + + +def _accessible_courses_list_from_groups(request): + """ + List all courses available to the logged in user by reversing access group names + """ + def filter_ccx(course_access): + """ CCXs cannot be edited in Studio and should not be shown in this dashboard """ + return not isinstance(course_access.course_id, CCXLocator) + + instructor_courses = UserBasedRole(request.user, CourseInstructorRole.ROLE).courses_with_role() + staff_courses = UserBasedRole(request.user, CourseStaffRole.ROLE).courses_with_role() + all_courses = list(filter(filter_ccx, instructor_courses | staff_courses)) + courses_list = [] + course_keys = {} + + for course_access in all_courses: + if course_access.course_id is None: + raise AccessListFallback + course_keys[course_access.course_id] = course_access.course_id + + course_keys = list(course_keys.values()) + + if course_keys: + courses_list = modulestore().get_course_summaries(course_keys=course_keys) + + return courses_list, [] + + +def _accessible_libraries_iter(user, org=None): + """ + List all libraries available to the logged in user by iterating through all libraries. + + org (string): if not None, this value will limit the libraries returned. An empty + string will result in no libraries, and otherwise only libraries with the + specified org will be returned. The default value is None. + """ + if org is not None: + libraries = [] if org == '' else modulestore().get_libraries(org=org) + else: + libraries = modulestore().get_library_summaries() + # No need to worry about ErrorDescriptors - split's get_libraries() never returns them. + return (lib for lib in libraries if has_studio_read_access(user, lib.location.library_key)) + + +@login_required +@ensure_csrf_cookie +def course_listing(request): + """ + List all courses and libraries available to the logged in user + """ + + optimization_enabled = GlobalStaff().has_user(request.user) and \ + WaffleSwitchNamespace(name=WAFFLE_NAMESPACE).is_enabled(u'enable_global_staff_optimization') + + org = request.GET.get('org', '') if optimization_enabled else None + courses_iter, in_process_course_actions = get_courses_accessible_to_user(request, org) + user = request.user + libraries = _accessible_libraries_iter(request.user, org) if LIBRARIES_ENABLED else [] + + def format_in_process_course_view(uca): + """ + Return a dict of the data which the view requires for each unsucceeded course + """ + return { + u'display_name': uca.display_name, + u'course_key': six.text_type(uca.course_key), + u'org': uca.course_key.org, + u'number': uca.course_key.course, + u'run': uca.course_key.run, + u'is_failed': True if uca.state == CourseRerunUIStateManager.State.FAILED else False, + u'is_in_progress': True if uca.state == CourseRerunUIStateManager.State.IN_PROGRESS else False, + u'dismiss_link': reverse_course_url( + u'course_notifications_handler', + uca.course_key, + kwargs={ + u'action_state_id': uca.id, + }, + ) if uca.state == CourseRerunUIStateManager.State.FAILED else u'' + } + + def format_library_for_view(library): + """ + Return a dict of the data which the view requires for each library + """ + + return { + u'display_name': library.display_name, + u'library_key': six.text_type(library.location.library_key), + u'url': reverse_library_url(u'library_handler', six.text_type(library.location.library_key)), + u'org': library.display_org_with_default, + u'number': library.display_number_with_default, + u'can_edit': has_studio_write_access(request.user, library.location.library_key), + } + + split_archived = settings.FEATURES.get(u'ENABLE_SEPARATE_ARCHIVED_COURSES', False) + active_courses, archived_courses = _process_courses_list(courses_iter, in_process_course_actions, split_archived) + in_process_course_actions = [format_in_process_course_view(uca) for uca in in_process_course_actions] + + return render_to_response(u'index.html', { + u'courses': active_courses, + u'archived_courses': archived_courses, + u'in_process_course_actions': in_process_course_actions, + u'libraries_enabled': LIBRARIES_ENABLED, + u'libraries': [format_library_for_view(lib) for lib in libraries], + u'show_new_library_button': get_library_creator_status(user), + u'user': user, + u'request_course_creator_url': reverse('request_course_creator'), + u'course_creator_status': _get_course_creator_status(user), + # Appsembler: Using the course creator status instead of `GlobalStaff().has_user(user)` + # to match the Tahoe multi-tenant requirements. + u'rerun_creator_status': _get_course_creator_status(user) == 'granted', + u'allow_unicode_course_id': settings.FEATURES.get(u'ALLOW_UNICODE_COURSE_ID', False), + u'allow_course_reruns': settings.FEATURES.get(u'ALLOW_COURSE_RERUNS', True), + u'optimization_enabled': optimization_enabled + }) + + +def _get_rerun_link_for_item(course_key): + """ Returns the rerun link for the given course key. """ + return reverse_course_url('course_rerun_handler', course_key) + + +def _deprecated_blocks_info(course_module, deprecated_block_types): + """ + Returns deprecation information about `deprecated_block_types` + + Arguments: + course_module (CourseDescriptor): course object + deprecated_block_types (list): list of deprecated blocks types + + Returns: + Dict with following keys: + deprecated_enabled_block_types (list): list containing all deprecated blocks types enabled on this course + blocks (list): List of `deprecated_enabled_block_types` instances and their parent's url + advance_settings_url (str): URL to advance settings page + """ + data = { + 'deprecated_enabled_block_types': [ + block_type for block_type in course_module.advanced_modules if block_type in deprecated_block_types + ], + 'blocks': [], + 'advance_settings_url': reverse_course_url('advanced_settings_handler', course_module.id) + } + + deprecated_blocks = modulestore().get_items( + course_module.id, + qualifiers={ + 'category': re.compile('^' + '$|^'.join(deprecated_block_types) + '$') + } + ) + + for block in deprecated_blocks: + data['blocks'].append([ + reverse_usage_url('container_handler', block.parent), + block.display_name + ]) + + return data + + +@login_required +@ensure_csrf_cookie +def course_index(request, course_key): + """ + Display an editable course overview. + + org, course, name: Attributes of the Location for the item to edit + """ + # A depth of None implies the whole course. The course outline needs this in order to compute has_changes. + # A unit may not have a draft version, but one of its components could, and hence the unit itself has changes. + with modulestore().bulk_operations(course_key): + course_module = get_course_and_check_access(course_key, request.user, depth=None) + if not course_module: + raise Http404 + lms_link = get_lms_link_for_item(course_module.location) + reindex_link = None + if settings.FEATURES.get('ENABLE_COURSEWARE_INDEX', False): + if GlobalStaff().has_user(request.user): + reindex_link = "/course/{course_id}/search_reindex".format(course_id=six.text_type(course_key)) + sections = course_module.get_children() + course_structure = _course_outline_json(request, course_module) + locator_to_show = request.GET.get('show', None) + + course_release_date = ( + get_default_time_display(course_module.start) + if course_module.start != DEFAULT_START_DATE + else _("Set Date") + ) + + settings_url = reverse_course_url('settings_handler', course_key) + + try: + current_action = CourseRerunState.objects.find_first(course_key=course_key, should_display=True) + except (ItemNotFoundError, CourseActionStateItemNotFoundError): + current_action = None + + deprecated_block_names = [block.name for block in deprecated_xblocks()] + deprecated_blocks_info = _deprecated_blocks_info(course_module, deprecated_block_names) + + frontend_app_publisher_url = configuration_helpers.get_value_for_org( + course_module.location.org, + 'FRONTEND_APP_PUBLISHER_URL', + settings.FEATURES.get('FRONTEND_APP_PUBLISHER_URL', False) + ) + + return render_to_response('course_outline.html', { + 'language_code': request.LANGUAGE_CODE, + 'context_course': course_module, + 'lms_link': lms_link, + 'sections': sections, + 'course_structure': course_structure, + 'initial_state': course_outline_initial_state(locator_to_show, course_structure) if locator_to_show else None, + 'rerun_notification_id': current_action.id if current_action else None, + 'course_release_date': course_release_date, + 'settings_url': settings_url, + 'reindex_link': reindex_link, + 'deprecated_blocks_info': deprecated_blocks_info, + 'notification_dismiss_url': reverse_course_url( + 'course_notifications_handler', + current_action.course_key, + kwargs={ + 'action_state_id': current_action.id, + }, + ) if current_action else None, + 'frontend_app_publisher_url': frontend_app_publisher_url, + }) + + +def get_courses_accessible_to_user(request, org=None): + """ + Try to get all courses by first reversing django groups and fallback to old method if it fails + Note: overhead of pymongo reads will increase if getting courses from django groups fails + + Arguments: + request: the request object + org (string): for global staff users ONLY, this value will be used to limit + the courses returned. A value of None will have no effect (all courses + returned), an empty string will result in no courses, and otherwise only courses with the + specified org will be returned. The default value is None. + """ + if GlobalStaff().has_user(request.user): + # user has global access so no need to get courses from django groups + courses, in_process_course_actions = _accessible_courses_summary_iter(request, org) + else: + try: + courses, in_process_course_actions = _accessible_courses_list_from_groups(request) + except AccessListFallback: + # user have some old groups or there was some error getting courses from django groups + # so fallback to iterating through all courses + courses, in_process_course_actions = _accessible_courses_summary_iter(request) + return courses, in_process_course_actions + + +def _process_courses_list(courses_iter, in_process_course_actions, split_archived=False): + """ + Iterates over the list of courses to be displayed to the user, and: + + * Removes any in-process courses from the courses list. "In-process" refers to courses + that are in the process of being generated for re-run. + * If split_archived=True, removes any archived courses and returns them in a separate list. + Archived courses have has_ended() == True. + * Formats the returned courses (in both lists) to prepare them for rendering to the view. + """ + def format_course_for_view(course): + """ + Return a dict of the data which the view requires for each course + """ + return { + 'display_name': course.display_name, + 'course_key': six.text_type(course.location.course_key), + 'url': reverse_course_url('course_handler', course.id), + 'lms_link': get_lms_link_for_item(course.location), + 'rerun_link': _get_rerun_link_for_item(course.id), + 'org': course.display_org_with_default, + 'number': course.display_number_with_default, + 'run': course.location.run + } + + in_process_action_course_keys = {uca.course_key for uca in in_process_course_actions} + active_courses = [] + archived_courses = [] + + for course in courses_iter: + if isinstance(course, ErrorDescriptor) or (course.id in in_process_action_course_keys): + continue + + formatted_course = format_course_for_view(course) + if split_archived and course.has_ended(): + archived_courses.append(formatted_course) + else: + active_courses.append(formatted_course) + + return active_courses, archived_courses + + +def course_outline_initial_state(locator_to_show, course_structure): + """ + Returns the desired initial state for the course outline view. If the 'show' request parameter + was provided, then the view's initial state will be to have the desired item fully expanded + and to scroll to see the new item. + """ + def find_xblock_info(xblock_info, locator): + """ + Finds the xblock info for the specified locator. + """ + if xblock_info['id'] == locator: + return xblock_info + children = xblock_info['child_info']['children'] if xblock_info.get('child_info', None) else None + if children: + for child_xblock_info in children: + result = find_xblock_info(child_xblock_info, locator) + if result: + return result + return None + + def collect_all_locators(locators, xblock_info): + """ + Collect all the locators for an xblock and its children. + """ + locators.append(xblock_info['id']) + children = xblock_info['child_info']['children'] if xblock_info.get('child_info', None) else None + if children: + for child_xblock_info in children: + collect_all_locators(locators, child_xblock_info) + + selected_xblock_info = find_xblock_info(course_structure, locator_to_show) + if not selected_xblock_info: + return None + expanded_locators = [] + collect_all_locators(expanded_locators, selected_xblock_info) + return { + 'locator_to_show': locator_to_show, + 'expanded_locators': expanded_locators + } + + +@expect_json +def _create_or_rerun_course(request): + """ + To be called by requests that create a new destination course (i.e., create_new_course and rerun_course) + Returns the destination course_key and overriding fields for the new course. + Raises DuplicateCourseError and InvalidKeyError + """ + if not auth.user_has_role(request.user, CourseCreatorRole()): + raise PermissionDenied() + + try: + org = request.json.get('org') + course = request.json.get('number', request.json.get('course')) + display_name = request.json.get('display_name') + # force the start date for reruns and allow us to override start via the client + start = request.json.get('start', CourseFields.start.default) + run = request.json.get('run') + + # allow/disable unicode characters in course_id according to settings + if not settings.FEATURES.get('ALLOW_UNICODE_COURSE_ID'): + if _has_non_ascii_characters(org) or _has_non_ascii_characters(course) or _has_non_ascii_characters(run): + return JsonResponse( + {'error': _('Special characters not allowed in organization, course number, and course run.')}, + status=400 + ) + + fields = {'start': start} + if display_name is not None: + fields['display_name'] = display_name + + # Set a unique wiki_slug for newly created courses. To maintain active wiki_slugs for + # existing xml courses this cannot be changed in CourseDescriptor. + # # TODO get rid of defining wiki slug in this org/course/run specific way and reconcile + # w/ xmodule.course_module.CourseDescriptor.__init__ + wiki_slug = u"{0}.{1}.{2}".format(org, course, run) + definition_data = {'wiki_slug': wiki_slug} + fields.update(definition_data) + + source_course_key = request.json.get('source_course_key') + if source_course_key: + source_course_key = CourseKey.from_string(source_course_key) + destination_course_key = rerun_course(request.user, source_course_key, org, course, run, fields) + return JsonResponse({ + 'url': reverse_url('course_handler'), + 'destination_course_key': six.text_type(destination_course_key) + }) + else: + try: + new_course = create_new_course(request.user, org, course, run, fields) + return JsonResponse({ + 'url': reverse_course_url('course_handler', new_course.id), + 'course_key': six.text_type(new_course.id), + }) + except ValidationError as ex: + return JsonResponse({'error': text_type(ex)}, status=400) + except DuplicateCourseError: + return JsonResponse({ + 'ErrMsg': _( + 'There is already a course defined with the same ' + 'organization and course number. Please ' + 'change either organization or course number to be unique.' + ), + 'OrgErrMsg': _( + 'Please change either the organization or ' + 'course number so that it is unique.'), + 'CourseErrMsg': _( + 'Please change either the organization or ' + 'course number so that it is unique.'), + }) + except InvalidKeyError as error: + return JsonResponse({ + "ErrMsg": _(u"Unable to create course '{name}'.\n\n{err}").format(name=display_name, err=text_type(error))} + ) + + +def create_new_course(user, org, number, run, fields): + """ + Create a new course run. + + Raises: + DuplicateCourseError: Course run already exists. + """ + org_data = get_organization_by_short_name(org) + if not org_data and organizations_enabled(): + raise ValidationError(_('You must link this course to an organization in order to continue. Organization ' + 'you selected does not exist in the system, you will need to add it to the system')) + store_for_new_course = modulestore().default_modulestore.get_modulestore_type() + new_course = create_new_course_in_store(store_for_new_course, user, org, number, run, fields) + add_organization_course(org_data, new_course.id) + return new_course + + +def create_new_course_in_store(store, user, org, number, run, fields): + """ + Create course in store w/ handling instructor enrollment, permissions, and defaulting the wiki slug. + Separated out b/c command line course creation uses this as well as the web interface. + """ + + # Set default language from settings and enable web certs + fields.update({ + 'language': getattr(settings, 'DEFAULT_COURSE_LANGUAGE', 'en'), + 'cert_html_view_enabled': True, + }) + + with modulestore().default_store(store): + # Creating the course raises DuplicateCourseError if an existing course with this org/name is found + new_course = modulestore().create_course( + org, + number, + run, + user.id, + fields=fields, + ) + + # Make sure user has instructor and staff access to the new course + add_instructor(new_course.id, user, user) + + # Initialize permissions for user in the new course + initialize_permissions(new_course.id, user) + return new_course + + +def rerun_course(user, source_course_key, org, number, run, fields, background=True): + """ + Rerun an existing course. + """ + # verify user has access to the original course + if not has_studio_write_access(user, source_course_key): + raise PermissionDenied() + + # create destination course key + store = modulestore() + with store.default_store('split'): + destination_course_key = store.make_course_key(org, number, run) + + # verify org course and run don't already exist + if store.has_course(destination_course_key, ignore_case=True): + raise DuplicateCourseError(source_course_key, destination_course_key) + + # Make sure user has instructor and staff access to the destination course + # so the user can see the updated status for that course + add_instructor(destination_course_key, user, user) + + # Mark the action as initiated + CourseRerunState.objects.initiated(source_course_key, destination_course_key, user, fields['display_name']) + + # Clear the fields that must be reset for the rerun + fields['advertised_start'] = None + fields['enrollment_start'] = None + fields['enrollment_end'] = None + fields['video_upload_pipeline'] = {} + + json_fields = json.dumps(fields, cls=EdxJSONEncoder) + args = [six.text_type(source_course_key), six.text_type(destination_course_key), user.id, json_fields] + + if background: + rerun_course_task.delay(*args) + else: + rerun_course_task(*args) + + return destination_course_key + + +@login_required +@ensure_csrf_cookie +@require_http_methods(["GET"]) +def course_info_handler(request, course_key_string): + """ + GET + html: return html for editing the course info handouts and updates. + """ + try: + course_key = CourseKey.from_string(course_key_string) + except InvalidKeyError: + raise Http404 + + with modulestore().bulk_operations(course_key): + course_module = get_course_and_check_access(course_key, request.user) + if not course_module: + raise Http404 + if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'): + return render_to_response( + 'course_info.html', + { + 'context_course': course_module, + 'updates_url': reverse_course_url('course_info_update_handler', course_key), + 'handouts_locator': course_key.make_usage_key('course_info', 'handouts'), + 'base_asset_url': StaticContent.get_base_url_path_for_course_assets(course_module.id), + } + ) + else: + return HttpResponseBadRequest("Only supports html requests") + + +@login_required +@ensure_csrf_cookie +@require_http_methods(("GET", "POST", "PUT", "DELETE")) +@expect_json +def course_info_update_handler(request, course_key_string, provided_id=None): + """ + restful CRUD operations on course_info updates. + provided_id should be none if it's new (create) and index otherwise. + GET + json: return the course info update models + POST + json: create an update + PUT or DELETE + json: change an existing update + """ + if 'application/json' not in request.META.get('HTTP_ACCEPT', 'application/json'): + return HttpResponseBadRequest("Only supports json requests") + + course_key = CourseKey.from_string(course_key_string) + usage_key = course_key.make_usage_key('course_info', 'updates') + if provided_id == '': + provided_id = None + + # check that logged in user has permissions to this item (GET shouldn't require this level?) + if not has_studio_write_access(request.user, usage_key.course_key): + raise PermissionDenied() + + if request.method == 'GET': + course_updates = get_course_updates(usage_key, provided_id, request.user.id) + if isinstance(course_updates, dict) and course_updates.get('error'): + return JsonResponse(course_updates, course_updates.get('status', 400)) + else: + return JsonResponse(course_updates) + elif request.method == 'DELETE': + try: + return JsonResponse(delete_course_update(usage_key, request.json, provided_id, request.user)) + except: + return HttpResponseBadRequest( + "Failed to delete", + content_type="text/plain" + ) + # can be either and sometimes django is rewriting one to the other: + elif request.method in ('POST', 'PUT'): + try: + return JsonResponse(update_course_updates(usage_key, request.json, provided_id, request.user)) + except: + return HttpResponseBadRequest( + "Failed to save", + content_type="text/plain" + ) + + +@login_required +@ensure_csrf_cookie +@require_http_methods(("GET", "PUT", "POST")) +@expect_json +def settings_handler(request, course_key_string): + """ + Course settings for dates and about pages + GET + html: get the page + json: get the CourseDetails model + PUT + json: update the Course and About xblocks through the CourseDetails model + """ + course_key = CourseKey.from_string(course_key_string) + credit_eligibility_enabled = settings.FEATURES.get('ENABLE_CREDIT_ELIGIBILITY', False) + with modulestore().bulk_operations(course_key): + course_module = get_course_and_check_access(course_key, request.user) + if 'text/html' in request.META.get('HTTP_ACCEPT', '') and request.method == 'GET': + upload_asset_url = reverse_course_url('assets_handler', course_key) + + # see if the ORG of this course can be attributed to a defined configuration . In that case, the + # course about page should be editable in Studio + publisher_enabled = configuration_helpers.get_value_for_org( + course_module.location.org, + 'ENABLE_PUBLISHER', + settings.FEATURES.get('ENABLE_PUBLISHER', False) + ) + marketing_enabled = configuration_helpers.get_value_for_org( + course_module.location.org, + 'ENABLE_MKTG_SITE', + settings.FEATURES.get('ENABLE_MKTG_SITE', False) + ) + enable_extended_course_details = configuration_helpers.get_value_for_org( + course_module.location.org, + 'ENABLE_EXTENDED_COURSE_DETAILS', + settings.FEATURES.get('ENABLE_EXTENDED_COURSE_DETAILS', False) + ) + + about_page_editable = not publisher_enabled + enrollment_end_editable = GlobalStaff().has_user(request.user) or not publisher_enabled + short_description_editable = configuration_helpers.get_value_for_org( + course_module.location.org, + 'EDITABLE_SHORT_DESCRIPTION', + settings.FEATURES.get('EDITABLE_SHORT_DESCRIPTION', True) + ) + sidebar_html_enabled = course_experience_waffle().is_enabled(ENABLE_COURSE_ABOUT_SIDEBAR_HTML) + # self_paced_enabled = SelfPacedConfiguration.current().enabled + + verified_mode = CourseMode.verified_mode_for_course(course_key, include_expired=True) + upgrade_deadline = (verified_mode and verified_mode.expiration_datetime and + verified_mode.expiration_datetime.isoformat()) + + settings_context = { + 'context_course': course_module, + 'course_locator': course_key, + 'lms_link_for_about_page': get_link_for_about_page(course_module), + 'course_image_url': course_image_url(course_module, 'course_image'), + 'banner_image_url': course_image_url(course_module, 'banner_image'), + 'video_thumbnail_image_url': course_image_url(course_module, 'video_thumbnail_image'), + 'details_url': reverse_course_url('settings_handler', course_key), + 'about_page_editable': about_page_editable, + 'marketing_enabled': marketing_enabled, + 'short_description_editable': short_description_editable, + 'sidebar_html_enabled': sidebar_html_enabled, + 'upload_asset_url': upload_asset_url, + 'course_handler_url': reverse_course_url('course_handler', course_key), + 'language_options': settings.ALL_LANGUAGES, + 'credit_eligibility_enabled': credit_eligibility_enabled, + 'is_credit_course': False, + 'show_min_grade_warning': False, + 'enrollment_end_editable': enrollment_end_editable, + 'is_prerequisite_courses_enabled': is_prerequisite_courses_enabled(), + 'is_entrance_exams_enabled': is_entrance_exams_enabled(), + 'enable_extended_course_details': enable_extended_course_details, + 'upgrade_deadline': upgrade_deadline, + } + if is_prerequisite_courses_enabled(): + courses, in_process_course_actions = get_courses_accessible_to_user(request, course_module.location.org) + # exclude current course from the list of available courses + courses = (course for course in courses if course.id != course_key) + if courses: + courses, __ = _process_courses_list(courses, in_process_course_actions) + settings_context.update({'possible_pre_requisite_courses': list(courses)}) + + if credit_eligibility_enabled: + if is_credit_course(course_key): + # get and all credit eligibility requirements + credit_requirements = get_credit_requirements(course_key) + # pair together requirements with same 'namespace' values + paired_requirements = {} + for requirement in credit_requirements: + namespace = requirement.pop("namespace") + paired_requirements.setdefault(namespace, []).append(requirement) + + # if 'minimum_grade_credit' of a course is not set or 0 then + # show warning message to course author. + show_min_grade_warning = False if course_module.minimum_grade_credit > 0 else True + settings_context.update( + { + 'is_credit_course': True, + 'credit_requirements': paired_requirements, + 'show_min_grade_warning': show_min_grade_warning, + } + ) + + return render_to_response('settings.html', settings_context) + elif 'application/json' in request.META.get('HTTP_ACCEPT', ''): + if request.method == 'GET': + course_details = CourseDetails.fetch(course_key) + return JsonResponse( + course_details, + # encoder serializes dates, old locations, and instances + encoder=CourseSettingsEncoder + ) + # For every other possible method type submitted by the caller... + else: + # if pre-requisite course feature is enabled set pre-requisite course + if is_prerequisite_courses_enabled(): + prerequisite_course_keys = request.json.get('pre_requisite_courses', []) + if prerequisite_course_keys: + if not all(is_valid_course_key(course_key) for course_key in prerequisite_course_keys): + return JsonResponseBadRequest({"error": _("Invalid prerequisite course key")}) + set_prerequisite_courses(course_key, prerequisite_course_keys) + else: + # None is chosen, so remove the course prerequisites + course_milestones = milestones_api.get_course_milestones(course_key=course_key, relationship="requires") + for milestone in course_milestones: + ee_milestone_namespace = generate_milestone_namespace( + get_namespace_choices().get('ENTRANCE_EXAM'), + course_key + ) + if not milestone["namespace"] == ee_milestone_namespace: + remove_prerequisite_course(course_key, milestone) + + # If the entrance exams feature has been enabled, we'll need to check for some + # feature-specific settings and handle them accordingly + # We have to be careful that we're only executing the following logic if we actually + # need to create or delete an entrance exam from the specified course + if is_entrance_exams_enabled(): + course_entrance_exam_present = course_module.entrance_exam_enabled + entrance_exam_enabled = request.json.get('entrance_exam_enabled', '') == 'true' + ee_min_score_pct = request.json.get('entrance_exam_minimum_score_pct', None) + # If the entrance exam box on the settings screen has been checked... + if entrance_exam_enabled: + # Load the default minimum score threshold from settings, then try to override it + entrance_exam_minimum_score_pct = float(settings.ENTRANCE_EXAM_MIN_SCORE_PCT) + if ee_min_score_pct: + entrance_exam_minimum_score_pct = float(ee_min_score_pct) + if entrance_exam_minimum_score_pct.is_integer(): + entrance_exam_minimum_score_pct = entrance_exam_minimum_score_pct / 100 + # If there's already an entrance exam defined, we'll update the existing one + if course_entrance_exam_present: + exam_data = { + 'entrance_exam_minimum_score_pct': entrance_exam_minimum_score_pct + } + update_entrance_exam(request, course_key, exam_data) + # If there's no entrance exam defined, we'll create a new one + else: + create_entrance_exam(request, course_key, entrance_exam_minimum_score_pct) + + # If the entrance exam box on the settings screen has been unchecked, + # and the course has an entrance exam attached... + elif not entrance_exam_enabled and course_entrance_exam_present: + delete_entrance_exam(request, course_key) + + # Perform the normal update workflow for the CourseDetails model + return JsonResponse( + CourseDetails.update_from_json(course_key, request.json, request.user), + encoder=CourseSettingsEncoder + ) + + +@login_required +@ensure_csrf_cookie +@require_http_methods(("GET", "POST", "PUT", "DELETE")) +@expect_json +def grading_handler(request, course_key_string, grader_index=None): + """ + Course Grading policy configuration + GET + html: get the page + json no grader_index: get the CourseGrading model (graceperiod, cutoffs, and graders) + json w/ grader_index: get the specific grader + PUT + json no grader_index: update the Course through the CourseGrading model + json w/ grader_index: create or update the specific grader (create if index out of range) + """ + course_key = CourseKey.from_string(course_key_string) + with modulestore().bulk_operations(course_key): + course_module = get_course_and_check_access(course_key, request.user) + + if 'text/html' in request.META.get('HTTP_ACCEPT', '') and request.method == 'GET': + course_details = CourseGradingModel.fetch(course_key) + + return render_to_response('settings_graders.html', { + 'context_course': course_module, + 'course_locator': course_key, + 'course_details': course_details, + 'grading_url': reverse_course_url('grading_handler', course_key), + 'is_credit_course': is_credit_course(course_key), + }) + elif 'application/json' in request.META.get('HTTP_ACCEPT', ''): + if request.method == 'GET': + if grader_index is None: + return JsonResponse( + CourseGradingModel.fetch(course_key), + # encoder serializes dates, old locations, and instances + encoder=CourseSettingsEncoder + ) + else: + return JsonResponse(CourseGradingModel.fetch_grader(course_key, grader_index)) + elif request.method in ('POST', 'PUT'): # post or put, doesn't matter. + # update credit course requirements if 'minimum_grade_credit' + # field value is changed + if 'minimum_grade_credit' in request.json: + update_credit_course_requirements.delay(six.text_type(course_key)) + + # None implies update the whole model (cutoffs, graceperiod, and graders) not a specific grader + if grader_index is None: + return JsonResponse( + CourseGradingModel.update_from_json(course_key, request.json, request.user), + encoder=CourseSettingsEncoder + ) + else: + return JsonResponse( + CourseGradingModel.update_grader_from_json(course_key, request.json, request.user) + ) + elif request.method == "DELETE" and grader_index is not None: + CourseGradingModel.delete_grader(course_key, grader_index, request.user) + return JsonResponse() + + +def _refresh_course_tabs(request, course_module): + """ + Automatically adds/removes tabs if changes to the course require them. + + Raises: + InvalidTabsException: raised if there's a problem with the new version of the tabs. + """ + + def update_tab(tabs, tab_type, tab_enabled): + """ + Adds or removes a course tab based upon whether it is enabled. + """ + tab_panel = { + "type": tab_type.type, + } + has_tab = tab_panel in tabs + if tab_enabled and not has_tab: + tabs.append(CourseTab.from_json(tab_panel)) + elif not tab_enabled and has_tab: + tabs.remove(tab_panel) + + course_tabs = copy.copy(course_module.tabs) + + # Additionally update any tabs that are provided by non-dynamic course views + for tab_type in CourseTabPluginManager.get_tab_types(): + if not tab_type.is_dynamic and tab_type.is_default: + tab_enabled = tab_type.is_enabled(course_module, user=request.user) + update_tab(course_tabs, tab_type, tab_enabled) + + CourseTabList.validate_tabs(course_tabs) + + # Save the tabs into the course if they have been changed + if course_tabs != course_module.tabs: + course_module.tabs = course_tabs + + +@login_required +@ensure_csrf_cookie +@require_http_methods(("GET", "POST", "PUT")) +@expect_json +def advanced_settings_handler(request, course_key_string): + """ + Course settings configuration + GET + html: get the page + json: get the model + PUT, POST + json: update the Course's settings. The payload is a json rep of the + metadata dicts. + """ + course_key = CourseKey.from_string(course_key_string) + with modulestore().bulk_operations(course_key): + course_module = get_course_and_check_access(course_key, request.user) + + advanced_dict = CourseMetadata.fetch(course_module) + if settings.FEATURES.get('DISABLE_MOBILE_COURSE_AVAILABLE', False): + advanced_dict.get('mobile_available')['deprecated'] = True + + if 'text/html' in request.META.get('HTTP_ACCEPT', '') and request.method == 'GET': + publisher_enabled = configuration_helpers.get_value_for_org( + course_module.location.org, + 'ENABLE_PUBLISHER', + settings.FEATURES.get('ENABLE_PUBLISHER', False) + ) + + return render_to_response('settings_advanced.html', { + 'context_course': course_module, + 'advanced_dict': advanced_dict, + 'advanced_settings_url': reverse_course_url('advanced_settings_handler', course_key), + 'publisher_enabled': publisher_enabled, + + }) + elif 'application/json' in request.META.get('HTTP_ACCEPT', ''): + if request.method == 'GET': + return JsonResponse(CourseMetadata.fetch(course_module)) + else: + try: + # validate data formats and update the course module. + # Note: don't update mongo yet, but wait until after any tabs are changed + is_valid, errors, updated_data = CourseMetadata.validate_and_update_from_json( + course_module, + request.json, + user=request.user, + ) + + if is_valid: + try: + # update the course tabs if required by any setting changes + _refresh_course_tabs(request, course_module) + except InvalidTabsException as err: + log.exception(text_type(err)) + response_message = [ + { + 'message': _('An error occurred while trying to save your tabs'), + 'model': {'display_name': _('Tabs Exception')} + } + ] + return JsonResponseBadRequest(response_message) + + # now update mongo + modulestore().update_item(course_module, request.user.id) + + return JsonResponse(updated_data) + else: + return JsonResponseBadRequest(errors) + + # Handle all errors that validation doesn't catch + except (TypeError, ValueError, InvalidTabsException) as err: + return HttpResponseBadRequest( + django.utils.html.escape(text_type(err)), + content_type="text/plain" + ) + + +class TextbookValidationError(Exception): + "An error thrown when a textbook input is invalid" + pass + + +def validate_textbooks_json(text): + """ + Validate the given text as representing a single PDF textbook + """ + if isinstance(text, (bytes, bytearray)): # data appears as bytes + text = text.decode('utf-8') + try: + textbooks = json.loads(text) + except ValueError: + raise TextbookValidationError("invalid JSON") + if not isinstance(textbooks, (list, tuple)): + raise TextbookValidationError("must be JSON list") + for textbook in textbooks: + validate_textbook_json(textbook) + # check specified IDs for uniqueness + all_ids = [textbook["id"] for textbook in textbooks if "id" in textbook] + unique_ids = set(all_ids) + if len(all_ids) > len(unique_ids): + raise TextbookValidationError("IDs must be unique") + return textbooks + + +def validate_textbook_json(textbook): + """ + Validate the given text as representing a list of PDF textbooks + """ + if isinstance(textbook, (bytes, bytearray)): # data appears as bytes + textbook = textbook.decode('utf-8') + if isinstance(textbook, six.string_types): + try: + textbook = json.loads(textbook) + except ValueError: + raise TextbookValidationError("invalid JSON") + if not isinstance(textbook, dict): + raise TextbookValidationError("must be JSON object") + if not textbook.get("tab_title"): + raise TextbookValidationError("must have tab_title") + tid = six.text_type(textbook.get("id", "")) + if tid and not tid[0].isdigit(): + raise TextbookValidationError("textbook ID must start with a digit") + return textbook + + +def assign_textbook_id(textbook, used_ids=()): + """ + Return an ID that can be assigned to a textbook + and doesn't match the used_ids + """ + tid = BlockUsageLocator.clean(textbook["tab_title"]) + if not tid[0].isdigit(): + # stick a random digit in front + tid = random.choice(string.digits) + tid + while tid in used_ids: + # add a random ASCII character to the end + tid = tid + random.choice(string.ascii_lowercase) + return tid + + +@require_http_methods(("GET", "POST", "PUT")) +@login_required +@ensure_csrf_cookie +def textbooks_list_handler(request, course_key_string): + """ + A RESTful handler for textbook collections. + + GET + html: return textbook list page (Backbone application) + json: return JSON representation of all textbooks in this course + POST + json: create a new textbook for this course + PUT + json: overwrite all textbooks in the course with the given list + """ + course_key = CourseKey.from_string(course_key_string) + store = modulestore() + with store.bulk_operations(course_key): + course = get_course_and_check_access(course_key, request.user) + + if "application/json" not in request.META.get('HTTP_ACCEPT', 'text/html'): + # return HTML page + upload_asset_url = reverse_course_url('assets_handler', course_key) + textbook_url = reverse_course_url('textbooks_list_handler', course_key) + return render_to_response('textbooks.html', { + 'context_course': course, + 'textbooks': course.pdf_textbooks, + 'upload_asset_url': upload_asset_url, + 'textbook_url': textbook_url, + }) + + # from here on down, we know the client has requested JSON + if request.method == 'GET': + return JsonResponse(course.pdf_textbooks) + elif request.method == 'PUT': + try: + textbooks = validate_textbooks_json(request.body) + except TextbookValidationError as err: + return JsonResponse({"error": text_type(err)}, status=400) + + tids = set(t["id"] for t in textbooks if "id" in t) + for textbook in textbooks: + if "id" not in textbook: + tid = assign_textbook_id(textbook, tids) + textbook["id"] = tid + tids.add(tid) + + if not any(tab['type'] == 'pdf_textbooks' for tab in course.tabs): + course.tabs.append(CourseTab.load('pdf_textbooks')) + course.pdf_textbooks = textbooks + store.update_item(course, request.user.id) + return JsonResponse(course.pdf_textbooks) + elif request.method == 'POST': + # create a new textbook for the course + try: + textbook = validate_textbook_json(request.body) + except TextbookValidationError as err: + return JsonResponse({"error": text_type(err)}, status=400) + if not textbook.get("id"): + tids = set(t["id"] for t in course.pdf_textbooks if "id" in t) + textbook["id"] = assign_textbook_id(textbook, tids) + existing = course.pdf_textbooks + existing.append(textbook) + course.pdf_textbooks = existing + if not any(tab['type'] == 'pdf_textbooks' for tab in course.tabs): + course.tabs.append(CourseTab.load('pdf_textbooks')) + store.update_item(course, request.user.id) + resp = JsonResponse(textbook, status=201) + resp["Location"] = reverse_course_url( + 'textbooks_detail_handler', + course.id, + kwargs={'textbook_id': textbook["id"]} + ) + return resp + + +@login_required +@ensure_csrf_cookie +@require_http_methods(("GET", "POST", "PUT", "DELETE")) +def textbooks_detail_handler(request, course_key_string, textbook_id): + """ + JSON API endpoint for manipulating a textbook via its internal ID. + Used by the Backbone application. + + GET + json: return JSON representation of textbook + POST or PUT + json: update textbook based on provided information + DELETE + json: remove textbook + """ + course_key = CourseKey.from_string(course_key_string) + store = modulestore() + with store.bulk_operations(course_key): + course_module = get_course_and_check_access(course_key, request.user) + matching_id = [tb for tb in course_module.pdf_textbooks + if six.text_type(tb.get("id")) == six.text_type(textbook_id)] + if matching_id: + textbook = matching_id[0] + else: + textbook = None + + if request.method == 'GET': + if not textbook: + return JsonResponse(status=404) + return JsonResponse(textbook) + elif request.method in ('POST', 'PUT'): # can be either and sometimes django is rewriting one to the other + try: + new_textbook = validate_textbook_json(request.body) + except TextbookValidationError as err: + return JsonResponse({"error": text_type(err)}, status=400) + new_textbook["id"] = textbook_id + if textbook: + i = course_module.pdf_textbooks.index(textbook) + new_textbooks = course_module.pdf_textbooks[0:i] + new_textbooks.append(new_textbook) + new_textbooks.extend(course_module.pdf_textbooks[i + 1:]) + course_module.pdf_textbooks = new_textbooks + else: + course_module.pdf_textbooks.append(new_textbook) + store.update_item(course_module, request.user.id) + return JsonResponse(new_textbook, status=201) + elif request.method == 'DELETE': + if not textbook: + return JsonResponse(status=404) + i = course_module.pdf_textbooks.index(textbook) + remaining_textbooks = course_module.pdf_textbooks[0:i] + remaining_textbooks.extend(course_module.pdf_textbooks[i + 1:]) + course_module.pdf_textbooks = remaining_textbooks + store.update_item(course_module, request.user.id) + return JsonResponse() + + +def remove_content_or_experiment_group(request, store, course, configuration, group_configuration_id, group_id=None): + """ + Remove content group or experiment group configuration only if it's not in use. + """ + configuration_index = course.user_partitions.index(configuration) + if configuration.scheme.name == RANDOM_SCHEME: + usages = GroupConfiguration.get_content_experiment_usage_info(store, course) + used = int(group_configuration_id) in usages + + if used: + return JsonResponse( + {"error": _("This group configuration is in use and cannot be deleted.")}, + status=400 + ) + course.user_partitions.pop(configuration_index) + elif configuration.scheme.name == COHORT_SCHEME: + if not group_id: + return JsonResponse(status=404) + + group_id = int(group_id) + usages = GroupConfiguration.get_partitions_usage_info(store, course) + used = group_id in usages[configuration.id] + + if used: + return JsonResponse( + {"error": _("This content group is in use and cannot be deleted.")}, + status=400 + ) + + matching_groups = [group for group in configuration.groups if group.id == group_id] + if matching_groups: + group_index = configuration.groups.index(matching_groups[0]) + configuration.groups.pop(group_index) + else: + return JsonResponse(status=404) + + course.user_partitions[configuration_index] = configuration + + store.update_item(course, request.user.id) + return JsonResponse(status=204) + + +@require_http_methods(("GET", "POST")) +@login_required +@ensure_csrf_cookie +def group_configurations_list_handler(request, course_key_string): + """ + A RESTful handler for Group Configurations + + GET + html: return Group Configurations list page (Backbone application) + POST + json: create new group configuration + """ + course_key = CourseKey.from_string(course_key_string) + store = modulestore() + with store.bulk_operations(course_key): + course = get_course_and_check_access(course_key, request.user) + + if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'): + group_configuration_url = reverse_course_url('group_configurations_list_handler', course_key) + course_outline_url = reverse_course_url('course_handler', course_key) + should_show_experiment_groups = are_content_experiments_enabled(course) + if should_show_experiment_groups: + experiment_group_configurations = GroupConfiguration.get_split_test_partitions_with_usage(store, course) + else: + experiment_group_configurations = None + + all_partitions = GroupConfiguration.get_all_user_partition_details(store, course) + should_show_enrollment_track = False + has_content_groups = False + displayable_partitions = [] + for partition in all_partitions: + partition['read_only'] = getattr(UserPartition.get_scheme(partition['scheme']), 'read_only', False) + + if partition['scheme'] == COHORT_SCHEME: + has_content_groups = True + displayable_partitions.append(partition) + elif partition['scheme'] == CONTENT_TYPE_GATING_SCHEME: + # Add it to the front of the list if it should be shown. + if ContentTypeGatingConfig.current(course_key=course_key).studio_override_enabled: + displayable_partitions.append(partition) + elif partition['scheme'] == ENROLLMENT_SCHEME: + should_show_enrollment_track = len(partition['groups']) > 1 + + # Add it to the front of the list if it should be shown. + if should_show_enrollment_track: + displayable_partitions.insert(0, partition) + elif partition['scheme'] != RANDOM_SCHEME: + # Experiment group configurations are handled explicitly above. We don't + # want to display their groups twice. + displayable_partitions.append(partition) + + # Set the sort-order. Higher numbers sort earlier + scheme_priority = defaultdict(lambda: -1, { + ENROLLMENT_SCHEME: 1, + CONTENT_TYPE_GATING_SCHEME: 0 + }) + displayable_partitions.sort(key=lambda p: scheme_priority[p['scheme']], reverse=True) + # Add empty content group if there is no COHORT User Partition in the list. + # This will add ability to add new groups in the view. + if not has_content_groups: + displayable_partitions.append(GroupConfiguration.get_or_create_content_group(store, course)) + + return render_to_response('group_configurations.html', { + 'context_course': course, + 'group_configuration_url': group_configuration_url, + 'course_outline_url': course_outline_url, + 'experiment_group_configurations': experiment_group_configurations, + 'should_show_experiment_groups': should_show_experiment_groups, + 'all_group_configurations': displayable_partitions, + 'should_show_enrollment_track': should_show_enrollment_track + }) + elif "application/json" in request.META.get('HTTP_ACCEPT'): + if request.method == 'POST': + # create a new group configuration for the course + try: + new_configuration = GroupConfiguration(request.body, course).get_user_partition() + except GroupConfigurationsValidationError as err: + return JsonResponse({"error": text_type(err)}, status=400) + + course.user_partitions.append(new_configuration) + response = JsonResponse(new_configuration.to_json(), status=201) + + response["Location"] = reverse_course_url( + 'group_configurations_detail_handler', + course.id, + kwargs={'group_configuration_id': new_configuration.id} + ) + store.update_item(course, request.user.id) + return response + else: + return HttpResponse(status=406) + + +@login_required +@ensure_csrf_cookie +@require_http_methods(("POST", "PUT", "DELETE")) +def group_configurations_detail_handler(request, course_key_string, group_configuration_id, group_id=None): + """ + JSON API endpoint for manipulating a group configuration via its internal ID. + Used by the Backbone application. + + POST or PUT + json: update group configuration based on provided information + """ + course_key = CourseKey.from_string(course_key_string) + store = modulestore() + with store.bulk_operations(course_key): + course = get_course_and_check_access(course_key, request.user) + matching_id = [p for p in course.user_partitions + if six.text_type(p.id) == six.text_type(group_configuration_id)] + if matching_id: + configuration = matching_id[0] + else: + configuration = None + + if request.method in ('POST', 'PUT'): # can be either and sometimes django is rewriting one to the other + try: + new_configuration = GroupConfiguration(request.body, course, group_configuration_id).get_user_partition() + except GroupConfigurationsValidationError as err: + return JsonResponse({"error": text_type(err)}, status=400) + + if configuration: + index = course.user_partitions.index(configuration) + course.user_partitions[index] = new_configuration + else: + course.user_partitions.append(new_configuration) + store.update_item(course, request.user.id) + configuration = GroupConfiguration.update_usage_info(store, course, new_configuration) + return JsonResponse(configuration, status=201) + + elif request.method == "DELETE": + if not configuration: + return JsonResponse(status=404) + + return remove_content_or_experiment_group( + request=request, + store=store, + course=course, + configuration=configuration, + group_configuration_id=group_configuration_id, + group_id=group_id + ) + + +def are_content_experiments_enabled(course): + """ + Returns True if content experiments have been enabled for the course. + """ + return ( + 'split_test' in ADVANCED_COMPONENT_TYPES and + 'split_test' in course.advanced_modules + ) + + +def _get_course_creator_status(user): + """ + Helper method for returning the course creator status for a particular user, + taking into account the values of DISABLE_COURSE_CREATION and ENABLE_CREATOR_GROUP. + + If the user passed in has not previously visited the index page, it will be + added with status 'unrequested' if the course creator group is in use. + """ + + if user.is_staff: + course_creator_status = 'granted' + elif settings.FEATURES.get('DISABLE_COURSE_CREATION', False): + course_creator_status = 'disallowed_for_this_site' + elif settings.FEATURES.get('ENABLE_CREATOR_GROUP', False): + course_creator_status = get_course_creator_status(user) + if course_creator_status is None: + # User not grandfathered in as an existing user, has not previously visited the dashboard page. + # Add the user to the course creator admin table with status 'unrequested'. + add_user_with_status_unrequested(user) + course_creator_status = get_course_creator_status(user) + else: + course_creator_status = 'granted' + + return course_creator_status diff --git a/cms/envs/common.py b/cms/envs/common.py index 3e77bc28d3b4..95e57cd3abf6 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -1461,6 +1461,9 @@ # CMS specific user task handling 'cms_user_tasks.apps.CmsUserTasksConfig', + # Appsembler customization app for CMS + 'appsembler.apps.CMSAppsemblerConfig', + # Unusual migrations 'database_fixups', diff --git a/cms/static/js/certificates/models/certificate.js b/cms/static/js/certificates/models/certificate.js index a440d569d606..d0e6ae6e9f35 100644 --- a/cms/static/js/certificates/models/certificate.js +++ b/cms/static/js/certificates/models/certificate.js @@ -43,7 +43,7 @@ define([ initialize: function(attributes, options) { // Set up the initial state of the attributes set for this model instance this.canBeEmpty = options && options.canBeEmpty; - if (options.add) { + if (options.add && !attributes.signatories) { // Ensure at least one child Signatory model is defined for any new Certificate model attributes.signatories = new SignatoryModel({certificate: this}); } diff --git a/cms/templates/js/group-configuration-details.underscore b/cms/templates/js/group-configuration-details.underscore index 8310a2a657bf..f3fd77971827 100644 --- a/cms/templates/js/group-configuration-details.underscore +++ b/cms/templates/js/group-configuration-details.underscore @@ -31,9 +31,9 @@ <% if(showGroups) { %> <% allocation = Math.floor(100 / groups.length) %> -