Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions core/common/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.paginator import Paginator
from django.db import transaction
from django.db import transaction, models
from django.db.models import Q, F, QuerySet
from django.http import HttpResponseForbidden, Http404
from django.shortcuts import get_object_or_404, redirect
Expand Down Expand Up @@ -601,6 +601,15 @@ def get_repo_events(self, private=False):


class SourceChildMixin(ChecksumModel):
external_id = models.TextField(null=True, blank=True)
comment = models.TextField(null=True, blank=True)
retire_reason = models.TextField(null=True, blank=True)
versioned_object = models.ForeignKey(
'self', related_name='versions_set', null=True, blank=True, on_delete=models.CASCADE
)
_counted = models.BooleanField(default=True, null=True, blank=True)
_index = models.BooleanField(default=True)

class Meta:
abstract = True

Expand Down Expand Up @@ -721,23 +730,24 @@ def parent_resource(self):
def parent_url(self):
return get(self.parent, 'uri')

def retire(self, user, comment=None):
def retire(self, user, comment=None, reason=None):
if self.versioned_object.retired:
return {'__all__': self.ALREADY_RETIRED}

return self.__update_retire(True, comment or self.WAS_RETIRED, user)
return self.__update_retire(True, user, comment or self.WAS_RETIRED, reason)

def unretire(self, user, comment=None):
if not self.versioned_object.retired:
return {'__all__': self.ALREADY_NOT_RETIRED}

return self.__update_retire(False, comment or self.WAS_UNRETIRED, user)
return self.__update_retire(False, user, comment or self.WAS_UNRETIRED)

def __update_retire(self, retired, comment, user):
def __update_retire(self, retired, user, comment, reason=None):
latest_version = self.get_latest_version() or self.get_last_version()
new_version = latest_version.clone()
new_version.retired = retired
new_version.comment = comment
new_version.retire_reason = reason if retired else None
return new_version.save_as_new_version(user)

@classmethod
Expand Down
2 changes: 1 addition & 1 deletion core/concept_maps/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ def update(self, instance, validated_data):
if ConceptMapDetailSerializer.is_mapping_same(mapping, new_mapping):
found = True
if not found:
mapping.retire(user, 'Deleted from ConceptMap resource')
mapping.retire(user, None, 'Deleted from ConceptMap resource')

source.refresh_from_db()

Expand Down
28 changes: 28 additions & 0 deletions core/concepts/migrations/0082_concept_retire_reason_and_more.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Generated by Django 5.1.15 on 2026-05-26 02:43

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('concepts', '0081_remove_conceptname_preferred_locale_and_more'),
]

operations = [
migrations.AddField(
model_name='concept',
name='retire_reason',
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='conceptdescription',
name='retire_reason',
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='conceptname',
name='retire_reason',
field=models.TextField(blank=True, null=True),
),
]
19 changes: 12 additions & 7 deletions core/concepts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class Meta:
locale_preferred = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
retired = models.BooleanField(default=False)
retire_reason = models.TextField(null=True, blank=True)

SMART_CHECKSUM_KEY = None

Expand All @@ -55,6 +56,7 @@ def clone(self):
locale=self.locale,
locale_preferred=self.locale_preferred,
retired=self.retired,
retire_reason=self.retire_reason,
)

@staticmethod
Expand Down Expand Up @@ -226,23 +228,16 @@ class Meta:
)
] + VersionedModel.Meta.indexes

