diff --git a/cms/djangoapps/api/v1/tests/test_views/test_course_runs.py b/cms/djangoapps/api/v1/tests/test_views/test_course_runs.py index f62e74d9e4458be8eeaef50670fc489d0273d515..7b2cc67242bdaedc56733d9418f4b1aac8b193b8 100644 --- a/cms/djangoapps/api/v1/tests/test_views/test_course_runs.py +++ b/cms/djangoapps/api/v1/tests/test_views/test_course_runs.py @@ -6,16 +6,16 @@ import datetime import ddt import pytz from django.core.files.uploadedfile import SimpleUploadedFile -from django.test import RequestFactory +from django.test import RequestFactory, override_settings from django.urls import reverse from mock import patch from opaque_keys.edx.keys import CourseKey +from organizations.api import add_organization, get_course_organizations from rest_framework.test import APIClient from openedx.core.lib.courses import course_image_url from common.djangoapps.student.models import CourseAccessRole from common.djangoapps.student.tests.factories import TEST_PASSWORD, AdminFactory, UserFactory -from common.djangoapps.util.organizations_helpers import add_organization, get_course_organizations from xmodule.contentstore.content import StaticContent from xmodule.contentstore.django import contentstore from xmodule.exceptions import NotFoundError @@ -321,7 +321,7 @@ class CourseRunViewSetTests(ModuleStoreTestCase): # There should now be an image stored contentstore().find(content_key) - @patch.dict('django.conf.settings.FEATURES', {'ORGANIZATIONS_APP': True}) + @override_settings(ORGANIZATIONS_AUTOCREATE=False) @ddt.data( ('instructor_paced', False, 'NotOriginalNumber1x'), ('self_paced', True, None), diff --git a/cms/djangoapps/contentstore/management/commands/backfill_orgs_and_org_courses.py b/cms/djangoapps/contentstore/management/commands/backfill_orgs_and_org_courses.py new file mode 100644 index 0000000000000000000000000000000000000000..41b8465953868087334bfef624c513892e0cab9b --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/backfill_orgs_and_org_courses.py @@ -0,0 +1,162 @@ +""" +A backfill command to migrate Open edX instances to the new world of +"organizations are enabled everywhere". + +For full context, see: +https://github.com/edx/edx-organizations/blob/master/docs/decisions/0001-phase-in-db-backed-organizations-to-all.rst +""" +from django.core.management import BaseCommand, CommandError +from organizations import api as organizations_api + +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from xmodule.modulestore.django import modulestore + + +class Command(BaseCommand): + """ + Back-populate edx-organizations models from existing course runs & content libraries. + + Before the Lilac open release, Open edX instances by default did not make + use of the models in edx-organizations. + In Lilac and beyond, the edx-organizations models are enabled globally. + + This command exists to migrate pre-Lilac instances that did not enable + `FEATURES['ORGANIZATIONS_APP']`. + It automatically creates all missing Organization and OrganizationCourse + instances based on the course runs in the system (loaded from CourseOverview) + and the V1 content libraries in the system (loaded from the Modulestore). + + Organizations created by this command will have their `short_name` and + `name` equal to the `org` part of the library/course key that triggered + their creation. For example, given an Open edX instance with the course run + `course-v1:myOrg+myCourse+myRun` but no such Organization with the short name + "myOrg" (case-insensitive), this command will create the following + organization: + > Organization( + > short_name='myOrg', + > name='myOrg', + > description=None, + > logo=None, + > active=True, + > ) + """ + + # Make help message the first line of docstring. + # I'd like to include the entire docstring but Django omits the newlines, + # so it looks pretty bad. + help = __doc__.strip().splitlines()[0] + + def add_arguments(self, parser): + parser.add_argument( + '--apply', + action='store_true', + help="Apply backfill to database without prompting for confirmation." + ) + parser.add_argument( + '--dry', + action='store_true', + help="Show backfill, but do not apply changes to database." + ) + + def handle(self, *args, **options): + """ + Handle the backfill command. + """ + orgslug_coursekey_pairs = find_orgslug_coursekey_pairs() + orgslug_library_pairs = find_orgslug_library_pairs() + orgslugs = ( + {orgslug for orgslug, _ in orgslug_coursekey_pairs} | + {orgslug for orgslug, _ in orgslug_library_pairs} + ) + # Note: the `organizations.api.bulk_add_*` code will handle: + # * not overwriting existing organizations, and + # * skipping duplicates, based on the short name (case-insensiive), + # so we don't have to worry about those here. + orgs = [ + {"short_name": orgslug, "name": orgslug} + # The `sorted` calls aren't strictly necessary, but they'll help make this + # function more deterministic in case something goes wrong. + for orgslug in sorted(orgslugs) + ] + org_coursekey_pairs = [ + ({"short_name": orgslug}, coursekey) + for orgslug, coursekey in sorted(orgslug_coursekey_pairs) + ] + if not confirm_changes(options, orgs, org_coursekey_pairs): + print("No changes applied.") + return + print("Applying changes...") + organizations_api.bulk_add_organizations(orgs, dry_run=False) + organizations_api.bulk_add_organization_courses(org_coursekey_pairs, dry_run=False) + print("Changes applied successfully.") + + +def confirm_changes(options, orgs, org_coursekey_pairs): + """ + Should we apply the changes to the database? + + If `--apply`, this just returns True. + If `--dry`, this does a dry run and then returns False. + Otherwise, it does a dry run and then prompts the user. + + Arguments: + options (dict[str]): command-line arguments. + orgs (list[dict]): list of org data dictionaries to bulk-add. + org_coursekey_pairs (list[tuple[dict, CourseKey]]): + list of (org data dictionary, course key) links to bulk-add. + + Returns: bool + """ + if options.get('apply') and options.get('dry'): + raise CommandError("Only one of 'apply' and 'dry' may be specified") + if options.get('apply'): + return True + organizations_api.bulk_add_organizations(orgs, dry_run=True) + organizations_api.bulk_add_organization_courses(org_coursekey_pairs, dry_run=True) + if options.get('dry'): + return False + answer = "" + while answer.lower() not in {'y', 'yes', 'n', 'no'}: + answer = input('Commit changes shown above to the database [y/n]? ') + return answer.lower().startswith('y') + + +def find_orgslug_coursekey_pairs(): + """ + Returns the unique pairs of (organization short name, course run key) + from the CourseOverviews table, which should contain all course runs in the + system. + + Returns: set[tuple[str, CourseKey]] + """ + # Using a set comprehension removes any duplicate (org, course) pairs. + return { + (course_key.org, course_key) + for course_key + # Worth noting: This will load all CourseOverviews, no matter their VERSION. + # This is intentional: there may be course runs that haven't updated + # their CourseOverviews entry since the last schema change; we still want + # capture those course runs. + in CourseOverview.objects.all().values_list("id", flat=True) + } + + +def find_orgslug_library_pairs(): + """ + Returns the unique pairs of (organization short name, content library key) + from the modulestore. + + Note that this only considers "version 1" (aka "legacy" or "modulestore-based") + content libraries. + We do not consider "version 2" (aka "blockstore-based") content libraries, + because those require a database-level link to their authoring organization, + and thus would not need backfilling via this command. + + Returns: set[tuple[str, LibraryLocator]] + """ + # Using a set comprehension removes any duplicate (org, library) pairs. + return { + (library_key.org, library_key) + for library_key + in modulestore().get_library_keys() + } diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_backfill_orgs_and_org_courses.py b/cms/djangoapps/contentstore/management/commands/tests/test_backfill_orgs_and_org_courses.py new file mode 100644 index 0000000000000000000000000000000000000000..1d2f66f35ba5fb6791187478a0f59265c186c0e6 --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/tests/test_backfill_orgs_and_org_courses.py @@ -0,0 +1,186 @@ +""" +Tests for `backfill_orgs_and_org_courses` CMS management command. +""" +from unittest.mock import patch + +import ddt +from django.core.management import CommandError, call_command +from organizations import api as organizations_api +from organizations.api import ( + add_organization, + add_organization_course, + get_organization_by_short_name, + get_organization_courses, + get_organizations +) + +from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import LibraryFactory + +from .. import backfill_orgs_and_org_courses + + +@ddt.ddt +class BackfillOrgsAndOrgCoursesTest(SharedModuleStoreTestCase): + """ + Test `backfill_orgs_and_org_courses`. + + We test: + * That one happy path of the command works. + * That the command line args are processed correctly. + * That the confirmation prompt works. + + We don't test: + * Specifics/edge cases around fetching course run keys, content library keys, + or the actual application of the backfill. Those are handled by tests within + `course_overviews`, `modulestore`, and `organizations`, respectively. + """ + + def test_end_to_end(self): + """ + Test the happy path of the backfill command without any mocking. + """ + # org_A: already existing, with courses and a library. + org_a = add_organization({"short_name": "org_A", "name": "Org A"}) + course_a1_key = CourseOverviewFactory(org="org_A", run="1").id + CourseOverviewFactory(org="org_A", run="2") + LibraryFactory(org="org_A") + + # Write linkage for org_a->course_a1. + # (Linkage for org_a->course_a2 is purposefully left out here; + # it should be created by the backfill). + add_organization_course(org_a, course_a1_key) + + # org_B: already existing, but has no content. + add_organization({"short_name": "org_B", "name": "Org B"}) + + # org_C: has a couple courses; should be created. + CourseOverviewFactory(org="org_C", run="1") + CourseOverviewFactory(org="org_C", run="2") + + # org_D: has both a course and a library; should be created. + CourseOverviewFactory(org="org_D", run="1") + LibraryFactory(org="org_D") + + # org_E: just has a library; should be created. + LibraryFactory(org="org_E") + + # Confirm starting condition: + # Only orgs are org_A and org_B, and only linkage is org_a->course_a1. + assert set( + org["short_name"] for org in get_organizations() + ) == { + "org_A", "org_B" + } + assert len(get_organization_courses(get_organization_by_short_name('org_A'))) == 1 + assert len(get_organization_courses(get_organization_by_short_name('org_B'))) == 0 + + # Run the backfill. + call_command("backfill_orgs_and_org_courses", "--apply") + + # Confirm ending condition: + # All five orgs present. Each org a has expected number of org-course linkages. + assert set( + org["short_name"] for org in get_organizations() + ) == { + "org_A", "org_B", "org_C", "org_D", "org_E" + } + assert len(get_organization_courses(get_organization_by_short_name('org_A'))) == 2 + assert len(get_organization_courses(get_organization_by_short_name('org_B'))) == 0 + assert len(get_organization_courses(get_organization_by_short_name('org_C'))) == 2 + assert len(get_organization_courses(get_organization_by_short_name('org_D'))) == 1 + assert len(get_organization_courses(get_organization_by_short_name('org_E'))) == 0 + + @ddt.data( + { + "command_line_args": [], + "user_inputs": ["n"], + "should_apply_changes": False, + }, + { + "command_line_args": [], + "user_inputs": ["x", "N"], + "should_apply_changes": False, + }, + { + "command_line_args": [], + "user_inputs": ["", "", "YeS"], + "should_apply_changes": True, + }, + { + "command_line_args": ["--dry"], + "user_inputs": [], + "should_apply_changes": False, + }, + { + "command_line_args": ["--apply"], + "user_inputs": [], + "should_apply_changes": True, + }, + ) + @ddt.unpack + @patch.object(organizations_api, 'bulk_add_organizations') + @patch.object(organizations_api, 'bulk_add_organization_courses') + def test_arguments_and_input( + self, + mock_add_orgs, + mock_add_org_courses, + command_line_args, + user_inputs, + should_apply_changes, + ): + """ + Test that the command-line arguments and user input processing works as + expected. + + Given a list of `command_line_args` and a sequence of `user_inputs` + that will be supplied, we expect that: + * the user will be prompted a number of times equal to the length of `user_inputs`, and + * the command will/won't apply changes according to `should_apply_changes`. + """ + with patch.object( + backfill_orgs_and_org_courses, "input", side_effect=user_inputs + ) as mock_input: + call_command("backfill_orgs_and_org_courses", *command_line_args) + + # Make sure user was prompted the number of times we expected. + assert mock_input.call_count == len(user_inputs) + + if should_apply_changes and user_inputs: + # If we DID apply changes and the user WAS prompted first, + # then we expect one DRY bulk-add run *and* one REAL bulk-add run. + assert mock_add_orgs.call_count == 2 + assert mock_add_org_courses.call_count == 2 + assert mock_add_orgs.call_args_list[0].kwargs == {"dry_run": True} + assert mock_add_org_courses.call_args_list[0].kwargs == {"dry_run": True} + assert mock_add_orgs.call_args_list[1].kwargs == {"dry_run": False} + assert mock_add_org_courses.call_args_list[1].kwargs == {"dry_run": False} + elif should_apply_changes: + # If DID apply changes but the user WASN'T prompted, + # then we expect just one REAL bulk-add run. + assert mock_add_orgs.call_count == 1 + assert mock_add_org_courses.call_count == 1 + assert mock_add_orgs.call_args.kwargs == {"dry_run": False} + assert mock_add_org_courses.call_args.kwargs == {"dry_run": False} + elif user_inputs: + # If we DIDN'T apply changes but the user WAS prompted + # then we expect just one DRY bulk-add run. + assert mock_add_orgs.call_count == 1 + assert mock_add_org_courses.call_count == 1 + assert mock_add_orgs.call_args.kwargs == {"dry_run": True} + assert mock_add_org_courses.call_args.kwargs == {"dry_run": True} + else: + # Similarly, if we DIDN'T apply changes and the user WASN'T prompted + # then we expect just one DRY bulk-add run. + assert mock_add_orgs.call_count == 1 + assert mock_add_org_courses.call_count == 1 + assert mock_add_orgs.call_args.kwargs == {"dry_run": True} + assert mock_add_org_courses.call_args.kwargs == {"dry_run": True} + + def test_conflicting_arguments(self): + """ + Test that calling the command with both "--dry" and "--apply" raises an exception. + """ + with self.assertRaises(CommandError): + call_command("backfill_orgs_and_org_courses", "--dry", "--apply") diff --git a/cms/djangoapps/contentstore/tasks.py b/cms/djangoapps/contentstore/tasks.py index 5435986f6a4d8af6eda7d2bdc5051d85d3e58f4d..97e8c5bbb1f9e4bd5a9a8e0bc7750050942789b4 100644 --- a/cms/djangoapps/contentstore/tasks.py +++ b/cms/djangoapps/contentstore/tasks.py @@ -25,6 +25,7 @@ from django.utils.translation import ugettext as _ from edx_django_utils.monitoring import set_code_owner_attribute, set_code_owner_attribute_from_module from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import LibraryLocator +from organizations.api import add_organization_course, ensure_organization from organizations.models import OrganizationCourse from path import Path as path from pytz import UTC @@ -44,7 +45,6 @@ from common.djangoapps.course_action_state.models import CourseRerunState from openedx.core.djangoapps.embargo.models import CountryAccessRule, RestrictedCourse from openedx.core.lib.extract_tar import safetar_extractall from common.djangoapps.student.auth import has_course_author_access -from common.djangoapps.util.organizations_helpers import add_organization_course, get_organization_by_short_name from xmodule.contentstore.django import contentstore from xmodule.course_module import CourseFields from xmodule.exceptions import SerializationError @@ -128,7 +128,7 @@ def rerun_course(source_course_key_string, destination_course_key_string, user_i for country_access_rule in country_access_rules: clone_instance(country_access_rule, {'restricted_course': new_restricted_course}) - org_data = get_organization_by_short_name(source_course_key.org) + org_data = ensure_organization(source_course_key.org) add_organization_course(org_data, destination_course_key) return "succeeded" diff --git a/cms/djangoapps/contentstore/tests/test_course_create_rerun.py b/cms/djangoapps/contentstore/tests/test_course_create_rerun.py index b819d504ffc22a59bcd47c6c78c23dfd5b92569e..391815f278e52c63b0073551f7e46959703222c5 100644 --- a/cms/djangoapps/contentstore/tests/test_course_create_rerun.py +++ b/cms/djangoapps/contentstore/tests/test_course_create_rerun.py @@ -7,15 +7,20 @@ import datetime import ddt import six +from django.test import override_settings from django.test.client import RequestFactory from django.urls import reverse -from mock import patch from opaque_keys.edx.keys import CourseKey +from organizations.api import ( + add_organization, + get_organization_by_short_name, + get_course_organizations +) +from organizations.exceptions import InvalidOrganizationException from cms.djangoapps.contentstore.tests.utils import AjaxEnabledTestClient, parse_json from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole from common.djangoapps.student.tests.factories import UserFactory -from common.djangoapps.util.organizations_helpers import add_organization, get_course_organizations from xmodule.course_module import CourseFields from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore @@ -66,7 +71,6 @@ class TestCourseListing(ModuleStoreTestCase): self.client.logout() ModuleStoreTestCase.tearDown(self) - @patch.dict('django.conf.settings.FEATURES', {'ORGANIZATIONS_APP': True}) def test_rerun(self): """ Just testing the functionality the view handler adds over the tasks tested in test_clone_course @@ -114,13 +118,15 @@ class TestCourseListing(ModuleStoreTestCase): course = self.store.get_course(new_course_key) self.assertTrue(course.cert_html_view_enabled) - @patch.dict('django.conf.settings.FEATURES', {'ORGANIZATIONS_APP': False}) @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split) - def test_course_creation_without_org_app_enabled(self, store): + def test_course_creation_for_unknown_organization_relaxed(self, store): """ - Tests course creation workflow should not create course to org - link if organizations_app is not enabled. + Tests that when ORGANIZATIONS_AUTOCREATE is True, + creating a course-run with an unknown org slug will create an organization + and organization-course linkage in the system. """ + with self.assertRaises(InvalidOrganizationException): + get_organization_by_short_name("orgX") with modulestore().default_store(store): response = self.client.ajax_post(self.course_create_rerun_url, { 'org': 'orgX', @@ -129,17 +135,19 @@ class TestCourseListing(ModuleStoreTestCase): 'run': '2015_T2' }) self.assertEqual(response.status_code, 200) + self.assertIsNotNone(get_organization_by_short_name("orgX")) data = parse_json(response) new_course_key = CourseKey.from_string(data['course_key']) course_orgs = get_course_organizations(new_course_key) - self.assertEqual(course_orgs, []) + self.assertEqual(len(course_orgs), 1) + self.assertEqual(course_orgs[0]['short_name'], 'orgX') - @patch.dict('django.conf.settings.FEATURES', {'ORGANIZATIONS_APP': True}) @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split) - def test_course_creation_with_org_not_in_system(self, store): + @override_settings(ORGANIZATIONS_AUTOCREATE=False) + def test_course_creation_for_unknown_organization_strict(self, store): """ - Tests course creation workflow when course organization does not exist - in system. + Tests that when ORGANIZATIONS_AUTOCREATE is False, + creating a course-run with an unknown org slug will raise a validation error. """ with modulestore().default_store(store): response = self.client.ajax_post(self.course_create_rerun_url, { @@ -149,12 +157,13 @@ class TestCourseListing(ModuleStoreTestCase): 'run': '2015_T2' }) self.assertEqual(response.status_code, 400) + with self.assertRaises(InvalidOrganizationException): + get_organization_by_short_name("orgX") data = parse_json(response) self.assertIn(u'Organization you selected does not exist in the system', data['error']) - @patch.dict('django.conf.settings.FEATURES', {'ORGANIZATIONS_APP': True}) - @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split) - def test_course_creation_with_org_in_system(self, store): + @ddt.data(True, False) + def test_course_creation_for_known_organization(self, organizations_autocreate): """ Tests course creation workflow when course organization exist in system. """ @@ -163,7 +172,7 @@ class TestCourseListing(ModuleStoreTestCase): 'short_name': 'orgX', 'description': 'Testing Organization Description', }) - with modulestore().default_store(store): + with override_settings(ORGANIZATIONS_AUTOCREATE=organizations_autocreate): response = self.client.ajax_post(self.course_create_rerun_url, { 'org': 'orgX', 'number': 'CS101', diff --git a/cms/djangoapps/contentstore/tests/test_tasks.py b/cms/djangoapps/contentstore/tests/test_tasks.py index 192280682a37539db9dec3f5e84ce65df0a08ed3..1239f85373ce0826b3a0b9193ed9f596792199dc 100644 --- a/cms/djangoapps/contentstore/tests/test_tasks.py +++ b/cms/djangoapps/contentstore/tests/test_tasks.py @@ -127,7 +127,7 @@ class RerunCourseTaskTestCase(CourseTestCase): old_course_id = str(old_course_key) new_course_id = str(new_course_key) - organization = OrganizationFactory() + organization = OrganizationFactory(short_name=old_course_key.org) OrganizationCourse.objects.create(course_id=old_course_id, organization=organization) restricted_course = RestrictedCourse.objects.create(course_key=self.course.id) diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index afb4b7c9558f2ca17278953fce3d1fbb6a36af57..94af33f0aa0fa215df6133e935928363635e2387 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -28,6 +28,8 @@ 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 organizations.api import add_organization_course, ensure_organization +from organizations.exceptions import InvalidOrganizationException from six import text_type from six.moves import filter @@ -66,9 +68,6 @@ from common.djangoapps.util.milestones_helpers import ( set_prerequisite_courses ) from openedx.core import toggles as core_toggles -from common.djangoapps.util.organizations_helpers import ( - add_organization_course, get_organization_by_short_name, organizations_enabled -) from common.djangoapps.util.string_utils import _has_non_ascii_characters from common.djangoapps.xblock_django.api import deprecated_xblocks from xmodule.contentstore.content import StaticContent @@ -887,10 +886,13 @@ def create_new_course(user, org, number, run, fields): 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')) + try: + org_data = ensure_organization(org) + except InvalidOrganizationException: + 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) diff --git a/cms/djangoapps/contentstore/views/organization.py b/cms/djangoapps/contentstore/views/organization.py index 89f6bab278610860ffc66868d481fc3cdb5ef1c0..d0dd98e5b0a04e6dbb6c7f545a135fd31a81ef46 100644 --- a/cms/djangoapps/contentstore/views/organization.py +++ b/cms/djangoapps/contentstore/views/organization.py @@ -5,9 +5,9 @@ from django.contrib.auth.decorators import login_required from django.http import HttpResponse from django.utils.decorators import method_decorator from django.views.generic import View +from organizations.api import get_organizations from openedx.core.djangolib.js_utils import dump_js_escaped_json -from common.djangoapps.util.organizations_helpers import get_organizations class OrganizationListView(View): diff --git a/cms/djangoapps/contentstore/views/tests/test_organizations.py b/cms/djangoapps/contentstore/views/tests/test_organizations.py index 6aabcbd781b9aa40cccb9e3a606e772a95e3d58a..ce12b9899f5fbfba1c5de8346982b9d72e88f5c5 100644 --- a/cms/djangoapps/contentstore/views/tests/test_organizations.py +++ b/cms/djangoapps/contentstore/views/tests/test_organizations.py @@ -5,16 +5,13 @@ import json from django.test import TestCase from django.urls import reverse -from mock import patch +from organizations.api import add_organization from common.djangoapps.student.tests.factories import UserFactory -from common.djangoapps.util.organizations_helpers import add_organization -@patch.dict('django.conf.settings.FEATURES', {'ORGANIZATIONS_APP': True}) class TestOrganizationListing(TestCase): """Verify Organization listing behavior.""" - @patch.dict('django.conf.settings.FEATURES', {'ORGANIZATIONS_APP': True}) def setUp(self): super(TestOrganizationListing, self).setUp() self.staff = UserFactory(is_staff=True) diff --git a/cms/envs/bok_choy.py b/cms/envs/bok_choy.py index 62a6e8aa98545ef9b1f81d1fa8b84673e4ac9d7b..d75eed80b978ff7284d55fc39cca3ad642465a5c 100644 --- a/cms/envs/bok_choy.py +++ b/cms/envs/bok_choy.py @@ -147,7 +147,8 @@ FEATURES['ENABLE_COURSEWARE_INDEX'] = True FEATURES['ENABLE_LIBRARY_INDEX'] = True FEATURES['ENABLE_CONTENT_LIBRARY_INDEX'] = False -FEATURES['ORGANIZATIONS_APP'] = True +ORGANIZATIONS_AUTOCREATE = False + SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine" # Path at which to store the mock index MOCK_SEARCH_BACKING_FILE = ( diff --git a/cms/envs/common.py b/cms/envs/common.py index b981b566236aae5abbb4a187f74df375c23e498c..7a4cd7b77fbeb77f4d89d0e9c5a49787f1cdfa78 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -293,8 +293,6 @@ FEATURES = { # Special Exams, aka Timed and Proctored Exams 'ENABLE_SPECIAL_EXAMS': False, - 'ORGANIZATIONS_APP': False, - # Show the language selector in the header 'SHOW_HEADER_LANGUAGE_SELECTOR': False, @@ -1513,6 +1511,9 @@ INSTALLED_APPS = [ 'openedx.core.djangoapps.content.learning_sequences.apps.LearningSequencesConfig', 'ratelimitbackend', + + # Database-backed Organizations App (http://github.com/edx/edx-organizations) + 'organizations', ] @@ -1641,9 +1642,6 @@ OPTIONAL_APPS = ( # edxval ('edxval', 'openedx.core.djangoapps.content.course_overviews.apps.CourseOverviewsConfig'), - # Organizations App (http://github.com/edx/edx-organizations) - ('organizations', None), - # Enterprise App (http://github.com/edx/edx-enterprise) ('enterprise', None), ('consent', None), @@ -2294,3 +2292,20 @@ VERIFY_STUDENT = { # The variable represents the window within which a verification is considered to be "expiring soon." "EXPIRING_SOON_WINDOW": 28, } + +######################## Organizations ######################## + +# .. toggle_name: ORGANIZATIONS_AUTOCREATE +# .. toggle_implementation: DjangoSetting +# .. toggle_default: True +# .. toggle_description: When enabled, creating a course run or content library with +# an "org slug" that does not map to an Organization in the database will trigger the +# creation of a new Organization, with its name and short_name set to said org slug. +# When disabled, creation of such content with an unknown org slug will instead +# result in a validation error. +# If you want the Organization table to be an authoritative information source in +# Studio, then disable this; however, if you want the table to just be a reflection of +# the orgs referenced in Studio content, then leave it enabled. +# .. toggle_use_cases: open_edx +# .. toggle_creation_date: 2020-11-02 +ORGANIZATIONS_AUTOCREATE = True diff --git a/cms/envs/devstack.py b/cms/envs/devstack.py index 79d41d633831b4a12fea49be2cb5531d832da4d6..14ebce79a0e82a8e99bbe2285f1b15fbb81fcb10 100644 --- a/cms/envs/devstack.py +++ b/cms/envs/devstack.py @@ -118,12 +118,11 @@ def should_show_debug_toolbar(request): FEATURES['MILESTONES_APP'] = True ########################### ORGANIZATIONS ################################# -# This is disabled for Devstack Studio for developer convenience. -# If it were enabled, then users would not be able to create course runs -# with any arbritrary org slug -- they would have to first make sure that -# the organization exists in the Organization table. -# Note that some production environments (such as studio.edx.org) do enable this flag. -FEATURES['ORGANIZATIONS_APP'] = False +# Although production studio.edx.org disables `ORGANIZATIONS_AUTOCREATE`, +# we purposefully leave auto-creation enabled in Devstack Studio for developer +# convenience, allowing devs to create test courses for any organization +# without having to first manually create said organizations in the admin panel. +ORGANIZATIONS_AUTOCREATE = True ################################ ENTRANCE EXAMS ################################ FEATURES['ENTRANCE_EXAMS'] = True diff --git a/common/djangoapps/util/organizations_helpers.py b/common/djangoapps/util/organizations_helpers.py deleted file mode 100644 index 3990af513d5743fb6837564c9e5c51c9f10813dd..0000000000000000000000000000000000000000 --- a/common/djangoapps/util/organizations_helpers.py +++ /dev/null @@ -1,106 +0,0 @@ -""" -Utility library for working with the edx-organizations app -""" - - -from django.conf import settings -from django.db.utils import DatabaseError - - -def add_organization(organization_data): - """ - Client API operation adapter/wrapper - """ - if not organizations_enabled(): - return None - from organizations import api as organizations_api - return organizations_api.add_organization(organization_data=organization_data) - - -def add_organization_course(organization_data, course_id): - """ - Client API operation adapter/wrapper - """ - if not organizations_enabled(): - return None - from organizations import api as organizations_api - return organizations_api.add_organization_course(organization_data=organization_data, course_key=course_id) - - -def get_organization(organization_id): - """ - Client API operation adapter/wrapper - """ - if not organizations_enabled(): - return [] - from organizations import api as organizations_api - return organizations_api.get_organization(organization_id) - - -def get_organization_by_short_name(organization_short_name): - """ - Client API operation adapter/wrapper - """ - if not organizations_enabled(): - return None - from organizations import api as organizations_api - from organizations.exceptions import InvalidOrganizationException - try: - return organizations_api.get_organization_by_short_name(organization_short_name) - except InvalidOrganizationException: - return None - - -def get_organizations(): - """ - Client API operation adapter/wrapper - """ - if not organizations_enabled(): - return [] - from organizations import api as organizations_api - # Due to the way unit tests run for edx-platform, models are not yet available at the time - # of Django admin form instantiation. This unfortunately results in an invocation of the following - # workflow, because the test configuration is (correctly) configured to exercise the application - # The good news is that this case does not manifest in the Real World, because migrations have - # been run ahead of application instantiation and the flag set only when that is truly the case. - try: - return organizations_api.get_organizations() - except DatabaseError: - return [] - - -def get_organization_courses(organization_id): - """ - Client API operation adapter/wrapper - """ - if not organizations_enabled(): - return [] - from organizations import api as organizations_api - return organizations_api.get_organization_courses(organization_id) - - -def get_course_organizations(course_id): - """ - Client API operation adapter/wrapper - """ - if not organizations_enabled(): - return [] - from organizations import api as organizations_api - return organizations_api.get_course_organizations(course_id) - - -def get_course_organization_id(course_id): - """ - Returns organization id for course or None if the course is not linked to an org - """ - course_organization = get_course_organizations(course_id) - if course_organization: - return course_organization[0]['id'] - return None - - -def organizations_enabled(): - """ - Returns boolean indication if organizations app is enabled on not. - """ - return settings.FEATURES.get('ORGANIZATIONS_APP', False) diff --git a/common/djangoapps/util/tests/test_organizations_helpers.py b/common/djangoapps/util/tests/test_organizations_helpers.py deleted file mode 100644 index 9c48fa8dede6c71dc97adb60877f8a1fb35b2e4a..0000000000000000000000000000000000000000 --- a/common/djangoapps/util/tests/test_organizations_helpers.py +++ /dev/null @@ -1,79 +0,0 @@ -""" -Tests for the organizations helpers library, which is the integration point for the edx-organizations API -""" - - -import six -from mock import patch - -from common.djangoapps.util import organizations_helpers -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory - - -@patch.dict('django.conf.settings.FEATURES', {'ORGANIZATIONS_APP': False}) -class OrganizationsHelpersTestCase(ModuleStoreTestCase): - """ - Main test suite for Organizations API client library - """ - - CREATE_USER = False - - def setUp(self): - """ - Test case scaffolding - """ - super(OrganizationsHelpersTestCase, self).setUp() - self.course = CourseFactory.create() - - self.organization = { - 'name': 'Test Organization', - 'short_name': 'Orgx', - 'description': 'Testing Organization Helpers Library', - } - - def test_get_organization_returns_none_when_app_disabled(self): - response = organizations_helpers.get_organization(1) - self.assertEqual(len(response), 0) - - def test_get_organizations_returns_none_when_app_disabled(self): - response = organizations_helpers.get_organizations() - self.assertEqual(len(response), 0) - - def test_get_organization_courses_returns_none_when_app_disabled(self): - response = organizations_helpers.get_organization_courses(1) - self.assertEqual(len(response), 0) - - def test_get_course_organizations_returns_none_when_app_disabled(self): - response = organizations_helpers.get_course_organizations(six.text_type(self.course.id)) - self.assertEqual(len(response), 0) - - def test_add_organization_returns_none_when_app_disabled(self): - response = organizations_helpers.add_organization(organization_data=self.organization) - self.assertIsNone(response) - - def test_add_organization_course_returns_none_when_app_disabled(self): - response = organizations_helpers.add_organization_course(self.organization, self.course.id) - self.assertIsNone(response) - - def test_get_organization_by_short_name_when_app_disabled(self): - """ - Tests get_organization_by_short_name api when app is disabled. - """ - response = organizations_helpers.get_organization_by_short_name(self.organization['short_name']) - self.assertIsNone(response) - - @patch.dict('django.conf.settings.FEATURES', {'ORGANIZATIONS_APP': True}) - def test_get_organization_by_short_name_when_app_enabled(self): - """ - Tests get_organization_by_short_name api when app is enabled. - """ - response = organizations_helpers.add_organization(organization_data=self.organization) - self.assertIsNotNone(response['id']) - - response = organizations_helpers.get_organization_by_short_name(self.organization['short_name']) - self.assertIsNotNone(response['id']) - - # fetch non existing org - response = organizations_helpers.get_organization_by_short_name('non_existing') - self.assertIsNone(response) diff --git a/common/lib/xmodule/xmodule/modulestore/mixed.py b/common/lib/xmodule/xmodule/modulestore/mixed.py index 0abfc1f251e1a005df9b0524b20691e32d1339ec..9c4b06a43353baefff34aeadb07e09e3bd392848 100644 --- a/common/lib/xmodule/xmodule/modulestore/mixed.py +++ b/common/lib/xmodule/xmodule/modulestore/mixed.py @@ -327,6 +327,23 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase): courses[course_id] = course return list(courses.values()) + def get_library_keys(self): + """ + Returns a list of all unique content library keys in the mixed + modulestore. + + Returns: list[LibraryLocator] + """ + all_library_keys = set() + for store in self.modulestores: + if not hasattr(store, 'get_library_keys'): + continue + all_library_keys |= set( + self._clean_locator_for_mapping(library_key) + for library_key in store.get_library_keys() + ) + return list(all_library_keys) + @strip_key def get_library_summaries(self, **kwargs): """ diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py index e5f27126e94b5dd27217277a83d56631a05b3060..f8321d20b65bb6e85058d9361e2cc1df05ec49ac 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py @@ -1073,6 +1073,19 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase): ) return courses_summaries + def get_library_keys(self): + """ + Returns a list of all unique content library keys in the Split + modulestore. + + Returns: list[LibraryLocator] + """ + return list({ + self._create_library_locator(library_index, branch=None) + for library_index + in self.find_matching_course_indexes(branch="library") + }) + @autoretry_read() def get_library_summaries(self, **kwargs): """ diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py b/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py index 94bd27c92e11a20b6dd65707ba7b6a5dd6eb0e56..202ae351daf2ddf456b7643e5579d1ea98220aa8 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py @@ -145,6 +145,13 @@ class TestLibraries(MixedSplitTestCase): result = self.store.get_library(LibraryLocator("non", "existent")) self.assertEqual(result, None) + def test_get_library_keys(self): + """ Test get_library_keys() """ + libraries = [LibraryFactory.create(modulestore=self.store) for _ in range(3)] + lib_keys_expected = {lib.location.library_key for lib in libraries} + lib_keys_actual = set(self.store.get_library_keys()) + assert lib_keys_expected == lib_keys_actual + def test_get_libraries(self): """ Test get_libraries() """ libraries = [LibraryFactory.create(modulestore=self.store) for _ in range(3)] diff --git a/import_shims/lms/util/organizations_helpers.py b/import_shims/lms/util/organizations_helpers.py deleted file mode 100644 index 82916a2464ccaa909bcee23b469a4bdf66dbf4e7..0000000000000000000000000000000000000000 --- a/import_shims/lms/util/organizations_helpers.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Deprecated import support. Auto-generated by import_shims/generate_shims.sh.""" -# pylint: disable=redefined-builtin,wrong-import-position,wildcard-import,useless-suppression,line-too-long - -from import_shims.warn import warn_deprecated_import - -warn_deprecated_import('util.organizations_helpers', 'common.djangoapps.util.organizations_helpers') - -from common.djangoapps.util.organizations_helpers import * diff --git a/import_shims/lms/util/tests/test_organizations_helpers.py b/import_shims/lms/util/tests/test_organizations_helpers.py deleted file mode 100644 index f4904e5c966e2b02d457c0f1ef7e8d2c04e77c41..0000000000000000000000000000000000000000 --- a/import_shims/lms/util/tests/test_organizations_helpers.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Deprecated import support. Auto-generated by import_shims/generate_shims.sh.""" -# pylint: disable=redefined-builtin,wrong-import-position,wildcard-import,useless-suppression,line-too-long - -from import_shims.warn import warn_deprecated_import - -warn_deprecated_import('util.tests.test_organizations_helpers', 'common.djangoapps.util.tests.test_organizations_helpers') - -from common.djangoapps.util.tests.test_organizations_helpers import * diff --git a/import_shims/studio/util/organizations_helpers.py b/import_shims/studio/util/organizations_helpers.py deleted file mode 100644 index 82916a2464ccaa909bcee23b469a4bdf66dbf4e7..0000000000000000000000000000000000000000 --- a/import_shims/studio/util/organizations_helpers.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Deprecated import support. Auto-generated by import_shims/generate_shims.sh.""" -# pylint: disable=redefined-builtin,wrong-import-position,wildcard-import,useless-suppression,line-too-long - -from import_shims.warn import warn_deprecated_import - -warn_deprecated_import('util.organizations_helpers', 'common.djangoapps.util.organizations_helpers') - -from common.djangoapps.util.organizations_helpers import * diff --git a/import_shims/studio/util/tests/test_organizations_helpers.py b/import_shims/studio/util/tests/test_organizations_helpers.py deleted file mode 100644 index f4904e5c966e2b02d457c0f1ef7e8d2c04e77c41..0000000000000000000000000000000000000000 --- a/import_shims/studio/util/tests/test_organizations_helpers.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Deprecated import support. Auto-generated by import_shims/generate_shims.sh.""" -# pylint: disable=redefined-builtin,wrong-import-position,wildcard-import,useless-suppression,line-too-long - -from import_shims.warn import warn_deprecated_import - -warn_deprecated_import('util.tests.test_organizations_helpers', 'common.djangoapps.util.tests.test_organizations_helpers') - -from common.djangoapps.util.tests.test_organizations_helpers import * diff --git a/lms/djangoapps/certificates/admin.py b/lms/djangoapps/certificates/admin.py index 01b3c4981bb368c492bde8b144787e8a4b8f0077..aeef8210e8a37f2fc7b250d1eaba5c16e2e22065 100644 --- a/lms/djangoapps/certificates/admin.py +++ b/lms/djangoapps/certificates/admin.py @@ -10,6 +10,7 @@ from django import forms from django.conf import settings from django.contrib import admin from django.utils.safestring import mark_safe +from organizations.api import get_organizations from lms.djangoapps.certificates.models import ( CertificateGenerationConfiguration, @@ -19,7 +20,6 @@ from lms.djangoapps.certificates.models import ( CertificateTemplateAsset, GeneratedCertificate ) -from common.djangoapps.util.organizations_helpers import get_organizations class CertificateTemplateForm(forms.ModelForm): diff --git a/lms/djangoapps/certificates/api.py b/lms/djangoapps/certificates/api.py index f0a90f8f19db0e230b9c5c22acf690a360aab724..8b63fb5e41b09d0a06e897d8e1d3403c2bfa66d3 100644 --- a/lms/djangoapps/certificates/api.py +++ b/lms/djangoapps/certificates/api.py @@ -15,6 +15,7 @@ from django.urls import reverse from eventtracking import tracker from opaque_keys.edx.django.models import CourseKeyField from opaque_keys.edx.keys import CourseKey +from organizations.api import get_course_organization_id from lms.djangoapps.branding import api as branding_api from lms.djangoapps.certificates.models import ( @@ -32,7 +33,6 @@ from lms.djangoapps.certificates.queue import XQueueCertInterface from lms.djangoapps.instructor.access import list_with_level from openedx.core.djangoapps.certificates.api import certificates_viewable_for_course from openedx.core.djangoapps.content.course_overviews.models import CourseOverview -from common.djangoapps.util.organizations_helpers import get_course_organization_id from xmodule.modulestore.django import modulestore log = logging.getLogger("edx.certificate") diff --git a/lms/djangoapps/certificates/tests/test_webview_views.py b/lms/djangoapps/certificates/tests/test_webview_views.py index 665853265324437e6b8ab5b3e40d2d126e49d6ac..15cd8f2ba39dffb7056ec2b76fb4394fe08655cc 100644 --- a/lms/djangoapps/certificates/tests/test_webview_views.py +++ b/lms/djangoapps/certificates/tests/test_webview_views.py @@ -5,6 +5,7 @@ import datetime import json from collections import OrderedDict +from urllib.parse import urlencode from uuid import uuid4 import ddt @@ -14,7 +15,7 @@ from django.test.client import Client, RequestFactory from django.test.utils import override_settings from django.urls import reverse from mock import patch -from urllib.parse import urlencode +from organizations import api as organizations_api from common.djangoapps.course_modes.models import CourseMode from edx_toggles.toggles import WaffleSwitch @@ -53,7 +54,6 @@ from openedx.core.lib.tests.assertions.events import assert_event_matches from common.djangoapps.student.roles import CourseStaffRole from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory from common.djangoapps.track.tests import EventTrackingTestCase -from common.djangoapps.util import organizations_helpers as organizations_api from common.djangoapps.util.date_utils import strftime_localized from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory @@ -413,7 +413,7 @@ class CertificatesViewsTests(CommonCertificatesTestCase, CacheIsolationTestCase) 'logo': '/logo_test1.png/' } test_org = organizations_api.add_organization(organization_data=test_organization_data) - organizations_api.add_organization_course(organization_data=test_org, course_id=six.text_type(self.course.id)) + organizations_api.add_organization_course(organization_data=test_org, course_key=six.text_type(self.course.id)) self._add_course_certificates(count=1, signatory_count=1, is_active=True) test_url = get_certificate_url( user_id=self.user.id, @@ -483,7 +483,7 @@ class CertificatesViewsTests(CommonCertificatesTestCase, CacheIsolationTestCase) 'logo': '/logo_test1.png' } test_org = organizations_api.add_organization(organization_data=test_organization_data) - organizations_api.add_organization_course(organization_data=test_org, course_id=six.text_type(self.course.id)) + organizations_api.add_organization_course(organization_data=test_org, course_key=six.text_type(self.course.id)) self._add_course_certificates(count=1, signatory_count=1, is_active=True) badge_class = get_completion_badge(course_id=self.course_id, user=self.user) BadgeAssertionFactory.create( diff --git a/lms/djangoapps/certificates/views/webview.py b/lms/djangoapps/certificates/views/webview.py index 26f8e7f03330e9ead77b72ceb6185de456cb4f93..5f9afcb479b2f218c02646eaa54bfa1d34a9d59e 100644 --- a/lms/djangoapps/certificates/views/webview.py +++ b/lms/djangoapps/certificates/views/webview.py @@ -20,6 +20,7 @@ from django.utils.encoding import smart_str from eventtracking import tracker from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey +from organizations import api as organizations_api from lms.djangoapps.badges.events.course_complete import get_completion_badge from lms.djangoapps.badges.utils import badges_enabled @@ -48,7 +49,6 @@ from openedx.core.djangoapps.lang_pref.api import get_closest_released_language from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.lib.courses import course_image_url from common.djangoapps.student.models import LinkedInAddToProfileConfiguration -from common.djangoapps.util import organizations_helpers as organization_api from common.djangoapps.util.date_utils import strftime_localized from common.djangoapps.util.views import handle_500 @@ -428,7 +428,7 @@ def _update_organization_context(context, course): """ partner_long_name, organization_logo = None, None partner_short_name = course.display_organization if course.display_organization else course.org - organizations = organization_api.get_course_organizations(course_id=course.id) + organizations = organizations_api.get_course_organizations(course_key=course.id) if organizations: #TODO Need to add support for multiple organizations, Currently we are interested in the first one. organization = organizations[0] diff --git a/lms/envs/common.py b/lms/envs/common.py index 2183dd33da6d44168c2328de75357b1d9b645e5e..2dcc99d26f63d161574c9a82c0c8a250a2833e9f 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -442,9 +442,6 @@ FEATURES = { # Milestones application flag 'MILESTONES_APP': False, - # Organizations application flag - 'ORGANIZATIONS_APP': False, - # Prerequisite courses feature flag 'ENABLE_PREREQUISITE_COURSES': False, @@ -2735,6 +2732,9 @@ INSTALLED_APPS = [ 'openedx.core.djangoapps.content.learning_sequences.apps.LearningSequencesConfig', 'ratelimitbackend', + + # Database-backed Organizations App (http://github.com/edx/edx-organizations) + 'organizations', ] ######################### CSRF ######################################### @@ -3349,9 +3349,6 @@ OPTIONAL_APPS = [ # edxval ('edxval', 'openedx.core.djangoapps.content.course_overviews.apps.CourseOverviewsConfig'), - # Organizations App (http://github.com/edx/edx-organizations) - ('organizations', None), - # Enterprise Apps (http://github.com/edx/edx-enterprise) ('enterprise', None), ('consent', None), diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index 8dc01e8024caa2b93daf01ff6a7b2e959516325e..b6b9607ad23ff69bf8a6aee76a1793583a958a0b 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -151,9 +151,6 @@ FEATURES['PREVENT_CONCURRENT_LOGINS'] = False ########################### Milestones ################################# FEATURES['MILESTONES_APP'] = True -########################### Organizations ################################# -FEATURES['ORGANIZATIONS_APP'] = True - ########################### Entrance Exams ################################# FEATURES['ENTRANCE_EXAMS'] = True diff --git a/lms/envs/devstack_decentralized.py b/lms/envs/devstack_decentralized.py index 44b0c53280ffc48079c3cfd4e0d616a102d2ab26..bf9dab9c5fb525cbeb38ac39d6e30114fdfbba72 100644 --- a/lms/envs/devstack_decentralized.py +++ b/lms/envs/devstack_decentralized.py @@ -117,9 +117,6 @@ FEATURES['PREVENT_CONCURRENT_LOGINS'] = False ########################### Milestones ################################# FEATURES['MILESTONES_APP'] = True -########################### Milestones ################################# -FEATURES['ORGANIZATIONS_APP'] = True - ########################### Entrance Exams ################################# FEATURES['ENTRANCE_EXAMS'] = True diff --git a/lms/envs/test.py b/lms/envs/test.py index 4a00c16ca51f35e5dfa13a768fde5e6842b16036..3b71ef3545a69ed5da0bdf7eed1d40d498eb7f35 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -468,9 +468,6 @@ FEATURES['ENABLE_LTI_PROVIDER'] = True INSTALLED_APPS.append('lms.djangoapps.lti_provider.apps.LtiProviderConfig') AUTHENTICATION_BACKENDS.append('lms.djangoapps.lti_provider.users.LtiBackend') -# ORGANIZATIONS -FEATURES['ORGANIZATIONS_APP'] = True - # Financial assistance page FEATURES['ENABLE_FINANCIAL_ASSISTANCE_FORM'] = True diff --git a/openedx/core/djangoapps/content_libraries/views.py b/openedx/core/djangoapps/content_libraries/views.py index cedc1029953340bcbeccc89aaad5a812971b65c9..d1ff9a237db5297678a188c8a86061b1e7c0d93c 100644 --- a/openedx/core/djangoapps/content_libraries/views.py +++ b/openedx/core/djangoapps/content_libraries/views.py @@ -10,6 +10,8 @@ from django.shortcuts import get_object_or_404 from django.utils.translation import ugettext as _ import edx_api_doc_tools as apidocs from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 +from organizations.api import ensure_organization +from organizations.exceptions import InvalidOrganizationException from organizations.models import Organization from rest_framework import status from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError @@ -156,14 +158,17 @@ class LibraryRootView(APIView): # definitions elsewhere. data['library_type'] = data.pop('type') data['library_license'] = data.pop('license') - # Get the organization short_name out of the "key.org" pseudo-field that the serializer added: - org_name = data["key"]["org"] # Move "slug" out of the "key.slug" pseudo-field that the serializer added: data["slug"] = data.pop("key")["slug"] + # Get the organization short_name out of the "key.org" pseudo-field that the serializer added: + org_name = data["key"]["org"] try: - org = Organization.objects.get(short_name=org_name) - except Organization.DoesNotExist: - raise ValidationError(detail={"org": "No such organization '{}' found.".format(org_name)}) + ensure_organization(org_name) + except InvalidOrganizationException: + raise ValidationError( + detail={"org": "No such organization '{}' found.".format(org_name)} + ) + org = Organization.objects.get(short_name=org_name) try: result = api.create_library(org=org, **data) except api.LibraryAlreadyExists: