diff --git a/contentcuration/contentcuration/constants/community_library_submission.py b/contentcuration/contentcuration/constants/community_library_submission.py index 640b804f92..4e02bc5f6e 100644 --- a/contentcuration/contentcuration/constants/community_library_submission.py +++ b/contentcuration/contentcuration/constants/community_library_submission.py @@ -1,11 +1,27 @@ STATUS_PENDING = "PENDING" STATUS_APPROVED = "APPROVED" STATUS_REJECTED = "REJECTED" +STATUS_SUPERSEDED = "SUPERSEDED" STATUS_LIVE = "LIVE" status_choices = ( (STATUS_PENDING, "Pending"), (STATUS_APPROVED, "Approved"), (STATUS_REJECTED, "Rejected"), + (STATUS_SUPERSEDED, "Superseded"), (STATUS_LIVE, "Live"), ) + +REASON_INVALID_LICENSING = "INVALID_LICENSING" +REASON_TECHNICAL_QUALITY_ASSURANCE = "TECHNICAL_QUALITY_ASSURANCE" +REASON_INVALID_METADATA = "INVALID_METADATA" +REASON_PORTABILITY_ISSUES = "PORTABILITY_ISSUES" +REASON_OTHER = "OTHER" + +resolution_reason_choices = ( + (REASON_INVALID_LICENSING, "Invalid Licensing"), + (REASON_TECHNICAL_QUALITY_ASSURANCE, "Technical Quality Assurance"), + (REASON_INVALID_METADATA, "Invalid Metadata"), + (REASON_PORTABILITY_ISSUES, "Portability Issues"), + (REASON_OTHER, "Other"), +) diff --git a/contentcuration/contentcuration/migrations/0156_communitylibrarysubmission_admin_fields.py b/contentcuration/contentcuration/migrations/0156_communitylibrarysubmission_admin_fields.py new file mode 100644 index 0000000000..e7af358f71 --- /dev/null +++ b/contentcuration/contentcuration/migrations/0156_communitylibrarysubmission_admin_fields.py @@ -0,0 +1,75 @@ +# Generated by Django 3.2.24 on 2025-07-15 19:12 +import django.db.models.deletion +from django.conf import settings +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "contentcuration", + "0155_communitylibrarysubmission_submission_date_created_idx", + ), + ] + + operations = [ + migrations.AddField( + model_name="communitylibrarysubmission", + name="date_resolved", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="communitylibrarysubmission", + name="feedback_notes", + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name="communitylibrarysubmission", + name="internal_notes", + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name="communitylibrarysubmission", + name="resolution_reason", + field=models.CharField( + blank=True, + choices=[ + ("INVALID_LICENSING", "Invalid Licensing"), + ("TECHNICAL_QUALITY_ASSURANCE", "Technical Quality Assurance"), + ("INVALID_METADATA", "Invalid Metadata"), + ("PORTABILITY_ISSUES", "Portability Issues"), + ("OTHER", "Other"), + ], + max_length=50, + null=True, + ), + ), + migrations.AddField( + model_name="communitylibrarysubmission", + name="resolved_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="resolved_community_library_submissions", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="communitylibrarysubmission", + name="status", + field=models.CharField( + choices=[ + ("PENDING", "Pending"), + ("APPROVED", "Approved"), + ("REJECTED", "Rejected"), + ("SUPERSEDED", "Superseded"), + ("LIVE", "Live"), + ], + default="PENDING", + max_length=20, + ), + ), + ] diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index d174261fa8..b62441402a 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -2558,11 +2558,27 @@ class CommunityLibrarySubmission(models.Model): ) categories = models.JSONField(blank=True, null=True) date_created = models.DateTimeField(auto_now_add=True) + date_resolved = models.DateTimeField(blank=True, null=True) status = models.CharField( max_length=20, choices=community_library_submission.status_choices, default=community_library_submission.STATUS_PENDING, ) + resolved_by = models.ForeignKey( + User, + related_name="resolved_community_library_submissions", + blank=True, + null=True, + on_delete=models.SET_NULL, + ) + resolution_reason = models.CharField( + max_length=50, + choices=community_library_submission.resolution_reason_choices, + blank=True, + null=True, + ) + feedback_notes = models.TextField(blank=True, null=True) + internal_notes = models.TextField(blank=True, null=True) def save(self, *args, **kwargs): # Validate on save that the submission author is an editor of the channel diff --git a/contentcuration/contentcuration/tests/viewsets/test_community_library_submission.py b/contentcuration/contentcuration/tests/viewsets/test_community_library_submission.py index d4de9e7d6b..4d2808658f 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_community_library_submission.py +++ b/contentcuration/contentcuration/tests/viewsets/test_community_library_submission.py @@ -1,7 +1,13 @@ +import datetime +from unittest import mock from urllib.parse import urlencode +import pytz from django.urls import reverse +from contentcuration.constants import ( + community_library_submission as community_library_submission_constants, +) from contentcuration.models import CommunityLibrarySubmission from contentcuration.tests import testdata from contentcuration.tests.base import StudioAPITestCase @@ -276,8 +282,10 @@ def test_get_single_submission__author_name(self): self.assertEqual(response.status_code, 200, response.content) result = response.data - self.assertEqual(result["author_first_name"], self.author_user.first_name) - self.assertEqual(result["author_last_name"], self.author_user.last_name) + self.assertEqual( + result["author_name"], + f"{self.author_user.first_name} {self.author_user.last_name}", + ) def test_update_submission__is_author(self): self.client.force_authenticate(user=self.author_user) @@ -422,3 +430,385 @@ def test_delete_submission__is_forbidden(self): id=self.existing_submission1.id ).exists() ) + + +class AdminViewSetTestCase(StudioAPITestCase): + def setUp(self): + super().setUp() + + self.submission = testdata.community_library_submission() + self.submission.channel.version = 3 + self.submission.channel.save() + self.submission.channel_version = 2 + self.submission.save() + + self.editor_user = self.submission.channel.editors.first() + + self.superseded_submission = CommunityLibrarySubmission.objects.create( + channel=self.submission.channel, + author=self.editor_user, + status=community_library_submission_constants.STATUS_PENDING, + date_created=datetime.datetime(2023, 1, 1, tzinfo=pytz.utc), + channel_version=1, + ) + self.not_superseded_submission = CommunityLibrarySubmission.objects.create( + channel=self.submission.channel, + author=self.editor_user, + status=community_library_submission_constants.STATUS_PENDING, + date_created=datetime.datetime(2024, 1, 1, tzinfo=pytz.utc), + channel_version=3, + ) + self.submission_for_other_channel = testdata.community_library_submission() + self.submission_for_other_channel.channel_version = 1 + self.submission_for_other_channel.save() + + self.feedback_notes = "Feedback" + self.internal_notes = "Internal notes" + + self.resolve_approve_metadata = { + "status": community_library_submission_constants.STATUS_APPROVED, + "feedback_notes": self.feedback_notes, + "internal_notes": self.internal_notes, + } + self.resolve_reject_metadata = { + "status": community_library_submission_constants.STATUS_REJECTED, + "resolution_reason": community_library_submission_constants.REASON_INVALID_METADATA, + "feedback_notes": self.feedback_notes, + "internal_notes": self.internal_notes, + } + + self.resolved_time = datetime.datetime(2023, 10, 1, tzinfo=pytz.utc) + self.patcher = mock.patch( + "contentcuration.viewsets.community_library_submission.timezone.now", + return_value=self.resolved_time, + ) + self.mock_datetime = self.patcher.start() + + def tearDown(self): + self.patcher.stop() + super().tearDown() + + def _manually_reject_submission(self): + self.submission.status = community_library_submission_constants.STATUS_REJECTED + self.submission.resolved_by = self.admin_user + self.submission.resolution_reason = ( + community_library_submission_constants.REASON_INVALID_METADATA + ) + self.submission.feedback_notes = self.feedback_notes + self.submission.internal_notes = self.internal_notes + self.submission.date_resolved = self.resolved_time + self.submission.save() + + def _refresh_submissions_from_db(self): + self.submission.refresh_from_db() + self.superseded_submission.refresh_from_db() + self.not_superseded_submission.refresh_from_db() + self.submission_for_other_channel.refresh_from_db() + + def test_list_submissions__admin(self): + self.client.force_authenticate(user=self.admin_user) + + self._manually_reject_submission() + + response = self.client.get( + reverse("admin-community-library-submission-list"), + ) + self.assertEqual(response.status_code, 200, response.content) + + results = response.data + self.assertEqual(len(results), 4) + rejected_results = [ + result + for result in results + if result["status"] + == community_library_submission_constants.STATUS_REJECTED + ] + self.assertEqual(len(rejected_results), 1) + result = rejected_results[0] + + self.assertEqual(result["resolved_by_id"], self.admin_user.id) + self.assertEqual( + result["resolved_by_name"], + f"{self.admin_user.first_name} {self.admin_user.last_name}", + ) + self.assertEqual(result["internal_notes"], self.internal_notes) + + def test_list_submissions__editor(self): + self.client.force_authenticate(user=self.editor_user) + + self._manually_reject_submission() + + response = self.client.get( + reverse("admin-community-library-submission-list"), + ) + self.assertEqual(response.status_code, 403, response.content) + + def test_submission_detail__admin(self): + self.client.force_authenticate(user=self.admin_user) + + self._manually_reject_submission() + + response = self.client.get( + reverse( + "admin-community-library-submission-detail", + args=[self.submission.id], + ), + ) + self.assertEqual(response.status_code, 200, response.content) + + result = response.data + self.assertEqual(result["id"], self.submission.id) + self.assertEqual(result["resolved_by_id"], self.admin_user.id) + self.assertEqual( + result["resolved_by_name"], + f"{self.admin_user.first_name} {self.admin_user.last_name}", + ) + self.assertEqual(result["internal_notes"], self.internal_notes) + + def test_submission_detail__editor(self): + self.client.force_authenticate(user=self.editor_user) + + self._manually_reject_submission() + + response = self.client.get( + reverse( + "admin-community-library-submission-detail", + args=[self.submission.id], + ), + ) + self.assertEqual(response.status_code, 403, response.content) + + def test_update_submission(self): + self.client.force_authenticate(user=self.admin_user) + + response = self.client.put( + reverse( + "admin-community-library-submission-detail", + args=[self.submission.id], + ), + {}, + format="json", + ) + self.assertEqual(response.status_code, 405, response.content) + + def test_partial_update_submission(self): + self.client.force_authenticate(user=self.admin_user) + + response = self.client.patch( + reverse( + "admin-community-library-submission-detail", + args=[self.submission.id], + ), + {}, + format="json", + ) + self.assertEqual(response.status_code, 405, response.content) + + def test_destroy_submission(self): + self.client.force_authenticate(user=self.admin_user) + + response = self.client.delete( + reverse( + "admin-community-library-submission-detail", + args=[self.submission.id], + ), + format="json", + ) + self.assertEqual(response.status_code, 405, response.content) + + def test_resolve_submission__editor(self): + self.client.force_authenticate(user=self.editor_user) + response = self.client.post( + reverse( + "admin-community-library-submission-resolve", + args=[self.submission.id], + ), + self.resolve_approve_metadata, + format="json", + ) + self.assertEqual(response.status_code, 403, response.content) + + def test_resolve_submission__accept_correct(self): + self.client.force_authenticate(user=self.admin_user) + response = self.client.post( + reverse( + "admin-community-library-submission-resolve", + args=[self.submission.id], + ), + self.resolve_approve_metadata, + format="json", + ) + self.assertEqual(response.status_code, 200, response.content) + + resolved_submission = CommunityLibrarySubmission.objects.get( + id=self.submission.id + ) + self.assertEqual( + resolved_submission.status, + community_library_submission_constants.STATUS_APPROVED, + ) + self.assertEqual(resolved_submission.feedback_notes, self.feedback_notes) + self.assertEqual(resolved_submission.internal_notes, self.internal_notes) + self.assertEqual(resolved_submission.resolved_by, self.admin_user) + self.assertEqual(resolved_submission.date_resolved, self.resolved_time) + + def test_resolve_submission__reject_correct(self): + self.client.force_authenticate(user=self.admin_user) + response = self.client.post( + reverse( + "admin-community-library-submission-resolve", + args=[self.submission.id], + ), + self.resolve_reject_metadata, + format="json", + ) + self.assertEqual(response.status_code, 200, response.content) + + resolved_submission = CommunityLibrarySubmission.objects.get( + id=self.submission.id + ) + self.assertEqual( + resolved_submission.status, + community_library_submission_constants.STATUS_REJECTED, + ) + self.assertEqual( + resolved_submission.resolution_reason, + community_library_submission_constants.REASON_INVALID_METADATA, + ) + self.assertEqual(resolved_submission.feedback_notes, self.feedback_notes) + self.assertEqual(resolved_submission.internal_notes, self.internal_notes) + self.assertEqual(resolved_submission.resolved_by, self.admin_user) + self.assertEqual(resolved_submission.date_resolved, self.resolved_time) + + def test_resolve_submission__reject_missing_resolution_reason(self): + self.client.force_authenticate(user=self.admin_user) + metadata = self.resolve_reject_metadata.copy() + del metadata["resolution_reason"] + response = self.client.post( + reverse( + "admin-community-library-submission-resolve", + args=[self.submission.id], + ), + metadata, + format="json", + ) + self.assertEqual(response.status_code, 400, response.content) + + def test_resolve_submission__reject_missing_feedback_notes(self): + self.client.force_authenticate(user=self.admin_user) + metadata = self.resolve_reject_metadata.copy() + del metadata["feedback_notes"] + response = self.client.post( + reverse( + "admin-community-library-submission-resolve", + args=[self.submission.id], + ), + metadata, + format="json", + ) + self.assertEqual(response.status_code, 400, response.content) + + def test_resolve_submission__invalid_status(self): + self.client.force_authenticate(user=self.admin_user) + metadata = self.resolve_approve_metadata.copy() + metadata["status"] = (community_library_submission_constants.STATUS_PENDING,) + response = self.client.post( + reverse( + "admin-community-library-submission-resolve", + args=[self.submission.id], + ), + metadata, + format="json", + ) + self.assertEqual(response.status_code, 400, response.content) + + def test_resolve_submission__not_pending(self): + self.client.force_authenticate(user=self.admin_user) + self.submission.status = community_library_submission_constants.STATUS_APPROVED + self.submission.save() + + response = self.client.post( + reverse( + "admin-community-library-submission-resolve", + args=[self.submission.id], + ), + self.resolve_approve_metadata, + format="json", + ) + self.assertEqual(response.status_code, 400, response.content) + + def test_resolve_submission__overrite_categories(self): + self.client.force_authenticate(user=self.admin_user) + categories = ["Category 1"] + self.resolve_approve_metadata["categories"] = categories + + response = self.client.post( + reverse( + "admin-community-library-submission-resolve", + args=[self.submission.id], + ), + self.resolve_approve_metadata, + format="json", + ) + self.assertEqual(response.status_code, 200, response.content) + + resolved_submission = CommunityLibrarySubmission.objects.get( + id=self.submission.id + ) + self.assertListEqual(resolved_submission.categories, categories) + + def test_resolve_submission__accept_mark_superseded(self): + self.client.force_authenticate(user=self.admin_user) + + response = self.client.post( + reverse( + "admin-community-library-submission-resolve", + args=[self.submission.id], + ), + self.resolve_approve_metadata, + format="json", + ) + self.assertEqual(response.status_code, 200, response.content) + + self._refresh_submissions_from_db() + + self.assertEqual( + self.superseded_submission.status, + community_library_submission_constants.STATUS_SUPERSEDED, + ) + self.assertEqual( + self.not_superseded_submission.status, + community_library_submission_constants.STATUS_PENDING, + ) + self.assertEqual( + self.submission_for_other_channel.status, + community_library_submission_constants.STATUS_PENDING, + ) + + def test_resolve_submission__reject_do_not_mark_superseded(self): + self.client.force_authenticate(user=self.admin_user) + + response = self.client.post( + reverse( + "admin-community-library-submission-resolve", + args=[self.submission.id], + ), + self.resolve_reject_metadata, + format="json", + ) + self.assertEqual(response.status_code, 200, response.content) + + self._refresh_submissions_from_db() + + self.assertEqual( + self.superseded_submission.status, + community_library_submission_constants.STATUS_PENDING, + ) + self.assertEqual( + self.not_superseded_submission.status, + community_library_submission_constants.STATUS_PENDING, + ) + self.assertEqual( + self.submission_for_other_channel.status, + community_library_submission_constants.STATUS_PENDING, + ) diff --git a/contentcuration/contentcuration/urls.py b/contentcuration/contentcuration/urls.py index b948effc8b..f65d9fa35c 100644 --- a/contentcuration/contentcuration/urls.py +++ b/contentcuration/contentcuration/urls.py @@ -38,6 +38,9 @@ from contentcuration.viewsets.channel import ChannelViewSet from contentcuration.viewsets.channelset import ChannelSetViewSet from contentcuration.viewsets.clipboard import ClipboardViewSet +from contentcuration.viewsets.community_library_submission import ( + AdminCommunityLibrarySubmissionViewSet, +) from contentcuration.viewsets.community_library_submission import ( CommunityLibrarySubmissionViewSet, ) @@ -88,6 +91,11 @@ def get_redirect_url(self, *args, **kwargs): CommunityLibrarySubmissionViewSet, basename="community-library-submission", ) +router.register( + r"admin_communitylibrary_submission", + AdminCommunityLibrarySubmissionViewSet, + basename="admin-community-library-submission", +) urlpatterns = [ re_path(r"^api/", include(router.urls)), diff --git a/contentcuration/contentcuration/viewsets/community_library_submission.py b/contentcuration/contentcuration/viewsets/community_library_submission.py index 2aef31bfba..163ab50e77 100644 --- a/contentcuration/contentcuration/viewsets/community_library_submission.py +++ b/contentcuration/contentcuration/viewsets/community_library_submission.py @@ -1,8 +1,14 @@ +from django.utils import timezone from django_filters.rest_framework import DjangoFilterBackend +from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.relations import PrimaryKeyRelatedField +from rest_framework.response import Response from rest_framework.serializers import ValidationError +from contentcuration.constants import ( + community_library_submission as community_library_submission_constants, +) from contentcuration.models import Channel from contentcuration.models import CommunityLibrarySubmission from contentcuration.models import Country @@ -14,6 +20,7 @@ from contentcuration.viewsets.base import RESTDestroyModelMixin from contentcuration.viewsets.base import RESTUpdateModelMixin from contentcuration.viewsets.common import UserFilteredPrimaryKeyRelatedField +from contentcuration.viewsets.user import IsAdminUser class CommunityLibrarySubmissionSerializer(BulkModelSerializer): @@ -82,18 +89,68 @@ def update(self, instance, validated_data): return super().update(instance, validated_data) +class CommunityLibrarySubmissionResolveSerializer(CommunityLibrarySubmissionSerializer): + class Meta(CommunityLibrarySubmissionSerializer.Meta): + fields = CommunityLibrarySubmissionSerializer.Meta.fields + [ + "status", + "resolution_reason", + "feedback_notes", + "internal_notes", + ] + + def create(self, validated_data): + raise ValidationError( + "Cannot create a community library submission with this serializer. " + "Use the standard CommunityLibrarySubmissionSerializer instead." + ) + + def update(self, instance, validated_data): + if instance.status != community_library_submission_constants.STATUS_PENDING: + raise ValidationError( + "Cannot resolve a community library submission that is not pending." + ) + + if "status" not in validated_data or validated_data["status"] not in [ + community_library_submission_constants.STATUS_APPROVED, + community_library_submission_constants.STATUS_REJECTED, + ]: + raise ValidationError( + "Status must be either APPROVED or REJECTED when resolving a submission." + ) + + if ( + "status" not in validated_data + or validated_data["status"] + == community_library_submission_constants.STATUS_REJECTED + ): + if not validated_data.get("resolution_reason", "").strip(): + raise ValidationError( + "Resolution reason must be provided when rejecting a submission." + ) + if not validated_data.get("feedback_notes", "").strip(): + raise ValidationError( + "Feedback notes must be provided when rejecting a submission." + ) + + return super().update(instance, validated_data) + + class CommunityLibrarySubmissionPagination(ValuesViewsetCursorPagination): ordering = "-date_created" page_size_query_param = "max_results" max_page_size = 100 -class CommunityLibrarySubmissionViewSet( - RESTCreateModelMixin, - RESTUpdateModelMixin, - RESTDestroyModelMixin, - ReadOnlyValuesViewset, -): +def get_author_name(item): + return "{} {}".format(item["author__first_name"], item["author__last_name"]) + + +class CommunityLibrarySubmissionViewSetMixin: + """ + Mixin with logic shared between the CommunityLibrarySubmissionViewSet and + AdminCommunityLibrarySubmissionViewSet. + """ + values = ( "id", "description", @@ -105,17 +162,17 @@ class CommunityLibrarySubmissionViewSet( "categories", "date_created", "status", + "resolution_reason", + "feedback_notes", + "date_resolved", ) field_map = { - "author_first_name": "author__first_name", - "author_last_name": "author__last_name", + "author_name": get_author_name, } + queryset = CommunityLibrarySubmission.objects.all().order_by("-date_created") filter_backends = [DjangoFilterBackend] filterset_fields = ["channel"] - permission_classes = [IsAuthenticated] pagination_class = CommunityLibrarySubmissionPagination - serializer_class = CommunityLibrarySubmissionSerializer - queryset = CommunityLibrarySubmission.objects.all().order_by("-date_created") def consolidate(self, items, queryset): countries = {} @@ -130,3 +187,68 @@ def consolidate(self, items, queryset): item["countries"] = countries.get(item["id"], []) return items + + +class CommunityLibrarySubmissionViewSet( + CommunityLibrarySubmissionViewSetMixin, + RESTCreateModelMixin, + RESTUpdateModelMixin, + RESTDestroyModelMixin, + ReadOnlyValuesViewset, +): + permission_classes = [IsAuthenticated] + serializer_class = CommunityLibrarySubmissionSerializer + + +def get_resolved_by_name(item): + return "{} {}".format( + item["resolved_by__first_name"], item["resolved_by__last_name"] + ) + + +class AdminCommunityLibrarySubmissionViewSet( + CommunityLibrarySubmissionViewSetMixin, + ReadOnlyValuesViewset, +): + permission_classes = [IsAdminUser] + + values = CommunityLibrarySubmissionViewSetMixin.values + ( + "resolved_by_id", + "resolved_by__first_name", + "resolved_by__last_name", + "internal_notes", + ) + field_map = CommunityLibrarySubmissionViewSetMixin.field_map.copy() + field_map.update( + { + "resolved_by_name": get_resolved_by_name, + } + ) + + def _mark_previous_pending_submissions_as_superseded(self, submission): + CommunityLibrarySubmission.objects.filter( + status=community_library_submission_constants.STATUS_PENDING, + channel=submission.channel, + channel_version__lt=submission.channel_version, + ).update(status=community_library_submission_constants.STATUS_SUPERSEDED) + + @action( + methods=["post"], + detail=True, + serializer_class=CommunityLibrarySubmissionResolveSerializer, + ) + def resolve(self, request, pk=None): + instance = self.get_edit_object() + serializer = self.get_serializer(instance, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + + date_resolved = timezone.now() + submission = serializer.save( + date_resolved=date_resolved, + resolved_by=request.user, + ) + + if submission.status == community_library_submission_constants.STATUS_APPROVED: + self._mark_previous_pending_submissions_as_superseded(submission) + + return Response(self.serialize_object())