From f5bc09a683da502c79a109e761f54d59d10a9332 Mon Sep 17 00:00:00 2001 From: Filipe Lopes Date: Fri, 12 Jun 2026 14:08:54 -0300 Subject: [PATCH 1/7] feat(concepts): restrict hard deletion of versioned concepts * Add CONCEPT_HARD_DELETE_REQUIRES_HEAD_ONLY to enforce deletion policy * Implement belongs_to_non_head_source_version to detect snapshotted concepts * Prevent non-admin hard deletes on non-HEAD concepts in view logic * Add integration tests to validate deletion protection and access control --- core/concepts/constants.py | 3 + core/concepts/models.py | 11 + core/concepts/views.py | 67 +++-- core/integration_tests/tests_concepts.py | 314 ++++++++++++++++++++++- 4 files changed, 378 insertions(+), 17 deletions(-) diff --git a/core/concepts/constants.py b/core/concepts/constants.py index e64d19375..1aa146267 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 c8a63f953..703eba5c5 100644 --- a/core/concepts/models.py +++ b/core/concepts/models.py @@ -897,6 +897,17 @@ 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 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 c779831bc..9a0d6ce28 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,59 @@ 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(): + 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(): + 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 fc5fd53ee..1e44d9b33 100644 --- a/core/integration_tests/tests_concepts.py +++ b/core/integration_tests/tests_concepts.py @@ -6,9 +6,10 @@ 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 @@ -1447,6 +1448,317 @@ 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_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() From 34d6eeb40e43cfd5728ffece7c059072c6585b4c Mon Sep 17 00:00:00 2001 From: Filipe Lopes Date: Fri, 12 Jun 2026 18:26:03 -0300 Subject: [PATCH 2/7] fix(concepts): prevent hard deletion during pending source version seeding * Queue seed tasks with transaction.on_commit to ensure consistency * Detect pending source version seeds to block unsafe deletions * Update view logic to prevent hard deletes when seeds are pending * Add tests to validate deletion blocking and normal behavior --- core/common/models.py | 48 ++++++++++++++-------- core/concepts/models.py | 28 +++++++++++++ core/concepts/views.py | 7 +++- core/integration_tests/tests_concepts.py | 51 ++++++++++++++++++++++++ core/sources/tests/tests.py | 24 +++++++++++ 5 files changed, 141 insertions(+), 17 deletions(-) diff --git a/core/common/models.py b/core/common/models.py index 889d9fb48..aa3d2db83 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/models.py b/core/concepts/models.py index 703eba5c5..8bf0ea141 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): @@ -908,6 +910,32 @@ def belongs_to_non_head_source_version(self): 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 9a0d6ce28..76ba131fd 100644 --- a/core/concepts/views.py +++ b/core/concepts/views.py @@ -406,6 +406,8 @@ 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, @@ -418,7 +420,10 @@ def _hard_delete(self, request, concept): raise Http404() is_admin = IsAdminUser().has_permission(request, self) - if not is_admin and versioned_concept.belongs_to_non_head_source_version(): + 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, diff --git a/core/integration_tests/tests_concepts.py b/core/integration_tests/tests_concepts.py index 1e44d9b33..e4ceb27ea 100644 --- a/core/integration_tests/tests_concepts.py +++ b/core/integration_tests/tests_concepts.py @@ -1,6 +1,7 @@ import unittest from unittest.mock import patch +from celery.states import PENDING from django.conf import settings from mock import ANY @@ -17,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 @@ -1614,6 +1616,55 @@ def test_editor_cannot_hard_delete_concept_in_draft_source_version(self): 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]) diff --git a/core/sources/tests/tests.py b/core/sources/tests/tests.py index 4d351bf12..248175e01 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()) From ec3551b1582cab56c25a642bd9c0887fc4550630 Mon Sep 17 00:00:00 2001 From: Filipe Lopes Date: Mon, 15 Jun 2026 08:29:12 -0300 Subject: [PATCH 3/7] feat(concepts): allow editors to trigger async hard delete * Allow non-admin editors to trigger asynchronous hard deletes while keeping db bypass restricted to admins * Enforce safety checks to block deletion of concepts in snapshotted source versions * Optimize queries for version checks and pending seed detection * Add tests for editor permissions, conflict responses, and access control --- core/concepts/models.py | 26 +++++---------- core/concepts/views.py | 15 +++++---- core/integration_tests/tests_concepts.py | 40 +++++++++++++++++++++--- 3 files changed, 51 insertions(+), 30 deletions(-) diff --git a/core/concepts/models.py b/core/concepts/models.py index 8bf0ea141..659408184 100644 --- a/core/concepts/models.py +++ b/core/concepts/models.py @@ -21,7 +21,6 @@ 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): @@ -901,13 +900,12 @@ def latest_source_version(self): def belongs_to_non_head_source_version(self): """Return whether any version of this source-scoped concept was snapshotted.""" + # Snapshots link the `is_latest_version=True` row (see Source.seed_concepts), not the + # versioned-object row this method is usually called on, so aggregate across all versions. 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, + concept__parent_id=self.parent_id, + concept__versioned_object_id=versioned_object_id, ).exclude(source__version=HEAD).exists() def has_pending_source_version_seed(self): @@ -919,19 +917,9 @@ def has_pending_source_version_seed(self): 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], - ) + source_versions = self.parent.versions.exclude(version=HEAD).filter(created_at__gte=concept_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 diff --git a/core/concepts/views.py b/core/concepts/views.py index 76ba131fd..59c4c831c 100644 --- a/core/concepts/views.py +++ b/core/concepts/views.py @@ -347,9 +347,12 @@ def get_permissions(self): if self.request.method in ['GET']: return [CanViewParentDictionary(), ] + # `db=true` is a raw bypass of the HEAD-only safety check, so it stays admin-only. + # Every other hard delete (including async) is open to repo editors and guarded in + # `_hard_delete`, which returns 409 for non-admins targeting a snapshotted concept. if ( self.request.method == 'DELETE' and self.is_hard_delete_requested() and - (self.is_async_requested() or self.is_db_delete_requested()) + self.is_db_delete_requested() ): return [IsAdminUser(), ] @@ -399,11 +402,6 @@ def _db_hard_delete(self): 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. @@ -429,6 +427,11 @@ def _hard_delete(self, request, concept): 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() diff --git a/core/integration_tests/tests_concepts.py b/core/integration_tests/tests_concepts.py index e4ceb27ea..d4a54a60f 100644 --- a/core/integration_tests/tests_concepts.py +++ b/core/integration_tests/tests_concepts.py @@ -1752,17 +1752,47 @@ def test_admin_can_hard_delete_concept_in_released_source_version(self): 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() + @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 ' + user.get_token(), + HTTP_AUTHORIZATION='Token ' + member.get_token(), ) - self.assertEqual(response.status_code, 403) + 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_editor_cannot_use_db_hard_delete(self): From 31d8d9ac6090d44d1cfb7b807e1ad9ff68c1f2e4 Mon Sep 17 00:00:00 2001 From: Filipe Lopes Date: Mon, 15 Jun 2026 13:51:43 -0300 Subject: [PATCH 4/7] feat(concepts): allow dictionary members to perform async and db hard deletes * Add CanAdministerParentDictionary permission for sensitive delete operations * Update view to use dictionary-level permissions instead of admin-only checks * Preserve HEAD-only safety validation for snapshotted concepts * Add tests verifying dictionary member delete permissions --- core/concepts/permissions.py | 7 +++- core/concepts/views.py | 27 ++++++++++--- core/integration_tests/tests_concepts.py | 48 +++++++++++++++++++++++- 3 files changed, 73 insertions(+), 9 deletions(-) diff --git a/core/concepts/permissions.py b/core/concepts/permissions.py index c80ef0b3f..932ebe9d4 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 59c4c831c..6bf09201e 100644 --- a/core/concepts/views.py +++ b/core/concepts/views.py @@ -47,7 +47,7 @@ ) 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, @@ -347,14 +347,14 @@ def get_permissions(self): if self.request.method in ['GET']: return [CanViewParentDictionary(), ] - # `db=true` is a raw bypass of the HEAD-only safety check, so it stays admin-only. - # Every other hard delete (including async) is open to repo editors and guarded in - # `_hard_delete`, which returns 409 for non-admins targeting a snapshotted concept. + # 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_db_delete_requested() + (self.is_async_requested() or self.is_db_delete_requested()) ): - return [IsAdminUser(), ] + return [CanAdministerParentDictionary(), ] return [CanEditParentDictionary(), ] @@ -396,6 +396,21 @@ 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() diff --git a/core/integration_tests/tests_concepts.py b/core/integration_tests/tests_concepts.py index d4a54a60f..d1d311ed6 100644 --- a/core/integration_tests/tests_concepts.py +++ b/core/integration_tests/tests_concepts.py @@ -1795,8 +1795,8 @@ def test_anonymous_user_cannot_async_hard_delete(self): self.assertEqual(response.status_code, 401) self.assertTrue(Concept.objects.filter(id=concept.id).exists()) - def test_editor_cannot_use_db_hard_delete(self): - source = OrganizationSourceFactory() + def test_non_member_editor_cannot_use_db_hard_delete(self): + source = OrganizationSourceFactory() # public edit access user = UserProfileFactory() concept = ConceptFactory(parent=source) @@ -1808,6 +1808,50 @@ def test_editor_cannot_use_db_hard_delete(self): 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() From c344d33699d89306c45b3b4cd44350482992d3db Mon Sep 17 00:00:00 2001 From: Filipe Lopes Date: Tue, 16 Jun 2026 05:03:09 -0300 Subject: [PATCH 5/7] fix(concepts): allow version save when only retiring locales * Bypass duplicate version checks when updating retired flag on locales * Restrict bypass to requests explicitly modifying retired field --- core/concepts/views.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/core/concepts/views.py b/core/concepts/views.py index 6bf09201e..1dd485a85 100644 --- a/core/concepts/views.py +++ b/core/concepts/views.py @@ -780,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: @@ -809,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) From f8bd22bea9d2db303a0f1e97cc3142cf0edabad1 Mon Sep 17 00:00:00 2001 From: Filipe Lopes Date: Wed, 17 Jun 2026 09:23:50 -0300 Subject: [PATCH 6/7] refactor(concepts): improve non-head source version detection * Refactor belongs_to_non_head_source_version to iterate through related concept versions and sources * Improve accuracy by leveraging prefetching and direct object inspection --- core/concepts/models.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/core/concepts/models.py b/core/concepts/models.py index 659408184..a62ca631f 100644 --- a/core/concepts/models.py +++ b/core/concepts/models.py @@ -899,14 +899,15 @@ 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.""" - # Snapshots link the `is_latest_version=True` row (see Source.seed_concepts), not the - # versioned-object row this method is usually called on, so aggregate across all versions. versioned_object_id = self.versioned_object_id or self.id - return Concept.sources.through.objects.filter( - concept__parent_id=self.parent_id, - concept__versioned_object_id=versioned_object_id, - ).exclude(source__version=HEAD).exists() + versions = Concept.objects.filter( + parent_id=self.parent_id, + versioned_object_id=versioned_object_id, + ).prefetch_related('sources') + return any( + any(source.version != HEAD for source in version.sources.all()) + for version in versions + ) def has_pending_source_version_seed(self): """Return whether an unfinished source snapshot may include this concept.""" From ea8d888b2db9478a8dc6d5effa04f48818bc1ed0 Mon Sep 17 00:00:00 2001 From: Filipe Lopes Date: Wed, 17 Jun 2026 15:25:27 -0300 Subject: [PATCH 7/7] refactor(common): move source version validation logic to SourceChildMixin * Move belongs_to_non_head_source_version to SourceChildMixin for reuse across models * Move has_pending_source_version_seed to SourceChildMixin to centralize validation logic --- core/common/mixins.py | 27 +++++++++++++++++++++++++++ core/concepts/models.py | 28 ---------------------------- 2 files changed, 27 insertions(+), 28 deletions(-) diff --git a/core/common/mixins.py b/core/common/mixins.py index 8b27da890..e555deb1b 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/concepts/models.py b/core/concepts/models.py index a62ca631f..c8a63f953 100644 --- a/core/concepts/models.py +++ b/core/concepts/models.py @@ -1,4 +1,3 @@ -from celery.states import SUCCESS from django.conf import settings from django.contrib.postgres.indexes import HashIndex from django.core.exceptions import ValidationError @@ -898,33 +897,6 @@ 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): - versioned_object_id = self.versioned_object_id or self.id - versions = Concept.objects.filter( - parent_id=self.parent_id, - versioned_object_id=versioned_object_id, - ).prefetch_related('sources') - return any( - any(source.version != HEAD for source in version.sources.all()) - for version in versions - ) - - 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_versions = self.parent.versions.exclude(version=HEAD).filter(created_at__gte=concept_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 - 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()