From a2341f1a3a2222d51671e77ed3074d620ac79f74 Mon Sep 17 00:00:00 2001 From: ieuans Date: Wed, 27 Nov 2024 10:27:16 +0000 Subject: [PATCH 001/164] Initial group changes & management --- CodeListLibrary_project/clinicalcode/admin.py | 44 +++ .../clinicalcode/entity_utils/constants.py | 26 ++ .../clinicalcode/forms/OrganisationForms.py | 286 ++++++++++++++++++ ...ion_genericentity_organisation_and_more.py | 82 +++++ .../0118_brand_org_user_managed_and_more.py | 23 ++ .../clinicalcode/models/Brand.py | 3 + .../clinicalcode/models/GenericEntity.py | 6 +- .../clinicalcode/models/Organisation.py | 152 ++++++++++ .../clinicalcode/models/__init__.py | 6 + CodeListLibrary_project/clinicalcode/urls.py | 30 +- .../clinicalcode/views/Organisation.py | 169 +++++++++++ .../cll/static/scss/components/_banner.scss | 29 ++ .../cll/static/scss/components/_tabView.scss | 91 ++++++ .../cll/static/scss/pages/organisations.scss | 164 ++++++++++ .../clinicalcode/organisation/create.html | 67 ++++ .../clinicalcode/organisation/manage.html | 145 +++++++++ .../clinicalcode/organisation/view.html | 92 ++++++ 17 files changed, 1402 insertions(+), 13 deletions(-) create mode 100644 CodeListLibrary_project/clinicalcode/forms/OrganisationForms.py create mode 100644 CodeListLibrary_project/clinicalcode/migrations/0117_organisation_genericentity_organisation_and_more.py create mode 100644 CodeListLibrary_project/clinicalcode/migrations/0118_brand_org_user_managed_and_more.py create mode 100644 CodeListLibrary_project/clinicalcode/models/Organisation.py create mode 100644 CodeListLibrary_project/clinicalcode/views/Organisation.py create mode 100644 CodeListLibrary_project/cll/static/scss/pages/organisations.scss create mode 100644 CodeListLibrary_project/cll/templates/clinicalcode/organisation/create.html create mode 100644 CodeListLibrary_project/cll/templates/clinicalcode/organisation/manage.html create mode 100644 CodeListLibrary_project/cll/templates/clinicalcode/organisation/view.html diff --git a/CodeListLibrary_project/clinicalcode/admin.py b/CodeListLibrary_project/clinicalcode/admin.py index ef5b30ff2..6523e2561 100644 --- a/CodeListLibrary_project/clinicalcode/admin.py +++ b/CodeListLibrary_project/clinicalcode/admin.py @@ -8,9 +8,12 @@ from .models.GenericEntity import GenericEntity from .models.Template import Template from .models.OntologyTag import OntologyTag +from .models.Organisation import Organisation from .models.DMD_CODES import DMD_CODES + from .forms.TemplateForm import TemplateAdminForm from .forms.EntityClassForm import EntityAdminForm +from .forms.OrganisationForms import OrganisationAdminForm, OrganisationMembershipInline, OrganisationAuthorityInline @admin.register(OntologyTag) class OntologyTag(admin.ModelAdmin): @@ -72,6 +75,47 @@ class CodingSystemAdmin(admin.ModelAdmin): search_fields = ['name', 'codingsystem_id', 'description'] exclude = [] +@admin.register(Organisation) +class OrganisationAdmin(admin.ModelAdmin): + """ + Organisation admin representation + """ + form = OrganisationAdminForm + inlines = [OrganisationMembershipInline, OrganisationAuthorityInline] + #exclude = ['created', 'owner', 'members', 'brands'] + + list_filter = ['id', 'name'] + search_fields = ['id', 'name'] + list_display = ['id', 'name', 'slug'] + prepopulated_fields = {'slug': ['name']} + + def get_form(self, request, obj=None, **kwargs): + """ + Responsible for pre-populating form data & resolving the associated model form + + Args: + request (RequestContext): the request context of the form + obj (dict|None): an Organisation model instance (optional; defaults to `None`) + **kwargs (**kwargs): arbitrary form key-value pair data + + Returns: + (OrganisationModelForm) - the prepared ModelForm instance + """ + form = super(OrganisationAdmin, self).get_form(request, obj, **kwargs) + + if obj is None: + form.base_fields['slug'].initial = '' + form.base_fields['created'].initial = timezone.now() + else: + form.base_fields['slug'].initial = obj.slug + form.base_fields['created'].initial = obj.created + + form.base_fields['slug'].disabled = True + form.base_fields['slug'].help_text = 'This field is not editable' + form.base_fields['created'].disabled = True + form.base_fields['created'].help_text = 'This field is not editable' + + return form @admin.register(Template) class TemplateAdmin(admin.ModelAdmin): diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/constants.py b/CodeListLibrary_project/clinicalcode/entity_utils/constants.py index 6bd6c8304..f0b808fa0 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/constants.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/constants.py @@ -34,6 +34,27 @@ def __contains__(cls, lhs): return True +class ORGANISATION_ROLES(int, enum.Enum, metaclass=IterableMeta): + """ + Defines organisation roles + """ + MEMBER = 0 + EDITOR = 1 + MODERATOR = 2 + ADMIN = 3 + + +class ORGANISATION_INVITE_STATUS(int, enum.Enum, metaclass=IterableMeta): + """ + Defines organisation invite status + """ + EXPIRED = 0 + ACTIVE = 1 + SEEN = 2 + ACCEPTED = 3 + REJECTED = 4 + + class TAG_TYPE(int, enum.Enum): """ Tag types used for differentiate Collections & Tags @@ -147,6 +168,11 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta): CLINICAL_DOMAIN = 1 CLINICAL_FUNCTIONAL_ANATOMY = 2 +""" + Number of days before organisation invite expires +""" +INVITE_TIMEOUT = 30 + """ Used to define the labels for each known ontology type diff --git a/CodeListLibrary_project/clinicalcode/forms/OrganisationForms.py b/CodeListLibrary_project/clinicalcode/forms/OrganisationForms.py new file mode 100644 index 000000000..952aa2064 --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/forms/OrganisationForms.py @@ -0,0 +1,286 @@ +from django import forms +from django.forms.models import modelformset_factory +from django.core.validators import RegexValidator +from django.core.exceptions import ValidationError +from django.contrib.auth.models import User +from django.contrib import admin +from django.utils.text import slugify + +from ..models.Organisation import Organisation, OrganisationMembership, OrganisationAuthority +from ..entity_utils import gen_utils, permission_utils, model_utils + +from django.utils import timezone + +""" Admin """ + +class OrganisationMembershipInline(admin.TabularInline): + model = OrganisationMembership + extra = 1 + +class OrganisationAuthorityInline(admin.TabularInline): + model = OrganisationAuthority + extra = 1 + +class OrganisationAdminForm(forms.ModelForm): + """ + Override organisation admin form management + """ + name = forms.CharField( + required=True, + widget=forms.TextInput( + attrs={ } + ), + min_length=3, + max_length=250 + ) + slug = forms.CharField( + required=False, + widget=forms.TextInput( + attrs={ } + ) + ) + description = forms.CharField( + required=False, + widget=forms.Textarea( + attrs={ } + ), + max_length=1000 + ) + email = forms.EmailField(required=False) + website = forms.URLField(required=False) + owner = forms.ModelChoiceField(queryset=User.objects.all()) + created = forms.DateTimeField( + required=False, + widget=forms.DateTimeInput(attrs={'readonly': 'readonly'}) + ) + + def __init__(self, *args, **kwargs): + super(OrganisationAdminForm, self).__init__(*args, **kwargs) + + class Meta: + model = Organisation + fields = '__all__' + + def clean_created(self): + """ + Responsible for cleaning the `created` field + + Returns: + (timezone) - the cleaned `created` value + """ + # Example clean individual fields + if self.cleaned_data.get('created', None) is None: + return timezone.now() + + return self.cleaned_data['created'] + + def clean(self): + """ + Responsible for cleaning the model fields form data + + Returns: + (dict) - the cleaned model form data + """ + # Example clean multiple fields + data = self.cleaned_data + + create_datetime = data.get('created', None) + if create_datetime is None: + data.update({ 'created': timezone.now() }) + + return data + +""" Create / Update """ + +class OrganisationCreateForm(forms.ModelForm): + NameValidator = RegexValidator( + r'^(?=.*[a-zA-Z].*)([a-zA-Z0-9\-_\(\) ]+)*$', + 'Name can only contain a-z, 0-9, -, _, ( and )' + ) + + name = forms.CharField( + required=True, + widget=forms.TextInput( + attrs={ + 'class': 'text-input', + 'aria-label': 'Enter the organisation\'s name', + 'autocomplete': 'off', + 'autocorrect': 'off', + } + ), + min_length=3, + max_length=250, + validators=[NameValidator] + ) + description = forms.CharField( + required=False, + widget=forms.Textarea( + attrs={ + 'class': 'text-area-input', + 'style': 'resize: none;', + 'aria-label': 'Describe your organisation', + 'rows': '4', + 'autocomplete': 'off', + 'autocorrect': 'on', + 'spellcheck': 'default', + 'wrap': 'soft', + } + ), + max_length=1000 + ) + email = forms.EmailField( + required=False, + widget=forms.EmailInput( + attrs={ + 'class': 'text-input', + 'aria-label': 'Enter the organisation\'s name', + 'autocomplete': 'on', + 'autocorrect': 'off', + } + ) + ) + website = forms.URLField( + required=False, + widget=forms.URLInput( + attrs={ + 'class': 'text-input', + 'aria-label': 'Enter the organisation\'s name', + 'autocomplete': 'off', + 'autocorrect': 'off', + } + ) + ) + + def __init__(self, *args, **kwargs): + initial = kwargs.get('initial') + super(OrganisationCreateForm, self).__init__(*args, **kwargs) + + owner = initial.get('owner') + self.fields['owner'] = forms.ModelChoiceField( + queryset=User.objects.filter(pk=owner.id), + initial=owner, + widget=forms.HiddenInput(), + required=False + ) + + class Meta: + model = Organisation + fields = '__all__' + exclude = ['slug', 'created', 'members', 'brands'] + + def clean(self): + """ + Responsible for cleaning the model fields form data + + Returns: + (dict) - the cleaned model form data + """ + data = super(OrganisationCreateForm, self).clean() + + name = data.get('name', None) + if gen_utils.is_empty_string(name): + self.add_error( + 'name', + ValidationError('Name cannot be empty') + ) + else: + data.update({ 'slug': slugify(name) }) + + create_datetime = data.get('created', None) + if create_datetime is None: + data.update({ 'created': timezone.now() }) + + return data + +class OrganisationManageForm(forms.ModelForm): + NameValidator = RegexValidator( + r'^(?=.*[a-zA-Z].*)([a-zA-Z0-9\-_\(\) ]+)*$', + 'Name can only contain a-z, 0-9, -, _, ( and )' + ) + + name = forms.CharField( + required=True, + widget=forms.TextInput( + attrs={ + 'class': 'text-input', + 'aria-label': 'Enter the organisation\'s name', + 'autocomplete': 'off', + 'autocorrect': 'off', + } + ), + min_length=3, + max_length=250, + validators=[NameValidator] + ) + description = forms.CharField( + required=False, + widget=forms.Textarea( + attrs={ + 'class': 'text-area-input', + 'style': 'resize: none;', + 'aria-label': 'Describe your organisation', + 'rows': '4', + 'autocomplete': 'off', + 'autocorrect': 'on', + 'spellcheck': 'default', + 'wrap': 'soft', + } + ), + max_length=1000 + ) + email = forms.EmailField( + required=False, + widget=forms.EmailInput( + attrs={ + 'class': 'text-input', + 'aria-label': 'Enter the organisation\'s name', + 'autocomplete': 'on', + 'autocorrect': 'off', + } + ) + ) + website = forms.URLField( + required=False, + widget=forms.URLInput( + attrs={ + 'class': 'text-input', + 'aria-label': 'Enter the organisation\'s name', + 'autocomplete': 'off', + 'autocorrect': 'off', + } + ) + ) + + def __init__(self, *args, **kwargs): + super(OrganisationManageForm, self).__init__(*args, **kwargs) + + print(args, kwargs) + + class Meta: + model = Organisation + fields = '__all__' + exclude = ['slug', 'created', 'owner', 'brands'] + + def clean(self): + """ + Responsible for cleaning the model fields form data + + Returns: + (dict) - the cleaned model form data + """ + data = super(OrganisationManageForm, self).clean() + + name = data.get('name', None) + if gen_utils.is_empty_string(name): + self.add_error( + 'name', + ValidationError('Name cannot be empty') + ) + else: + data.update({ 'slug': slugify(name) }) + + create_datetime = data.get('created', None) + if create_datetime is None: + data.update({ 'created': timezone.now() }) + + return data diff --git a/CodeListLibrary_project/clinicalcode/migrations/0117_organisation_genericentity_organisation_and_more.py b/CodeListLibrary_project/clinicalcode/migrations/0117_organisation_genericentity_organisation_and_more.py new file mode 100644 index 000000000..989f24eea --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/migrations/0117_organisation_genericentity_organisation_and_more.py @@ -0,0 +1,82 @@ +# Generated by Django 5.1.2 on 2024-11-26 22:01 + +import django.db.models.deletion +import django.utils.timezone +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('clinicalcode', '0116_ontology_descendants'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Organisation', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('slug', models.SlugField(unique=True)), + ('name', models.CharField(max_length=250, unique=True)), + ('description', models.TextField(blank=True, max_length=1000)), + ('email', models.EmailField(blank=True, max_length=254, null=True)), + ('website', models.URLField(blank=True, null=True)), + ('created', models.DateTimeField(default=django.utils.timezone.now)), + ('owner', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_organisations', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddField( + model_name='genericentity', + name='organisation', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='entities', to='clinicalcode.organisation'), + ), + migrations.AddField( + model_name='historicalgenericentity', + name='organisation', + field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='clinicalcode.organisation'), + ), + migrations.CreateModel( + name='OrganisationAuthority', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('can_post', models.BooleanField(default=False)), + ('can_moderate', models.BooleanField(default=False)), + ('brand', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='clinicalcode.brand')), + ('organisation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='clinicalcode.organisation')), + ], + ), + migrations.AddField( + model_name='organisation', + name='brands', + field=models.ManyToManyField(related_name='organisations', through='clinicalcode.OrganisationAuthority', to='clinicalcode.brand'), + ), + migrations.CreateModel( + name='OrganisationInvite', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('outcome', models.IntegerField(choices=[(0, 'EXPIRED'), (1, 'ACTIVE'), (2, 'SEEN'), (3, 'ACCEPTED'), (4, 'REJECTED')], default=1)), + ('sent', models.BooleanField(default=False)), + ('created', models.DateTimeField(default=django.utils.timezone.now, editable=False)), + ('organisation', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='invites', to='clinicalcode.organisation')), + ('user', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='organisation_invites', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='OrganisationMembership', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('role', models.IntegerField(choices=[(0, 'MEMBER'), (1, 'EDITOR'), (2, 'MODERATOR'), (3, 'ADMIN')], default=0)), + ('joined', models.DateTimeField(default=django.utils.timezone.now, editable=False)), + ('organisation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='clinicalcode.organisation')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddField( + model_name='organisation', + name='members', + field=models.ManyToManyField(related_name='organisations', through='clinicalcode.OrganisationMembership', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/CodeListLibrary_project/clinicalcode/migrations/0118_brand_org_user_managed_and_more.py b/CodeListLibrary_project/clinicalcode/migrations/0118_brand_org_user_managed_and_more.py new file mode 100644 index 000000000..a16cb83a0 --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/migrations/0118_brand_org_user_managed_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.2 on 2024-11-26 22:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('clinicalcode', '0117_organisation_genericentity_organisation_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='brand', + name='org_user_managed', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='historicalbrand', + name='org_user_managed', + field=models.BooleanField(default=False), + ), + ] diff --git a/CodeListLibrary_project/clinicalcode/models/Brand.py b/CodeListLibrary_project/clinicalcode/models/Brand.py index 8fa43e08e..5d3322899 100644 --- a/CodeListLibrary_project/clinicalcode/models/Brand.py +++ b/CodeListLibrary_project/clinicalcode/models/Brand.py @@ -28,6 +28,9 @@ class Brand(TimeStampedModel): history = HistoricalRecords() + # Organisation controls + org_user_managed = models.BooleanField(default=False) + class Meta: ordering = ('name', ) diff --git a/CodeListLibrary_project/clinicalcode/models/GenericEntity.py b/CodeListLibrary_project/clinicalcode/models/GenericEntity.py index 99d26674c..332ecccef 100644 --- a/CodeListLibrary_project/clinicalcode/models/GenericEntity.py +++ b/CodeListLibrary_project/clinicalcode/models/GenericEntity.py @@ -8,6 +8,7 @@ from django.core.exceptions import ValidationError from simple_history.models import HistoricalRecords +from .Organisation import Organisation from .Template import Template from .EntityClass import EntityClass from ..entity_utils import gen_utils, constants @@ -66,12 +67,15 @@ class GenericEntity(models.Model): deleted = models.DateTimeField(null=True, blank=True) deleted_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="entity_deleted") owner = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name="entity_owned") - group = models.ForeignKey(Group, on_delete=models.SET_NULL, null=True, blank=True) + organisation = models.ForeignKey(Organisation, on_delete=models.SET_NULL, null=True, related_name="entities") + # TODO: WILL BE DEPRECATED + group = models.ForeignKey(Group, on_delete=models.SET_NULL, null=True, blank=True) owner_access = models.IntegerField(choices=[(e.name, e.value) for e in constants.OWNER_PERMISSIONS], default=constants.OWNER_PERMISSIONS.EDIT) 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.WORLD_ACCESS_PERMISSIONS], default=constants.WORLD_ACCESS_PERMISSIONS.NONE) + ''' Publish status ''' publish_status = models.IntegerField(null=True, choices=[(e.name, e.value) for e in constants.APPROVAL_STATUS], default=constants.APPROVAL_STATUS.ANY) diff --git a/CodeListLibrary_project/clinicalcode/models/Organisation.py b/CodeListLibrary_project/clinicalcode/models/Organisation.py new file mode 100644 index 000000000..e6352b777 --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/models/Organisation.py @@ -0,0 +1,152 @@ +from django.db import models +from django.contrib.auth.models import User +from django.utils.timezone import now +from django.utils.text import slugify + +import uuid + +from .Brand import Brand +from ..entity_utils import constants + +class Organisation(models.Model): + """ + + """ + id = models.AutoField(primary_key=True) + slug = models.SlugField(db_index=True, unique=True) + name = models.CharField(max_length=250, unique=True) + description = models.TextField(blank=True, max_length=1000) + email = models.EmailField(null=True, blank=True) + website = models.URLField(blank=True, null=True) + + owner = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + default=None, + related_name='owned_organisations' + ) + members = models.ManyToManyField( + User, + through='OrganisationMembership', + related_name='organisations' + ) + brands = models.ManyToManyField( + Brand, + through='OrganisationAuthority', + related_name='organisations' + ) + + created = models.DateTimeField(default=now) + + def save(self, *args, **kwargs): + self.slug = slugify(self.name) + super(Organisation, self).save(*args, **kwargs) + + def get_view_absolute_url(self): + return reverse( + 'view_organisation', + kwargs={'slug': self.slug} + ) + + def get_edit_absolute_url(self): + return reverse( + 'edit_organisation', + kwargs={'slug': self.slug} + ) + + def __str__(self): + return f'name={self.name}, slug={self.slug}' + +class OrganisationMembership(models.Model): + """ + + """ + id = models.AutoField(primary_key=True) + user = models.ForeignKey( + User, on_delete=models.CASCADE + ) + organisation = models.ForeignKey( + Organisation, on_delete=models.CASCADE + ) + + role = models.IntegerField( + choices=[(e.value, e.name) for e in constants.ORGANISATION_ROLES], + default=constants.ORGANISATION_ROLES.MEMBER.value + ) + + joined = models.DateTimeField(default=now, editable=False) + + def __str__(self): + return f'user: {self.user}, org: {self.organisation}' + +class OrganisationAuthority(models.Model): + """ + + """ + id = models.AutoField(primary_key=True) + brand = models.ForeignKey( + Brand, on_delete=models.CASCADE + ) + organisation = models.ForeignKey( + Organisation, on_delete=models.CASCADE + ) + + can_post = models.BooleanField(default=False) + can_moderate = models.BooleanField(default=False) + + def __str__(self): + return f'brand={self.brand}, org={self.organisation}' + +class OrganisationInvite(models.Model): + """ + + """ + id = models.UUIDField( + primary_key = True, + default = uuid.uuid4, + editable = False + ) + + user = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + default=None, + related_name='organisation_invites' + ) + organisation = models.ForeignKey( + Organisation, + on_delete=models.SET_NULL, + null=True, + default=None, + related_name='invites' + ) + + outcome = models.IntegerField( + choices=[(e.value, e.name) for e in constants.ORGANISATION_INVITE_STATUS], + default=constants.ORGANISATION_INVITE_STATUS.ACTIVE.value + ) + sent = models.BooleanField(default=False) + created = models.DateTimeField(default=now, editable=False) + + def is_expired(self): + if not self.sent: + return False + + date_expired = self.created + datetime.timedelta(days=constants.INVITE_TIMEOUT) + return date_expired <= timezone.now() + + def is_sent(self): + return self.sent + + def send(self): + """ + TODO + """ + if self.sent: + return False + pass + + def __str__(self): + return f'user={self.user}, org={self.organisation}' diff --git a/CodeListLibrary_project/clinicalcode/models/__init__.py b/CodeListLibrary_project/clinicalcode/models/__init__.py index a003553a9..ffb0e25e1 100644 --- a/CodeListLibrary_project/clinicalcode/models/__init__.py +++ b/CodeListLibrary_project/clinicalcode/models/__init__.py @@ -42,6 +42,12 @@ from .Template import Template from .GenericEntity import GenericEntity from .PublishedGenericEntity import PublishedGenericEntity +from .Organisation import ( + Organisation, + OrganisationMembership, + OrganisationAuthority, + OrganisationInvite +) # need to restore EMIS/Vision when deploy. to prod. #from .EMIS_CODES import EMIS_CODES diff --git a/CodeListLibrary_project/clinicalcode/urls.py b/CodeListLibrary_project/clinicalcode/urls.py index 7ee6c63a8..57780c55c 100644 --- a/CodeListLibrary_project/clinicalcode/urls.py +++ b/CodeListLibrary_project/clinicalcode/urls.py @@ -12,7 +12,7 @@ from clinicalcode.views.DocumentationViewer import DocumentationViewer from clinicalcode.views import (View, Admin, adminTemp, GenericEntity, Profile, Moderation, - Publish, Decline, site) + Publish, Decline, site, Organisation) # Main urlpatterns = [ @@ -35,7 +35,23 @@ ## About pages url(r'^about/(?P([A-Za-z0-9\-\_]+))/$', View.brand_about_index_return, name='about_page'), - + + ## Moderation + url(r'moderation/$', Moderation.EntityModeration.as_view(), name='moderation_page'), + + ## Contact + url(r'^contact-us/$', View.contact_us, name='contact_us'), + + # User + ## Profile + url(r'profile/$', Profile.MyCollection.as_view(), name='my_profile'), + url(r'profile/collection/$', Profile.MyCollection.as_view(), name='my_collection'), + + ## Organisation + url(r'^org/view/(?P([\w\d\-\_]+))/?$', Organisation.OrganisationView.as_view(), name='view_organisation'), + url(r'^org/create/?$', Organisation.OrganisationCreateView.as_view(), name='create_organisation'), + url(r'^org/manage/(?P([\w\d\-\_]+))/?$', Organisation.OrganisationManageView.as_view(), name='manage_organisation'), + ## Changing password(s) url( route='^change-password/$', @@ -57,10 +73,6 @@ url(r'^phenotypes/(?P\w+)/export/codes/$', GenericEntity.export_entity_codes_to_csv, name='export_entity_latest_version_codes_to_csv'), url(r'^phenotypes/(?P\w+)/version/(?P\d+)/export/codes/$', GenericEntity.export_entity_codes_to_csv, name='export_entity_version_codes_to_csv'), - ## Profile - url(r'profile/$', Profile.MyCollection.as_view(), name='my_profile'), - url(r'profile/collection/$', Profile.MyCollection.as_view(), name='my_collection'), - ## Selection service(s) url(r'^query/(?P\w+)/?$', GenericEntity.EntityDescendantSelection.as_view(), name='entity_descendants'), @@ -71,12 +83,6 @@ ## Documentation for create url(r'^documentation/(?P([A-Za-z0-9\-]+))/?$', DocumentationViewer.as_view(), name='documentation_viewer'), - ## Moderation - url(r'moderation/$', Moderation.EntityModeration.as_view(), name='moderation_page'), - - ## Contact - url(r'^contact-us/$', View.contact_us, name='contact_us'), - # GenericEnities (Phenotypes) ## Create / Update url(r'^create/$', GenericEntity.CreateEntityView.as_view(), name='create_phenotype'), diff --git a/CodeListLibrary_project/clinicalcode/views/Organisation.py b/CodeListLibrary_project/clinicalcode/views/Organisation.py new file mode 100644 index 000000000..bd1112c8a --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/views/Organisation.py @@ -0,0 +1,169 @@ +from datetime import datetime +from django.utils.timezone import make_aware +from django.views.generic import TemplateView, CreateView, UpdateView +from django.shortcuts import render +from django.conf import settings +from django.db.models import F, When, Case, Value +from django.db import transaction +from django.contrib.auth.models import User, Group +from django.contrib.auth.decorators import login_required +from django.utils.decorators import method_decorator +from django.http.response import JsonResponse, Http404 +from django.urls import reverse_lazy +from django.db import models + +from ..models.GenericEntity import GenericEntity +from ..models.Brand import Brand +from ..models.Organisation import Organisation, OrganisationAuthority +from ..forms.OrganisationForms import OrganisationCreateForm, OrganisationManageForm +from ..entity_utils import permission_utils, model_utils, gen_utils, constants + +class OrganisationCreateView(CreateView): + model = Organisation + template_name = 'clinicalcode/organisation/create.html' + form_class = OrganisationCreateForm + + @method_decorator([login_required, permission_utils.redirect_readonly]) + def dispatch(self, request, *args, **kwargs): + return super(OrganisationCreateView, self).dispatch(request, *args, **kwargs) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['label_suffix'] = '' + return kwargs + + def get_context_data(self, **kwargs): + context = super(OrganisationCreateView, self).get_context_data(**kwargs) + return context + + def get_initial(self): + self.initial.update({'owner': self.request.user}) + return self.initial + + def get_success_url(self): + resolve_target = self.request.GET.get('resolve-target') + if not gen_utils.is_empty_string(resolve_target): + return reverse_lazy(resolve_target) + return reverse_lazy('view_organisation', kwargs={ 'slug': self.object.slug }) + + @transaction.atomic + def form_valid(self, form): + form.instance.owner = self.request.user + obj = form.save() + + brand = self.request.BRAND_OBJECT + if isinstance(brand, Brand): + obj.brands.add( + brand, + through_defaults={ + 'can_post': False, + 'can_moderate': False + } + ) + + return super(OrganisationCreateView, self).form_valid(form) + +class OrganisationManageView(UpdateView): + model = Organisation + template_name = 'clinicalcode/organisation/manage.html' + form_class = OrganisationManageForm + + @method_decorator([login_required, permission_utils.redirect_readonly]) + def dispatch(self, request, *args, **kwargs): + return super(OrganisationManageView, self).dispatch(request, *args, **kwargs) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['label_suffix'] = '' + return kwargs + + def get_object(self, queryset=None): + if queryset is None: + queryset = self.get_queryset() + + slug = self.kwargs.get('slug') + try: + obj = queryset.filter(slug=slug).get() + except queryset.model.DoesNotExist: + raise Http404('No organisation found') + return obj + + def get_context_data(self, **kwargs): + context = super(OrganisationManageView, self).get_context_data(**kwargs) + + members = self.object.members.through.objects.all() \ + .annotate( + role_name=Case( + *[When(role=v.value, then=Value(v.name)) for v in constants.ORGANISATION_ROLES], + default=Value(constants.ORGANISATION_ROLES.MEMBER.name), + output_field=models.CharField() + ) + ) + + return context | { + 'instance': self.object, + 'members': members + } + + def get_success_url(self): + resolve_target = self.request.GET.get('resolve-target') + if not gen_utils.is_empty_string(resolve_target): + return reverse_lazy(resolve_target) + return reverse_lazy('view_organisation', kwargs={ 'slug': self.object.slug }) + + @transaction.atomic + def form_valid(self, form): + obj = form.save() + + brand = self.request.BRAND_OBJECT + if isinstance(brand, Brand): + obj.brands.add( + brand, + through_defaults={ + 'can_post': False, + 'can_moderate': False + } + ) + + return super(OrganisationManageView, self).form_valid(form) + +class OrganisationView(TemplateView): + template_name = 'clinicalcode/organisation/view.html' + + def dispatch(self, request, *args, **kwargs): + return super(OrganisationView, self).dispatch(request, *args, **kwargs) + + def get_context_data(self, *args, **kwargs): + context = super(OrganisationView, self).get_context_data(*args, **kwargs) + request = self.request + + slug = kwargs.get('slug') + if not gen_utils.is_empty_string(slug): + organisation = Organisation.objects.filter(slug=slug) + if organisation.exists(): + organisation = organisation.first() + + members = organisation.members.through.objects.all() \ + .annotate( + role_name=Case( + *[When(role=v.value, then=Value(v.name)) for v in constants.ORGANISATION_ROLES], + default=Value(constants.ORGANISATION_ROLES.MEMBER.name), + output_field=models.CharField() + ) + ) + + entities = GenericEntity.objects \ + .filter(organisation__id=organisation.id) \ + .all() + + return context | { + 'instance': organisation, + 'members': members, + 'entities': entities + } + + raise Http404('No organisation found') + + def get(self, request, *args, **kwargs): + context = self.get_context_data(*args, **kwargs) + return render(request, self.template_name, context) diff --git a/CodeListLibrary_project/cll/static/scss/components/_banner.scss b/CodeListLibrary_project/cll/static/scss/components/_banner.scss index c177060ff..fdf5e61f9 100644 --- a/CodeListLibrary_project/cll/static/scss/components/_banner.scss +++ b/CodeListLibrary_project/cll/static/scss/components/_banner.scss @@ -77,6 +77,35 @@ } } + &__footer-items { + display: flex; + flex-flow: row wrap; + gap: 1rem 2rem; + margin: 1rem 0 0.5rem 0; + + & > p { + margin: 0; + + & > a, & > a:visited, & > a:hover, & > a:active { + color: col(accent-anchor); + } + } + + .email-icon:before { + @include fontawesome-icon(); + content: '\f0e0'; + margin: 0 0.25rem 0 0; + cursor: initial; + } + + .website-icon:before { + @include fontawesome-icon(); + content: '\f0ac'; + margin: 0 0.25rem 0 0; + cursor: initial; + } + } + &__cards { @include flex-col(); padding: 1rem 0 2rem 0; diff --git a/CodeListLibrary_project/cll/static/scss/components/_tabView.scss b/CodeListLibrary_project/cll/static/scss/components/_tabView.scss index c85c697c5..0d7fed34d 100644 --- a/CodeListLibrary_project/cll/static/scss/components/_tabView.scss +++ b/CodeListLibrary_project/cll/static/scss/components/_tabView.scss @@ -76,3 +76,94 @@ border-top: 1px solid col(accent-washed); } } + +// tab group +// @desc describes a scss-driven tab-based view +// @TODO temp tab group +.tab-group { + display: flex; + flex-wrap: wrap; + position: relative; + max-width: 100%; + padding: 0; + margin: 0.5rem 0; + border-radius: 0.1rem; + background-color: col(bg); + list-style: none; + + .tab { + display: none; + + @for $i from 1 through 5 { + &:checked:nth-of-type(#{$i}) ~ .tab__content:nth-of-type(#{$i}) { + opacity: 1; + position: relative; + top: 0; + z-index: 100; + transform: translateY(0px); + text-shadow: 0 0 0; + } + } + + &:first-of-type:not(:last-of-type) + label { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + &:not(:first-of-type):not(:last-of-type) + label { + border-radius: 0; + } + + &:last-of-type:not(:first-of-type) + label { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + + &:checked + label { + background-color: col(bg); + cursor: default; + border-bottom: 0; + + &:hover { + background-color: col(bg); + } + } + + + label { + flex-grow: 3; + display: block; + height: 50px; + padding: 15px; + border: 1px solid col(accent-washed); + border-radius: 0.1rem 0.1rem 0 0; + text-decoration: none; + color: col(text-darker); + background-color: col(accent-washed); + text-align: center; + user-select: none; + text-align: center; + box-sizing: border-box; + cursor: pointer; + + &:hover { + background-color: col(accent-lightest); + } + } + + &__content { + display: flex; + flex-flow: column nowrap; + position: absolute; + width: 100%; + padding: 0.5rem 0; + z-index: -1; + left: 0; + opacity: 0; + border-radius: 0 0 0.1rem 0.1rem; + border: 1px solid col(accent-washed); + border-top: 0; + background-color: transparent; + transform: translateY(-3px); + } + } +} diff --git a/CodeListLibrary_project/cll/static/scss/pages/organisations.scss b/CodeListLibrary_project/cll/static/scss/pages/organisations.scss new file mode 100644 index 000000000..de0e4f3ad --- /dev/null +++ b/CodeListLibrary_project/cll/static/scss/pages/organisations.scss @@ -0,0 +1,164 @@ +@import '../_methods'; +@import '../_variables'; +@import '../_media'; +@import '../_utils'; + +/// Organisation page +/// @desc Stylesheet relating to organisations +.organisation-page { + @include flex-col(); + align-self: center; + width: var(--phenotype-article-lg-size); + max-width: var(--phenotype-article-lg-size); + + @include media(">desktop", "screen") { + width: var(--phenotype-article-sm-size); + max-width: var(--phenotype-article-sm-size); + } + + @include media(" + {% compress js %} + + {% endcompress %} + + + {% compress css %} + + {% endcompress %} + + + + +
+
+
+
+ {% csrf_token %} + + {% for field in form.visible_fields %} +
+

