From 73adc729d95c768a8df5ad45f756c78aeca00212 Mon Sep 17 00:00:00 2001
From: Fox Piacenti <fox@artconomy.com>
Date: Mon, 5 Oct 2020 10:45:36 -0500
Subject: [PATCH] Add a license field to libraries. (#25007)

---
 .../core/djangoapps/content_libraries/api.py  | 12 +++++++++++-
 .../djangoapps/content_libraries/constants.py | 19 +++++++++++++++++++
 .../migrations/0004_contentlibrary_license.py | 18 ++++++++++++++++++
 .../djangoapps/content_libraries/models.py    |  6 +++++-
 .../content_libraries/serializers.py          |  9 ++++++++-
 .../content_libraries/tests/base.py           |  8 ++++++--
 .../tests/test_content_libraries.py           |  8 ++++++--
 .../content_libraries/tests/test_runtime.py   |  4 +++-
 .../djangoapps/content_libraries/views.py     |  7 ++++++-
 9 files changed, 82 insertions(+), 9 deletions(-)
 create mode 100644 openedx/core/djangoapps/content_libraries/migrations/0004_contentlibrary_license.py

diff --git a/openedx/core/djangoapps/content_libraries/api.py b/openedx/core/djangoapps/content_libraries/api.py
index 80355ea7ccd..73c0231aa3b 100644
--- a/openedx/core/djangoapps/content_libraries/api.py
+++ b/openedx/core/djangoapps/content_libraries/api.py
@@ -147,6 +147,7 @@ class ContentLibraryMetadata:
     # Allow any user with Studio access to view this library's content in
     # Studio, use it in their courses, and copy content out of this library.
     allow_public_read = attr.ib(False)
+    license = attr.ib("")
 
 
 class AccessLevel:
@@ -307,6 +308,7 @@ def get_metadata_from_index(queryset, text_search=None):
             last_published=metadata[i].get('last_published'),
             has_unpublished_changes=metadata[i].get('has_unpublished_changes'),
             has_unpublished_deletes=metadata[i].get('has_unpublished_deletes'),
+            license=lib.license,
         )
         for i, lib in enumerate(queryset)
         if metadata[i] is not None
@@ -357,11 +359,13 @@ def get_library(library_key):
         allow_public_read=ref.allow_public_read,
         has_unpublished_changes=has_unpublished_changes,
         has_unpublished_deletes=has_unpublished_deletes,
+        license=ref.license,
     )
 
 
 def create_library(
     collection_uuid, library_type, org, slug, title, description, allow_public_learning, allow_public_read,
+    library_license,
 ):
     """
     Create a new content library.
@@ -399,6 +403,7 @@ def create_library(
             bundle_uuid=bundle.uuid,
             allow_public_learning=allow_public_learning,
             allow_public_read=allow_public_read,
+            license=library_license,
         )
     except IntegrityError:
         delete_bundle(bundle.uuid)
@@ -415,6 +420,7 @@ def create_library(
         last_published=None,
         allow_public_learning=ref.allow_public_learning,
         allow_public_read=ref.allow_public_read,
+        license=library_license,
     )
 
 
@@ -490,9 +496,10 @@ def update_library(
     allow_public_learning=None,
     allow_public_read=None,
     library_type=None,
+    library_license=None,
 ):
     """
-    Update a library's title or description.
+    Update a library's metadata
     (Slug cannot be changed as it would break IDs throughout the system.)
 
     A value of None means "don't change".
@@ -522,6 +529,9 @@ def update_library(
                     )
         ref.type = library_type
 
+        changed = True
+    if library_license is not None:
+        ref.license = library_license
         changed = True
     if changed:
         ref.save()
diff --git a/openedx/core/djangoapps/content_libraries/constants.py b/openedx/core/djangoapps/content_libraries/constants.py
index 810ae37f863..0ae985ea2ce 100644
--- a/openedx/core/djangoapps/content_libraries/constants.py
+++ b/openedx/core/djangoapps/content_libraries/constants.py
@@ -14,3 +14,22 @@ LIBRARY_TYPES = (
     (COMPLEX, _('Complex')),
     (PROBLEM, _('Problem')),
 )