external_id = models.TextField(null=True, blank=True)
concept_class = models.TextField()
datatype = models.TextField()
comment = models.TextField(null=True, blank=True)
parent = models.ForeignKey('sources.Source', related_name='concepts_set', on_delete=models.CASCADE)
sources = models.ManyToManyField('sources.Source', related_name='concepts')
versioned_object = models.ForeignKey(
'self', related_name='versions_set', null=True, blank=True, on_delete=models.CASCADE
)
parent_concepts = models.ManyToManyField(
'self', through='HierarchicalConcepts', symmetrical=False, related_name='child_concepts'
)
mnemonic = models.CharField(
max_length=255, validators=[RegexValidator(regex=CONCEPT_REGEX)],
)
_counted = models.BooleanField(default=True, null=True, blank=True)
_index = models.BooleanField(default=True)
logo_path = None
name = None
full_name = None
Expand Down Expand Up @@ -531,6 +526,7 @@ def clone(self):
concept_class=self.concept_class,
datatype=self.datatype,
retired=self.retired,
retire_reason=self.retire_reason,
released=self.released,
extras=self.extras or {},
parent=self.parent,
Expand Down Expand Up @@ -584,6 +580,14 @@ def create_new_version_for(
instance.extras = data.get('extras', instance.extras)
instance.external_id = data.get('external_id', instance.external_id)
instance.comment = data.get('update_comment') or data.get('comment')
instance.retire_reason = data.get('retire_reason', instance.retire_reason)
is_retired = data.get('retired', None)
if is_retired is True and not instance.retired:
instance.retire_comment = instance.comment
instance.comment = instance.WAS_RETIRED
elif instance.retired and is_retired is False:
instance.retire_comment = None
instance.comment = instance.comment or instance.WAS_RETIRED
instance.retired = data.get('retired', instance.retired)
if is_patch:
prev = instance.versions.exclude(id=instance.id).filter(is_latest_version=True).first()
Expand Down Expand Up @@ -1143,6 +1147,7 @@ def update_versioned_object(self):
concept.concept_class = self.concept_class
concept.datatype = self.datatype
concept.retired = self.retired
concept.retire_reason = self.retire_reason
concept.external_id = self.external_id or concept.external_id
concept.updated_by_id = self.updated_by_id
concept.save()
Expand Down
18 changes: 13 additions & 5 deletions core/concepts/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ class ConceptLocaleSerializer(ModelSerializer):
class Meta:
model = ConceptName
fields = (
'uuid', 'external_id', 'type', 'locale', 'locale_preferred', 'concept_id', 'retired'
'uuid', 'external_id', 'type', 'locale', 'locale_preferred', 'concept_id', 'retired', 'retire_reason'
)

@staticmethod
Expand All @@ -80,7 +80,14 @@ def create(self, validated_data, instance=None): # pylint: disable=arguments-di
locale.name = validated_data.get('name', locale.name)
locale.locale = validated_data.get('locale', locale.locale)
locale.locale_preferred = validated_data.get('locale_preferred', locale.locale_preferred)

retired = validated_data.get('retired', None)
if retired is True and not locale.retired:
locale.retire_reason = validated_data.get('retire_reason', None)
elif locale.retired and retired is False:
locale.retire_reason = None
locale.retired = validated_data.get('retired', locale.retired)

locale.type = self.get_locale_type(validated_data, locale)
locale.external_id = validated_data.get('external_id', locale.external_id)
locale.concept_id = validated_data.get('concept_id', locale.concept_id)
Expand Down Expand Up @@ -307,7 +314,7 @@ class Meta:
'owner', 'owner_type', 'owner_url', 'display_name', 'display_locale', 'version', 'update_comment',
'locale', 'version_created_by', 'version_created_on', 'mappings', 'is_latest_version', 'versions_url',
'version_url', 'extras', 'type', 'versioned_object_id', 'version_updated_on', 'version_updated_by',
'latest_source_version', 'property'
'latest_source_version', 'property', 'retire_reason'
)


Expand Down Expand Up @@ -466,7 +473,7 @@ class Meta:
'owner', 'owner_type', 'owner_url', 'display_name', 'display_locale', 'names', 'descriptions',
'created_on', 'updated_on', 'versions_url', 'version', 'extras', 'parent_id', 'type',
'update_comment', 'version_url', 'updated_by', 'created_by',
'public_can_view', 'versioned_object_id', 'latest_source_version', 'property'
'public_can_view', 'versioned_object_id', 'latest_source_version', 'property', 'retire_reason'
)

def create(self, validated_data):
Expand Down Expand Up @@ -518,7 +525,8 @@ class Meta:
'names', 'descriptions', 'extras', 'retired', 'source', 'source_url', 'owner', 'owner_name', 'owner_url',
'version', 'created_on', 'updated_on', 'version_created_on', 'version_created_by', 'update_comment',
'is_latest_version', 'locale', 'url', 'owner_type', 'version_url', 'previous_version_url',
'parent_concept_urls', 'child_concept_urls', 'version_updated_on', 'version_updated_by', 'checksums'
'parent_concept_urls', 'child_concept_urls', 'version_updated_on', 'version_updated_by', 'checksums',
'retire_reason'
)

@staticmethod
Expand Down Expand Up @@ -597,7 +605,7 @@ class Meta:
'is_latest_version', 'locale', 'url', 'owner_type', 'version_url', 'mappings', 'previous_version_url',
'parent_concepts', 'child_concepts', 'parent_concept_urls', 'child_concept_urls',
'source_versions', 'collection_versions', 'versioned_object_id', 'references', 'checksums',
'version_updated_on', 'version_updated_by', 'latest_source_version',
'version_updated_on', 'version_updated_by', 'latest_source_version', 'retire_reason'
)

def get_references(self, obj):
Expand Down
77 changes: 75 additions & 2 deletions core/concepts/tests/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -880,6 +880,41 @@ def test_retire(self):
self.assertTrue(concept.is_versioned_object)
self.assertTrue(concept_v1.is_latest_version)

concept_v1.retire(concept_v1.created_by, None, 'Forceful retirement') # concept will become old/prev version
concept.refresh_from_db()
concept_v1.refresh_from_db()

self.assertFalse(concept_v1.is_latest_version)
self.assertEqual(concept.versions.count(), 3)
self.assertTrue(concept.retired)
latest_version = concept.get_latest_version()
self.assertTrue(latest_version.retired)
self.assertEqual(latest_version.retire_reason, 'Forceful retirement')
self.assertEqual(latest_version.comment, 'Concept was retired')

self.assertEqual(
concept.retire(concept.created_by),
{'__all__': CONCEPT_IS_ALREADY_RETIRED}
)