+ {{ field.label_tag }} + {% if field.field.required %} + * + {% endif %} +

+

Your organisation's {{ field.label_tag|lower }}

+ {{ field }} + {% if field.errors %} + {{ field.errors|striptags }} + {% endif %} +
+ {% endfor %} + +
+ +
+
+
+
+
+ +{% endblock container %} diff --git a/CodeListLibrary_project/cll/templates/clinicalcode/organisation/manage.html b/CodeListLibrary_project/cll/templates/clinicalcode/organisation/manage.html new file mode 100644 index 000000000..ef37a6d93 --- /dev/null +++ b/CodeListLibrary_project/cll/templates/clinicalcode/organisation/manage.html @@ -0,0 +1,145 @@ +{% extends "base.html" %} + +{% load static %} +{% load compress %} +{% load sass_tags %} +{% load cl_extras %} +{% load breadcrumbs %} +{% load entity_renderer %} + +{% block title %}| Manage Organisation {% endblock title %} + +{% block container %} + + {% compress js %} + + {% endcompress %} + + + {% compress css %} + + {% endcompress %} + + + + +
+
+
+
+ {% csrf_token %} + +
+

+ Name + * +

+

Your organisation's name

+ {{ form.name }} +
+
+

+ Description +

+

Describe your organisation

+ {{ form.description }} +
+
+

+ Email +

+

Your organisation's contact email

+ {{ form.email }} +
+
+

+ Website +

+

Your organisation's website

+ {{ form.website }} +
+ + +
+ +
+
+ +
+
+

+ Manage Access +

+
+ +
+
+
+

+ Manage your organisation members +

+
+ + + + + + +
+
+

+ Your organisation has no members yet +

+
+ +
+ +
+
+ +
+

TODO

+
+
+
+ + +
+
+
+
+ +{% endblock container %} diff --git a/CodeListLibrary_project/cll/templates/clinicalcode/organisation/view.html b/CodeListLibrary_project/cll/templates/clinicalcode/organisation/view.html new file mode 100644 index 000000000..a4563966d --- /dev/null +++ b/CodeListLibrary_project/cll/templates/clinicalcode/organisation/view.html @@ -0,0 +1,92 @@ +{% extends "base.html" %} + +{% load static %} +{% load compress %} +{% load sass_tags %} +{% load cl_extras %} +{% load breadcrumbs %} +{% load entity_renderer %} + +{% block title %}| Create Organisation {% endblock title %} + +{% block container %} + + {% compress js %} + + {% endcompress %} + + + {% compress css %} + + {% endcompress %} + + + + +
+
+
+
+
+

Contributors

+
+
+
+ {{instance.owner.username}} +
+ {% for member in members %} +
+ {{member.user.username}} +
+ {% endfor %} +
+
+
+
+

Collection