+
+# These are all the licenses we support so far.
+ALL_RIGHTS_RESERVED = ''
+CC_4_BY = 'CC:4.0:BY'
+CC_4_BY_NC = 'CC:4.0:BY:NC'
+CC_4_BY_NC_ND = 'CC:4.0:BY:NC:ND'
+CC_4_BY_NC_SA = 'CC:4.0:BY:NC:SA'
+CC_4_BY_ND = 'CC:4.0:BY:ND'
+CC_4_BY_SA = 'CC:4.0:BY:SA'
+
+LICENSE_OPTIONS = (
+    (ALL_RIGHTS_RESERVED, _('All Rights Reserved.')),
+    (CC_4_BY, _('Creative Commons Attribution 4.0')),
+    (CC_4_BY_NC, _('Creative Commons Attribution-NonCommercial 4.0')),
+    (CC_4_BY_NC_ND, _('Creative Commons Attribution-NonCommercial-NoDerivatives 4.0')),
+    (CC_4_BY_NC_SA, _('Creative Commons Attribution-NonCommercial-ShareAlike 4.0')),
+    (CC_4_BY_ND, _('Creative Commons Attribution-NoDerivatives 4.0')),
+    (CC_4_BY_SA, _('Creative Commons Attribution-ShareAlike 4.0'))
+)
diff --git a/openedx/core/djangoapps/content_libraries/migrations/0004_contentlibrary_license.py b/openedx/core/djangoapps/content_libraries/migrations/0004_contentlibrary_license.py
new file mode 100644
index 00000000000..e571a30eb93
--- /dev/null
+++ b/openedx/core/djangoapps/content_libraries/migrations/0004_contentlibrary_license.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.2.16 on 2020-09-16 21:50
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('content_libraries', '0003_contentlibrary_type'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='contentlibrary',
+            name='license',
+            field=models.CharField(choices=[('', 'All Rights Reserved.'), ('CC:4.0:BY', 'Creative Commons Attribution 4.0'), ('CC:4.0:BY:NC', 'Creative Commons Attribution-NonCommercial 4.0'), ('CC:4.0:BY:NC:ND', 'Creative Commons Attribution-NonCommercial-NoDerivatives 4.0'), ('CC:4.0:BY:NC:SA', 'Creative Commons Attribution-NonCommercial-ShareAlike 4.0'), ('CC:4.0:BY:ND', 'Creative Commons Attribution-NoDerivatives 4.0'), ('CC:4.0:BY:SA', 'Creative Commons Attribution-ShareAlike 4.0')], default='', max_length=25),
+        ),
+    ]
diff --git a/openedx/core/djangoapps/content_libraries/models.py b/openedx/core/djangoapps/content_libraries/models.py
index 14014d8ace3..94b23189f26 100644
--- a/openedx/core/djangoapps/content_libraries/models.py
+++ b/openedx/core/djangoapps/content_libraries/models.py
@@ -7,7 +7,10 @@ from django.core.exceptions import ValidationError
 from django.db import models
 from django.utils.translation import ugettext_lazy as _
 from opaque_keys.edx.locator import LibraryLocatorV2
-from openedx.core.djangoapps.content_libraries.constants import LIBRARY_TYPES, COMPLEX
+from openedx.core.djangoapps.content_libraries.constants import (
+    LIBRARY_TYPES, COMPLEX, LICENSE_OPTIONS,
+    ALL_RIGHTS_RESERVED,
+)
 from organizations.models import Organization
 import six
 
@@ -47,6 +50,7 @@ class ContentLibrary(models.Model):
     slug = models.SlugField(allow_unicode=True)
     bundle_uuid = models.UUIDField(unique=True, null=False)
     type = models.CharField(max_length=25, default=COMPLEX, choices=LIBRARY_TYPES)
