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/models.py b/core/concepts/models.py index c8a63f95..8bf0ea14 100644 --- a/core/concepts/models.py +++ b/core/concepts/models.py @@ -1,3 +1,4 @@ +from celery.states import SUCCESS from django.conf import settings from django.contrib.postgres.indexes import HashIndex from django.core.exceptions import ValidationError @@ -20,6 +21,7 @@ MAX_NAMES_LIMIT, MAX_DESCRIPTIONS_LIMIT from core.concepts.mixins import ConceptValidationMixin from core.services.storages.postgres import PostgresQL +from core.tasks.models import Task class AbstractLocalizedText(ChecksumModel): @@ -897,6 +899,43 @@ def is_existing_in_parent(self): def latest_source_version(self): return self.sources.exclude(version=HEAD).order_by('-created_at').first() + def belongs_to_non_head_source_version(self): + """Return whether any version of this source-scoped concept was snapshotted.""" + versioned_object_id = self.versioned_object_id or self.id + concept_ids = Concept.objects.filter( + parent_id=self.parent_id, + versioned_object_id=versioned_object_id, + ).values_list('id', flat=True) + return Concept.sources.through.objects.filter( + concept_id__in=concept_ids, + ).exclude(source__version=HEAD).exists() + + def has_pending_source_version_seed(self): + """Return whether an unfinished source snapshot may include this concept.""" + versioned_object_id = self.versioned_object_id or self.id + concept_created_at = Concept.objects.filter(id=versioned_object_id).values_list( + 'created_at', flat=True + ).first() + if not concept_created_at: + return False + + source = self.parent + source_version_ids = source.__class__.objects.filter( + mnemonic=source.mnemonic, + organization_id=source.organization_id, + user_id=source.user_id, + created_at__gte=concept_created_at, + ).exclude(version=HEAD).values_list('id', flat=True) + + for source_version_id in source_version_ids: + seed_task = Task.find( + name__iendswith='seed_children_to_new_version', + args__contains=['source', source_version_id], + ) + if seed_task and seed_task.state != SUCCESS: + return True + return False + def get_source_version_before_creation(self): return self.sources.exclude(version=HEAD).filter( created_at__lte=self.created_at).order_by('-created_at').first() diff --git a/core/concepts/views.py b/core/concepts/views.py index c779831b..76ba131f 100644 --- a/core/concepts/views.py +++ b/core/concepts/views.py @@ -41,7 +41,10 @@ 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 @@ -344,7 +347,10 @@ def get_permissions(self): if self.request.method in ['GET']: return [CanViewParentDictionary(), ] - if self.request.method == 'DELETE' and self.is_hard_delete_requested(): + if ( + self.request.method == 'DELETE' and self.is_hard_delete_requested() and + (self.is_async_requested() or self.is_db_delete_requested()) + ): return [IsAdminUser(), ] return [CanEditParentDictionary(), ] @@ -383,30 +389,64 @@ 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() + 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): + 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) + + 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, + ) + + # 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) diff --git a/core/integration_tests/tests_concepts.py b/core/integration_tests/tests_concepts.py index fc5fd53e..e4ceb27e 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,366 @@ 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()) + + def test_editor_cannot_use_async_hard_delete(self): + source = OrganizationSourceFactory() + 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) + self.assertTrue(Concept.objects.filter(id=concept.id).exists()) + + def test_editor_cannot_use_db_hard_delete(self): + source = OrganizationSourceFactory() + 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()) + + 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())