diff --git a/core/common/mixins.py b/core/common/mixins.py index 8b27da89..e555deb1 100644 --- a/core/common/mixins.py +++ b/core/common/mixins.py @@ -3,6 +3,7 @@ from urllib import parse from urllib.parse import urlencode +from celery.states import SUCCESS from django.core.cache import cache from django.core.exceptions import ValidationError from django.core.paginator import Paginator @@ -634,6 +635,32 @@ def latest_source_version(self): def _cached_latest_source_version(self): return self.parent.get_latest_released_version() + def belongs_to_non_head_source_version(self): + versioned_object_id = self.versioned_object_id or self.id + version_ids = self.__class__.objects.filter( + parent_id=self.parent_id, + versioned_object_id=versioned_object_id, + ).values('id') + return self.sources.through.objects.filter( + **{f'{self._meta.model_name}_id__in': version_ids} + ).exclude(source__version=HEAD).exists() + + def has_pending_source_version_seed(self): + """Return whether an unfinished source snapshot may include this resource.""" + versioned_object_id = self.versioned_object_id or self.id + created_at = self.__class__.objects.filter(id=versioned_object_id).values_list( + 'created_at', flat=True + ).first() + if not created_at: + return False + + source_versions = self.parent.versions.exclude(version=HEAD).filter(created_at__gte=created_at) + for source_version in source_versions: + seed_task = source_version.get_seed_new_version_task() + if seed_task and seed_task.state != SUCCESS: + return True + return False + @staticmethod def is_strictly_equal(instance1, instance2): return instance1.get_checksums() == instance2.get_checksums() diff --git a/core/common/models.py b/core/common/models.py index 889d9fb4..aa3d2db8 100644 --- a/core/common/models.py +++ b/core/common/models.py @@ -775,6 +775,7 @@ def persist_new(cls, obj, created_by, **kwargs): @classmethod def persist_new_version(cls, obj, user=None, **kwargs): + """Persist a repository version and schedule its children snapshot.""" errors = {} obj.is_active = True @@ -787,24 +788,39 @@ def persist_new_version(cls, obj, user=None, **kwargs): if not head: errors[repo_resource_name.lower()] = 'Version Head not found.' return errors - obj.update_version_data(head) - obj.save(**kwargs) - - if obj.id: - obj.sibling_versions.update(is_latest_version=False) is_test_mode = get(settings, 'TEST_MODE', False) - if is_test_mode or sync: - seed_children_to_new_version(obj.resource_type.lower(), obj.id, not is_test_mode, sync) - else: - from core.tasks.models import Task - task = Task.new(queue='default', user=user, name=seed_children_to_new_version.__name__) - seed_children_to_new_version.apply_async( - (obj.resource_type.lower(), obj.id, True, sync), - task_id=task.id, - queue='default', - persist_args=True - ) + with transaction.atomic(): + # Serialize version creation with destructive child operations on the HEAD repository. + head = cls.objects.select_for_update().get(id=head.id) + obj.update_version_data(head) + obj.save(**kwargs) + + if obj.id: + obj.sibling_versions.update(is_latest_version=False) + + task_args = (obj.resource_type.lower(), obj.id, not is_test_mode, sync) + if is_test_mode or sync: + seed_children_to_new_version(*task_args) + else: + from core.tasks.models import Task + task = Task.new( + queue='default', + user=user, + name=seed_children_to_new_version.__name__, + args=task_args, + ) + + def enqueue_seed_task(): + """Queue snapshot seeding only after its repository version is committed.""" + seed_children_to_new_version.apply_async( + task_args, + task_id=task.id, + queue='default', + persist_args=True + ) + + transaction.on_commit(enqueue_seed_task) return errors diff --git a/core/concepts/constants.py b/core/concepts/constants.py index e64d1937..1aa14626 100644 --- a/core/concepts/constants.py +++ b/core/concepts/constants.py @@ -38,6 +38,9 @@ CONCEPT_WAS_UNRETIRED = 'Concept was un-retired' CONCEPT_IS_ALREADY_RETIRED = 'Concept is already retired' CONCEPT_IS_ALREADY_NOT_RETIRED = 'Concept is already not retired' +CONCEPT_HARD_DELETE_REQUIRES_HEAD_ONLY = ( + 'Concept cannot be hard deleted because it belongs to a source version.' +) ALREADY_EXISTS = "Concept ID must be unique within a source." PERSIST_CLONE_SPECIFY_USER_ERROR = "Must specify which user is attempting to create a new version." PERSIST_CLONE_ERROR = 'An error occurred while saving new version.' diff --git a/core/concepts/permissions.py b/core/concepts/permissions.py index c80ef0b3..932ebe9d 100644 --- a/core/concepts/permissions.py +++ b/core/concepts/permissions.py @@ -1,6 +1,6 @@ from rest_framework.permissions import BasePermission, IsAuthenticatedOrReadOnly -from core.common.permissions import CanViewConceptDictionary, CanEditConceptDictionary +from core.common.permissions import CanViewConceptDictionary, CanEditConceptDictionary, HasPrivateAccess class CanAccessParentDictionary(BasePermission): @@ -27,5 +27,10 @@ class CanEditParentDictionary(IsAuthenticatedOrReadOnly, CanAccessParentDictiona parent_permission_class = CanEditConceptDictionary +class CanAdministerParentDictionary(IsAuthenticatedOrReadOnly, CanAccessParentDictionary, HasPrivateAccess): + """Repo admins: platform staff or the dictionary's owner/org-members (its authors/editors).""" + parent_permission_class = HasPrivateAccess + + class CanViewParentDictionaryAsGuest(CanAccessParentDictionary, CanViewConceptDictionary): parent_permission_class = CanViewConceptDictionary diff --git a/core/concepts/views.py b/core/concepts/views.py index c779831b..1dd485a8 100644 --- a/core/concepts/views.py +++ b/core/concepts/views.py @@ -41,10 +41,13 @@ drop_version, get_falsy_values) from core.common.views import SourceChildCommonBaseView, SourceChildExtrasView, \ SourceChildExtraRetrieveUpdateDestroyView, BaseAPIView -from core.concepts.constants import PARENT_VERSION_NOT_LATEST_CANNOT_UPDATE_CONCEPT +from core.concepts.constants import ( + CONCEPT_HARD_DELETE_REQUIRES_HEAD_ONLY, + PARENT_VERSION_NOT_LATEST_CANNOT_UPDATE_CONCEPT, +) from core.concepts.documents import ConceptDocument from core.concepts.models import Concept, ConceptName -from core.concepts.permissions import CanViewParentDictionary, CanEditParentDictionary +from core.concepts.permissions import CanViewParentDictionary, CanEditParentDictionary, CanAdministerParentDictionary from core.concepts.search import ConceptFacetedSearch, ConceptFuzzySearch from core.concepts.serializers import ( ConceptDetailSerializer, ConceptListSerializer, ConceptDescriptionSerializer, ConceptNameSerializer, @@ -344,8 +347,14 @@ def get_permissions(self): if self.request.method in ['GET']: return [CanViewParentDictionary(), ] - if self.request.method == 'DELETE' and self.is_hard_delete_requested(): - return [IsAdminUser(), ] + # async/db hard deletes are power operations reserved for repo admins (the dictionary's + # owner/org-members) and platform staff. A regular HEAD-only hard delete stays open to any + # repo editor. The HEAD-only safety check (409 for non-staff) is enforced in the handlers. + if ( + self.request.method == 'DELETE' and self.is_hard_delete_requested() and + (self.is_async_requested() or self.is_db_delete_requested()) + ): + return [CanAdministerParentDictionary(), ] return [CanEditParentDictionary(), ] @@ -383,30 +392,79 @@ def update(self, request, *args, **kwargs): def is_db_delete_requested(self): return self.request.query_params.get('db', None) in TRUTHY + def _db_hard_delete(self): + parent_filters = Concept.get_parent_and_owner_filters_from_kwargs(self.kwargs) + concepts = Concept.objects.filter(mnemonic=self.kwargs['concept'], **parent_filters) + concept = concepts.filter(id=F('versioned_object_id')).first() + if not concept: + raise Http404() + self.check_object_permissions(self.request, concept) + + # The raw delete skips the cascade path, so re-apply the HEAD-only guard non-staff would + # otherwise hit in `_hard_delete`, keeping snapshotted concepts protected from `db=true`. + if not IsAdminUser().has_permission(self.request, self) and ( + concept.belongs_to_non_head_source_version() or + concept.has_pending_source_version_seed() + ): + return Response( + {'detail': CONCEPT_HARD_DELETE_REQUIRES_HEAD_ONLY}, + status=status.HTTP_409_CONFLICT, + ) + + parent = concept.parent + result = concepts.delete() + parent.update_concepts_count() + return Response(result, status=status.HTTP_204_NO_CONTENT) + + def _hard_delete(self, request, concept): + parent = concept.parent + with transaction.atomic(): + # Source version creation locks the same HEAD row before registering its seed task. + parent.__class__.objects.select_for_update().get(id=concept.parent_id) + locked_concepts = list(Concept.objects.select_for_update().filter( + parent_id=concept.parent_id, + versioned_object_id=concept.versioned_object_id, + )) + versioned_concept = next( + (candidate for candidate in locked_concepts if candidate.id == concept.id), + None, + ) + if not versioned_concept: + raise Http404() + + is_admin = IsAdminUser().has_permission(request, self) + if not is_admin and ( + versioned_concept.belongs_to_non_head_source_version() or + versioned_concept.has_pending_source_version_seed() + ): + return Response( + {'detail': CONCEPT_HARD_DELETE_REQUIRES_HEAD_ONLY}, + status=status.HTTP_409_CONFLICT, + ) + + if self.is_async_requested(): + task = Task.new(queue='default', user=request.user, name=delete_concept.__name__) + delete_concept.apply_async((concept.id,), queue=task.queue, task_id=task.id) + return Response(status=status.HTTP_204_NO_CONTENT) + + # Versions reference the versioned concept with on_delete=CASCADE. + # Deleting the root removes every HEAD-only version and its related rows. + versioned_concept.delete() + parent.update_concepts_count() + return Response(status=status.HTTP_204_NO_CONTENT) + def destroy(self, request, *args, **kwargs): if self.is_container_version_specified(): return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) is_hard_delete_requested = self.is_hard_delete_requested() if self.is_db_delete_requested() and is_hard_delete_requested: - parent_filters = Concept.get_parent_and_owner_filters_from_kwargs(self.kwargs) - concepts = Concept.objects.filter(mnemonic=self.kwargs['concept'], **parent_filters) - concept = concepts.filter(id=F('versioned_object_id')).first() - parent = concept.parent - result = concepts.delete() - parent.update_concepts_count() - return Response(result, status=status.HTTP_204_NO_CONTENT) + return self._db_hard_delete() concept = self.get_object() parent = concept.parent if is_hard_delete_requested: - if self.is_async_requested(): - task = Task.new(queue='default', user=request.user, name=delete_concept.__name__) - delete_concept.apply_async((concept.id,), queue=task.queue, task_id=task.id) - return Response(status=status.HTTP_204_NO_CONTENT) - concept.delete() - parent.update_concepts_count() - return Response(status=status.HTTP_204_NO_CONTENT) + return self._hard_delete(request, concept) comment = request.data.get('update_comment', None) or request.data.get('comment', None) reason = request.data.get('retire_reason', None) @@ -722,7 +780,10 @@ def update(self, request, **_): # pylint: disable=arguments-differ new_version, subject_label_attr, [*[locale.clone() for locale in locales.all()], saved_instance] ) new_version.comment = f'Updated {saved_instance.name} in {self.parent_list_attribute}.' - errors = new_version.save_as_new_version(request.user) + # (Un)retiring a locale doesn't affect the standard checksum, so skip the + # duplicate-version guard for it; content edits still change the checksum. + errors = new_version.save_as_new_version( + request.user, skip_duplicate_version_check='retired' in request.data) if errors: raise ValidationError(errors) except ValidationError as e: @@ -751,7 +812,9 @@ def delete(self, request, *args, **kwargs): labels.append(retired_locale) setattr(new_version, subject_label_attr, labels) new_version.comment = f'Retired {instance.name} in {self.parent_list_attribute}.' - errors = new_version.save_as_new_version(request.user) + # Retiring a locale is an intentional change, but the standard checksum ignores the + # locale `retired` flag, so skip the duplicate-version guard that would block it. + errors = new_version.save_as_new_version(request.user, skip_duplicate_version_check=True) if errors: return Response(errors, status=status.HTTP_400_BAD_REQUEST) return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/core/integration_tests/tests_concepts.py b/core/integration_tests/tests_concepts.py index fc5fd53e..d1d311ed 100644 --- a/core/integration_tests/tests_concepts.py +++ b/core/integration_tests/tests_concepts.py @@ -1,14 +1,16 @@ import unittest from unittest.mock import patch +from celery.states import PENDING from django.conf import settings from mock import ANY from core.bundles.models import Bundle from core.collections.tests.factories import OrganizationCollectionFactory, ExpansionFactory -from core.common.constants import OPENMRS_VALIDATION_SCHEMA +from core.common.constants import ACCESS_TYPE_NONE, OPENMRS_VALIDATION_SCHEMA from core.common.tasks import rebuild_indexes from core.common.tests import OCLAPITestCase +from core.concepts.constants import CONCEPT_HARD_DELETE_REQUIRES_HEAD_ONLY from core.concepts.documents import ConceptDocument from core.concepts.models import Concept from core.concepts.tests.factories import ConceptFactory, ConceptNameFactory, ConceptDescriptionFactory @@ -16,6 +18,7 @@ from core.mappings.tests.factories import MappingFactory from core.orgs.models import Organization from core.sources.tests.factories import OrganizationSourceFactory, UserSourceFactory +from core.tasks.models import Task from core.users.models import UserProfile from core.users.tests.factories import UserProfileFactory @@ -1447,6 +1450,440 @@ def test_get_200_with_mappings(self): self.assertFalse(response.has_header('next')) +class ConceptHeadOnlyHardDeleteTest(OCLAPITestCase): + def _create_private_user_source_concept(self): + owner = UserProfileFactory() + source = UserSourceFactory(user=owner, public_access=ACCESS_TYPE_NONE) + concept = ConceptFactory( + parent=source, + names=[ConceptNameFactory.build(name='Head-only concept')], + descriptions=[ConceptDescriptionFactory.build(name='Head-only description')], + ) + return owner, source, concept + + @staticmethod + def _create_source_version(source, concept, *, released): + source_version = OrganizationSourceFactory( + organization=source.organization, + mnemonic=source.mnemonic, + version='released-v1' if released else 'draft-v1', + released=released, + ) + source_version.concepts.add(concept.get_latest_version()) + return source_version + + def test_user_source_owner_can_hard_delete_head_only_concept(self): + owner, source, concept = self._create_private_user_source_concept() + source.update_concepts_count(sync=True) + self.assertEqual(source.active_concepts, 1) + + response = self.client.delete( + concept.uri + '?hardDelete=true', + HTTP_AUTHORIZATION='Token ' + owner.get_token(), + ) + + self.assertEqual(response.status_code, 204) + self.assertFalse(Concept.objects.filter(parent_id=source.id, mnemonic=concept.mnemonic).exists()) + source.refresh_from_db() + self.assertEqual(source.active_concepts, 0) + + def test_organization_member_can_hard_delete_head_only_concept(self): + source = OrganizationSourceFactory(public_access=ACCESS_TYPE_NONE) + member = UserProfileFactory(organizations=[source.organization]) + concept = ConceptFactory(parent=source) + + response = self.client.delete( + concept.uri + '?hardDelete=true', + HTTP_AUTHORIZATION='Token ' + member.get_token(), + ) + + self.assertEqual(response.status_code, 204) + self.assertFalse(Concept.objects.filter(id=concept.id).exists()) + + def test_authenticated_user_can_hard_delete_from_public_edit_source(self): + source = OrganizationSourceFactory() + user = UserProfileFactory() + concept = ConceptFactory(parent=source) + + response = self.client.delete( + concept.uri + '?hardDelete=true', + HTTP_AUTHORIZATION='Token ' + user.get_token(), + ) + + self.assertEqual(response.status_code, 204) + self.assertFalse(Concept.objects.filter(id=concept.id).exists()) + + def test_same_mnemonic_in_versioned_different_source_does_not_block_delete(self): + source = OrganizationSourceFactory() + other_source = OrganizationSourceFactory() + user = UserProfileFactory() + concept = ConceptFactory(parent=source, mnemonic='shared-id') + other_concept = ConceptFactory(parent=other_source, mnemonic='shared-id') + self._create_source_version(other_source, other_concept, released=True) + + response = self.client.delete( + concept.uri + '?hardDelete=true', + HTTP_AUTHORIZATION='Token ' + user.get_token(), + ) + + self.assertEqual(response.status_code, 204) + self.assertFalse(Concept.objects.filter(id=concept.id).exists()) + self.assertTrue(Concept.objects.filter(id=other_concept.id).exists()) + + def test_hard_delete_removes_all_head_only_concept_versions(self): + source = OrganizationSourceFactory() + user = UserProfileFactory() + concept = ConceptFactory( + parent=source, + names=[ConceptNameFactory.build(name='Edited HEAD-only concept')], + ) + + for index in range(4): + response = self.client.patch( + concept.uri, + {'extras': {'edit': index}}, + HTTP_AUTHORIZATION='Token ' + user.get_token(), + format='json', + ) + self.assertEqual(response.status_code, 200, response.data) + + concept_versions = Concept.objects.filter( + parent_id=source.id, + versioned_object_id=concept.id, + ) + self.assertEqual(concept_versions.count(), 6) + self.assertFalse(concept.belongs_to_non_head_source_version()) + + response = self.client.delete( + concept.uri + '?hardDelete=true', + HTTP_AUTHORIZATION='Token ' + user.get_token(), + ) + + self.assertEqual(response.status_code, 204) + self.assertFalse(Concept.objects.filter( + parent_id=source.id, + versioned_object_id=concept.id, + ).exists()) + + def test_user_without_write_access_cannot_hard_delete(self): + _, _, concept = self._create_private_user_source_concept() + user = UserProfileFactory() + + response = self.client.delete( + concept.uri + '?hardDelete=true', + HTTP_AUTHORIZATION='Token ' + user.get_token(), + ) + + self.assertEqual(response.status_code, 403) + self.assertTrue(Concept.objects.filter(id=concept.id).exists()) + + def test_anonymous_user_cannot_hard_delete_from_public_edit_source(self): + source = OrganizationSourceFactory() + concept = ConceptFactory(parent=source) + + response = self.client.delete(concept.uri + '?hardDelete=true') + + self.assertEqual(response.status_code, 401) + self.assertTrue(Concept.objects.filter(id=concept.id).exists()) + + def test_editor_cannot_hard_delete_concept_in_released_source_version(self): + source = OrganizationSourceFactory(public_access=ACCESS_TYPE_NONE) + member = UserProfileFactory(organizations=[source.organization]) + concept = ConceptFactory(parent=source) + self._create_source_version(source, concept, released=True) + + response = self.client.delete( + concept.uri + '?hardDelete=true', + HTTP_AUTHORIZATION='Token ' + member.get_token(), + ) + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.data, {'detail': CONCEPT_HARD_DELETE_REQUIRES_HEAD_ONLY}) + self.assertTrue(Concept.objects.filter(id=concept.id).exists()) + + def test_editor_cannot_hard_delete_concept_in_draft_source_version(self): + source = OrganizationSourceFactory(public_access=ACCESS_TYPE_NONE) + member = UserProfileFactory(organizations=[source.organization]) + concept = ConceptFactory(parent=source) + self._create_source_version(source, concept, released=False) + + response = self.client.delete( + concept.uri + '?hardDelete=true', + HTTP_AUTHORIZATION='Token ' + member.get_token(), + ) + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.data, {'detail': CONCEPT_HARD_DELETE_REQUIRES_HEAD_ONLY}) + self.assertTrue(Concept.objects.filter(id=concept.id).exists()) + + def test_editor_cannot_hard_delete_while_source_version_seed_is_pending(self): + source = OrganizationSourceFactory(public_access=ACCESS_TYPE_NONE) + member = UserProfileFactory(organizations=[source.organization]) + concept = ConceptFactory(parent=source) + source_version = OrganizationSourceFactory( + organization=source.organization, + mnemonic=source.mnemonic, + version='pending-v1', + ) + Task.new( + user=member, + name='seed_children_to_new_version', + args=('source', source_version.id, True, False), + state=PENDING, + ) + + response = self.client.delete( + concept.uri + '?hardDelete=true', + HTTP_AUTHORIZATION='Token ' + member.get_token(), + ) + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.data, {'detail': CONCEPT_HARD_DELETE_REQUIRES_HEAD_ONLY}) + self.assertTrue(Concept.objects.filter(id=concept.id).exists()) + + def test_pending_seed_does_not_block_concept_created_after_source_version(self): + source = OrganizationSourceFactory(public_access=ACCESS_TYPE_NONE) + member = UserProfileFactory(organizations=[source.organization]) + source_version = OrganizationSourceFactory( + organization=source.organization, + mnemonic=source.mnemonic, + version='pending-v1', + ) + Task.new( + user=member, + name='seed_children_to_new_version', + args=('source', source_version.id, True, False), + state=PENDING, + ) + concept = ConceptFactory(parent=source) + + response = self.client.delete( + concept.uri + '?hardDelete=true', + HTTP_AUTHORIZATION='Token ' + member.get_token(), + ) + + self.assertEqual(response.status_code, 204) + self.assertFalse(Concept.objects.filter(id=concept.id).exists()) + + def test_historical_concept_version_in_release_blocks_hard_delete(self): + source = OrganizationSourceFactory(public_access=ACCESS_TYPE_NONE) + member = UserProfileFactory(organizations=[source.organization]) + concept = ConceptFactory( + parent=source, + names=[ConceptNameFactory.build(name='Historically released concept')], + ) + released_version = self._create_source_version(source, concept, released=True) + released_concept_version_id = concept.get_latest_version().id + + update_response = self.client.patch( + concept.uri, + {'datatype': 'Text'}, + HTTP_AUTHORIZATION='Token ' + member.get_token(), + format='json', + ) + self.assertEqual(update_response.status_code, 200, update_response.data) + self.assertNotEqual(concept.get_latest_version().id, released_concept_version_id) + self.assertTrue(released_version.concepts.filter(id=released_concept_version_id).exists()) + + response = self.client.delete( + concept.uri + '?hardDelete=true', + HTTP_AUTHORIZATION='Token ' + member.get_token(), + ) + + self.assertEqual(response.status_code, 409) + self.assertTrue(Concept.objects.filter(id=concept.id).exists()) + + def test_blocked_hard_delete_preserves_related_data(self): + source = OrganizationSourceFactory(public_access=ACCESS_TYPE_NONE) + member = UserProfileFactory(organizations=[source.organization]) + concept = ConceptFactory( + parent=source, + names=[ConceptNameFactory.build(name='Published concept')], + descriptions=[ConceptDescriptionFactory.build(name='Published description')], + ) + target = ConceptFactory(parent=source) + mapping = MappingFactory( + parent=source, + from_concept=concept.get_latest_version(), + to_concept=target.get_latest_version(), + ) + source_version = self._create_source_version(source, concept, released=True) + concept_ids = list(Concept.objects.filter( + parent_id=source.id, + versioned_object_id=concept.id, + ).values_list('id', flat=True)) + counts_before = { + 'concepts': Concept.objects.filter(id__in=concept_ids).count(), + 'names': sum(Concept.objects.get(id=cid).names.count() for cid in concept_ids), + 'descriptions': sum(Concept.objects.get(id=cid).descriptions.count() for cid in concept_ids), + 'source_associations': Concept.sources.through.objects.filter(concept_id__in=concept_ids).count(), + 'mappings': Mapping.objects.filter(id=mapping.id).count(), + } + + response = self.client.delete( + concept.uri + '?hardDelete=true', + HTTP_AUTHORIZATION='Token ' + member.get_token(), + ) + + self.assertEqual(response.status_code, 409) + self.assertEqual( + { + 'concepts': Concept.objects.filter(id__in=concept_ids).count(), + 'names': sum(Concept.objects.get(id=cid).names.count() for cid in concept_ids), + 'descriptions': sum(Concept.objects.get(id=cid).descriptions.count() for cid in concept_ids), + 'source_associations': Concept.sources.through.objects.filter(concept_id__in=concept_ids).count(), + 'mappings': Mapping.objects.filter(id=mapping.id).count(), + }, + counts_before, + ) + self.assertTrue(source_version.concepts.filter(id__in=concept_ids).exists()) + + def test_admin_can_hard_delete_concept_in_released_source_version(self): + source = OrganizationSourceFactory() + concept = ConceptFactory(parent=source) + self._create_source_version(source, concept, released=True) + admin = UserProfile.objects.get(username='ocladmin') + + response = self.client.delete( + concept.uri + '?hardDelete=true', + HTTP_AUTHORIZATION='Token ' + admin.get_token(), + ) + + self.assertEqual(response.status_code, 204) + self.assertFalse(Concept.objects.filter(id=concept.id).exists()) + + @patch('core.concepts.views.delete_concept') + def test_editor_can_async_hard_delete_head_only_concept(self, delete_concept_task_mock): + delete_concept_task_mock.__name__ = 'delete_concept' + source = OrganizationSourceFactory(public_access=ACCESS_TYPE_NONE) + member = UserProfileFactory(organizations=[source.organization]) + concept = ConceptFactory(parent=source) + + response = self.client.delete( + concept.uri + '?async=true&hardDelete=true', + HTTP_AUTHORIZATION='Token ' + member.get_token(), + ) + + self.assertEqual(response.status_code, 204) + delete_concept_task_mock.apply_async.assert_called_once_with( + (concept.id,), queue='default', task_id=ANY) + + @patch('core.concepts.views.delete_concept') + def test_editor_cannot_async_hard_delete_concept_in_released_source_version(self, delete_concept_task_mock): + delete_concept_task_mock.__name__ = 'delete_concept' + source = OrganizationSourceFactory(public_access=ACCESS_TYPE_NONE) + member = UserProfileFactory(organizations=[source.organization]) + concept = ConceptFactory(parent=source) + self._create_source_version(source, concept, released=True) + + response = self.client.delete( + concept.uri + '?async=true&hardDelete=true', + HTTP_AUTHORIZATION='Token ' + member.get_token(), + ) + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.data, {'detail': CONCEPT_HARD_DELETE_REQUIRES_HEAD_ONLY}) + delete_concept_task_mock.apply_async.assert_not_called() + self.assertTrue(Concept.objects.filter(id=concept.id).exists()) + + def test_anonymous_user_cannot_async_hard_delete(self): + source = OrganizationSourceFactory(public_access=ACCESS_TYPE_NONE) + concept = ConceptFactory(parent=source) + + response = self.client.delete(concept.uri + '?async=true&hardDelete=true') + + self.assertEqual(response.status_code, 401) + self.assertTrue(Concept.objects.filter(id=concept.id).exists()) + + def test_non_member_editor_cannot_use_db_hard_delete(self): + source = OrganizationSourceFactory() # public edit access + user = UserProfileFactory() + concept = ConceptFactory(parent=source) + + response = self.client.delete( + concept.uri + '?db=true&hardDelete=true', + HTTP_AUTHORIZATION='Token ' + user.get_token(), + ) + + self.assertEqual(response.status_code, 403) + self.assertTrue(Concept.objects.filter(id=concept.id).exists()) + + @patch('core.concepts.views.delete_concept') + def test_non_member_editor_cannot_use_async_hard_delete(self, delete_concept_task_mock): + delete_concept_task_mock.__name__ = 'delete_concept' + source = OrganizationSourceFactory() # public edit access + user = UserProfileFactory() + concept = ConceptFactory(parent=source) + + response = self.client.delete( + concept.uri + '?async=true&hardDelete=true', + HTTP_AUTHORIZATION='Token ' + user.get_token(), + ) + + self.assertEqual(response.status_code, 403) + delete_concept_task_mock.apply_async.assert_not_called() + self.assertTrue(Concept.objects.filter(id=concept.id).exists()) + + def test_org_member_can_db_hard_delete_head_only_concept(self): + source = OrganizationSourceFactory(public_access=ACCESS_TYPE_NONE) + member = UserProfileFactory(organizations=[source.organization]) + concept = ConceptFactory(parent=source) + + response = self.client.delete( + concept.uri + '?db=true&hardDelete=true', + HTTP_AUTHORIZATION='Token ' + member.get_token(), + ) + + self.assertEqual(response.status_code, 204) + self.assertFalse(Concept.objects.filter(id=concept.id).exists()) + + def test_org_member_cannot_db_hard_delete_concept_in_released_source_version(self): + source = OrganizationSourceFactory(public_access=ACCESS_TYPE_NONE) + member = UserProfileFactory(organizations=[source.organization]) + concept = ConceptFactory(parent=source) + self._create_source_version(source, concept, released=True) + + response = self.client.delete( + concept.uri + '?db=true&hardDelete=true', + HTTP_AUTHORIZATION='Token ' + member.get_token(), + ) + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.data, {'detail': CONCEPT_HARD_DELETE_REQUIRES_HEAD_ONLY}) + self.assertTrue(Concept.objects.filter(id=concept.id).exists()) + + def test_editor_cannot_hard_delete_individual_concept_version(self): + source = OrganizationSourceFactory() + user = UserProfileFactory() + concept = ConceptFactory(parent=source) + concept_version = concept.get_latest_version() + + response = self.client.delete( + f'{concept.uri}{concept_version.version}/?hardDelete=true', + HTTP_AUTHORIZATION='Token ' + user.get_token(), + ) + + self.assertEqual(response.status_code, 403) + self.assertTrue(Concept.objects.filter(id=concept_version.id).exists()) + + def test_regular_delete_still_retires_for_editor(self): + source = OrganizationSourceFactory() + user = UserProfileFactory() + concept = ConceptFactory(parent=source) + + response = self.client.delete( + concept.uri, + {'comment': 'Retired instead of deleted'}, + HTTP_AUTHORIZATION='Token ' + user.get_token(), + format='json', + ) + + self.assertEqual(response.status_code, 204) + concept.refresh_from_db() + self.assertTrue(concept.retired) + self.assertEqual(concept.get_latest_version().comment, 'Retired instead of deleted') + + class ConceptVersionRetrieveViewTest(OCLAPITestCase): def setUp(self): super().setUp() diff --git a/core/sources/tests/tests.py b/core/sources/tests/tests.py index 4d351bf1..248175e0 100644 --- a/core/sources/tests/tests.py +++ b/core/sources/tests/tests.py @@ -1,6 +1,7 @@ import factory from django.core.exceptions import ValidationError from django.db import transaction +from django.test import override_settings from mock import patch, Mock, ANY, PropertyMock, call from core.collections.models import Collection @@ -23,6 +24,7 @@ from core.sources.documents import SourceDocument from core.sources.models import Source, CloneError from core.sources.tests.factories import OrganizationSourceFactory, UserSourceFactory +from core.tasks.models import Task from core.url_registry.factories import OrganizationURLRegistryFactory, GlobalURLRegistryFactory from core.users.models import UserProfile from core.users.tests.factories import UserProfileFactory @@ -263,6 +265,28 @@ def test_persist_new_version(self): self.assertEqual(version1.concepts.first(), source.concepts.filter(is_latest_version=True).first()) self.assertEqual(version1.concepts_set.count(), 0) # no direct child + @override_settings(TEST_MODE=False) + @patch('core.common.models.seed_children_to_new_version.apply_async') + def test_persist_new_version_registers_seed_task_before_enqueue(self, apply_async): + source = OrganizationSourceFactory(version=HEAD) + source_version = OrganizationSourceFactory.build( + version='v1', + mnemonic=source.mnemonic, + organization=source.organization, + ) + + with self.captureOnCommitCallbacks(execute=True): + Source.persist_new_version(source_version, source.created_by) + + task = Task.objects.get(name='seed_children_to_new_version') + self.assertEqual(task.args, ['source', source_version.id, True, False]) + apply_async.assert_called_once_with( + ('source', source_version.id, True, False), + task_id=task.id, + queue='default', + persist_args=True, + ) + @patch('core.sources.models.index_source_concepts', Mock(__name__='index_source_concepts')) @patch('core.sources.models.index_source_mappings', Mock(__name__='index_source_mappings')) @patch('core.common.models.delete_s3_objects', Mock())