+
+
+ {% for entity in entities %} +
{{entity.name}}
+ {% endfor %} +
+
+
+
+
+ +{% endblock container %} From 31cf884d80b1ba3d847a5cb37d31cc789c119283 Mon Sep 17 00:00:00 2001 From: Arthur zinnurov Date: Tue, 3 Dec 2024 15:44:21 +0000 Subject: [PATCH 002/164] initial organisation_checks --- .../clinicalcode/entity_utils/publish_utils.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py index f843c0f10..6a0343d7a 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py @@ -151,6 +151,12 @@ def check_entity_to_publish(request, pk, entity_history_id): } return checks +def check_organisation_authorities(request,entity,entity_class): + organisation_checks = {} + + return organisation_checks + + def check_child_validity(request, entity, entity_class, check_publication_validity=False): """ Wrapper for 'check_children' method authored by @zinnurov From f85d4b5922dd5a09ae7753b744db93343aabc41e Mon Sep 17 00:00:00 2001 From: Arthur zinnurov Date: Mon, 20 Jan 2025 13:02:45 +0000 Subject: [PATCH 003/164] organisisation permission utils --- .../clinicalcode/entity_utils/permission_utils.py | 9 +++++++++ CodeListLibrary_project/clinicalcode/views/Publish.py | 2 ++ 2 files changed, 11 insertions(+) diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py index 115b28e0b..34c47e575 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py @@ -85,6 +85,13 @@ def has_member_access(user, entity, permissions): return False +def has_org_member_edit(user,organisation,roles): + if organisation.user_id == user.id: + if organisation.role in roles: + return True + else: + return False + def is_publish_status(entity, status): """ Checks the publication status of an entity @@ -1175,6 +1182,8 @@ def can_user_edit_entity(request, entity_id, entity_history_id=None): if has_member_access(user, live_entity, [GROUP_PERMISSIONS.EDIT]): is_allowed_to_edit = True + print(is_brand_accessible(request,entity_id)) + if is_allowed_to_edit: if not is_brand_accessible(request, entity_id): is_allowed_to_edit = False diff --git a/CodeListLibrary_project/clinicalcode/views/Publish.py b/CodeListLibrary_project/clinicalcode/views/Publish.py index 1d9c35cd5..6c10a7910 100644 --- a/CodeListLibrary_project/clinicalcode/views/Publish.py +++ b/CodeListLibrary_project/clinicalcode/views/Publish.py @@ -212,6 +212,7 @@ def get(self, request, pk, history_id): """ #get additional checks in case if ws is deleted/approved etc checks = publish_utils.check_entity_to_publish(self.request, pk, history_id) + print(checks) checks['entity_history_id'] = history_id checks['entity_id'] = pk @@ -228,6 +229,7 @@ def post(self,request, pk, history_id): """ is_published = permission_utils.check_if_published(GenericEntity, pk, history_id) checks = publish_utils.check_entity_to_publish(self.request, pk, history_id) + if not is_published: checks = publish_utils.check_entity_to_publish(self.request, pk, history_id) From 5eb17898f65cd4db434cdcfe50fad6a7eee9d2b6 Mon Sep 17 00:00:00 2001 From: Arthur zinnurov Date: Mon, 20 Jan 2025 13:03:20 +0000 Subject: [PATCH 004/164] migration merge --- .../migrations/0121_merge_20250113_1345.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 CodeListLibrary_project/clinicalcode/migrations/0121_merge_20250113_1345.py diff --git a/CodeListLibrary_project/clinicalcode/migrations/0121_merge_20250113_1345.py b/CodeListLibrary_project/clinicalcode/migrations/0121_merge_20250113_1345.py new file mode 100644 index 000000000..ff4e4c956 --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/migrations/0121_merge_20250113_1345.py @@ -0,0 +1,14 @@ +# Generated by Django 5.1.2 on 2025-01-13 13:45 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('clinicalcode', '0118_brand_org_user_managed_and_more'), + ('clinicalcode', '0120_ontologytag_clinicalcod_type_id_c68d0b_idx_and_more'), + ] + + operations = [ + ] From 022c397171259e9aab8337221071c1ea6f49602a Mon Sep 17 00:00:00 2001 From: Arthur zinnurov Date: Mon, 27 Jan 2025 15:32:40 +0000 Subject: [PATCH 005/164] some test of permission publish --- .../clinicalcode/entity_utils/publish_utils.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py index 6a0343d7a..6b46c403f 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py @@ -4,6 +4,8 @@ from django.contrib.auth.models import User from django.urls import reverse, reverse_lazy from django.template.loader import render_to_string +from clinicalcode.entity_utils import model_utils +from clinicalcode.models import Organisation from clinicalcode.tasks import send_review_email from clinicalcode.entity_utils import constants, permission_utils, entity_db_utils @@ -131,6 +133,8 @@ def check_entity_to_publish(request, pk, entity_history_id): if not entity_has_data and entity_class == "Workingset": allow_to_publish = False + check_organisation_authorities(request,entity, entity_class) + checks = { 'entity_type': entity_class, 'name': entity_ver.name, @@ -154,6 +158,9 @@ def check_entity_to_publish(request, pk, entity_history_id): def check_organisation_authorities(request,entity,entity_class): organisation_checks = {} + organisation = permission_utils.get_organisation_info(request.user) + print(permission_utils.has_org_authority(request.user,organisation)) + return organisation_checks From 5baeb1c795452172798c5beba64eaf43a8416b8c Mon Sep 17 00:00:00 2001 From: Arthur zinnurov Date: Mon, 27 Jan 2025 15:32:51 +0000 Subject: [PATCH 006/164] adding organisation permissions --- .../entity_utils/permission_utils.py | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py index 34c47e575..e5641acd8 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py @@ -6,6 +6,7 @@ from functools import wraps +from ..models.Organisation import OrganisationAuthority, OrganisationMembership from . import model_utils, gen_utils from ..models.Brand import Brand from ..models.Concept import Concept @@ -13,7 +14,7 @@ from ..models.GenericEntity import GenericEntity from ..models.PublishedConcept import PublishedConcept from ..models.PublishedGenericEntity import PublishedGenericEntity -from .constants import APPROVAL_STATUS, GROUP_PERMISSIONS, WORLD_ACCESS_PERMISSIONS +from .constants import APPROVAL_STATUS, GROUP_PERMISSIONS, WORLD_ACCESS_PERMISSIONS, ORGANISATION_ROLES """ Permission decorators """ @@ -85,13 +86,28 @@ def has_member_access(user, entity, permissions): return False -def has_org_member_edit(user,organisation,roles): - if organisation.user_id == user.id: - if organisation.role in roles: +def has_org_member(user): + organisation_memebership = model_utils.try_get_instance(OrganisationMembership, user_id=user.id) + if organisation_memebership.role in ORGANISATION_ROLES: return True else: return False +def has_org_authority(user,organisation): + if has_org_member(user): + authority = model_utils.try_get_instance(OrganisationAuthority, organisation_id=organisation.id) + return {"can_moderate":authority.can_moderate, "can_post": authority.can_post} + else: + return False + +def get_organisation_info(user): + if has_org_member(user): + organisation_memebership = model_utils.try_get_instance(OrganisationMembership, user_id=user.id) + return organisation_memebership.organisation + else: + return None + + def is_publish_status(entity, status): """ Checks the publication status of an entity @@ -1182,8 +1198,6 @@ def can_user_edit_entity(request, entity_id, entity_history_id=None): if has_member_access(user, live_entity, [GROUP_PERMISSIONS.EDIT]): is_allowed_to_edit = True - print(is_brand_accessible(request,entity_id)) - if is_allowed_to_edit: if not is_brand_accessible(request, entity_id): is_allowed_to_edit = False From 775f7d9658c89b983215316bcea0ac46053aee5d Mon Sep 17 00:00:00 2001 From: Arthur zinnurov Date: Tue, 28 Jan 2025 16:24:38 +0000 Subject: [PATCH 007/164] getting the right role --- .../entity_utils/permission_utils.py | 19 ++++++++++++++++++- .../entity_utils/publish_utils.py | 1 + 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py index e5641acd8..ab6541a44 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py @@ -106,7 +106,24 @@ def get_organisation_info(user): return organisation_memebership.organisation else: return None - + +def get_organisation_role(user): + + org_membership = model_utils.try_get_instance(OrganisationMembership, user_id=user.id) + + match org_membership.role: + case ORGANISATION_ROLES.ADMIN: + return ORGANISATION_ROLES.ADMIN + case ORGANISATION_ROLES.MODERATOR: + return ORGANISATION_ROLES.MODERATOR + case ORGANISATION_ROLES.EDITOR: + return ORGANISATION_ROLES.EDITOR + case ORGANISATION_ROLES.MEMBER: + return ORGANISATION_ROLES.MEMBER + case _: + return -1 + + def is_publish_status(entity, status): """ diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py index 6b46c403f..4394664d5 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py @@ -160,6 +160,7 @@ def check_organisation_authorities(request,entity,entity_class): organisation = permission_utils.get_organisation_info(request.user) print(permission_utils.has_org_authority(request.user,organisation)) + print(permission_utils.get_organisation_role(request.user)) return organisation_checks From 64ec18412bc05bcd8321358944da52c867d42153 Mon Sep 17 00:00:00 2001 From: Arthur zinnurov Date: Thu, 20 Feb 2025 11:30:05 +0000 Subject: [PATCH 008/164] adding the brand check --- .../clinicalcode/entity_utils/permission_utils.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py index ab6541a44..6ad32c86a 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py @@ -93,10 +93,12 @@ def has_org_member(user): else: return False -def has_org_authority(user,organisation): - if has_org_member(user): +def has_org_authority(request,organisation): + if has_org_member(request.user): authority = model_utils.try_get_instance(OrganisationAuthority, organisation_id=organisation.id) - return {"can_moderate":authority.can_moderate, "can_post": authority.can_post} + brand = model_utils.try_get_brand(request) + org_user_managed = brand.org_user_managed if brand else None + return {"can_moderate":authority.can_moderate, "can_post": authority.can_post, "org_user_managed": org_user_managed } else: return False From b17e71c9b297e1bae5a0bb55b664480d6f1840de Mon Sep 17 00:00:00 2001 From: Arthur zinnurov Date: Thu, 20 Feb 2025 16:51:59 +0000 Subject: [PATCH 009/164] adding brand checks and allowed to publish cheks --- .../clinicalcode/entity_utils/publish_utils.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py index 4394664d5..67000a666 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py @@ -159,8 +159,17 @@ def check_organisation_authorities(request,entity,entity_class): organisation_checks = {} organisation = permission_utils.get_organisation_info(request.user) - print(permission_utils.has_org_authority(request.user,organisation)) - print(permission_utils.get_organisation_role(request.user)) + organisation_permissions = permission_utils.has_org_authority(request,organisation) + organisation_user_role = permission_utils.get_organisation_role(request.user) + print(organisation_permissions) + + if organisation_permissions["org_user_managed"] is None or False: + return False + + if organisation_permissions["can_moderate"]: + if organisation_user_role.value >= 1: + organisation_checks["allowed_to_publish"] = True + print(organisation_checks) return organisation_checks From 67fe179776b3a237b38d4f6fe0a997d3bfac3db3 Mon Sep 17 00:00:00 2001 From: Arthur zinnurov Date: Fri, 28 Feb 2025 12:45:12 +0000 Subject: [PATCH 010/164] removed print --- CodeListLibrary_project/clinicalcode/views/Publish.py | 1 - 1 file changed, 1 deletion(-) diff --git a/CodeListLibrary_project/clinicalcode/views/Publish.py b/CodeListLibrary_project/clinicalcode/views/Publish.py index 6c10a7910..d5eae9cfd 100644 --- a/CodeListLibrary_project/clinicalcode/views/Publish.py +++ b/CodeListLibrary_project/clinicalcode/views/Publish.py @@ -212,7 +212,6 @@ def get(self, request, pk, history_id): """ #get additional checks in case if ws is deleted/approved etc checks = publish_utils.check_entity_to_publish(self.request, pk, history_id) - print(checks) checks['entity_history_id'] = history_id checks['entity_id'] = pk From ddb3fcf7eb9d5f1649cc7fd52ece9f70acefc0b3 Mon Sep 17 00:00:00 2001 From: Arthur zinnurov Date: Fri, 28 Feb 2025 12:45:38 +0000 Subject: [PATCH 011/164] adding the conjestion of two dictionaries of permissions --- .../clinicalcode/entity_utils/publish_utils.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py index 67000a666..1c77e9358 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py @@ -133,7 +133,8 @@ def check_entity_to_publish(request, pk, entity_history_id): if not entity_has_data and entity_class == "Workingset": allow_to_publish = False - check_organisation_authorities(request,entity, entity_class) + organisation_checks = check_organisation_authorities(request,entity, entity_class) + checks = { 'entity_type': entity_class, @@ -152,7 +153,8 @@ def check_entity_to_publish(request, pk, entity_history_id): 'is_latest_pending_version': is_latest_pending_version, 'all_are_published': all_are_published, 'all_not_deleted': all_not_deleted - } + } | organisation_checks + print(checks) return checks def check_organisation_authorities(request,entity,entity_class): @@ -161,7 +163,6 @@ def check_organisation_authorities(request,entity,entity_class): organisation = permission_utils.get_organisation_info(request.user) organisation_permissions = permission_utils.has_org_authority(request,organisation) organisation_user_role = permission_utils.get_organisation_role(request.user) - print(organisation_permissions) if organisation_permissions["org_user_managed"] is None or False: return False @@ -169,7 +170,9 @@ def check_organisation_authorities(request,entity,entity_class): if organisation_permissions["can_moderate"]: if organisation_user_role.value >= 1: organisation_checks["allowed_to_publish"] = True - print(organisation_checks) + organisation_checks["is_moderator"] = True + return organisation_checks + return organisation_checks From bb82d15c450aad45a2d0a561def4adde4b7ce824 Mon Sep 17 00:00:00 2001 From: Arthur zinnurov Date: Fri, 28 Feb 2025 12:46:15 +0000 Subject: [PATCH 012/164] using checks from publish utils --- .../clinicalcode/templatetags/entity_publish_renderer.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/CodeListLibrary_project/clinicalcode/templatetags/entity_publish_renderer.py b/CodeListLibrary_project/clinicalcode/templatetags/entity_publish_renderer.py index c6b277d6e..d11e60fd5 100644 --- a/CodeListLibrary_project/clinicalcode/templatetags/entity_publish_renderer.py +++ b/CodeListLibrary_project/clinicalcode/templatetags/entity_publish_renderer.py @@ -2,7 +2,9 @@ from django.utils.translation import gettext_lazy as _ from django.urls import reverse -from ..entity_utils import permission_utils, constants + + +from ..entity_utils import permission_utils, publish_utils, constants register = template.Library() @@ -39,9 +41,10 @@ def render_errors_approval(context, *args, **kwargs): @register.inclusion_tag('components/publish_request/publish_button.html', takes_context=True, name='render_publish_button') def render_publish_button(context, *args, **kwargs): - user_is_moderator = permission_utils.is_member(context['request'].user, "Moderators") + publish_checks = publish_utils.check_entity_to_publish(context['request'], context['entity'].id, context['entity'].history_id) + user_is_moderator = publish_checks['is_moderator'] user_entity_access = permission_utils.can_user_edit_entity(context['request'], context['entity'].id) #context['entity'].owner == context['request'].user - user_is_publisher = user_entity_access and permission_utils.is_member(context['request'].user, "publishers") + user_is_publisher = publish_checks['is_publisher'] button_context = { 'url_decline': reverse('generic_entity_decline', kwargs={'pk': context['entity'].id, 'history_id': context['entity'].history_id}), From 4d8a6ae86156178eba4e9d9552f7e738444ddfd1 Mon Sep 17 00:00:00 2001 From: Arthur zinnurov Date: Fri, 28 Feb 2025 12:46:27 +0000 Subject: [PATCH 013/164] some code refactor --- .../clinicalcode/entity_utils/permission_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py index 6ad32c86a..249c58074 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py @@ -89,7 +89,7 @@ def has_member_access(user, entity, permissions): def has_org_member(user): organisation_memebership = model_utils.try_get_instance(OrganisationMembership, user_id=user.id) if organisation_memebership.role in ORGANISATION_ROLES: - return True + return True else: return False From 4dbb6ab1daf9760b7a8107c385da6cf16057f17f Mon Sep 17 00:00:00 2001 From: Arthur zinnurov Date: Fri, 28 Feb 2025 16:16:24 +0000 Subject: [PATCH 014/164] some condition changes --- .../entity_utils/publish_utils.py | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py index 1c77e9358..c4823a690 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py @@ -154,7 +154,6 @@ def check_entity_to_publish(request, pk, entity_history_id): 'all_are_published': all_are_published, 'all_not_deleted': all_not_deleted } | organisation_checks - print(checks) return checks def check_organisation_authorities(request,entity,entity_class): @@ -164,15 +163,17 @@ def check_organisation_authorities(request,entity,entity_class): organisation_permissions = permission_utils.has_org_authority(request,organisation) organisation_user_role = permission_utils.get_organisation_role(request.user) - if organisation_permissions["org_user_managed"] is None or False: - return False - - if organisation_permissions["can_moderate"]: - if organisation_user_role.value >= 1: - organisation_checks["allowed_to_publish"] = True - organisation_checks["is_moderator"] = True - return organisation_checks - + if isinstance(organisation_permissions,dict): + if organisation_permissions["can_moderate"]: + if organisation_user_role.value >= 1: + organisation_checks["allowed_to_publish"] = True + organisation_checks["is_moderator"] = True + else: + organisation_checks["allowed_to_publish"] = False + organisation_checks["is_moderator"] = False + else: + return organisation_checks + return organisation_checks From 1cc7e2384c0cbbe7e688d2fce84179f1480f674c Mon Sep 17 00:00:00 2001 From: Arthur zinnurov Date: Fri, 28 Feb 2025 16:24:58 +0000 Subject: [PATCH 015/164] adding more checks --- .../entity_utils/permission_utils.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py index 249c58074..92093c2c7 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py @@ -88,10 +88,13 @@ def has_member_access(user, entity, permissions): def has_org_member(user): organisation_memebership = model_utils.try_get_instance(OrganisationMembership, user_id=user.id) - if organisation_memebership.role in ORGANISATION_ROLES: - return True + if organisation_memebership is not None: + if organisation_memebership.role in ORGANISATION_ROLES: + return True + else: + return False else: - return False + return False def has_org_authority(request,organisation): if has_org_member(request.user): @@ -110,9 +113,11 @@ def get_organisation_info(user): return None def get_organisation_role(user): - org_membership = model_utils.try_get_instance(OrganisationMembership, user_id=user.id) + if org_membership is None: + return None + match org_membership.role: case ORGANISATION_ROLES.ADMIN: return ORGANISATION_ROLES.ADMIN @@ -1220,6 +1225,10 @@ def can_user_edit_entity(request, entity_id, entity_history_id=None): if is_allowed_to_edit: if not is_brand_accessible(request, entity_id): is_allowed_to_edit = False + + print(has_org_authority(request,get_organisation_info(request.user))) + print(get_organisation_role(user)) + return is_allowed_to_edit From 8d2f44753717110377a23912d0bc5a38b699264d Mon Sep 17 00:00:00 2001 From: Arthur zinnurov Date: Mon, 3 Mar 2025 17:10:47 +0000 Subject: [PATCH 016/164] adding organisation check alongside if user exists there --- .../clinicalcode/entity_utils/permission_utils.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py index 92093c2c7..7eba4fa4c 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py @@ -1225,10 +1225,21 @@ def can_user_edit_entity(request, entity_id, entity_history_id=None): if is_allowed_to_edit: if not is_brand_accessible(request, entity_id): is_allowed_to_edit = False - + + org_info = has_org_authority(request,get_organisation_info(request.user)) + if isinstance(org_info,dict): + if org_info['org_user_managed'] and has_org_member(request.user): + if get_organisation_role(user).value != 0: + is_allowed_to_edit = True + else: + is_allowed_to_edit = False + + + print(has_org_member(request.user)) print(has_org_authority(request,get_organisation_info(request.user))) print(get_organisation_role(user)) + print('is_allowed to edit' + str(is_allowed_to_edit)) return is_allowed_to_edit From 298172d28dd11c84ba45024d4ab77f85d6ae0a32 Mon Sep 17 00:00:00 2001 From: Arthur zinnurov Date: Mon, 3 Mar 2025 18:03:34 +0000 Subject: [PATCH 017/164] fix the moderator issue --- .../clinicalcode/entity_utils/publish_utils.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py index c4823a690..f14995c46 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py @@ -154,6 +154,8 @@ def check_entity_to_publish(request, pk, entity_history_id): 'all_are_published': all_are_published, 'all_not_deleted': all_not_deleted } | organisation_checks + + print(checks) return checks def check_organisation_authorities(request,entity,entity_class): @@ -164,13 +166,17 @@ def check_organisation_authorities(request,entity,entity_class): organisation_user_role = permission_utils.get_organisation_role(request.user) if isinstance(organisation_permissions,dict): + + organisation_checks["allowed_to_publish"] = False + organisation_checks["is_moderator"] = False + if organisation_permissions["can_moderate"]: if organisation_user_role.value >= 1: organisation_checks["allowed_to_publish"] = True - organisation_checks["is_moderator"] = True - else: - organisation_checks["allowed_to_publish"] = False - organisation_checks["is_moderator"] = False + + if organisation_user_role.value == 2: + organisation_checks["is_moderator"] = True + else: return organisation_checks From a847e363862f943b7bb9972a6febce1321da64e4 Mon Sep 17 00:00:00 2001 From: Arthur zinnurov Date: Mon, 3 Mar 2025 18:03:42 +0000 Subject: [PATCH 018/164] hide button --- .../clinicalcode/templatetags/entity_publish_renderer.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CodeListLibrary_project/clinicalcode/templatetags/entity_publish_renderer.py b/CodeListLibrary_project/clinicalcode/templatetags/entity_publish_renderer.py index d11e60fd5..c358ebc40 100644 --- a/CodeListLibrary_project/clinicalcode/templatetags/entity_publish_renderer.py +++ b/CodeListLibrary_project/clinicalcode/templatetags/entity_publish_renderer.py @@ -134,5 +134,8 @@ def render_publish_button(context, *args, **kwargs): }) else: button_context.update({ 'pub_btn_hidden': True }) + + else: + button_context.update({ 'pub_btn_hidden': True }) - return button_context + return button_context From f142e2771aebfaf668ea20f0ab89d9bf41ad6186 Mon Sep 17 00:00:00 2001 From: Arthur zinnurov Date: Tue, 4 Mar 2025 13:16:09 +0000 Subject: [PATCH 019/164] deleted print --- .../clinicalcode/entity_utils/permission_utils.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py index 7eba4fa4c..d7822cb72 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py @@ -1235,12 +1235,6 @@ def can_user_edit_entity(request, entity_id, entity_history_id=None): is_allowed_to_edit = False - print(has_org_member(request.user)) - print(has_org_authority(request,get_organisation_info(request.user))) - print(get_organisation_role(user)) - - print('is_allowed to edit' + str(is_allowed_to_edit)) - return is_allowed_to_edit def has_derived_edit_access(request, entity_id, entity_history_id=None): From 78e4b0faafd1ab23495cbeff2ec48b78ca80c734 Mon Sep 17 00:00:00 2001 From: Arthur zinnurov Date: Tue, 4 Mar 2025 13:16:24 +0000 Subject: [PATCH 020/164] fix conditions --- .../entity_utils/publish_utils.py | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py index f14995c46..c690101d4 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py @@ -133,7 +133,7 @@ def check_entity_to_publish(request, pk, entity_history_id): if not entity_has_data and entity_class == "Workingset": allow_to_publish = False - organisation_checks = check_organisation_authorities(request,entity, entity_class) + organisation_checks = check_organisation_authorities(request) checks = { @@ -158,7 +158,7 @@ def check_entity_to_publish(request, pk, entity_history_id): print(checks) return checks -def check_organisation_authorities(request,entity,entity_class): +def check_organisation_authorities(request): organisation_checks = {} organisation = permission_utils.get_organisation_info(request.user) @@ -166,16 +166,16 @@ def check_organisation_authorities(request,entity,entity_class): organisation_user_role = permission_utils.get_organisation_role(request.user) if isinstance(organisation_permissions,dict): - - organisation_checks["allowed_to_publish"] = False - organisation_checks["is_moderator"] = False - - if organisation_permissions["can_moderate"]: - if organisation_user_role.value >= 1: + if organisation_permissions['org_user_managed']: + + organisation_checks["allowed_to_publish"] = False + organisation_checks["is_moderator"] = False + + if organisation_permissions.get("can_post", False) and organisation_user_role.value >= 1: organisation_checks["allowed_to_publish"] = True - - if organisation_user_role.value == 2: - organisation_checks["is_moderator"] = True + + if organisation_permissions.get("can_moderate", False) and organisation_user_role.value >= 2: + organisation_checks["is_moderator"] = True else: return organisation_checks From 70b720f8cf85a82fc7499088fa228f09e826bb0b Mon Sep 17 00:00:00 2001 From: Arthur zinnurov Date: Thu, 6 Mar 2025 15:53:53 +0000 Subject: [PATCH 021/164] using allowed to publish instead of user edi --- .../clinicalcode/templatetags/entity_publish_renderer.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/CodeListLibrary_project/clinicalcode/templatetags/entity_publish_renderer.py b/CodeListLibrary_project/clinicalcode/templatetags/entity_publish_renderer.py index c358ebc40..00c8b200c 100644 --- a/CodeListLibrary_project/clinicalcode/templatetags/entity_publish_renderer.py +++ b/CodeListLibrary_project/clinicalcode/templatetags/entity_publish_renderer.py @@ -43,9 +43,10 @@ def render_errors_approval(context, *args, **kwargs): def render_publish_button(context, *args, **kwargs): publish_checks = publish_utils.check_entity_to_publish(context['request'], context['entity'].id, context['entity'].history_id) user_is_moderator = publish_checks['is_moderator'] - user_entity_access = permission_utils.can_user_edit_entity(context['request'], context['entity'].id) #context['entity'].owner == context['request'].user user_is_publisher = publish_checks['is_publisher'] - + user_allowed_publish = publish_checks['allowed_to_publish'] + user_entity_access = permission_utils.can_user_edit_entity(context['request'], context['entity'].id) #context['entity'].owner == context['request'].user + button_context = { 'url_decline': reverse('generic_entity_decline', kwargs={'pk': context['entity'].id, 'history_id': context['entity'].history_id}), 'url_redirect': reverse('entity_history_detail', kwargs={'pk': context['entity'].id, 'history_id': context['entity'].history_id}), @@ -85,7 +86,7 @@ def render_publish_button(context, *args, **kwargs): 'title': "Deleted phenotypes cannot be published!" }) return button_context - elif user_entity_access: + elif user_allowed_publish: if not context["is_lastapproved"] and (context["approval_status"] is None or context["approval_status"] == constants.APPROVAL_STATUS.ANY) and user_entity_access and not context["live_ver_is_deleted"]: if user_is_publisher: button_context.update({'class_modal':"primary-btn bold dropdown-btn__label", From adb120957ca60779913785fc7b4d4e050919e2b5 Mon Sep 17 00:00:00 2001 From: Arthur zinnurov Date: Thu, 6 Mar 2025 15:54:18 +0000 Subject: [PATCH 022/164] adding publish for a moderator --- .../clinicalcode/entity_utils/publish_utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py index c690101d4..0533b5886 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py @@ -176,6 +176,7 @@ def check_organisation_authorities(request): if organisation_permissions.get("can_moderate", False) and organisation_user_role.value >= 2: organisation_checks["is_moderator"] = True + organisation_checks["allowed_to_publish"] = True else: return organisation_checks From de9c3d8d552bda3fce983445c2b9c6a00ea31d1f Mon Sep 17 00:00:00 2001 From: Arthur zinnurov Date: Fri, 7 Mar 2025 17:27:28 +0000 Subject: [PATCH 023/164] adding the organisation user checks when brand is accessd --- .../entity_utils/permission_utils.py | 66 +++++++++---------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py index d7822cb72..fff84209d 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py @@ -6,7 +6,7 @@ from functools import wraps -from ..models.Organisation import OrganisationAuthority, OrganisationMembership +from ..models.Organisation import Organisation, OrganisationAuthority, OrganisationMembership from . import model_utils, gen_utils from ..models.Brand import Brand from ..models.Concept import Concept @@ -86,34 +86,8 @@ def has_member_access(user, entity, permissions): return False -def has_org_member(user): - organisation_memebership = model_utils.try_get_instance(OrganisationMembership, user_id=user.id) - if organisation_memebership is not None: - if organisation_memebership.role in ORGANISATION_ROLES: - return True - else: - return False - else: - return False - -def has_org_authority(request,organisation): - if has_org_member(request.user): - authority = model_utils.try_get_instance(OrganisationAuthority, organisation_id=organisation.id) - brand = model_utils.try_get_brand(request) - org_user_managed = brand.org_user_managed if brand else None - return {"can_moderate":authority.can_moderate, "can_post": authority.can_post, "org_user_managed": org_user_managed } - else: - return False - -def get_organisation_info(user): - if has_org_member(user): - organisation_memebership = model_utils.try_get_instance(OrganisationMembership, user_id=user.id) - return organisation_memebership.organisation - else: - return None - -def get_organisation_role(user): - org_membership = model_utils.try_get_instance(OrganisationMembership, user_id=user.id) +def get_organisation_role(user, organisation): + org_membership = model_utils.try_get_instance(OrganisationMembership, user_id=user.id, organisation_id=organisation.id) if org_membership is None: return None @@ -130,6 +104,31 @@ def get_organisation_role(user): case _: return -1 +def has_org_member(user, organisation): + org_membership = model_utils.try_get_instance(OrganisationMembership, user_id=user.id, organisation_id=organisation.id) + return org_membership is not None and org_membership.role in ORGANISATION_ROLES + +def has_org_authority(request,organisation): + if has_org_member(request.user,organisation): + authority = model_utils.try_get_instance(OrganisationAuthority, organisation_id=organisation.id) + brand = model_utils.try_get_brand(request) + org_user_managed = brand.org_user_managed if brand else None + return {"can_moderate":authority.can_moderate, "can_post": authority.can_post, "org_user_managed": org_user_managed } + else: + return False + +def get_organisation(request): + brand = model_utils.try_get_brand(request) + if brand: + organisation = model_utils.try_get_instance(Organisation,slug=brand.name.lower()) + if organisation: + return organisation + else: + return None + + + + def is_publish_status(entity, status): @@ -1226,10 +1225,11 @@ def can_user_edit_entity(request, entity_id, entity_history_id=None): if not is_brand_accessible(request, entity_id): is_allowed_to_edit = False - org_info = has_org_authority(request,get_organisation_info(request.user)) - if isinstance(org_info,dict): - if org_info['org_user_managed'] and has_org_member(request.user): - if get_organisation_role(user).value != 0: + organisation = get_organisation(request) + if organisation: + organisation_permissions = has_org_authority(request, organisation) + if isinstance(organisation_permissions, dict) and organisation_permissions.get('org_user_managed'): + if get_organisation_role(user, organisation) != ORGANISATION_ROLES.MEMBER: is_allowed_to_edit = True else: is_allowed_to_edit = False From 6db8f4bab0924e3b6139974b1b97955c15df0898 Mon Sep 17 00:00:00 2001 From: Arthur zinnurov Date: Fri, 7 Mar 2025 17:27:37 +0000 Subject: [PATCH 024/164] applied changes --- .../clinicalcode/entity_utils/publish_utils.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py index 0533b5886..4b8400b59 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py @@ -161,12 +161,17 @@ def check_entity_to_publish(request, pk, entity_history_id): def check_organisation_authorities(request): organisation_checks = {} - organisation = permission_utils.get_organisation_info(request.user) - organisation_permissions = permission_utils.has_org_authority(request,organisation) - organisation_user_role = permission_utils.get_organisation_role(request.user) + organisation = permission_utils.get_organisation(request) + + if organisation: + organisation_permissions = permission_utils.has_org_authority(request,organisation) + organisation_user_role = permission_utils.get_organisation_role(request.user,organisation) + else: + return organisation_checks if isinstance(organisation_permissions,dict): if organisation_permissions['org_user_managed']: + #Todo Fix the bug if moderator has 2 groups unless has to organisations and check if it from the same org organisation_checks["allowed_to_publish"] = False organisation_checks["is_moderator"] = False From b49ac2ea2f4635d25de6a696ce552208d5b5f098 Mon Sep 17 00:00:00 2001 From: JackScanlon Date: Mon, 10 Mar 2025 22:04:50 +0000 Subject: [PATCH 025/164] fix!: data sources, breaking change as datasource_id now refers to internal HDRUK ID fix: preparatory phenoflow mapping from id to uuid ref feat: impl. HDRN data assets --- .../entity_utils/entity_db_utils.py | 2 +- .../clinicalcode/models/DataSource.py | 4 +- .../clinicalcode/models/HDRNDataAsset.py | 40 +++ .../clinicalcode/models/HDRNDataCategory.py | 21 ++ .../clinicalcode/models/HDRNSite.py | 15 ++ .../clinicalcode/views/Admin.py | 243 +++++++++++++----- .../.processing/hdrn/hdrn_data_assets.py | 110 ++++++++ .../.processing/phenoflow/phenoflow_map.py | 104 ++++++++ .../.processing/phenoflow/phenoflow_query.sql | 29 +++ 9 files changed, 495 insertions(+), 73 deletions(-) create mode 100644 CodeListLibrary_project/clinicalcode/models/HDRNDataAsset.py create mode 100644 CodeListLibrary_project/clinicalcode/models/HDRNDataCategory.py create mode 100644 CodeListLibrary_project/clinicalcode/models/HDRNSite.py create mode 100644 docs/sql-scripts/.processing/hdrn/hdrn_data_assets.py create mode 100644 docs/sql-scripts/.processing/phenoflow/phenoflow_map.py create mode 100644 docs/sql-scripts/.processing/phenoflow/phenoflow_query.sql diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/entity_db_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/entity_db_utils.py index feddbc9a1..1af073f4a 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/entity_db_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/entity_db_utils.py @@ -483,7 +483,7 @@ def get_entity_full_template_data(entity_record, template_id, return_queryset_as entity_data_sources = fields_data[field_name]['value'] if entity_data_sources: if return_queryset_as_list: - data_sources = list(DataSource.objects.filter(pk__in=entity_data_sources).values('datasource_id', 'name', 'url')) + data_sources = list(DataSource.objects.filter(pk__in=entity_data_sources).values('id', 'name', 'url')) else: data_sources = DataSource.objects.filter(pk__in=entity_data_sources) fields_data[field_name]['value'] = data_sources diff --git a/CodeListLibrary_project/clinicalcode/models/DataSource.py b/CodeListLibrary_project/clinicalcode/models/DataSource.py index a962097f0..edd6d3fa0 100644 --- a/CodeListLibrary_project/clinicalcode/models/DataSource.py +++ b/CodeListLibrary_project/clinicalcode/models/DataSource.py @@ -1,6 +1,6 @@ -from django.contrib.auth.models import User from django.db import models from simple_history.models import HistoricalRecords +from django.contrib.auth.models import User from clinicalcode.models.TimeStampedModel import TimeStampedModel @@ -22,7 +22,7 @@ class DataSource(TimeStampedModel): on_delete=models.SET_NULL, null=True, related_name="data_source_updated") - datasource_id = models.IntegerField(unique=True, null=True) + datasource_id = models.IntegerField(unique=False, null=True) history = HistoricalRecords() diff --git a/CodeListLibrary_project/clinicalcode/models/HDRNDataAsset.py b/CodeListLibrary_project/clinicalcode/models/HDRNDataAsset.py new file mode 100644 index 000000000..35ebcc4e9 --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/models/HDRNDataAsset.py @@ -0,0 +1,40 @@ +from django.db import models +from django.contrib.postgres.fields import ArrayField +from django.contrib.postgres.indexes import GinIndex + +from clinicalcode.models.HDRNSite import HDRNSite +from clinicalcode.models.TimeStampedModel import TimeStampedModel + +class HDRNDataAsset(TimeStampedModel): + """ + HDRN Inventory Data Assets + """ + # Top-level + id = models.AutoField(primary_key=True) + name = models.CharField(max_length=512, unique=False, null=False, blank=False) + description = models.TextField(null=True, blank=True) + + # Reference + hdrn_id = models.IntegerField(null=True, blank=True) + hdrn_uuid = models.UUIDField(primary_key=False, null=True, blank=True) + + # Metadata + site = models.ForeignKey(HDRNSite, on_delete=models.SET_NULL, null=True, related_name='data_assets') + scope = models.CharField(max_length=256, unique=False, null=True, blank=True) + region = models.CharField(max_length=2048, unique=False, null=True, blank=True) + purpose = models.TextField(null=True, blank=True) + + collection_period = models.TextField(null=True, blank=True) + + data_level = models.CharField(max_length=256, unique=False, null=True, blank=True) + data_categories = ArrayField(models.TextField(), blank=True, null=True) + + class Meta: + ordering = ('name', ) + indexes = [ + GinIndex(name='hdrn_danm_trgm_idx', fields=['name'], opclasses=['gin_trgm_ops']), + GinIndex(name='hdrn_dadc_arr_idx', fields=['data_categories'], opclasses=['array_ops']), + ] + + def __str__(self): + return self.name diff --git a/CodeListLibrary_project/clinicalcode/models/HDRNDataCategory.py b/CodeListLibrary_project/clinicalcode/models/HDRNDataCategory.py new file mode 100644 index 000000000..fffba36b7 --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/models/HDRNDataCategory.py @@ -0,0 +1,21 @@ +from django.db import models +from django.contrib.postgres.indexes import GinIndex + +from clinicalcode.models.TimeStampedModel import TimeStampedModel + +class HDRNDataCategory(TimeStampedModel): + """ + HDRN Data Categories (e.g. Phenotype Type) + """ + id = models.AutoField(primary_key=True) + name = models.CharField(max_length=512, unique=False, null=False, blank=False) + description = models.TextField(null=True, blank=True) + metadata = models.JSONField(blank=True, null=True) + + class Meta: + indexes = [ + GinIndex(name='hdrn_dcnm_trgm_idx', fields=['name'], opclasses=['gin_trgm_ops']), + ] + + def __str__(self): + return self.name diff --git a/CodeListLibrary_project/clinicalcode/models/HDRNSite.py b/CodeListLibrary_project/clinicalcode/models/HDRNSite.py new file mode 100644 index 000000000..5ddc053a2 --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/models/HDRNSite.py @@ -0,0 +1,15 @@ +from django.db import models + +from clinicalcode.models.TimeStampedModel import TimeStampedModel + +class HDRNSite(TimeStampedModel): + """ + HDRN Institution Sites + """ + id = models.AutoField(primary_key=True) + name = models.CharField(max_length=512, unique=True, null=False, blank=False) + description = models.TextField(null=True, blank=True) + metadata = models.JSONField(blank=True, null=True) + + def __str__(self): + return self.name diff --git a/CodeListLibrary_project/clinicalcode/views/Admin.py b/CodeListLibrary_project/clinicalcode/views/Admin.py index 083016e48..4a9498358 100644 --- a/CodeListLibrary_project/clinicalcode/views/Admin.py +++ b/CodeListLibrary_project/clinicalcode/views/Admin.py @@ -9,11 +9,13 @@ from django.contrib.auth.decorators import login_required from django.utils.decorators import method_decorator from celery import shared_task +from http import HTTPStatus -import json +import time import logging import requests +from clinicalcode.entity_utils import gen_utils from clinicalcode.models.DataSource import DataSource from ..entity_utils import permission_utils, stats_utils @@ -22,26 +24,20 @@ ### Entity Statistics class EntityStatisticsView(TemplateView): - """ - Admin job panel to save statistics for templates across entities - """ + """Admin job panel to save statistics for templates across entities""" @method_decorator([login_required, permission_utils.redirect_readonly]) def get(self, request, *args, **kwargs): if not request.user.is_superuser: raise PermissionDenied stats_utils.collect_statistics(request) - context = { - 'successMsg': ['Filter statistics for Concepts/Phenotypes saved'], - } - - return render(request, 'clinicalcode/admin/run_statistics.html', context) + return render(request, 'clinicalcode/admin/run_statistics.html', { + 'successMsg': ['Filter statistics for Concepts/Phenotypes saved'] + }) def run_homepage_statistics(request): - """ - save home page statistics - """ + """Manual run for administrators to save home page statistics""" if not request.user.is_superuser: raise PermissionDenied @@ -61,39 +57,141 @@ def run_homepage_statistics(request): raise BadRequest + ##### Datasources +def query_hdruk_datasource( + page=1, + req_timeout=30, + retry_attempts=3, + retry_delay=2, + retry_codes=[ + HTTPStatus.REQUEST_TIMEOUT.value, + HTTPStatus.TOO_MANY_REQUESTS.value, + HTTPStatus.BAD_GATEWAY.value, + HTTPStatus.SERVICE_UNAVAILABLE.value, + HTTPStatus.GATEWAY_TIMEOUT.value, + ] +): + """ + Attempts to query HDRUK HealthDataGateway API + + Args: + page (int): the page id to query + req_timeout (int): request timeout (in seconds) + retry_attempts (int): max num. of attempts for each page request + retry_delay (int): timeout, in seconds, between retry attempts (backoff algo) + retry_codes (list): a list of ints specifying HTTP Status Codes from which to trigger retry attempts + + Returns: + A dict containing the assoc. data and page bounds, if applicable + """ + url = f'https://api.healthdatagateway.org/api/v1/datasets/?page={page}' + proxy = { + 'http': '' if settings.IS_DEVELOPMENT_PC else 'http://proxy:8080/', + 'https': '' if settings.IS_DEVELOPMENT_PC else 'http://proxy:8080/' + } + + response = None + retry_attempts = max(retry_attempts, 0) + 1 + for attempts in range(0, retry_attempts, 1): + try: + response = requests.get(url, req_timeout, proxies=proxy) + if retry_attempts - attempts > 0 and (not retry_codes or response.status_code in retry_codes): + time.sleep(retry_delay*pow(2, attempts - 1)) + continue + except requests.exceptions.ConnectionError: + pass + + if not response or response.status_code != 200: + status = response.status_code if response else 'INT_ERR' + raise Exception(f'Err response from server, Status') + + result = response.json() + if not isinstance(result, dict): + raise Exception(f'Invalid resultset, expected result as `dict` but got `{type(result)}`') + + last_page = result.get('last_page') + if not isinstance(last_page, int): + raise TypeError(f'Invalid resultset, expected `last_page` as `int` but got `{type(last_page)}`') + + data = result.get('data') + if not isinstance(data, list): + raise TypeError(f'Invalid resultset, expected `data` as `list` but got `{type(last_page)}`') + + return { 'data': data, 'last_page': last_page, 'page': page } + + +def collect_datasources(data): + """ + Safely parses datasource records from a resultset page + + Args: + data (list): a list specifying the datasources contained by a resultset + + Returns: + A dict containing the parsed data + """ + sources = {} + for ds in data: + meta = ds.get('latest_metadata') + idnt = ds.get('id') + uuid = ds.get('pid') + + if not isinstance(meta, dict) or not isinstance(idnt, int) or not isinstance(uuid, str): + continue + + meta = meta.get('metadata').get('metadata') if isinstance(meta.get('metadata'), dict) else None + meta = meta.get('summary') if isinstance(meta, dict) else None + if meta is None or not isinstance(meta.get('title'), str): + continue + + name = meta.get('title') + if not isinstance(name, str) or gen_utils.is_empty_string(name): + continue + + sources[uuid] = { + 'id': idnt, + 'uuid': uuid, + 'name': name[:500].strip(), + 'description': meta.get('description').strip()[:500] if isinstance(meta.get('description'), str) else None, + 'url': f'https://healthdatagateway.org/en/dataset/{idnt}', + } + + return sources + def get_hdruk_datasources(): + """ + Attempts to collect HDRUK datasources via its API + + Returns: + A tuple variant specifying the resulting datasources, specified as a dict, and an optional err message, defined as a string, if an err occurs + """ try: - result = requests.get( - 'https://api.www.healthdatagateway.org/api/v2/datasets', - proxies={ - 'http': '' if settings.IS_DEVELOPMENT_PC else 'http://proxy:8080/', - 'https': '' if settings.IS_DEVELOPMENT_PC else 'http://proxy:8080/' - } - ) + result = query_hdruk_datasource(page=1) except Exception as e: - return {}, 'Unable to sync HDRUK datasources, failed to reach api' - - datasources = {} - if result.status_code == 200: - datasets = json.loads(result.content)['datasets'] - - for dataset in datasets: - if 'pid' in dataset and 'datasetv2' in dataset: - dataset_name = dataset['datasetv2']['summary']['title'].strip() - dataset_uid = dataset['pid'].strip() - dataset_url = 'https://web.www.healthdatagateway.org/dataset/%s' % dataset_uid - dataset_description = dataset['datasetv2']['summary']['abstract'].strip() - - datasources[dataset_uid] = { - 'name': dataset_name if dataset_name != '' else dataset['datasetfields']['metadataquality']['title'].strip(), - 'url': dataset_url, - 'description': dataset_description - } + msg = f'Unable to sync HDRUK datasources, failed to reach api with err:\n\n{str(e)}' + logger.warning(msg) + + return {}, msg + + datasources = collect_datasources(result.get('data')) + for page in range(2, result.get('last_page') + 1, 1): + try: + result = query_hdruk_datasource(page=page) + datasources |= collect_datasources(result.get('data')) + except Exception as e: + logger.warning(f'Failed to retrieve HDRUK DataSource @ Page[{page}] with err:\n\n{str(e)}') + return datasources, None def create_or_update_internal_datasources(): + """ + Attempts to sync the DataSource model with those resolved from the HDRUK HealthDataGateway API + + Returns: + Either (a) an err message (str), or (b) a dict specifying the result of the diff + """ hdruk_datasources, error_message = get_hdruk_datasources() if error_message: return error_message @@ -102,52 +200,57 @@ def create_or_update_internal_datasources(): 'created': [], 'updated': [] } + for uid, datasource in hdruk_datasources.items(): + idnt = datasource.get('id') + name = datasource.get('name') + try: - internal_datasource = DataSource.objects.filter(Q(uid__iexact=uid) | Q(name__iexact=datasource['name'])) + internal_datasource = DataSource.objects.filter( + Q(uid__iexact=uid) | \ + Q(name__iexact=name) | \ + Q(datasource_id=idnt) + ) except DataSource.DoesNotExist: internal_datasource = False - if internal_datasource: - for internal in internal_datasource: - if internal.source == 'HDRUK': - update_uid = internal.uid != uid - update_name = internal.name != datasource['name'] - update_url = internal.url != datasource['url'] - update_description = internal.description != datasource['description'][:500] - - if update_uid or update_name or update_url or update_description: - internal.uid = uid - internal.name = datasource['name'] - internal.url = datasource['url'] - internal.description = datasource['description'] - internal.save() - - results['updated'].append({ - 'uid': uid, - 'name': datasource['name'] - }) + if internal_datasource and internal_datasource.exists(): + for internal in internal_datasource.all(): + if internal.source != 'HDRUK': + continue + + desc = datasource['description'] if isinstance(datasource['description'], str) else internal.description + + update_id = internal.datasource_id != idnt + update_uid = internal.uid != uid + update_url = internal.url != datasource['url'] + update_name = internal.name != name + update_description = internal.description != desc + + if update_id or update_uid or update_url or update_name or update_description: + internal.uid = uid + internal.url = datasource['url'] + internal.name = name + internal.description = desc + internal.datasource_id = idnt + internal.save() + results['updated'].append({ 'id': idnt, 'name': name }) else: new_datasource = DataSource() new_datasource.uid = uid - new_datasource.name = datasource['name'] new_datasource.url = datasource['url'] - new_datasource.description = datasource['description'] + new_datasource.name = name + new_datasource.description = datasource['description'] if datasource['description'] else '' + new_datasource.datasource_id = idnt new_datasource.source = 'HDRUK' new_datasource.save() - - new_datasource.datasource_id = new_datasource.id - new_datasource.save() - - results['created'].append({ - 'uid': uid, - 'name': datasource['name'] - }) + results['created'].append({ 'id': idnt, 'name': name }) return results def run_datasource_sync(request): + """Manual run of the DataSource sync""" if settings.CLL_READ_ONLY: raise PermissionDenied @@ -158,6 +261,7 @@ def run_datasource_sync(request): 'successMsg': ['HDR-UK datasources synced'], 'result': results } + if isinstance(results, str): message = { 'errorMsg': [results] @@ -172,6 +276,7 @@ def run_datasource_sync(request): @shared_task(bind=True) def run_celery_datasource(self): + """Celery cron task, used to synchronise DataSources on a schedule""" request_factory = RequestFactory() my_url = r'^admin/run-datasource-sync/$' request = request_factory.get(my_url) @@ -180,6 +285,4 @@ def run_celery_datasource(self): request.CURRENT_BRAND = '' if request.method == 'GET': results = create_or_update_internal_datasources() - - return True,results - + return True, results diff --git a/docs/sql-scripts/.processing/hdrn/hdrn_data_assets.py b/docs/sql-scripts/.processing/hdrn/hdrn_data_assets.py new file mode 100644 index 000000000..26ed539a5 --- /dev/null +++ b/docs/sql-scripts/.processing/hdrn/hdrn_data_assets.py @@ -0,0 +1,110 @@ +'''See: https://dash.hdrn.ca/en/inventory/''' +from typing import Any, Dict + +import os +import re +import json +import datetime + +import numpy as np +import pandas as pd + +COL_NAMES = ['Name', 'UUID', 'Site', 'Region', 'Data Categories', 'Description', 'Purpose', 'Scope', 'Data Level', 'Collection Period', 'Link', 'Years'] + +class JsonEncoder(json.JSONEncoder): + '''Encodes PyObj to JSON serialisable objects, see `jencoder`_ + + .. _jencoder: https://docs.python.org/3/library/json.html#json.JSONEncoder + ''' + def default(self, obj: Any) -> None | Dict[str, str]: + '''JSON encoder extensible implementation, see `jencoder`_ + + Args: + obj (Any): URL to target resource + + Returns: + A JSON-encoded object + + .. _jencoder: https://docs.python.org/3/library/json.html#json.JSONEncoder.default + ''' + if isinstance(obj, (datetime.date, datetime.datetime)): + return { 'type': 'datetime', 'value': obj.isoformat() } + + +def tx_link(link: str | None) -> pd.Series: + '''Parse id & convert links from fmt: `https://www.hdrn.ca/en/inventory/` to `https://dash.hdrn.ca/en/inventory/` + + Args: + link (str|None): URL to target resource + + Returns: + A `pd.Series` with the parsed `id` & the transformed `link`, if applicable + ''' + if isinstance(link, str): + ident = re.findall(r'(\d+)\/$', link) + if ident is not None and len(ident) > 0: + ident = int(ident[-1]) + return pd.Series([ + ident, + f'https://dash.hdrn.ca/en/inventory/{ident}/' + ]) + + return pd.Series([None, None]) + + +def build_pkg(inv_path: str ='./assets/HDRN_CanadaInventoryList.xlsx', out_fpath: str = './.out/HDRN_Data.json') -> None: + '''Builds the HDRN Data Asset JSON packet + + Note: + XLSX file located @ `inv_path` expects the following shape: + - Sheet: `Inventory` + - Headers: `Name | UUID | Site | Region | Data Categories | Description | Purpose | Scope | Data Level | Collection Period | Link | Years | Created Date | Modified By` + + Args: + inv_path (str): path to inventory list; defaults to `./assets/HDRN_CanadaInventoryList.xlsx` + out_fpath (str): output file path; defaults to `./.out/HDRN_Data.json` + ''' + # Process data + pkg = pd.read_excel(inv_path, sheet_name='Inventory') + pkg = pkg.replace({ np.nan: '' }) + + pkg[COL_NAMES] = pkg[COL_NAMES].astype(str) + pkg = pkg.replace(r'^\s*$', None, regex=True) + + pkg[['Id', 'Link']] = pkg.apply(lambda x: tx_link(x['Link']), axis=1) + pkg['Created Date'] = pd.to_datetime(pkg['Created Date'], format='%Y-%m-%d %H:%M:%S') + pkg['Modified By'] = pd.to_datetime(pkg['Created Date'], format='%Y-%m-%d %H:%M:%S') + + pkg = pkg.rename(columns={'Modified By': 'Modified Date'}) + pkg = pkg.sort_values(by='Created Date', axis=0, ascending=True, ignore_index=True) + + pkg.columns = [x.strip().replace(' ', '_').lower() for x in pkg.columns] + + # Compute unique categorical data + unq_sites = pkg['site'].str.split(r'(?:;)\s*').dropna().to_numpy() + unq_sites = np.unique(sum(unq_sites, [])) + + unq_cats = pkg['data_categories'].str.split(r'(?:,|;)\s*').dropna().to_numpy() + unq_cats = np.unique(sum(unq_cats, [])) + unq_cats = np.strings.capitalize(unq_cats) + + # Save pkg + dpath = os.path.realpath(os.path.dirname(out_fpath)) + if not os.path.exists(dpath): + os.makedirs(dpath) + + pkg = pkg.to_dict('records') + pkg = { + 'assets': pkg, + 'metadata': { + 'site': list(unq_sites), + 'data_categories': list(unq_cats), + }, + } + + with open(out_fpath, 'w') as f: + json.dump(pkg, f, cls=JsonEncoder) + + +if __name__ == '__main__': + build_pkg() diff --git a/docs/sql-scripts/.processing/phenoflow/phenoflow_map.py b/docs/sql-scripts/.processing/phenoflow/phenoflow_map.py new file mode 100644 index 000000000..d817a62fb --- /dev/null +++ b/docs/sql-scripts/.processing/phenoflow/phenoflow_map.py @@ -0,0 +1,104 @@ +'''See: https://kclhi.org/phenoflow/''' +from typing import Any, Dict + +import json +import requests + + +def get_workflow(group: Dict[str, Any]) -> tuple[Dict[str, Any] | None, str | None]: + ''' + Attempts to resolve the Phenoflow from the specified Phenotype + + Args: + group (dict): some Phenotype-Phenoflow group + + Returns: + A tuple variant specifying the resulting workflow and/or an err msg, if applicable + ''' + legacy_id = group.get('phenoflow_id') + phenotypes = group.get('related_phenotypes') + if not isinstance(legacy_id, int) or not isinstance(phenotypes, list): + return None, f'Workflow {repr(group)} is invalid, expected `related_phenotypes` as `list` type and property `phenoflow_id` to be an int' + + result = None + for pheno in phenotypes: + pheno_id = pheno.get('id') + if not isinstance(pheno_id, str): + continue + + try: + response = requests.post('https://kclhi.org/phenoflow/phenotype/all', headers={'Accept': 'application/json'}, json={'importedId': pheno_id}) + if response.status_code != 200: + continue + + result = response.json() + result = result.get('url') if isinstance(result, dict) else None + if isinstance(result, str): + break + except requests.exceptions.ConnectionError: + return None, 'Failed to retrieve workflows' + except Exception: + continue + + if not result: + return None, f'Failed to resolve Group' + + return { 'id': legacy_id, 'url': result }, None + + +def query_phenoflow(in_fpath: str = './assets/minified.json', out_fpath: str = './.out/phenoflow.json') -> None: + ''' + Queries an entire Phenotype set to derive the new Phenoflow URL target + + Args: + in_fpath (str): file path to a JSON file specifying the Phenotypes & Phenoflow relationships; defaults to `./assets/minified.json` + out_fpath (str): output file path; defaults to `./.out/phenoflow.json` + ''' + with open(in_fpath) as f: + groups = json.load(f) + + workflows = [] + for group in groups: + result, err = get_workflow(group) + if err is not None: + continue + workflows.append(result) + + with open(out_fpath, 'w') as f: + json.dump(workflows, f, indent=2) + + +def map_phenoflow(in_fpath: str = './assets/minified.json', trg_fpath: str = './.out/phenoflow.json', out_fpath: str = './.out/mapped.json') -> None: + ''' + Attempts to map a Phenotype and its assoc. Phenoflow relationships to the newest implementation + + Args: + in_fpath (str): file path to a JSON file specifying the Phenotypes & Phenoflow relationships; defaults to `./assets/minified.json` + out_fpath (str): file path to a JSON file specifying the resolved targets; defaults to `./.out/phenoflow.json` + out_fpath (str): output file path; defaults to `./.out/mapped.json` + ''' + with open(in_fpath) as f: + groups = json.load(f) + + with open(trg_fpath) as f: + relation = json.load(f) + + mapped = {} + for group in groups: + phenotypes = group.get('related_phenotypes') + phenoflow_id = group.get('phenoflow_id') + if not isinstance(phenotypes, list): + continue + + mapping = next((x for x in relation if x.get('id') == phenoflow_id), None) + mapping = mapping.get('url') if isinstance(mapping, dict) else None + for pheno in phenotypes: + mapped[pheno.get('id')] = { 'source': phenoflow_id, 'target': mapping } + + with open(out_fpath, 'w') as f: + json.dump(mapped, f, indent=2) + + +if __name__ == '__main__': + query_phenoflow() + map_phenoflow() diff --git a/docs/sql-scripts/.processing/phenoflow/phenoflow_query.sql b/docs/sql-scripts/.processing/phenoflow/phenoflow_query.sql new file mode 100644 index 000000000..fa79a6a1c --- /dev/null +++ b/docs/sql-scripts/.processing/phenoflow/phenoflow_query.sql @@ -0,0 +1,29 @@ +--> Used to collect all historical records of Phenotypes assoc. with some unique Phenoflow obj +--> See Phenoflow @ https://kclhi.org/phenoflow/ + +with + phenoflow_objs as ( + select + ent.id, + ent.history_id, + (ent.template_data::jsonb->>'phenoflowid')::int as phenoflow_id + from public.clinicalcode_historicalgenericentity as ent + where ent.template_data::jsonb ? 'phenoflowid' + and ent.template_data::jsonb->>'phenoflowid' ~ E'^\\d+$' + ), + aggregated_objs as ( + select + ent.phenoflow_id, + json_agg(json_build_object( + 'id', ent.id, + 'history_id', ent.history_id + )) as related_phenotypes + from phenoflow_objs as ent + group by ent.phenoflow_id + ) +select + json_agg(json_build_object( + 'phenoflow_id', ent.phenoflow_id, + 'related_phenotypes', ent.related_phenotypes + )) as results + from aggregated_objs as ent; From 15de5f2f1ada86846d4cc7bb2180e7a1b0a8fdab Mon Sep 17 00:00:00 2001 From: JackScanlon Date: Tue, 11 Mar 2025 15:37:16 +0000 Subject: [PATCH 026/164] feat: collection/tag brand behaviour --- .../clinicalcode/entity_utils/constants.py | 21 ++- .../clinicalcode/entity_utils/create_utils.py | 23 ++- .../clinicalcode/entity_utils/filter_utils.py | 81 ++++++++-- .../clinicalcode/entity_utils/model_utils.py | 75 +++++++-- .../clinicalcode/entity_utils/search_utils.py | 17 +- .../clinicalcode/entity_utils/stats_utils.py | 78 ++++++--- .../entity_utils/template_utils.py | 149 ++++++++++-------- .../clinicalcode/models/HDRNDataAsset.py | 2 +- .../templatetags/detail_pg_renderer.py | 8 +- .../templatetags/entity_renderer.py | 37 +++-- .../clinicalcode/views/Admin.py | 7 +- .../clinicalcode/views/GenericEntity.py | 10 +- .../clinicalcode/views/View.py | 17 +- .../js/clinicalcode/components/tagify.js | 7 +- .../clinicalcode/forms/entityCreator/utils.js | 20 ++- .../services/referenceDataService.js | 2 +- .../clinicalcode/about/reference_data.html | 69 ++++---- .../components/create/inputs/tagbox.html | 10 +- 18 files changed, 410 insertions(+), 223 deletions(-) diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/constants.py b/CodeListLibrary_project/clinicalcode/entity_utils/constants.py index 637d27ba0..c28a7ae9e 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/constants.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/constants.py @@ -571,7 +571,14 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta): 'tag_type': 2, ## Can be added once we det. what we're doing with brands - # 'source_by_brand': None + 'source_by_brand': { + 'ADP': { + 'allowed_brands': [3], + 'allow_null': True, + }, + 'HDRUK': 'allow_null', + 'SAIL': False, + }, } } }, @@ -599,7 +606,11 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta): 'tag_type': 1, ## Can be added once we det. what we're doing with brands - # 'source_by_brand': None + 'source_by_brand': { + 'ADP': 'allow_null', + 'HDRUK': 'allow_null', + 'SAIL': False, + }, } } }, @@ -810,13 +821,15 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta): 'system_defined': True, 'description': 'list of tags ids (managed by code snippet)', 'input_type': 'tagbox', - 'output_type': 'tagbox' + 'output_type': 'tagbox', + 'vis_vary_on_opts': True, }, 'collections': { 'system_defined': True, 'description': 'list of collections ids (managed by code snippet)', 'input_type': 'tagbox', - 'output_type': 'tagbox' + 'output_type': 'tagbox', + 'vis_vary_on_opts': True, }, 'data_sources': { 'system_defined': True, diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/create_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/create_utils.py index e75697026..6e9274028 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/create_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/create_utils.py @@ -1,3 +1,5 @@ +from operator import and_ +from functools import reduce from django.db import transaction, IntegrityError, connection from django.apps import apps from django.db.models import Q @@ -301,21 +303,18 @@ def try_validate_sourced_value(field, template, data, default=None, request=None try: source_info = validation.get('source') model = apps.get_model(app_label='clinicalcode', model_name=model_name) - - if isinstance(data, list): - query = { - 'pk__in': data - } - else: - query = { - 'pk': data - } + query = { 'pk__in': data } if isinstance(data, list) else { 'pk': data } if 'filter' in source_info: filter_query = template_utils.try_get_filter_query(field, source_info.get('filter'), request=request) - query = {**query, **filter_query} - - queryset = model.objects.filter(Q(**query)) + if isinstance(filter_query, list): + query = [Q(**query), *filter_query] + + if isinstance(query, list): + queryset = model.objects.filter(reduce(and_, query)) + else: + queryset = model.objects.filter(**query) + queryset = list(queryset.values_list('id', flat=True)) if isinstance(data, list): diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/filter_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/filter_utils.py index 4038668b9..03fb83fbd 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/filter_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/filter_utils.py @@ -1,6 +1,11 @@ +from operator import or_ +from functools import reduce +from django.db.models import Q + import inspect -from . import model_utils +from . import gen_utils +from ..models import Brand def is_class_method(method, expected_class): """ @@ -34,8 +39,7 @@ def test_params(cls, expected_params, **kwargs): **kwargs: the passed parameters Returns: - (boolean) that reflects whether the kwargs incl. the expected - parameters + A boolean reflecting the kwargs validity as described by the expected parameters arg """ for key, expected in expected_params.items(): item = kwargs.get(key) @@ -58,22 +62,22 @@ def try_generate_filter(cls, desired_filter, expected_params=None, **kwargs): by ENTITY_FILTER_PARAMS[(%s)].PROPERTIES Returns: - Either (a) a null result if it fails or (b) the generated filter query + Either (a) a `None` type result if it fails or (b) the generated filter query """ desired_filter = getattr(cls, desired_filter) if desired_filter is None or not is_class_method(desired_filter, cls): - return - + return None + if isinstance(expected_params, dict) and not cls.test_params(expected_params, **kwargs): - return - + return None + return desired_filter(**kwargs) """ Filters """ @classmethod - def brand_filter(cls, request, column_name): + def brand_filter(cls, **kwargs): """ - @desc Generates the brand-related filter query + Generates the brand-related filter query Args: request (RequestContext): the request context during this execution @@ -81,12 +85,55 @@ def brand_filter(cls, request, column_name): column_name (string): the name of the column to filter by Result: - Either (a) a null result or (b) the generated filter query + Either (a) a `None` type result or (b) the generated filter query """ - current_brand = model_utils.try_get_brand(request) + column_name = kwargs.get('column_name', None) + if not column_name: + return None + + request = kwargs.get('request', None) + if request is None: + current_brand = kwargs.get('brand_target', None) + else: + current_brand = getattr(request, 'CURRENT_BRAND', None) + if not isinstance(current_brand, str) or gen_utils.is_empty_string(current_brand): + current_brand = None + else: + current_brand = Brand.objects.filter(name=current_brand) + if current_brand.exists(): + current_brand = current_brand.first() + else: + current_brand = None + if current_brand is None: - return - - return { - f'{column_name}': current_brand.id - } + return None + + target_id = current_brand.id + source_value = kwargs.get('source_value') + + result = [ Q(**{column_name: target_id}) ] + if isinstance(source_value, dict): + # Modify behaviour on brand request target + modifier = source_value.get(current_brand.name) + if isinstance(modifier, bool) and not modifier: + # Don't apply filter if this request target ignores brand context + return [] + elif isinstance(modifier, str) and modifier == 'allow_null': + # Allow null values as well as request target + result.append(Q(**{f'{column_name}__isnull': True})) + result = [reduce(or_, result)] + elif isinstance(modifier, dict): + allowed_brands = gen_utils.parse_as_int_list(modifier.get('allowed_brands'), None) + if isinstance(allowed_brands, list): + # Vary the filter if this request target desires different behaviour + if target_id not in allowed_brands: + allowed_brands = [target_id, *allowed_brands] + + result = [ Q(**{f'{column_name}__id__in': allowed_brands}) ] + + if modifier.get('allow_null', False): + # Allow null values + result.append(Q(**{f'{column_name}__isnull': True})) + result = [reduce(or_, result)] + + return result diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/model_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/model_utils.py index b0cbeee71..03ffd5027 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/model_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/model_utils.py @@ -7,14 +7,11 @@ import json import simple_history -from . import gen_utils -from ..models.GenericEntity import GenericEntity +from . import gen_utils, filter_utils, constants from ..models.Tag import Tag +from ..models.Brand import Brand from ..models.CodingSystem import CodingSystem -from ..models import Brand -from ..models import Tag -from .constants import (USERDATA_MODELS, STRIPPED_FIELDS, APPROVAL_STATUS, - GROUP_PERMISSIONS, WORLD_ACCESS_PERMISSIONS) +from ..models.GenericEntity import GenericEntity def try_get_instance(model, **kwargs): """ @@ -61,15 +58,65 @@ def get_entity_id(primary_key): return entity_id[:2] return False +def get_brand_tag_ids(brand_name): + """ + Returns list of tags (tags with tag_type=1) ids associated with the brand + """ + brand = Brand.objects.all().filter(name__iexact=brand_name) + if not brand.exists(): + return list(Tag.objects.filter(tag_type=1).values_list('id', flat=True)) + + brand = brand.first() + source = constants.metadata.get('tags', {}) \ + .get('validation', {}) \ + .get('source', {}) \ + .get('filter', {}) \ + .get('source_by_brand', None) + + if source is not None: + result = filter_utils.DataTypeFilters.try_generate_filter( + desired_filter='brand_filter', + expected_params=None, + source_value=source, + column_name='collection_brand', + brand_target=brand + ) + + if isinstance(result, list) and len(result) > 0: + res = list(Tag.objects.filter(*result).values_list('id', flat=True)) + return res + + return list(Tag.objects.filter(collection_brand=brand.id, tag_type=1).values_list('id', flat=True)) + def get_brand_collection_ids(brand_name): """ - Returns list of collections (tags) ids associated with the brand + Returns list of collections (tags with tag_type=2) ids associated with the brand """ - if Brand.objects.all().filter(name__iexact=brand_name).exists(): - brand = Brand.objects.get(name__iexact=brand_name) - brand_collection_ids = list(Tag.objects.filter(collection_brand=brand.id).values_list('id', flat=True)) - return brand_collection_ids - return [-1] + brand = Brand.objects.all().filter(name__iexact=brand_name) + if not brand.exists(): + return list(Tag.objects.filter(tag_type=2).values_list('id', flat=True)) + + brand = brand.first() + source = constants.metadata.get('collections', {}) \ + .get('validation', {}) \ + .get('source', {}) \ + .get('filter', {}) \ + .get('source_by_brand', None) + + if source is not None: + result = filter_utils.DataTypeFilters.try_generate_filter( + desired_filter='brand_filter', + expected_params=None, + source_value=source, + column_name='collection_brand', + brand_target=brand + ) + + if isinstance(result, list): + res = list(Tag.objects.filter(*result).values_list('id', flat=True)) + return res + + return list(Tag.objects.filter(collection_brand=brand.id, tag_type=2).values_list('id', flat=True)) def get_entity_approval_status(entity_id, historical_id): """ @@ -104,7 +151,7 @@ def jsonify_object(obj, remove_userdata=True, strip_fields=True, strippable_fiel if remove_userdata or strip_fields: for field in obj._meta.fields: field_type = field.get_internal_type() - if strip_fields and field_type and field.get_internal_type() in STRIPPED_FIELDS: + if strip_fields and field_type and field.get_internal_type() in constants.STRIPPED_FIELDS: instance.pop(field.name, None) continue @@ -120,7 +167,7 @@ def jsonify_object(obj, remove_userdata=True, strip_fields=True, strippable_fiel continue model = str(field.target_field.model) - if model not in USERDATA_MODELS: + if model not in constants.USERDATA_MODELS: continue instance.pop(field.name, None) diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/search_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/search_utils.py index e2c3f8d83..10c85dfa5 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/search_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/search_utils.py @@ -1,3 +1,5 @@ +from operator import and_ +from functools import reduce from django.apps import apps from django.db import connection from django.db.models import Q @@ -240,15 +242,18 @@ def validate_query_param(param, template, data, default=None, request=None): try: source_info = validation.get('source') model = apps.get_model(app_label='clinicalcode', model_name=source_info.get('table')) - query = { - 'pk__in': data - } + query = { 'pk__in': data } if 'filter' in source_info: filter_query = template_utils.try_get_filter_query(param, source_info.get('filter'), request=request) - query = {**query, **filter_query} - - queryset = model.objects.filter(Q(**query)) + if isinstance(filter_query, list): + query = [Q(**query), *filter_query] + + if isinstance(query, list): + queryset = model.objects.filter(reduce(and_, query)) + else: + queryset = model.objects.filter(**query) + queryset = list(queryset.values_list('id', flat=True)) except: return default diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/stats_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/stats_utils.py index 070d7facf..04f15209e 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/stats_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/stats_utils.py @@ -30,12 +30,31 @@ def sort_by_count(a, b): return -1 return 0 -def get_field_values(field, validation, struct): +def get_field_values(field, field_value, validation, struct, brand=None): value = None if 'options' in validation: - value = template_utils.get_options_value(field, struct) + value = template_utils.get_options_value(field_value, struct) elif 'source' in validation: - value = template_utils.get_sourced_value(field, struct) + source = None + if brand is not None and (field == 'collections' or field == 'tags'): + source = constants.metadata.get(field, {}) \ + .get('validation', {}) \ + .get('source', {}) \ + .get('filter', {}) \ + .get('source_by_brand', None) + + if source is not None: + value = template_utils.get_sourced_value(field_value, struct, filter_params={ + 'field_name': field, + 'desired_filter': 'brand_filter', + 'expected_params': None, + 'source_value': source, + 'column_name': 'collection_brand', + 'brand_target': brand + }) + else: + value = template_utils.get_sourced_value(field_value, struct) + return value def transform_counted_field(data): @@ -50,23 +69,24 @@ def transform_counted_field(data): array.sort(key=sort_fn) return array -def try_get_cached_data(cache, entity, template, field, field_value, validation, struct, brand=None): +def try_get_cached_data(cache, _entity, template, field, field_value, validation, struct, brand=None): if template is None or not isinstance(cache, dict): - return get_field_values(field_value, validation, struct) - + return get_field_values(field, field_value, validation, struct, brand) + if brand is not None: cache_key = f'{brand.name}__{field}__{field_value}__{template.id}__{template.template_version}' else: cache_key = f'{field}__{field_value}__{template.id}__{template.template_version}' - + if cache_key not in cache: - value = get_field_values(field_value, validation, struct) + value = get_field_values(field, field_value, validation, struct, brand) if value is None: + cache[cache_key] = None return None - + cache[cache_key] = value return value - + return cache[cache_key] def build_statistics(statistics, entity, field, struct, data_cache=None, template_entity=None, brand=None): @@ -161,7 +181,6 @@ def compute_statistics(statistics, entity, data_cache=None, template_cache=None, if not isinstance(struct, dict): continue - build_statistics(statistics['all'], entity, field, struct, data_cache=data_cache, template_entity=template, brand=brand) if entity.publish_status == constants.APPROVAL_STATUS.APPROVED: @@ -199,10 +218,11 @@ def collect_statistics(request): cache = { } template_cache = { } - all_entities = GenericEntity.objects.all() - to_update = [ ] to_create = [ ] + + results = [] + all_entities = GenericEntity.objects.all() for brand in Brand.objects.all(): stats = collate_statistics( all_entities, @@ -217,20 +237,24 @@ def collect_statistics(request): ) if obj.exists(): + action = 'update' obj = obj.first() obj.stat = stats + obj.modified = datetime.datetime.now() obj.updated_by = user to_update.append(obj) - continue + else: + action = 'create' + obj = Statistics( + org=brand.name, + type='GenericEntity', + stat=stats, + created_by=user + ) + to_create.append(obj) + + results.append({ 'brand': brand.name, 'value': stats, 'action': action }) - obj = Statistics( - org=brand.name, - type='GenericEntity', - stat=stats, - created_by=user - ) - to_create.append(obj) - stats = collate_statistics( all_entities, data_cache=cache, @@ -243,11 +267,14 @@ def collect_statistics(request): ) if obj.exists(): + action = 'update' obj = obj.first() obj.stat = stats + obj.modified = datetime.datetime.now() obj.updated_by = user to_update.append(obj) else: + action = 'create' obj = Statistics( org='ALL', type='GenericEntity', @@ -255,12 +282,15 @@ def collect_statistics(request): created_by=user ) to_create.append(obj) - + + results.append({ 'brand': 'all', 'value': stats, 'action': action }) + # Create / Update stat objs Statistics.objects.bulk_create(to_create) - Statistics.objects.bulk_update(to_update, ['stat', 'updated_by']) + Statistics.objects.bulk_update(to_update, ['stat', 'updated_by', 'modified']) clear_statistics_history() + return results def clear_statistics_history(): diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/template_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/template_utils.py index dfdd0aa6c..bd67bc286 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/template_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/template_utils.py @@ -1,10 +1,15 @@ +from operator import and_ +from functools import reduce from django.apps import apps from django.db.models import Q, ForeignKey +import logging + from . import concept_utils from . import filter_utils from . import constants +logger = logging.getLogger(__name__) def try_get_content(body, key, default=None): """ @@ -373,13 +378,13 @@ def try_get_filter_query(field_name, source, request=None): request (RequestContext): the current request context, if available Returns: - The final filter query as a (dict) + The final filter query as a (list) """ - output = {} + output = [] for key, value in source.items(): filter_packet = constants.ENTITY_FILTER_PARAMS.get(key) if not isinstance(filter_packet, dict): - output[key] = value + output.append(Q(**{key: value})) continue filter_name = filter_packet.get('filter') @@ -400,15 +405,16 @@ def try_get_filter_query(field_name, source, request=None): result = None try: result = filter_utils.DataTypeFilters.try_generate_filter( - desired_filter=filter_name, - expected_params=filter_packet.get('expected_params'), - **filter_props + desired_filter=filter_name, + expected_params=filter_packet.get('expected_params'), + source_value=value, + **filter_props ) except Exception as e: - pass + logger.warning(f'Failed to build filter of field "{field_name}" with err:\n\n{e}') - if isinstance(result, dict): - output = output | result + if isinstance(result, list): + output = output + result return output @@ -426,11 +432,14 @@ def get_metadata_value_from_source(entity, field, default=None, request=None): data = getattr(entity, field) if field in constants.metadata: validation = get_field_item(constants.metadata, field, 'validation', {}) + if not validation: + return default + source_info = validation.get('source') + if not source_info: + return default - model = apps.get_model( - app_label='clinicalcode', model_name=source_info.get('table') - ) + model = apps.get_model(app_label='clinicalcode', model_name=source_info.get('table')) column = 'id' if 'query' in source_info: @@ -439,37 +448,32 @@ def get_metadata_value_from_source(entity, field, default=None, request=None): if isinstance(data, model): data = getattr(data, column) - if isinstance(data, list): - query = { - f'{column}__in': data - } - else: - query = { - f'{column}': data - } + query = { f'{column}__in': data } if isinstance(data, list) else { f'{column}': data } if 'filter' in source_info: filter_query = try_get_filter_query(field, source_info.get('filter'), request=request) - query = {**query, **filter_query} + if isinstance(filter_query, list): + query = [Q(**query), *filter_query] + + if isinstance(query, list): + queryset = model.objects.filter(reduce(and_, query)) + else: + queryset = model.objects.filter(**query) - queryset = model.objects.filter(Q(**query)) if queryset.exists(): relative = 'name' if 'relative' in source_info: relative = source_info['relative'] - output = [] - for instance in queryset: - output.append({ - 'name': getattr(instance, relative), - 'value': getattr(instance, column) - }) + output = [ + { 'name': getattr(instance, relative), 'value': getattr(instance, column) } + for instance in queryset + ] return output if len(output) > 0 else default - except: - pass - else: - return default + except Exception as e: + logger.warning(f'Failed to build metadata value of field "{field}" with err:\n\n{e}') + return default def get_template_sourced_values(template, field, default=None, request=None): @@ -513,8 +517,7 @@ def get_template_sourced_values(template, field, default=None, request=None): if isinstance(output, list): return output except Exception as e: - # Logging - pass + logger.warning(f'Failed to derive template sourced values of tree, from field "{field}" with err:\n\n{e}') elif isinstance(model_name, str): try: model = apps.get_model(app_label='clinicalcode', model_name=model_name) @@ -523,13 +526,17 @@ def get_template_sourced_values(template, field, default=None, request=None): if 'query' in source_info: column = source_info.get('query') + query = { } if 'filter' in source_info: filter_query = try_get_filter_query(field, source_info.get('filter'), request=request) - query = {**filter_query} + if isinstance(filter_query, list): + query = [Q(**query), *filter_query] + + if isinstance(query, list): + queryset = model.objects.filter(reduce(and_, query)) else: - query = {} + queryset = model.objects.filter(**query) - queryset = model.objects.filter(Q(**query)) if queryset.exists(): relative = 'name' if 'relative' in source_info: @@ -543,8 +550,8 @@ def get_template_sourced_values(template, field, default=None, request=None): }) return output if len(output) > 0 else default - except: - pass + except Exception as e: + logger.warning(f'Failed to derive template sourced values of column, from field "{field}" with err:\n\n{e}') return default @@ -593,24 +600,16 @@ def get_detailed_sourced_value(data, info, default=None): if 'relative' in source_info: relative = source_info['relative'] - query = None - if 'query' in source_info: - query = { - source_info['query']: data - } - else: - query = { - 'pk': data - } - + query = { source_info['query']: data } if 'query' in source_info else { 'pk': data } queryset = model.objects.filter(Q(**query)) if queryset.exists(): queryset = queryset.first() packet = { - 'name': try_get_instance_field(queryset, relative, default), - 'value': data + 'name': try_get_instance_field(queryset, relative, default), + 'value': data } + included_fields = source_info.get('include') if included_fields: for included_field in included_fields: @@ -620,12 +619,15 @@ def get_detailed_sourced_value(data, info, default=None): packet[included_field] = value return packet + return default - except: - return default + except Exception as e: + field = info.get('title', 'Unknown Field') + logger.warning(f'Failed to build detailed source value of field "{field}" with err:\n\n{e}') + return default -def get_sourced_value(data, info, default=None): +def get_sourced_value(data, info, default=None, filter_params=None): """ Tries to get the sourced value of a dynamic field from its layout and/or another model (if sourced) """ @@ -640,23 +642,37 @@ def get_sourced_value(data, info, default=None): if 'relative' in source_info: relative = source_info['relative'] - query = None - if 'query' in source_info: - query = { - source_info['query']: data - } + query = { source_info['query']: data } if 'query' in source_info else { 'pk': data } + + if 'filter' in source_info and isinstance(filter_params, dict): + filter_query = None + try: + if 'source_value' not in filter_params: + filter_params.update({ 'source_value': source_info.get('filter') }) + + filter_query = filter_utils.DataTypeFilters.try_generate_filter(**filter_params) + if isinstance(filter_query, list): + query = [Q(**query), *filter_query] + + except Exception as e: + field = info.get('title', 'Unknown Field') + logger.warning(f'Failed to build filter of field "{field}" with err:\n\n{e}') + + if isinstance(query, list): + queryset = model.objects.filter(reduce(and_, query)) else: - query = { - 'pk': data - } + queryset = model.objects.filter(**query) - queryset = model.objects.filter(Q(**query)) if queryset.exists(): queryset = queryset.first() return try_get_instance_field(queryset, relative, default) + return default - except: - return default + except Exception as e: + field = info.get('title', 'Unknown Field') + logger.warning(f'Failed to build sourced value of field "{field}" with err:\n\n{e}') + + return default def get_template_data_values(entity, layout, field, hide_user_details=False, request=None, default=None): @@ -701,8 +717,7 @@ def get_template_data_values(entity, layout, field, hide_user_details=False, req if isinstance(output, list): return output except Exception as e: - # Logging - pass + logger.warning(f'Failed to derive template data values of "{field}" with err:\n\n{e}') elif isinstance(model_name, str): values = [] for item in data: diff --git a/CodeListLibrary_project/clinicalcode/models/HDRNDataAsset.py b/CodeListLibrary_project/clinicalcode/models/HDRNDataAsset.py index 35ebcc4e9..aaa0d5401 100644 --- a/CodeListLibrary_project/clinicalcode/models/HDRNDataAsset.py +++ b/CodeListLibrary_project/clinicalcode/models/HDRNDataAsset.py @@ -33,7 +33,7 @@ class Meta: ordering = ('name', ) indexes = [ GinIndex(name='hdrn_danm_trgm_idx', fields=['name'], opclasses=['gin_trgm_ops']), - GinIndex(name='hdrn_dadc_arr_idx', fields=['data_categories'], opclasses=['array_ops']), + GinIndex(name='hdrn_dadc_arr_idx', fields=['data_categories'], opclasses=['array_ops']), ] def __str__(self): diff --git a/CodeListLibrary_project/clinicalcode/templatetags/detail_pg_renderer.py b/CodeListLibrary_project/clinicalcode/templatetags/detail_pg_renderer.py index 4b3be02cc..b12677874 100644 --- a/CodeListLibrary_project/clinicalcode/templatetags/detail_pg_renderer.py +++ b/CodeListLibrary_project/clinicalcode/templatetags/detail_pg_renderer.py @@ -1,10 +1,11 @@ -from django.apps import apps +from copy import deepcopy from django import template +from django.apps import apps +from django.conf import settings from jinja2.exceptions import TemplateSyntaxError +from django.http.request import HttpRequest from django.template.loader import render_to_string from django.utils.translation import gettext_lazy as _ -from django.http.request import HttpRequest -from django.conf import settings import re import json @@ -356,6 +357,7 @@ def __generate_wizard(self, request, context): if component is None: continue + component = deepcopy(component) active = template_field.get('active', False) is_hidden = ( (isinstance(active, bool) and not active) diff --git a/CodeListLibrary_project/clinicalcode/templatetags/entity_renderer.py b/CodeListLibrary_project/clinicalcode/templatetags/entity_renderer.py index 0b3890feb..5b193af26 100644 --- a/CodeListLibrary_project/clinicalcode/templatetags/entity_renderer.py +++ b/CodeListLibrary_project/clinicalcode/templatetags/entity_renderer.py @@ -1,3 +1,4 @@ +from copy import deepcopy from django import template from django.conf import settings from django.urls import reverse @@ -414,17 +415,10 @@ def render(self, context): layout = template_utils.try_get_content(layouts, f'{entity.template.id}/{entity.template_version}') if not template_utils.is_layout_safe(layout): continue + card = template_utils.try_get_content(layout['definition'].get('template_details'), 'card_type', constants.DEFAULT_CARD) card = f'{constants.CARDS_DIRECTORY}/{card}.html' - try: - html = render_to_string(card, { - 'entity': entity, - 'layout': layout - }) - except: - raise - else: - output += html + output += render_to_string(card, { 'entity': entity, 'layout': layout }) return output @register.tag(name='render_entity_filters') @@ -508,7 +502,8 @@ def __render_metadata_component(self, context, field, structure): if 'compute_statistics' in structure: current_brand = request.CURRENT_BRAND or 'ALL' options = search_utils.get_metadata_stats_by_field(field, brand=current_brand) - options = self.__check_excluded_brand_collections(context, field, current_brand, options) + print(field, options) + # options = self.__check_excluded_brand_collections(context, field, current_brand, options) if options is None: validation = template_utils.try_get_content(structure, 'validation') @@ -743,17 +738,19 @@ def __try_get_computed(self, request, field): return permission_utils.get_user_groups(request) return - def __apply_mandatory_property(self, template, field): + def __apply_properties(self, component, template, _field): """ - Returns boolean that reflects the mandatory status of a field given its - template's validation field (if present) + Applies properties assoc. with some template's field to some target + + Returns: + Updates in place but returns the updated (dict) """ validation = template_utils.try_get_content(template, 'validation') - if validation is None: - return False - - mandatory = template_utils.try_get_content(validation, 'mandatory') - return mandatory if isinstance(mandatory, bool) else False + if validation is not None: + mandatory = template_utils.try_get_content(validation, 'mandatory') + component['mandatory'] = mandatory if isinstance(mandatory, bool) else False + + return component def __append_section(self, output, section_content): if gen_utils.is_empty_string(section_content): @@ -801,6 +798,7 @@ def __generate_wizard(self, request, context): if component is None: continue + component = deepcopy(component) if template_utils.is_metadata(GenericEntity, field): field_data = template_utils.try_get_content(constants.metadata, field) else: @@ -843,7 +841,8 @@ def __generate_wizard(self, request, context): component['value'] = self.__try_get_entity_value(request, template, entity, field) else: component['value'] = '' - component['mandatory'] = self.__apply_mandatory_property(template_field, field) + + self.__apply_properties(component, template_field, field) uri = f'{constants.CREATE_WIZARD_INPUT_DIR}/{component.get("input_type")}.html' section_content += self.__try_render_item(template_name=uri, request=request, context=context.flatten() | { 'component': component }) diff --git a/CodeListLibrary_project/clinicalcode/views/Admin.py b/CodeListLibrary_project/clinicalcode/views/Admin.py index 4a9498358..bf024ba18 100644 --- a/CodeListLibrary_project/clinicalcode/views/Admin.py +++ b/CodeListLibrary_project/clinicalcode/views/Admin.py @@ -30,9 +30,10 @@ def get(self, request, *args, **kwargs): if not request.user.is_superuser: raise PermissionDenied - stats_utils.collect_statistics(request) + stat = stats_utils.collect_statistics(request) return render(request, 'clinicalcode/admin/run_statistics.html', { - 'successMsg': ['Filter statistics for Concepts/Phenotypes saved'] + 'successMsg': ['Filter statistics for Concepts/Phenotypes saved'], + 'stat': stat, }) @@ -51,7 +52,7 @@ def run_homepage_statistics(request): 'clinicalcode/admin/run_statistics.html', { 'successMsg': ['Homepage statistics saved'], - 'stat': stat + 'stat': stat, } ) diff --git a/CodeListLibrary_project/clinicalcode/views/GenericEntity.py b/CodeListLibrary_project/clinicalcode/views/GenericEntity.py index e4236d102..098d0ad79 100644 --- a/CodeListLibrary_project/clinicalcode/views/GenericEntity.py +++ b/CodeListLibrary_project/clinicalcode/views/GenericEntity.py @@ -495,7 +495,15 @@ def get_options(self, request, *args, **kwargs): return gen_utils.jsonify_response(message='Invalid field parameter', code=400, status='false') if template_utils.is_metadata(GenericEntity, field): - options = template_utils.get_template_sourced_values(constants.metadata, field, request=request) + default_value = None + + struct = template_utils.get_layout_field(constants.metadata, field) + if struct is not None: + struct = template_utils.try_get_content(struct, 'validation') + if struct is not None: + default_value = [] if struct.get('type') == 'int_array' else default_value + + options = template_utils.get_template_sourced_values(constants.metadata, field, request=request, default=default_value) else: options = template_utils.get_template_sourced_values(template, field, request=request) diff --git a/CodeListLibrary_project/clinicalcode/views/View.py b/CodeListLibrary_project/clinicalcode/views/View.py index 60a6a858b..6c6216d48 100644 --- a/CodeListLibrary_project/clinicalcode/views/View.py +++ b/CodeListLibrary_project/clinicalcode/views/View.py @@ -22,11 +22,10 @@ from ..models.CodingSystem import CodingSystem from ..models.DataSource import DataSource from ..models.Statistics import Statistics -from ..models.OntologyTag import OntologyTag -from ..entity_utils import gen_utils +from ..entity_utils import gen_utils, template_utils, constants, model_utils from ..entity_utils.constants import ONTOLOGY_TYPES -from ..entity_utils.permission_utils import should_render_template, redirect_readonly +from ..entity_utils.permission_utils import redirect_readonly logger = logging.getLogger(__name__) @@ -313,18 +312,14 @@ def reference_data(request): """ Open page to list Data sources, Coding systems, Tags, Collections, Phenotype types, etc """ - tags = Tag.objects.extra(select={ - 'name': 'description' - }).order_by('id') - - collections = tags.filter(tag_type=2).values('id', 'name') - tags = tags.filter(tag_type=1).values('id', 'name') + tags = template_utils.get_template_sourced_values(constants.metadata, 'tags', request=request, default=[]) + collections = template_utils.get_template_sourced_values(constants.metadata, 'collections', request=request, default=[]) context = { 'data_sources': list(DataSource.objects.all().order_by('id').values('id', 'name')), 'coding_system': list(CodingSystem.objects.all().order_by('id').values('id', 'name')), - 'tags': list(tags), - 'collections': list(collections), + 'tags': tags, + 'collections': collections, 'ontology_groups': [x.value for x in ONTOLOGY_TYPES] } diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/tagify.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/tagify.js index 63e4ab8be..74edf4ebd 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/components/tagify.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/tagify.js @@ -386,11 +386,16 @@ export default class Tagify { this.#buildOptions(options || { }, phenotype) .catch(e => console.error(e)) .finally(() => { + let callback; if (this.options?.onLoad && this.options.onLoad instanceof Function) { - this.options.onLoad(this); + callback = this.options.onLoad(this); } this.#bindEvents(); + + if (typeof callback === 'function') { + callback(this); + } }); } diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/entityCreator/utils.js b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/entityCreator/utils.js index 792862024..06cd02250 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/entityCreator/utils.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/entityCreator/utils.js @@ -24,7 +24,6 @@ export const ENTITY_HANDLERS = { // Generates a groupedenum component context 'groupedenum': (element) => { const data = element.parentNode.querySelectorAll(`script[type="application/json"][for="${element.getAttribute('data-field')}"]`); - const packet = { }; for (let i = 0; i < data.length; ++i) { let datafield = data[i]; @@ -46,8 +45,12 @@ export const ENTITY_HANDLERS = { // Generates a tagify component for an element 'tagify': (element, dataset) => { - const data = element.parentNode.querySelectorAll(`script[type="application/json"][for="${element.getAttribute('data-field')}"]`); - + const parent = element.parentElement; + const data = parent.querySelectorAll(`script[type="application/json"][for="${element.getAttribute('data-field')}"]`); + + let varyDataVis = parseInt(element.getAttribute('data-vis') ?? '0'); + varyDataVis = !Number.isNaN(varyDataVis) && Boolean(varyDataVis); + let value = []; let options = []; for (let i = 0; i < data.length; ++i) { @@ -88,6 +91,17 @@ export const ENTITY_HANDLERS = { box.addTag(item.name, item.value); } + + return () => { + if (!varyDataVis) { + return; + } + + const choices = box?.options?.items?.length ?? 0; + if (choices < 1) { + parent.style.setProperty('display', 'none'); + } + } } }, dataset); diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/services/referenceDataService.js b/CodeListLibrary_project/cll/static/js/clinicalcode/services/referenceDataService.js index 3a12be8db..67481b1a9 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/services/referenceDataService.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/services/referenceDataService.js @@ -26,7 +26,7 @@ const const RDS_REFERENCE_MAP = (item, index) => { return [ index, - item.id, + item?.id ?? item?.value, item.name ]; } diff --git a/CodeListLibrary_project/cll/templates/clinicalcode/about/reference_data.html b/CodeListLibrary_project/cll/templates/clinicalcode/about/reference_data.html index 4788791f8..a2d3ebe86 100644 --- a/CodeListLibrary_project/cll/templates/clinicalcode/about/reference_data.html +++ b/CodeListLibrary_project/cll/templates/clinicalcode/about/reference_data.html @@ -71,46 +71,49 @@
-
- {% if tags %} + {% if tags and tags|length > 0 %} +
{% to_json_script tags data-owner="reference-data-service" name="tags" desc-type="text/json" %} - {% endif %} -

