diff --git a/CodeListLibrary_project/clinicalcode/admin.py b/CodeListLibrary_project/clinicalcode/admin.py index 9cf3949a4..b57fe30eb 100644 --- a/CodeListLibrary_project/clinicalcode/admin.py +++ b/CodeListLibrary_project/clinicalcode/admin.py @@ -115,19 +115,10 @@ def save_model(self, request, obj, form, change): obj.modified = timezone.now() obj.save() -#admin.site.register(CodingSystem) - -# ############################################ -# # Unregister the original Group admin. -# admin.site.unregister(Group) -# -# # Create a new Group admin. -# class GroupAdmin(admin.ModelAdmin): -# # Use our custom form. -# form = GroupAdminForm -# #form_class = GroupAdminForm -# # Filter permissions horizontal as well. -# filter_horizontal = ['permissions'] -# -# # Register the new Group ModelAdmin. -# admin.site.register(Group, GroupAdmin) +# Tests +from .models.ClinicalConcept import ClinicalConcept + +@admin.register(ClinicalConcept) +class ClinicalConceptAdmin(admin.ModelAdmin): + list_display = ['id', 'name'] + exclude = [] diff --git a/CodeListLibrary_project/clinicalcode/apps.py b/CodeListLibrary_project/clinicalcode/apps.py new file mode 100644 index 000000000..ea41bac5d --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + +class ClinicalCodeAppConfig(AppConfig): + name = 'clinicalcode' + + def ready(self): + from . import signals diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/constants.py b/CodeListLibrary_project/clinicalcode/entity_utils/constants.py index ead095c8c..dcbf6a3e6 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/constants.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/constants.py @@ -15,6 +15,14 @@ def __contains__(cls, lhs): else: return True +class HISTORICAL_CHANGE_TYPE(int, enum.Enum): + ''' + Historical change type for History Mixin + ''' + CREATED = 1 + EDITED = 2 + DELETED = 3 + class TAG_TYPE(int, enum.Enum): ''' Tag types used for differentiate Collections & Tags diff --git a/CodeListLibrary_project/clinicalcode/management/__init__.py b/CodeListLibrary_project/clinicalcode/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/CodeListLibrary_project/clinicalcode/management/commands/__init__.py b/CodeListLibrary_project/clinicalcode/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/CodeListLibrary_project/clinicalcode/management/commands/concept_migrate.py b/CodeListLibrary_project/clinicalcode/management/commands/concept_migrate.py new file mode 100644 index 000000000..c38d31659 --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/management/commands/concept_migrate.py @@ -0,0 +1,89 @@ +from django.core.management.base import BaseCommand + +import os +import glob +import json + +from ...models.ClinicalConcept import ClinicalConcept +from ...models.ClinicalConcept import ClinicalRuleset + +from ...models.CodingSystem import CodingSystem +from ...models.Concept import Concept +from ...models.Component import Component +from ...models.CodeList import CodeList +from ...models.Code import Code + +class Command(BaseCommand): + IS_DEBUG = True + + help = 'Example usage of historical models' + + def __get_log_style(self, style): + if not isinstance(style, str): + return style + + style = style.upper() + if style == 'SUCCESS': + return self.style.SUCCESS + elif style == 'NOTICE': + return self.style.NOTICE + elif style == 'WARNING': + return self.style.WARNING + elif style == 'ERROR': + return self.style.ERROR + return self.style.SUCCESS + + def __log(self, message, style='SUCCESS'): + style = self.__get_log_style(style) + self.stdout.write(style(message)) + + def __migrate_concept(self, *args, **kwargs): + ids = kwargs.get('ids') or 'ALL' + ids = ids.upper().split(',') + if ids[0] == 'ALL': + ids = list(Concept.objects.all().values_list('id', flat=True)) + + self.__log(f'Migrating Concepts #{len(ids)}') + + def __test_historical_records(self, *args, **kwargs): + # Create Concept + self.__log('Create Concept') + clin_concept = ClinicalConcept( + name='COVID-19 with rules', + coding_system=CodingSystem.objects.get(id=4) + ) + clin_concept.save() + self.__log(f'Created initial: ClinicalConcept', style=self.style.MIGRATE_LABEL) + + # Add a rule + clin_ruleset = ClinicalRuleset(name='Some ruleset') + clin_ruleset.save() + clin_concept.rulesets.add(clin_ruleset) + + # Update the concept for some reason + self.__log('Update Concept') + clin_concept.name = 'COVID-19 with change' + clin_concept.save() + self.__log(f'Diff: {clin_concept.history.get(version_id=1).get_delta(clin_concept)}', style=self.style.MIGRATE_HEADING) + + # Remove the ruleset(s) and update the Concept + self.__log('Remove Rules') + clin_concept.name = 'COVID-19 with removed rules' + clin_concept.coding_system = CodingSystem.objects.get(id=7) + clin_concept.save() + clin_concept.rulesets.clear() + self.__log(f'Diff: {clin_concept.history.get(version_id=2).get_delta(clin_concept)}', style=self.style.MIGRATE_HEADING) + + self.__log('Result:') + self.__log(f'1. ClinicalConcept', style=self.style.MIGRATE_LABEL) + self.__log(f'2. Historical records: {list(clin_concept.history.all())}', style=self.style.MIGRATE_LABEL) + + def add_arguments(self, parser): + if not self.IS_DEBUG: + parser.add_argument('-i', '--ids', type=str, help='Only migrate a specific instance, or instances (using a comma delimiter)') + + def handle(self, *args, **kwargs): + if not self.IS_DEBUG: + self.__migrate_concept(*args, **kwargs) + return + self.__test_historical_records(*args, **kwargs) diff --git a/CodeListLibrary_project/clinicalcode/managers/HistoricalManager.py b/CodeListLibrary_project/clinicalcode/managers/HistoricalManager.py new file mode 100644 index 000000000..b84ea7e14 --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/managers/HistoricalManager.py @@ -0,0 +1,63 @@ +from django.db import models +from django.db.models import QuerySet + +from ..entity_utils import constants + +class HistoricalQuerySet(QuerySet): + ''' + Replicate QuerySet behaviour of historical records + + e.g. + ClinicalConcept.history.get(entity_id__in=[1, 2]).latest_of_each() + + ''' + def filter(self, *args, **kwargs): + return super().filter(*args, **kwargs) + + def latest_of_each(self): + latest_versions = self.order_by('-entity_id', '-version_date') \ + .distinct('entity_id') \ + .values_list('pk', flat=True) + return self.filter(pk__in=latest_versions) + +class CurrentRecordManager(models.Manager): + def get_super_queryset(self): + queryset = super().get_queryset() + latest_live_versions = queryset.exclude(change_reason=constants.HISTORICAL_CHANGE_TYPE.DELETED.value) \ + .order_by('-entity_id', '-version_date') \ + .distinct('entity_id') \ + .values_list('pk', flat=True) + return queryset.filter(pk__in=latest_live_versions) + + def get_queryset(self): + return self.get_super_queryset() + +class HistoricalRecordManager(models.Manager): + ''' + Replicate class level behaviour of historical records + + e.g. + ClinicalConcept.history.all() + ''' + def __init__(self, model, instance=None): + super().__init__() + self.model = model + self.instance = instance + + def get_super_queryset(self): + return super().get_queryset() + + def get_queryset(self): + if self.instance is None: + return self.get_super_queryset() + return self.get_super_queryset().filter(entity_id=self.instance.entity_id) + + def most_recent(self): + if not self.instance: + return + return self.get_queryset().order_by('-version_date').first() + + def earliest(self): + if not self.instance: + return + return self.get_queryset().order_by('version_date').first() diff --git a/CodeListLibrary_project/clinicalcode/managers/__init__.py b/CodeListLibrary_project/clinicalcode/managers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/CodeListLibrary_project/clinicalcode/migrations/0099_clinicalcodeitem_clinicalconcept_legacyclinicaldata_and_more.py b/CodeListLibrary_project/clinicalcode/migrations/0099_clinicalcodeitem_clinicalconcept_legacyclinicaldata_and_more.py new file mode 100644 index 000000000..b8aa84b0f --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/migrations/0099_clinicalcodeitem_clinicalconcept_legacyclinicaldata_and_more.py @@ -0,0 +1,115 @@ +# Generated by Django 4.0.10 on 2023-04-26 20:01 + +import clinicalcode.entity_utils.constants +import clinicalcode.mixins.DeltaMixin +from django.conf import settings +import django.contrib.postgres.fields +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('clinicalcode', '0098_brand_footer_images_historicalbrand_footer_images'), + ] + + operations = [ + migrations.CreateModel( + name='ClinicalCodeItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('attributes', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=250), blank=True, null=True, size=None)), + ('code', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='clinicalcode.code')), + ], + ), + migrations.CreateModel( + name='ClinicalConcept', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('entity_id', models.BigIntegerField(db_index=True, editable=False)), + ('version_id', models.BigIntegerField(db_index=True, default=1, editable=False)), + ('version_date', models.DateTimeField(blank=True, db_index=True, default=django.utils.timezone.now, editable=False)), + ('created_date', models.DateTimeField(blank=True, db_index=True, default=django.utils.timezone.now, editable=False)), + ('change_reason', models.TextField(blank=True, null=True)), + ('change_type', models.IntegerField(choices=[('CREATED', 1), ('EDITED', 2), ('DELETED', 3)], default=clinicalcode.entity_utils.constants.HISTORICAL_CHANGE_TYPE['CREATED'])), + ('name', models.CharField(max_length=250)), + ('code_attribute_header', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), blank=True, null=True, size=None)), + ('coding_system', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='coded_concepts', to='clinicalcode.codingsystem')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='child_concepts', to='clinicalcode.genericentity')), + ('root_concept', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='forked_concepts', to='clinicalcode.clinicalconcept')), + ], + options={ + 'ordering': ('name',), + }, + bases=(models.Model, clinicalcode.mixins.DeltaMixin.DeltaModelMixin), + ), + migrations.CreateModel( + name='LegacyClinicalData', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('entry_date', models.DateField()), + ('author', models.CharField(max_length=1000)), + ('description', models.TextField(blank=True, null=True)), + ('tags', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), blank=True, null=True, size=None)), + ('collections', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), blank=True, null=True, size=None)), + ('validation_performed', models.BooleanField(default=False, null=True)), + ('validation_description', models.TextField(blank=True, null=True)), + ('publication_doi', models.CharField(max_length=100)), + ('publication_link', models.URLField(max_length=1000)), + ('secondary_publication_links', models.TextField(blank=True, null=True)), + ('paper_published', models.BooleanField(default=False, null=True)), + ('source_reference', models.CharField(max_length=250)), + ('citation_requirements', models.TextField(blank=True, null=True)), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='legacy_details', to='clinicalcode.clinicalconcept')), + ], + ), + migrations.CreateModel( + name='ContentPermission', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('world_access', models.IntegerField(choices=[('NONE', 1), ('VIEW', 2), ('EDIT', 3)], default=clinicalcode.entity_utils.constants.GROUP_PERMISSIONS['NONE'])), + ('group', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='auth.group')), + ('owner', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('id',), + }, + ), + migrations.CreateModel( + name='ClinicalRuleset', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=250)), + ('source_type', models.IntegerField(choices=[('CONCEPT', 1), ('QUERY_BUILDER', 2), ('EXPRESSION', 3), ('SELECT_IMPORT', 4), ('FILE_IMPORT', 5), ('SEARCH_TERM', 6)], default=clinicalcode.entity_utils.constants.CLINICAL_CODE_SOURCE['SEARCH_TERM'])), + ('logical_type', models.IntegerField(choices=[('INCLUDE', 1), ('EXCLUDE', 2)], default=clinicalcode.entity_utils.constants.CLINICAL_RULE_TYPE['INCLUDE'])), + ('codes', models.ManyToManyField(blank=True, related_name='rulesets', through='clinicalcode.ClinicalCodeItem', to='clinicalcode.code')), + ], + options={ + 'ordering': ('name',), + }, + ), + migrations.AddField( + model_name='clinicalconcept', + name='rulesets', + field=models.ManyToManyField(blank=True, related_name='parent_concepts', to='clinicalcode.clinicalruleset'), + ), + migrations.AddField( + model_name='clinicalcodeitem', + name='ruleset', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='clinicalcode.clinicalruleset'), + ), + migrations.AddField( + model_name='genericentity', + name='permissions', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='permitted_entities', to='clinicalcode.contentpermission'), + ), + migrations.AddField( + model_name='historicalgenericentity', + name='permissions', + field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='clinicalcode.contentpermission'), + ), + ] diff --git a/CodeListLibrary_project/clinicalcode/mixins/DeltaMixin.py b/CodeListLibrary_project/clinicalcode/mixins/DeltaMixin.py new file mode 100644 index 000000000..2a0324e65 --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/mixins/DeltaMixin.py @@ -0,0 +1,129 @@ +from django.db import models +from decimal import Decimal +from itertools import chain +from datetime import datetime + +import inspect + +def model_to_dict(instance, fields=None, exclude=None, date_to_strf=None): + from django.db.models.fields.related import ManyToManyField + from django.db.models.fields import DateTimeField + from django.db.models.fields.files import ImageField, FileField + opts = instance._meta + data = {} + + __fields = list(map(lambda a: a.split('__')[0], fields or [])) + + for f in chain(opts.concrete_fields, opts.many_to_many): + is_editable = getattr(f, 'editable', False) + + if fields and f.name not in __fields: + continue + + if exclude and f.name in exclude: + continue + + if isinstance(f, ManyToManyField): + if instance.pk is None: + data[f.name] = [] + else: + qs = f.value_from_object(instance) + if isinstance(qs, list): + data[f.name] = qs + elif qs._result_cache is not None: + data[f.name] = [item.pk for item in qs] + else: + try: + m2m_field = list(filter(lambda a: f.name in a and a.find('__') != -1, fields))[0] + key = m2m_field[len(f.name) + 2:] + data[f.name] = list(qs.values_list(key, flat=True)) + except IndexError: + data[f.name] = list(qs.values_list('pk', flat=True)) + + elif isinstance(f, DateTimeField): + date = f.value_from_object(instance) + data[f.name] = date_to_strf(date) if date_to_strf else datetime.timestamp(date) + + elif isinstance(f, ImageField): + image = f.value_from_object(instance) + data[f.name] = image.url if image else None + + elif isinstance(f, FileField): + file = f.value_from_object(instance) + data[f.name] = file.url if file else None + + elif is_editable: + data[f.name] = f.value_from_object(instance) + + if not instance.pk: + return data + + funcs = set(__fields) - set(list(data.keys())) + for func in funcs: + obj = getattr(instance, func) + if inspect.ismethod(obj): + data[func] = obj() + else: + data[func] = obj + return data + +class DeltaModelMixin(object): + ''' + Delta diff between models + ''' + FLOAT_EPSILON = 0.00001 + IGNORED_FIELDS = ['id', 'entity_id', 'version_id', 'created_date', 'version_date', 'change_reason', 'change_type'] + + def __init__(self, *args, **kwargs): + super(DeltaModelMixin, self).__init__(*args, **kwargs) + self.__initial = self._dict + + def get_delta(self, d2): + d1 = self.__initial + + if isinstance(d2, models.Model) and getattr(d2, '_dict'): + d2 = d2._dict + + if not d1: + return dict() + + diffs = {} + for k, v1 in d1.items(): + v2 = d2[k] + if isinstance(v1, Decimal): + v1 = float(v1) + if isinstance(v2, Decimal): + v2 = float(v2) + + if isinstance(v2, float) or isinstance(v1, float): + changed = self.is_float_diff(v1, v2) + else: + changed = v1 != v2 + + if changed: + diffs[k] = [v1, v2] + + return dict(diffs) + + @property + def diff(self): + return self.get_delta(self._dict) + + @property + def has_changed(self): + return bool(self.diff) + + @property + def changed_fields(self): + return self.diff.keys() + + def is_float_diff(self, a, b): + return abs(round(a, b, 5)) > self.FLOAT_EPSILON + + def get_field_diff(self, field_name): + return self.diff.get(field_name, None) + + @property + def _dict(self): + data = model_to_dict(self, fields=[field.name for field in self._meta.get_fields() if field.name not in self.IGNORED_FIELDS]) + return data diff --git a/CodeListLibrary_project/clinicalcode/mixins/HistoricalMixin.py b/CodeListLibrary_project/clinicalcode/mixins/HistoricalMixin.py new file mode 100644 index 000000000..4bf6252df --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/mixins/HistoricalMixin.py @@ -0,0 +1,77 @@ +from datetime import datetime +from django.db import models, transaction +from django.utils.timezone import now, make_aware +from django.contrib.auth.models import Group, User + +from .DeltaMixin import DeltaModelMixin +from ..entity_utils import constants +from ..managers.HistoricalManager import CurrentRecordManager, HistoricalRecordManager, HistoricalQuerySet + +class classproperty(property): + def __get__(self, owner_self, owner_cls): + return self.fget(self, owner_cls, owner_self) + +class HistoricalModelMixin(models.Model, DeltaModelMixin): + id = models.AutoField(primary_key=True) + entity_id = models.BigIntegerField(db_index=True, editable=False) + version_id = models.BigIntegerField(db_index=True, editable=False, default=1) + version_date = models.DateTimeField(db_index=True, editable=False, default=now, blank=True) + created_date = models.DateTimeField(db_index=True, editable=False, default=now, blank=True) + change_reason = models.TextField(null=True, blank=True) + change_type = models.IntegerField(choices=[(e.name, e.value) for e in constants.HISTORICAL_CHANGE_TYPE], default=constants.HISTORICAL_CHANGE_TYPE.CREATED) + + objects = CurrentRecordManager() + + ''' Meta ''' + class Meta: + abstract = True + + ''' Properties ''' + @property + def is_historic(self): + if self.pk is None: + return + return self.history.most_recent().pk != self.pk + + @classproperty + def history(self, model, instance): + queryset = HistoricalRecordManager.from_queryset(HistoricalQuerySet) + return queryset(model, instance) + + ''' Model Methods ''' + def __init__(self, *args, **kwargs): + super(HistoricalModelMixin, self).__init__(*args, **kwargs) + + def save(self, *args, **kwargs): + if self.pk is None: + return self.__create_new_record(*args, **kwargs) + return self.__create_new_version(*args, **kwargs) + + ''' Private Methods ''' + def __get_next_id(self): + last = self._meta.model.objects.order_by('-id').first() + if last is not None: + return last.id + 1 + return 1 + + def __create_new_record(self, *args, **kwargs): + with transaction.atomic(): + self.entity_id = self.entity_id or self.__get_next_id() + self.change_reason = 'CREATED' + self.change_type = constants.HISTORICAL_CHANGE_TYPE.CREATED + super(HistoricalModelMixin, self).save(*args, **kwargs) + return self + + def __create_new_version(self, *args, **kwargs): + with transaction.atomic(): + last_version = self.history.most_recent() + + self.pk = None + self.version_id = last_version.version_id + 1 + self.version_date = make_aware(datetime.now()) + self.created_date = last_version.created_date + self.change_reason = 'UPDATED' + self.change_type = constants.HISTORICAL_CHANGE_TYPE.EDITED + super(HistoricalModelMixin, self).save(*args, **kwargs) + + return self diff --git a/CodeListLibrary_project/clinicalcode/mixins/__init__.py b/CodeListLibrary_project/clinicalcode/mixins/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/CodeListLibrary_project/clinicalcode/models/ClinicalConcept.py b/CodeListLibrary_project/clinicalcode/models/ClinicalConcept.py new file mode 100644 index 000000000..01542ca9e --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/models/ClinicalConcept.py @@ -0,0 +1,134 @@ +from django.db import models +from django.contrib.postgres.fields import ArrayField + +from .Code import Code +from .CodingSystem import CodingSystem +from .GenericEntity import GenericEntity +from ..mixins.HistoricalMixin import HistoricalModelMixin +from ..entity_utils import constants + +class ClinicalRuleset(models.Model): + ''' + + ''' + id = models.AutoField(primary_key=True) + name = models.CharField(max_length=250) + source_type = models.IntegerField(choices=[(e.name, e.value) for e in constants.CLINICAL_CODE_SOURCE], default=constants.CLINICAL_CODE_SOURCE.SEARCH_TERM) + logical_type = models.IntegerField(choices=[(e.name, e.value) for e in constants.CLINICAL_RULE_TYPE], default=constants.CLINICAL_RULE_TYPE.INCLUDE) + codes = models.ManyToManyField(Code, blank=True, related_name='rulesets', through='ClinicalCodeItem') + + ''' Meta ''' + class Meta: + ordering = ('name', ) + + ''' Properties ''' + @property + def code_count(self): + ''' + ''' + return 0 + + @property + def codelist(self): + ''' + + ''' + return None + + ''' Methods ''' + + ''' Operators ''' + def __str__(self): + return self.name + +class ClinicalCodeItem(models.Model): + ''' + + ''' + ruleset = models.ForeignKey(ClinicalRuleset, on_delete=models.CASCADE) + code = models.ForeignKey(Code, on_delete=models.CASCADE) + attributes = ArrayField(models.CharField(max_length=250), blank=True, null=True) + +class ClinicalConcept(HistoricalModelMixin): + ''' + + ''' + + ''' Data ''' + # Metadata + name = models.CharField(max_length=250) + + # Forking + root_concept = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name='forked_concepts') + + # Phenotype parent + parent = models.ForeignKey(GenericEntity, on_delete=models.CASCADE, null=True, blank=True, related_name='child_concepts') + + # Clinical data + rulesets = models.ManyToManyField(ClinicalRuleset, blank=True, related_name='parent_concepts') + coding_system = models.ForeignKey(CodingSystem, on_delete=models.SET_NULL, null=True, blank=True, related_name='coded_concepts') + code_attribute_header = ArrayField(models.CharField(max_length=100), blank=True, null=True) + + ''' Meta ''' + class Meta: + ordering = ('name', ) + + def save(self, *args, **kwargs): + super(ClinicalConcept, self).save(*args, **kwargs) + + ''' Properties ''' + @property + def code_count(self): + ''' + ''' + return 0 + + @property + def codelist(self): + ''' + Property to return aggregated codes from codelists of each ruleset + + e.g. + concept = ClinicalConcept.objects.all().first() + codelist = concept.codelist + ''' + return None + + ''' Methods ''' + def can_edit(self, user): + ''' + Det. whether the user is able to edit this Concept based on its parent's permissions + + e.g. + user = request.user + concept = ClinicalConcept.objects.all().first() + + if concept.can_user_edit(user): + print('Do things') + ''' + return True + + ''' Operators ''' + def __str__(self): + return self.name + +class LegacyClinicalData(models.Model): + ''' + + ''' + id = models.AutoField(primary_key=True) + parent = models.ForeignKey(ClinicalConcept, on_delete=models.CASCADE, null=True, blank=True, related_name='legacy_details') + + entry_date = models.DateField() + author = models.CharField(max_length=1000) + description = models.TextField(null=True, blank=True) + tags = ArrayField(models.IntegerField(), blank=True, null=True) + collections = ArrayField(models.IntegerField(), blank=True, null=True) + validation_performed = models.BooleanField(null=True, default=False) + validation_description = models.TextField(null=True, blank=True) + publication_doi = models.CharField(max_length=100) + publication_link = models.URLField(max_length=1000) + secondary_publication_links = models.TextField(null=True, blank=True) + paper_published = models.BooleanField(null=True, default=False) + source_reference = models.CharField(max_length=250) + citation_requirements = models.TextField(null=True, blank=True) diff --git a/CodeListLibrary_project/clinicalcode/models/ContentPermission.py b/CodeListLibrary_project/clinicalcode/models/ContentPermission.py new file mode 100644 index 000000000..4bbcdac46 --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/models/ContentPermission.py @@ -0,0 +1,22 @@ +from django.db import models +from django.contrib.auth.models import Group, User + +from ..entity_utils import constants + +class ContentPermission(models.Model): + id = models.AutoField(primary_key=True) + owner = models.ForeignKey(User, on_delete=models.SET_NULL, null=True) + group = models.ForeignKey(Group, on_delete=models.SET_NULL, null=True) + world_access = models.IntegerField(choices=[(e.name, e.value) for e in constants.GROUP_PERMISSIONS], default=constants.GROUP_PERMISSIONS.NONE) + + class Meta: + ordering = ('id', ) + + def __str__(self): + ownership = filter(None, [ + f'owner: {self.owner.username}' if self.owner is not None else None, + f'group: {self.group.name}' if self.group is not None else None + ]) + + ownership = (', ').join(ownership) if any(ownership) else 'NULL' + return f'ContentPermission<{ownership}>' diff --git a/CodeListLibrary_project/clinicalcode/models/GenericEntity.py b/CodeListLibrary_project/clinicalcode/models/GenericEntity.py index 4b16bcaa8..a97749306 100644 --- a/CodeListLibrary_project/clinicalcode/models/GenericEntity.py +++ b/CodeListLibrary_project/clinicalcode/models/GenericEntity.py @@ -10,7 +10,7 @@ from .Template import Template from .EntityClass import EntityClass -from .TimeStampedModel import TimeStampedModel +from .ContentPermission import ContentPermission from clinicalcode.constants import * from ..entity_utils import gen_utils, constants @@ -75,6 +75,9 @@ class GenericEntity(models.Model): group_access = models.IntegerField(choices=[(e.name, e.value) for e in constants.GROUP_PERMISSIONS], default=constants.GROUP_PERMISSIONS.NONE) world_access = models.IntegerField(choices=[(e.name, e.value) for e in constants.GROUP_PERMISSIONS], default=constants.GROUP_PERMISSIONS.NONE) + ''' Permission ''' + permissions = models.ForeignKey(ContentPermission, on_delete=models.CASCADE, null=True, blank=True, related_name='permitted_entities') + ''' Historical data ''' history = HistoricalRecords() @@ -82,7 +85,6 @@ def save(self, ignore_increment=False, *args, **kwargs): ''' [!] Note: 1. On creation, increments counter within template and increment's entity ID by count + 1 - 2. template_version field is computed from the template_data.version field ''' template_layout = self.template diff --git a/CodeListLibrary_project/clinicalcode/serializers/ClinicalConcept.py b/CodeListLibrary_project/clinicalcode/serializers/ClinicalConcept.py new file mode 100644 index 000000000..b240041d5 --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/serializers/ClinicalConcept.py @@ -0,0 +1,15 @@ +from rest_framework import serializers + +from ..models import ClinicalConcept + +class ClinicalConceptSerializer(serializers.ModelSerializer): + ''' + Will be used for serialisation of Concepts for API, Forms and Detail + ''' + class Meta: + model = ClinicalConcept + fields = '__all__' + + def to_representation(self, instance): + data = super(ClinicalConceptSerializer, self).to_representation(instance) + return data diff --git a/CodeListLibrary_project/clinicalcode/serializers/__init__.py b/CodeListLibrary_project/clinicalcode/serializers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/CodeListLibrary_project/clinicalcode/signals.py b/CodeListLibrary_project/clinicalcode/signals.py new file mode 100644 index 000000000..9c9f1916b --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/signals.py @@ -0,0 +1,41 @@ +from django.dispatch import receiver +from django.db.models.signals import m2m_changed, post_save +from django.db.models.fields.related import ManyToManyField + +from .mixins.HistoricalMixin import HistoricalModelMixin +from .models.ClinicalConcept import ClinicalConcept + +@receiver(post_save) +def historical_m2m_clone_handler(sender, **kwargs): + if not issubclass(sender, HistoricalModelMixin): + return + + instance = kwargs.get('instance') + if not instance: + return + + initial = None + try: + initial = getattr(instance, '__initial') + except: + pass + + if initial is None: + instance.__initial = instance._dict + return + + for key, value in initial.items(): + field = instance._meta.get_field(key) + if isinstance(field, ManyToManyField): + getattr(instance, key).add(*value) + + instance.__initial = instance._dict + +@receiver(m2m_changed, sender=ClinicalConcept.rulesets.through) +def ruleset_changed(sender, **kwargs): + action = kwargs.get('action') + timeline = action.split('_')[0] + concept = kwargs.get('instance') + if timeline == 'pre': + return + concept.__initial = concept._dict