def test_retire_without_retire_reason(self):
source = OrganizationSourceFactory(version=HEAD)
concept = Concept.persist_new({
**factory.build(dict, FACTORY_CLASS=ConceptFactory), 'mnemonic': 'c1', 'parent': source,
'names': [ConceptNameFactory.build(locale='en', name='English', locale_preferred=True)]
})
concept_v1 = concept.clone()
concept_v1.datatype = 'foobar'
concept_v1.save_as_new_version(concept.created_by)
concept_v1 = Concept.objects.order_by('-created_at').first()
concept.refresh_from_db()

self.assertEqual(concept.versions.count(), 2)
self.assertFalse(concept.retired)
self.assertFalse(concept.is_latest_version)
self.assertTrue(concept.is_versioned_object)
self.assertTrue(concept_v1.is_latest_version)

concept_v1.retire(concept_v1.created_by, 'Forceful retirement') # concept will become old/prev version
concept.refresh_from_db()
concept_v1.refresh_from_db()
Expand All @@ -889,17 +924,18 @@ def test_retire(self):
self.assertTrue(concept.retired)
latest_version = concept.get_latest_version()
self.assertTrue(latest_version.retired)
self.assertEqual(latest_version.retire_reason, None)
self.assertEqual(latest_version.comment, 'Forceful retirement')

self.assertEqual(
concept.retire(concept.created_by),
{'__all__': CONCEPT_IS_ALREADY_RETIRED}
)

def test_unretire(self):
def test_retire_with_default_comment(self):
source = OrganizationSourceFactory(version=HEAD)
concept = Concept.persist_new({
**factory.build(dict, FACTORY_CLASS=ConceptFactory), 'mnemonic': 'c1', 'parent': source, 'retired': True,
**factory.build(dict, FACTORY_CLASS=ConceptFactory), 'mnemonic': 'c1', 'parent': source,
'names': [ConceptNameFactory.build(locale='en', name='English', locale_preferred=True)]
})
concept_v1 = concept.clone()
Expand All @@ -908,6 +944,42 @@ def test_unretire(self):
concept_v1 = Concept.objects.order_by('-created_at').first()
concept.refresh_from_db()

self.assertEqual(concept.versions.count(), 2)
self.assertFalse(concept.retired)
self.assertFalse(concept.is_latest_version)
self.assertTrue(concept.is_versioned_object)
self.assertTrue(concept_v1.is_latest_version)

concept_v1.retire(concept_v1.created_by) # concept will become old/prev version
concept.refresh_from_db()
concept_v1.refresh_from_db()

self.assertFalse(concept_v1.is_latest_version)
self.assertEqual(concept.versions.count(), 3)
self.assertTrue(concept.retired)
latest_version = concept.get_latest_version()
self.assertTrue(latest_version.retired)
self.assertEqual(latest_version.retire_reason, None)
self.assertEqual(latest_version.comment, 'Concept was retired')

self.assertEqual(
concept.retire(concept.created_by),
{'__all__': CONCEPT_IS_ALREADY_RETIRED}
)

def test_unretire(self):
source = OrganizationSourceFactory(version=HEAD)
concept = Concept.persist_new({
**factory.build(dict, FACTORY_CLASS=ConceptFactory), 'mnemonic': 'c1', 'parent': source,
'names': [ConceptNameFactory.build(locale='en', name='English', locale_preferred=True)],
'retire_reason': 'unwanted', 'retired': True
})
concept_v1 = concept.clone()
concept_v1.datatype = 'foobar'
concept_v1.save_as_new_version(concept.created_by)
concept_v1 = Concept.objects.order_by('-created_at').first()
concept.refresh_from_db()

self.assertEqual(concept.versions.count(), 2)
self.assertTrue(concept.retired)
self.assertFalse(concept.is_latest_version)
Expand All @@ -924,6 +996,7 @@ def test_unretire(self):
latest_version = concept.get_latest_version()
self.assertFalse(latest_version.retired)
self.assertEqual(latest_version.comment, 'World needs you!')
self.assertEqual(latest_version.retire_reason, None)

self.assertEqual(
concept.unretire(concept.created_by),
Expand Down
4 changes: 3 additions & 1 deletion core/concepts/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,8 @@ def destroy(self, request, *args, **kwargs):
return Response(status=status.HTTP_204_NO_CONTENT)

comment = request.data.get('update_comment', None) or request.data.get('comment', None)
errors = concept.retire(request.user, comment)
reason = request.data.get('retire_reason', None)
errors = concept.retire(request.user, comment, reason)

if errors:
return Response(errors, status=status.HTTP_400_BAD_REQUEST)
Expand Down Expand Up @@ -745,6 +746,7 @@ def delete(self, request, *args, **kwargs):
]
retired_locale = instance.clone()
retired_locale.retired = True
retired_locale.retire_reason = request.data.get('retire_reason', None)
labels.append(retired_locale)
setattr(new_version, subject_label_attr, labels)
new_version.comment = f'Retired {instance.name} in {self.parent_list_attribute}.'
Expand Down
Loading