Tags

-

Optional keywords helping to categorize this content.

-
+

Tags

+

Optional keywords helping to categorize this content.

+
-
-
-
- {% if collections %} +
+ + {% endif %} + + {% if collections and collections|length > 0 %} +
{% to_json_script collections data-owner="reference-data-service" name="collections" desc-type="text/json" %} - {% endif %} -

Collections

-

List of content collections this phenotype belongs to.

-
+

Collections

+

List of content collections this phenotype belongs to.

+
-
-
-
- {% if coding_system %} + +
+ {% endif %} + + {% if coding_system and coding_system|length > 0 %} +
{% to_json_script coding_system data-owner="reference-data-service" name="coding_system" desc-type="text/json" %} - {% endif %} -

Coding Systems

-

Clinical coding system(s) that relate to Phenotypes.

-
+

Coding Systems

+

Clinical coding system(s) that relate to Phenotypes.

+
-
-
-
- {% if data_sources %} - {% to_json_script data_sources data-owner="reference-data-service" name="data_sources" desc-type="text/json" %} - {% endif %} -

Data Sources

-

Data sources the phenotype creators have run this phenotype against; or view as appropriate to use this phenotype for.

-
+
+
+ {% endif %} - - + {% if data_sources and data_sources|length > 0 %} +
+ {% to_json_script data_sources data-owner="reference-data-service" name="data_sources" desc-type="text/json" %} +