+    license = models.CharField(max_length=25, default=ALL_RIGHTS_RESERVED, choices=LICENSE_OPTIONS)
 
     # How is this library going to be used?
     allow_public_learning = models.BooleanField(
diff --git a/openedx/core/djangoapps/content_libraries/serializers.py b/openedx/core/djangoapps/content_libraries/serializers.py
index 9c8a0eaf24f..52978eeb8bb 100644
--- a/openedx/core/djangoapps/content_libraries/serializers.py
+++ b/openedx/core/djangoapps/content_libraries/serializers.py
@@ -5,7 +5,12 @@ Serializers for the content libraries REST API
 from django.core.validators import validate_unicode_slug
 from rest_framework import serializers
 
-from openedx.core.djangoapps.content_libraries.constants import LIBRARY_TYPES, COMPLEX
+from openedx.core.djangoapps.content_libraries.constants import (
+    LIBRARY_TYPES,
+    COMPLEX,
+    ALL_RIGHTS_RESERVED,
+    LICENSE_OPTIONS,
+)
 from openedx.core.djangoapps.content_libraries.models import ContentLibraryPermission
 from openedx.core.lib import blockstore_api
 
@@ -36,6 +41,7 @@ class ContentLibraryMetadataSerializer(serializers.Serializer):
     allow_public_read = serializers.BooleanField(default=False)
     has_unpublished_changes = serializers.BooleanField(read_only=True)
     has_unpublished_deletes = serializers.BooleanField(read_only=True)
+    license = serializers.ChoiceField(choices=LICENSE_OPTIONS, default=ALL_RIGHTS_RESERVED)
 
 
 class ContentLibraryUpdateSerializer(serializers.Serializer):
@@ -48,6 +54,7 @@ class ContentLibraryUpdateSerializer(serializers.Serializer):
     allow_public_learning = serializers.BooleanField()
     allow_public_read = serializers.BooleanField()
     type = serializers.ChoiceField(choices=LIBRARY_TYPES)
+    license = serializers.ChoiceField(choices=LICENSE_OPTIONS)
 
 
 class ContentLibraryPermissionLevelSerializer(serializers.Serializer):
diff --git a/openedx/core/djangoapps/content_libraries/tests/base.py b/openedx/core/djangoapps/content_libraries/tests/base.py
index 6788c423492..dfb19608192 100644
--- a/openedx/core/djangoapps/content_libraries/tests/base.py
+++ b/openedx/core/djangoapps/content_libraries/tests/base.py
@@ -16,7 +16,7 @@ from search.search_engine_base import SearchEngine
 
 from student.tests.factories import UserFactory
 from openedx.core.djangoapps.content_libraries.libraries_index import MAX_SIZE
-from openedx.core.djangoapps.content_libraries.constants import COMPLEX
+from openedx.core.djangoapps.content_libraries.constants import COMPLEX, ALL_RIGHTS_RESERVED
 from openedx.core.djangolib.testing.utils import skip_unless_cms
 from openedx.core.lib import blockstore_api
 
@@ -167,7 +167,10 @@ class ContentLibrariesRestApiTest(APITestCase):
         yield
         self.client = old_client  # pylint: disable=attribute-defined-outside-init
 
-    def _create_library(self, slug, title, description="", org=None, library_type=COMPLEX, expect_response=200):
+    def _create_library(
+        self, slug, title, description="", org=None, library_type=COMPLEX,
+        license_type=ALL_RIGHTS_RESERVED, expect_response=200,
+    ):
         """ Create a library """
         if org is None:
             org = self.organization.short_name
@@ -177,6 +180,7 @@ class ContentLibrariesRestApiTest(APITestCase):
             "title": title,
             "description": description,
             "type": library_type,
+            "license": license_type,
             "collection_uuid": str(self.collection.uuid),
         }, expect_response)
 
diff --git a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py
index 4edd0b56bda..ca54adf21bb 100644
--- a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py
+++ b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py
@@ -12,7 +12,7 @@ from mock import patch
 from organizations.models import Organization
 
 from openedx.core.djangoapps.content_libraries.tests.base import ContentLibrariesRestApiTest, elasticsearch_test
-from openedx.core.djangoapps.content_libraries.constants import VIDEO, COMPLEX, PROBLEM
+from openedx.core.djangoapps.content_libraries.constants import VIDEO, COMPLEX, PROBLEM, CC_4_BY, ALL_RIGHTS_RESERVED
 from student.tests.factories import UserFactory
 
 
