diff --git a/doc/release/RELEASE-NOTES.md b/doc/release/RELEASE-NOTES.md index aeb74f3ca..0a644e506 100644 --- a/doc/release/RELEASE-NOTES.md +++ b/doc/release/RELEASE-NOTES.md @@ -19,6 +19,8 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html ### Code/API changes * [OSDEV-1346](https://opensupplyhub.atlassian.net/browse/OSDEV-1346) - Create GET request for `v1/moderation-events` endpoint. +* [OSDEV-1332](https://opensupplyhub.atlassian.net/browse/OSDEV-1332) - Introduced new `PATCH api/v1/moderation-events/{moderation_id}` endpoint +to modify moderation event `status`. ### Architecture/Environment changes * *Describe architecture/environment changes here.* diff --git a/src/django/api/serializers/v1/moderation_event_update_serializer.py b/src/django/api/serializers/v1/moderation_event_update_serializer.py new file mode 100644 index 000000000..a6dfc0184 --- /dev/null +++ b/src/django/api/serializers/v1/moderation_event_update_serializer.py @@ -0,0 +1,70 @@ +from rest_framework.serializers import ( + ModelSerializer, + ValidationError, + CharField, + IntegerField +) +from api.models.moderation_event \ + import ModerationEvent +from django.utils.timezone import now + + +class ModerationEventUpdateSerializer(ModelSerializer): + + contributor_id = IntegerField(source='contributor.id', read_only=True) + contributor_name = CharField(source='contributor.name', read_only=True) + os_id = IntegerField(source='os.id', read_only=True, allow_null=True) + claim_id = IntegerField(source='claim.id', read_only=True, allow_null=True) + + class Meta: + model = ModerationEvent + fields = [ + 'uuid', + 'created_at', + 'updated_at', + 'os_id', + 'contributor_id', + 'contributor_name', + 'cleaned_data', + 'request_type', + 'source', + 'status', + 'status_change_date', + 'claim_id' + ] + + def to_internal_value(self, data): + status = data.get('status') + + if status is None: + raise ValidationError({ + "field": "status", + "message": "This field is required." + }) + + self.__validate_status(status) + return super().to_internal_value(data) + + def __validate_status(self, value): + if value not in [ + ModerationEvent.Status.PENDING, + ModerationEvent.Status.APPROVED, + ModerationEvent.Status.REJECTED + ]: + raise ValidationError({ + "field": "status", + "message": ( + "Moderation status must be one of " + "PENDING, APPROVED or REJECTED." + ) + }) + return value + + def update(self, instance, validated_data): + if 'status' in validated_data: + value = validated_data['status'] + instance.status = value + instance.status_change_date = now() + + instance.save() + return instance diff --git a/src/django/api/tests/test_moderation_events_update.py b/src/django/api/tests/test_moderation_events_update.py new file mode 100644 index 000000000..155be6f87 --- /dev/null +++ b/src/django/api/tests/test_moderation_events_update.py @@ -0,0 +1,118 @@ +import json +from django.test import override_settings +from api.models import ( + ModerationEvent, + User, + Contributor +) +from django.utils.timezone import now +from rest_framework.test import APITestCase + + +@override_settings(DEBUG=True) +class ModerationEventsUpdateTest(APITestCase): + def setUp(self): + super().setUp() + + self.email = "test@example.com" + self.password = "example123" + self.user = User.objects.create(email=self.email) + self.user.set_password(self.password) + self.user.save() + + self.contributor = Contributor.objects.create( + admin=self.user, + name="test contributor", + contrib_type=Contributor.OTHER_CONTRIB_TYPE, + ) + + self.superemail = "admin@example.com" + self.superpassword = "example123" + self.superuser = User.objects.create_superuser( + email=self.superemail, + password=self.superpassword + ) + + self.moderation_event = ModerationEvent.objects.create( + uuid='f65ec710-f7b9-4f50-b960-135a7ab24ee6', + created_at=now(), + updated_at=now(), + request_type='UPDATE', + raw_data={"name": "raw_name", "country_code": "UK"}, + cleaned_data={"name": "cleaned_name", "country_code": "UK"}, + geocode_result={"latitude": -53, "longitude": 142}, + status='PENDING', + source='API', + contributor=self.contributor + ) + + def test_moderation_event_permission(self): + self.client.login( + email=self.email, + password=self.password + ) + response = self.client.patch( + "/api/v1/moderation-events/{}/" + .format("f65ec710-f7b9-4f50-b960-135a7ab24ee6"), + data=json.dumps({"status": "APPROVED"}), + content_type="application/json" + ) + self.assertEqual(403, response.status_code) + + self.client.login( + email=self.superemail, + password=self.superpassword + ) + response = self.client.patch( + "/api/v1/moderation-events/{}/" + .format("f65ec710-f7b9-4f50-b960-135a7ab24ee6"), + data=json.dumps({"status": "APPROVED"}), + content_type="application/json" + ) + print(response.json()) + self.assertEqual(200, response.status_code) + + def test_moderation_event_not_found(self): + self.client.login( + email=self.superemail, + password=self.superpassword + ) + response = self.client.patch( + "/api/v1/moderation-events/{}/" + .format("f65ec710-f7b9-4f50-b960-135a7ab24ee1"), + data=json.dumps({"status": "APPROVED"}), + content_type="application/json" + ) + + self.assertEqual(404, response.status_code) + + def test_moderation_event_invalid_status(self): + self.client.login( + email=self.superemail, + password=self.superpassword + ) + response = self.client.patch( + "/api/v1/moderation-events/{}/" + .format("f65ec710-f7b9-4f50-b960-135a7ab24ee6"), + data=json.dumps({"status": "NEW"}), + content_type="application/json" + ) + + self.assertEqual(400, response.status_code) + + def test_moderation_event_status_changed(self): + self.client.login( + email=self.superemail, + password=self.superpassword + ) + response = self.client.patch( + "/api/v1/moderation-events/{}/" + .format("f65ec710-f7b9-4f50-b960-135a7ab24ee6"), + data=json.dumps({"status": "APPROVED"}), + content_type="application/json" + ) + + self.assertEqual(200, response.status_code) + self.moderation_event.refresh_from_db() + self.assertEqual(self.moderation_event.status, "APPROVED") + self.assertIsNotNone(self.moderation_event.status_change_date) diff --git a/src/django/api/views/v1/moderation_events.py b/src/django/api/views/v1/moderation_events.py index cd4d6e9e9..9440499ce 100644 --- a/src/django/api/views/v1/moderation_events.py +++ b/src/django/api/views/v1/moderation_events.py @@ -1,9 +1,11 @@ from rest_framework import status from rest_framework.viewsets import ViewSet from rest_framework.response import Response +from rest_framework.exceptions import PermissionDenied from api.views.v1.utils import ( serialize_params, - handle_errors_decorator + handle_errors_decorator, + handle_path_error ) from api.permissions import IsRegisteredAndConfirmed from api.services.opensearch.search import OpenSearchService @@ -13,7 +15,13 @@ import OpenSearchQueryDirector from api.serializers.v1.moderation_events_serializer \ import ModerationEventsSerializer +from api.serializers.v1.opensearch_common_validators.moderation_id_validator \ + import ModerationIdValidator +from api.serializers.v1.moderation_event_update_serializer \ + import ModerationEventUpdateSerializer from api.views.v1.index_names import OpenSearchIndexNames +from api.models.moderation_event \ + import ModerationEvent class ModerationEvents(ViewSet): @@ -49,3 +57,45 @@ def list(self, request): query_body ) return Response(response) + + @handle_errors_decorator + def patch(self, request, moderation_id): + if not (request.user.is_superuser or request.user.is_staff): + raise PermissionDenied( + detail="Only the Moderator can perform this action." + ) + + if not ModerationIdValidator.is_valid_uuid(moderation_id): + return handle_path_error( + field="moderation_id", + message="Invalid UUID format.", + status_code=status.HTTP_400_BAD_REQUEST + ) + + try: + event = ModerationEvent.objects.get(uuid=moderation_id) + except ModerationEvent.DoesNotExist: + return handle_path_error( + field="moderation_id", + message="Moderation event not found.", + status_code=status.HTTP_404_NOT_FOUND + ) + + serializer = ModerationEventUpdateSerializer( + event, + data=request.data, + partial=True + ) + + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + + return Response( + { + "message": 'The request body contains ' + 'invalid or missing fields.', + "error": [serializer.errors] + }, + status=status.HTTP_400_BAD_REQUEST + ) diff --git a/src/django/api/views/v1/utils.py b/src/django/api/views/v1/utils.py index bbabd8c32..5ab9e69f2 100644 --- a/src/django/api/views/v1/utils.py +++ b/src/django/api/views/v1/utils.py @@ -108,3 +108,18 @@ def _wrapped_view(self, request, *args, **kwargs): except OpenSearchServiceException as e: return handle_opensearch_exception(e) return _wrapped_view + + +def handle_path_error(field, message, status_code): + return Response( + { + "message": "The request path parameter is invalid.", + "errors": [ + { + "field": field, + "message": message + } + ] + }, + status=status_code + ) diff --git a/src/django/oar/urls.py b/src/django/oar/urls.py index 95fcf3096..11fef4d2c 100644 --- a/src/django/oar/urls.py +++ b/src/django/oar/urls.py @@ -102,6 +102,11 @@ ProductionLocations.as_view({'get': 'retrieve'}), name='production-locations-details' ), + path( + 'api/v1/moderation-events//', + ModerationEvents.as_view({'patch': 'patch'}), + name='moderation-event-update' + ), ] schema_view = get_schema_view(