Data Sources

+

Data sources the phenotype creators have run this phenotype against; or view as appropriate to use this phenotype for.

+
+ +
+
+ {% endif %} {% if ontology_groups %}
diff --git a/CodeListLibrary_project/cll/templates/components/create/inputs/tagbox.html b/CodeListLibrary_project/cll/templates/components/create/inputs/tagbox.html index a3ddf83ac..1d071f82c 100644 --- a/CodeListLibrary_project/cll/templates/components/create/inputs/tagbox.html +++ b/CodeListLibrary_project/cll/templates/components/create/inputs/tagbox.html @@ -22,6 +22,10 @@

{% endif %} - - + + \ No newline at end of file From 5a520282ad73010720fa3e37f39237dea5d1a991 Mon Sep 17 00:00:00 2001 From: JackScanlon Date: Wed, 12 Mar 2025 10:28:25 +0000 Subject: [PATCH 027/164] feat: administrator asset & relation interface; fix!: phenoflow retargeting; now records URL/UID alongside PID --- .../clinicalcode/models/DataSource.py | 3 +- .../clinicalcode/models/HDRNDataAsset.py | 6 +- .../templatetags/entity_renderer.py | 1 - .../clinicalcode/tests/conftest.py | 2 +- CodeListLibrary_project/clinicalcode/urls.py | 4 +- .../clinicalcode/views/Admin.py | 14 +- .../clinicalcode/views/adminTemp.py | 265 ++++++++++++++++-- .../cll/static/scss/components/_inputs.scss | 4 + .../adminTemp/admin_temp_tool.html | 17 +- .../components/create/inputs/tagbox.html | 2 +- .../dynamic_templates/bhf_phenotype.json | 2 +- .../clinical_coded_phenotype.json | 2 +- .../structured_data_algorithm_phenotype.json | 2 +- 13 files changed, 271 insertions(+), 53 deletions(-) diff --git a/CodeListLibrary_project/clinicalcode/models/DataSource.py b/CodeListLibrary_project/clinicalcode/models/DataSource.py index edd6d3fa0..d1ae99b5d 100644 --- a/CodeListLibrary_project/clinicalcode/models/DataSource.py +++ b/CodeListLibrary_project/clinicalcode/models/DataSource.py @@ -23,10 +23,9 @@ class DataSource(TimeStampedModel): null=True, related_name="data_source_updated") datasource_id = models.IntegerField(unique=False, null=True) + source = models.CharField(max_length=100, null=True, blank=True) history = HistoricalRecords() - source = models.CharField(max_length=100, null=True, blank=True) - def __str__(self): return self.name diff --git a/CodeListLibrary_project/clinicalcode/models/HDRNDataAsset.py b/CodeListLibrary_project/clinicalcode/models/HDRNDataAsset.py index aaa0d5401..6bfbd6e8f 100644 --- a/CodeListLibrary_project/clinicalcode/models/HDRNDataAsset.py +++ b/CodeListLibrary_project/clinicalcode/models/HDRNDataAsset.py @@ -19,15 +19,17 @@ class HDRNDataAsset(TimeStampedModel): hdrn_uuid = models.UUIDField(primary_key=False, null=True, blank=True) # Metadata + link = models.URLField(max_length=500, blank=True, null=True) site = models.ForeignKey(HDRNSite, on_delete=models.SET_NULL, null=True, related_name='data_assets') - scope = models.CharField(max_length=256, unique=False, null=True, blank=True) + years = models.CharField(max_length=256, unique=False, null=True, blank=True) + scope = models.TextField(null=True, blank=True) region = models.CharField(max_length=2048, unique=False, null=True, blank=True) purpose = models.TextField(null=True, blank=True) collection_period = models.TextField(null=True, blank=True) data_level = models.CharField(max_length=256, unique=False, null=True, blank=True) - data_categories = ArrayField(models.TextField(), blank=True, null=True) + data_categories = ArrayField(models.IntegerField(), blank=True, null=True) class Meta: ordering = ('name', ) diff --git a/CodeListLibrary_project/clinicalcode/templatetags/entity_renderer.py b/CodeListLibrary_project/clinicalcode/templatetags/entity_renderer.py index 5b193af26..3b051743a 100644 --- a/CodeListLibrary_project/clinicalcode/templatetags/entity_renderer.py +++ b/CodeListLibrary_project/clinicalcode/templatetags/entity_renderer.py @@ -502,7 +502,6 @@ def __render_metadata_component(self, context, field, structure): if 'compute_statistics' in structure: current_brand = request.CURRENT_BRAND or 'ALL' options = search_utils.get_metadata_stats_by_field(field, brand=current_brand) - print(field, options) # options = self.__check_excluded_brand_collections(context, field, current_brand, options) if options is None: diff --git a/CodeListLibrary_project/clinicalcode/tests/conftest.py b/CodeListLibrary_project/clinicalcode/tests/conftest.py index e0b241c9d..678ef95ed 100644 --- a/CodeListLibrary_project/clinicalcode/tests/conftest.py +++ b/CodeListLibrary_project/clinicalcode/tests/conftest.py @@ -43,7 +43,7 @@ def generate_user(create_groups): 'view_group_user': View group user instance 'edit_group_user': Edit group user instance """ - su_user = User.objects.create_superuser(username='superuser', password='superuserpassword', email=None) + su_user = User.objects.create_user(username='superuser', password='superuserpassword', email=None, is_superuser=True, is_staff=True) nm_user = User.objects.create_user(username='normaluser', password='normaluserpassword', email=None) ow_user = User.objects.create_user(username='owneruser', password='owneruserpassword', email=None) gp_user = User.objects.create_user(username='groupuser', password='groupuserpassword', email=None) diff --git a/CodeListLibrary_project/clinicalcode/urls.py b/CodeListLibrary_project/clinicalcode/urls.py index f6f4a1a34..923c7e1f0 100644 --- a/CodeListLibrary_project/clinicalcode/urls.py +++ b/CodeListLibrary_project/clinicalcode/urls.py @@ -115,9 +115,11 @@ # url(r'^adminTemp/admin_force_links_dt/$', adminTemp.admin_force_concept_linkage_dt, name='admin_force_links_dt'), # url(r'^adminTemp/admin_fix_breathe_dt/$', adminTemp.admin_fix_breathe_dt, name='admin_fix_breathe_dt'), # url(r'^adminTemp/admin_fix_malformed_codes/$', adminTemp.admin_fix_malformed_codes, name='admin_fix_malformed_codes'), + #url(r'^adminTemp/admin_update_phenoflowids/$', adminTemp.admin_update_phenoflowids, name='admin_update_phenoflowids'), url(r'^adminTemp/admin_force_adp_links/$', adminTemp.admin_force_adp_linkage, name='admin_force_adp_links'), url(r'^adminTemp/admin_fix_coding_system_linkage/$', adminTemp.admin_fix_coding_system_linkage, name='admin_fix_coding_system_linkage'), url(r'^adminTemp/admin_fix_concept_linkage/$', adminTemp.admin_fix_concept_linkage, name='admin_fix_concept_linkage'), url(r'^adminTemp/admin_force_brand_links/$', adminTemp.admin_force_brand_links, name='admin_force_brand_links'), - url(r'^adminTemp/admin_update_phenoflowids/$', adminTemp.admin_update_phenoflowids, name='admin_update_phenoflowids'), + url(r'^adminTemp/admin_update_phenoflow_targets/$', adminTemp.admin_update_phenoflow_targets, name='admin_update_phenoflow_targets'), + url(r'^adminTemp/admin_upload_hdrn_assets/$', adminTemp.admin_upload_hdrn_assets, name='admin_upload_hdrn_assets'), ] diff --git a/CodeListLibrary_project/clinicalcode/views/Admin.py b/CodeListLibrary_project/clinicalcode/views/Admin.py index bf024ba18..195690c5d 100644 --- a/CodeListLibrary_project/clinicalcode/views/Admin.py +++ b/CodeListLibrary_project/clinicalcode/views/Admin.py @@ -1,15 +1,15 @@ -from django.db.models import Q +from http import HTTPStatus +from celery import shared_task from django.conf import settings -from django.contrib.auth.models import AnonymousUser -from django.core.exceptions import PermissionDenied -from django.core.exceptions import BadRequest from django.test import RequestFactory +from django.db.models import Q from django.shortcuts import render from django.views.generic import TemplateView -from django.contrib.auth.decorators import login_required +from django.core.exceptions import BadRequest +from django.core.exceptions import PermissionDenied from django.utils.decorators import method_decorator -from celery import shared_task -from http import HTTPStatus +from django.contrib.auth.models import AnonymousUser +from django.contrib.auth.decorators import login_required import time import logging diff --git a/CodeListLibrary_project/clinicalcode/views/adminTemp.py b/CodeListLibrary_project/clinicalcode/views/adminTemp.py index bce9a64d1..b3ca6d237 100644 --- a/CodeListLibrary_project/clinicalcode/views/adminTemp.py +++ b/CodeListLibrary_project/clinicalcode/views/adminTemp.py @@ -1,32 +1,65 @@ from datetime import datetime -from django.db.models import Q -from django.utils.timezone import make_aware -from django.db import connection, transaction +from operator import is_not +from functools import partial +from django.db import connection from django.conf import settings -from django.contrib import messages from django.urls import reverse -from django.core.exceptions import BadRequest, PermissionDenied -from django.contrib.auth.decorators import login_required -from django.contrib.auth.models import Group +from django.db.models import Q from django.shortcuts import render +from django.utils.timezone import make_aware from rest_framework.reverse import reverse +from django.core.exceptions import BadRequest, PermissionDenied +from django.contrib.auth.models import Group +from django.contrib.auth.decorators import login_required import re import json import logging +import dateutil + +from clinicalcode.entity_utils import permission_utils, gen_utils -from clinicalcode.entity_utils import permission_utils, gen_utils, create_utils from clinicalcode.models.Tag import Tag +from clinicalcode.models.Brand import Brand from clinicalcode.models.Concept import Concept from clinicalcode.models.Template import Template from clinicalcode.models.Phenotype import Phenotype -from clinicalcode.models.WorkingSet import WorkingSet from clinicalcode.models.GenericEntity import GenericEntity from clinicalcode.models.PublishedPhenotype import PublishedPhenotype +from clinicalcode.models.HDRNSite import HDRNSite +from clinicalcode.models.HDRNDataAsset import HDRNDataAsset +from clinicalcode.models.HDRNDataCategory import HDRNDataCategory + logger = logging.getLogger(__name__) +#### Const #### +BASE_LINKAGE_TEMPLATE = { + # all sex is '3' unless specified by user + 'sex': '3', + # all PhenotypeType is 'Disease or syndrome' unless specified by user + 'type': '2', + # all version is '1' for migration + 'version': '1', +} + #### Dynamic Template #### +def try_parse_hdrn_datetime(obj, default=None): + if not isinstance(obj, dict): + return default + + typed = obj.get('type') + value = obj.get('value') + if typed != 'datetime' or not isinstance(value, str) or gen_utils.is_empty_string(value): + return default + + try: + result = datetime.strptime(value, '%Y-%m-%dT%H:%M:%S.%f') + except: + return default + else: + return make_aware(result) + def sort_pk_list(a, b): pk1 = int(a.replace('PH', '')) pk2 = int(b.replace('PH', '')) @@ -72,15 +105,6 @@ def compute_related_brands(pheno, default=''): related_brands = ','.join([str(x) for x in list(related_brands)]) return "brands='{%s}' " % related_brands -BASE_LINKAGE_TEMPLATE = { - # all sex is '3' unless specified by user - 'sex': '3', - # all PhenotypeType is 'Disease or syndrome' unless specified by user - 'type': '2', - # all version is '1' for migration - 'version': '1', -} - def get_publications(concept): publication_doi = concept.publication_doi publication_link = concept.publication_link @@ -732,6 +756,207 @@ def admin_update_phenoflowids(request): } ) +@login_required +def admin_upload_hdrn_assets(request): + if settings.CLL_READ_ONLY: + raise PermissionDenied + + if not request.user.is_superuser: + raise PermissionDenied + + if not permission_utils.is_member(request.user, 'system developers'): + raise PermissionDenied + + # get + if request.method == 'GET': + return render( + request, + 'clinicalcode/adminTemp/admin_temp_tool.html', + { + 'url': reverse('admin_upload_hdrn_assets'), + 'action_title': 'Upload HDRN Assets', + 'hide_phenotype_options': False, + } + ) + + # post + if request.method != 'POST': + raise BadRequest('Invalid') + + try: + input_data = request.POST.get('input_data') + input_data = json.loads(input_data) + except: + return render( + request, + 'clinicalcode/adminTemp/admin_temp_tool.html', + { + 'pk': -10, + 'errorMsg': { 'message': 'Unable to read data provided' }, + 'action_title': 'Upload HDRN Assets', + 'hide_phenotype_options': True, + } + ) + + brand = Brand.objects.filter(name__iexact='HDRN') + if brand.exists(): + brand = brand.first() + else: + brand = Brand.objects.create(name='HDRN') + + models = {} + metadata = input_data.get('metadata') + for key, data in metadata.items(): + result = None + match key: + case 'site': + '''HDRNSite''' + result = HDRNSite.objects.bulk_create([HDRNSite(name=v) for v in data]) + case 'categories': + '''Tags (tag_type=2)''' + result = Tag.objects.bulk_create([Tag(description=v, tag_type=2, collection_brand=brand) for v in data]) + case 'data_categories': + '''HDRNDataCategory''' + result = HDRNDataCategory.objects.bulk_create([HDRNDataCategory(name=v) for v in data]) + case _: + pass + + if result is not None: + models.update({ key: result }) + + now = make_aware(datetime.now()) + assets = input_data.get('assets') + to_create = [] + + for data in assets: + site = data.get('site') + cats = data.get('data_categories') + + if isinstance(cats, list): + cats = [next((x for x in models.get('data_categories') if x.id == v), None) for v in cats] + cats = [x.id for x in cats if x is not None] + + site = next((x for x in models.get('site') if x.id == site), None) if isinstance(site, int) else None + created_date = try_parse_hdrn_datetime(data.get('created_date', None), default=now) + modified_date = try_parse_hdrn_datetime(data.get('modified_date', None), default=now) + + to_create.append(HDRNDataAsset( + name=data.get('name'), + description=data.get('description'), + hdrn_id=data.get('hdrn_id'), + hdrn_uuid=data.get('hdrn_uuid'), + site=site, + link=data.get('link'), + years=data.get('years'), + scope=data.get('scope'), + region=data.get('region'), + purpose=data.get('purpose'), + collection_period=data.get('collection_period'), + data_level=data.get('data_level'), + data_categories=cats, + created=created_date, + modified=modified_date + )) + + models.update({ 'assets': HDRNDataAsset.objects.bulk_create(to_create) }) + + return render( + request, + 'clinicalcode/adminTemp/admin_temp_tool.html', + { + 'pk': -10, + 'rowsAffected' : { k: len(v) for k, v in models.items() }, + 'action_title': 'Upload HDRN Assets', + 'hide_phenotype_options': True, + } + ) + +@login_required +def admin_update_phenoflow_targets(request): + if settings.CLL_READ_ONLY: + raise PermissionDenied + + if not request.user.is_superuser: + raise PermissionDenied + + if not permission_utils.is_member(request.user, 'system developers'): + raise PermissionDenied + + # get + if request.method == 'GET': + return render( + request, + 'clinicalcode/adminTemp/admin_temp_tool.html', + { + 'url': reverse('admin_update_phenoflow_targets'), + 'action_title': 'Update Phenoflow Targets', + 'hide_phenotype_options': False, + } + ) + + # post + if request.method != 'POST': + raise BadRequest('Invalid') + + try: + input_data = request.POST.get('input_data') + input_data = json.loads(input_data) + except: + return render( + request, + 'clinicalcode/adminTemp/admin_temp_tool.html', + { + 'pk': -10, + 'errorMsg': { 'message': 'Unable to read data provided' }, + 'action_title': 'Update Phenoflow Targets', + 'hide_phenotype_options': True, + } + ) + + with connection.cursor() as cursor: + sql = f''' + update public.clinicalcode_genericentity as trg + set template_data['phenoflowid'] = to_jsonb(src.target) + from ( + select * + from jsonb_to_recordset( + '{json.dumps(input_data)}'::jsonb + ) as x(id varchar, source int, target varchar) + ) as src + where trg.id = src.id + and trg.template_data::jsonb ? 'phenoflowid'; + ''' + cursor.execute(sql) + entity_updates = cursor.rowcount + + sql = f''' + update public.clinicalcode_historicalgenericentity as trg + set template_data['phenoflowid'] = to_jsonb(src.target) + from ( + select * + from jsonb_to_recordset( + '{json.dumps(input_data)}'::jsonb + ) as x(id varchar, source int, target varchar) + ) as src + where trg.id = src.id + and trg.template_data::jsonb ? 'phenoflowid'; + ''' + cursor.execute(sql) + historical_updates = cursor.rowcount + + return render( + request, + 'clinicalcode/adminTemp/admin_temp_tool.html', + { + 'pk': -10, + 'rowsAffected' : { + '1': f'entities: {entity_updates}, historical: {historical_updates}' + }, + 'action_title': 'Update Phenoflow Targets', + 'hide_phenotype_options': True, + } + ) + @login_required def admin_force_concept_linkage_dt(request): """ @@ -968,10 +1193,6 @@ def admin_mig_phenotypes_dt(request): elif request.method == 'POST': if not settings.CLL_READ_ONLY: - code = request.POST.get('code') - if code.strip() != "6)r&9hpr_a0_4g(xan5p@=kaz2q_cd(v5n^!#ru*_(+d)#_0-i": - raise PermissionDenied - phenotype_ids = request.POST.get('phenotype_ids') phenotype_ids = phenotype_ids.strip().upper() diff --git a/CodeListLibrary_project/cll/static/scss/components/_inputs.scss b/CodeListLibrary_project/cll/static/scss/components/_inputs.scss index 245cb0ff1..e0eec76b6 100644 --- a/CodeListLibrary_project/cll/static/scss/components/_inputs.scss +++ b/CodeListLibrary_project/cll/static/scss/components/_inputs.scss @@ -1175,6 +1175,10 @@ max-width: calc(100% - 1rem); transition: border-color 250ms ease; + &--bbox-size { + box-sizing: border-box; + } + &:focus { outline: none; border-color: col(accent-dark); diff --git a/CodeListLibrary_project/cll/templates/clinicalcode/adminTemp/admin_temp_tool.html b/CodeListLibrary_project/cll/templates/clinicalcode/adminTemp/admin_temp_tool.html index e7427d5f8..b2f0ad74e 100644 --- a/CodeListLibrary_project/cll/templates/clinicalcode/adminTemp/admin_temp_tool.html +++ b/CodeListLibrary_project/cll/templates/clinicalcode/adminTemp/admin_temp_tool.html @@ -70,17 +70,6 @@
{% csrf_token %} {% if not hide_phenotype_options %} -
-