@@ -47,7 +47,9 @@ class ContentLibrariesTest(ContentLibrariesRestApiTest):
         Test Create, Read, Update, and Delete of a Content Library
         """
         # Create:
-        lib = self._create_library(slug="lib-crud", title="A Test Library", description="Just Testing")
+        lib = self._create_library(
+            slug="lib-crud", title="A Test Library", description="Just Testing", license_type=CC_4_BY,
+        )
         expected_data = {
             "id": "lib:CL-TEST:lib-crud",
             "org": "CL-TEST",
@@ -56,6 +58,7 @@ class ContentLibrariesTest(ContentLibrariesRestApiTest):
             "description": "Just Testing",
             "version": 0,
             "type": COMPLEX,
+            "license": CC_4_BY,
             "has_unpublished_changes": False,
             "has_unpublished_deletes": False,
         }
@@ -96,6 +99,7 @@ class ContentLibrariesTest(ContentLibrariesRestApiTest):
             "version": 0,
             "has_unpublished_changes": False,
             "has_unpublished_deletes": False,
+            "license": ALL_RIGHTS_RESERVED,
         }
         self.assertDictContainsEntries(lib, expected_data)
 
diff --git a/openedx/core/djangoapps/content_libraries/tests/test_runtime.py b/openedx/core/djangoapps/content_libraries/tests/test_runtime.py
index 4551fc71982..69e18fd26e6 100644
--- a/openedx/core/djangoapps/content_libraries/tests/test_runtime.py
+++ b/openedx/core/djangoapps/content_libraries/tests/test_runtime.py
@@ -19,7 +19,7 @@ from openedx.core.djangoapps.content_libraries.tests.base import (
     URL_BLOCK_METADATA_URL,
 )
 from openedx.core.djangoapps.content_libraries.tests.user_state_block import UserStateTestBlock
-from openedx.core.djangoapps.content_libraries.constants import COMPLEX
+from openedx.core.djangoapps.content_libraries.constants import COMPLEX, ALL_RIGHTS_RESERVED, CC_4_BY
 from openedx.core.djangoapps.dark_lang.models import DarkLangConfig
 from openedx.core.djangoapps.xblock import api as xblock_api
 from openedx.core.djangolib.testing.utils import skip_unless_lms, skip_unless_cms
@@ -55,6 +55,7 @@ class ContentLibraryContentTestMixin(object):
             description="",
             allow_public_learning=True,
             allow_public_read=False,
+            library_license=ALL_RIGHTS_RESERVED,
         )
 
 
@@ -89,6 +90,7 @@ class ContentLibraryRuntimeTest(ContentLibraryContentTestMixin, TestCase):
             library_type=COMPLEX,
             allow_public_learning=True,
             allow_public_read=False,
+            library_license=CC_4_BY,
         )
         unit_block2_key = library_api.create_library_block(library2.key, "unit", "u1").usage_key
         library_api.create_library_block_child(unit_block2_key, "problem", "p1")
diff --git a/openedx/core/djangoapps/content_libraries/views.py b/openedx/core/djangoapps/content_libraries/views.py
index 91cdf2ac00a..90154005612 100644
--- a/openedx/core/djangoapps/content_libraries/views.py
+++ b/openedx/core/djangoapps/content_libraries/views.py
@@ -152,8 +152,10 @@ class LibraryRootView(APIView):
         serializer = ContentLibraryMetadataSerializer(data=request.data)
         serializer.is_valid(raise_exception=True)
         data = dict(serializer.validated_data)
-        # Converting this over because using the reserved name 'type' would shadow the built-in definition elsewhere.
+        # Converting this over because using the reserved names 'type' and 'license' would shadow the built-in
+        # 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:
@@ -196,8 +198,11 @@ class LibraryDetailsView(APIView):
         serializer = ContentLibraryUpdateSerializer(data=request.data, partial=True)
         serializer.is_valid(raise_exception=True)
         data = dict(serializer.validated_data)
+        # Prevent ourselves from shadowing global names.
         if 'type' in data:
             data['library_type'] = data.pop('type')
+        if 'license' in data:
+            data['library_license'] = data.pop('license')
         try:
             api.update_library(key, **data)
         except api.IncompatibleTypesError as err:
-- 
GitLab