diff --git a/contentcuration/contentcuration/migrations/0158_add_audited_special_permissions_license.py b/contentcuration/contentcuration/migrations/0158_add_audited_special_permissions_license.py new file mode 100644 index 0000000000..8a74da76c3 --- /dev/null +++ b/contentcuration/contentcuration/migrations/0158_add_audited_special_permissions_license.py @@ -0,0 +1,39 @@ +# Generated by Django 3.2.24 on 2025-11-05 22:17 +import uuid + +from django.db import migrations +from django.db import models + +import contentcuration.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("contentcuration", "0157_merge_20251015_0333"), + ] + + operations = [ + migrations.CreateModel( + name="AuditedSpecialPermissionsLicense", + fields=[ + ( + "id", + contentcuration.models.UUIDField( + default=uuid.uuid4, + max_length=32, + primary_key=True, + serialize=False, + ), + ), + ("description", models.TextField(db_index=True, unique=True)), + ("distributable", models.BooleanField(default=False)), + ], + ), + migrations.AddIndex( + model_name="auditedspecialpermissionslicense", + index=models.Index( + fields=["description"], name="audited_special_perms_desc_idx" + ), + ), + ] diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index 84ecb2690f..e30040ed65 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -2708,6 +2708,29 @@ class Meta: ] +class AuditedSpecialPermissionsLicense(models.Model): + """ + Stores special permission license descriptions that have been audited + for community library submissions. When a channel contains resources with + "Special Permissions" licenses, their license descriptions are stored here + for admin review. + """ + + id = UUIDField(primary_key=True, default=uuid.uuid4) + description = models.TextField(unique=True, db_index=True) + distributable = models.BooleanField(default=False) + + def __str__(self): + return ( + self.description[:100] if len(self.description) > 100 else self.description + ) + + class Meta: + indexes = [ + models.Index(fields=["description"], name="audited_special_perms_desc_idx"), + ] + + ASSESSMENT_ID_INDEX_NAME = "assessment_id_idx" diff --git a/contentcuration/contentcuration/tasks.py b/contentcuration/contentcuration/tasks.py index 11b7690fa5..e584deb4d6 100644 --- a/contentcuration/contentcuration/tasks.py +++ b/contentcuration/contentcuration/tasks.py @@ -16,6 +16,7 @@ from contentcuration.models import Change from contentcuration.models import ContentNode from contentcuration.models import User +from contentcuration.utils.audit_channel_licenses import audit_channel_licenses from contentcuration.utils.csv_writer import write_user_csv from contentcuration.utils.nodes import calculate_resource_size from contentcuration.utils.nodes import generate_diff @@ -152,7 +153,7 @@ def sendcustomemails_task(subject, message, query): text = message.format( current_date=time.strftime("%A, %B %d"), current_time=time.strftime("%H:%M %Z"), - **recipient.__dict__ + **recipient.__dict__, ) text = render_to_string("registration/custom_email.txt", {"message": text}) recipient.email_user( @@ -165,3 +166,8 @@ def sendcustomemails_task(subject, message, query): @app.task(name="ensure_versioned_database_exists_task") def ensure_versioned_database_exists_task(channel_id, channel_version): ensure_versioned_database_exists(channel_id, channel_version) + + +@app.task(name="audit-channel-licenses") +def audit_channel_licenses_task(channel_id, user_id): + audit_channel_licenses(channel_id, user_id) diff --git a/contentcuration/contentcuration/tests/test_asynctask.py b/contentcuration/contentcuration/tests/test_asynctask.py index 79b239099b..9757d05b63 100644 --- a/contentcuration/contentcuration/tests/test_asynctask.py +++ b/contentcuration/contentcuration/tests/test_asynctask.py @@ -2,6 +2,7 @@ import time import uuid +import mock import pytest from celery import states from celery.result import allow_join_result @@ -9,9 +10,14 @@ from django.core.management import call_command from django.test import TransactionTestCase from django_celery_results.models import TaskResult +from le_utils.constants import licenses +from mock import patch from . import testdata +from .base import StudioTestCase from .helpers import clear_tasks +from .helpers import EagerTasksTestMixin +from contentcuration import models as cc from contentcuration.celery import app logger = get_task_logger(__name__) @@ -273,3 +279,229 @@ def test_revoke_task(self): TaskResult.objects.get(task_id=async_result.task_id, status=states.REVOKED) except TaskResult.DoesNotExist: self.fail("Missing revoked task result") + + +class AuditChannelLicensesTaskTestCase(EagerTasksTestMixin, StudioTestCase): + """Tests for the audit_channel_licenses_task""" + + def setUp(self): + super().setUp() + self.setUpBase() + self.channel.main_tree.published = True + self.channel.main_tree.save() + self.channel.version = 1 + self.channel.save() + + @patch("contentcuration.utils.audit_channel_licenses.KolibriContentNode") + @patch( + "contentcuration.utils.audit_channel_licenses.using_temp_migrated_content_database" + ) + @patch("contentcuration.utils.audit_channel_licenses.storage.exists") + def test_audit_licenses_task__no_invalid_or_special_permissions( + self, mock_storage_exists, mock_using_db, mock_kolibri_node + ): + """Test audit task when channel has no invalid or special permissions licenses""" + from contentcuration.tasks import audit_channel_licenses_task + + license1, _ = cc.License.objects.get_or_create(license_name="CC BY") + license2, _ = cc.License.objects.get_or_create(license_name="CC BY-SA") + cc.License.objects.get_or_create(license_name=licenses.SPECIAL_PERMISSIONS) + node1 = testdata.node({"kind_id": "video", "title": "Video Node"}) + node1.parent = self.channel.main_tree + node1.license = license1 + node1.save() + node1.published = True + node1.save() + + node2 = testdata.node({"kind_id": "video", "title": "Video Node 2"}) + node2.parent = self.channel.main_tree + node2.license = license2 + node2.save() + node2.published = True + node2.save() + + mock_storage_exists.return_value = True + + mock_context = mock.MagicMock() + mock_using_db.return_value.__enter__ = mock.Mock(return_value=mock_context) + mock_using_db.return_value.__exit__ = mock.Mock(return_value=None) + + # Mock KolibriContentNode to return license names from the nodes we created + mock_license_names_distinct = ["CC BY", "CC BY-SA"] + mock_license_names_values_list = mock.Mock() + mock_license_names_values_list.distinct.return_value = ( + mock_license_names_distinct + ) + mock_license_names_exclude3 = mock.Mock() + mock_license_names_exclude3.values_list.return_value = ( + mock_license_names_values_list + ) + mock_license_names_exclude2 = mock.Mock() + mock_license_names_exclude2.exclude.return_value = mock_license_names_exclude3 + mock_license_names_exclude1 = mock.Mock() + mock_license_names_exclude1.exclude.return_value = mock_license_names_exclude2 + + mock_kolibri_node.objects = mock.Mock() + mock_kolibri_node.objects.exclude = mock.Mock( + return_value=mock_license_names_exclude1 + ) + + audit_channel_licenses_task.apply( + kwargs={"channel_id": self.channel.id, "user_id": self.user.id} + ) + + self.channel.refresh_from_db() + version_str = str(self.channel.version) + self.assertIn(version_str, self.channel.published_data) + published_data_version = self.channel.published_data[version_str] + + self.assertIn("included_licenses", published_data_version) + self.assertIsNone( + published_data_version.get("community_library_invalid_licenses") + ) + self.assertIsNone( + published_data_version.get("community_library_special_permissions") + ) + + @patch("contentcuration.utils.audit_channel_licenses.KolibriContentNode") + @patch( + "contentcuration.utils.audit_channel_licenses.using_temp_migrated_content_database" + ) + @patch("contentcuration.utils.audit_channel_licenses.storage.exists") + def test_audit_licenses_task__with_all_rights_reserved( + self, mock_storage_exists, mock_using_db, mock_kolibri_node + ): + """Test audit task when channel has All Rights Reserved license""" + from contentcuration.tasks import audit_channel_licenses_task + + all_rights_license, _ = cc.License.objects.get_or_create( + license_name=licenses.ALL_RIGHTS_RESERVED + ) + + mock_storage_exists.return_value = True + + mock_context = mock.MagicMock() + mock_using_db.return_value.__enter__ = mock.Mock(return_value=mock_context) + mock_using_db.return_value.__exit__ = mock.Mock(return_value=None) + + mock_license_names_distinct = [licenses.ALL_RIGHTS_RESERVED] + mock_license_names_values_list = mock.Mock() + mock_license_names_values_list.distinct.return_value = ( + mock_license_names_distinct + ) + mock_license_names_exclude3 = mock.Mock() + mock_license_names_exclude3.values_list.return_value = ( + mock_license_names_values_list + ) + mock_license_names_exclude2 = mock.Mock() + mock_license_names_exclude2.exclude.return_value = mock_license_names_exclude3 + mock_license_names_exclude1 = mock.Mock() + mock_license_names_exclude1.exclude.return_value = mock_license_names_exclude2 + mock_license_names_base = mock.Mock() + mock_license_names_base.exclude.return_value = mock_license_names_exclude1 + + mock_kolibri_node.objects = mock.Mock() + mock_kolibri_node.objects.exclude = mock.Mock( + return_value=mock_license_names_exclude1 + ) + + audit_channel_licenses_task.apply( + kwargs={"channel_id": self.channel.id, "user_id": self.user.id} + ) + + self.channel.refresh_from_db() + version_str = str(self.channel.version) + published_data_version = self.channel.published_data[version_str] + + self.assertEqual( + published_data_version.get("community_library_invalid_licenses"), + [all_rights_license.id], + ) + + @patch("contentcuration.utils.audit_channel_licenses.KolibriContentNode") + @patch( + "contentcuration.utils.audit_channel_licenses.using_temp_migrated_content_database" + ) + @patch("contentcuration.utils.audit_channel_licenses.storage.exists") + def test_audit_licenses_task__with_special_permissions( + self, mock_storage_exists, mock_using_db, mock_kolibri_node + ): + """Test audit task when channel has Special Permissions licenses""" + from contentcuration.tasks import audit_channel_licenses_task + + special_perms_license, _ = cc.License.objects.get_or_create( + license_name="Special Permissions" + ) + node = testdata.node({"kind_id": "video", "title": "Video Node"}) + node.parent = self.channel.main_tree + node.license = special_perms_license + node.save() + node.published = True + node.save() + + mock_storage_exists.return_value = True + + mock_context = mock.MagicMock() + mock_using_db.return_value.__enter__ = mock.Mock(return_value=mock_context) + mock_using_db.return_value.__exit__ = mock.Mock(return_value=None) + + mock_license_names_distinct = [licenses.SPECIAL_PERMISSIONS] + mock_license_names_values_list = mock.Mock() + mock_license_names_values_list.distinct.return_value = ( + mock_license_names_distinct + ) + mock_license_names_exclude3 = mock.Mock() + mock_license_names_exclude3.values_list.return_value = ( + mock_license_names_values_list + ) + mock_license_names_exclude2 = mock.Mock() + mock_license_names_exclude2.exclude.return_value = mock_license_names_exclude3 + mock_license_names_exclude1 = mock.Mock() + mock_license_names_exclude1.exclude.return_value = mock_license_names_exclude2 + mock_license_names_base = mock.Mock() + mock_license_names_base.exclude.return_value = mock_license_names_exclude1 + mock_special_perms_distinct = ["Custom permission 1", "Custom permission 2"] + mock_special_perms_values_list = mock.Mock() + mock_special_perms_values_list.distinct.return_value = ( + mock_special_perms_distinct + ) + mock_special_perms_exclude3 = mock.Mock() + mock_special_perms_exclude3.values_list.return_value = ( + mock_special_perms_values_list + ) + mock_special_perms_exclude2 = mock.Mock() + mock_special_perms_exclude2.exclude.return_value = mock_special_perms_exclude3 + mock_special_perms_exclude1 = mock.Mock() + mock_special_perms_exclude1.exclude.return_value = mock_special_perms_exclude2 + mock_special_perms_filter = mock.Mock() + mock_special_perms_filter.exclude.return_value = mock_special_perms_exclude1 + + # Set up the mock to return different querysets based on the method called + mock_kolibri_node.objects = mock.Mock() + mock_kolibri_node.objects.exclude = mock.Mock( + return_value=mock_license_names_exclude1 + ) + mock_kolibri_node.objects.filter = mock.Mock( + return_value=mock_special_perms_filter + ) + + audit_channel_licenses_task.apply( + kwargs={"channel_id": self.channel.id, "user_id": self.user.id} + ) + + self.channel.refresh_from_db() + version_str = str(self.channel.version) + published_data_version = self.channel.published_data[version_str] + + special_perms = published_data_version.get( + "community_library_special_permissions" + ) + self.assertIsNotNone(special_perms) + self.assertEqual(len(special_perms), 2) + + from contentcuration.models import AuditedSpecialPermissionsLicense + + audited_licenses = AuditedSpecialPermissionsLicense.objects.filter( + description__in=["Custom permission 1", "Custom permission 2"] + ) + self.assertEqual(audited_licenses.count(), 2) diff --git a/contentcuration/contentcuration/tests/test_models.py b/contentcuration/contentcuration/tests/test_models.py index d8aaad9896..5c5ec67360 100644 --- a/contentcuration/contentcuration/tests/test_models.py +++ b/contentcuration/contentcuration/tests/test_models.py @@ -15,6 +15,7 @@ from contentcuration.constants import community_library_submission from contentcuration.constants import user_history from contentcuration.models import AssessmentItem +from contentcuration.models import AuditedSpecialPermissionsLicense from contentcuration.models import Change from contentcuration.models import Channel from contentcuration.models import ChannelHistory @@ -1602,3 +1603,67 @@ def test_create_recommendations_event(self): ) self.assertEqual(len(recommendations_event.content), 1) self.assertEqual(recommendations_event.content[0]["score"], 4) + + +class AuditedSpecialPermissionsLicenseTestCase(StudioTestCase): + def setUp(self): + super().setUp() + self.user = testdata.user() + + def test_create_audited_special_permissions_license(self): + """Test creating an AuditedSpecialPermissionsLicense instance""" + description = "This is all rights reserved, but can be distributed for Kolibri" + audited_license = AuditedSpecialPermissionsLicense.objects.create( + description=description + ) + + self.assertIsNotNone(audited_license.id) + self.assertEqual(audited_license.description, description) + self.assertFalse(audited_license.distributable) + + def test_audited_special_permissions_license_unique_description(self): + """Test that description field is unique""" + description = "Unique description" + AuditedSpecialPermissionsLicense.objects.create(description=description) + + with self.assertRaises(IntegrityError): + AuditedSpecialPermissionsLicense.objects.create(description=description) + + def test_audited_special_permissions_license_get_or_create(self): + """Test get_or_create functionality""" + description = "Test description for get_or_create" + ( + audited_license, + created, + ) = AuditedSpecialPermissionsLicense.objects.get_or_create( + description=description, defaults={"distributable": False} + ) + + self.assertTrue(created) + self.assertEqual(audited_license.description, description) + self.assertFalse(audited_license.distributable) + + ( + audited_license2, + created2, + ) = AuditedSpecialPermissionsLicense.objects.get_or_create( + description=description, defaults={"distributable": False} + ) + + self.assertFalse(created2) + self.assertEqual(audited_license.id, audited_license2.id) + + def test_audited_special_permissions_license_str(self): + """Test __str__ method""" + short_description = "Short description" + audited_license = AuditedSpecialPermissionsLicense.objects.create( + description=short_description + ) + self.assertEqual(str(audited_license), short_description) + + long_description = "A" * 150 + audited_license2 = AuditedSpecialPermissionsLicense.objects.create( + description=long_description + ) + self.assertEqual(len(str(audited_license2)), 100) + self.assertEqual(str(audited_license2), "A" * 100) diff --git a/contentcuration/contentcuration/tests/viewsets/test_channel.py b/contentcuration/contentcuration/tests/viewsets/test_channel.py index 814acdabb6..3bf627f6a2 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_channel.py +++ b/contentcuration/contentcuration/tests/viewsets/test_channel.py @@ -6,6 +6,7 @@ from django.urls import reverse from kolibri_public.models import ContentNode as PublicContentNode from le_utils.constants import content_kinds +from mock import Mock from mock import patch from contentcuration import models @@ -1289,3 +1290,118 @@ def test_get_published_data__is_forbidden_user(self): format="json", ) self.assertEqual(response.status_code, 404, response.content) + + def test_published_data_in_channel_list(self): + """Test that published_data is included in channel list response""" + self.client.force_authenticate(user=self.editor_user) + + response = self.client.get( + reverse("channel-list") + "?edit=true", format="json" + ) + self.assertEqual(response.status_code, 200, response.content) + + response_data = response.json() + channels = ( + response_data + if isinstance(response_data, list) + else response_data["results"] + ) + channel = next((c for c in channels if c["id"] == self.channel.id), None) + self.assertIsNotNone(channel) + self.assertIn("published_data", channel) + self.assertEqual(channel["published_data"], self.channel.published_data) + + +class AuditLicensesActionTestCase(StudioAPITestCase): + def setUp(self): + super().setUp() + + self.editor_user = testdata.user(email="editor@user.com") + self.forbidden_user = testdata.user(email="forbidden@user.com") + self.admin_user = self.admin_user + + self.channel = testdata.channel() + self.channel.editors.add(self.editor_user) + # Mark channel as published + self.channel.main_tree.published = True + self.channel.main_tree.save() + self.channel.version = 1 + self.channel.save() + + def test_audit_licenses__is_editor(self): + """Test that an editor can trigger license audit""" + from contentcuration.tasks import audit_channel_licenses_task + + self.client.force_authenticate(user=self.editor_user) + + with patch.object( + audit_channel_licenses_task, "fetch_or_enqueue" + ) as mock_enqueue: + mock_async_result = Mock() + mock_async_result.task_id = "test-task-id-123" + mock_enqueue.return_value = mock_async_result + + response = self.client.post( + reverse("channel-audit-licenses", kwargs={"pk": self.channel.id}), + format="json", + ) + + self.assertEqual(response.status_code, 200, response.content) + data = response.json() + self.assertIn("task_id", data) + self.assertEqual(data["task_id"], "test-task-id-123") + mock_enqueue.assert_called_once() + + def test_audit_licenses__is_admin(self): + """Test that an admin can trigger license audit""" + from contentcuration.tasks import audit_channel_licenses_task + + self.client.force_authenticate(user=self.admin_user) + + with patch.object( + audit_channel_licenses_task, "fetch_or_enqueue" + ) as mock_enqueue: + mock_async_result = Mock() + mock_async_result.task_id = "test-task-id-456" + mock_enqueue.return_value = mock_async_result + + response = self.client.post( + reverse("channel-audit-licenses", kwargs={"pk": self.channel.id}), + format="json", + ) + + self.assertEqual(response.status_code, 200, response.content) + data = response.json() + self.assertIn("task_id", data) + + def test_audit_licenses__is_forbidden_user(self): + """Test that a non-editor cannot trigger license audit""" + self.client.force_authenticate(user=self.forbidden_user) + + response = self.client.post( + reverse("channel-audit-licenses", kwargs={"pk": self.channel.id}), + format="json", + ) + + self.assertEqual(response.status_code, 404, response.content) + + def test_audit_licenses__channel_not_published(self): + """Test that audit fails when channel is not published""" + self.channel.main_tree.published = False + self.channel.main_tree.save() + + self.client.force_authenticate(user=self.editor_user) + + response = self.client.post( + reverse("channel-audit-licenses", kwargs={"pk": self.channel.id}), + format="json", + ) + + self.assertEqual(response.status_code, 400, response.content) + response_data = response.json() + error_message = ( + response_data["detail"] + if isinstance(response_data, dict) + else response_data[0] + ) + self.assertIn("must be published", str(error_message)) diff --git a/contentcuration/contentcuration/utils/audit_channel_licenses.py b/contentcuration/contentcuration/utils/audit_channel_licenses.py new file mode 100644 index 0000000000..571087d433 --- /dev/null +++ b/contentcuration/contentcuration/utils/audit_channel_licenses.py @@ -0,0 +1,202 @@ +""" +Utility functions for auditing channel licenses for community library submission. +""" +import logging + +from django.core.files.storage import default_storage as storage +from kolibri_content.models import ContentNode as KolibriContentNode +from kolibri_public.utils.export_channel_to_kolibri_public import ( + using_temp_migrated_content_database, +) +from le_utils.constants import content_kinds +from le_utils.constants import licenses + +from contentcuration.models import AuditedSpecialPermissionsLicense +from contentcuration.models import Change +from contentcuration.models import Channel +from contentcuration.models import License +from contentcuration.models import User +from contentcuration.utils.publish import get_content_db_path +from contentcuration.viewsets.sync.constants import CHANNEL +from contentcuration.viewsets.sync.utils import generate_update_event + +logger = logging.getLogger(__name__) + + +def _get_content_database_path(channel_id, channel_version): + """ + Get the path to the content database for a channel version. + Returns the versioned database path if it exists, otherwise the unversioned path. + """ + versioned_db_path = get_content_db_path(channel_id, channel_version) + unversioned_db_path = get_content_db_path(channel_id) + + if storage.exists(versioned_db_path): + return versioned_db_path + + if storage.exists(unversioned_db_path): + return unversioned_db_path + + return None + + +def get_channel_and_user(channel_id, user_id): + user = User.objects.get(pk=user_id) + channel = Channel.objects.select_related("main_tree").get(pk=channel_id) + + if user and channel: + return user, channel + else: + return None, None + + +def _process_content_database(channel_id, channel_version, published_data_version=None): + included_licenses = None + if published_data_version: + included_licenses = published_data_version.get("included_licenses") + + db_path = _get_content_database_path(channel_id, channel_version) + if not db_path: + raise FileNotFoundError( + f"Content database not found for channel {channel_id} version {channel_version}. " + "This indicates missing or corrupted channel data." + ) + + with using_temp_migrated_content_database(db_path): + if included_licenses is None: + license_names = list( + KolibriContentNode.objects.exclude(kind=content_kinds.TOPIC) + .exclude(license_name__isnull=True) + .exclude(license_name="") + .values_list("license_name", flat=True) + .distinct() + ) + + license_ids = list( + License.objects.filter(license_name__in=license_names).values_list( + "id", flat=True + ) + ) + + included_licenses = sorted(set(license_ids)) + + special_permissions_license = License.objects.get( + license_name=licenses.SPECIAL_PERMISSIONS + ) + + if not special_permissions_license: + return included_licenses, [] + + special_permissions_license_ids = [] + if special_permissions_license.id in included_licenses: + special_perms_nodes = KolibriContentNode.objects.filter( + license_name=licenses.SPECIAL_PERMISSIONS + ).exclude(kind=content_kinds.TOPIC) + + license_descriptions = list( + special_perms_nodes.exclude(license_description__isnull=True) + .exclude(license_description="") + .values_list("license_description", flat=True) + .distinct() + ) + + if license_descriptions: + existing_licenses = AuditedSpecialPermissionsLicense.objects.filter( + description__in=license_descriptions + ) + existing_descriptions = set( + existing_licenses.values_list("description", flat=True) + ) + + new_licenses = [ + AuditedSpecialPermissionsLicense( + description=description, distributable=False + ) + for description in license_descriptions + if description not in existing_descriptions + ] + + if new_licenses: + AuditedSpecialPermissionsLicense.objects.bulk_create( + new_licenses, ignore_conflicts=True + ) + + all_licenses = AuditedSpecialPermissionsLicense.objects.filter( + description__in=license_descriptions + ) + special_permissions_license_ids = list( + all_licenses.values_list("id", flat=True) + ) + + return included_licenses, special_permissions_license_ids + + +def check_invalid_licenses(included_licenses): + """Check for invalid licenses (All Rights Reserved).""" + invalid_license_ids = [] + all_rights_reserved_license = License.objects.get( + license_name=licenses.ALL_RIGHTS_RESERVED + ) + if all_rights_reserved_license.id in included_licenses: + invalid_license_ids = [all_rights_reserved_license.id] + + return invalid_license_ids + + +def _save_audit_results( + channel, + published_data_version, + invalid_license_ids, + special_permissions_license_ids, + user_id, +): + """Save audit results to published_data and create change event.""" + published_data_version["community_library_invalid_licenses"] = ( + invalid_license_ids if invalid_license_ids else None + ) + published_data_version["community_library_special_permissions"] = ( + special_permissions_license_ids if special_permissions_license_ids else None + ) + + channel.save() + + Change.create_change( + generate_update_event( + channel.id, + CHANNEL, + {"published_data": channel.published_data}, + channel_id=channel.id, + ), + applied=True, + created_by_id=user_id, + ) + + +def audit_channel_licenses(channel_id, user_id): + user, channel = get_channel_and_user(channel_id, user_id) + if not user or not channel: + return + + channel_version = channel.version + version_str = str(channel_version) + + if version_str not in channel.published_data: + channel.published_data[version_str] = {} + + published_data_version = channel.published_data[version_str] + + included_licenses, special_permissions_license_ids = _process_content_database( + channel_id, channel_version, published_data_version=published_data_version + ) + + published_data_version["included_licenses"] = included_licenses + + invalid_license_ids = check_invalid_licenses(included_licenses) + + _save_audit_results( + channel, + published_data_version, + invalid_license_ids, + special_permissions_license_ids, + user_id, + ) diff --git a/contentcuration/contentcuration/utils/publish.py b/contentcuration/contentcuration/utils/publish.py index 3fcd90cc2f..fda40cadd6 100644 --- a/contentcuration/contentcuration/utils/publish.py +++ b/contentcuration/contentcuration/utils/publish.py @@ -850,17 +850,19 @@ def mark_all_nodes_as_published(tree): logging.info("Marked all nodes as published.") +def get_content_db_path(channel_id, version=None): + if version is not None: + return os.path.join(settings.DB_ROOT, f"{channel_id}-{version}.sqlite3") + return os.path.join(settings.DB_ROOT, f"{channel_id}.sqlite3") + + def save_export_database(channel_id, version, is_draft_version=False): logging.debug("Saving export database") current_export_db_location = get_active_content_database() - target_paths = [ - os.path.join(settings.DB_ROOT, "{}-{}.sqlite3".format(channel_id, version)) - ] + target_paths = [get_content_db_path(channel_id, version)] # Only create non-version path if not is_draft_version if not is_draft_version: - target_paths.append( - os.path.join(settings.DB_ROOT, "{id}.sqlite3".format(id=channel_id)) - ) + target_paths.append(get_content_db_path(channel_id)) for target_export_db_location in target_paths: with open(current_export_db_location, "rb") as currentf: @@ -1102,13 +1104,8 @@ def ensure_versioned_database_exists(channel_id, channel_version): if channel_version == 0: raise ValueError("An unpublished channel cannot have a versioned database.") - unversioned_db_storage_path = os.path.join( - settings.DB_ROOT, "{id}.sqlite3".format(id=channel_id) - ) - versioned_db_storage_path = os.path.join( - settings.DB_ROOT, - "{id}-{version}.sqlite3".format(id=channel_id, version=channel_version), - ) + unversioned_db_storage_path = get_content_db_path(channel_id) + versioned_db_storage_path = get_content_db_path(channel_id, channel_version) if not storage.exists(versioned_db_storage_path): if not storage.exists(unversioned_db_storage_path): diff --git a/contentcuration/contentcuration/viewsets/channel.py b/contentcuration/contentcuration/viewsets/channel.py index 396699d20e..6bba2ad08d 100644 --- a/contentcuration/contentcuration/viewsets/channel.py +++ b/contentcuration/contentcuration/viewsets/channel.py @@ -56,6 +56,7 @@ from contentcuration.models import generate_storage_url from contentcuration.models import SecretToken from contentcuration.models import User +from contentcuration.tasks import audit_channel_licenses_task from contentcuration.utils.garbage_collect import get_deleted_chefs_root from contentcuration.utils.pagination import CachedListPagination from contentcuration.utils.pagination import ValuesViewsetPageNumberPagination @@ -394,6 +395,7 @@ def format_demo_server_url(item): "staging_tree__id", "source_url", "demo_server_url", + "published_data", ) channel_field_map = { @@ -900,6 +902,34 @@ def get_published_data(self, request, pk=None) -> Response: return Response(channel.published_data) + @action( + detail=True, + methods=["post"], + url_path="audit_licenses", + url_name="audit-licenses", + ) + def audit_licenses(self, request, pk=None) -> Response: + """ + Trigger license audit for a channel's community library submission. + This will check for invalid licenses (All Rights Reserved) and special + permissions licenses, and update the channel's published_data with audit results. + + :param request: The request object + :param pk: The ID of the channel + :return: Response with task_id if task was enqueued + :rtype: Response + """ + channel = self.get_edit_object() + + if not channel.main_tree.published: + raise ValidationError("Channel must be published to audit licenses") + + async_result = audit_channel_licenses_task.fetch_or_enqueue( + request.user, channel_id=channel.id, user_id=request.user.id + ) + + return Response({"task_id": async_result.task_id}) + @action( detail=True, methods=["get"],