- Auth-Code -

-

- Authentication Code -

- -
-

Data @@ -88,8 +77,10 @@

Data to send

- +

{% endif %} diff --git a/CodeListLibrary_project/cll/templates/components/create/inputs/tagbox.html b/CodeListLibrary_project/cll/templates/components/create/inputs/tagbox.html index 1d071f82c..0015c5190 100644 --- a/CodeListLibrary_project/cll/templates/components/create/inputs/tagbox.html +++ b/CodeListLibrary_project/cll/templates/components/create/inputs/tagbox.html @@ -28,4 +28,4 @@

name="entity-{{ component.field_name }}" placeholder="" data-class="tagify" data-field="{{ component.field_name }}" data-vis="{% if component.vis_vary_on_opts %}1{% else %}0{% endif %}"> - \ No newline at end of file + diff --git a/CodeListLibrary_project/dynamic_templates/bhf_phenotype.json b/CodeListLibrary_project/dynamic_templates/bhf_phenotype.json index 5ef6df103..d0996bac3 100644 --- a/CodeListLibrary_project/dynamic_templates/bhf_phenotype.json +++ b/CodeListLibrary_project/dynamic_templates/bhf_phenotype.json @@ -291,7 +291,7 @@ "validation": { "type": "string", "mandatory": false, - "length": [0, 250], + "length": [0, 500], "regex": "^https?:\\/\\/(?:www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_\\+.~#?&\\/=]*)$" }, "hide_if_empty": true diff --git a/CodeListLibrary_project/dynamic_templates/clinical_coded_phenotype.json b/CodeListLibrary_project/dynamic_templates/clinical_coded_phenotype.json index 20f5cf10a..e30829ae9 100644 --- a/CodeListLibrary_project/dynamic_templates/clinical_coded_phenotype.json +++ b/CodeListLibrary_project/dynamic_templates/clinical_coded_phenotype.json @@ -306,7 +306,7 @@ "validation": { "type": "string", "mandatory": false, - "length": [0, 250], + "length": [0, 500], "regex": "^https?:\\/\\/(?:www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_\\+.~#?&\\/=]*)$" }, "hide_if_empty": true diff --git a/CodeListLibrary_project/dynamic_templates/structured_data_algorithm_phenotype.json b/CodeListLibrary_project/dynamic_templates/structured_data_algorithm_phenotype.json index bdc90677b..dc079de79 100644 --- a/CodeListLibrary_project/dynamic_templates/structured_data_algorithm_phenotype.json +++ b/CodeListLibrary_project/dynamic_templates/structured_data_algorithm_phenotype.json @@ -122,7 +122,7 @@ "validation": { "type": "string", "mandatory": false, - "length": [0, 250], + "length": [0, 500], "regex": "^https?:\\/\\/(?:www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_\\+.~#?&\\/=]*)$" }, "hide_if_empty": true From a2ee22a7b9925afede517f947e559f1c15dbd457 Mon Sep 17 00:00:00 2001 From: JackScanlon Date: Wed, 12 Mar 2025 10:47:10 +0000 Subject: [PATCH 028/164] fix: use tags as asset cat --- .../clinicalcode/models/HDRNDataAsset.py | 2 +- .../clinicalcode/views/adminTemp.py | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/CodeListLibrary_project/clinicalcode/models/HDRNDataAsset.py b/CodeListLibrary_project/clinicalcode/models/HDRNDataAsset.py index 6bfbd6e8f..1a1f267d0 100644 --- a/CodeListLibrary_project/clinicalcode/models/HDRNDataAsset.py +++ b/CodeListLibrary_project/clinicalcode/models/HDRNDataAsset.py @@ -29,7 +29,7 @@ class HDRNDataAsset(TimeStampedModel): collection_period = models.TextField(null=True, blank=True) data_level = models.CharField(max_length=256, unique=False, null=True, blank=True) - data_categories = ArrayField(models.IntegerField(), blank=True, null=True) + data_categories = ArrayField(models.IntegerField(), blank=True, null=True) # Note: ref to `Tag` class Meta: ordering = ('name', ) diff --git a/CodeListLibrary_project/clinicalcode/views/adminTemp.py b/CodeListLibrary_project/clinicalcode/views/adminTemp.py index b3ca6d237..266495b61 100644 --- a/CodeListLibrary_project/clinicalcode/views/adminTemp.py +++ b/CodeListLibrary_project/clinicalcode/views/adminTemp.py @@ -813,14 +813,14 @@ def admin_upload_hdrn_assets(request): '''HDRNSite''' result = HDRNSite.objects.bulk_create([HDRNSite(name=v) for v in data]) case 'categories': - '''Tags (tag_type=2)''' - result = Tag.objects.bulk_create([Tag(description=v, tag_type=2, collection_brand=brand) for v in data]) - case 'data_categories': '''HDRNDataCategory''' result = HDRNDataCategory.objects.bulk_create([HDRNDataCategory(name=v) for v in data]) + case 'data_categories': + '''Tag (tag_type=2)''' + result = Tag.objects.bulk_create([Tag(description=v, tag_type=2, collection_brand=brand) for v in data]) case _: pass - + if result is not None: models.update({ key: result }) @@ -833,8 +833,7 @@ def admin_upload_hdrn_assets(request): cats = data.get('data_categories') if isinstance(cats, list): - cats = [next((x for x in models.get('data_categories') if x.id == v), None) for v in cats] - cats = [x.id for x in cats if x is not None] + cats = [models.get('data_categories')[v - 1].id for v in cats if models.get('data_categories')[v - 1] is not None] site = next((x for x in models.get('site') if x.id == site), None) if isinstance(site, int) else None created_date = try_parse_hdrn_datetime(data.get('created_date', None), default=now) From 27f8a0e97a503ea4c958cfa66e103cd277ba9980 Mon Sep 17 00:00:00 2001 From: JackScanlon Date: Wed, 12 Mar 2025 10:50:54 +0000 Subject: [PATCH 029/164] feat: use tags instead of collections --- CodeListLibrary_project/clinicalcode/views/adminTemp.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CodeListLibrary_project/clinicalcode/views/adminTemp.py b/CodeListLibrary_project/clinicalcode/views/adminTemp.py index 266495b61..e1369649f 100644 --- a/CodeListLibrary_project/clinicalcode/views/adminTemp.py +++ b/CodeListLibrary_project/clinicalcode/views/adminTemp.py @@ -816,8 +816,8 @@ def admin_upload_hdrn_assets(request): '''HDRNDataCategory''' result = HDRNDataCategory.objects.bulk_create([HDRNDataCategory(name=v) for v in data]) case 'data_categories': - '''Tag (tag_type=2)''' - result = Tag.objects.bulk_create([Tag(description=v, tag_type=2, collection_brand=brand) for v in data]) + '''Tag (tag_type=1)''' + result = Tag.objects.bulk_create([Tag(description=v, tag_type=1, collection_brand=brand) for v in data]) case _: pass From 8e992b831040247511796a8baf033d9f65f12b54 Mon Sep 17 00:00:00 2001 From: Arthur zinnurov Date: Thu, 13 Mar 2025 15:49:09 +0000 Subject: [PATCH 030/164] changing publish file so it can work in org env --- .../clinicalcode/views/Publish.py | 78 +++++++++++-------- 1 file changed, 46 insertions(+), 32 deletions(-) diff --git a/CodeListLibrary_project/clinicalcode/views/Publish.py b/CodeListLibrary_project/clinicalcode/views/Publish.py index d5eae9cfd..8f6d3cf65 100644 --- a/CodeListLibrary_project/clinicalcode/views/Publish.py +++ b/CodeListLibrary_project/clinicalcode/views/Publish.py @@ -68,31 +68,38 @@ def post(self,request,pk, history_id): with transaction.atomic(): entity = GenericEntity.objects.get(pk=pk) - #Check if moderator first and if was already approved to filter by only approved entitys - if checks['is_moderator']: - if checks['is_lastapproved']: - self.last_approved_publish(self.request,entity,history_id) - else: - self.moderator_publish(self.request,history_id,pk,checks,data) - - if checks['is_publisher']: - published_entity = PublishedGenericEntity(entity=entity,entity_history_id=history_id, moderator_id=request.user.id, + if checks['org_user_managed']: + if checks['is_moderator']: + published_entity = PublishedGenericEntity(entity=entity,entity_history_id=history_id, moderator_id=request.user.id, created_by_id=GenericEntity.objects.get(pk=pk).created_by.id,approval_status=constants.APPROVAL_STATUS.APPROVED) - published_entity.save() - - #Check if was already published by user only to filter entitys and take the moderator id - if checks['is_lastapproved'] and not checks['is_moderator'] and not checks['is_publisher']: - self.last_approved_publish(self.request,entity,history_id) - - #Approve other pending entity if available to publish - if checks['other_pending']: - published_entity = PublishedGenericEntity.objects.filter(entity_id=entity.id, - approval_status=constants.APPROVAL_STATUS.PENDING) - for en in published_entity: - en.approval_status = constants.APPROVAL_STATUS.APPROVED - en.moderator_id = self.request.user.id - en.modified = make_aware(datetime.now()) - en.save() + published_entity.save() + + else: + #Check if moderator first and if was already approved to filter by only approved entitys + if checks['is_moderator']: + if checks['is_lastapproved']: + self.last_approved_publish(self.request,entity,history_id) + else: + self.moderator_publish(self.request,history_id,pk,checks,data) + + if checks['is_publisher']: + published_entity = PublishedGenericEntity(entity=entity,entity_history_id=history_id, moderator_id=request.user.id, + created_by_id=GenericEntity.objects.get(pk=pk).created_by.id,approval_status=constants.APPROVAL_STATUS.APPROVED) + published_entity.save() + + #Check if was already published by user only to filter entitys and take the moderator id + if checks['is_lastapproved'] and not checks['is_moderator'] and not checks['is_publisher']: + self.last_approved_publish(self.request,entity,history_id) + + #Approve other pending entity if available to publish + if checks['other_pending']: + published_entity = PublishedGenericEntity.objects.filter(entity_id=entity.id, + approval_status=constants.APPROVAL_STATUS.PENDING) + for en in published_entity: + en.approval_status = constants.APPROVAL_STATUS.APPROVED + en.moderator_id = self.request.user.id + en.modified = make_aware(datetime.now()) + en.save() data['form_is_valid'] = True data['approval_status'] = constants.APPROVAL_STATUS.APPROVED @@ -138,14 +145,21 @@ def condition_to_publish(self,checks,is_published): def moderator_publish(self,request,history_id,pk,conditions,data): entity = GenericEntity.objects.get(pk=pk) if conditions['approval_status'] == constants.APPROVAL_STATUS.PENDING: - published_entity = PublishedGenericEntity.objects.filter(entity_id=entity.id, - approval_status=constants.APPROVAL_STATUS.PENDING) - #filter and publish all pending ws - for en in published_entity: - en.approval_status = constants.APPROVAL_STATUS.APPROVED - en.modified = make_aware(datetime.now()) - en.moderator_id = request.user.id - en.save() + if conditions['org_user_managed']: + published_entity = PublishedGenericEntity.objects.get(entity_id=entity.id,entity_history_id=history_id,approval_status=constants.APPROVAL_STATUS.PENDING) + published_entity.approval_status = constants.APPROVAL_STATUS.APPROVED + published_entity.modified = make_aware(datetime.now()) + published_entity.moderator_id = request.user.id + published_entity.save() + else: + published_entity = PublishedGenericEntity.objects.filter(entity_id=entity.id, + approval_status=constants.APPROVAL_STATUS.PENDING) + #filter and publish all pending ws + for en in published_entity: + en.approval_status = constants.APPROVAL_STATUS.APPROVED + en.modified = make_aware(datetime.now()) + en.moderator_id = request.user.id + en.save() data['approval_status'] = constants.APPROVAL_STATUS.APPROVED data['form_is_valid'] = True From 9f92809c57981dbae55b87efebbde00e7aeae4fc Mon Sep 17 00:00:00 2001 From: Arthur zinnurov Date: Thu, 13 Mar 2025 15:49:36 +0000 Subject: [PATCH 031/164] changing template tag button visibility --- .../clinicalcode/templatetags/entity_publish_renderer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CodeListLibrary_project/clinicalcode/templatetags/entity_publish_renderer.py b/CodeListLibrary_project/clinicalcode/templatetags/entity_publish_renderer.py index 00c8b200c..20c7f5ffe 100644 --- a/CodeListLibrary_project/clinicalcode/templatetags/entity_publish_renderer.py +++ b/CodeListLibrary_project/clinicalcode/templatetags/entity_publish_renderer.py @@ -87,7 +87,7 @@ def render_publish_button(context, *args, **kwargs): }) return button_context elif user_allowed_publish: - if not context["is_lastapproved"] and (context["approval_status"] is None or context["approval_status"] == constants.APPROVAL_STATUS.ANY) and user_entity_access and not context["live_ver_is_deleted"]: + if not publish_checks["is_lastapproved"] and (publish_checks["approval_status"] is None or publish_checks["approval_status"] == constants.APPROVAL_STATUS.ANY) and user_entity_access and not context["live_ver_is_deleted"]: if user_is_publisher: button_context.update({'class_modal':"primary-btn bold dropdown-btn__label", 'url': reverse('generic_entity_publish', kwargs={'pk': context['entity'].id, 'history_id': context['entity'].history_id}), @@ -100,7 +100,7 @@ def render_publish_button(context, *args, **kwargs): 'url': reverse('generic_entity_request_publish', kwargs={'pk': context['entity'].id, 'history_id': context['entity'].history_id}), 'title': "Needs to be approved" }) - elif context["is_lastapproved"] and not context["live_ver_is_deleted"] and context["approval_status"] != constants.APPROVAL_STATUS.REJECTED: + elif publish_checks["is_lastapproved"] and not context["live_ver_is_deleted"] and context["approval_status"] != constants.APPROVAL_STATUS.REJECTED: button_context.update({'class_modal':"primary-btn bold dropdown-btn__label", 'url': reverse('generic_entity_publish', kwargs={'pk': context['entity'].id, 'history_id': context['entity'].history_id}), 'Button_type': "Publish", From b7737100df1dac6c4d1cadfd6dd2b5f24eeb9cd4 Mon Sep 17 00:00:00 2001 From: Arthur zinnurov Date: Thu, 13 Mar 2025 15:49:52 +0000 Subject: [PATCH 032/164] reset everything if using org --- .../clinicalcode/entity_utils/publish_utils.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py index 4b8400b59..d6b807161 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py @@ -153,8 +153,9 @@ def check_entity_to_publish(request, pk, entity_history_id): 'is_latest_pending_version': is_latest_pending_version, 'all_are_published': all_are_published, 'all_not_deleted': all_not_deleted - } | organisation_checks + } + checks |= organisation_checks print(checks) return checks @@ -173,16 +174,23 @@ def check_organisation_authorities(request): if organisation_permissions['org_user_managed']: #Todo Fix the bug if moderator has 2 groups unless has to organisations and check if it from the same org + organisation_checks['org_user_managed'] = organisation_permissions['org_user_managed'] + # Reset everything because of organisations organisation_checks["allowed_to_publish"] = False organisation_checks["is_moderator"] = False - + organisation_checks["is_publisher"] = False + organisation_checks["other_pending"] = False + organisation_checks["is_lastapproved"] = False + organisation_checks["is_published"] = False + organisation_checks["is_latest_pending_version"] = False + organisation_checks["all_are_published"] = False + if organisation_permissions.get("can_post", False) and organisation_user_role.value >= 1: organisation_checks["allowed_to_publish"] = True if organisation_permissions.get("can_moderate", False) and organisation_user_role.value >= 2: organisation_checks["is_moderator"] = True organisation_checks["allowed_to_publish"] = True - else: return organisation_checks From 08dce38eb9e5b70ad9aa0c3d49ad9d5eee9a103e Mon Sep 17 00:00:00 2001 From: Arthur zinnurov Date: Thu, 13 Mar 2025 16:13:32 +0000 Subject: [PATCH 033/164] adding check if false --- CodeListLibrary_project/clinicalcode/views/Publish.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CodeListLibrary_project/clinicalcode/views/Publish.py b/CodeListLibrary_project/clinicalcode/views/Publish.py index 8f6d3cf65..3bce4be40 100644 --- a/CodeListLibrary_project/clinicalcode/views/Publish.py +++ b/CodeListLibrary_project/clinicalcode/views/Publish.py @@ -68,7 +68,7 @@ def post(self,request,pk, history_id): with transaction.atomic(): entity = GenericEntity.objects.get(pk=pk) - if checks['org_user_managed']: + if checks.get('org_user_managed', False): if checks['is_moderator']: published_entity = PublishedGenericEntity(entity=entity,entity_history_id=history_id, moderator_id=request.user.id, created_by_id=GenericEntity.objects.get(pk=pk).created_by.id,approval_status=constants.APPROVAL_STATUS.APPROVED) @@ -145,7 +145,7 @@ def condition_to_publish(self,checks,is_published): def moderator_publish(self,request,history_id,pk,conditions,data): entity = GenericEntity.objects.get(pk=pk) if conditions['approval_status'] == constants.APPROVAL_STATUS.PENDING: - if conditions['org_user_managed']: + if conditions.get('org_user_managed',False): published_entity = PublishedGenericEntity.objects.get(entity_id=entity.id,entity_history_id=history_id,approval_status=constants.APPROVAL_STATUS.PENDING) published_entity.approval_status = constants.APPROVAL_STATUS.APPROVED published_entity.modified = make_aware(datetime.now()) From e5ef36ad2657c82ae3c3abd7b001bf302e2fe079 Mon Sep 17 00:00:00 2001 From: Arthur zinnurov Date: Thu, 13 Mar 2025 16:20:36 +0000 Subject: [PATCH 034/164] using the normal flow of conditions instead of previos --- .../clinicalcode/entity_utils/publish_utils.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py index d6b807161..5e2f1661b 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py @@ -184,13 +184,13 @@ def check_organisation_authorities(request): organisation_checks["is_published"] = False organisation_checks["is_latest_pending_version"] = False organisation_checks["all_are_published"] = False - - if organisation_permissions.get("can_post", False) and organisation_user_role.value >= 1: - organisation_checks["allowed_to_publish"] = True + - if organisation_permissions.get("can_moderate", False) and organisation_user_role.value >= 2: - organisation_checks["is_moderator"] = True - organisation_checks["allowed_to_publish"] = True + if organisation_permissions.get("can_moderate", False): + if organisation_user_role.value >= 1: + organisation_checks["allowed_to_publish"] = True + if organisation_user_role.value >= 2: + organisation_checks["is_moderator"] = True else: return organisation_checks From 110215927cd63b17071b254a6b6c97f8198aef2a Mon Sep 17 00:00:00 2001 From: ieuans Date: Fri, 14 Mar 2025 13:46:52 +0000 Subject: [PATCH 035/164] feat: brand administration & cleanup --- CodeListLibrary_project/clinicalcode/admin.py | 5 +- .../clinicalcode/entity_utils/api_utils.py | 3 +- .../entity_utils/entity_db_utils.py | 22 +- .../clinicalcode/middleware/brands.py | 54 +- ...on_genericentity_organisation_and_more.py} | 2 +- ...> 0122_brand_org_user_managed_and_more.py} | 2 +- ...d_css_path_remove_brand_groups_and_more.py | 40 + .../clinicalcode/models/Brand.py | 35 +- .../templatetags/detail_pg_renderer.py | 428 ---------- .../templatetags/entity_renderer.py | 754 ++++++++++++++---- CodeListLibrary_project/cll/settings.py | 1 - .../generic_entity/detail/detail.html | 12 +- .../components/base/footer_link_img.html | 72 +- .../templates/components/base/navigation.html | 65 +- .../components/base/profile_menu.html | 8 +- .../templates/components/create/aside.html | 4 +- .../create/section/section_start.html | 2 +- .../templates/components/details/aside.html | 6 +- .../details/section/section_start.html | 2 +- 19 files changed, 771 insertions(+), 746 deletions(-) rename CodeListLibrary_project/clinicalcode/migrations/{0117_organisation_genericentity_organisation_and_more.py => 0121_organisation_genericentity_organisation_and_more.py} (98%) rename CodeListLibrary_project/clinicalcode/migrations/{0118_brand_org_user_managed_and_more.py => 0122_brand_org_user_managed_and_more.py} (89%) create mode 100644 CodeListLibrary_project/clinicalcode/migrations/0123_remove_brand_css_path_remove_brand_groups_and_more.py delete mode 100644 CodeListLibrary_project/clinicalcode/templatetags/detail_pg_renderer.py diff --git a/CodeListLibrary_project/clinicalcode/admin.py b/CodeListLibrary_project/clinicalcode/admin.py index 6523e2561..dff35d0df 100644 --- a/CodeListLibrary_project/clinicalcode/admin.py +++ b/CodeListLibrary_project/clinicalcode/admin.py @@ -54,10 +54,9 @@ def save_model(self, request, obj, form, change): @admin.register(Brand) class BrandAdmin(admin.ModelAdmin): - list_display = ['name', 'id', 'logo_path', 'owner', 'description'] - list_filter = ['name', 'description', 'created', 'modified', 'owner'] + list_filter = ['name', 'description', 'created', 'modified'] + list_display = ['name', 'id', 'logo_path', 'description'] search_fields = ['name', 'id', 'description'] - exclude = ['created_by', 'updated_by'] @admin.register(DataSource) diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/api_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/api_utils.py index f4b8215e5..f060c378b 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/api_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/api_utils.py @@ -650,8 +650,9 @@ def build_query_string_from_param(param, data, validation, field_type, is_dynami if is_dynamic: source = validation.get('source') trees = source.get('trees') if source and 'trees' in validation.get('source') else None + + data = [ str(x) for x in data.split(',') if gen_utils.try_value_as_type(x, 'string') is not None ] if trees: - data = [ str(x) for x in data.split(',') if gen_utils.try_value_as_type(x, 'string') is not None ] model = source.get('model') if isinstance(source.get('model'), str) else None if model: diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/entity_db_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/entity_db_utils.py index feddbc9a1..70c2315d8 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/entity_db_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/entity_db_utils.py @@ -123,26 +123,6 @@ def get_concept_ids_from_phenotypes(data, return_id_or_history_id="both"): return [] -def getConceptBrands(request, concept_list): - ''' - return concept brands - ''' - conceptBrands = {} - concepts = Concept.objects.filter(id__in=concept_list).values('id', 'name', 'group') - - for c in concepts: - conceptBrands[c['id']] = [] - if c['group'] != None: - g = Group.objects.get(pk=c['group']) - for item in request.BRAND_GROUPS: - for brand, groups in item.items(): - if g.name in groups: - #conceptBrands[c['id']].append('' + brand + '') - conceptBrands[c['id']].append(brand) - - return conceptBrands - - #============================================================================= # TO BE CONVERTED TO THE GENERIC ENTITY ....... @@ -483,7 +463,7 @@ def get_entity_full_template_data(entity_record, template_id, return_queryset_as entity_data_sources = fields_data[field_name]['value'] if entity_data_sources: if return_queryset_as_list: - data_sources = list(DataSource.objects.filter(pk__in=entity_data_sources).values('datasource_id', 'name', 'url')) + data_sources = list(DataSource.objects.filter(pk__in=entity_data_sources).values('id', 'name', 'url')) else: data_sources = DataSource.objects.filter(pk__in=entity_data_sources) fields_data[field_name]['value'] = data_sources diff --git a/CodeListLibrary_project/clinicalcode/middleware/brands.py b/CodeListLibrary_project/clinicalcode/middleware/brands.py index 6122fc3c9..3c6e62c2b 100644 --- a/CodeListLibrary_project/clinicalcode/middleware/brands.py +++ b/CodeListLibrary_project/clinicalcode/middleware/brands.py @@ -24,13 +24,11 @@ def process_request(self, request): # if the user is a member of 'ReadOnlyUsers' group, make READ-ONLY True if request.user.is_authenticated: CLL_READ_ONLY_org = self.get_env_value('CLL_READ_ONLY', cast='bool') - if settings.DEBUG: - print("CLL_READ_ONLY_org = ", str(CLL_READ_ONLY_org)) settings.CLL_READ_ONLY = CLL_READ_ONLY_org + if settings.DEBUG: - print("CLL_READ_ONLY_org (after) = ", str(CLL_READ_ONLY_org)) + print("CLL_READ_ONLY_org = ", str(CLL_READ_ONLY_org)) - #self.chkReadOnlyUsers(request) if not settings.CLL_READ_ONLY: if (request.user.groups.filter(name='ReadOnlyUsers').exists()): msg1 = "You are assigned as a Read-Only-User." @@ -42,11 +40,11 @@ def process_request(self, request): if settings.DEBUG: print("settings.CLL_READ_ONLY = ", str(settings.CLL_READ_ONLY)) - #--------------------------------- + #--------------------------------- - #if request.user.is_authenticated: - #print "...........start..............." - #brands = Brand.objects.values_list('name', flat=True) + #if request.user.is_authenticated: + #print "...........start..............." + #brands = Brand.objects.values_list('name', flat=True) brands = Brand.objects.all() brands_list = [x.upper() for x in list(brands.values_list('name', flat=True)) ] current_page_url = request.path_info.lstrip('/') @@ -85,24 +83,11 @@ def process_request(self, request): request.session['all_brands'] = brands_list #json.dumps(brands_list) request.session['current_brand'] = root - request.BRAND_GROUPS = [] - userBrands = [] - all_brands_groups = [] - for b in brands: - b_g = {} - groups = b.groups.all() - if (any(x in request.user.groups.all() for x in groups) or b.owner == request.user): - userBrands.append(b.name.upper()) - - b_g[b.name.upper()] = list(groups.values_list('name', flat=True)) - all_brands_groups.append(b_g) - - request.session['user_brands'] = userBrands #json.dumps(userBrands) - request.BRAND_GROUPS = all_brands_groups - do_redirect = False if root in brands_list: - if settings.DEBUG: print("root=", root) + if settings.DEBUG: + print("root=", root) + settings.CURRENT_BRAND = root request.CURRENT_BRAND = root @@ -121,19 +106,11 @@ def process_request(self, request): if (request.get_host().lower().find('phenotypes.healthdatagateway') != -1 or request.get_host().lower().find('web-phenotypes-hdr') != -1): - pass else: # # path_info does not change address bar urls request.path_info = '/' + '/'.join([root.upper()] + current_page_url.split('/')[1:]) -# print "-------" -# print current_page_url -# print current_page_url.split('/')[1:] -# print '/'.join([root.upper()] + current_page_url.split('/')[1:]) -# print request.path_info -# print "-------" - urlconf = "cll.urls_brand" set_urlconf(urlconf) request.urlconf = urlconf # this is the python file path to custom urls.py file @@ -154,23 +131,10 @@ def process_request(self, request): print(request.path_info) print(str(request.get_full_path())) - # Do NOT allow concept create under HDRUK - for now - if (str(request.get_full_path()).upper().replace('/', '') == "/HDRUK/concepts/create/".upper().replace('/', '') or - ((request.get_host().lower().find('phenotypes.healthdatagateway') != -1 or request.get_host().lower().find('web-phenotypes-hdr') != -1) - and str(request.get_full_path()).upper().replace('/', '').endswith('/concepts/create/'.upper().replace('/', '')))): - - raise PermissionDenied - # redirect /{brand}/api/ to /{brand}/api/v1/ to appear in URL address bar if do_redirect: return redirect(reverse('api:root')) - #print "get_urlconf=" , str(get_urlconf()) - #print "settings.CURRENT_BRAND=" , settings.CURRENT_BRAND - #print "request.CURRENT_BRAND=" , request.CURRENT_BRAND - - #print "...........end..............." - return None def chkReadOnlyUsers(self, request): diff --git a/CodeListLibrary_project/clinicalcode/migrations/0117_organisation_genericentity_organisation_and_more.py b/CodeListLibrary_project/clinicalcode/migrations/0121_organisation_genericentity_organisation_and_more.py similarity index 98% rename from CodeListLibrary_project/clinicalcode/migrations/0117_organisation_genericentity_organisation_and_more.py rename to CodeListLibrary_project/clinicalcode/migrations/0121_organisation_genericentity_organisation_and_more.py index 989f24eea..26c0c0a6f 100644 --- a/CodeListLibrary_project/clinicalcode/migrations/0117_organisation_genericentity_organisation_and_more.py +++ b/CodeListLibrary_project/clinicalcode/migrations/0121_organisation_genericentity_organisation_and_more.py @@ -10,7 +10,7 @@ class Migration(migrations.Migration): dependencies = [ - ('clinicalcode', '0116_ontology_descendants'), + ('clinicalcode', '0120_ontologytag_clinicalcod_type_id_c68d0b_idx_and_more'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] diff --git a/CodeListLibrary_project/clinicalcode/migrations/0118_brand_org_user_managed_and_more.py b/CodeListLibrary_project/clinicalcode/migrations/0122_brand_org_user_managed_and_more.py similarity index 89% rename from CodeListLibrary_project/clinicalcode/migrations/0118_brand_org_user_managed_and_more.py rename to CodeListLibrary_project/clinicalcode/migrations/0122_brand_org_user_managed_and_more.py index a16cb83a0..07d59c14b 100644 --- a/CodeListLibrary_project/clinicalcode/migrations/0118_brand_org_user_managed_and_more.py +++ b/CodeListLibrary_project/clinicalcode/migrations/0122_brand_org_user_managed_and_more.py @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ - ('clinicalcode', '0117_organisation_genericentity_organisation_and_more'), + ('clinicalcode', '0121_organisation_genericentity_organisation_and_more'), ] operations = [ diff --git a/CodeListLibrary_project/clinicalcode/migrations/0123_remove_brand_css_path_remove_brand_groups_and_more.py b/CodeListLibrary_project/clinicalcode/migrations/0123_remove_brand_css_path_remove_brand_groups_and_more.py new file mode 100644 index 000000000..f72b49179 --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/migrations/0123_remove_brand_css_path_remove_brand_groups_and_more.py @@ -0,0 +1,40 @@ +# Generated by Django 5.1.2 on 2025-03-14 13:43 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('clinicalcode', '0122_brand_org_user_managed_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveField( + model_name='brand', + name='css_path', + ), + migrations.RemoveField( + model_name='brand', + name='groups', + ), + migrations.RemoveField( + model_name='brand', + name='owner', + ), + migrations.AddField( + model_name='brand', + name='admins', + field=models.ManyToManyField(related_name='administered_brands', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='brand', + name='overrides', + field=models.JSONField(blank=True, null=True), + ), + migrations.DeleteModel( + name='HistoricalBrand', + ), + ] diff --git a/CodeListLibrary_project/clinicalcode/models/Brand.py b/CodeListLibrary_project/clinicalcode/models/Brand.py index 5d3322899..829a13eea 100644 --- a/CodeListLibrary_project/clinicalcode/models/Brand.py +++ b/CodeListLibrary_project/clinicalcode/models/Brand.py @@ -1,36 +1,39 @@ -from django.contrib.auth.models import Group, User -from django.db.models import JSONField from django.db import models from simple_history.models import HistoricalRecords +from django.contrib.auth.models import User from django.contrib.postgres.fields import ArrayField from .TimeStampedModel import TimeStampedModel class Brand(TimeStampedModel): id = models.AutoField(primary_key=True) + + # Brand metadata name = models.CharField(max_length=250, unique=True) description = models.TextField(blank=True, null=True) - logo_path = models.CharField(max_length=250) - index_path = models.CharField(max_length=250, blank=True, null=True) - css_path = models.CharField(max_length=250, blank=True, null=True) website = models.URLField(max_length=1000, blank=True, null=True) - owner = models.ForeignKey(User, - on_delete=models.SET_NULL, - null=True, - related_name="brand_owner") - groups = models.ManyToManyField(Group, related_name="brand_groups") + # Brand appearance site_title = models.CharField(max_length=50, blank=True, null=True) - about_menu = JSONField(blank=True, null=True) - allowed_tabs = JSONField(blank=True, null=True) - footer_images = JSONField(blank=True, null=True) - collections_excluded_from_filters = ArrayField(models.IntegerField(), blank=True, null=True) + logo_path = models.CharField(max_length=250) + index_path = models.CharField(max_length=250, blank=True, null=True) - history = HistoricalRecords() + # Brand administration + admins = models.ManyToManyField(User, related_name='administered_brands') - # Organisation controls + # Brand overrides + # - e.g. entity name override ('Concept' instead of 'Phenotype' _etc_ for HDRN brand) + overrides = models.JSONField(blank=True, null=True) + + # Brand organisation controls org_user_managed = models.BooleanField(default=False) + # Brand menu targets + about_menu = models.JSONField(blank=True, null=True) + allowed_tabs = models.JSONField(blank=True, null=True) + footer_images = models.JSONField(blank=True, null=True) + collections_excluded_from_filters = ArrayField(models.IntegerField(), blank=True, null=True) + class Meta: ordering = ('name', ) diff --git a/CodeListLibrary_project/clinicalcode/templatetags/detail_pg_renderer.py b/CodeListLibrary_project/clinicalcode/templatetags/detail_pg_renderer.py deleted file mode 100644 index 4b3be02cc..000000000 --- a/CodeListLibrary_project/clinicalcode/templatetags/detail_pg_renderer.py +++ /dev/null @@ -1,428 +0,0 @@ -from django.apps import apps -from django import template -from jinja2.exceptions import TemplateSyntaxError -from django.template.loader import render_to_string -from django.utils.translation import gettext_lazy as _ -from django.http.request import HttpRequest -from django.conf import settings - -import re -import json - -from ..entity_utils import permission_utils, template_utils, model_utils, gen_utils, constants, concept_utils -from ..models.GenericEntity import GenericEntity - -register = template.Library() - - -@register.filter(name='is_member') -def is_member(user, args): - ''' - Det. whether has a group membership - - Args: - user {RequestContext.user()} - the user model - args {string} - a string, can be deliminated by ',' to confirm membership in multiple groups - - Returns: - {boolean} that reflects membership status - ''' - if args is None: - return False - - args = [arg.strip() for arg in args.split(',')] - for arg in args: - if permission_utils.is_member(user, arg): - return True - return False - - -@register.filter(name='jsonify') -def jsonify(value, should_print=False): - ''' - Attempts to dump a value to JSON - ''' - if should_print: - print(type(value), value) - - if value is None: - value = {} - - if isinstance(value, (dict, list)): - return json.dumps(value, cls=gen_utils.ModelEncoder) - return model_utils.jsonify_object(value) - - -@register.filter(name='trimmed') -def trimmed(value): - return re.sub(r'\s+', '_', value).lower() - - -@register.filter(name='stylise_number') -def stylise_number(n): - ''' - Stylises a number so that it adds a comma delimiter for numbers greater than 1000 - ''' - return '{:,}'.format(n) - - -@register.filter(name='stylise_date') -def stylise_date(date): - ''' - Stylises a datetime object in the YY-MM-DD format - ''' - return date.strftime('%Y-%m-%d') - - -@register.simple_tag(name='truncate') -def truncate(value, lim=0, ending=None): - ''' - Truncates a string if its length is greater than the limit - - can append an ending, e.g. an ellipsis, by passing the 'ending' parameter - ''' - if lim <= 0: - return value - - try: - truncated = str(value) - if len(truncated) > lim: - truncated = truncated[0:lim] - if ending is not None: - truncated = truncated + ending - except: - return value - else: - return truncated - - -@register.tag(name='render_wizard_sidemenu') -def render_aside_wizard(parser, token): - ''' - Responsible for rendering the

    - {% render_wizard_sections_detail_pg %} - {% endrender_wizard_sections_detail_pg %} + {% render_wizard_sections detail_pg=True %} + {% endrender_wizard_sections %}
diff --git a/CodeListLibrary_project/cll/templates/components/base/footer_link_img.html b/CodeListLibrary_project/cll/templates/components/base/footer_link_img.html index 5a193127c..359c36a0c 100644 --- a/CodeListLibrary_project/cll/templates/components/base/footer_link_img.html +++ b/CodeListLibrary_project/cll/templates/components/base/footer_link_img.html @@ -1,41 +1,43 @@ -{% load static %} {% load i18n %} +{% load cache %} +{% load static %} {% load cl_extras %} {% block content %} - + {% with request.CURRENT_BRAND|default:"BASE_BRAND" as brand_target %} + {% cache 3600 brand_footer_imgs brand_target %} + + {% endcache %} + {% endwith %} {% endblock content %} diff --git a/CodeListLibrary_project/cll/templates/components/base/navigation.html b/CodeListLibrary_project/cll/templates/components/base/navigation.html index e9b3829c8..b786bb295 100644 --- a/CodeListLibrary_project/cll/templates/components/base/navigation.html +++ b/CodeListLibrary_project/cll/templates/components/base/navigation.html @@ -1,44 +1,51 @@ +{% load i18n %} +{% load cache %} {% load static %} {% load compress %} -{% load i18n %} {% block content %} {% compress js %} - + {% endcompress %}