diff --git a/.github/workflows/testing-pipline.yml b/.github/workflows/testing-pipline.yml
index 0967d52f7..8b2dc2c96 100644
--- a/.github/workflows/testing-pipline.yml
+++ b/.github/workflows/testing-pipline.yml
@@ -43,9 +43,8 @@ jobs:
steps:
- uses: actions/checkout@v3
- run: |
- sudo apt-get update
- sudo apt-get install -y libgconf-2-4 libatk1.0-0 libatk-bridge2.0-0 libgdk-pixbuf2.0-0 libgtk-3-0 libgbm-dev libnss3-dev libxss-dev libasound2
- wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
+ sudo apt-get update
+ sudo apt-get install -y libgconf-2-4 libatk1.0-0 libatk-bridge2.0-0 libgdk-pixbuf2.0-0 libgtk-3-0 libgbm-dev libnss3-dev libxss-dev libasound2
- uses: browser-actions/setup-chrome@v1
- uses: actions/cache@v3
with:
@@ -65,6 +64,7 @@ jobs:
- name: Install Dependencies
run: |
+ sudo apt-get install -y -q dirmngr
python -m pip install --upgrade pip
pip install --upgrade --upgrade-strategy eager --default-timeout 100 -r docker/requirements/test.txt
diff --git a/CodeListLibrary_project/clinicalcode/admin.py b/CodeListLibrary_project/clinicalcode/admin.py
index ef5b30ff2..dff35d0df 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):
@@ -51,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)
@@ -72,6 +74,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/api/urls.py b/CodeListLibrary_project/clinicalcode/api/urls.py
index 6034628f4..27dbec60c 100644
--- a/CodeListLibrary_project/clinicalcode/api/urls.py
+++ b/CodeListLibrary_project/clinicalcode/api/urls.py
@@ -139,6 +139,9 @@ def get_schema(self, request=None, public=False):
url(r'^data-sources/$',
DataSource.get_datasources,
name='data_sources'),
+ url(r'^data-sources/(?P[\d-]+)/export/$',
+ DataSource.get_datasource_internal_detail,
+ name='data_source_by_internal_id'),
url(r'^data-sources/(?P[\w-]+)/detail/$',
DataSource.get_datasource_detail,
name='data_source_by_id'),
diff --git a/CodeListLibrary_project/clinicalcode/api/views/Collection.py b/CodeListLibrary_project/clinicalcode/api/views/Collection.py
index 66877f063..d20baa556 100644
--- a/CodeListLibrary_project/clinicalcode/api/views/Collection.py
+++ b/CodeListLibrary_project/clinicalcode/api/views/Collection.py
@@ -1,31 +1,46 @@
+from rest_framework import status
+from django.db.models import F, Q
+from rest_framework.response import Response
from rest_framework.decorators import (api_view, permission_classes)
from rest_framework.permissions import IsAuthenticatedOrReadOnly
-from rest_framework.response import Response
-from rest_framework import status
-from django.db.models import F
+from django.contrib.postgres.search import TrigramWordSimilarity
from ...models import Tag, GenericEntity
-from ...entity_utils import api_utils
-from ...entity_utils import constants
+from ...entity_utils import constants, gen_utils, api_utils
@api_view(['GET'])
@permission_classes([IsAuthenticatedOrReadOnly])
def get_collections(request):
"""
- Get all collections
+ Get all Collections
+
+ Available parameters:
+
+ | Param | Type | Default | Desc |
+ |---------------|-----------------|---------|---------------------------------------------------------------|
+ | search | `str` | `NULL` | Full-text search across _name_ field |
+ | id | `int/list[int]` | `NULL` | Match by a single `int` _id_ field, or match by array overlap |
"""
- collections = Tag.objects.filter(
- tag_type=constants.TAG_TYPE.COLLECTION.value
- ) \
- .order_by('id')
-
- result = collections.annotate(
- name=F('description')
- ) \
- .values('id', 'name')
+ search = request.query_params.get('search', '')
+
+ collections = Tag.get_brand_records_by_request(request, params={ 'tag_type': 2 })
+ if collections is not None:
+ if not gen_utils.is_empty_string(search) and len(search.strip()) > 1:
+ collections = collections.annotate(
+ similarity=TrigramWordSimilarity(search, 'description')
+ ) \
+ .filter(Q(similarity__gte=0.7)) \
+ .order_by('-similarity')
+ else:
+ collections = collections.order_by('id')
+
+ collections = collections.annotate(
+ name=F('description')
+ ) \
+ .values('id', 'name')
return Response(
- data=list(result),
+ data=collections.values('id', 'name'),
status=status.HTTP_200_OK
)
@@ -36,8 +51,11 @@ def get_collection_detail(request, collection_id):
Get detail of specified collection by collection_id, including associated
published entities
"""
- collection = Tag.objects.filter(id=collection_id)
- if not collection.exists():
+ collection = Tag.get_brand_assoc_queryset(request.BRAND_OBJECT, 'collections')
+ if collection is not None:
+ collection = collection.filter(id=collection_id)
+
+ if not collection or not collection.exists():
return Response(
data={
'message': 'Collection with id does not exist'
diff --git a/CodeListLibrary_project/clinicalcode/api/views/Concept.py b/CodeListLibrary_project/clinicalcode/api/views/Concept.py
index 2437f8c59..060fb63c8 100644
--- a/CodeListLibrary_project/clinicalcode/api/views/Concept.py
+++ b/CodeListLibrary_project/clinicalcode/api/views/Concept.py
@@ -119,7 +119,7 @@ def get_concepts(request):
query_clauses.append(psycopg2.sql.SQL('''(
setweight(to_tsvector('pg_catalog.english', coalesce(historical.name,'')), 'A') ||
setweight(to_tsvector('pg_catalog.english', coalesce(historical.description,'')), 'B')
- ) @@ to_tsquery('pg_catalog.english', replace(websearch_to_tsquery('pg_catalog.english', %(search_query)s)::text || ':*', '<->', '|'))
+ ) @@ to_tsquery('pg_catalog.english', replace(to_tsquery('pg_catalog.english', concat(regexp_replace(trim(%(search_query)s), '\W+', ':* & ', 'gm'), ':*'))::text, '<->', '|'))
'''))
# Resolve pagination behaviour
@@ -422,7 +422,7 @@ def get_concept_detail(request, concept_id, version_id=None, export_codes=False,
if not user_can_access:
return Response(
data={
- 'message': 'Concept version must be published or you must have permission to access it'
+ 'message': 'Entity version must be published or you must have permission to access it'
},
content_type='json',
status=status.HTTP_401_UNAUTHORIZED
diff --git a/CodeListLibrary_project/clinicalcode/api/views/DataSource.py b/CodeListLibrary_project/clinicalcode/api/views/DataSource.py
index 82dc53827..4fd8d2698 100644
--- a/CodeListLibrary_project/clinicalcode/api/views/DataSource.py
+++ b/CodeListLibrary_project/clinicalcode/api/views/DataSource.py
@@ -1,24 +1,145 @@
-from rest_framework.decorators import (api_view, permission_classes)
-from rest_framework.permissions import IsAuthenticatedOrReadOnly
-from rest_framework.response import Response
from rest_framework import status
-from django.db.models import Subquery, OuterRef
+from django.db.models import Q, Subquery, OuterRef
+from rest_framework.response import Response
+from rest_framework.decorators import api_view, permission_classes
+from rest_framework.permissions import IsAuthenticatedOrReadOnly
+from django.contrib.postgres.search import TrigramWordSimilarity
from ...models import DataSource, Template, GenericEntity
-from ...entity_utils import api_utils
-from ...entity_utils import gen_utils
-from ...entity_utils import constants
+from ...entity_utils import api_utils, gen_utils, constants
@api_view(['GET'])
@permission_classes([IsAuthenticatedOrReadOnly])
def get_datasources(request):
"""
- Get all datasources
+ Get all DataSources
+
+ Available parameters:
+
+ | Param | Type | Default | Desc |
+ |---------------|-----------------|---------|---------------------------------------------------------------|
+ | search | `str` | `NULL` | Full-text search across _name_ and _description_ fields |
+ | id | `int/list[int]` | `NULL` | Match by a single `int` _id_ field, or match by array overlap |
+ | name | `str` | `NULL` | Case insensitive direct match of _name_ field |
+ | uid | `str/uuid` | `NULL` | Case insensitive direct match of _uid_ field |
+ | datasource_id | `int` | `NULL` | Match by exact _datasource_id_ |
+ | url | `str` | `NULL` | Case insensitive direct match of _url_ field |
+ | source | `str` | `NULL` | Case insensitive direct match of _source_ field |
"""
- datasources = DataSource.objects.all().order_by('id')
- datasources = list(datasources.values('id', 'name', 'url', 'uid', 'source'))
+ params = gen_utils.parse_model_field_query(DataSource, request, ignored_fields=['description'])
+ if params is not None:
+ datasources = DataSource.objects.filter(**params)
+ else:
+ datasources = DataSource.objects.all()
+
+ search = request.query_params.get('search')
+ if not gen_utils.is_empty_string(search) and len(search.strip()) > 3:
+ datasources = datasources.annotate(
+ similarity=(
+ TrigramWordSimilarity(search, 'name') + \
+ TrigramWordSimilarity(search, 'description')
+ )
+ ) \
+ .filter(Q(similarity__gte=0.7)) \
+ .order_by('-similarity')
+ else:
+ datasources = datasources.order_by('id')
+
return Response(
- data=datasources,
+ data=datasources.values('id', 'name', 'description', 'url', 'uid', 'datasource_id', 'source'),
+ status=status.HTTP_200_OK
+ )
+
+@api_view(['GET'])
+@permission_classes([IsAuthenticatedOrReadOnly])
+def get_datasource_internal_detail(request, datasource_id):
+ """
+ Get detail of specified datasource by by its internal Id
+ """
+ query = None
+ if gen_utils.parse_int(datasource_id, default=None) is not None:
+ query = { 'id': int(datasource_id) }
+
+ if not query:
+ return Response(
+ data={
+ 'message': 'Invalid id, expected int-like value'
+ },
+ content_type='json',
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ datasource = DataSource.objects.filter(**query)
+ if not datasource.exists():
+ return Response(
+ data={
+ 'message': 'Datasource with this internal Id does not exist'
+ },
+ content_type='json',
+ status=status.HTTP_404_NOT_FOUND
+ )
+
+ datasource = datasource.first()
+
+ # Get all templates and their versions where data_sources exist
+ templates = Template.history.filter(
+ definition__fields__has_key='data_sources'
+ ) \
+ .annotate(
+ was_deleted=Subquery(
+ Template.history.filter(
+ id=OuterRef('id'),
+ history_date__gte=OuterRef('history_date'),
+ history_type='-'
+ )
+ .order_by('id', '-history_id')
+ .distinct('id')
+ .values('id')
+ )
+ ) \
+ .exclude(was_deleted__isnull=False) \
+ .order_by('id', '-template_version', '-history_id') \
+ .distinct('id', 'template_version')
+
+ template_ids = list(templates.values_list('id', flat=True))
+ template_versions = list(templates.values_list('template_version', flat=True))
+
+ # Get all published entities with this datasource
+ entities = GenericEntity.history.filter(
+ template_id__in=template_ids,
+ template_version__in=template_versions,
+ publish_status=constants.APPROVAL_STATUS.APPROVED.value
+ ) \
+ .extra(where=[f"""
+ exists(
+ select 1
+ from jsonb_array_elements(
+ case jsonb_typeof(template_data->'data_sources') when 'array'
+ then template_data->'data_sources'
+ else '[]'
+ end
+ ) as val
+ where val in ('{datasource.id}')
+ )"""
+ ]) \
+ .order_by('id', '-history_id') \
+ .distinct('id')
+
+ # Format results
+ entities = api_utils.annotate_linked_entities(entities)
+
+ result = {
+ 'id': datasource.id,
+ 'name': datasource.name,
+ 'url': datasource.url,
+ 'uid': datasource.uid,
+ 'description': datasource.description,
+ 'source': datasource.source,
+ 'phenotypes': list(entities)
+ }
+
+ return Response(
+ data=result,
status=status.HTTP_200_OK
)
@@ -26,14 +147,13 @@ def get_datasources(request):
@permission_classes([IsAuthenticatedOrReadOnly])
def get_datasource_detail(request, datasource_id):
"""
- Get detail of specified datasource by datasource_id (id or HDRUK UUID for
- linkage between applications), including associated published entities
+ Get detail of specified datasource by `datasource_id`, _i.e._ the HDRUK DataSource `pid` or its `UUID` for linkage between applications, including associated published entities.
"""
query = None
if gen_utils.is_valid_uuid(datasource_id):
query = { 'uid': datasource_id }
elif gen_utils.parse_int(datasource_id, default=None) is not None:
- query = { 'id': int(datasource_id) }
+ query = { 'datasource_id': int(datasource_id) }
if not query:
return Response(
diff --git a/CodeListLibrary_project/clinicalcode/api/views/GenericEntity.py b/CodeListLibrary_project/clinicalcode/api/views/GenericEntity.py
index 9cf6654d6..49742b1f8 100644
--- a/CodeListLibrary_project/clinicalcode/api/views/GenericEntity.py
+++ b/CodeListLibrary_project/clinicalcode/api/views/GenericEntity.py
@@ -6,6 +6,8 @@
from django.conf import settings
from django.core.exceptions import BadRequest
+import logging
+
from ...models import GenericEntity, Template
from ...entity_utils import permission_utils
from ...entity_utils import template_utils
@@ -15,6 +17,10 @@
from ...entity_utils import gen_utils
from ...entity_utils import constants
+
+logger = logging.getLogger(__name__)
+
+
""" Create/Update GenericEntity """
@swagger_auto_schema(method='post', auto_schema=None)
@@ -33,17 +39,17 @@ def create_generic_entity(request):
content_type='json',
status=status.HTTP_403_FORBIDDEN
)
-
+
form = api_utils.validate_api_create_update_form(
request, method=constants.FORM_METHODS.CREATE.value
)
if isinstance(form, Response):
return form
-
+
entity = api_utils.create_update_from_api_form(request, form)
if isinstance(entity, Response):
return entity
-
+
entity_data = {
'id': entity.id,
'version_id': entity.history_id,
@@ -159,11 +165,11 @@ def get_generic_entities(request):
- **Metadata Parameters** → _i.e._ Top-level fields associated with all `Phenotypes`
- | Param | Type | Default | Desc |
- |-------------|----------------|---------|----------------------------------------------|
- | tags | `list` | `NULL` | Filter results by one or more tag IDs |
- | collections | `list` | `NULL` | Filter results by one or more collection IDs |
- | created | `list` | `NULL` | Date range filter on `created` field |
+ | Param | Type | Default | Desc |
+ |-------------|--------------------|---------|------------------------------------------------------|
+ | tags | `list` | `NULL` | Filter results by one or more tag IDs / names |
+ | collections | `list` | `NULL` | Filter results by one or more collection IDs / names |
+ | created | `list` | `NULL` | Date range filter on `created` field |
- **Template Parameters** → _i.e._ Fields relating to a specific `Template`
@@ -184,8 +190,8 @@ def get_generic_entities(request):
| Search across ID, Name and Code | `?ontology=([^&]+)` | Search string or List of deliminated strings |
| Search across ID | `?ontology_id=([^&]+)` | Single `ID` (`int`) or List of deliminated `ID`s |
| Search across Code | `?ontology_code=([^&]+)` | Single `Code` string (_e.g._ ICD-10, SNOMED _etc_) or List of deliminated `Codes`s |
- | Search acrross Name | `?ontology_name=([^&]+)` | Single `Name` string or List of deliminated `Name`s |
- | Search across Type | `?ontology_type=([^&]+)` | Single `Type` (`int`) or List of deliminated `Type`s |
+ | Search acrros Name | `?ontology_name=([^&]+)` | Single `Name` string or List of deliminated `Name`s |
+ | Search across Type | `?ontology_type=([^&]+)` | Single `Type` (`int` or `str`) or List of deliminated `Type` IDs/Names |
| Search across Reference | `?ontology_reference=([^&]+)` | Single `ReferenceID` (`int`) or List of deliminated `ReferenceID`s |
"""
@@ -256,20 +262,39 @@ def get_generic_entities(request):
# Finalise accessibility clause(s)
user_id = None
- brand_id = None
- group_ids = None
+ brand_ids = None
+ group_ids = None # [!] Change
brand = model_utils.try_get_brand(request)
if brand is not None:
- brand_id = brand.id
- accessible_clauses.append('''%(brand_id)s = any(entity.brands)''')
+ vis_rules = brand.get_vis_rules()
+ brand_ctx = None
+ if isinstance(vis_rules, dict):
+ allow_null = vis_rules.get('allow_null')
+ allowed_brands = vis_rules.get('ids')
+ if isinstance(allowed_brands, list) and isinstance(allow_null, bool) and allow_null:
+ brand_ids = allowed_brands
+ brand_ctx = '''((entity.brands is null or array_length(entity.brands, 1) < 1) or %(brand_ids)s && entity.brands)'''
+ elif isinstance(allowed_brands, list) and len(allowed_brands):
+ brand_ids = allowed_brands
+ brand_ctx = '''(entity.brands is not null and array_length(entity.brands, 1) > 0 and %(brand_ids)s && entity.brands)'''
+ elif isinstance(allow_null, bool) and allow_null:
+ brand_ids = allowed_brands
+ brand_ctx = '''(entity.brands is null or array_length(entity.brands, 1) < 1)'''
+
+ if brand_ctx is None:
+ brand_ids = [brand.id]
+ brand_ctx = '''(%(brand_ids)s && entity.brands)'''
+
+ accessible_clauses.append(brand_ctx)
user = request.user if request.user and not request.user.is_anonymous else None
user_clause = '''entity.publish_status = 2'''
if user:
user_id = user.id
- user_clause = f'''({user_clause} or entity.world_access = 2) or entity.owner_id = %(user_id)s'''
+ user_clause = f'''{user_clause} or entity.owner_id = %(user_id)s'''
+ # [!] Change
groups = list(user.groups.all().values_list('id', flat=True))
if len(groups) > 0:
group_ids = [ int(group) for group in groups if gen_utils.parse_int(group, default=None) ]
@@ -280,7 +305,7 @@ def get_generic_entities(request):
# Cache base params
base_params = {
'user_id': user_id,
- 'brand_id': brand_id,
+ 'brand_ids': brand_ids,
'group_ids': group_ids,
'template_id': template_id,
'template_version_id': template_version_id,
@@ -350,7 +375,7 @@ def get_generic_entities(request):
continue
success, query, query_params = api_utils.build_query_string_from_param(
- key, data, validation, field_type,
+ request, key, data, field_data, field_type,
prefix='mt', is_dynamic=False
)
@@ -391,7 +416,7 @@ def get_generic_entities(request):
continue
success, query, query_params = api_utils.build_query_string_from_param(
- key, data, validation, field_type,
+ request, key, data, field_data, field_type,
prefix=prefix, is_dynamic=True
)
@@ -479,7 +504,7 @@ def get_generic_entities(request):
query = query + '''
where t.search_vector @@ to_tsquery(
'pg_catalog.english',
- replace(websearch_to_tsquery('pg_catalog.english', %(search)s)::text || ':*', '<->', '|')
+ replace(to_tsquery('pg_catalog.english', concat(regexp_replace(trim(%(search)s), '\W+', ':* & ', 'gm'), ':*'))::text, '<->', '|')
)
'''
query_params.update({ 'search': search })
@@ -505,7 +530,7 @@ def get_generic_entities(request):
on template.id = live_tmpl.id
where (live_entity.is_deleted is null or live_entity.is_deleted = false)
and (entity.is_deleted is null or entity.is_deleted = false)
- and entity.publish_status = 2
+ {accessible_clauses}
{tmpl_clauses}
) as t0
join public.clinicalcode_historicalgenericentity as t1
@@ -525,7 +550,7 @@ def get_generic_entities(request):
order by entity.history_id desc
) as rn_ref_n
from public.clinicalcode_historicalgenericentity as entity
- left join public.clinicalcode_genericentity as live_entity
+ join public.clinicalcode_genericentity as live_entity
on entity.id = live_entity.id
join public.clinicalcode_historicaltemplate as template
on entity.template_id = template.id and entity.template_version = template.template_version
@@ -576,7 +601,7 @@ def get_generic_entities(request):
for entity in entities:
entity_detail = api_utils.get_entity_detail(
request,
- entity.id,
+ entity.id,
entity,
is_authed,
fields_to_ignore=constants.ENTITY_LIST_API_HIDDEN_FIELDS,
@@ -584,6 +609,7 @@ def get_generic_entities(request):
)
if not isinstance(entity_detail, Response):
+ entity_detail.update(phenotype_version_id=entity.history_id)
formatted_entities.append(entity_detail)
result = formatted_entities if not should_paginate else {
@@ -592,9 +618,8 @@ def get_generic_entities(request):
'page_size': page_size,
'data': formatted_entities
}
-
except Exception as e:
- # log exception?
+ logger.error('Encountered error on Phenotype API Query: \n%s\n' % (str(e)))
raise BadRequest('Invalid request, failed to perform query')
else:
return Response(
diff --git a/CodeListLibrary_project/clinicalcode/api/views/Healthcheck.py b/CodeListLibrary_project/clinicalcode/api/views/Healthcheck.py
index 772ea6a15..f0bb86266 100644
--- a/CodeListLibrary_project/clinicalcode/api/views/Healthcheck.py
+++ b/CodeListLibrary_project/clinicalcode/api/views/Healthcheck.py
@@ -40,7 +40,7 @@ def get(self, request):
pg_connected = self.__ping_db()
is_readonly = not settings.IS_DEVELOPMENT_PC and (settings.IS_DEMO or settings.CLL_READ_ONLY or settings.IS_INSIDE_GATEWAY)
- if settings.DEBUG or (not settings.DEBUG and is_readonly):
+ if settings.DEBUG or (not settings.DEBUG and is_readonly and not settings.ENABLE_DEMO_TASK_QUEUE):
return Response(
data={
'healthcheck_mode': HealthcheckMode.DEBUG.value,
diff --git a/CodeListLibrary_project/clinicalcode/api/views/Ontology.py b/CodeListLibrary_project/clinicalcode/api/views/Ontology.py
index dd6473d2b..c6478d9d2 100644
--- a/CodeListLibrary_project/clinicalcode/api/views/Ontology.py
+++ b/CodeListLibrary_project/clinicalcode/api/views/Ontology.py
@@ -150,8 +150,8 @@ def get_ontology_nodes(request):
if 'exact_codes' not in request.query_params.keys():
# Fuzzy across every code mapping
clauses.append('''(
- (relation_vector @@ to_tsquery('pg_catalog.english', replace(websearch_to_tsquery('pg_catalog.english', array_to_string(%(codes)s, '|'))::text || ':*', '<->', '|')))
- or (relation_vector @@ to_tsquery('pg_catalog.english', replace(websearch_to_tsquery('pg_catalog.english', array_to_string(%(alt_codes)s, '|'))::text || ':*', '<->', '|')))
+ (relation_vector @@ to_tsquery('pg_catalog.english', replace(to_tsquery('pg_catalog.english', concat(regexp_replace(trim(array_to_string(%(codes)s, '|')), '\W+', ':* & ', 'gm'), ':*'))::text, '<->', '|')))
+ or (relation_vector @@ to_tsquery('pg_catalog.english',replace(to_tsquery('pg_catalog.english', concat(regexp_replace(trim(array_to_string(%(alt_codes)s, '|')), '\W+', ':* & ', 'gm'), ':*'))::text, '<->', '|')))
)''')
else:
# Direct search for snomed
@@ -169,10 +169,10 @@ def get_ontology_nodes(request):
# Fuzzy across code / desc / synonyms / relation
clauses.append('''(
node.search_vector
- @@ to_tsquery('pg_catalog.english', replace(websearch_to_tsquery('pg_catalog.english', %(search)s)::text || ':*', '<->', '|'))
+ @@ to_tsquery('pg_catalog.english', replace(to_tsquery('pg_catalog.english', concat(regexp_replace(trim(%(search)s), '\W+', ':* & ', 'gm'), ':*'))::text, '<->', '|'))
)''')
- search_rank = '''ts_rank_cd(node.search_vector, websearch_to_tsquery('pg_catalog.english', %(search)s))'''
+ search_rank = '''ts_rank_cd(node.search_vector, to_tsquery('pg_catalog.english', replace(to_tsquery('pg_catalog.english', concat(regexp_replace(trim(%(search)s), '\W+', ':* & ', 'gm'), ':*'))::text, '<->', '|')))'''
row_clause = '''row_number() over (order by %s) as rn,''' % search_rank
search_rank = search_rank + ' as score,'
diff --git a/CodeListLibrary_project/clinicalcode/api/views/Tag.py b/CodeListLibrary_project/clinicalcode/api/views/Tag.py
index da9d5b057..692899b9e 100644
--- a/CodeListLibrary_project/clinicalcode/api/views/Tag.py
+++ b/CodeListLibrary_project/clinicalcode/api/views/Tag.py
@@ -1,31 +1,47 @@
+from rest_framework import status
+from django.db.models import F, Q
+from rest_framework.response import Response
from rest_framework.decorators import (api_view, permission_classes)
from rest_framework.permissions import IsAuthenticatedOrReadOnly
-from rest_framework.response import Response
-from rest_framework import status
-from django.db.models import F
+from django.contrib.postgres.search import TrigramWordSimilarity
from ...models import Tag, GenericEntity
-from ...entity_utils import api_utils
-from ...entity_utils import constants
+from ...entity_utils import constants, gen_utils, api_utils
@api_view(['GET'])
@permission_classes([IsAuthenticatedOrReadOnly])
def get_tags(request):
"""
- Get all tags
+ Get all Tags
+
+ Available parameters:
+
+ | Param | Type | Default | Desc |
+ |---------------|-----------------|---------|---------------------------------------------------------------|
+ | search | `str` | `NULL` | Full-text search across _name_ field |
+ | id | `int/list[int]` | `NULL` | Match by a single `int` _id_ field, or match by array overlap |
"""
- tags = Tag.objects.filter(
- tag_type=constants.TAG_TYPE.TAG.value
- ) \
- .order_by('id')
-
- result = tags.annotate(
- name=F('description')
- ) \
- .values('id', 'name')
+ search = request.query_params.get('search', '')
+
+ tags = Tag.get_brand_records_by_request(request, params={ 'tag_type': 1 })
+ if tags is not None:
+ if not gen_utils.is_empty_string(search) and len(search.strip()) > 1:
+ tags = tags.annotate(
+ similarity=TrigramWordSimilarity(search, 'description')
+ ) \
+ .filter(Q(similarity__gte=0.7)) \
+ .order_by('-similarity')
+ else:
+ tags = tags.order_by('id')
+
+ tags = tags.annotate(
+ name=F('description')
+ ) \
+ .values('id', 'name')
+
return Response(
- data=list(result),
+ data=tags.values('id', 'name'),
status=status.HTTP_200_OK
)
@@ -36,8 +52,11 @@ def get_tag_detail(request, tag_id):
Get detail of specified tag by tag_id, including associated
published entities
"""
- tag = Tag.objects.filter(id=tag_id)
- if not tag.exists():
+ tag = Tag.get_brand_assoc_queryset(request.BRAND_OBJECT, 'tags')
+ if tag is not None:
+ tag = tag.filter(id=tag_id)
+
+ if not tag or not tag.exists():
return Response(
data={
'message': 'Tag with id does not exist'
@@ -54,7 +73,7 @@ def get_tag_detail(request, tag_id):
) \
.order_by('id', '-history_id') \
.distinct('id')
-
+
# Format results
entities = api_utils.annotate_linked_entities(entities)
diff --git a/CodeListLibrary_project/clinicalcode/api/views/Template.py b/CodeListLibrary_project/clinicalcode/api/views/Template.py
index 5efb3b68d..9129e2701 100644
--- a/CodeListLibrary_project/clinicalcode/api/views/Template.py
+++ b/CodeListLibrary_project/clinicalcode/api/views/Template.py
@@ -1,20 +1,48 @@
+from rest_framework import status
+from django.db.models import Q
+from rest_framework.response import Response
from rest_framework.decorators import (api_view, permission_classes)
from rest_framework.permissions import IsAuthenticatedOrReadOnly
-from rest_framework.response import Response
-from rest_framework import status
+from django.contrib.postgres.search import TrigramWordSimilarity
from ...models import Template
-from ...entity_utils import api_utils
-from ...entity_utils import template_utils
+from ...entity_utils import template_utils, gen_utils, api_utils
@api_view(['GET'])
@permission_classes([IsAuthenticatedOrReadOnly])
def get_templates(request):
"""
- Get all templates
- """
- templates = Template.objects.all()
+ Get all Collections
+
+ Available parameters:
+
+ | Param | Type | Default | Desc |
+ |---------------|-----------------|---------|----------------------------------------------------------------------------|
+ | search | `str` | `NULL` | Full-text search across _name_ and _description_ field |
+ | id | `int/list[int]` | `NULL` | Match by a single `int` _id_ field, or match by array overlap |
+ | name | `str` | `NULL` | Case insensitive direct match of _name_ field |
+ """
+ templates = Template.get_brand_records_by_request(request)
+ if templates is None:
+ return Response(
+ data=[],
+ status=status.HTTP_200_OK
+ )
+
+ search = request.query_params.get('search')
+ if not gen_utils.is_empty_string(search) and len(search.strip()) > 3:
+ templates = templates.annotate(
+ similarity=(
+ TrigramWordSimilarity(search, 'name') + \
+ TrigramWordSimilarity(search, 'description')
+ )
+ ) \
+ .filter(Q(similarity__gte=0.7)) \
+ .order_by('-similarity')
+ else:
+ templates = templates.order_by('id')
+
result = []
for template in templates:
result.append({
@@ -83,7 +111,7 @@ def get_template(request, template_id, version_id=None):
status=status.HTTP_404_NOT_FOUND
)
template = template.latest()
-
+
merged_definition = template_utils.get_merged_definition(template, default={})
template_fields = template_utils.try_get_content(merged_definition, 'fields')
diff --git a/CodeListLibrary_project/clinicalcode/apps.py b/CodeListLibrary_project/clinicalcode/apps.py
new file mode 100644
index 000000000..fe6dc9d0d
--- /dev/null
+++ b/CodeListLibrary_project/clinicalcode/apps.py
@@ -0,0 +1,29 @@
+"""App registry"""
+from django.conf import settings
+from django.apps import AppConfig
+from django.template import base as template_base
+from django.core.signals import request_started
+
+import re
+
+# Enable multi-line tag support
+template_base.tag_re = re.compile(template_base.tag_re.pattern, re.DOTALL)
+
+# App registration
+class ClinicalCodeConfig(AppConfig):
+ """CLL Base App Config"""
+ name = 'clinicalcode'
+
+ def ready(self):
+ """Initialises signals on app start"""
+
+ # Enable EasyAudit signal override
+ if settings.REMOTE_TEST:
+ return
+
+ from clinicalcode.audit.request_signals import request_started_watchdog
+
+ request_started.connect(
+ receiver=request_started_watchdog,
+ dispatch_uid='easy_audit_signals_request_started'
+ )
diff --git a/CodeListLibrary_project/clinicalcode/audit/__init__.py b/CodeListLibrary_project/clinicalcode/audit/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/CodeListLibrary_project/clinicalcode/audit/request_signals.py b/CodeListLibrary_project/clinicalcode/audit/request_signals.py
new file mode 100644
index 000000000..208d68316
--- /dev/null
+++ b/CodeListLibrary_project/clinicalcode/audit/request_signals.py
@@ -0,0 +1,342 @@
+"""Custom EasyAudit request handling."""
+from importlib import import_module
+from ipaddress import ip_address as validate_ip
+from easyaudit import settings as EasySettings
+from django.conf import settings as AppSettings
+from django.utils import timezone
+from django.http.cookie import SimpleCookie
+from django.contrib.auth import get_user_model, SESSION_KEY
+from django.http.request import HttpRequest, split_domain_port, validate_host
+from django.core.handlers.wsgi import WSGIHandler
+from django.utils.regex_helper import _lazy_re_compile
+from django.utils.module_loading import import_string
+from django.contrib.sessions.models import Session
+
+import re
+import inspect
+
+from clinicalcode.models import Brand
+
+
+audit_logger = import_string(EasySettings.LOGGING_BACKEND)()
+session_engine = import_module(AppSettings.SESSION_ENGINE)
+
+
+def validate_ip_addr(addr):
+ """
+ Attempts to validate the given IP address
+
+ Args:
+ default (str): the IP address to evaluate
+
+ Returns:
+ A (bool) reflecting the validity of the specified IP address
+
+ """
+ try:
+ validate_ip(addr)
+ except ValueError:
+ return False
+ else:
+ return True
+
+
+def resolve_ip_addr(request, validate_ip=False, default='10.0.0.1'):
+ """
+ Resolves the IP address assoc. with some request / the sender's _environ_
+
+ Args:
+ request (HttpRequest|Dict[string, Any]): either (a) a :class:`HttpRequest` assoc. with the signal or (b) a `Dict[str, Any]` describing its _environ_
+ validate_ip (bool): optionally specify whether to validate the IP address; defaults to `False`
+ default (Any): optionally specify the return value; defaults to `10.0.0.1` to represent an empty IP, likely from a proxy hiding the client
+
+ Returns:
+ A (str) specifying the IP if applicable; otherwise returns the given `default` parameter
+
+ """
+ addr = None
+ if isinstance(request, HttpRequest):
+ if isinstance(request.headers.get('X-Forwarded-For'), str):
+ addr = request.headers.get('X-Forwarded-For')
+
+ if addr is None:
+ request = request.META
+
+ if isinstance(request, dict):
+ for x in ['HTTP_X_FORWARDED_FOR', EasySettings.REMOTE_ADDR_HEADER, 'REMOTE_ADDR']:
+ var = request.get(x)
+ if isinstance(var, str):
+ addr = var
+ break
+
+ if validate_ip and addr is not None and not validate_ip_addr(addr):
+ addr = default
+ elif addr is None:
+ addr = default
+
+ return addr
+
+
+def match_url_patterns(url, patterns, flags=re.MULTILINE | re.IGNORECASE):
+ """
+ Tests a variadic number of patterns against the given URL.
+
+ Args:
+ url (str): the URL to evaluate
+ patterns (list[Pattern]): specifies the patterns to be matched against the URL test case
+ flags (int): optionally specify the regex flags to be used across all patterns; defaults to `MULTILINE` and `IGNORECASE`
+
+ Returns:
+ A (bool) specifying whether the URL test case has been successfully matched against one of the given pattern(s).
+
+ """
+ if not isinstance(url, str) or not isinstance(patterns, list):
+ return False
+
+ for target in patterns:
+ pattern = _lazy_re_compile(target, flags=flags)
+ if pattern.match(url):
+ return True
+
+ return False
+
+
+def get_request_info(sender, params):
+ """
+ Attempts to resolve to resolve RequestContext data from the given WSGIRequest event kwargs.
+
+ Args:
+ sender (WSGIHandler|Any): the sender assoc. with this request
+ params (Dict[str, Any]): the kwargs associated with a request event
+
+ Returns:
+ A (Dict[str, Any]) specifying the request information if resolved; otherwise returns a (None) value
+
+ """
+ scope = params.get('scope')
+ environ = params.get('environ')
+
+ # Resolve header
+ info = None
+ if environ:
+ info = environ
+ path = environ['PATH_INFO']
+ cookie_string = environ.get('HTTP_COOKIE')
+ method = environ['REQUEST_METHOD']
+ query_string = environ['QUERY_STRING']
+
+ remote_ip = None
+ if isinstance(sender, WSGIHandler) or (inspect.isclass(sender) and issubclass(sender, WSGIHandler)):
+ try:
+ request = sender.request_class(environ)
+ remote_ip = resolve_ip_addr(request, validate_ip=True, default=None)
+ except:
+ pass
+
+ if remote_ip is None:
+ remote_ip = resolve_ip_addr(environ)
+ else:
+ info = scope
+ path = scope.get('path')
+ method = scope.get('method')
+ headers = dict(scope.get('headers'))
+ cookie_string = headers.get(b'cookie')
+ if isinstance(cookie_string, bytes):
+ cookie_string = cookie_string.decode('utf-8')
+
+ remote_ip = next(iter(scope.get('client', ('0.0.0.0', 0))))
+ query_string = scope.get('query_string')
+
+ # Resolve protocol
+ protocol = None
+ if AppSettings.SECURE_PROXY_SSL_HEADER:
+ try:
+ header, secure_value = AppSettings.SECURE_PROXY_SSL_HEADER
+ except ValueError:
+ header = None
+
+ if header is not None:
+ header_value = info.get(header)
+ if header_value is not None:
+ header_value, *_ = header_value.split(',', 1)
+ protocol = 'https' if header_value.strip() == secure_value else 'http'
+
+ if protocol is None:
+ protocol = info.get('wsgi.url_scheme', 'http')
+
+ # Resolve raw port
+ if AppSettings.USE_X_FORWARDED_PORT and 'HTTP_X_FORWARDED_HOST' in info:
+ rport = str(info.get('HTTP_X_FORWARDED_HOST'))
+ else:
+ rport = str(info.get('SERVER_PORT'))
+
+ # Resolve raw host
+ host = None
+ if AppSettings.USE_X_FORWARDED_HOST and 'HTTP_X_FORWARDED_HOST' in info:
+ host = info.get('HTTP_X_FORWARDED_HOST')
+ elif 'HTTP_HOST' in info:
+ host = info.get('HTTP_HOST')
+ else:
+ host = info.get('SERVER_NAME')
+ if rport != ('443' if protocol == 'https' else '80'):
+ host = '%s:%s' % (host, rport)
+
+ # Attempt host validation
+ allowed_hosts = AppSettings.ALLOWED_HOSTS
+ if not isinstance(allowed_hosts, list) and AppSettings.DEBUG:
+ allowed_hosts = ['.localhost', '127.0.0.1', '[::1]']
+
+ domain, port = split_domain_port(host)
+ if domain and not validate_host(domain, allowed_hosts):
+ return None
+
+ return {
+ 'method': method,
+ 'protocol': protocol,
+ 'host': host,
+ 'domain': domain,
+ 'port': port,
+ 'request_port': rport,
+ 'path': path,
+ 'remote_ip': remote_ip,
+ 'query_string': query_string,
+ 'cookie_string': cookie_string,
+ }
+
+
+def get_brand_from_request_info(info):
+ """
+ Attempts to resolve a Brand from the request info, if applicable.
+
+ Args:
+ info (Dict[str, Any]): the request information derived from `get_request_info()`
+
+ Returns:
+ A (str) describing the name of the Brand associated with this request if applicable, otherwise returns a (None) value.
+
+ """
+ if not isinstance(info, dict):
+ return None
+
+ domain = info.get('domain')
+ if isinstance(domain, str):
+ pattern = _lazy_re_compile(r'^(phenotypes\.healthdatagateway|web\-phenotypes\-hdr)', flags=re.MULTILINE | re.IGNORECASE)
+ if pattern.match(domain):
+ return 'HDRUK'
+
+ url = info.get('path')
+ root = url.lstrip('/').split('/')[0].upper().rstrip('/')
+ if root in Brand.all_names():
+ return root
+
+ return None
+
+
+def is_blacklisted_url(url, brand_name=None):
+ """
+ Determines whether the requested URL event is blacklisted from being audited, considers Brand context if the Brand is specified.
+
+ Args:
+ url (str): the desired url path, _e.g._ `/api/v1/some-endpoint/`
+ brand_name (str|None): optionally specify the Brand context; defaults to `None`
+
+ Returns:
+ A (boolean) specifying whether this event's URL is blacklisted
+
+ """
+ if not isinstance(url, str):
+ return False
+
+ # Det. whether URL is blacklisted per override rules (if applicable)
+ override = AppSettings.OVERRIDE_EASY_AUDIT_IGNORE_URLS \
+ if (hasattr(AppSettings, 'OVERRIDE_EASY_AUDIT_IGNORE_URLS') and isinstance(AppSettings.OVERRIDE_EASY_AUDIT_IGNORE_URLS, dict)) \
+ else None
+
+ all_override = override.get('all_brands') if override is not None else None
+ brand_override = override.get(brand_name) if override is not None and isinstance(brand_name, str) else None
+ if all_override or brand_override:
+ if all_override and match_url_patterns(url, all_override):
+ return True
+
+ if brand_override and match_url_patterns(url, brand_override):
+ return True
+
+ return False
+
+ # Otherwise, default to EasyAudit rules if not found
+ return match_url_patterns(url, EasySettings.UNREGISTERED_URLS)
+
+
+def should_log_url(url, brand_name=None):
+ """
+ Determines whether the requested URL event should be logged to the audit table, considers Brand context if the Brand is specified.
+
+ Args:
+ url (str): the desired url path, _e.g._ `/api/v1/some-endpoint/`
+ brand_name (str|None): optionally specify the Brand context; defaults to `None`
+
+ Returns:
+ A (boolean) specifying whether this event should be logged
+
+ """
+ if not isinstance(url, str):
+ return False
+
+ # Only include registered URLs if defined
+ if brand_name is not None:
+ url = url.lstrip('/' + brand_name)
+
+ if not url.startswith('/'):
+ url = '/' + url
+
+ if isinstance(EasySettings.REGISTERED_URLS, list) and len(EasySettings.REGISTERED_URLS) > 0:
+ for registered_url in EasySettings.REGISTERED_URLS:
+ pattern = re.compile(registered_url)
+ if pattern.match(url):
+ return True
+ return False
+
+ # Otherwise, record all except those that are blacklisted
+ return not is_blacklisted_url(url, brand_name)
+
+
+def request_started_watchdog(sender, *args, **kwargs):
+ """A signal handler to observe Django `request_started `__ events"""
+ # Reconcile request context
+ info = get_request_info(sender, kwargs)
+ path = info.get('path')
+ brand_name = get_brand_from_request_info(info)
+ if not should_log_url(path, brand_name):
+ return
+
+ # Resolve the user from the auth cookie if applicable
+ user = None
+ cookie = info.get('cookie_string')
+ if not user and cookie:
+ cookie = SimpleCookie()
+ cookie.load(cookie)
+
+ session_cookie_name = AppSettings.SESSION_COOKIE_NAME
+ if session_cookie_name in cookie:
+ session_id = cookie[session_cookie_name].value
+ try:
+ session = session_engine.SessionStore(session_key=session_id).load()
+ except Session.DoesNotExist:
+ session = None
+
+ if session and SESSION_KEY in session:
+ user_id = session.get(SESSION_KEY)
+ try:
+ user = get_user_model().objects.get(id=user_id)
+ except Exception:
+ user = None
+
+ # Log request interaction
+ audit_logger.request({
+ 'url': path,
+ 'method': info.get('method'),
+ 'query_string': info.get('query_string'),
+ 'user_id': getattr(user, 'id', None) if user is not None else None,
+ 'remote_ip': info.get('remote_ip'),
+ 'datetime': timezone.now(),
+ })
diff --git a/CodeListLibrary_project/clinicalcode/context_processors/general.py b/CodeListLibrary_project/clinicalcode/context_processors/general.py
index 113a884f6..8d6e93a83 100644
--- a/CodeListLibrary_project/clinicalcode/context_processors/general.py
+++ b/CodeListLibrary_project/clinicalcode/context_processors/general.py
@@ -1,10 +1,12 @@
from django.conf import settings
+from clinicalcode.entity_utils import constants, permission_utils
from clinicalcode.api.views.View import get_canonical_path
-from clinicalcode.entity_utils import constants
def general_var(request):
return {
+ 'USER_CREATE_CONTEXT': permission_utils.user_has_create_context(request),
+ 'IS_BRAND_ADMIN': permission_utils.is_requestor_brand_admin(request),
'MEDIA_URL': settings.MEDIA_URL,
'CLL_READ_ONLY': settings.CLL_READ_ONLY,
'SHOWADMIN': settings.SHOWADMIN,
diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/api_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/api_utils.py
index 18ac6bdb7..e2e278909 100644
--- a/CodeListLibrary_project/clinicalcode/entity_utils/api_utils.py
+++ b/CodeListLibrary_project/clinicalcode/entity_utils/api_utils.py
@@ -1,12 +1,17 @@
from django.db import connection
-from django.contrib.auth.models import User
+from difflib import SequenceMatcher as SM
from rest_framework.response import Response
from rest_framework import status
from django.db.models.functions import JSONObject
from django.db.models import ForeignKey, F
+from django.core.exceptions import FieldDoesNotExist
from rest_framework.renderers import JSONRenderer
+from django.contrib.auth import get_user_model
+
+import psycopg2
from ..models.GenericEntity import GenericEntity
+from ..models.Organisation import Organisation
from ..models.Template import Template
from ..models.Concept import Concept
from . import model_utils
@@ -18,6 +23,8 @@
from . import gen_utils
from . import constants
+User = get_user_model()
+
""" REST renderer """
class PrettyJsonRenderer(JSONRenderer):
def get_indent(self, accepted_media_type, renderer_context):
@@ -87,16 +94,16 @@ def exists_historical_entity(entity_id, user, historical_id=None):
returns response 404
"""
if not historical_id:
- historical_id = permission_utils.get_latest_entity_historical_id(
- entity_id, user
+ historical_entity = GenericEntity.history.filter(id=entity_id).latest_of_each()
+ else:
+ historical_entity = model_utils.try_get_instance(
+ GenericEntity,
+ id=entity_id
)
+ if historical_entity:
+ historical_entity = historical_entity.history.filter(history_id=historical_id)
- historical_entity = model_utils.try_get_instance(
- GenericEntity,
- id=entity_id
- ).history.filter(history_id=historical_id)
-
- if not historical_entity.exists():
+ if not historical_entity or not historical_entity.exists():
return Response(
data={
'message': 'Historical entity version does not exist'
@@ -124,7 +131,7 @@ def exists_concept(concept_id):
if not concept:
return Response(
data={
- 'message': 'Concept does not exist'
+ 'message': 'Entity does not exist'
},
content_type='json',
status=status.HTTP_404_NOT_FOUND
@@ -160,7 +167,7 @@ def exists_historical_concept(request, concept_id, historical_id=None):
if not historical_concept:
return Response(
data={
- 'message': 'Historical concept version does not exist'
+ 'message': 'Historical entity version does not exist'
},
content_type='json',
status=status.HTTP_404_NOT_FOUND
@@ -517,8 +524,28 @@ def build_template_subquery_from_string(param, data, top_ref, sub_ref, validatio
datatype = None
processor = ''
if sub_type == 'int_array':
- data = [ int(x) for x in data.split(',') if isinstance(gen_utils.parse_int(x, default=None), int) ]
- datatype = 'bigint'
+ data = data.split(',')
+
+ coercion = sub_validation.get('coerce')
+ if isinstance(coercion, list):
+ res = []
+ for x in data:
+ if isinstance(x, str):
+ matched = [{ 'out': v.get('out'), 'ratio': SM(None, x.strip().lower(), v.get('compare').lower()).ratio() } for v in coercion]
+ matched.sort(key=lambda x: x.get('ratio'), reverse=True)
+
+ matched = matched[0] if len(matched) > 0 else None
+ if matched and matched.get('ratio') > 0.3:
+ res.append(matched.get('out'))
+ continue
+
+ if gen_utils.is_int(x):
+ res.append(int(x))
+ data = res
+ else:
+ data = [ int(x) for x in data if isinstance(gen_utils.parse_int(x, default=None), int) ]
+
+ datatype = 'bigint'
elif sub_type == 'string_array':
data = [ str(x).lower() for x in data.split(',') if gen_utils.try_value_as_type(x, 'string') is not None ]
datatype = 'text'
@@ -598,18 +625,20 @@ def build_template_subquery_from_string(param, data, top_ref, sub_ref, validatio
return True, [ query, dataset ]
-def build_query_string_from_param(param, data, validation, field_type, is_dynamic=False, prefix=''):
+def build_query_string_from_param(request, param, data, field_data, field_type, is_dynamic=False, prefix=''):
"""
Builds query (terms and where clauses) based on a template
[!] NOTE: Parameters & types should be validated _BEFORE_ calling this function
Args:
+ request (HTTPContext): Request context
+
param (string): the name of the request param that's been mapped to the template
data (any): the value portion of the key-value pair param
- validation (dict): validation dictionary defined by the template
+ field_data (dict): the field data dict
field_type (str): the associated field type as defined by the template
@@ -626,12 +655,16 @@ def build_query_string_from_param(param, data, validation, field_type, is_dynami
3. dict|None - the processed data if successful
"""
+ validation = field_data.get('validation')
+ if not isinstance(validation, dict):
+ return False, None, None
+
if len(prefix) > 0:
prefix = prefix + '_'
query = None
if field_type == 'int' or field_type == 'enum':
- if 'options' in validation or 'source' in validation:
+ if not validation.get('ugc', False) and ('options' in validation or 'source' in validation):
data = [ int(x) for x in data.split(',') if gen_utils.parse_int(x, default=None) is not None ]
if is_dynamic:
@@ -650,6 +683,7 @@ 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
+
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
@@ -685,10 +719,7 @@ def build_query_string_from_param(param, data, validation, field_type, is_dynami
t1.search_vector
@@ to_tsquery(
'pg_catalog.english',
- replace(
- websearch_to_tsquery('pg_catalog.english', %({prefix}{param}_trsearch)s)::text
- || ':*', '<->', '|'
- )
+ replace(to_tsquery('pg_catalog.english', concat(regexp_replace(trim(%({prefix}{param}_trsearch)s), '\W+', ':* & ', 'gm'), ':*'))::text, '<->', '|')
)
)
)
@@ -717,6 +748,9 @@ def build_query_string_from_param(param, data, validation, field_type, is_dynami
)
'''
else:
+ data = coerce_into_opts(request, field_data, data)
+ if data is None:
+ return False, None, None
query = f'''
exists(
select 1
@@ -727,11 +761,13 @@ def build_query_string_from_param(param, data, validation, field_type, is_dynami
else '[]'
end
) as val
- where val::text = any(%({prefix}{param}_data)s)
+ where val::text = any(%({prefix}{param}_data)s::text[])
)
'''
else:
- data = [ int(x) for x in data.split(',') if gen_utils.parse_int(x, default=None) is not None ]
+ data = coerce_into_opts(request, field_data, data)
+ if data is None:
+ return False, None, None
query = f'''entity.{param} && %({prefix}{param}_data)s'''
return True, query, { f'{prefix}{param}_data': data }
@@ -750,6 +786,72 @@ def build_query_string_from_param(param, data, validation, field_type, is_dynami
return False, None, None
+def coerce_into_opts(request, field_data, data, default=None):
+ """
+ Attempts to coerce str|int list values for `int_array` targets into an `int`-only array by querying the options available to the client
+
+ Args:
+ request (HTTPContext): Request context
+
+ field_data (dict): the field data assoc. with the queryable field
+
+ data (list|string): the data query input
+
+ default (Any): optionally specify a default return value; defaults to `None`
+
+ Returns:
+ Either (a) a list of IDs assoc. with the specified input; or (b) if invalid, a None-type value
+ """
+ if isinstance(data, str):
+ data = data.split(',')
+
+ if not isinstance(data, list):
+ return default
+
+ data_ids = [ int(x.strip()) for x in data if gen_utils.is_int(x.strip()) ]
+ data_str = [ x.lower().strip() for x in data if isinstance(x, str) ]
+
+ validation = field_data.get('validation')
+ source = validation.get('source') if isinstance(validation, dict) else None
+ if not isinstance(source, dict):
+ return data_ids
+
+ model = source.get('table')
+ field = source.get('query')
+ relative = source.get('relative')
+ if gen_utils.is_empty_string(model) or gen_utils.is_empty_string(field) or gen_utils.is_empty_string(relative):
+ return data_ids
+
+ if len(data_str) < 1:
+ return data_ids
+
+ model = f'clinicalcode_{model}'.lower()
+ with connection.cursor() as cursor:
+ sql = psycopg2.sql.SQL('''
+ select
+ {field} as id
+ from {model} as item
+ where lower({relative}::text) = any(%(str_comp)s::text[])
+ or {field}::bigint = any(%(int_comp)s::bigint[]);
+ ''') \
+ .format(
+ field=psycopg2.sql.Identifier(field),
+ model=psycopg2.sql.Identifier(model),
+ relative=psycopg2.sql.Identifier(relative)
+ )
+
+ cursor.execute(sql, params={
+ 'int_comp': data_ids,
+ 'str_comp': data_str,
+ })
+
+ columns = [col[0] for col in cursor.description]
+ results = [dict(zip(columns, row)) for row in cursor.fetchall()]
+ return [x.get('id') for x in results]
+
+ return data_ids
+
+
def build_query_from_template(request, user_authed, template=None):
"""
Builds query (terms and where clauses) based on a template
@@ -837,26 +939,54 @@ def get_entity_detail_from_layout(
if is_active == False:
continue
- requires_auth = template_utils.try_get_content(
- field_definition, 'requires_auth')
+ requires_auth = template_utils.try_get_content(field_definition, 'requires_auth')
if requires_auth and not user_authed:
continue
if template_utils.is_metadata(entity, field):
- validation = template_utils.get_field_item(
- constants.metadata, field, 'validation', {}
- )
+ field_info = template_utils.get_layout_field(fields, field)
+ validation = field_info.get('validation')
is_source = validation.get('source')
if is_source:
- if template_utils.is_metadata(entity, field=field):
- value = template_utils.get_metadata_value_from_source(
- entity, field, default=None
- )
- else:
+ value = template_utils.get_metadata_value_from_source(
+ entity, field, default=None
+ )
+ else:
+ value = None
+ try:
+ field_ref = entity._meta.get_field(field)
+ field_type = field_ref.get_internal_type()
+ if field_type in constants.STRIPPED_FIELDS:
+ continue
+
+ if isinstance(field_ref, ForeignKey):
+ model = field_ref.target_field.model
+ model_type = str(model)
+ if model_type in constants.USERDATA_MODELS:
+ if model_type == str(User):
+ value = template_utils.get_one_of_field(value, ['username', 'name'])
+ elif hasattr(model, 'serialise_api') and callable(getattr(model, 'serialise_api')):
+ value = getattr(entity, field).serialise_api()
+ else:
+ value = {
+ 'id': field.id,
+ 'name': template_utils.get_one_of_field(field, ['username', 'name'])
+ }
+ except:
+ pass
+
+ if value is None:
value = template_utils.get_entity_field(entity, field)
- result[field] = value
+ if field in constants.API_MAP_FIELD_NAMES:
+ field_name = constants.API_MAP_FIELD_NAMES.get(field)
+ elif isinstance(field_info.get('shunt'), str):
+ field_name = field_info.get('shunt')
+ else:
+ field_name = field
+
+ result[field_name] = value
continue
if field == 'concept_information':
@@ -880,13 +1010,14 @@ def get_entity_detail_from_layout(
return result
-def get_entity_detail_from_meta(entity, data, fields_to_ignore=[], target_field=None):
+def get_entity_detail_from_meta(entity, fields, data, fields_to_ignore=[], target_field=None):
"""
Retrieves entity detail in the format required for detail API endpoint,
specifically from metadata fields, e.g. constants
Args:
entity (GenericEntity): Entity object to get the detail for
+ fields (dict): dict containing layout of the entity
data (dict): dict containing previously built detail
fields_to_ignore (list of strings): List of fields to remove from output
target_field (string): Field to be targeted, i.e. only build the detail
@@ -904,6 +1035,10 @@ def get_entity_detail_from_meta(entity, data, fields_to_ignore=[], target_field=
if field_name.lower() in data or field_name.lower() in fields_to_ignore:
continue
+ field_tmpl = fields.get(field_name)
+ if isinstance(field_tmpl, dict):
+ continue
+
field_type = field.get_internal_type()
if field_type and field_type in constants.STRIPPED_FIELDS:
continue
@@ -911,6 +1046,10 @@ def get_entity_detail_from_meta(entity, data, fields_to_ignore=[], target_field=
if field_name in constants.API_HIDDEN_FIELDS:
continue
+ field_data = constants.metadata.get(field_name)
+ if field_data and field_data.get('active') == False:
+ continue
+
field_value = template_utils.get_entity_field(entity, field_name)
if field_value is None:
result[field_name] = None
@@ -923,6 +1062,8 @@ def get_entity_detail_from_meta(entity, data, fields_to_ignore=[], target_field=
if model_type == str(User):
result[field_name] = template_utils.get_one_of_field(
field_value, ['username', 'name'])
+ elif hasattr(model, 'serialise_api') and callable(getattr(model, 'serialise_api')):
+ result[field_name] = getattr(entity, field_name).serialise_api()
else:
result[field_name] = {
'id': field_value.id,
@@ -1004,8 +1145,7 @@ def get_entity_detail(
return layout_response
layout = layout_response
- layout_definition = template_utils.get_merged_definition(
- layout, default={})
+ layout_definition = template_utils.get_merged_definition(layout, default={})
layout_version = layout.template_version
fields = template_utils.try_get_content(layout_definition, 'fields')
@@ -1017,20 +1157,19 @@ def get_entity_detail(
)
result = result | get_entity_detail_from_meta(
- entity, result, fields_to_ignore=fields_to_ignore, target_field=target_field
+ entity, fields, result, fields_to_ignore=fields_to_ignore, target_field=target_field
)
entity_versions = get_entity_version_history(request, entity_id)
if target_field is None:
- result = get_ordered_entity_detail(
- fields, layout, layout_version, entity_versions, result)
+ result = get_ordered_entity_detail(fields, layout, layout_version, entity_versions, result)
+ result = {'phenotype_id': entity.id, 'phenotype_version_id': entity.history_id} | result
if return_data:
return result
return Response(
- data=[{'phenotype_id': entity.id,
- 'phenotype_version_id': entity.history_id} | result],
+ data=[result],
status=status.HTTP_200_OK
)
@@ -1077,10 +1216,6 @@ def build_final_codelist_from_concepts(
if include_headers:
concept_data |= { 'code_attribute_header': concept_entity.code_attribute_header}
- if 'attributes' in concept and concept['attributes']:
- concept_data |= {'attributes': concept['attributes']}
-
-
# Get codes
concept_codes = concept_utils.get_concept_codelist(
concept_id,
@@ -1325,30 +1460,51 @@ def get_formatted_concept_codes(concept, concept_codelist, headers=None):
return concept_codes
-def annotate_linked_entities(entities):
+def annotate_linked_entities(entities, has_hx_id=True):
"""
Annotates linked entities with phenotype and template details
Args:
entities (QuerySet): Entities queryset
+ has_hx_id (bool): Optionally specify whether to append the phenotype history id
Returns:
Queryset containing annotated entities
"""
- return entities.annotate(
- phenotype_id=F('id'),
- phenotype_version_id=F('history_id'),
- phenotype_name=F('name')
- ) \
- .values(
- 'phenotype_id',
- 'phenotype_version_id',
- 'phenotype_name'
- ) \
- .annotate(
+ if has_hx_id:
+ res = entities.annotate(
+ phenotype_id=F('id'),
+ phenotype_version_id=F('history_id'),
+ phenotype_name=F('name')
+ ) \
+ .values(
+ 'phenotype_id',
+ 'phenotype_version_id',
+ 'phenotype_name'
+ ) \
+ .annotate(
+ template=JSONObject(
+ id=F('template__id'),
+ version_id=F('template_version'),
+ name=F('template__name')
+ )
+ )
+ else:
+ res = entities.annotate(
+ phenotype_id=F('id'),
+ phenotype_name=F('name')
+ ) \
+ .values(
+ 'phenotype_id',
+ 'phenotype_name'
+ )
+
+ res = res.annotate(
template=JSONObject(
id=F('template__id'),
version_id=F('template_version'),
name=F('template__name')
)
)
+
+ return res
diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/concept_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/concept_utils.py
index df63116ee..18be99d41 100644
--- a/CodeListLibrary_project/clinicalcode/entity_utils/concept_utils.py
+++ b/CodeListLibrary_project/clinicalcode/entity_utils/concept_utils.py
@@ -844,9 +844,9 @@ def get_minimal_concept_data(concept):
'coding_system': model_utils.get_coding_system_details(concept.coding_system)
} | concept_data
-def get_clinical_concept_data(concept_id, concept_history_id,include_reviewed_codes=False,
+def get_clinical_concept_data(concept_id, concept_history_id, include_reviewed_codes=False,
aggregate_component_codes=False, include_component_codes=True,
- include_attributes=False, strippable_fields=None,concept_attributes=None,
+ include_attributes=False, strippable_fields=None,
remove_userdata=False, hide_user_details=False,
derive_access_from=None, requested_entity_id=None,
format_for_api=False, include_source_data=False):
@@ -867,7 +867,6 @@ def get_clinical_concept_data(concept_id, concept_history_id,include_reviewed_co
include_attributes (boolean): Should we include attributes?
strippable_fields (list): Whether to strip any fields from the Concept model when
building the concept's data result
- concept_attributes (list): The workingset list of attributes
remove_userdata (boolean): Whether to remove userdata related fields from the result (assoc. with each Concept)
derive_access_from (RequestContext): Using the RequestContext, determine whether a user can edit a Concept
format_for_api (boolean): Flag to format against legacy API
@@ -1005,7 +1004,6 @@ def get_clinical_concept_data(concept_id, concept_history_id,include_reviewed_co
'concept_version_id': concept_history_id,
'coding_system': model_utils.get_coding_system_details(historical_concept.coding_system),
'details': concept_data,
- 'attributes': concept_attributes,
'components': components_data.get('components') if components_data is not None else [],
}
else:
diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/constants.py b/CodeListLibrary_project/clinicalcode/entity_utils/constants.py
index 637d27ba0..33c0c58d8 100644
--- a/CodeListLibrary_project/clinicalcode/entity_utils/constants.py
+++ b/CodeListLibrary_project/clinicalcode/entity_utils/constants.py
@@ -1,9 +1,13 @@
from django.http.request import HttpRequest
-from django.contrib.auth.models import User, Group
+from django.contrib.auth import get_user_model
+from django.contrib.auth.models import Group
import enum
+User = get_user_model()
+
+
class TypeStatus:
""" Legacy type status - needs removal during cleanup """
Disease = 0
@@ -34,6 +38,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
@@ -146,6 +171,17 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta):
CLINICAL_DISEASE = 0
CLINICAL_DOMAIN = 1
CLINICAL_FUNCTIONAL_ANATOMY = 2
+ MESH_CODES = 3
+
+"""
+ Number of days before organisation invite expires
+"""
+INVITE_TIMEOUT = 30
+
+"""
+ Describes a set of names representing a float/double-like numeric value
+"""
+NUMERIC_NAMES = ['float', 'numeric', 'decimal', 'percentage']
"""
Used to define the labels for each
@@ -153,6 +189,7 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta):
"""
ONTOLOGY_LABELS = {
+ ONTOLOGY_TYPES.MESH_CODES: 'Medical Subject Headings (MeSH)',
ONTOLOGY_TYPES.CLINICAL_DOMAIN: 'Clinical Domain',
ONTOLOGY_TYPES.CLINICAL_DISEASE: 'Clinical Disease Category (SNOMED)',
ONTOLOGY_TYPES.CLINICAL_FUNCTIONAL_ANATOMY: 'Functional Anatomy',
@@ -252,7 +289,7 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta):
Used to strip userdata from models when JSONifying them
e.g. user account, user profile, membership
"""
-USERDATA_MODELS = [str(User), str(Group)]
+USERDATA_MODELS = [str(User), str(Group), ""]
STRIPPED_FIELDS = ['SearchVectorField']
"""
@@ -281,6 +318,7 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta):
Describes fields that should be stripped from entity list api response
"""
ENTITY_LIST_API_HIDDEN_FIELDS = [
+ 'deleted', 'created_by', 'updated_by', 'deleted_by', 'brands',
'concept_information', 'definition', 'implementation'
]
@@ -347,7 +385,7 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta):
{
'title': 'Permissions',
'description': 'Settings for sharing and collaboration.',
- 'fields': ['group', 'group_access', 'world_access']
+ 'fields': ['organisation', 'world_access']
}
]
@@ -385,7 +423,8 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta):
'title': 'Permissions',
'field_type': 'permissions_section',
'active': True,
- 'hide_on_create': True
+ 'hide_on_create': True,
+ 'requires_auth': True
},
'api': {
'title': 'API',
@@ -407,6 +446,14 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta):
}
}
+"""Default brand ctx content name mapping"""
+DEFAULT_CONTENT_MAPPING = {
+ 'phenotype': 'Phenotype',
+ 'phenotype_url': 'phenotypes',
+ 'concept': 'Concept',
+ 'concept_url': 'concepts',
+}
+
"""
[!] Note: Will be moved to a table once tooling is finished, accessible through the 'base_template_version'
@@ -437,7 +484,7 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta):
},
'brands': {
'title': 'Brand',
- 'description': 'The brand that this Phenotype is related to.',
+ 'description': 'The brand that this entity is related to.',
'field_type': '???',
'active': True,
'validation': {
@@ -571,7 +618,15 @@ 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',
+ 'HDRN': True,
+ 'SAIL': False,
+ },
}
}
},
@@ -599,7 +654,12 @@ 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',
+ 'HDRN': True,
+ 'SAIL': False,
+ },
}
}
},
@@ -609,13 +669,25 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta):
},
'is_base_field': True
},
+ 'organisation': {
+ 'title': 'Organisation',
+ 'description': "The organisation that owns this entity for permissions purposes (optional).",
+ 'field_type': 'group_field',
+ 'active': True,
+ 'validation': {
+ 'type': 'organisation',
+ 'mandatory': False,
+ 'computed': True
+ },
+ 'is_base_field': True
+ },
'group': {
'title': 'Group',
- 'description': "The group that owns this Phenotype for permissions purposes (optional).",
+ 'description': "The group that owns this entity for permissions purposes (optional).",
'field_type': 'group_field',
- 'active': True,
+ 'active': False,
'validation': {
- 'type': 'int',
+ 'type': 'group',
'mandatory': False,
'computed': True
},
@@ -623,27 +695,43 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta):
},
'group_access': {
'title': 'Group Access',
- 'description': 'Optionally enable this Phenotype to be viewed or edited by the group.',
+ 'description': 'Optionally enable this entity to be viewed or edited by the group.',
'field_type': 'access_field_editable',
- 'active': True,
+ 'active': False,
+ 'validation': {
+ 'type': 'int',
+ 'mandatory': True,
+ 'range': [1, 3],
+ 'default': 1
+ },
+ 'is_base_field': True
+ },
+ 'owner_access': {
+ 'title': 'Owner Access',
+ 'description': 'Owner permissions',
+ 'field_type': 'access_field_editable',
+ 'active': False,
'validation': {
'type': 'int',
'mandatory': True,
- 'range': [1, 3]
+ 'range': [1, 3],
+ 'default': 3
},
'is_base_field': True
},
'world_access': {
- 'title': 'All authenticated users',
- 'description': "Enables this Phenotype to be viewed by all logged-in users of the Library (does not make it public on the web -- use the Publish action for that).",
+ 'title': 'Share with other organisations',
+ 'description': "Enables this entity to be viewed by all logged-in users of the Library who are members of an Organisation.",
'field_type': 'access_field',
'active': True,
'validation': {
'type': 'int',
'mandatory': True,
- 'range': [1, 3]
+ 'range': [1, 3],
+ 'default': 1
},
- 'is_base_field': True
+ 'is_base_field': True,
+ 'hide_non_org_managed': True
},
'updated': {
'title': 'Updated',
@@ -756,6 +844,23 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta):
'output_type': 'dropdown-list'
},
+ 'age_group': {
+ 'input_type': 'generic/age_group',
+ 'output_type': 'generic/age_group'
+ },
+
+ 'single_slider': {
+ 'data_type': 'string',
+ 'input_type': 'single_slider',
+ 'output_type': 'single_slider'
+ },
+
+ 'double_range_slider': {
+ 'data_type': 'string',
+ 'input_type': 'double_range_slider',
+ 'output_type': 'double_range'
+ },
+
'grouped_enum': {
'data_type': 'int',
'input_type': 'grouped_enum',
@@ -763,6 +868,18 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta):
'apply_badge_style': True
},
+ 'list_enum': {
+ 'data_type': 'int_array',
+ 'input_type': 'list_enum',
+ 'output_type': 'radiobutton',
+ 'apply_badge_style': True
+ },
+
+ 'contact_information': {
+ 'input_type': 'clinical/contact_information',
+ 'output_type': 'clinical/contact_information',
+ },
+
'ontology': {
'input_type': 'generic/ontology',
'output_type': 'generic/ontology'
@@ -800,6 +917,10 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta):
'input_type': 'clinical/trial',
'output_type': 'clinical/trial',
},
+ 'references': {
+ 'input_type': 'clinical/references',
+ 'output_type': 'clinical/references',
+ },
'coding_system': {
'system_defined': True,
'description': 'list of coding system ids (calculated from Phenotype concepts) (managed by code snippet)',
@@ -810,19 +931,25 @@ 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,
'description': 'list of data_sources ids (managed by code snippet)',
'input_type': 'tagbox',
- 'output_type': 'data_source'
+ 'output_type': 'data_source',
+ },
+ 'data_assets': {
+ 'input_type': 'data_assets',
+ 'output_type': 'data_assets',
},
'phenoflowid': {
'system_defined': True,
@@ -872,4 +999,52 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta):
'input_type': 'inputbox',
'output_type': 'source_reference'
},
+ 'model_relations': {
+ 'input_type': 'tagbox',
+ 'output_type': 'tagbox',
+ },
+ 'validation_measures': {
+ 'input_type': 'var_selector',
+ 'output_type': 'var_selector',
+ 'appearance': {
+ 'txt': {
+ 'single': 'Validation Measure',
+ 'plural': 'Validation Measures',
+ },
+ 'add_btn': {
+ 'label': 'Create new Measure',
+ 'icon': '',
+ },
+ 'clear_btn': {
+ 'label': 'Clear all Measures',
+ 'icon': '',
+ },
+ },
+ },
+ 'srv_list': {
+ 'input_type': 'var_selector',
+ 'output_type': 'var_selector',
+ 'appearance': {
+ 'txt': {
+ 'single': 'Variable',
+ 'plural': 'Variables',
+ },
+ 'add_btn': {
+ 'label': 'Create new Variable',
+ 'icon': '',
+ },
+ 'clear_btn': {
+ 'label': 'Clear all Variables',
+ 'icon': '',
+ },
+ },
+ },
+ 'indicator_calculation': {
+ 'input_type': 'indicator_calculation',
+ 'output_type': 'indicator_calculation',
+ },
+ 'related_entities': {
+ 'input_type': 'related_entities',
+ 'output_type': 'related_entities',
+ },
}
diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/create_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/create_utils.py
index 53e6e8387..12b6de99b 100644
--- a/CodeListLibrary_project/clinicalcode/entity_utils/create_utils.py
+++ b/CodeListLibrary_project/clinicalcode/entity_utils/create_utils.py
@@ -1,29 +1,32 @@
+from operator import and_
+from datetime import datetime
+from functools import reduce
from django.db import transaction, IntegrityError, connection
from django.apps import apps
from django.db.models import Q
from django.utils.timezone import make_aware
-from datetime import datetime
import logging
+import inspect
import psycopg2
-from ..models.EntityClass import EntityClass
+from ..models.Tag import Tag
+from ..models.Code import Code
+from ..models.Brand import Brand
+from ..models.Concept import Concept
+from ..models.CodeList import CodeList
from ..models.Template import Template
-from ..models.GenericEntity import GenericEntity
+from ..models.Component import Component
+from ..models.EntityClass import EntityClass
from ..models.CodingSystem import CodingSystem
-from ..models.Concept import Concept
+from ..models.Organisation import Organisation, OrganisationAuthority
+from ..models.GenericEntity import GenericEntity
from ..models.ConceptCodeAttribute import ConceptCodeAttribute
-from ..models.Component import Component
-from ..models.CodeList import CodeList
-from ..models.Code import Code
-from ..models.Tag import Tag
-from . import gen_utils
-from . import model_utils
-from . import permission_utils
-from . import template_utils
-from . import concept_utils
-from . import constants
+from . import (
+ gen_utils, model_utils, permission_utils,
+ template_utils, concept_utils, constants
+)
logger = logging.getLogger(__name__)
@@ -48,7 +51,7 @@ def get_createable_entities(request):
be created and their associated templates
"""
entities = EntityClass.objects.all().values('id', 'name', 'description', 'entity_prefix')
- templates = Template.objects.filter(
+ templates = Template.get_brand_records_by_request(request).filter(
entity_class__id__in=entities.values_list('id', flat=True)
) \
.exclude(hide_on_create=True) \
@@ -59,37 +62,33 @@ def get_createable_entities(request):
'templates': list(templates)
}
-def get_template_creation_data(request, entity, layout, field, default=None):
+def get_template_creation_data(request, entity, layout, field, default=None, info=None):
"""
Used to retrieve assoc. data values for specific keys, e.g.
concepts, in its expanded format for use with create/update pages
"""
- data = template_utils.get_entity_field(entity, field)
- info = template_utils.get_layout_field(layout, field)
- if not info and template_utils.is_metadata(entity, field):
- info = template_utils.try_get_content(constants.metadata, field)
+ if info is None:
+ info = template_utils.get_template_field_info(layout, field)
+ data = template_utils.get_entity_field(entity, field)
if not info or not data:
return default
- validation = template_utils.try_get_content(info, 'validation')
+ field_info = info.get('field')
+ validation = template_utils.try_get_content(field_info, 'validation')
if validation is None:
return default
field_type = template_utils.try_get_content(validation, 'type')
if field_type is None:
return default
-
+
if field_type == 'concept':
values = []
for item in data:
- workingset_concept_attributes = None
- if 'attributes' in item:
- workingset_concept_attributes = item['attributes']
value = concept_utils.get_clinical_concept_data(
item['concept_id'],
item['concept_version_id'],
- concept_attributes=workingset_concept_attributes,
aggregate_component_codes=False,
requested_entity_id=entity.id,
derive_access_from=request,
@@ -99,7 +98,7 @@ def get_template_creation_data(request, entity, layout, field, default=None):
if value:
values.append(value)
-
+
return values
elif field_type == 'int_array':
source_info = validation.get('source')
@@ -113,17 +112,19 @@ def get_template_creation_data(request, entity, layout, field, default=None):
logger.warning(f'Failed to retrieve template "{field}" property from Entity<{entity}> with err: {e}')
return default
- if template_utils.is_metadata(entity, field):
- return template_utils.get_metadata_value_from_source(entity, field, default=default)
+ if info.get('is_metadata'):
+ return template_utils.get_metadata_value_from_source(entity, field, field_info=info, layout=layout, default=default)
return template_utils.get_template_data_values(entity, layout, field, default=default)
-def try_add_computed_fields(field, form_data, form_template, data):
+def try_add_computed_fields(field, form_data, form_template, data, field_data=None):
"""
Checks to see if any of our fields have any computed data
that we need to collect from a child or related field
"""
- field_data = template_utils.get_layout_field(form_template, field)
+ if field_data is None:
+ field_data = template_utils.get_layout_field(form_template, field)
+
if field_data is None:
return
@@ -305,21 +306,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):
@@ -450,6 +448,84 @@ def validate_template_field(template, field):
return field in fields
+def validate_freeform_field(request, field, field_data, value, errors=[]):
+ """
+ Validates fields with behaviour variations, e.g. freeform tags
+ """
+ validation = template_utils.try_get_content(field_data, 'validation')
+ source = validation.get('source') if isinstance(validation, dict) else { }
+
+ model = source.get('table')
+ query = source.get('query')
+ relative = source.get('relative')
+
+ if value is not None and not isinstance(value, list):
+ if value is None:
+ return None
+
+ errors.append(f'Expected {field} as array, got {type(value)}')
+ return None
+
+ data_ids = [ x.get('value') for x in value if x is not None and gen_utils.is_int(x.get('value')) ]
+ data_str = [ x.get('name').strip() for x in value if x is None or not gen_utils.is_int(x.get('value')) ]
+
+ creatable = None
+ invalid = gen_utils.is_empty_string(model) or gen_utils.is_empty_string(query) or gen_utils.is_empty_string(relative)
+ if len(data_str) > 0 and not invalid:
+ model = f'clinicalcode_{model}'.lower()
+ with connection.cursor() as cursor:
+ sql = psycopg2.sql.SQL('''
+ select
+ distinct on ({relative})
+ {field} as id,
+ {relative} as name
+ from {model} as item
+ where lower({relative}::text) = any(%(str_comp)s::text[]);
+ ''') \
+ .format(
+ field=psycopg2.sql.Identifier(query),
+ model=psycopg2.sql.Identifier(model),
+ relative=psycopg2.sql.Identifier(relative)
+ )
+
+ cursor.execute(sql, params={
+ 'str_comp': [ x.lower() for x in data_str ],
+ })
+
+ columns = [col[0] for col in cursor.description]
+ results = [dict(zip(columns, row)) for row in cursor.fetchall()]
+
+ resultset = []
+ remainder = list(set(data_str.copy()))
+ for x in results:
+ try:
+ idx = remainder.index(x.get('name'))
+ if idx is not None:
+ remainder.pop(idx)
+ except:
+ pass
+
+ resultset.append(x.get('id'))
+
+ if len(resultset) > 0:
+ data_ids = list(set(data_ids + resultset))
+ else:
+ data_ids = list(set(data_ids))
+
+ if len(remainder) > 0:
+ creatable = remainder
+
+ if 'source' in validation or 'options' in validation:
+ field_value = try_validate_sourced_value(field, field_data, data_ids, request=request)
+ if not isinstance(field_value, list) or len(field_value) < 1:
+ field_value = None
+
+ if field_value is not None or creatable is not None:
+ return { 'value': field_value, 'creatable': creatable }
+
+ return None
+
+
def validate_computed_field(request, field, field_data, value, errors=[]):
"""
Computed fields, e.g. Groups, that can be computed based on RequestContext
@@ -459,6 +535,8 @@ def validate_computed_field(request, field, field_data, value, errors=[]):
errors.append('RequestContext invalid')
return None
+ current_brand = model_utils.try_get_brand(request)
+
validation = template_utils.try_get_content(field_data, 'validation')
if validation is None:
return value
@@ -469,29 +547,48 @@ def validate_computed_field(request, field, field_data, value, errors=[]):
field_info = GenericEntity._meta.get_field(field)
if not field_info:
- return
+ return None
field_type = field_info.get_internal_type()
if field_type != 'ForeignKey':
- return
+ return None
model = field_info.target_field.model
if model is not None:
- field_value = gen_utils.try_value_as_type(value, field_type, validation)
+ field_value = gen_utils.try_value_as_type(value, validation.get('type', field_type), validation)
if field_value is None:
return None
-
+
instance = model_utils.try_get_instance(model, pk=field_value)
if instance is None:
errors.append(f'"{field}" is invalid')
return None
-
- if field == 'group':
- is_member = user.is_superuser or user.groups.filter(name__iexact=instance.name).exists()
+
+ if field == 'organisation' or field == 'group':
+ is_member = user.is_superuser or (
+ not user.is_anonymous and (
+ instance.owner_id == user.id or
+ user.organisationmembership_set.filter(
+ Q(organisation_id=instance.id) &
+ Q(role__gte=constants.ORGANISATION_ROLES.EDITOR.value)
+ ).exists()
+ )
+ )
if not is_member:
- errors.append(f'Tried to set {field} without being a member of that group.')
+ errors.append(f'Tried to set {field}<{instance}> without being a member of that group.')
return None
-
+
+ if current_brand and current_brand.org_user_managed:
+ if not isinstance(instance, Organisation):
+ errors.append(f'You must supply an Organisation.')
+ return None
+
+ brand_authority = OrganisationAuthority.objects \
+ .filter(organisation_id=instance.id, brand_id=current_brand.id)
+ if not brand_authority.exists():
+ errors.append(f'You are trying to use an Organisation that\'s not associated with this Brand, please use another organisation.')
+ return None
+
return instance
return value
@@ -510,37 +607,14 @@ def validate_concept_form(form, errors):
'components': [ ],
}
- # Validate concept attributes based on their type
- concept_attributes = []
- if form.get('attributes'):
- for attribute in form.get('attributes'):
- if attribute['value']:
- attribute_type = attribute.get('type')
- attribute_value = attribute.get('value')
- if not gen_utils.is_empty_string(attribute_value):
- if attribute_type == 'INT':
- try:
- attribute_value = str(int(attribute_value))
- except ValueError:
- errors.append(f'Attribute {attribute["name"]} must be an integer.')
- continue
- elif attribute_type == 'FLOAT':
- try:
- attribute['value'] = str(float(attribute_value))
- except ValueError:
- errors.append(f'Attribute {attribute["name"]} must be a float.')
- continue
- concept_attributes.append(attribute)
-
if not is_new_concept and concept_id is not None and concept_history_id is not None:
concept = model_utils.try_get_instance(Concept, id=concept_id)
concept = model_utils.try_get_entity_history(concept, history_id=concept_history_id)
if concept is None:
- errors.append(f'Child Concept entity with ID {concept_id} and history ID {concept_history_id} does not exist.')
+ errors.append(f'Child entity with ID {concept_id} and history ID {concept_history_id} does not exist.')
return None
field_value['concept']['id'] = concept_id
field_value['concept']['history_id'] = concept_history_id
- field_value['concept']['attributes'] = concept_attributes
else:
is_new_concept = True
@@ -549,19 +623,19 @@ def validate_concept_form(form, errors):
concept_details = form.get('details')
if is_new_concept and (concept_details is None or not isinstance(concept_details, dict)):
- errors.append(f'Invalid concept with ID {concept_id} - details is a non-nullable dict field.')
+ errors.append(f'Invalid child entity with ID {concept_id} - details is a non-nullable dict field.')
return None
if isinstance(concept_details, dict):
concept_name = gen_utils.try_value_as_type(concept_details.get('name'), 'string', { 'sanitise': 'strict' })
if is_new_concept and concept_name is None:
- errors.append(f'Invalid concept with ID {concept_id} - name is non-nullable, string field.')
+ errors.append(f'Invalid child entity with ID {concept_id} - name is non-nullable, string field.')
return None
concept_coding = gen_utils.parse_int(concept_details.get('coding_system'), None)
concept_coding = model_utils.try_get_instance(CodingSystem, pk=concept_coding)
if is_new_concept and concept_coding is None:
- errors.append(f'Invalid concept with ID {concept_id} - coding_system is non-nullable int field.')
+ errors.append(f'Invalid child entity with ID {concept_id} - coding_system is non-nullable int field.')
return None
attribute_headers = gen_utils.try_value_as_type(
@@ -572,7 +646,7 @@ def validate_concept_form(form, errors):
concept_components = form.get('components')
if is_new_concept and (concept_components is None or not isinstance(concept_components, list)):
- errors.append(f'Invalid concept with ID {concept_id} - components is a non-nullable list field.')
+ errors.append(f'Invalid child entity with ID {concept_id} - components is a non-nullable list field.')
return None
components = [ ]
@@ -585,7 +659,7 @@ def validate_concept_form(form, errors):
if not is_new_component and component_id is not None:
historical_component = Component.history.filter(id=component_id)
if not historical_component.exists():
- errors.append(f'Invalid concept with ID {concept_id} - component is not valid')
+ errors.append(f'Invalid child entity with ID {concept_id} - component is not valid')
return None
component['id'] = component_id
else:
@@ -593,37 +667,37 @@ def validate_concept_form(form, errors):
component_name = gen_utils.try_value_as_type(concept_component.get('name'), 'string', { 'sanitise': 'strict' })
if component_name is None or gen_utils.is_empty_string(component_name):
- errors.append(f'Invalid concept with ID {concept_id} - Component names are non-nullable, string fields.')
+ errors.append(f'Invalid child entity with ID {concept_id} - Component names are non-nullable, string fields.')
return None
component_logical_type = concept_component.get('logical_type')
if component_logical_type is None or component_logical_type not in constants.CLINICAL_RULE_TYPE:
- errors.append(f'Invalid concept with ID {concept_id} - Component logical types are non-nullable, string fields.')
+ errors.append(f'Invalid child entity with ID {concept_id} - Component logical types are non-nullable, string fields.')
return None
component_logical_type = constants.CLINICAL_RULE_TYPE.from_name(component_logical_type)
component_source_type = concept_component.get('source_type')
if component_source_type is None or component_source_type not in constants.CLINICAL_CODE_SOURCE:
- errors.append(f'Invalid concept with ID {concept_id} - Component source types are non-nullable, string fields.')
+ errors.append(f'Invalid child entity with ID {concept_id} - Component source types are non-nullable, string fields.')
return None
component_source_type = constants.CLINICAL_CODE_SOURCE.from_name(component_source_type)
component_source = concept_component.get('source')
if component_source_type == constants.CLINICAL_CODE_SOURCE.SEARCH_TERM and (component_source is None or gen_utils.is_empty_string(component_source)):
- errors.append(f'Invalid concept with ID {concept_id} - Component sources are non-nullable, string fields for search terms.')
+ errors.append(f'Invalid child entity with ID {concept_id} - Component sources are non-nullable, string fields for search terms.')
return None
component_codes = concept_component.get('codes')
component_codes = list() if not isinstance(component_codes, list) else component_codes
# if len(component_codes) < 1:
- # errors.append(f'Invalid concept with ID {concept_id} - Component codes is a non-nullable, list field')
+ # errors.append(f'Invalid child entity with ID {concept_id} - Component codes is a non-nullable, list field')
# return None
codes = { }
for component_code in component_codes:
code = { }
if not isinstance(component_code, dict):
- errors.append(f'Invalid concept with ID {concept_id} - Component code items are non-nullable, dict field')
+ errors.append(f'Invalid child entity with ID {concept_id} - Component code items are non-nullable, dict field')
return None
is_new_code = is_new_component or component_code.get('is_new')
@@ -631,7 +705,7 @@ def validate_concept_form(form, errors):
if not is_new_code and code_id is not None:
historical_code = Code.history.filter(id=code_id)
if not historical_code.exists():
- errors.append(f'Invalid concept with ID {concept_id} - Code is not valid')
+ errors.append(f'Invalid child entity with ID {concept_id} - Code is not valid')
return None
code['id'] = code_id
else:
@@ -639,7 +713,7 @@ def validate_concept_form(form, errors):
code_name = gen_utils.try_value_as_type(component_code.get('code'), 'code', { 'sanitise': 'strict' })
if gen_utils.is_empty_string(code_name):
- errors.append(f'Invalid concept with ID {concept_id} - A code\'s code is a non-nullable, string field')
+ errors.append(f'Invalid child entity with ID {concept_id} - A code\'s code is a non-nullable, string field')
return None
if code_name in codes:
@@ -656,7 +730,7 @@ def validate_concept_form(form, errors):
if isinstance(code_attributes, list):
if len(set(attribute_headers)) != len(code_attributes):
- errors.append(f'Invalid concept with ID {concept_id} - attribute headers must be unique.')
+ errors.append(f'Invalid child entity with ID {concept_id} - attribute headers must be unique.')
return None
code_attributes = code_attributes[:len(attribute_headers)]
@@ -691,7 +765,6 @@ def validate_concept_form(form, errors):
field_value['concept']['name'] = concept_name
field_value['concept']['coding_system'] = concept_coding
field_value['concept']['code_attribute_header'] = attribute_headers
- field_value['concept']['attributes'] = concept_attributes
field_value['components'] = components
return field_value
@@ -734,33 +807,47 @@ def validate_related_entities(field, field_data, value, errors):
return value
-def validate_metadata_value(request, field, value, errors=[]):
+def validate_metadata_value(request, field, value, errors=[], field_data=None):
"""
Validates the form's field value against the metadata fields
"""
- field_data = template_utils.try_get_content(constants.metadata, field)
+ if field_data is None:
+ field_data = template_utils.try_get_content(constants.metadata, field)
+
if field_data is None:
return None, True
validation = template_utils.try_get_content(field_data, 'validation')
if validation is None:
# Exit without error since we haven't included any validation
- return value, False
+ return value, True
field_required = template_utils.try_get_content(validation, 'mandatory')
if field_required and value is None:
+ default_value = template_utils.try_get_content(validation, 'default')
+ if 'default' in validation:
+ return default_value, True
+
errors.append(f'"{field}" is a non-nullable, required field')
return value, False
-
+
+ field_behaviour = template_utils.try_get_content(field_data, 'behaviour')
+ if field_behaviour is not None and field_behaviour.get('freeform', False):
+ field_value = validate_freeform_field(request, field, field_data, value, errors)
+ if field_value is None and field_required:
+ errors.append(f'"{field}" is invalid.')
+ return field_value, False
+ return field_value, True
+
field_type = template_utils.try_get_content(validation, 'type')
- if 'source' in validation or 'options' in validation:
+ if not validation.get('ugc', False) and ('source' in validation or 'options' in validation):
field_value = gen_utils.try_value_as_type(value, field_type, validation)
field_value = try_validate_sourced_value(field, field_data, field_value, request=request)
if field_value is None and field_required:
errors.append(f'"{field}" is invalid')
return field_value, False
return field_value, True
-
+
field_computed = template_utils.try_get_content(validation, 'computed')
if field_computed is not None:
field_value = validate_computed_field(request, field, field_data, value, errors)
@@ -772,11 +859,13 @@ def validate_metadata_value(request, field, value, errors=[]):
field_value = gen_utils.try_value_as_type(value, field_type, validation)
return field_value, True
-def is_computed_template_field(field, form_template):
+def is_computed_template_field(field, form_template, field_data=None):
"""
Checks whether a field is considered a computed field within its template
"""
- field_data = template_utils.get_layout_field(form_template, field)
+ if field_data is None:
+ field_data = template_utils.get_layout_field(form_template, field)
+
if field_data is None:
return False
@@ -790,33 +879,47 @@ def is_computed_template_field(field, form_template):
return False
-def validate_template_value(request, field, form_template, value, errors=[]):
+def validate_template_value(request, field, form_template, value, errors=[], field_data=None):
"""
Validates the form's field value against the entity template
"""
- field_data = template_utils.get_layout_field(form_template, field)
+ if field_data is None:
+ field_data = template_utils.get_layout_field(form_template, field)
+
if field_data is None:
return None, True
-
+
validation = template_utils.try_get_content(field_data, 'validation')
if validation is None:
# Exit without error since we haven't included any validation
- return value, False
-
+ return value, True
+
field_required = template_utils.try_get_content(validation, 'mandatory')
if field_required and value is None:
+ default_value = template_utils.try_get_content(validation, 'default')
+ if 'default' in validation:
+ return default_value, True
+
errors.append(f'"{field}" is a non-nullable, required field')
return value, False
-
+
+ field_behaviour = template_utils.try_get_content(field_data, 'behaviour')
+ if field_behaviour is not None and field_behaviour.get('freeform', False):
+ field_value = validate_freeform_field(request, field, field_data, value, errors)
+ if field_value is None and field_required:
+ errors.append(f'"{field}" is invalid.')
+ return field_value, False
+ return field_value, True
+
field_type = template_utils.try_get_content(validation, 'type')
- if 'source' in validation or 'options' in validation:
+ if not validation.get('ugc', False) and ('source' in validation or 'options' in validation):
field_value = gen_utils.try_value_as_type(value, field_type, validation)
field_value = try_validate_sourced_value(field, field_data, field_value, request=request)
if field_value is None and field_required:
- errors.append(f'"{field}" is invalid')
+ errors.append(f'"{field}" is invalid.')
return field_value, False
return field_value, True
-
+
field_computed = template_utils.try_get_content(validation, 'computed')
if field_computed is not None:
field_value = validate_computed_field(request, field, field_data, value, errors)
@@ -867,26 +970,44 @@ def validate_entity_form(request, content, errors=[], method=None):
if len(errors) > 0:
return
-
+
+ current_brand = model_utils.try_get_brand(request)
+ current_brand = current_brand if current_brand and current_brand.org_user_managed else None
+ if current_brand is not None:
+ organisation = form_data.get('organisation')
+ if organisation is None:
+ errors.append('Your work must be associated with an organisation')
+ return
+
+ valid_user_orgs = permission_utils.get_user_organisations(request)
+ if organisation not in [org.get('id') for org in valid_user_orgs]:
+ errors.append('Your organisation doesn\'t have authorisation to post this work')
+ return
+
# Validate & Clean the form data
top_level_data = { }
template_data = { }
for field, value in form_data.items():
- if template_utils.is_metadata(GenericEntity, field):
- field_value, validated = validate_metadata_value(request, field, value, errors)
+ if field == 'group':
+ field = 'organisation'
+
+ info = template_utils.get_template_field_info(form_template, field)
+ struct = info.get('field')
+ if info.get('is_metadata'):
+ field_value, validated = validate_metadata_value(request, field, value, errors, field_data=struct)
if not validated or field_value is None:
continue
top_level_data[field] = field_value
- elif validate_template_field(form_template, field):
+ elif struct:
if is_computed_template_field(field, form_template):
continue
- field_value, validated = validate_template_value(request, field, form_template, value, errors)
+ field_value, validated = validate_template_value(request, field, form_template, value, errors, field_data=struct)
if not validated or field_value is None:
continue
template_data[field] = field_value
- try_add_computed_fields(field, form_data, form_template, template_data)
+ try_add_computed_fields(field, form_data, form_template, template_data, field_data=struct)
if len(errors) > 0:
return
@@ -1194,7 +1315,7 @@ def build_related_entities(request, field_data, packet, override_dirty=False, en
if override_dirty or concept.get('is_dirty'):
result = try_update_concept(request, item)
if result is not None:
- entities.append({'method': 'update', 'entity': result, 'historical': result.history.latest(), 'attributes': concept.get('attributes')})
+ entities.append({'method': 'update', 'entity': result, 'historical': result.history.latest() })
continue
# If we're not dirty, append the current concept
@@ -1203,51 +1324,109 @@ def build_related_entities(request, field_data, packet, override_dirty=False, en
result = model_utils.try_get_instance(Concept, id=concept_id)
historical = model_utils.try_get_entity_history(result, history_id=concept_history_id)
if historical is not None:
- entities.append({ 'method': 'set', 'entity': result, 'historical': historical, 'attributes': concept.get('attributes') })
+ entities.append({ 'method': 'set', 'entity': result, 'historical': historical })
continue
# Create new concept & components
result = try_create_concept(request, item, entity=entity)
if result is None:
continue
- entities.append({ 'method': 'create', 'entity': result, 'historical': result.history.latest(), 'attributes': concept.get('attributes') })
+ entities.append({ 'method': 'create', 'entity': result, 'historical': result.history.latest() })
# Build concept list
return True, [
'phenotype_owner',
[obj.get('entity') for obj in entities if obj.get('method') == 'create'],
- [{ 'concept_id': obj.get('historical').id, 'concept_version_id': obj.get('historical').history_id, 'attributes': obj.get('attributes')} for obj in entities]
+ [{ 'concept_id': obj.get('historical').id, 'concept_version_id': obj.get('historical').history_id } for obj in entities]
]
+ elif field_type == 'int_array':
+ behaviour = template_utils.try_get_content(field_data, 'behaviour')
+ defaults = behaviour.get('defaults', None) if isinstance(behaviour, dict) and isinstance(behaviour.get('defaults'), dict) else {}
+ branding = behaviour.get('branding', None) if isinstance(behaviour, dict) and isinstance(behaviour.get('branding'), str) else None
+
+ if branding:
+ brand = model_utils.try_get_brand(request)
+ defaults[branding] = brand if isinstance(brand, Brand) else None
+
+ values = packet.get('value') if isinstance(packet.get('value'), list) else None
+ creatable = packet.get('creatable') if isinstance(packet.get('creatable'), list) else []
+
+ source_info = validation.get('source')
+ model = source_info.get('table') if isinstance(source_info, dict) else None
+ query = source_info.get('query') if isinstance(source_info, dict) else None
+ relative = source_info.get('relative') if isinstance(source_info, dict) else None
+
+ if gen_utils.is_empty_string(model) or gen_utils.is_empty_string(query) or gen_utils.is_empty_string(relative):
+ return True, [None, None, values]
+
+ try:
+ table = apps.get_model(app_label='clinicalcode', model_name=model)
+ except:
+ table = None
+
+ if table is None:
+ return True, [None, None, values]
+
+ entities = [ ]
+ for item in creatable:
+ if gen_utils.is_empty_string(item):
+ continue
+ entities.append(table(**({ relative: item } | defaults)))
+
+ entities = table.objects.bulk_create(entities) if len(entities) > 0 else []
+ entities = [getattr(x, query) for x in entities]
+ entities += (values if isinstance(values, list) else [])
+
+ if len(entities) < 1:
+ entities = None
+
+ return True, [None, None, entities]
return False, None
-def compute_brand_context(request, form_data):
- """
- Computes the brand context given the metadata of an entity,
- where brand is computed by the RequestContext's brand and its
- given collections
+def compute_brand_context(request, form_data, form_entity=None):
"""
- related_brands = set([])
+ Computes the brand context given the metadata of an entity, where brand is computed by the RequestContext's brand and its given collections
+
+ Args:
+ request (RequestContext): the HTTP request ctx
+ form_data (dict): the form data assoc. with this request
+ form_entity (GenericEntity|None): the entity assoc. with this request, if applicable
+ Returns:
+ A (list) specifying a unique set of brands assoc. with this entity
+ """
brand = model_utils.try_get_brand(request)
- if brand:
+ metadata = form_data.get('metadata') if isinstance(form_data.get('metadata'), dict) else {}
+ compute_collections = True
+
+ # Derive current brand ctx from the entity
+ if isinstance(form_entity, GenericEntity) or (inspect.isclass(form_entity) and issubclass(form_entity, GenericEntity)):
+ related_brands = getattr(form_entity, 'brands') if isinstance(getattr(form_entity, 'brands'), list) else []
+ related_brands = set(related_brands)
+ else:
+ related_brands = set([])
+
+ # Compute request brand context
+ if brand is not None:
+ # Add current brand ctx
related_brands.add(brand.id)
-
- metadata = form_data.get('metadata')
- if not metadata:
- return list(related_brands)
-
- collections = metadata.get('collections')
- if isinstance(collections, list):
- for collection_id in collections:
- collection = Tag.objects.filter(id=collection_id)
- if not collection.exists():
- continue
-
- brand = collection.first().collection_brand
- if brand is None:
- continue
- related_brands.add(brand.id)
+
+ # Det. whether to compute brands from collection(s)
+ ctx = getattr(brand, 'overrides').get('ignore_collection_ctx') if isinstance(getattr(brand, 'overrides'), dict) else None
+ if isinstance(ctx, bool) and ctx:
+ compute_collections = False
+
+ # Compute brand collection ctx if applicable for this request's Brand context
+ if compute_collections:
+ collections = metadata.get('collections')
+ if isinstance(collections, list):
+ collections = gen_utils.parse_as_int_list(collections, default=[])
+ collections = Tag.objects.filter(Q(id__in=collections) & Q(collection_brand__isnull=False))
+ collections = list(collections.values_list('collection_brand_id', flat=True))
+ if len(collections) > 0:
+ related_brands.update(collections)
+
return list(related_brands)
@transaction.atomic
@@ -1302,9 +1481,11 @@ def create_or_update_entity_from_form(request, form, errors=[], override_dirty=F
errors.append('You do not have permissions to modify this entity')
return
form_entity = entity
-
+ else:
+ form_entity = None
+
# Build related brand instances
- related_brands = compute_brand_context(request, form_data)
+ related_brands = compute_brand_context(request, form_data, form_entity)
# Atomically create the instance and its children
entity = None
@@ -1313,23 +1494,33 @@ def create_or_update_entity_from_form(request, form, errors=[], override_dirty=F
# Build any validated children
template_data = { }
new_entities = [ ]
- for field, packet in template.items():
- field_data = template_utils.get_layout_field(form_template, field)
- if field_data is None:
- continue
+ for group in [metadata, template]:
+ for field, packet in group.items():
+ info = template_utils.get_template_field_info(form_template, field)
+ field_data = info.get('field')
+ if field_data is None:
+ continue
- validation = template_utils.try_get_content(field_data, 'validation')
- if validation is None or not validation.get('has_children'):
- template_data[field] = packet
- continue
-
- success, res = build_related_entities(request, field_data, packet, override_dirty, entity=form_entity)
- if not success or not res:
- continue
+ validation = template_utils.try_get_content(field_data, 'validation')
+ freeform = template_utils.try_get_content(field_data, 'behaviour')
+ freeform = freeform.get('freeform', False) if isinstance(freeform, dict) else None
- ownership_key, created_entities, field_value = res
- template_data[field] = field_value
- new_entities.append({'field': ownership_key, 'entities': created_entities})
+ data_value = packet
+ if validation is not None and (validation.get('has_children') or freeform):
+ success, res = build_related_entities(request, field_data, packet, override_dirty, entity=form_entity)
+ if not success or not res:
+ continue
+
+ ownership_key, created_entities, field_value = res
+ data_value = field_value
+
+ if ownership_key is not None:
+ new_entities.append({'field': ownership_key, 'entities': created_entities})
+
+ if not info.get('is_metadata'):
+ template_data[field] = data_value
+ else:
+ metadata[field] = data_value
# Create or update the entity
template_data['version'] = form_template.template_version
@@ -1347,9 +1538,12 @@ def create_or_update_entity_from_form(request, form, errors=[], override_dirty=F
elif form_method == constants.FORM_METHODS.UPDATE:
entity = form_entity
- group = metadata.get('group')
- if not group and permission_utils.has_derived_edit_access(request, entity.id):
- group = entity.group
+ org = metadata.get('organisation')
+ if not org and permission_utils.has_derived_edit_access(request, entity.id):
+ try:
+ org = entity.organisation
+ except Organisation.DoesNotExist:
+ org = None
entity.name = metadata.get('name')
entity.status = constants.ENTITY_STATUS.DRAFT
@@ -1361,9 +1555,10 @@ def create_or_update_entity_from_form(request, form, errors=[], override_dirty=F
entity.tags = metadata.get('tags')
entity.collections = metadata.get('collections')
entity.publications = metadata.get('publications')
- entity.group = group
- entity.group_access = metadata.get('group_access')
- entity.world_access = metadata.get('world_access')
+ entity.organisation = org
+ entity.owner_access = metadata.get('owner_access', entity.owner_access)
+ entity.world_access = metadata.get('world_access', entity.world_access)
+ entity.group_access = metadata.get('group_access', entity.group_access)
entity.template = template_instance
entity.template_version = form_template.template_version
entity.template_data = template_data
@@ -1373,7 +1568,7 @@ def create_or_update_entity_from_form(request, form, errors=[], override_dirty=F
entity.brands = related_brands
entity.save()
- # Update child related entities with entity object
+ # Update child related entities with entity object
for group in new_entities:
field = group.get('field')
instances = group.get('entities')
diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/email_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/email_utils.py
index 50162efa5..368e49a1d 100644
--- a/CodeListLibrary_project/clinicalcode/entity_utils/email_utils.py
+++ b/CodeListLibrary_project/clinicalcode/entity_utils/email_utils.py
@@ -1,19 +1,73 @@
from django.conf import settings
-from django.contrib.auth.models import User
+from django.db.models import Model
+from email.mime.image import MIMEImage
from django.core.mail import BadHeaderError, EmailMultiAlternatives
+from django.contrib.auth import get_user_model
from django.template.loader import render_to_string
from django.contrib.staticfiles import finders
-from email.mime.image import MIMEImage
-import datetime
+import os
import logging
+import datetime
+from clinicalcode.entity_utils import model_utils, gen_utils
from clinicalcode.models.Phenotype import Phenotype
from clinicalcode.models.PublishedPhenotype import PublishedPhenotype
+
+User = get_user_model()
logger = logging.getLogger(__name__)
+
+def send_invite_email(request, invite):
+ brand_title = model_utils.try_get_brand_string(request.BRAND_OBJECT, 'site_title', default='Concept Library')
+
+ owner_email = User.objects.filter(id=invite.user_id)
+ if not owner_email.exists():
+ return
+ owner_email = owner_email.first().email
+
+ if not owner_email or len(owner_email.strip()) < 1:
+ return
+
+ email_subject = f'{brand_title} - Organisation Invite'
+ email_content = render_to_string(
+ 'clinicalcode/email/invite_email.html',
+ {
+ 'invite': {
+ 'uuid': invite.id
+ }
+ },
+ request=request
+ )
+
+ if not settings.IS_DEVELOPMENT_PC or settings.HAS_MAILHOG_SERVICE:
+ try:
+ branded_imgs = get_branded_email_images(request.BRAND_OBJECT)
+
+ msg = EmailMultiAlternatives(
+ email_subject,
+ email_content,
+ settings.DEFAULT_FROM_EMAIL,
+ to=[owner_email]
+ )
+ msg.content_subtype = 'related'
+ msg.attach_alternative(email_content, "text/html")
+
+ msg.attach(attach_image_to_email(branded_imgs.get('apple', 'img/email_images/apple-touch-icon.jpg'), 'mainlogo'))
+ msg.attach(attach_image_to_email(branded_imgs.get('logo', 'img/email_images/combine.jpg'), 'sponsors'))
+ msg.send()
+ return True
+ except BadHeaderError as error:
+ logging.error(f'Failed to send emails to:\n- Targets: {owner_email}\n-Error: {str(error)}')
+ return False
+ else:
+ logging.info(f'Scheduled emails sent:\n- Targets: {owner_email}')
+ return True
+
+
def send_review_email_generic(request,data,message_from_reviewer=None):
+ brand_title = model_utils.try_get_brand_string(request.BRAND_OBJECT, 'site_title', default='Concept Library')
owner_email = User.objects.filter(id=data.get('entity_user_id',''))
owner_email = owner_email.first().email if owner_email and owner_email.exists() else ''
staff_emails = data.get('staff_emails', [])
@@ -22,7 +76,7 @@ def send_review_email_generic(request,data,message_from_reviewer=None):
if len(owner_email.strip()) > 1:
all_emails.append(owner_email)
- email_subject = 'Concept Library - Phenotype %s: %s' % (data['id'], data['message'])
+ email_subject = '%s - %s: %s' % (brand_title, data['id'], data['message'])
email_content = render_to_string(
'clinicalcode/email/email_content.html',
data,
@@ -30,6 +84,8 @@ def send_review_email_generic(request,data,message_from_reviewer=None):
)
if not settings.IS_DEVELOPMENT_PC or settings.HAS_MAILHOG_SERVICE:
try:
+ branded_imgs = get_branded_email_images(request.BRAND_OBJECT)
+
msg = EmailMultiAlternatives(email_subject,
email_content,
settings.DEFAULT_FROM_EMAIL,
@@ -37,10 +93,9 @@ def send_review_email_generic(request,data,message_from_reviewer=None):
)
msg.content_subtype = 'related'
msg.attach_alternative(email_content, "text/html")
-
- msg.attach(attach_image_to_email('img/email_images/apple-touch-icon.jpg','mainlogo'))
- msg.attach(attach_image_to_email('img/email_images/combine.jpg','sponsors'))
+ msg.attach(attach_image_to_email(branded_imgs.get('apple', 'img/email_images/apple-touch-icon.jpg'), 'mainlogo'))
+ msg.attach(attach_image_to_email(branded_imgs.get('logo', 'img/email_images/combine.jpg'), 'sponsors'))
msg.send()
return True
except BadHeaderError as error:
@@ -49,7 +104,8 @@ def send_review_email_generic(request,data,message_from_reviewer=None):
else:
logging.info(f'Scheduled emails sent:\n- Targets: {all_emails}')
return True
-
+
+
def attach_image_to_email(image,cid):
with open(finders.find(image), 'rb') as f:
img = MIMEImage(f.read())
@@ -59,6 +115,33 @@ def attach_image_to_email(image,cid):
return img
+def get_branded_email_images(brand=None):
+ """
+ Gets the brand-related e-mail image path(s)
+
+ Args:
+ brand (Brand|dict|None): the brand from which to resolve the info
+
+ Returns:
+ A (dict) with key-value pairs specifying the `logo`, `apple`, and `favicon` path target(s)
+ """
+ if isinstance(brand, dict):
+ path = brand.get('logo_path', None)
+ elif isinstance(brand, Model):
+ path = getattr(brand, 'logo_path', None) if hasattr(brand, 'logo_path') else None
+ else:
+ path = None
+
+ if path is None or gen_utils.is_empty_string(path):
+ path = settings.APP_LOGO_PATH
+
+ return {
+ 'logo': os.path.join(path, 'header_logo.png'),
+ 'apple': os.path.join(path, 'apple-touch-icon.png'),
+ 'favicon': os.path.join(path, 'favicon-32x32.png'),
+ }
+
+
def get_scheduled_email_to_send():
HDRUK_pending_phenotypes = PublishedPhenotype.objects.filter(approval_status=1)
HDRUK_declined_phenotypes = PublishedPhenotype.objects.filter(approval_status=3)
@@ -91,17 +174,17 @@ def get_scheduled_email_to_send():
review_message = ''
if result['data'][i]['approval_status'] == 1:
review_decision = 'Pending'
- review_message = "Phenotype is waiting to be approved"
+ review_message = "Your work is waiting to be approved"
elif result['data'][i]['approval_status'] == 3:
review_decision = 'Declined'
- review_message = 'Phenotype has been declined'
+ review_message = 'Your work has been declined'
owner_email = User.objects.get(id=phenotype_owner_id).email
if owner_email == '':
return False
email_message = '''
- Phenotype: {id} - {name}
+ Entity: {id} - {name}
Decision: {decision}
Reviewer message: {message}
'''.format(id=phenotype_id, name=phenotype_name, decision=review_decision, message=review_message)
diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/entity_db_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/entity_db_utils.py
index feddbc9a1..20c14c05a 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(' ')
- 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
@@ -750,10 +730,16 @@ def get_concept_ids_versions_of_historical_phenotype(phenotype_id, phenotype_his
'''
concept_id_version = []
- concept_information = GenericEntity.history.get(id=phenotype_id, history_id=phenotype_history_id).template_data['concept_information']
+ concept_information = GenericEntity.history.get(id=phenotype_id, history_id=phenotype_history_id).template_data
+ concept_information = concept_information.get('concept_information') if isinstance(concept_information, dict) else None
if concept_information:
- for c in concept_information:
- concept_id_version.append((c['concept_id'], c['concept_version_id']))
+ concept_id_version = [
+ (c['concept_id'], c['concept_version_id'])
+ for c in concept_information
+ if isinstance(c.get('concept_id'), int) and isinstance(c.get('concept_version_id'), int)
+ ]
+ else:
+ concept_id_version = []
return concept_id_version
diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/filter_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/filter_utils.py
index 4038668b9..d5a3783c3 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, Model
+
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,57 @@ 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)
- if current_brand is None:
- return
-
- return {
- f'{column_name}': current_brand.id
- }
+ 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, 'BRAND_OBJECT', None)
+ if not isinstance(current_brand, Model):
+ 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 not isinstance(current_brand, Model):
+ 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/gen_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/gen_utils.py
index f21300644..601457043 100644
--- a/CodeListLibrary_project/clinicalcode/entity_utils/gen_utils.py
+++ b/CodeListLibrary_project/clinicalcode/entity_utils/gen_utils.py
@@ -1,25 +1,36 @@
-from uuid import UUID
from json import JSONEncoder
-from functools import wraps
from typing import Pattern
from dateutil import parser as dateparser
+from functools import wraps
+from django.http import HttpRequest
+from django.apps import apps
from django.conf import settings
+from django.db.models import Q, Model
+from django.core.cache import cache
from django.http.response import JsonResponse
+from rest_framework.request import Request
from django.core.exceptions import BadRequest
+from django.utils.translation import gettext_lazy as _
+from django.contrib.auth.models import Group
from django.http.multipartparser import MultiPartParser
import re
import time
import json
-import datetime
-import logging
+import uuid
import urllib
+import inspect
+import hashlib
+import logging
+import datetime
from cll.settings import Symbol
from . import constants, sanitise_utils
+
logger = logging.getLogger(__name__)
+
def is_datetime(x):
"""
Legacy method from ./utils.py
@@ -42,6 +53,9 @@ def is_float(x):
Desc:
- "Checks if a param can be parsed as a float"
"""
+ if x is None:
+ return False
+
try:
a = float(x)
except ValueError:
@@ -57,6 +71,9 @@ def is_int(x):
Desc:
- "Checks if a number is an integer"
"""
+ if x is None:
+ return False
+
try:
a = float(x)
b = int(a)
@@ -84,9 +101,7 @@ def clean_str_as_db_col_name(txt):
def try_parse_form(request):
- """
- Attempts to parse multipart/form-data from the request body
- """
+ """Attempts to parse multipart/form-data from the request body"""
try:
parser = MultiPartParser(request.META, request.body, request.upload_handlers)
post, files = parser.parse()
@@ -97,9 +112,7 @@ def try_parse_form(request):
def get_request_body(request):
- """
- Decodes the body of a request and attempts to load it as JSON
- """
+ """Decodes the body of a request and attempts to load it as JSON"""
try:
body = request.body.decode('utf-8')
body = json.loads(body)
@@ -136,10 +149,10 @@ def is_empty_string(value):
Checks whether a string is empty or contains only spaces
Args:
- value (string): the value to check
+ value (str): the value to check
Returns:
- boolean
+ A (bool) reflecting whether the value is an empty string and/or contains only spaces
"""
if value is None:
return True
@@ -163,10 +176,7 @@ def is_fetch_request(request):
def handle_fetch_request(request, obj, *args, **kwargs):
- """
- @desc Parses the X-Target header to determine which GET method
- to respond with
- """
+ """Parses the X-Target header to determine which GET method to respond with"""
target = request.headers.get('X-Target', None)
if target is None or target not in obj.fetch_methods:
raise BadRequest('No such target')
@@ -184,7 +194,7 @@ def decode_uri_parameter(value, default=None):
Decodes an ecoded URI parameter e.g. 'wildcard:C\d+' encoded as 'wildcard%3AC%5Cd%2B'
Args:
- value (string): the value to decode
+ value (str): the value to decode
default (*): the default value to return if this method fails
Returns:
@@ -205,9 +215,9 @@ def jsonify_response(**kwargs):
Creates a JSON response with the given status
Args:
- code (integer): the status code
- status (string): the status response
- message (string): the message response
+ code (int): the status code
+ status (str): the status response
+ message (str): the message response
Returns:
A JSONResponse that matches the kwargs
@@ -252,19 +262,63 @@ def try_match_pattern(value, pattern, flags=re.IGNORECASE):
def is_valid_uuid(value):
"""
- Validates value as a UUID
+ Validates value as a `UUID`
+
+ Args:
+ value (Any): some value to evaluate
+
+ Returns:
+ A (bool) value specifying whether this value is a valid `UUID`
"""
+ if isinstance(value, uuid.UUID):
+ return True
+ elif value is None or not isinstance(value, (str, int)):
+ return False
+
+ typed = 'int' if isinstance(value, int) else 'hex'
try:
- uuid = UUID(value)
+ uid = uuid.UUID(**{typed: value})
except ValueError:
return False
- return str(uuid) == value
+ return getattr(uid, typed, None) == value
+
+
+def parse_uuid(value, default=None):
+ """
+ Attempts to parse a `UUID` from a value, if it fails to do so, returns the default value
+
+ Args:
+ value (Any): some value to parse
+ default (Any): optionally specify the default return value if the given value is both (1) not an `UUID` and (2) cannot be cast (or coerced) to a `UUID`; defaults to `None`
+
+ Returns:
+ A (uuid.UUID) value, if applicable; otherwise returns the specified default value
+ """
+ if isinstance(value, uuid.UUID):
+ return value
+ elif value is None or not isinstance(value, (str, int)):
+ return default
+
+ typed = 'int' if isinstance(value, int) else 'hex'
+ try:
+ uid = uuid.UUID(**{typed: value})
+ except ValueError:
+ return default
+
+ return value if getattr(uid, typed, None) == value else default
def parse_int(value, default=None):
"""
- Attempts to parse an int from a value, if it fails to do so, returns the default value
+ Attempts to parse an `int` from a value, if it fails to do so, returns the default value
+
+ Args:
+ value (Any): some value to parse
+ default (Any): optionally specify the default return value if the given value is both (1) not an `int` and (2) cannot be cast to a `int`; defaults to `None`
+
+ Returns:
+ A (int) value, if applicable; otherwise returns the specified default value
"""
if isinstance(value, int):
return value
@@ -280,6 +334,31 @@ def parse_int(value, default=None):
return value
+def parse_float(value, default=None):
+ """
+ Attempts to parse a `float` from a value, if it fails to do so, returns the default value
+
+ Args:
+ value (Any): some value to parse
+ default (Any): optionally specify the default return value if the given value is both (1) not a `float` and (2) cannot be cast to a `float`; defaults to `None`
+
+ Returns:
+ A (float) value, if applicable; otherwise returns the specified default value
+ """
+ if isinstance(value, float):
+ return value
+
+ if value is None:
+ return default
+
+ try:
+ value = float(value)
+ except:
+ return default
+ else:
+ return value
+
+
def get_start_and_end_dates(daterange):
"""
Sorts a date range to [min, max] and sets their timepoints to the [start] and [end] of the day respectively
@@ -341,6 +420,237 @@ def parse_as_int_list(value, default=Symbol('EmptyList')):
return default
+
+def parse_model_field_query(model, params, ignored_fields=None, default=None):
+ """
+ Attempts to parse ORM query fields & desired values for the specified model given the request parameters
+
+ Args:
+ model (Model): the model from which to build the ORM query
+ params (dict[str, str]|Request|HttpRequest): either the query parameter dict or the request assoc. with this query
+ ignored_fields (list[str]): optionally specify a list of fields on the given model to ignore when building the query; defaults to `None`
+ default (Any): optionally specify the default value to return on failure; defaults to `None`
+
+ Returns:
+ Either (a) a `dict[str, Any]` containing the ORM query, or (b) the specified `default` param if no query could be resolved
+ """
+ ignored_fields = ignored_fields if isinstance(ignored_fields, list) else None
+
+ if (not inspect.isclass(model) or not issubclass(model, Model)) and not isinstance(model, Model):
+ return default
+
+ if isinstance(params, Request):
+ params = params.query_params
+ elif isinstance(params, HttpRequest):
+ params = params.GET.dict()
+
+ if not isinstance(params, dict):
+ return default
+
+ result = None
+ for field in model._meta.get_fields():
+ field_name = field.name
+ if ignored_fields is not None and field_name in ignored_fields:
+ continue
+
+ value = params.get(field_name)
+ if value is None:
+ continue
+
+ typed = field.get_internal_type()
+ is_fk = typed == 'ForeignKey' and (field.many_to_one or field.one_to_one)
+ if is_fk:
+ typed = field.target_field.get_internal_type()
+
+ query = None
+ match typed:
+ case 'AutoField' | 'SmallAutoField' | 'BigAutoField':
+ if isinstance(value, str):
+ if is_empty_string(value):
+ continue
+
+ value = parse_as_int_list(value, default=None)
+ length = len(value) if isinstance(value, list) else 0
+ if length == 1:
+ value = value[0]
+ query = f'{field_name}'
+ elif length > 1:
+ value = value
+ query = f'{field_name}__in'
+ else:
+ value = None
+ elif isinstance(value, (int, float, complex)) and not isinstance(value, bool):
+ try:
+ value = int(value)
+ query = f'{field_name}'
+ except:
+ pass
+ elif isinstance(value, list):
+ arr = [int(x) for x in value if is_int(x, default=None)]
+ if isinstance(arr, list) and len(arr) > 1:
+ value = arr
+ query = f'{field_name}__in'
+
+ case 'BooleanField':
+ if isinstance(value, str):
+ if is_empty_string(value):
+ continue
+ value = value.lower() in ('y', 'yes', 't', 'true', 'on', '1')
+
+ if isinstance(value, bool):
+ value = value
+ query = f'{field_name}'
+
+ case 'SmallIntegerField' | 'PositiveSmallIntegerField' | \
+ 'IntegerField' | 'PositiveIntegerField' | \
+ 'BigIntegerField' | 'PositiveBigIntegerField':
+ if isinstance(value, str):
+ if is_empty_string(value):
+ continue
+
+ if value.find(','):
+ arr = [int(x) for x in value.split(',') if parse_int(x, default=None) is not None]
+ if isinstance(arr, list) and len(arr) > 1:
+ value = arr
+ query = f'{field_name}__in'
+ elif value.find(':'):
+ bounds = [int(x) for x in value.split(':') if is_int(x)]
+ if isinstance(bounds, list) and len(bounds) >= 2:
+ value = [min(bounds), max(bounds)]
+ query = f'{field_name}__range'
+
+ if query is None:
+ value = try_value_as_type(value, 'int')
+ query = f'{field_name}'
+ elif isinstance(value, (int, float, complex)) and not isinstance(value, bool):
+ try:
+ value = int(value)
+ query = f'{field_name}'
+ except:
+ pass
+ elif isinstance(value, list):
+ arr = [int(x) for x in value if is_int(x, default=None)]
+ if isinstance(arr, list) and len(arr) > 1:
+ value = arr
+ query = f'{field_name}__in'
+
+ case 'FloatField' | 'DecimalField':
+ if isinstance(value, str):
+ if is_empty_string(value):
+ continue
+
+ if value.find(','):
+ arr = [float(x) for x in value.split(',') if is_float(x)]
+ if isinstance(arr, list) and len(arr) > 1:
+ value = arr
+ query = f'{field_name}__in'
+ elif value.find(':'):
+ bounds = [float(x) for x in value.split(':') if is_float(x)]
+ if isinstance(bounds, list) and len(bounds) >= 2:
+ value = [min(bounds), max(bounds)]
+ query = f'{field_name}__range'
+
+ if query is None:
+ value = float(value) if is_float(value) else None
+ query = f'{field_name}'
+ elif isinstance(value, (int, float, complex)) and not isinstance(value, bool):
+ try:
+ value = float(value)
+ query = f'{field_name}'
+ except:
+ pass
+ elif isinstance(value, list):
+ arr = [float(x) for x in value if is_float(x, default=None)]
+ if isinstance(arr, list) and len(arr) > 1:
+ value = arr
+ query = f'{field_name}__in'
+
+ case 'SlugField' | 'CharField' | 'TextField':
+ value = str(value)
+ if is_fk:
+ values = value.split(',')
+ if len(values) > 1:
+ value = values
+ query = f'{field_name}__contained_by'
+
+ if query is None:
+ value = str(value)
+ query = f'{field_name}__icontains'
+
+ case 'UUIDField' | 'EmailField' | \
+ 'URLField' | 'FilePathField':
+ value = str(value)
+ if value.find(','):
+ values = value.split(',')
+ if len(values) > 1:
+ value = values
+ query = f'{field_name}__contained_by'
+
+ if query is None:
+ value = value
+ query = f'{field_name}__exact'
+
+ case 'DateField':
+ if is_empty_string(value):
+ continue
+
+ try:
+ bounds = [dateparser.parse(x).date() for x in value.split(':')] if value.find(':') else None
+ if bounds and len(bounds) >= 2:
+ value = [min(value), max(value)]
+ query = f'{field_name}__range'
+
+ if query is None:
+ value = dateparser.parse(value).date()
+ query = f'{field_name}'
+ except:
+ value = None
+
+ case 'TimeField':
+ if is_empty_string(value):
+ continue
+
+ try:
+ bounds = [dateparser.parse(x).time() for x in value.split(':') if dateparser.parse(x).time()] if value.find(':') else None
+ if bounds and len(bounds) >= 2:
+ value = [min(bounds), max(bounds)]
+ query = f'{field_name}__range'
+
+ if query is None:
+ value = dateparser.parse(value).time()
+ query = f'{field_name}'
+ except:
+ value = None
+
+ case 'DateTimeField':
+ if is_empty_string(value):
+ continue
+
+ try:
+ bounds = [dateparser.parse(x) for x in value.split(':')] if value.find(':') else None
+ if bounds and len(bounds) >= 2:
+ value = [datetime.datetime.combine(x, datetime.time(23, 59, 59, 999)) if not x.time() else x for x in bounds]
+ value = [min(value), max(value)]
+ query = f'{field_name}__range'
+
+ if query is None:
+ value = dateparser.parse(value)
+ value = datetime.datetime.combine(value, datetime.time(23, 59, 59, 999)) if not value.time() else value
+ query = f'{field_name}'
+ except:
+ value = None
+
+ case _:
+ pass
+
+ if query is not None and value is not None:
+ if not isinstance(result, dict):
+ result = { }
+ result[query] = value
+
+ return result if isinstance(result, dict) else default
+
+
def parse_prefixed_references(values, acceptable=None, pattern=None, transform=None, should_trim=False, default=None):
"""
Attempts to parse prefixed references from a list object. Note that this
@@ -463,6 +773,25 @@ def try_value_as_type(
"""
if field_type == 'enum' or field_type == 'int':
field_value = parse_int(field_value, default)
+ if field_value is not None and validation is not None:
+ limits = validation.get('range')
+ if isinstance(limits, list) and isinstance(field_type, int) and (field_value < limits[0] or field_value > limits[1]):
+ return default
+
+ properties = validation.get('properties')
+ if isinstance(properties, dict):
+ fmin = parse_int(properties.get('min'), None)
+ fmax = parse_int(properties.get('max'), None)
+
+ if fmin is None or fmax is None:
+ return field_value
+
+ if field_value < fmin or field_value > fmax:
+ return default
+
+ return field_value
+ elif field_type in constants.NUMERIC_NAMES:
+ field_value = parse_float(field_value, default)
if field_value is not None and validation is not None:
limits = validation.get('range')
if isinstance(limits, list) and isinstance(field_type, int) and (field_value < limits[0] or field_value > limits[1]):
@@ -517,6 +846,83 @@ def try_value_as_type(
if not strict_elements:
return cleaned
return cleaned if valid else default
+ elif field_type == 'int_range':
+ properties = validation.get('properties') if validation is not None else None
+
+ fmin = None
+ fmax = None
+ if isinstance(field_value, dict):
+ fmin = parse_int(field_value.get('min'), default)
+ fmax = parse_int(field_value.get('max'), default)
+ elif isinstance(field_value, list) and len(field_value) >= 2 and all(is_int(x) for x in field_value):
+ fmin = parse_int(field_value[0], default)
+ fmax = parse_int(field_value[1], default)
+
+ if fmin is None or fmax is None:
+ return default
+
+ if properties is not None:
+ vrange = properties.get('range')
+ if isinstance(vrange, list) and len(vrange) >= 2 and all(is_int(x) for x in vrange):
+ vmin = min(vrange[0], vrange[1])
+ vmax = max(vrange[0], vrange[1])
+ else:
+ vmin = parse_int(properties.get('min'), default)
+ vmax = parse_int(properties.get('max'), default)
+
+ if vmin is not None and vmax is not None:
+ min_valid = fmin >= vmin
+ max_valid = fmax <= vmax
+ if not min_valid or not max_valid:
+ return default
+ return field_value
+ elif field_type.endswith('_range') and field_type.split('_')[0] in constants.NUMERIC_NAMES:
+ properties = validation.get('properties') if validation is not None else None
+
+ fmin = None
+ fmax = None
+ if isinstance(field_value, dict):
+ fmin = parse_float(field_value.get('min'), default)
+ fmax = parse_float(field_value.get('max'), default)
+ elif isinstance(field_value, list) and len(field_value) >= 2 and all(is_float(x) for x in field_value):
+ fmin = parse_float(field_value[0], default)
+ fmax = parse_float(field_value[1], default)
+
+ if fmin is None or fmax is None:
+ return default
+
+ if properties is not None:
+ vrange = properties.get('range')
+ if isinstance(vrange, list) and len(vrange) >= 2 and all(is_float(x) for x in vrange):
+ vmin = min(vrange[0], vrange[1])
+ vmax = max(vrange[0], vrange[1])
+ else:
+ vmin = parse_float(properties.get('min'), default)
+ vmax = parse_float(properties.get('max'), default)
+
+ if vmin is not None and vmax is not None:
+ min_valid = fmin >= vmin
+ max_valid = fmax <= vmax
+ if not min_valid or not max_valid:
+ return default
+ return field_value
+ elif field_type == 'ci_interval':
+ if not isinstance(field_value, dict):
+ return default
+
+ lower = parse_float(field_value.get('lower'), None)
+ upper = parse_float(field_value.get('upper'), None)
+ probability = parse_float(field_value.get('probability'), None)
+
+ if lower is None or upper is None or probability is None:
+ return default
+
+ probability = min(max(probability, 0), 100)
+ return {
+ 'lower': lower,
+ 'upper': upper,
+ 'probability': probability,
+ }
elif field_type == 'code':
try:
value = str(field_value) if field_value is not None else ''
@@ -602,7 +1008,6 @@ def try_value_as_type(
else None
pattern = validation.get('regex') if isinstance(pattern, (list, str, Pattern)) else None
-
sanitiser = validation.get('sanitise') if isinstance(validation.get('sanitise'), str) else None
for val in field_value:
@@ -641,6 +1046,26 @@ def try_value_as_type(
if not isinstance(field_value, list):
return default
return field_value
+ elif field_type == 'organisation' or field_type == 'group':
+ if is_int(field_value) or (isinstance(field_value, str) and not is_empty_string(field_value)):
+ org = apps.get_model(app_label='clinicalcode', model_name='Organisation')
+ if is_int(field_value):
+ org = org.objects.filter(Q(pk=int(field_value)))
+
+ if org.exists():
+ return org.first().pk
+
+ org = org.objects.filter(Q(name__iexact=field_value) | Q(slug__iexact=field_value))
+ if org.exists():
+ return org.first().pk if org.exists() else default
+
+ if field_value is not None and is_int(field_value):
+ group = Group.objects.filter(id=int(field_value))
+ field_value = group.first().name if group.exists() else str(field_value)
+ org = org.objects.filter(Q(name__iexact=field_value) | Q(slug__iexact=field_value))
+ return org.first().pk if org.exists() else default
+
+ return default
elif field_type == 'url_list':
if not isinstance(field_value, list):
return default
@@ -687,6 +1112,136 @@ def try_value_as_type(
break
return field_value if valid else default
+ elif field_type == 'indicator_calculation':
+ if not isinstance(field_value, dict):
+ return default
+
+ keys = set(field_value.keys())
+ expected_keys = set(['description', 'numerator', 'denominator'])
+ if not keys.issubset(expected_keys):
+ return default
+
+ valid = False
+ output = { }
+ for key, val in field_value.items():
+ if not isinstance(val, str) or is_empty_string(val):
+ continue
+
+ value = sanitise_utils.sanitise_value(val, method='markdown', default=None)
+ if value is None or is_empty_string(value):
+ continue
+ else:
+ output[key] = value
+ valid = True
+
+ return output if valid else default
+ elif field_type == 'contact':
+ if not isinstance(field_value, list):
+ default
+
+ if len(field_value) < 1:
+ return field_value
+
+ valid = True
+ for val in field_value:
+ if not isinstance(val, dict):
+ valid = False
+ break
+
+ name = sanitise_utils.sanitise_value(val.get('name'), method='strict', default=None)
+ if not name or not isinstance(name, str) or is_empty_string(name):
+ valid = False
+ break
+
+ email = sanitise_utils.sanitise_value(val.get('email'), method='strict', default=None)
+ if not email or not isinstance(email, str) or is_empty_string(email):
+ valid = False
+ break
+ return field_value if valid else default
+ elif field_type == 'var_data':
+ if not isinstance(field_value, dict):
+ return default
+
+ options = validation.get('options') if validation is not None and isinstance(validation.get('options'), dict) else None
+ properties = validation.get('properties') if validation is not None else None
+ if isinstance(properties, dict):
+ allow_types = properties.get('allow_types', [])
+ allow_unknown = properties.get('allow_unknown', False)
+ allow_description = properties.get('allow_description', False)
+ else:
+ allow_types = []
+ allow_unknown = False
+ allow_description = False
+
+ result = { }
+ for key, item in field_value.items():
+ if not isinstance(item, dict):
+ continue
+
+ out = { 'name': key }
+ name = try_value_as_type(item.get('name'), 'string', { 'properties': { 'sanitise': 'strict', 'length': [2, 500] } })
+ typed = item.get('type')
+ value = None
+
+ props = options.get(key) if options is not None else None
+ if isinstance(props, dict) and isinstance(props.get('type'), str):
+ name = props.get('name')
+ typed = props.get('type')
+ value = try_value_as_type(item.get('value'), typed, { 'properties': props })
+
+ if value is None and allow_unknown and isinstance(allow_types, list):
+ if not name:
+ continue
+
+ typed = item.get('type')
+ if isinstance(typed, str) and typed in allow_types:
+ value = try_value_as_type(item.get('value'), typed, { 'properties': { 'sanitise': 'strict', 'length': 1024 } })
+
+ if value is None or typed is None:
+ continue
+
+ if allow_description:
+ desc = try_value_as_type(item.get('description'), 'string', { 'properties': { 'sanitise': 'strict', 'length': [2, 500] } })
+ if desc is not None:
+ out.update(description=desc)
+
+ out.update(name=name, type=typed, value=value)
+ result.update({ key: out })
+
+ return result
+ elif field_type == 'related_entities':
+ if not isinstance(field_value, list):
+ return default
+
+ if len(field_value) < 1:
+ return default
+
+ target = validation.get('target') if validation is not None else None
+ tbl_name = target.get('table') if target is not None else None
+ selector = target.get('select') if target is not None else None
+
+ properties = validation.get('properties') if validation is not None else None
+ storage = properties.get('storage') if properties is not None else None
+
+ if not isinstance(tbl_name, str) or not isinstance(storage, list) or not isinstance(selector, list):
+ return default
+
+ valid = True
+ result = []
+ try:
+ model = apps.get_model(app_label='clinicalcode', model_name=tbl_name)
+ for item in field_value:
+ selection = { k: item.get(k) for k in selector }
+ entity = model.objects.filter(**selection)
+ if entity is None or not entity.exists():
+ continue
+
+ entity = entity.first()
+ result.append({ k: getattr(entity, k) for k in storage })
+ except:
+ valid = False
+
+ return result if valid and len(result) > 0 else default
elif field_type == 'publication':
if not isinstance(field_value, list):
return default
@@ -720,15 +1275,139 @@ def try_value_as_type(
break
return field_value if valid else default
+ elif field_type == 'age_group':
+ if not isinstance(field_value, dict):
+ return default
+
+ comparator = field_value.get('comparator')
+ data_value = field_value.get('value')
+
+ if comparator == 'between':
+ if not isinstance(data_value, list):
+ return default
+
+ data_value = try_value_as_type(data_value, 'int_range', validation)
+ if data_value is None:
+ return default
+ else:
+ if not is_int(data_value):
+ return default
+
+ data_value = try_value_as_type(data_value, 'int', validation)
+ if data_value is None:
+ return default
+ return field_value
return field_value
-def measure_perf(func, show_args=False):
+def cache_resultset(
+ cache_age=3600,
+ cache_params=False,
+ cache_key=None,
+ cache_prefix='rs__',
+ cache_suffix='__ctrg',
+ debug_metrics=False
+):
"""
- Helper function to estimate view execution time
+ Tries to parse a value as a given type, otherwise returns default
+
+ .. Note::
+ - The `debug_metrics` parameter will only log _perf._ metrics if global `DEBUG` setting is active;
+ - Cache prefixes & suffixes are ignored if the `cache_key` param is specified;
+ - Returned values will not be cached if the optionally specified `cache_age` param is less than `1s`;
+ - Cache results are _NOT_ varied by arguments unless the `cache_params` param is flagged `True`.
+
+ Args:
+ cache_age (int): optionally specify the max age, in seconds, of the callable's cached result; defaults to `3600`
+ cache_params (bool): optionally specify whether to vary the cache key by the given parameters; defaults to `False`
+ cache_key (str): optionally specify the key pair name; defaults to `None` - non-string type cache keys are built using the `cache_prefix` and `cache_suffix` args unless specified
+ cache_prefix (str): optionally specify the cache key prefix; defaults to `rs__`
+ cache_suffix (str): optionally specify the cache key suffix; defautls to `__ctrg`
+ debug_metrics (bool): optionally specify whether to log performance metrics; defaults to `False`
- Ref @ https://stackoverflow.com/posts/62522469/revisions
+ Returns:
+ The cached value, if applicable
+ """
+ cache_age = max(cache_age if isinstance(cache_age, int) else 0, 0)
+ has_cache_key = isinstance(cache_key, str) and not is_empty_string(cache_key)
+ debug_metrics = debug_metrics and settings.DEBUG
+
+ def _cache_resultset(func):
+ """
+ Cache resultset decorator
+
+ Args:
+ func (Callable): some callable function to decorate
+
+ Returns:
+ A (Callable) decorator
+ """
+ if not has_cache_key:
+ cache_key = f'{cache_prefix}{func.__name__}'
+
+ @wraps(func)
+ def wrapper(*args, **kwargs):
+ if cache_age < 1:
+ return func(*args, **kwargs)
+
+ perf_start = None
+ perf_hashed = None
+ perf_duration = None
+ if debug_metrics:
+ perf_start = time.time()
+
+ if cache_params:
+ key = hashlib.md5(repr([args, kwargs]).encode('utf-8'), usedforsecurity=False).hexdigest()
+ key = '%s__%s%s' % (cache_key, key, '' if has_cache_key else cache_suffix)
+
+ perf_hashed = time.time() if debug_metrics else None
+ else:
+ key = cache_key
+
+ resultset = cache.get(key)
+ if not isinstance(resultset, dict):
+ resultset = func(*args, **kwargs)
+ resultset = { 'value': resultset, 'timepoint': time.time() }
+ cache.set(key, resultset, cache_age)
+
+ resultset = resultset.get('value')
+
+ if debug_metrics:
+ perf_duration = (time.time() - perf_start)*1000
+ if perf_hashed:
+ perf_hashed = (perf_hashed - perf_start)*1000
+ logger.info(
+ ('CachedCallable {\n' + \
+ ' - Key: \'%s\'\n' + \
+ ' - Perf:\n' + \
+ ' - Hashed: %.2f ms\n' + \
+ ' - Duration: %.2f ms\n' + \
+ '}') % (func.__name__, cache_age, key, perf_hashed, perf_duration)
+ )
+ else:
+ logger.info(
+ ('CachedCallable {\n' + \
+ ' - Key: \'%s\'\n' + \
+ ' - Duration: %.2f ms\n' + \
+ '}') % (func.__name__, cache_age, key, perf_duration)
+ )
+
+ return resultset
+
+ return wrapper
+ return _cache_resultset
+
+
+def measure_perf(func):
+ """
+ Helper decorator to estimate view execution time
+
+ Args:
+ func (Callable): some callable function to decorate
+
+ Returns:
+ A (Callable) decorator
"""
@wraps(func)
@@ -737,10 +1416,7 @@ def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
duration = (time.time() - start) * 1000
- if show_args:
- print('view {} takes {:.2f} ms... \n 1. args: {}\n 2.kwargs:{}'.format(func.__name__, duration, args, kwargs))
- else:
- print('view {} takes {:.2f} ms'.format(func.__name__, duration))
+ logger.info('View {\n 1. args: %s\n 2. kwargs: %s\n}\n' % (func.__name__, duration, args, kwargs))
return result
return func(*args, **kwargs)
@@ -756,3 +1432,12 @@ class ModelEncoder(JSONEncoder):
def default(self, obj):
if isinstance(obj, (datetime.date, datetime.datetime)):
return obj.isoformat()
+
+
+class PrettyPrintOrderedDefinition(json.JSONEncoder):
+ """
+ Indents and prettyprints the definition field so that it's readable
+ Preserves order that was given by `template_utils.get_ordered_definition`
+ """
+ def __init__(self, *args, indent, sort_keys, **kwargs):
+ super().__init__(*args, indent=2, sort_keys=False, **kwargs)
diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/model_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/model_utils.py
index b0cbeee71..735e389e4 100644
--- a/CodeListLibrary_project/clinicalcode/entity_utils/model_utils.py
+++ b/CodeListLibrary_project/clinicalcode/entity_utils/model_utils.py
@@ -1,25 +1,22 @@
+from django.db import connection
from django.apps import apps
-from django.db.models import ForeignKey
+from django.db.models import Model, ForeignKey
+from django.core.cache import cache
from django.forms.models import model_to_dict
-from django.contrib.auth.models import User, Group
+from django.contrib.auth.models import Group
+from django.contrib.auth import get_user_model
import re
import json
+import inspect
import simple_history
-from . import gen_utils
-from ..models.GenericEntity import GenericEntity
-from ..models.Tag import Tag
-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 . import gen_utils, filter_utils, constants
+
+User = get_user_model()
def try_get_instance(model, **kwargs):
- """
- Safely attempts to get an instance
- """
+ """Safely attempts to get an instance"""
try:
instance = model.objects.get(**kwargs)
except:
@@ -28,10 +25,7 @@ def try_get_instance(model, **kwargs):
return instance
def try_get_entity_history(entity, history_id):
- """
- Safely attempts to get an entity's historical record given an entity
- and a history id
- """
+ """Safely attempts to get an entity's historical record given an entity and a history id."""
if not entity:
return None
@@ -43,13 +37,41 @@ def try_get_entity_history(entity, history_id):
return instance
def try_get_brand(request, default=None):
+ """Safely get the Brand instance from the RequestContext"""
+ current_brand = request.BRAND_OBJECT
+ if inspect.isclass(current_brand) and issubclass(current_brand, Model):
+ return current_brand
+
+ current_brand = request.CURRENT_BRAND
+ if gen_utils.is_empty_string(current_brand) or current_brand.lower() == 'ALL':
+ return default
+ return try_get_instance(apps.get_model(app_label='clinicalcode', model_name='Brand'), name=current_brand)
+
+def try_get_brand_string(brand, field_name, default=None):
"""
- Safely get the Brand instance from the RequestContext
+ Attempts to safely get a `str` attribute from a Brand object
+
+ Args:
+ brand (Model|dict): the specified Brand from which to resolve the field
+ field_name (str): the name of the attribute to resolve
+ default (Any): some default to value to return on failure; defaults to `None`
+
+ Returns:
+ If successfully resolved will return a (str) field; otherwise returns the specified `default` value
"""
- current_brand = request.CURRENT_BRAND
- if gen_utils.is_empty_string(current_brand):
+ try:
+ if isinstance(brand, dict):
+ value = brand.get(field_name, None)
+ elif isinstance(brand, Model):
+ value = getattr(brand, field_name, None) if hasattr(brand, field_name) else None
+ else:
+ value = None
+ except:
+ value = None
+
+ if value is None or gen_utils.is_empty_string(value):
return default
- return try_get_instance(Brand, name=current_brand)
+ return value
def get_entity_id(primary_key):
"""
@@ -61,30 +83,128 @@ def get_entity_id(primary_key):
return entity_id[:2]
return False
-def get_brand_collection_ids(brand_name):
+def get_brand_tag_ids(brand):
"""
- Returns list of collections (tags) ids associated with the brand
+ Resolves a list of tags (tags with tag_type=1) ids associated with the brand
+
+ Args:
+ brand (Brand|str): the Brand instance or name of the brand to query
+
+ Returns:
+ A (list) of tags assoc. with this brand
+ """
+ src_tag = apps.get_model(app_label='clinicalcode', model_name='Tag')
+ src_brand = apps.get_model(app_label='clinicalcode', model_name='Brand')
+ if isinstance(brand, str):
+ brand = src_brand.objects.filter(name__iexact=brand)
+ if brand.exists():
+ brand = brand.first()
+
+ if not isinstance(brand, src_brand):
+ brand = None
+ brand_name = 'ALL'
+ else:
+ brand_name = brand.name
+
+ cache_key = f'tag_brands__{brand_name}__cache'
+
+ resultset = cache.get(cache_key)
+ if resultset is None:
+ if brand:
+ 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:
+ resultset = list(src_tag.objects.filter(*result).values_list('id', flat=True))
+
+ if resultset is None:
+ resultset = list(src_tag.objects.filter(collection_brand=brand.id, tag_type=1).values_list('id', flat=True))
+ else:
+ resultset = list(src_tag.objects.filter(tag_type=1).values_list('id', flat=True))
+ cache.set(cache_key, resultset, 3600)
+
+ return resultset
+
+def get_brand_collection_ids(brand):
+ """
+ Resolves a list of collections (tags with tag_type=2) ids associated with the brand
+
+ Args:
+ brand (Brand|str): the Brand instance or name of the brand to query
+
+ Returns:
+ A (list) of collections assoc. with this 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]
+ src_tag = apps.get_model(app_label='clinicalcode', model_name='Tag')
+ src_brand = apps.get_model(app_label='clinicalcode', model_name='Brand')
+ if isinstance(brand, str):
+ brand = src_brand.objects.filter(name__iexact=brand)
+ if brand.exists():
+ brand = brand.first()
+
+ if not isinstance(brand, src_brand):
+ brand_name = 'ALL'
+ else:
+ brand_name = brand.name
+
+ cache_key = f'collections_brands__{brand_name}__cache'
+
+ resultset = cache.get(cache_key)
+ if resultset is None:
+ if brand:
+ 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):
+ resultset = list(src_tag.objects.filter(*result).values_list('id', flat=True))
+
+ if resultset is None:
+ resultset = list(src_tag.objects.filter(collection_brand=brand.id, tag_type=2).values_list('id', flat=True))
+ else:
+ resultset = list(src_tag.objects.filter(tag_type=2).values_list('id', flat=True))
+ cache.set(cache_key, resultset, 3600)
+
+ return resultset
def get_entity_approval_status(entity_id, historical_id):
"""
Gets the entity's approval status, given an entity id and historical id
"""
- entity = GenericEntity.history.filter(id=entity_id, history_id=historical_id)
+ entity = apps.get_model(app_label='clinicalcode', model_name='GenericEntity').history.filter(id=entity_id, history_id=historical_id)
if entity.exists():
return entity.first().publish_status
+@gen_utils.measure_perf
def is_legacy_entity(entity_id, entity_history_id):
"""
Checks whether this entity_id and entity_history_id match the latest record
to determine whether a historical entity is legacy or not
"""
- latest_entity = GenericEntity.history.filter(id=entity_id)
+ latest_entity = apps.get_model(app_label='clinicalcode', model_name='GenericEntity').history.filter(id=entity_id)
if not latest_entity.exists():
return False
@@ -104,7 +224,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 +240,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)
@@ -132,7 +252,7 @@ def get_tag_attribute(tag_id, tag_type):
"""
Returns a dict that describes a tag given its id and type
"""
- tag = Tag.objects.filter(id=tag_id, tag_type=tag_type)
+ tag = apps.get_model(app_label='clinicalcode', model_name='Tag').objects.filter(id=tag_id, tag_type=tag_type)
if tag.exists():
tag = tag.first()
return {
@@ -149,13 +269,14 @@ def get_coding_system_details(coding_system):
the coding_system parameter passed is either an obj or an int
that references a coding_system by its codingsystem_id
"""
+ src = apps.get_model(app_label='clinicalcode', model_name='CodingSystem')
if isinstance(coding_system, int):
- coding_system = CodingSystem.objects.filter(codingsystem_id=coding_system)
+ coding_system = src.objects.filter(codingsystem_id=coding_system)
if not coding_system.exists():
return None
coding_system = coding_system.first()
- if not isinstance(coding_system, CodingSystem):
+ if not isinstance(coding_system, src):
return None
return {
@@ -165,10 +286,7 @@ def get_coding_system_details(coding_system):
}
def get_userdata_details(model, **kwargs):
- """
- Attempts to return a dict that describes a userdata field e.g. a user, or a group
- in a human readable format
- """
+ """Attempts to return a dict that describes a userdata field e.g. a user, or a group in a human readable format"""
hide_user_id = False
if kwargs.get('hide_user_details'):
hide_user_id = kwargs.pop('hide_user_details')
@@ -197,25 +315,78 @@ def append_coding_system_data(systems):
whether a search rule is applicable
Args:
- systems (list of dicts) A list of dicts that contains the coding systems of interest
- e.g. {name: (str), value: (int)} where value is the pk
+ systems (list[dict]): A list of dicts that contains the coding systems of interest e.g. {name: (str), value: (int)} where value is the pk
Returns:
A list of dicts that has the number of codes/searchable status appended,
as defined by their code reference tables
"""
- for i, system in enumerate(systems):
- try:
- coding_system = CodingSystem.objects.get(codingsystem_id=system.get('value'))
- table = coding_system.table_name.replace('clinicalcode_', '')
- codes = apps.get_model(app_label='clinicalcode', model_name=table)
- count = codes.objects.count() > 0
- systems[i]['code_count'] = count
- systems[i]['can_search'] = count
- except:
- continue
-
- return systems
+ with connection.cursor() as cursor:
+ sql = '''
+ do
+ $bd$
+ declare
+ _query text;
+ _ref text;
+ _row_cnt int;
+ _record json;
+ _coding_tables json;
+
+ _result jsonb := '[]'::jsonb;
+ _cursor constant refcursor := '_cursor';
+ begin
+ select
+ json_agg(json_build_object(
+ 'name', coding.name,
+ 'value', coding.id,
+ 'table_name', coding.table_name
+ )) as tbl
+ from public.clinicalcode_codingsystem as coding
+ into _coding_tables;
+
+ for _record, _ref
+ in select obj, obj->>'table_name'::text from json_array_elements(_coding_tables) obj
+ loop
+ if exists(select 1 from information_schema.tables where table_name = _ref) then
+ _query := format('select count(*) from %I', _ref);
+ execute _query into _row_cnt;
+ else
+ _row_cnt = 0;
+ end if;
+
+ _result = _result || format(
+ '[{
+ "name": "%s",
+ "value": %s,
+ "table_name": "%s",
+ "code_count": %s,
+ "can_search": %s
+ }]',
+ _record->>'name'::text,
+ _record->>'value'::text,
+ _record->>'table_name'::text,
+ _row_cnt::int,
+ (_row_cnt::int > 0)::text
+ )::jsonb;
+ end loop;
+
+ _query := format('select %L::jsonb as res', _result::text);
+ open _cursor for execute _query;
+ end;
+ $bd$;
+ fetch all from _cursor;
+ '''
+ cursor.execute(sql)
+
+ results = cursor.fetchone()
+ results = results[0] if isinstance(results, (list, tuple)) else None
+ if isinstance(results, str):
+ try:
+ results = json.loads(results)
+ except:
+ results = None
+
+ return results if isinstance(results, list) else systems
def modify_entity_change_reason(entity, reason):
"""
diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py
index 115b28e0b..f5d9eb4ee 100644
--- a/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py
+++ b/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py
@@ -1,11 +1,20 @@
+"""Permission-related utilities; defines functions to vary content access & render behaviour."""
+from functools import wraps
from django.db import connection
+from django.http import HttpRequest
from django.conf import settings
-from django.db.models import Q, Subquery, OuterRef
+from rest_framework import status as RestHttpStatus
+from django.db.models import Q, Model
+from django.contrib.auth import get_user_model
+from rest_framework.request import Request
from django.core.exceptions import PermissionDenied
+from rest_framework.exceptions import APIException, MethodNotAllowed, NotAuthenticated
+from rest_framework.permissions import BasePermission, SAFE_METHODS
from django.contrib.auth.models import Group
-from functools import wraps
+import inspect
+from ..models.Organisation import Organisation, OrganisationAuthority, OrganisationMembership
from . import model_utils, gen_utils
from ..models.Brand import Brand
from ..models.Concept import Concept
@@ -13,42 +22,296 @@
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 ..models.Organisation import Organisation, OrganisationAuthority
+
+from .constants import (
+ ORGANISATION_ROLES, APPROVAL_STATUS,
+ GROUP_PERMISSIONS, WORLD_ACCESS_PERMISSIONS
+)
+
-""" Permission decorators """
+User = get_user_model()
+
+'''Permission decorators'''
def redirect_readonly(fn):
- """
- Method decorator to raise 403 if we're on the read only site
- to avoid insert / update methods via UI
+ """
+ Method decorator to raise 403 if we're on the read only site to avoid insert / update methods via UI
- e.g.
+ Args:
+ fn (Callable): the view `Callable` to wrap
- @permission_utils.redirect_readonly
- def some_view_func(request):
- # stuff
- """
- @wraps(fn)
- def wrap(request, *args, **kwargs):
- if settings.CLL_READ_ONLY:
- raise PermissionDenied("ERR_403_GATEWAY")
- return fn(request, *args, **kwargs)
+ Returns:
+ A (Callable) decorator
+
+ Raises:
+ PermissionDenied
- return wrap
+ Example:
+ ```py
+ from clinicalcode.entity_utils import permission_utils
+ @permission_utils.redirect_readonly
+ def some_view_func(request):
+ pass
+ ```
+ """
+ @wraps(fn)
+ def wrap(request, *args, **kwargs):
+ if settings.CLL_READ_ONLY:
+ raise PermissionDenied('ERR_403_GATEWAY')
+ return fn(request, *args, **kwargs)
-""" Render helpers """
+ return wrap
+
+def brand_admin_required(fn):
+ """
+ Method decorator to raise a 403 if a view isn't accessed by a Brand Administrator
+
+ .. Note::
+ Brand administration privileges are checked by comparing the user against the current brand (per request middleware).
+
+ Args:
+ fn (Callable): the view `Callable` to wrap
+ Returns:
+ A (Callable) decorator
+
+ Raises:
+ PermissionDenied
+
+ Example:
+ ```py
+ from django.utils.decorators import method_decorator
+
+ from clinicalcode.entity_utils import permission_utils
+
+ @method_decorator([permission_utils.brand_admin_required, *other_decorators])
+ def some_view_func(request):
+ pass
+
+ # Or...
+ @permission_utils.brand_admin_required
+ def some_view_func(request):
+ pass
+ ```
+ """
+ @wraps(fn)
+ def wrap(request, *args, **kwargs):
+ user = request.user if hasattr(request, 'user') and request.user.is_authenticated else None
+ brand = request.BRAND_OBJECT if hasattr(request, 'BRAND_OBJECT') else None
+
+ if user is None:
+ raise PermissionDenied
+
+ if not user.is_superuser:
+ if not isinstance(brand, Brand) or brand.id is None:
+ raise PermissionDenied
+
+ administrable = user.administered_brands \
+ .filter(id=brand.id, is_administrable=True) \
+ .exists()
+
+ if not administrable:
+ raise PermissionDenied
+
+ return fn(request, *args, **kwargs)
+
+ return wrap
+
+
+'''REST Permission Classes'''
+class IsReadOnlyRequest(BasePermission):
+ """
+ Ensures that a request is one of `GET`, `HEAD`, or `OPTIONS`.
+
+ Raises:
+ MethodNotAllowed (405)
+
+ Example:
+ ```py
+ from rest_framework.views import APIView
+ from rest_framework.response import Response
+ from rest_framework.decorators import decorators
+
+ from clinicalcode.entity_utils import permission_utils
+
+ @schema(None)
+ class SomeEndpoint(APIView):
+ permission_classes = [permission_utils.IsReadOnlyRequest]
+
+ def get(self, request):
+ return Response({ 'message': 'Hello, world!' })
+ ```
+ """
+ ERR_STATUS_CODE = RestHttpStatus.HTTP_405_METHOD_NOT_ALLOWED
+ ERR_REQUEST_MSG = (
+ 'Method Not Allowed: ' + \
+ 'Expected read only method of type `GET`, `HEAD`, `OPTIONS` but got `%s`'
+ )
+
+ def has_permission(self, request, view):
+ method = request.method
+ if not method in SAFE_METHODS:
+ raise MethodNotAllowed(
+ method=method,
+ detail=self.ERR_REQUEST_MSG % method,
+ code=self.ERR_STATUS_CODE
+ )
+
+ return True
+
+class IsReadOnlyOrNotGateway(BasePermission):
+ """
+ Ensures that a request is either (a) read-only or (b) not being made from a TRE read-only site.
+
+ .. Note::
+ - A request must be one of `GET`, `HEAD`, or `OPTIONS` to be considered `ReadOnly`;
+ - TRE read-only sites are declared as such by the deployment environment variables.
+
+ Raises:
+ MethodNotAllowed (405)
+
+ Example:
+ ```py
+ from rest_framework.views import APIView
+ from rest_framework.response import Response
+ from rest_framework.decorators import decorators
+
+ from clinicalcode.entity_utils import permission_utils
+
+ @schema(None)
+ class SomeEndpoint(APIView):
+ permission_classes = [permission_utils.IsReadOnlyOrNotGateway]
+
+ def get(self, request):
+ return Response({ 'message': 'Hello, world!' })
+ ```
+ """
+ ERR_STATUS_CODE = RestHttpStatus.HTTP_405_METHOD_NOT_ALLOWED
+ ERR_REQUEST_MSG = (
+ 'Method Not Allowed: ' + \
+ 'Only methods of type `GET`, `HEAD`, `OPTIONS` are accessible from the ReadOnly site, ' + \
+ 'request of type `%s` is not allowed.'
+ )
+
+ def has_permission(self, request, view):
+ method = request.method
+ if not request.method in SAFE_METHODS and settings.CLL_READ_ONLY:
+ raise MethodNotAllowed(
+ method=method,
+ detail=self.ERR_REQUEST_MSG % method,
+ code=self.ERR_STATUS_CODE
+ )
+
+ return True
+
+class IsNotGateway(BasePermission):
+ """
+ Ensures that a request is either not being made from a TRE read-only site.
+
+ .. Note::
+ - TRE read-only sites are declared as such by the deployment environment variables.
+
+ Raises:
+ MethodNotAllowed (405)
+
+ Example:
+ ```py
+ from rest_framework.views import APIView
+ from rest_framework.response import Response
+ from rest_framework.decorators import decorators
+
+ from clinicalcode.entity_utils import permission_utils
+
+ @schema(None)
+ class SomeEndpoint(APIView):
+ permission_classes = [permission_utils.IsNotGateway]
+
+ def get(self, request):
+ return Response({ 'message': 'Hello, world!' })
+ ```
+ """
+ ERR_STATUS_CODE = RestHttpStatus.HTTP_405_METHOD_NOT_ALLOWED
+ ERR_REQUEST_MSG = (
+ 'Method Not Allowed: ' + \
+ 'Not accessible from the TRE ReadOnly site, ' + \
+ 'request of type `%s` is not allowed.'
+ )
+
+ def has_permission(self, request, view):
+ method = request.method
+ if settings.CLL_READ_ONLY:
+ raise MethodNotAllowed(
+ method=method,
+ detail=self.ERR_REQUEST_MSG % method,
+ code=self.ERR_STATUS_CODE
+ )
+
+ return True
+
+class IsBrandAdmin(BasePermission):
+ """
+ Ensures that the request user is authorised as a Brand Administrator for the request's Brand context.
+
+ .. Note::
+ Requests made by superusers will always granted regardless of Brand context.
+
+ Raises:
+ | Error | Status | Reason |
+ |----------------|--------|--------------------------------------------------------------------------------|
+ | Unauthorised | 401 | If the request user is anonymous/hasn't supplied auth token |
+ | Forbidden | 403 | If the request user is _not_ a known Brand Administrator |
+ | Not Acceptable | 406 | If the request user is attempting to access the Dashboard for an unknown Brand |
+
+ Example:
+ ```py
+ from rest_framework.views import APIView
+ from rest_framework.response import Response
+ from rest_framework.decorators import decorators
+
+ from clinicalcode.entity_utils import permission_utils
+
+ @schema(None)
+ class SomeEndpoint(APIView):
+ permission_classes = [permission_utils.IsBrandAdmin]
+
+ def get(self, request):
+ return Response({ 'message': 'Hello, world!' })
+ ```
+ """
+ def has_permission(self, request, view):
+ user = request.user if hasattr(request, 'user') and not request.user.is_anonymous else None
+ if user is None:
+ raise NotAuthenticated
+
+ if user.is_superuser:
+ return True
+
+ brand = request.BRAND_OBJECT if hasattr(request, 'BRAND_OBJECT') else None
+ if not isinstance(brand, Brand) or brand.id is None:
+ raise APIException(detail='Could not resolve Brand context', code=RestHttpStatus.HTTP_406_NOT_ACCEPTABLE)
+
+ administrable = user.administered_brands \
+ .filter(id=brand.id, is_administrable=True) \
+ .exists()
+
+ if not administrable:
+ raise PermissionDenied
+
+ return True
+
+
+'''Render helpers'''
def should_render_template(template=None, **kwargs):
"""
- Method to det. whether a template should be renderable
- based on its `hide_on_create` property
+ Method to det. whether a template should be renderable based on its `hide_on_create` property.
Args:
template {model}: optional parameter to check a model instance directly
**kwargs (any): params to use when querying the template model
-
+
Returns:
A boolean reflecting the renderable status of a template model
@@ -68,23 +331,171 @@ def should_render_template(template=None, **kwargs):
return not template.hide_on_create
-""" Status helpers """
-
+'''Status helpers'''
def is_member(user, group_name):
"""
Checks if a User instance is a member of a group
"""
return user.groups.filter(name__iexact=group_name).exists()
-def has_member_access(user, entity, permissions):
+def is_requestor_brand_admin(request=None):
+ """Evaluates a request, the brand context, and the assoc. user (if any) to determine whether the user can access the Brand Administration panel"""
+ if not isinstance(request, (Request, HttpRequest)):
+ return False
+
+ user = request.user if hasattr(request, 'user') and not request.user.is_anonymous else None
+ if user is None:
+ return False
+
+ brand = request.BRAND_OBJECT if hasattr(request, 'BRAND_OBJECT') else None
+ if not isinstance(brand, Brand) or brand.id is None or not brand.is_administrable:
+ return False
+
+ if user.is_superuser:
+ return True
+
+ administrable = user.administered_brands \
+ .filter(id=brand.id, is_administrable=True) \
+ .exists()
+
+ return administrable
+
+def get_brand_related_users(req_brand=None):
"""
- Checks if a user has access to an entity via its group membership
+ Resolves users associated with a specific Brand context
+
+ Args:
+ req_brand (str|int|Brand|Request|HttpRequest): either (a) the name/id of the Brand, (b) the Brand object itself, or (c) the HTTP request assoc. with this operation
+
+ Returns:
+ A (QuerySet) containing the users assoc. with this request/brand context
+ """
+ if isinstance(req_brand, (Request, HttpRequest)):
+ brand = model_utils.try_get_brand(req_brand)
+ elif isinstance(req_brand, str) and not gen_utils.is_empty_string(req_brand):
+ brand = model_utils.try_get_instance(Brand, name__iexact=req_brand)
+ elif isinstance(req_brand, int) and req_brand >= 0:
+ brand = model_utils.try_get_instance(Brand, pk=req_brand)
+ elif isinstance(req_brand, Brand) and (inspect.isclass(req_brand) and issubclass(req_brand, Brand)):
+ brand = req_brand
+
+ records = None
+ if brand is not None:
+ vis_rules = brand.get_vis_rules()
+ if isinstance(vis_rules, dict):
+ allow_null = vis_rules.get('allow_null')
+ allowed_brands = vis_rules.get('ids')
+ if isinstance(allowed_brands, list) and isinstance(allow_null, bool) and allow_null:
+ records = User.objects.filter(Q(accessible_brands__id__isnull=True) | Q(accessible_brands__id__in=allowed_brands))
+ elif isinstance(allowed_brands, list):
+ records = User.objects.filter(accessible_brands__id__in=allowed_brands)
+ elif isinstance(allow_null, bool) and allow_null:
+ records = User.objects.filter(Q(accessible_brands__id__isnull=True) | Q(accessible_brands__id__in=[brand.id]))
+
+ if records is None:
+ records = User.objects.filter(accessible_brands__id=brand.id)
+
+ return records if records is not None else User.objects.all()
+
+def has_member_org_access(user, slug, min_permission):
"""
- if entity.group_id in user.groups.all().values_list('id', flat=True):
- return entity.group_access in permissions
+ Checks if a user has access to an organisation.
+ Min_permissions relates to the organisation role, e.g.
+ min_permission=1 means the user has to be an editor or above.
+ """
+ if user and not user.is_anonymous:
+ if not gen_utils.is_empty_string(slug):
+ organisation = Organisation.objects.filter(slug=slug)
+ if organisation.exists():
+ organisation = organisation.first()
+
+ if organisation.owner == user:
+ return True
+
+ membership = user.organisationmembership_set \
+ .filter(organisation__id=organisation.id)
+ if membership.exists():
+ return membership.first().role >= min_permission
return False
+def has_member_access(user, entity, min_permission):
+ """
+ Checks if a user has access to an entity via its organisation
+ membership. Min_permissions relates to the organisation role, e.g.
+ min_permission=1 means the user has to be an editor or above.
+ """
+ try:
+ org = entity.organisation
+ except Organisation.DoesNotExist:
+ org = None
+
+ if org:
+ if org.owner == user:
+ return True
+
+ if user and not user.is_anonymous:
+ membership = user.organisationmembership_set \
+ .filter(organisation__id=org.id)
+ if membership.exists():
+ return membership.first().role >= min_permission
+
+ return False
+
+def get_organisation_role(user, organisation):
+ if organisation.owner == user:
+ return ORGANISATION_ROLES.ADMIN
+
+ org_membership = model_utils.try_get_instance(OrganisationMembership, user_id=user.id, organisation_id=organisation.id)
+ if org_membership is None:
+ return None
+
+ if org_membership.role in ORGANISATION_ROLES:
+ return ORGANISATION_ROLES(org_membership.role)
+ return -1
+
+def has_org_member(user, organisation):
+ if organisation.owner == user:
+ return True
+
+ 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 not has_org_member(request.user, organisation):
+ return False
+
+ brand = model_utils.try_get_brand(request)
+ authorities = list(OrganisationAuthority.objects.filter(organisation_id=organisation.id).values('can_post','can_moderate','brand_id'))
+ requested_authority = None
+ if brand is not None:
+ requested_authority = next(
+ ({**authority, 'org_user_managed': brand.org_user_managed} for authority in authorities if brand.id == authority['brand_id']),
+ None
+ )
+
+ if requested_authority is None:
+ return {'org_user_managed': False, 'can_moderate': False, 'can_post': False}
+ return requested_authority
+
+def get_organisation(request, entity_id=None, default=None):
+ if entity_id is None:
+ return None
+
+ entity = model_utils.try_get_instance(GenericEntity, id=entity_id)
+ try:
+ org = entity.organisation
+ except Organisation.DoesNotExist:
+ org = default
+ return org
+
+def is_org_managed(request, brand_id=None):
+ if brand_id is not None:
+ brand = model_utils.try_get_instance(Brand, id=brand_id)
+ else:
+ brand = model_utils.try_get_brand(request)
+ return brand.org_user_managed if brand is not None else False
+
def is_publish_status(entity, status):
"""
Checks the publication status of an entity
@@ -101,8 +512,8 @@ def is_publish_status(entity, status):
return approval_status in status
return False
-""" General permissions """
+'''General permissions'''
def was_archived(entity_id):
"""
Checks whether an entity was ever archived:
@@ -135,6 +546,78 @@ def get_user_groups(request):
return list(user.groups.all().exclude(name='ReadOnlyUsers').values('id', 'name'))
+def get_user_organisations(request, min_role_permission=ORGANISATION_ROLES.EDITOR):
+ """
+ Get the organisations related to the requesting user
+ """
+ user = request.user
+ if not user or user.is_anonymous:
+ return []
+
+ if user.is_superuser:
+ return list(Organisation.objects.all().values('id', 'name'))
+
+ current_brand = model_utils.try_get_brand(request)
+ current_brand = current_brand if current_brand and current_brand.org_user_managed else None
+
+ with connection.cursor() as cursor:
+ if current_brand is None:
+ cursor.execute(
+ '''
+ select org.id, org.slug, org.name
+ from public.clinicalcode_organisation org
+ join public.clinicalcode_organisationmembership mem
+ on org.id = mem.organisation_id
+ where org.owner_id = %(user_id)s
+ or (mem.user_id = %(user_id)s and mem.role >= %(role_enum)s)
+ ''',
+ params={
+ 'user_id': user.id,
+ 'role_enum': min_role_permission
+ }
+ )
+ else:
+ cursor.execute(
+ '''
+ select org.id, org.slug, org.name
+ from public.clinicalcode_organisation org
+ join public.clinicalcode_organisationmembership mem
+ on org.id = mem.organisation_id
+ join public.clinicalcode_organisationauthority aut
+ on org.id = aut.organisation_id
+ and aut.brand_id = %(brand)s
+ where (org.owner_id = %(user_id)s or (mem.user_id = %(user_id)s and mem.role >= %(role_enum)s))
+ and aut.can_post = true
+ ''',
+ params={
+ 'user_id': user.id,
+ 'role_enum': min_role_permission,
+ 'brand': current_brand.id
+ }
+ )
+
+ columns = [col[0] for col in cursor.description]
+ results = [dict(zip(columns, row)) for row in cursor.fetchall()]
+ return results
+
+def user_has_create_context(request=None):
+ if request is None or settings.CLL_READ_ONLY:
+ return False
+
+ user = request.user
+ if user is None or user.is_anonymous or user.is_superuser:
+ return True
+
+ brand = model_utils.try_get_brand(request)
+ if brand is not None and brand.org_user_managed:
+ user_orgs = get_user_organisations(
+ request, min_role_permission=ORGANISATION_ROLES.EDITOR
+ )
+ if not user_orgs or len(user_orgs) < 1:
+ return False
+
+ return True
+
def get_moderation_entities(
request,
status=None
@@ -152,8 +635,16 @@ def get_moderation_entities(
entities = GenericEntity.history.all() \
.order_by('id', '-history_id') \
.distinct('id')
+ entities = entities.filter(Q(publish_status__in=status))
+
+ current_brand = model_utils.try_get_brand(request)
+ current_brand = current_brand if current_brand and current_brand.org_user_managed else None
+ if current_brand is not None:
+ entities = entities.filter(
+ Q(brands__overlap=[current_brand.id])
+ )
- return entities.filter(Q(publish_status__in=status))
+ return entities
def get_editable_entities(
request,
@@ -171,38 +662,69 @@ def get_editable_entities(
List of all editable entities
"""
user = request.user
- entities = GenericEntity.history.all() \
- .order_by('id', '-history_id') \
- .distinct('id')
+ if not user or user.is_anonymous:
+ return None
- brand = model_utils.try_get_brand(request)
- if consider_brand and brand:
- entities = entities.filter(Q(brands__overlap=[brand.id]))
+ brand = model_utils.try_get_brand(request) if consider_brand else None
+ if brand:
+ brand_sql = 'and %(brand)s = any(live.brands)'
+ else:
+ brand_sql = ''
+
+ if only_deleted:
+ delete_sql = 'and live.is_deleted = true'
+ else:
+ delete_sql = 'and (live.is_deleted is null or live.is_deleted = false)'
- if user and not user.is_anonymous:
- query = Q(owner=user.id)
- query |= Q(
- group_id__in=user.groups.all(),
- group_access__in=[GROUP_PERMISSIONS.EDIT]
+ sql = '''
+ with entity as (
+ select distinct on (id) *
+ from public.clinicalcode_historicalgenericentity entity
+ order by id, history_id desc
)
-
- entities = entities.filter(query) \
- .annotate(
- was_deleted=Subquery(
- GenericEntity.objects.filter(
- id=OuterRef('id'),
- is_deleted=True
- ) \
- .values('id')
- )
- )
-
- if only_deleted:
- return entities.exclude(was_deleted__isnull=True)
- else:
- return entities.exclude(was_deleted__isnull=False)
-
- return None
+ select entity.id,
+ entity.name,
+ entity.history_id,
+ entity.updated,
+ entity.publish_status,
+ live.is_deleted as was_deleted,
+ organisation.name as group_name,
+ uac.username as owner_name
+ from entity
+ join public.clinicalcode_genericentity live
+ on live.id = entity.id
+ join public.auth_user uac
+ on uac.id = entity.owner_id
+ left join public.clinicalcode_organisation organisation
+ on organisation.id = live.organisation_id
+ left join public.clinicalcode_organisationmembership membership
+ on membership.organisation_id = organisation.id
+ where (live.owner_id = %(user_id)s
+ or organisation.owner_id = %(user_id)s
+ or (membership.user_id = %(user_id)s AND membership.role >= %(role_enum)s))
+ {brand}
+ {delete}
+ group by entity.id, entity.name, entity.history_id,
+ entity.updated, entity.publish_status,
+ live.is_deleted, organisation.name, uac.username
+ '''.format(
+ brand=brand_sql,
+ delete=delete_sql
+ )
+
+ with connection.cursor() as cursor:
+ cursor.execute(
+ sql,
+ params={
+ 'user_id': user.id,
+ 'role_enum': ORGANISATION_ROLES.EDITOR,
+ 'brand': brand.id if brand else None
+ }
+ )
+
+ columns = [col[0] for col in cursor.description]
+ results = [dict(zip(columns, row)) for row in cursor.fetchall()]
+ return results
def get_accessible_entity_history(
request,
@@ -230,7 +752,7 @@ def get_accessible_entity_history(
brand_clause = ''
brand = model_utils.try_get_brand(request)
if brand is not None:
- brand_clause = 'and t0.brands && %(brand_ids)s'
+ brand_clause = 'and live.brands && %(brand_ids)s'
query_params.update({ 'brand_ids': [brand.id] })
data = f'''
@@ -313,6 +835,8 @@ def get_accessible_entity_history(
from public.clinicalcode_historicalgenericentity as t0
join pub_data as pub
on t0.history_id = any(pub.published_ids)
+ join public.clinicalcode_genericentity as live
+ on live.id = t0.id
left join public.auth_user as uau
on t0.updated_by_id = uau.id
left join public.auth_user as cau
@@ -330,7 +854,11 @@ def get_accessible_entity_history(
on true
left join (
select json_agg(row_to_json(t.*) order by t.history_id desc) as entities
- from historical_entities as t
+ from (
+ select *, row_number() over(partition by id, history_id) as rn
+ from historical_entities
+ ) as t
+ where rn = 1
) as t2
on true
'''
@@ -345,10 +873,24 @@ def get_accessible_entity_history(
# i.e. dependent on user role
clauses = 'true'
if not is_superuser:
- clauses = '''t0.world_access = %(world_access)s or t0.owner_id = %(user_id)s'''
+ org_view_clause = ''
+ if brand is not None and brand.org_user_managed:
+ user_orgs = get_user_organisations(request, min_role_permission=ORGANISATION_ROLES.MEMBER)
+ if user_orgs and len(user_orgs) >= 1:
+ org_view_clause = f'''
+ or t0.world_access = {WORLD_ACCESS_PERMISSIONS.VIEW.value}
+ '''
+
+ clauses = f'''
+ (live.owner_id = %(user_id)s
+ or org.owner_id = %(user_id)s
+ or (mem.user_id = %(user_id)s AND mem.role >= %(role_enum)s)
+ {org_view_clause})
+ '''
+
query_params.update({
'user_id': user.id,
- 'world_access': WORLD_ACCESS_PERMISSIONS.VIEW.value,
+ 'role_enum': ORGANISATION_ROLES.MEMBER
})
pub_status = [APPROVAL_STATUS.APPROVED.value]
@@ -369,17 +911,6 @@ def get_accessible_entity_history(
or t0.publish_status = {APPROVAL_STATUS.APPROVED.value}
'''
- group_ids = [x for x in list(user.groups.all().values_list('id', flat=True))]
-
- if len(group_ids):
- clauses += '''
- or (t0.group_id = any(%(group_ids)s) and t0.group_access = any(%(group_perms)s))
- '''
- query_params.update({
- 'group_ids': group_ids,
- 'group_perms': [GROUP_PERMISSIONS.VIEW.value, GROUP_PERMISSIONS.EDIT.value],
- })
-
sql = f'''
{data},
historical_entities as (
@@ -402,6 +933,12 @@ def get_accessible_entity_history(
on t0.owner_id = oau.id
left join (select json_array_elements(objects::json) as obj from pub_data pd) as pd
on t0.history_id = (pd.obj->>'entity_history_id')::int
+ join public.clinicalcode_genericentity live
+ on live.id = t0.id
+ left join public.clinicalcode_organisation org
+ on org.id = live.organisation_id
+ left join public.clinicalcode_organisationmembership mem
+ on mem.organisation_id = org.id
where t0.id = %(pk)s
and ({clauses})
{brand_clause}
@@ -412,7 +949,11 @@ def get_accessible_entity_history(
on true
left join (
select json_agg(row_to_json(t.*) order by t.history_id desc) as entities
- from historical_entities as t
+ from (
+ select *, row_number() over(partition by id, history_id) as rn
+ from historical_entities
+ ) as t
+ where rn = 1
) as t2
on true
'''
@@ -428,7 +969,7 @@ def get_accessible_entities(
consider_user_perms=True,
only_deleted=False,
status=[APPROVAL_STATUS.APPROVED],
- group_permissions=[GROUP_PERMISSIONS.VIEW, GROUP_PERMISSIONS.EDIT],
+ min_group_permission=ORGANISATION_ROLES.MEMBER,
consider_brand=True,
raw_query=False,
pk=None
@@ -456,7 +997,7 @@ def get_accessible_entities(
brand = model_utils.try_get_brand(request) if consider_brand else None
brand_clause = ''
if consider_brand and brand is not None:
- brand_clause = 'and hist_entity.brands && %(brand_ids)s'
+ brand_clause = 'and live_entity.brands && %(brand_ids)s'
query_params.update({ 'brand_ids': [brand.id] })
# Append pk clause if entity_id is valid
@@ -468,7 +1009,15 @@ def get_accessible_entities(
# Early exit if we're a superuser and want to consider privileges
if len(pk_clause) < 1 and consider_user_perms and user and user.is_superuser:
if brand is not None:
- results = GenericEntity.history.filter(brands__overlap=[brand.id]).latest_of_each()
+ results = GenericEntity.objects.filter(brands__overlap=[brand.id])
+
+ if only_deleted:
+ results = results.filter(is_deleted=True)
+ else:
+ results = results.exclude(is_deleted=True)
+
+ results = GenericEntity.history \
+ .filter(id__in=list(results.values_list('id', flat=True)))
else:
results = GenericEntity.history.all()
return results.latest_of_each()
@@ -532,10 +1081,21 @@ def get_accessible_entities(
# Non-anon user
# i.e. dependent on user role
- clauses = '''hist_entity.world_access = %(world_access)s or hist_entity.owner_id = %(user_id)s'''
+ org_view_clause = ''
+ if consider_brand and brand is not None and brand.org_user_managed:
+ user_orgs = get_user_organisations(request, min_role_permission=ORGANISATION_ROLES.MEMBER)
+ if user_orgs and len(user_orgs) >= 1:
+ org_view_clause = f'''or live_entity.world_access = {WORLD_ACCESS_PERMISSIONS.VIEW.value}'''
+
+ clauses = f'''
+ (live_entity.owner_id = %(user_id)s
+ or org.owner_id = %(user_id)s
+ or (mem.user_id = %(user_id)s AND mem.role >= %(role_enum)s){org_view_clause})
+ '''
+
query_params.update({
'user_id': user.id,
- 'world_access': WORLD_ACCESS_PERMISSIONS.VIEW.value,
+ 'role_enum': min_group_permission
})
if len(pub_status) < 1:
@@ -552,23 +1112,6 @@ def get_accessible_entities(
'''
query_params.update({ 'pub_status': pub_status })
- if isinstance(group_permissions, list):
- group_ids = [x for x in list(user.groups.all().values_list('id', flat=True))]
- group_perms = [
- x.value if x in GROUP_PERMISSIONS else gen_utils.parse_int(x, default=None)
- for x in group_permissions
- if x in GROUP_PERMISSIONS or gen_utils.parse_int(x, default=None) is not None
- ]
-
- if len(group_ids) | len(group_perms):
- clauses += '''
- or (hist_entity.group_id = any(%(group_ids)s) and hist_entity.group_access = any(%(group_perms)s))
- '''
- query_params.update({
- 'group_ids': group_ids,
- 'group_perms': group_perms,
- })
-
conditional = ''
if only_deleted:
conditional = '''
@@ -576,7 +1119,7 @@ def get_accessible_entities(
'''
else:
conditional = '''
- and (live_entity.id is not null and (live_entity.is_deleted is null or live_entity.is_deleted = false))
+ and (live_entity.is_deleted is null or live_entity.is_deleted = false)
and (hist_entity.is_deleted is null or hist_entity.is_deleted = false)
'''
@@ -591,12 +1134,16 @@ def get_accessible_entities(
order by hist_entity.history_id desc
) as rn_ref_n
from public.clinicalcode_historicalgenericentity as hist_entity
- left join public.clinicalcode_genericentity as live_entity
+ join public.clinicalcode_genericentity as live_entity
on hist_entity.id = live_entity.id
join public.clinicalcode_historicaltemplate as hist_tmpl
on hist_entity.template_id = hist_tmpl.id and hist_entity.template_version = hist_tmpl.template_version
join public.clinicalcode_template as live_tmpl
on hist_tmpl.id = live_tmpl.id
+ left join public.clinicalcode_organisation org
+ on org.id = live_entity.organisation_id
+ left join public.clinicalcode_organisationmembership mem
+ on mem.organisation_id = org.id
where (
{clauses}
)
@@ -722,7 +1269,6 @@ def get_latest_owner_version_from_concept(phenotype_id, concept_id, concept_vers
on pheno.phenotype_id = entity.id
and pheno.phenotype_version_id = entity.history_id
group by phenotype_id;
-
'''
cursor.execute(sql, params=params)
@@ -739,7 +1285,7 @@ def get_latest_owner_version_from_concept(phenotype_id, concept_id, concept_vers
def get_accessible_concepts(
request,
- group_permissions=[GROUP_PERMISSIONS.VIEW, GROUP_PERMISSIONS.EDIT]
+ min_group_permission=ORGANISATION_ROLES.MEMBER
):
"""
Tries to get all the concepts that are accessible to a specific user
@@ -781,7 +1327,7 @@ def get_accessible_concepts(
'''
cursor.execute(
sql,
- params=[WORLD_ACCESS_PERMISSIONS.VIEW.value]
+ params=[APPROVAL_STATUS.APPROVED.value]
)
columns = [col[0] for col in cursor.description]
@@ -794,16 +1340,16 @@ def get_accessible_concepts(
return concepts
- group_access = [x.value for x in group_permissions]
with connection.cursor() as cursor:
sql = '''
select distinct on (concept_id)
- id as phenotype_id,
- cast(concepts->>'concept_id' as integer) as concept_id,
- cast(concepts->>'concept_version_id' as integer) as concept_version_id
+ results.id as phenotype_id,
+ cast(results.concepts->>'concept_id' as integer) as concept_id,
+ cast(results.concepts->>'concept_version_id' as integer) as concept_version_id
from (
select id,
- concepts
+ concepts,
+ publish_status
from public.clinicalcode_historicalgenericentity as entity,
json_array_elements(entity.template_data::json->'concept_information') as concepts
where
@@ -812,31 +1358,31 @@ def get_accessible_concepts(
from public.clinicalcode_genericentity as ge
where ge.is_deleted = true and ge.id = entity.id
)
- and (
- entity.publish_status = %s
- or (
- exists (
- select 1
- from public.auth_user_groups as t
- where t.user_id = %s and t.group_id = entity.group_id
- )
- and entity.group_access in %s
- )
- or entity.owner_id = %s
- or entity.world_access = %s
- )
) results
+ join public.clinicalcode_genericentity as live
+ on live.id = results.id
+ left join public.clinicalcode_organisation as org
+ on org.id = live.organisation_id
+ left join public.clinicalcode_organisationmembership as mem
+ on mem.organisation_id = org.id
+ where (
+ results.publish_status = %(publish_status)s
+ or org.owner_id = %(user_id)s
+ or (mem.user_id = %(user_id)s and mem.role >= %(role_enum)s)
+ or live.owner_id = %(user_id)s
+ )
order by concept_id desc, concept_version_id desc
'''
cursor.execute(
sql,
- params=[
- APPROVAL_STATUS.APPROVED.value, user.id, tuple(group_access),
- user.id, WORLD_ACCESS_PERMISSIONS.VIEW.value
- ]
+ params={
+ 'publish_status': APPROVAL_STATUS.APPROVED.value,
+ 'user_id': user.id,
+ 'role_enum': min_group_permission,
+ }
)
-
+
columns = [col[0] for col in cursor.description]
results = [dict(zip(columns, row)) for row in cursor.fetchall()]
@@ -889,12 +1435,17 @@ def can_user_view_entity(request, entity_id, entity_history_id=None):
if live_entity.owner == user:
return check_brand_access(request, is_published, entity_id, entity_history_id)
- if has_member_access(user, live_entity, [GROUP_PERMISSIONS.VIEW, GROUP_PERMISSIONS.EDIT]):
+ if has_member_access(user, live_entity, ORGANISATION_ROLES.MEMBER):
return check_brand_access(request, is_published, entity_id, entity_history_id)
- if user and not user.is_anonymous:
- if live_entity.world_access == WORLD_ACCESS_PERMISSIONS.VIEW:
- return check_brand_access(request, is_published, entity_id, entity_history_id)
+ brand = model_utils.try_get_brand(request)
+ if brand is not None and brand.org_user_managed:
+ if live_entity.world_access == WORLD_ACCESS_PERMISSIONS.VIEW:
+ user_orgs = get_user_organisations(
+ request, min_role_permission=ORGANISATION_ROLES.MEMBER
+ )
+ if user_orgs and len(user_orgs) >= 1:
+ return check_brand_access(request, is_published, entity_id, entity_history_id)
return False
@@ -908,17 +1459,17 @@ def get_accessible_detail_entity(request, entity_id, entity_history_id=None):
entity_id (number): The entity ID of interest
Returns:
- Either (a) a dict containing the entity and the user's edit/view access
- or; (b) a boolean value reflecting the failed state of this func
+ Either tuple in which the first element is either (a) a dict containing the entity and the user's edit/view access or (b) a `None` type value;
+ while the latter element, if unsuccessful, specifies a dict containing a `status_code`, a `title`, and a `message` property describing the failed state of this operation
"""
live_entity = model_utils.try_get_instance(GenericEntity, pk=entity_id)
if live_entity is None:
- return False
+ return None, { 'title': 'Page Not Found - Missing Entity', 'message': 'No entity exists with the ID.', 'status_code': 404 }
if entity_history_id is not None:
historical_entity = model_utils.try_get_entity_history(live_entity, entity_history_id)
if historical_entity is None:
- return False
+ return None, { 'title': 'Page Not Found - Missing Version', 'message': 'No entity version exists with the specified version ID.', 'status_code': 404 }
else:
historical_entity = live_entity.history.latest()
entity_history_id = historical_entity.history_id
@@ -928,19 +1479,19 @@ def get_accessible_detail_entity(request, entity_id, entity_history_id=None):
brand_id = brand.get('id') if isinstance(brand, dict) else getattr(brand, 'id')
brand_id = gen_utils.parse_int(brand_id, default=None)
+ brand_accessible = False
+ brand_org_managed = False
if brand_id is not None:
related_brands = live_entity.brands
- if not isinstance(related_brands, list) or len(related_brands) < 1:
- brand_accessible = True
- elif isinstance(related_brands, list):
+ if isinstance(related_brands, list):
brand_accessible = brand_id in related_brands
+ brand_org_managed = brand.org_user_managed
else:
brand_accessible = True
user = request.user if request.user and not request.user.is_anonymous else None
user_groups = user.groups.all() if user is not None else None
user_has_groups = user_groups.exists() if user_groups is not None else None
- user_group_ids = list(user_groups.values_list('id', flat=True)) if user_has_groups else []
user_is_admin = user.is_superuser if user else False
user_is_moderator = ('Moderators' in list(user_groups.values_list('name', flat=True))) if user_has_groups else False
@@ -948,38 +1499,57 @@ def get_accessible_detail_entity(request, entity_id, entity_history_id=None):
is_viewable = False
is_editable = False
is_published = historical_entity.publish_status == APPROVAL_STATUS.APPROVED.value
+ is_org_accessible = False
if user is not None:
is_owner = live_entity.owner == user
is_moderatable = False
- is_group_member = False
- is_world_accessible = False
+ is_org_member = False
if user_is_admin:
is_editable = True
elif brand_accessible:
- entity_group_id = live_entity.group_id if isinstance(live_entity.group_id, int) else None
if live_entity.owner == user or live_entity.created_by == user:
is_editable = True
- if entity_group_id is not None:
- is_editable = live_entity.group_access == GROUP_PERMISSIONS.EDIT and entity_group_id in user_group_ids
- is_group_member = (
- live_entity.group_access in [GROUP_PERMISSIONS.VIEW, GROUP_PERMISSIONS.EDIT]
- and entity_group_id in user_group_ids
- )
+ entity_org = live_entity.organisation
+ if entity_org is not None:
+ if brand and brand.org_user_managed and live_entity.world_access == WORLD_ACCESS_PERMISSIONS.VIEW:
+ user_orgs = get_user_organisations(request, min_role_permission=ORGANISATION_ROLES.MEMBER)
+ if user_orgs and len(user_orgs) >= 1:
+ is_org_accessible = True
+
+ if entity_org.owner_id == user.id:
+ is_org_member = True
+ elif not user.is_anonymous:
+ membership = user.organisationmembership_set \
+ .filter(organisation__id=entity_org.id)
+
+ if membership.exists():
+ membership = membership.first()
+
+ is_org_member = True
+ is_editable = membership.role >= ORGANISATION_ROLES.EDITOR
+
+ if brand_org_managed:
+ brand_authority = OrganisationAuthority.objects \
+ .filter(organisation_id=entity_org.id, brand_id=brand.id)
+
+ if brand_authority.exists():
+ brand_authority = brand_authority.first()
+
+ if brand_authority.can_moderate:
+ user_is_moderator = user_is_moderator or membership.role >= ORGANISATION_ROLES.MODERATOR
if user_is_moderator and historical_entity.publish_status is not None:
is_moderatable = historical_entity.publish_status in [APPROVAL_STATUS.REQUESTED, APPROVAL_STATUS.PENDING, APPROVAL_STATUS.REJECTED]
- is_world_accessible = live_entity.world_access == WORLD_ACCESS_PERMISSIONS.VIEW
-
is_viewable = brand_accessible and (
user_is_admin
or is_published
or is_moderatable
- or is_world_accessible
- or (is_owner or is_group_member or is_world_accessible)
+ or is_org_accessible
+ or (is_owner or is_org_member)
)
else:
is_viewable = brand_accessible and is_published
@@ -990,11 +1560,13 @@ def get_accessible_detail_entity(request, entity_id, entity_history_id=None):
'edit_access': is_editable,
'view_access': is_viewable,
'is_published': is_published,
- }
+ }, None
-def can_user_view_concept(request,
- historical_concept,
- group_permissions=[GROUP_PERMISSIONS.VIEW, GROUP_PERMISSIONS.EDIT]):
+def can_user_view_concept(
+ request,
+ historical_concept,
+ min_group_permission=ORGANISATION_ROLES.MEMBER
+):
"""
Checks whether a user has the permissions to view a concept
@@ -1023,13 +1595,6 @@ def can_user_view_concept(request,
if historical_concept.owner == user:
return True
- if has_member_access(user, historical_concept, [GROUP_PERMISSIONS.VIEW, GROUP_PERMISSIONS.EDIT]):
- return True
-
- if user and not user.is_anonymous:
- if historical_concept.world_access == WORLD_ACCESS_PERMISSIONS.VIEW:
- return True
-
# Check associated phenotypes
concept = getattr(historical_concept, 'instance')
if not concept:
@@ -1079,49 +1644,63 @@ def can_user_view_concept(request,
]
)
else:
- group_access = [x.value for x in group_permissions]
- sql = '''
- select *
+ brand = model_utils.try_get_brand(request)
+
+ org_view_clause = ''
+ if brand is not None and brand.org_user_managed:
+ user_orgs = get_user_organisations(
+ request, min_role_permission=ORGANISATION_ROLES.MEMBER
+ )
+ if user_orgs and len(user_orgs) >= 1:
+ org_view_clause = f'''
+ or live.world_access = {WORLD_ACCESS_PERMISSIONS.VIEW.value}
+ '''
+
+ sql = f'''
+ select distinct results.concept_id, results.concept_version_id
from (
select distinct on (id)
+ entity.id,
+ entity.publish_status,
cast(concepts->>'concept_id' as integer) as concept_id,
cast(concepts->>'concept_version_id' as integer) as concept_version_id
from public.clinicalcode_historicalgenericentity as entity,
json_array_elements(entity.template_data::json->'concept_information') as concepts
where
(
- cast(concepts->>'concept_id' as integer) = %s
- and cast(concepts->>'concept_version_id' as integer) = %s
+ cast(concepts->>'concept_id' as integer) = %(concept_id)s
+ and cast(concepts->>'concept_version_id' as integer) = %(concept_version_id)s
)
and not exists (
select *
from public.clinicalcode_genericentity as ge
where ge.is_deleted = true and ge.id = entity.id
)
- and (
- entity.publish_status = %s
- or (
- exists (
- select 1
- from public.auth_user_groups as t
- where t.user_id = %s and t.group_id = entity.group_id
- )
- and entity.group_access in %s
- )
- or entity.owner_id = %s
- or entity.world_access = %s
- )
) results
+ join public.clinicalcode_genericentity as live
+ on live.id = results.id
+ left join public.clinicalcode_organisation as org
+ on org.id = live.organisation_id
+ left join public.clinicalcode_organisationmembership as mem
+ on mem.organisation_id = org.id
+ where (
+ results.publish_status = %(publish_status)s
+ or org.owner_id = %(user_id)s
+ or (mem.user_id = %(user_id)s and mem.role >= %(role_enum)s)
+ or live.owner_id = %(user_id)s
+ {org_view_clause}
+ )
limit 1
'''
cursor.execute(
sql,
- params=[
- historical_concept.id, historical_concept.history_id,
- APPROVAL_STATUS.APPROVED.value,
- user.id, tuple(group_access),
- user.id, WORLD_ACCESS_PERMISSIONS.VIEW.value
- ]
+ params={
+ 'concept_id': historical_concept.id,
+ 'concept_version_id': historical_concept.history_id,
+ 'publish_status': APPROVAL_STATUS.APPROVED.value,
+ 'user_id': user.id,
+ 'role_enum': min_group_permission
+ }
)
row = cursor.fetchone()
@@ -1139,6 +1718,25 @@ def check_brand_access(request, is_published, entity_id, entity_history_id=None)
return is_brand_accessible(request, entity_id, entity_history_id)
return True
+def can_user_create_entities(request):
+ """
+ Checks whether a user can create entities with its request's Brand context
+
+ Args:
+ request (RequestContext): the HTTPRequest
+
+ Returns:
+ A boolean value reflecting whether the user is able to create entities
+ """
+ current_brand = model_utils.try_get_brand(request)
+ if current_brand is None or not current_brand.org_user_managed:
+ return True
+
+ user_orgs = get_user_organisations(request)
+ if len(user_orgs) < 1:
+ return False
+ return True
+
def can_user_edit_entity(request, entity_id, entity_history_id=None):
"""
Checks whether a user has the permissions to modify an entity
@@ -1172,7 +1770,7 @@ def can_user_edit_entity(request, entity_id, entity_history_id=None):
if live_entity.owner == user or live_entity.created_by == user:
is_allowed_to_edit = True
- if has_member_access(user, live_entity, [GROUP_PERMISSIONS.EDIT]):
+ if has_member_access(user, live_entity, ORGANISATION_ROLES.EDITOR):
is_allowed_to_edit = True
if is_allowed_to_edit:
@@ -1215,7 +1813,7 @@ def has_derived_edit_access(request, entity_id, entity_history_id=None):
if user.is_superuser or live_entity.owner == user or live_entity.created_by == user:
return False
- elif has_member_access(user, live_entity, [GROUP_PERMISSIONS.EDIT]):
+ elif has_member_access(user, live_entity, ORGANISATION_ROLES.EDITOR):
return True
return False
@@ -1307,13 +1905,19 @@ def user_has_concept_ownership(user, concept):
Returns:
(boolean) that reflects whether the user has top-level access
"""
+ if user.is_superuser:
+ return True
+
if user is None or concept is None:
return False
if concept.owner == user:
return True
- return user.is_superuser or has_member_access(user, concept, [GROUP_PERMISSIONS.EDIT])
+ owner = concept.phenotype_owner
+ if owner is None:
+ return False
+ return has_member_access(user, owner, ORGANISATION_ROLES.EDITOR)
def validate_access_to_view(request, entity_id, entity_history_id=None):
"""
@@ -1344,7 +1948,7 @@ def is_brand_accessible(request, entity_id, entity_history_id=None):
Returns:
A (boolean) that reflects its accessibility to the request context
"""
- entity = model_utils.try_get_instance(GenericEntity, id=entity_id)
+ entity = model_utils.try_get_instance(GenericEntity, pk=entity_id)
if entity is None:
return False
@@ -1380,17 +1984,6 @@ def allowed_to_permit(user, entity_id):
return GenericEntity.objects.filter(Q(id=entity_id), Q(owner=user)).exists()
-class HasAccessToViewGenericEntityCheckMixin(object):
- """
- Mixin to check if user has view access to a working set
- this mixin is used within class based views and can be overridden
- """
- def dispatch(self, request, *args, **kwargs):
- if not can_user_view_entity(request, self.kwargs['pk']):
- raise PermissionDenied
-
- return super(HasAccessToViewGenericEntityCheckMixin, self).dispatch(request, *args, **kwargs)
-
def get_latest_entity_published(entity_id):
"""
Gets latest published entity given an entity id
@@ -1404,77 +1997,21 @@ def get_latest_entity_published(entity_id):
entity = entity.first()
return entity
-def get_latest_entity_historical_id(entity_id, user):
- """
- Gets the latest entity history id for a given entity
- and user, given the user has the permissions to access that
- particular entity
- """
- entity = model_utils.try_get_instance(GenericEntity, id=entity_id)
-
- if entity:
- if user.is_superuser:
- return int(entity.history.latest().history_id)
- if user and not user.is_anonymous:
- history = entity.history.filter(
- Q(owner=user.id) |
- Q(
- group_id__in=user.groups.all(),
- group_access__in=[
- GROUP_PERMISSIONS.VIEW, GROUP_PERMISSIONS.EDIT]
- ) |
- Q(
- world_access=WORLD_ACCESS_PERMISSIONS.VIEW
- )
- ) \
- .order_by('-history_id')
-
- if history.exists():
- return history.first().history_id
-
- published = get_latest_entity_published(entity.id)
- if published:
- return published.history_id
-
- return None
-
-def get_latest_concept_historical_id(concept_id, user):
- """
- Gets the latest concept history id for a given concept
- and user, given the user has the permissions to access that
- particular concept
- """
- concept = model_utils.try_get_instance(Concept, pk=concept_id)
+'''Permission Mixins'''
+class HasAccessToViewGenericEntityCheckMixin(object):
+ """
+ Mixin to check if user has view access to a working set
+ this mixin is used within class based views and can be overridden
+ """
+ def dispatch(self, request, *args, **kwargs):
+ if not can_user_view_entity(request, self.kwargs['pk']):
+ raise PermissionDenied
+
+ return super(HasAccessToViewGenericEntityCheckMixin, self).dispatch(request, *args, **kwargs)
- if concept:
- if user.is_superuser:
- return int(concept.history.latest().history_id)
- if user and not user.is_anonymous:
- history = concept.history.filter(
- Q(owner=user.id) |
- Q(
- group_id__in=user.groups.all(),
- group_access__in=[
- GROUP_PERMISSIONS.VIEW, GROUP_PERMISSIONS.EDIT]
- ) |
- Q(
- world_access=WORLD_ACCESS_PERMISSIONS.VIEW
- )
- ) \
- .order_by('-history_id')
-
- if history.exists():
- return history.first().history_id
-
- published = get_latest_publicly_accessible_concept(concept_id)
- if published:
- return published.history_id
-
- return None
-
-""" Legacy methods that require clenaup """
+'''Legacy methods that require cleanup'''
def get_publish_approval_status(set_class, set_id, set_history_id):
"""
[!] Note: Legacy method from ./permissions.py
@@ -1515,59 +2052,6 @@ def check_if_published(set_class, set_id, set_history_id):
return False
-def get_latest_published_version(set_class, set_id):
- """
- [!] Note: Legacy method from ./permissions.py
-
- Updated to only check GenericEntity since Phenotype/WorkingSet
- no longer exists in the current application
-
- Get latest published version
- """
-
- latest_published_version = None
- if set_class == GenericEntity:
- latest_published_version = PublishedGenericEntity.objects.filter(
- entity_id=set_id,
- approval_status=2
- ) \
- .order_by('-entity_history_id') \
- .first()
-
- if latest_published_version is not None:
- return latest_published_version.entity_history_id
-
- return latest_published_version
-
-def try_get_valid_history_id(request, set_class, set_id):
- """
- [!] Note: Legacy method from ./permissions.py
-
- Tries to resolve a valid history id for an entity query.
- If the entity is accessible (i.e. validate_access_to_view() is TRUE),
- then return the most recent version if the user is authenticated,
- Otherwise, this method will return the most recently published version, if available.
-
- Args:
- request (RequestContext): the request
- set_class (str): a model
- set_id (str): the id of the entity
-
- Returns:
- int representing history_id
- """
- set_history_id = None
- is_authenticated = request.user.is_authenticated
-
- if is_authenticated:
- set_history_id = int(set_class.objects.get(pk=set_id).history.latest().history_id)
-
- if not set_history_id:
- latest_published_version_id = get_latest_published_version(set_class, set_id)
- if latest_published_version_id:
- set_history_id = latest_published_version_id
-
- return set_history_id
def allowed_to_edit(request, set_class, set_id, user=None):
"""
diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py
index 1a29a0296..6b25b5e7d 100644
--- a/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py
+++ b/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py
@@ -1,15 +1,18 @@
from django.urls import reverse
+from django.contrib.auth import get_user_model
+from clinicalcode.entity_utils import model_utils
import re
-from django.contrib.auth.models import User
-from django.urls import reverse, reverse_lazy
-from django.template.loader import render_to_string
+
from clinicalcode.tasks import send_review_email
from clinicalcode.entity_utils import constants, permission_utils, entity_db_utils
-
from clinicalcode.models.Concept import Concept
+from clinicalcode.models.Organisation import Organisation, OrganisationMembership
from clinicalcode.models.GenericEntity import GenericEntity
from clinicalcode.models.PublishedGenericEntity import PublishedGenericEntity
+from clinicalcode.templatetags.entity_renderer import get_template_entity_name
+
+User = get_user_model()
def form_validation(request, data, entity_history_id, pk, entity,checks):
"""
@@ -131,8 +134,11 @@ 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)
+
checks = {
'entity_type': entity_class,
+ 'branded_entity_cls': get_template_entity_name(entity.template.entity_class, entity.template),
'name': entity_ver.name,
'errors': errors or None,
'allowed_to_publish': allow_to_publish,
@@ -148,9 +154,59 @@ 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
- }
+ }
+
+ checks |= organisation_checks
return checks
+def check_organisation_authorities(request, entity):
+ organisation_checks = {}
+
+ try:
+ organisation = entity.organisation
+ except Organisation.DoesNotExist:
+ organisation = None
+
+ if organisation:
+ organisation_permissions = permission_utils.has_org_authority(request,organisation)
+ organisation_user_role = permission_utils.get_organisation_role(request.user,organisation)
+ else:
+ brand = model_utils.try_get_brand(request)
+ if brand is not None:
+ organisation_checks['org_user_managed'] = brand.org_user_managed
+ organisation_checks['allowed_to_publish'] = not brand.org_user_managed
+ return organisation_checks
+
+ if isinstance(organisation_permissions,dict) and organisation_user_role is not None:
+ 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_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:
+ if permission_utils.is_org_managed(request):
+ organisation_checks["allowed_to_publish"] = False
+ return 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
@@ -274,38 +330,62 @@ def format_message_and_send_email(request, pk, data, entity, entity_history_id,
Format the message, send an email, and update data with the new message
"""
data['message'] = message_template.format(
- entity_type=checks['entity_type'],
+ entity_type=checks.get('branded_entity_cls'),
url=reverse('entity_history_detail', args=(pk, entity_history_id)),
pk=pk,
history=entity_history_id
)
- send_email_decision_entity(request,entity, entity_history_id, checks['entity_type'], data)
+ send_email_decision_entity(request,entity, entity_history_id, checks, data)
return data
def get_emails_by_groupname(groupname):
user_list = User.objects.filter(groups__name=groupname)
return [i.email for i in user_list]
-def send_email_decision_entity(request, entity, entity_history_id, entity_type,data):
+def get_emails_by_organization(request,entity_id=None):
+ organisation = permission_utils.get_organisation(request,entity_id=entity_id)
+
+ if organisation:
+ user_list = OrganisationMembership.objects.filter(organisation_id=organisation.id)
+ email_list = []
+ for membership in user_list:
+
+ if membership.role >= 2:
+ email_list.append(membership.user.email)
+
+ return email_list
+ else:
+ return None
+
+
+
+def send_email_decision_entity(request, entity, entity_history_id, checks,data):
"""
Call util function to send email decision
@param workingset: workingset object
@param approved: approved status flag
"""
url_redirect = reverse('entity_history_detail', kwargs={'pk': entity.id, 'history_id': entity_history_id})
- context = {"id":entity.id,"history_id":entity_history_id, "entity_name":data['entity_name_requested'], "entity_user_id": entity.owner_id,"url_redirect":url_redirect}
+
+ requested_userid = entity.created_by.id
+
+
+ context = {"id":entity.id,"history_id":entity_history_id, "entity_name":data['entity_name_requested'], "entity_user_id":requested_userid,"url_redirect":url_redirect}
if data['approval_status'].value == constants.APPROVAL_STATUS.PENDING:
context["status"] = "Pending"
- context["message"] = "Your Phenotype has been submitted and is under review"
+ context["message"] = "Your work has been submitted and is under review"
context["staff_emails"] = get_emails_by_groupname("Moderators")
+ if checks.get('org_user_managed',False):
+ context['staff_emails'] = get_emails_by_organization(request,entity.entity_id)
+
send_review_email(request, context)
elif data['approval_status'].value == constants.APPROVAL_STATUS.APPROVED:
# This line for the case when user want to get notification of same workingset id but different version
context["status"] = "Published"
- context["message"] = "Your Phenotype has been approved and successfully published"
+ context["message"] = "The work you submitted has been approved and successfully published"
send_review_email(request, context)
elif data['approval_status'].value == constants.APPROVAL_STATUS.REJECTED:
context["status"] = "Rejected"
- context["message"] = "Your Phenotype submission has been rejected by the moderator"
- context["custom_message"] = "We welcome you to try again but please address these concerns with your Phenotype first" #TODO add custom message logic
+ context["message"] = "The work you submitted has been rejected by the moderator"
+ context["custom_message"] = "We welcome you to try again but please address these concerns with your work first" #TODO add custom message logic
send_review_email(request, context)
diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/sanitise_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/sanitise_utils.py
index 1f4264743..1d3deeba2 100644
--- a/CodeListLibrary_project/clinicalcode/entity_utils/sanitise_utils.py
+++ b/CodeListLibrary_project/clinicalcode/entity_utils/sanitise_utils.py
@@ -1,6 +1,8 @@
from functools import partial
from django.conf import settings
+from html_to_markdown import convert_to_markdown
+import re
import bleach
import logging
import markdown
@@ -22,6 +24,10 @@
'strip_comments': True
}
+def nl_transform(match):
+ m = match.group(0)
+ return m + '\n'
+
def sanitise_markdown_html(text):
"""
Parses markdown as html and then sanitises it before reinterpreting it
@@ -45,6 +51,9 @@ def sanitise_markdown_html(text):
if len(text) < 1 or text.isspace():
return text
+ # text = re.sub(r'(^[^>\n].+[^\|\s])\n(?!\n)', nl_transform, text, flags=re.MULTILINE | re.IGNORECASE)
+ text = text.strip()
+
markdown_settings = settings.MARKDOWNIFY.get('default')
whitelist_tags = markdown_settings.get('WHITELIST_TAGS', bleach.sanitizer.ALLOWED_TAGS)
@@ -70,21 +79,36 @@ def sanitise_markdown_html(text):
parse_email=linkify_parse_email
)]
- html = markdown.markdown(text, extensions=extensions, extension_configs=extension_configs)
+ html = markdown.markdown(text, extensions=extensions, extension_configs=extension_configs, output_format='html', tab_length=2)
css_sanitizer = bleach.css_sanitizer.CSSSanitizer(allowed_css_properties=whitelist_styles)
- cleaner = bleach.Cleaner(tags=whitelist_tags,
+ cleaner = bleach.Cleaner(
+ tags=whitelist_tags,
attributes=whitelist_attrs,
css_sanitizer=css_sanitizer,
protocols=whitelist_protocols,
- strip=strip,
filters=linkify,
+ strip=True
)
# See [!note] above for why we're doing this...
text = cleaner.clean(html)
if isinstance(text, str) and len(text) > 0 and not text.isspace():
- text = pyhtml2md.convert(text)
+ # options = pyhtml2md.Options()
+ # options.splitLines = False
+
+ # converter = pyhtml2md.Converter(text, options)
+ # text = converter.convert()
+ text = convert_to_markdown(
+ html,
+ heading_style="atx",
+ strong_em_symbol="*",
+ bullets="-",
+ wrap=False,
+ escape_asterisks=False,
+ escape_underscores=False,
+ autolinks=False
+ )
else:
text = ''
diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/search_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/search_utils.py
index e2c3f8d83..02d069851 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
@@ -99,7 +101,7 @@ def get_metadata_filters(request):
options = get_metadata_stats_by_field(field, brand=current_brand)
if options is None and 'source' in validation:
- options = get_source_references(packet, default=[])
+ options = get_source_references(packet, default=[], request=request)
filters.append({
'details': details,
@@ -240,21 +242,24 @@ 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
else:
return queryset if len(queryset) > 0 else default
- elif 'options' in validation:
+ elif 'options' in validation and not validation.get('ugc', False):
options = validation['options']
cleaned = [ ]
for item in data:
@@ -285,7 +290,7 @@ def apply_param_to_query(query, where, params, template, param, data,
return False
if field_type == 'int' or field_type == 'enum':
- if 'options' in validation or 'source' in validation:
+ if not validation.get('ugc', False) and ('source' in validation or 'options' in validation):
data = [int(x) for x in data.split(',') if gen_utils.parse_int(x, default=None) is not None]
clean = validate_query_param(param, template_data, data, default=None, request=request)
if clean is None and force_term:
@@ -518,17 +523,14 @@ def try_search_child_concepts(entities, search=None, order_clause=None):
cast(regexp_replace(id, '[a-zA-Z]+', '') as integer) as true_id,
ts_rank_cd(
hge.search_vector,
- websearch_to_tsquery('pg_catalog.english', %(searchterm)s)
+ to_tsquery('pg_catalog.english', replace(to_tsquery('pg_catalog.english', concat(regexp_replace(trim(%(searchterm)s), '\W+', ':* & ', 'gm'), ':*'))::text, '<->', '|'))
) as score
from public.clinicalcode_historicalgenericentity as hge
where id = ANY(%(entity_ids)s)
and history_id = ANY(%(history_ids)s)
and hge.search_vector @@ to_tsquery(
'pg_catalog.english',
- replace(
- websearch_to_tsquery('pg_catalog.english', %(searchterm)s)::text || ':*',
- '<->', '|'
- )
+ replace(to_tsquery('pg_catalog.english', concat(regexp_replace(trim(%(searchterm)s), '\W+', ':* & ', 'gm'), ':*'))::text, '<->', '|')
)
{0}
),
@@ -888,12 +890,12 @@ def get_renderable_entities(request, entity_types=None, method='GET', force_term
history_ids = list(entities.values_list('history_id', flat=True))
entities = GenericEntity.history.extra(
- select={ 'score': '''ts_rank_cd("clinicalcode_historicalgenericentity"."search_vector", websearch_to_tsquery('pg_catalog.english', %s))'''},
+ select={ 'score': '''ts_rank_cd("clinicalcode_historicalgenericentity"."search_vector", to_tsquery('pg_catalog.english', replace(to_tsquery('pg_catalog.english', concat(regexp_replace(trim(%s), '\W+', ':* & ', 'gm'), ':*'))::text, '<->', '|')))'''},
select_params=[search],
where=[
'''"clinicalcode_historicalgenericentity"."id" = ANY(%s)''',
'''"clinicalcode_historicalgenericentity"."history_id" = ANY(%s)''',
- '''"clinicalcode_historicalgenericentity"."search_vector" @@ to_tsquery('pg_catalog.english', replace(websearch_to_tsquery('pg_catalog.english', %s)::text || ':*', '<->', '|'))'''
+ '''"clinicalcode_historicalgenericentity"."search_vector" @@ to_tsquery('pg_catalog.english', replace(to_tsquery('pg_catalog.english', concat(regexp_replace(trim(%s), '\W+', ':* & ', 'gm'), ':*'))::text, '<->', '|'))'''
],
params=[entity_ids, history_ids, search]
)
@@ -937,7 +939,7 @@ def try_get_paginated_results(request, entities, page=None, page_size=None):
page_obj = pagination.page(pagination.num_pages)
return page_obj
-def get_source_references(struct, default=None, modifier=None):
+def get_source_references(struct, default=None, modifier=None, request=None):
"""
Retrieves the refence values from source fields e.g. tags, collections, entity type
"""
@@ -957,7 +959,14 @@ def get_source_references(struct, default=None, modifier=None):
try:
model = apps.get_model(app_label='clinicalcode', model_name=source)
- objs = model.objects.all()
+ objs = None
+ if request is not None:
+ recfn = getattr(model, 'get_brand_records_by_request') if hasattr(model, 'get_brand_records_by_request') else None
+ if callable(recfn):
+ objs = model.get_brand_records_by_request(request)
+
+ if objs is None:
+ objs = model.objects.all()
if isinstance(modifier, dict):
objs = objs.filter(**modifier)
diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/stats_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/stats_utils.py
index 070d7facf..0ef5c9158 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):
@@ -104,7 +124,8 @@ def build_statistics(statistics, entity, field, struct, data_cache=None, templat
stats[entity_field]['count'] += 1
elif field_type == 'int_array':
- if 'source' in validation:
+ src_field = validation.get('source')
+ if isinstance(src_field, dict) and not src_field.get('trees'):
for item in entity_field:
value = try_get_cached_data(data_cache, entity, template_entity, field, item, validation, struct, brand=brand)
if value is None:
@@ -161,7 +182,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 +219,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 +238,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 +268,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 +283,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():
@@ -332,14 +363,6 @@ def get_homepage_stats(request, brand=None):
if brand is None:
brand = request.CURRENT_BRAND if request.CURRENT_BRAND is not None and request.CURRENT_BRAND != '' else 'ALL'
-
- collection_ids = [ ]
- if brand == 'ALL':
- collection_ids = Tag.objects.filter(tag_type=2)
- collection_ids = [str(i) for i in collection_ids]
- else:
- collection_ids = model_utils.get_brand_collection_ids(brand)
- collection_ids = [str(i) for i in collection_ids]
published_phenotypes = entity_db_utils.get_visible_live_or_published_generic_entity_versions(
request,
diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/template_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/template_utils.py
index dfdd0aa6c..3a353378f 100644
--- a/CodeListLibrary_project/clinicalcode/entity_utils/template_utils.py
+++ b/CodeListLibrary_project/clinicalcode/entity_utils/template_utils.py
@@ -1,11 +1,19 @@
+from operator import and_
+from functools import reduce
from django.apps import apps
from django.db.models import Q, ForeignKey
+import copy
+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):
"""
Attempts to get content within a dict by a key, if it fails to do so, returns the default value
@@ -37,6 +45,15 @@ def is_metadata(entity, field):
data = model._meta.get_field(field)
return True
except:
+ pass
+
+ try:
+ template_field = get_layout_field(entity.template, field) if entity.template is not None else None
+ if template_field is not None and template_field.get('is_base_field', False):
+ return True
+ except:
+ pass
+ finally:
return False
@@ -84,14 +101,47 @@ def get_layout_field(layout, field, default=None):
"""
Safely gets a field from a layout's field within its definition
"""
+ result = None
if is_layout_safe(layout):
definition = try_get_content(layout, 'definition') if isinstance(layout, dict) else getattr(layout,
'definition')
fields = try_get_content(definition, 'fields')
if fields is not None:
- return try_get_content(fields, field, default)
+ result = try_get_content(fields, field, default)
+
+ if result is None:
+ result = try_get_content(layout, field, default)
+
+ if isinstance(result, dict) and result.get('is_base_field'):
+ merged = copy.deepcopy(constants.metadata.get(field))
+ merged = merged | result
+ return merged
+ return result
+
+
+def get_template_field_info(layout, field_name, copy_field=True):
+ """"""
+ fields = get_layout_fields(layout)
+ if fields:
+ field = fields.get(field_name)
+ if isinstance(field, dict):
+ result = { 'key': field_name, 'field': field, 'is_metadata': False }
+ if not field.get('is_base_field'):
+ return result
+
+ merged = copy.deepcopy(constants.metadata.get(field_name)) if copy_field else constants.metadata.get(field_name)
+ merged = merged | field
+
+ result |= {
+ 'field': merged,
+ 'shunt': field.get('shunt') if isinstance(field.get('shunt'), str) else None,
+ 'is_metadata': True,
+ }
- return try_get_content(layout, field, default)
+ return result
+
+ field = constants.metadata.get(field_name, None)
+ return { 'key': field_name, 'field': field, 'is_metadata': True }
def get_merged_definition(template, default=None):
@@ -105,7 +155,9 @@ def get_merged_definition(template, default=None):
return default
fields = {field: packet for field, packet in constants.metadata.items() if not packet.get('ignore')}
- fields.update(definition.get('fields') or {})
+ for k, v in definition.get('fields', {}).items():
+ fields.update({ k: copy.deepcopy(fields.get(k, {})) | v })
+
fields = {
field: packet
for field, packet in fields.items()
@@ -286,9 +338,13 @@ def is_valid_field(entity, field):
if is_metadata(entity, field):
return True
- template = entity.template
- if template is None:
+ try:
+ template = entity.template
+ except:
return False
+ else:
+ if template is None:
+ return False
if get_layout_field(template, field):
return True
@@ -373,13 +429,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,20 +456,21 @@ 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
-def get_metadata_value_from_source(entity, field, default=None, request=None):
+def get_metadata_value_from_source(entity, field, field_info=None, layout=None, default=None, request=None):
"""
[!] Note: RequestContext is an optional parameter that can be provided to further filter
the results based on the request's Brand
@@ -423,14 +480,32 @@ def get_metadata_value_from_source(entity, field, default=None, request=None):
to another table
"""
try:
- data = getattr(entity, field)
- if field in constants.metadata:
- validation = get_field_item(constants.metadata, field, 'validation', {})
+ info = None
+ if field_info is not None:
+ info = field_info
+ elif layout is not None:
+ info = get_template_field_info(layout, field)
+
+ if info:
+ if not info.get('is_metadata'):
+ return default
+ else:
+ info = info.get('field')
+
+ if info is None:
+ info = constants.metadata.get(field)
+
+ if info is not None:
+ data = get_entity_field(entity, field)
+ validation = info.get('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,47 +514,44 @@ 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):
+def get_template_sourced_values(template, field, default=None, request=None, struct=None):
"""
[!] Note: RequestContext is an optional parameter that can be provided to further filter
the results based on the request's Brand
Returns the complete option list of an enum or a sourced field
"""
- struct = get_layout_field(template, field)
+ if struct is None:
+ struct = get_layout_field(template, field)
+
if struct is None:
return default
@@ -490,9 +562,16 @@ def get_template_sourced_values(template, field, default=None, request=None):
if 'options' in validation:
output = []
for i, v in validation.get('options').items():
+ if isinstance(v, dict):
+ ref = i
+ val = v
+ else:
+ ref = v
+ val = i
+
output.append({
- 'name': v,
- 'value': i
+ 'name': ref,
+ 'value': val,
})
return output
@@ -513,8 +592,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,28 +601,43 @@ 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:
relative = source_info.get('relative')
+ include = source_info.get('include')
+ if isinstance(include, list):
+ include = [x.strip() for x in include if isinstance(x, str) and len(x.strip()) > 0 and not x.strip().isspace()]
+ include = include if len(include) > 0 else None
+ else:
+ include = None
+
output = []
for instance in queryset:
- output.append({
- 'name': getattr(instance, relative),
- 'value': getattr(instance, column)
- })
+ item = {
+ 'name': getattr(instance, relative),
+ 'value': getattr(instance, column),
+ }
+
+ if include is not None:
+ item.update({ k: getattr(instance, k) for k in include if hasattr(instance, k) })
+ output.append(item)
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 +686,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 +705,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 +728,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):
@@ -678,7 +780,7 @@ def get_template_data_values(entity, layout, field, hide_user_details=False, req
if field_type == 'enum' or field_type == 'int':
output = None
- if 'options' in validation:
+ if 'options' in validation and not validation.get('ugc', False):
output = get_detailed_options_value(data, info)
elif 'source' in validation:
output = get_detailed_sourced_value(data, info)
@@ -686,32 +788,35 @@ def get_template_data_values(entity, layout, field, hide_user_details=False, req
return [output]
elif field_type == 'int_array':
source_info = validation.get('source')
- if not source_info:
- return default
-
- model_name = source_info.get('table')
- tree_models = source_info.get('trees')
-
- if isinstance(tree_models, list):
- model_source = source_info.get('model')
- if isinstance(model_source, str):
- try:
- model = apps.get_model(app_label='clinicalcode', model_name=model_source)
- output = model.get_detailed_source_value(data, tree_models, default=default)
- if isinstance(output, list):
- return output
- except Exception as e:
- # Logging
- pass
- elif isinstance(model_name, str):
- values = []
- for item in data:
- value = get_detailed_sourced_value(item, info)
- if value is None:
- continue
+ options = validation.get('options')
+ if source_info:
+ model_name = source_info.get('table')
+ tree_models = source_info.get('trees')
+
+ if isinstance(tree_models, list):
+ model_source = source_info.get('model')
+ if isinstance(model_source, str):
+ try:
+ model = apps.get_model(app_label='clinicalcode', model_name=model_source)
+ output = model.get_detailed_source_value(data, tree_models, default=default)
+ if isinstance(output, list):
+ return output
+ except Exception as e:
+ 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:
+ value = get_detailed_sourced_value(item, info)
+ if value is None:
+ continue
- values.append(value)
- return values
+ values.append(value)
+ return values
+ elif isinstance(options, dict) and isinstance(data, list):
+ values = [{ 'name': options.get(x), 'value': x } for x in data if isinstance(options.get(x), str)]
+ return values if len(values) > 0 else default
+ else:
+ return default
elif field_type == 'concept':
values = []
for item in data:
diff --git a/CodeListLibrary_project/clinicalcode/forms/ArchiveForm.py b/CodeListLibrary_project/clinicalcode/forms/ArchiveForm.py
index 40ffd40bf..22b988f0f 100644
--- a/CodeListLibrary_project/clinicalcode/forms/ArchiveForm.py
+++ b/CodeListLibrary_project/clinicalcode/forms/ArchiveForm.py
@@ -25,7 +25,7 @@ class ArchiveForm(forms.Form):
widget=forms.TextInput(
attrs={
'class': 'text-input',
- 'aria-label': 'Enter the Phenotype ID to confirm',
+ 'aria-label': 'Enter the entity ID to confirm',
'autocomplete': 'off',
'autocorrect': 'off',
}
@@ -38,7 +38,7 @@ class ArchiveForm(forms.Form):
attrs={
'class': 'text-area-input',
'style': 'resize: none;',
- 'aria-label': 'Enter the reason you\'re archiving this Phenotype',
+ 'aria-label': 'Enter the reason you\'re archiving this entity',
'rows': '4',
'autocomplete': 'off',
'autocorrect': 'on',
diff --git a/CodeListLibrary_project/clinicalcode/forms/EntityClassForm.py b/CodeListLibrary_project/clinicalcode/forms/EntityClassForm.py
index dca33c1fb..e6d959c27 100644
--- a/CodeListLibrary_project/clinicalcode/forms/EntityClassForm.py
+++ b/CodeListLibrary_project/clinicalcode/forms/EntityClassForm.py
@@ -1,8 +1,10 @@
from django import forms
-from django.contrib.auth.models import User
+from django.contrib.auth import get_user_model
from ..models.EntityClass import EntityClass
+User = get_user_model()
+
class EntityAdminForm(forms.ModelForm):
"""
excludes the created_by field so that the request.user who creates/updates this form is set as the
diff --git a/CodeListLibrary_project/clinicalcode/forms/OrganisationForms.py b/CodeListLibrary_project/clinicalcode/forms/OrganisationForms.py
new file mode 100644
index 000000000..f63ea899d
--- /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 import admin
+from django.utils.text import slugify
+from django.contrib.auth import get_user_model
+
+from ..models.Organisation import Organisation, OrganisationMembership, OrganisationAuthority
+from ..entity_utils import gen_utils, permission_utils, model_utils
+
+from django.utils import timezone
+
+User = get_user_model()
+
+""" 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)
+
+ class Meta:
+ model = Organisation
+ fields = '__all__'
+ exclude = ['slug', 'created', 'owner', 'brands', 'members']
+
+ 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/forms/TemplateForm.py b/CodeListLibrary_project/clinicalcode/forms/TemplateForm.py
index 358e8e476..614348f1c 100644
--- a/CodeListLibrary_project/clinicalcode/forms/TemplateForm.py
+++ b/CodeListLibrary_project/clinicalcode/forms/TemplateForm.py
@@ -1,17 +1,7 @@
from django import forms
-import json
-
+from ..entity_utils import gen_utils, template_utils
from ..models.Template import Template
-from ..entity_utils import template_utils
-
-class PrettyPrintOrderedDefinition(json.JSONEncoder):
- """
- Indents and prettyprints the definition field so that it's readable
- Preserves order that was given by template_utils.get_ordered_definition
- """
- def __init__(self, *args, indent, sort_keys, **kwargs):
- super().__init__(*args, indent=2, sort_keys=False, **kwargs)
class TemplateAdminForm(forms.ModelForm):
"""
@@ -23,7 +13,7 @@ class TemplateAdminForm(forms.ModelForm):
name = forms.CharField(widget=forms.TextInput(attrs={'readonly': 'readonly'}), required=False)
description = forms.CharField(widget=forms.Textarea(attrs={'readonly': 'readonly'}), required=False)
template_version = forms.IntegerField(widget=forms.NumberInput(attrs={'readonly':'readonly'}))
- definition = forms.JSONField(encoder=PrettyPrintOrderedDefinition)
+ definition = forms.JSONField(encoder=gen_utils.PrettyPrintOrderedDefinition)
def __init__(self, *args, **kwargs):
super(TemplateAdminForm, self).__init__(*args, **kwargs)
@@ -52,7 +42,6 @@ def clean(self):
return data
-
class Meta:
model = Template
fields = '__all__'
\ No newline at end of file
diff --git a/CodeListLibrary_project/clinicalcode/middleware/brands.py b/CodeListLibrary_project/clinicalcode/middleware/brands.py
index 6122fc3c9..69a08106b 100644
--- a/CodeListLibrary_project/clinicalcode/middleware/brands.py
+++ b/CodeListLibrary_project/clinicalcode/middleware/brands.py
@@ -1,18 +1,20 @@
+from importlib import import_module
from django.conf import settings
+from django.urls import clear_url_caches, set_urlconf
from django.contrib import auth, messages
-from django.core.exceptions import ImproperlyConfigured, PermissionDenied
from django.shortcuts import redirect
-from django.urls import clear_url_caches, set_urlconf
-from django.utils.deprecation import MiddlewareMixin
from rest_framework.reverse import reverse
-from importlib import import_module
+from django.core.exceptions import ImproperlyConfigured, PermissionDenied
+from django.utils.deprecation import MiddlewareMixin
import os
+import re
import sys
import numbers
import importlib
from clinicalcode.models import Brand
+from clinicalcode.entity_utils import gen_utils
class BrandMiddleware(MiddlewareMixin):
'''
@@ -24,41 +26,26 @@ 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))
- #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."
if request.session.get('read_only_msg', "") == "":
request.session['read_only_msg'] = msg1
messages.error(request, msg1)
-
settings.CLL_READ_ONLY = True
- 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)
- brands = Brand.objects.all()
- brands_list = [x.upper() for x in list(brands.values_list('name', flat=True)) ]
+ #---------------------------------
+ brands_list = Brand.all_names()
+ is_live_hdruk = re.search(r'(phenotypes\.healthdatagateway)|(web\-phenotypes\-hdr)', request.get_host(), flags=re.IGNORECASE)
current_page_url = request.path_info.lstrip('/')
- #print "**** get_host= " , str(request.get_host())
-
request.IS_HDRUK_EXT = "0"
settings.IS_HDRUK_EXT = "0"
root = current_page_url.split('/')[0]
- if (request.get_host().lower().find('phenotypes.healthdatagateway') != -1 or
- request.get_host().lower().find('web-phenotypes-hdr') != -1):
+ if is_live_hdruk:
root = 'HDRUK'
request.IS_HDRUK_EXT = "1"
settings.IS_HDRUK_EXT = "1"
@@ -82,63 +69,38 @@ def process_request(self, request):
urlconf = None
urlconf = settings.ROOT_URLCONF
- request.session['all_brands'] = brands_list #json.dumps(brands_list)
+ request.session['all_brands'] = 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)
settings.CURRENT_BRAND = root
request.CURRENT_BRAND = root
settings.CURRENT_BRAND_WITH_SLASH = "/" + root
request.CURRENT_BRAND_WITH_SLASH = "/" + root
- brand_object = Brand.objects.get(name__iexact=root)
+ brand_object = next((x for x in Brand.all_instances() if x.name.upper() == root.upper()), {})
+
settings.BRAND_OBJECT = brand_object
request.BRAND_OBJECT = brand_object
- if brand_object.site_title is not None:
+
+ has_brand = brand_object is not None and not isinstance(brand_object, dict)
+ if has_brand and brand_object.site_title is not None and not gen_utils.is_empty_string(brand_object.site_title):
request.SWAGGER_TITLE = brand_object.site_title + " API"
settings.SWAGGER_TITLE = brand_object.site_title + " API"
if not current_page_url.strip().endswith('/'):
current_page_url = current_page_url.strip() + '/'
- 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
+ if not is_live_hdruk:
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
+ urlconf = "cll.urls_brand"
+ set_urlconf(urlconf)
+ request.urlconf = urlconf
- # redirect /{brand}/api/ to /{brand}/api/v1/
+ # redirect `/{brand}/api/` to `/{brand}/api/v1/` to appear in URL address bar
if current_page_url.strip().rstrip('/').split('/')[-1].lower() in ['api']:
do_redirect = True
current_page_url = current_page_url.strip().rstrip('/') + '/v1/'
@@ -148,29 +110,14 @@ def process_request(self, request):
importlib.reload(sys.modules[urlconf])
importlib.reload(import_module(urlconf))
importlib.reload(sys.modules["clinicalcode.api.urls"])
- importlib.reload(import_module("clinicalcode.api.urls"))
+ importlib.reload(import_module("clinicalcode.api.urls"))
if settings.DEBUG:
- 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('/', '')))):
+ print(f'Brand Ctx: {settings.CURRENT_BRAND} | Route: {str(request.get_full_path())} | Info: {request.path_info}')
- 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/middleware/exceptions.py b/CodeListLibrary_project/clinicalcode/middleware/exceptions.py
new file mode 100644
index 000000000..c09b3af71
--- /dev/null
+++ b/CodeListLibrary_project/clinicalcode/middleware/exceptions.py
@@ -0,0 +1,26 @@
+from django.conf import settings
+from django.http import HttpResponseServerError
+from django.http.response import JsonResponse
+from django.template.loader import get_template
+
+import logging
+
+class ExceptionMiddleware:
+ def __init__(self, get_response):
+ self.get_response = get_response
+
+ def __call__(self, request):
+ return self.get_response(request)
+
+ def process_exception(self, request, exception):
+ if settings.DEBUG:
+ raise exception
+
+ logging.exception(f'Exception on View with err:\n{str(exception)}')
+
+ if request.accepts('text/html'):
+ response = HttpResponseServerError(get_template('500.html').render(request=request))
+ else:
+ response = JsonResponse({ 'status': 'false', 'message': 'Server Error' }, status=500)
+
+ return response
diff --git a/CodeListLibrary_project/clinicalcode/migrations/0121_organisation_genericentity_organisation_and_more.py b/CodeListLibrary_project/clinicalcode/migrations/0121_organisation_genericentity_organisation_and_more.py
new file mode 100644
index 000000000..26c0c0a6f
--- /dev/null
+++ b/CodeListLibrary_project/clinicalcode/migrations/0121_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', '0120_ontologytag_clinicalcod_type_id_c68d0b_idx_and_more'),
+ 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/0122_brand_org_user_managed_and_more.py b/CodeListLibrary_project/clinicalcode/migrations/0122_brand_org_user_managed_and_more.py
new file mode 100644
index 000000000..07d59c14b
--- /dev/null
+++ b/CodeListLibrary_project/clinicalcode/migrations/0122_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', '0121_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/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/migrations/0124_hdrnsite_brand_site_description_and_more.py b/CodeListLibrary_project/clinicalcode/migrations/0124_hdrnsite_brand_site_description_and_more.py
new file mode 100644
index 000000000..0a22fbfd6
--- /dev/null
+++ b/CodeListLibrary_project/clinicalcode/migrations/0124_hdrnsite_brand_site_description_and_more.py
@@ -0,0 +1,84 @@
+# Generated by Django 5.1.2 on 2025-03-16 18:16
+
+import django.contrib.postgres.fields
+import django.contrib.postgres.indexes
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('clinicalcode', '0123_remove_brand_css_path_remove_brand_groups_and_more'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='HDRNSite',
+ fields=[
+ ('created', models.DateTimeField(auto_now_add=True)),
+ ('modified', models.DateTimeField(auto_now_add=True)),
+ ('id', models.AutoField(primary_key=True, serialize=False)),
+ ('name', models.CharField(max_length=512, unique=True)),
+ ('description', models.TextField(blank=True, null=True)),
+ ('metadata', models.JSONField(blank=True, null=True)),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ migrations.AddField(
+ model_name='brand',
+ name='site_description',
+ field=models.CharField(blank=True, max_length=160, null=True),
+ ),
+ migrations.AlterField(
+ model_name='datasource',
+ name='datasource_id',
+ field=models.IntegerField(null=True),
+ ),
+ migrations.AlterField(
+ model_name='historicaldatasource',
+ name='datasource_id',
+ field=models.IntegerField(null=True),
+ ),
+ migrations.CreateModel(
+ name='HDRNDataCategory',
+ fields=[
+ ('created', models.DateTimeField(auto_now_add=True)),
+ ('modified', models.DateTimeField(auto_now_add=True)),
+ ('id', models.AutoField(primary_key=True, serialize=False)),
+ ('name', models.CharField(max_length=512)),
+ ('description', models.TextField(blank=True, null=True)),
+ ('metadata', models.JSONField(blank=True, null=True)),
+ ],
+ options={
+ 'indexes': [django.contrib.postgres.indexes.GinIndex(fields=['name'], name='hdrn_dcnm_trgm_idx', opclasses=['gin_trgm_ops'])],
+ },
+ ),
+ migrations.CreateModel(
+ name='HDRNDataAsset',
+ fields=[
+ ('created', models.DateTimeField(auto_now_add=True)),
+ ('modified', models.DateTimeField(auto_now_add=True)),
+ ('id', models.AutoField(primary_key=True, serialize=False)),
+ ('name', models.CharField(max_length=512)),
+ ('description', models.TextField(blank=True, null=True)),
+ ('hdrn_id', models.IntegerField(blank=True, null=True)),
+ ('hdrn_uuid', models.UUIDField(blank=True, null=True)),
+ ('link', models.URLField(blank=True, max_length=500, null=True)),
+ ('years', models.CharField(blank=True, max_length=256, null=True)),
+ ('scope', models.TextField(blank=True, null=True)),
+ ('region', models.CharField(blank=True, max_length=2048, null=True)),
+ ('purpose', models.TextField(blank=True, null=True)),
+ ('collection_period', models.TextField(blank=True, null=True)),
+ ('data_level', models.CharField(blank=True, max_length=256, null=True)),
+ ('data_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), blank=True, null=True, size=None)),
+ ('site', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='data_assets', to='clinicalcode.hdrnsite')),
+ ],
+ options={
+ 'ordering': ('name',),
+ 'indexes': [django.contrib.postgres.indexes.GinIndex(fields=['name'], name='hdrn_danm_trgm_idx', opclasses=['gin_trgm_ops']), django.contrib.postgres.indexes.GinIndex(fields=['data_categories'], name='hdrn_dadc_arr_idx', opclasses=['array_ops'])],
+ },
+ ),
+ ]
diff --git a/CodeListLibrary_project/clinicalcode/migrations/0125_brand_is_administrable.py b/CodeListLibrary_project/clinicalcode/migrations/0125_brand_is_administrable.py
new file mode 100644
index 000000000..edb1a0e54
--- /dev/null
+++ b/CodeListLibrary_project/clinicalcode/migrations/0125_brand_is_administrable.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.1.2 on 2025-03-18 11:02
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('clinicalcode', '0124_hdrnsite_brand_site_description_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='brand',
+ name='is_administrable',
+ field=models.BooleanField(default=False),
+ ),
+ ]
diff --git a/CodeListLibrary_project/clinicalcode/migrations/0126_historicaltemplate_brands_template_brands.py b/CodeListLibrary_project/clinicalcode/migrations/0126_historicaltemplate_brands_template_brands.py
new file mode 100644
index 000000000..e8be7d820
--- /dev/null
+++ b/CodeListLibrary_project/clinicalcode/migrations/0126_historicaltemplate_brands_template_brands.py
@@ -0,0 +1,24 @@
+# Generated by Django 5.1.2 on 2025-03-24 14:13
+
+import django.contrib.postgres.fields
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('clinicalcode', '0125_brand_is_administrable'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='historicaltemplate',
+ name='brands',
+ field=django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), blank=True, null=True, size=None),
+ ),
+ migrations.AddField(
+ model_name='template',
+ name='brands',
+ field=django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), blank=True, null=True, size=None),
+ ),
+ ]
diff --git a/CodeListLibrary_project/clinicalcode/migrations/0127_alter_brand_options.py b/CodeListLibrary_project/clinicalcode/migrations/0127_alter_brand_options.py
new file mode 100644
index 000000000..2f30922a6
--- /dev/null
+++ b/CodeListLibrary_project/clinicalcode/migrations/0127_alter_brand_options.py
@@ -0,0 +1,17 @@
+# Generated by Django 5.1.2 on 2025-03-27 11:05
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('clinicalcode', '0126_historicaltemplate_brands_template_brands'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='brand',
+ options={'ordering': ('name',), 'verbose_name': 'Brand', 'verbose_name_plural': 'Brands'},
+ ),
+ ]
diff --git a/CodeListLibrary_project/clinicalcode/migrations/0128_alter_hdrndataasset_options_and_more.py b/CodeListLibrary_project/clinicalcode/migrations/0128_alter_hdrndataasset_options_and_more.py
new file mode 100644
index 000000000..1591dee76
--- /dev/null
+++ b/CodeListLibrary_project/clinicalcode/migrations/0128_alter_hdrndataasset_options_and_more.py
@@ -0,0 +1,41 @@
+# Generated by Django 5.1.2 on 2025-03-28 10:17
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('clinicalcode', '0127_alter_brand_options'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='hdrndataasset',
+ options={'ordering': ('name',), 'verbose_name': 'HDRN Data Asset', 'verbose_name_plural': 'HDRN Data Assets'},
+ ),
+ migrations.AlterModelOptions(
+ name='hdrndatacategory',
+ options={'verbose_name': 'HDRN Data Category', 'verbose_name_plural': 'HDRN Data Categories'},
+ ),
+ migrations.AlterModelOptions(
+ name='hdrnsite',
+ options={'verbose_name': 'HDRN Site', 'verbose_name_plural': 'HDRN Sites'},
+ ),
+ migrations.AlterModelOptions(
+ name='historicaltag',
+ options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical Tag', 'verbose_name_plural': 'historical Tags'},
+ ),
+ migrations.AlterModelOptions(
+ name='historicaltemplate',
+ options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical Template', 'verbose_name_plural': 'historical Templates'},
+ ),
+ migrations.AlterModelOptions(
+ name='tag',
+ options={'ordering': ('description',), 'verbose_name': 'Tag', 'verbose_name_plural': 'Tags'},
+ ),
+ migrations.AlterModelOptions(
+ name='template',
+ options={'ordering': ('name',), 'verbose_name': 'Template', 'verbose_name_plural': 'Templates'},
+ ),
+ ]
diff --git a/CodeListLibrary_project/clinicalcode/migrations/0129_brand_users_alter_brand_admins.py b/CodeListLibrary_project/clinicalcode/migrations/0129_brand_users_alter_brand_admins.py
new file mode 100644
index 000000000..4a36fe513
--- /dev/null
+++ b/CodeListLibrary_project/clinicalcode/migrations/0129_brand_users_alter_brand_admins.py
@@ -0,0 +1,25 @@
+# Generated by Django 5.1.2 on 2025-03-28 14:56
+
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('clinicalcode', '0128_alter_hdrndataasset_options_and_more'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='brand',
+ name='users',
+ field=models.ManyToManyField(blank=True, related_name='accessible_brands', to=settings.AUTH_USER_MODEL),
+ ),
+ migrations.AlterField(
+ model_name='brand',
+ name='admins',
+ field=models.ManyToManyField(blank=True, related_name='administered_brands', to=settings.AUTH_USER_MODEL),
+ ),
+ ]
diff --git a/CodeListLibrary_project/clinicalcode/migrations/0130_hdrnjurisdiction_remove_hdrndataasset_region_and_more.py b/CodeListLibrary_project/clinicalcode/migrations/0130_hdrnjurisdiction_remove_hdrndataasset_region_and_more.py
new file mode 100644
index 000000000..65bd9c529
--- /dev/null
+++ b/CodeListLibrary_project/clinicalcode/migrations/0130_hdrnjurisdiction_remove_hdrndataasset_region_and_more.py
@@ -0,0 +1,76 @@
+# Generated by Django 5.1.8 on 2025-04-20 14:15
+
+import django.contrib.postgres.indexes
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('clinicalcode', '0129_brand_users_alter_brand_admins'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='HDRNJurisdiction',
+ fields=[
+ ('created', models.DateTimeField(auto_now_add=True)),
+ ('modified', models.DateTimeField(auto_now_add=True)),
+ ('id', models.AutoField(primary_key=True, serialize=False)),
+ ('name', models.CharField(max_length=512, unique=True)),
+ ('abbreviation', models.CharField(blank=True, max_length=256, null=True)),
+ ('description', models.TextField(blank=True, null=True)),
+ ('metadata', models.JSONField(blank=True, null=True)),
+ ],
+ options={
+ 'verbose_name': 'HDRN Jurisdiction',
+ 'verbose_name_plural': 'HDRN Jurisdictions',
+ },
+ ),
+ migrations.RemoveField(
+ model_name='hdrndataasset',
+ name='region',
+ ),
+ migrations.AddField(
+ model_name='hdrnsite',
+ name='abbreviation',
+ field=models.CharField(blank=True, max_length=256, null=True),
+ ),
+ migrations.AlterField(
+ model_name='ontologytag',
+ name='type_id',
+ field=models.IntegerField(choices=[('CLINICAL_DISEASE', 0), ('CLINICAL_DOMAIN', 1), ('CLINICAL_FUNCTIONAL_ANATOMY', 2), ('MESH_CODES', 3)]),
+ ),
+ migrations.CreateModel(
+ name='CCI_CODES',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+ ('code', models.CharField(blank=True, max_length=64, null=True)),
+ ('description', models.CharField(blank=True, max_length=255, null=True)),
+ ('location', models.CharField(blank=True, max_length=255, null=True)),
+ ('extent', models.CharField(blank=True, max_length=255, null=True)),
+ ('status', models.CharField(blank=True, max_length=255, null=True)),
+ ('created_date', models.DateTimeField(auto_now_add=True)),
+ ],
+ options={
+ 'indexes': [django.contrib.postgres.indexes.GinIndex(fields=['code'], name='hcci_cd_ln_gin_idx', opclasses=['gin_trgm_ops']), django.contrib.postgres.indexes.GinIndex(fields=['description'], name='hcci_lds_ln_gin_idx', opclasses=['gin_trgm_ops'])],
+ },
+ ),
+ migrations.AddField(
+ model_name='hdrndataasset',
+ name='regions',
+ field=models.ManyToManyField(blank=True, related_name='data_assets', to='clinicalcode.hdrnjurisdiction'),
+ ),
+ migrations.CreateModel(
+ name='PHYSICIAN_FEE_CODES',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+ ('code', models.CharField(blank=True, max_length=64, null=True)),
+ ('description', models.CharField(blank=True, max_length=255, null=True)),
+ ('created_date', models.DateTimeField(auto_now_add=True)),
+ ],
+ options={
+ 'indexes': [django.contrib.postgres.indexes.GinIndex(fields=['code'], name='hpfc_cd_ln_gin_idx', opclasses=['gin_trgm_ops']), django.contrib.postgres.indexes.GinIndex(fields=['description'], name='hpfc_lds_ln_gin_idx', opclasses=['gin_trgm_ops'])],
+ },
+ ),
+ ]
diff --git a/CodeListLibrary_project/clinicalcode/migrations/0131_alter_genericentity_group_access_and_more.py b/CodeListLibrary_project/clinicalcode/migrations/0131_alter_genericentity_group_access_and_more.py
new file mode 100644
index 000000000..24e6dcdba
--- /dev/null
+++ b/CodeListLibrary_project/clinicalcode/migrations/0131_alter_genericentity_group_access_and_more.py
@@ -0,0 +1,43 @@
+# Generated by Django 5.1.8 on 2025-06-16 09:24
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('clinicalcode', '0130_hdrnjurisdiction_remove_hdrndataasset_region_and_more'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='genericentity',
+ name='group_access',
+ field=models.IntegerField(choices=[(1, 'NONE'), (2, 'VIEW'), (3, 'EDIT')], default=1),
+ ),
+ migrations.AlterField(
+ model_name='genericentity',
+ name='owner_access',
+ field=models.IntegerField(blank=True, choices=[(1, 'NONE'), (2, 'VIEW'), (3, 'EDIT')], default=3, null=True),
+ ),
+ migrations.AlterField(
+ model_name='genericentity',
+ name='world_access',
+ field=models.IntegerField(blank=True, choices=[(1, 'NONE'), (2, 'VIEW')], default=1, null=True),
+ ),
+ migrations.AlterField(
+ model_name='historicalgenericentity',
+ name='group_access',
+ field=models.IntegerField(choices=[(1, 'NONE'), (2, 'VIEW'), (3, 'EDIT')], default=1),
+ ),
+ migrations.AlterField(
+ model_name='historicalgenericentity',
+ name='owner_access',
+ field=models.IntegerField(blank=True, choices=[(1, 'NONE'), (2, 'VIEW'), (3, 'EDIT')], default=3, null=True),
+ ),
+ migrations.AlterField(
+ model_name='historicalgenericentity',
+ name='world_access',
+ field=models.IntegerField(blank=True, choices=[(1, 'NONE'), (2, 'VIEW')], default=1, null=True),
+ ),
+ ]
diff --git a/CodeListLibrary_project/clinicalcode/models/Brand.py b/CodeListLibrary_project/clinicalcode/models/Brand.py
index 8fa43e08e..f1965dc9e 100644
--- a/CodeListLibrary_project/clinicalcode/models/Brand.py
+++ b/CodeListLibrary_project/clinicalcode/models/Brand.py
@@ -1,35 +1,340 @@
-from django.contrib.auth.models import Group, User
-from django.db.models import JSONField
+"""Multi-site branded domain targets."""
+
from django.db import models
-from simple_history.models import HistoricalRecords
+from django.core.cache import cache
+from django.utils.translation import gettext_lazy as _
from django.contrib.postgres.fields import ArrayField
+from django.contrib.auth import get_user_model
from .TimeStampedModel import TimeStampedModel
+from clinicalcode.entity_utils import constants
+
+User = get_user_model()
class Brand(TimeStampedModel):
+ """Domain Brand specifying site appearance and behaviour variation."""
+
+ '''Fields'''
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 site modifiers
+ # - title: modifies ` `-related content
+ # - description: modifies the ` ` content
+ #
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)
+ site_description = models.CharField(max_length=160, blank=True, null=True)
+
+ # Brand page & logo appearance
+ logo_path = models.CharField(max_length=250)
+ index_path = models.CharField(max_length=250, blank=True, null=True)
+
+ # Brand administration
+ admins = models.ManyToManyField(User, related_name='administered_brands', blank=True)
+ users = models.ManyToManyField(User, related_name='accessible_brands', blank=True)
+
+ # 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
+ is_administrable = models.BooleanField(default=False)
+ 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)
- history = HistoricalRecords()
+ '''Static methods'''
+ @staticmethod
+ def get_verbose_names(*args, **kwargs):
+ return { 'verbose_name': Brand._meta.verbose_name, 'verbose_name_plural': Brand._meta.verbose_name_plural }
+
+ @staticmethod
+ def all_instances(cached=True):
+ """
+ Gets all Brand instances from this model
+
+ Args:
+ cached (bool): optionally specify whether to retrieve the cached instances; defaults to `True`
+
+ Returns:
+ A (QuerySet) containing all Brands
+ """
+ if not cached:
+ return Brand.objects.all()
+
+ all_brands = cache.get('brands_all__cache')
+ if all_brands is None:
+ all_brands = Brand.objects.all()
+ cache.set('brands_all__cache', all_brands, 3600)
+ return all_brands
+
+ @staticmethod
+ def all_names(cached=True):
+ """
+ Gets a list of all Brand name targets
+
+ Args:
+ cached (bool): optionally specify whether to retrieve the cached name list; defaults to `True`
+
+ Returns:
+ A (list) containing the names of each Brand instance _assoc._ with this model
+ """
+ if not cached:
+ return [x.upper() for x in Brand.objects.all().values_list('name', flat=True)]
+
+ named_brands = cache.get('brands_names__cache')
+ if named_brands is None:
+ named_brands = [x.upper() for x in Brand.objects.all().values_list('name', flat=True)]
+ cache.set('brands_names__cache', named_brands, 3600)
+ return named_brands
+
+ def all_map_rules(cached=True):
+ """
+ Resolves all Brand content mapping rules
+
+ Note:
+ - Brands that do not specify mapping rules will resolve those specified in `constants.py`;
+ - Please beware that mapping rules are merged with those defined by `constants.py`
+
+ Args:
+ cached (bool): optionally specify whether to retrieve the cached resultset; defaults to `True`
+
+ Returns:
+ A (dict) containing key-value pairs in which the key describes the Brand name, and the value describes the content mapping rules _assoc._ with that Brand - _i.e._ a (Dict[str, str]).
+ """
+ mapping_rules = cache.get('brands_mapping-rules__cache') if cached else None
+ if mapping_rules is None:
+ brands = Brand.all_instances(cached=cached)
+ mapping_rules = [ x.get_map_rules(cached=False) for x in brands ]
+ mapping_rules = { brand.name: rule for brand, rule in zip(dict(brands, mapping_rules)).items() }
+
+ if cached:
+ cache.set('brands_mapping-rules__cache', mapping_rules, 3600)
+ return mapping_rules
+
+ @staticmethod
+ def all_asset_rules(cached=True):
+ """
+ Resolves all Brand asset rules
+
+ Note:
+ - Beware that not all Brands are _assoc._ with asset rules rules;
+ - Brands that do not specify asset rules will not be present in the resulting dict.
+
+ Args:
+ cached (bool): optionally specify whether to retrieve the cached resultset; defaults to `True`
+
+ Returns:
+ A (dict) containing key-value pairs in which the key describes the Brand name, and the value describes the asset rules _assoc._ with that Brand - _i.e._ a (list) of (Dict[str, str]).
+ """
+ asset_rules = cache.get('brands_asset-rules__cache') if cached else None
+ if asset_rules is None:
+ brands = Brand.all_instances(cached=cached)
+ asset_rules = [ x.get_asset_rules() for x in brands ]
+ asset_rules = { brand.name: rule for brand, rule in zip(dict(brands, asset_rules)).items() }
+
+ if cached:
+ cache.set('brands_asset-rules__cache', asset_rules, 3600)
+ return asset_rules
+
+ @staticmethod
+ def all_vis_rules(cached=True):
+ """
+ Resolves all Brand content visibility rules
+
+ Note:
+ - Beware that not all Brands are _assoc._ with content visibility rules;
+ - Beware that not all Brands are _assoc._ with asset rules rules;
+ - Brands that do not specify content visibility rules will not be present in the resulting dict.
+ - Brands that do not specify asset rules will not be present in the resulting dict.
+
+ Args:
+ cached (bool): optionally specify whether to retrieve the cached resultset; defaults to `True`
+
+ Returns:
+ A (dict) containing key-value pairs in which the key describes the Brand name, and the value describes the content visibility rules _assoc._ with that Brand.
+ """
+ vis_rules = cache.get('brands_vis-rules__cache') if cached else None
+ if vis_rules is None:
+ brands = Brand.all_instances(cached=cached)
+ vis_rules = [ x.get_vis_rules() for x in brands ]
+ vis_rules = { brand.name: rule for brand, rule in zip(dict(brands, vis_rules)).items() }
+
+ if cached:
+ cache.set('brands_vis-rules__cache', vis_rules, 3600)
+ return vis_rules
+
+
+ '''Instance methods'''
+ def get_map_rules(self, cached=True, default=constants.DEFAULT_CONTENT_MAPPING):
+ '''
+ Attempts to resolve this Brand's `content_mapping` override attribute
+
+ Note:
+ A Brand's `content_mapping` should define a (Dict[str, str]) which specifies a key-value translation pair
+
+ Args:
+ cached (bool): optionally specify whether to retrieve the cached resultset; defaults to `False`
+ default (Any): optionally specify the default return value if the `content_visibility` attr is undefined; defaults to `constants.DEFAULT_CONTENT_MAPPING`
+
+ Returns:
+ This Brand's `content_mapping` (Dict[str, str]) rule if applicable, otherwise returns the specified `default` value
+ '''
+ # Handle case where instance has yet to be saved
+ if self.id is None:
+ return {} | default if isinstance(default, dict) else None
+
+ cache_key = f'brands_mappings__{self.name}__cache' if cached else None
+ mapping_rules = cache.get(cache_key) if cached else None
+ if mapping_rules is not None:
+ return mapping_rules.get('value')
+
+ mapping_rules = getattr(self, 'overrides')
+ mapping_rules = mapping_rules.get('content_mapping') if isinstance(mapping_rules, dict) else None
+ if isinstance(mapping_rules, dict):
+ mapping_rules = {} | constants.DEFAULT_CONTENT_MAPPING | mapping_rules
+ else:
+ mapping_rules = {} | default if isinstance(default, dict) else None
+
+ if cached:
+ cache.set(cache_key, { 'value': mapping_rules }, 3600)
+
+ return mapping_rules
+
+ def get_asset_rules(self, cached=False, default=None):
+ '''
+ Attempts to resolve this Brand's `asset_rules` override attribute
+
+ Note:
+ A Brand's `asset_rules` should define a (list) describing a set of (Dict[str, str]) which specifies:
+
+ - `name` → the name of the asset
+ - `model` → the `apps.model` reference of the asset
+ - `target` → the name of the `TargetEndpoint` resolver
+
+ Args:
+ cached (bool): optionally specify whether to retrieve the cached resultset; defaults to `False`
+ default (Any): optionally specify the default return value if the `content_visibility` attr is undefined; defaults to `None`
+
+ Returns:
+ This Brand's `asset_rules` (list) rule if applicable, otherwise returns the specified `default` value
+ '''
+ # Handle case where instance has yet to be saved
+ if self.id is None:
+ return default
+
+ cache_key = f'brands_assets__{self.name}__cache' if cached else None
+ asset_rules = cache.get(cache_key) if cached else None
+ if asset_rules is not None:
+ return asset_rules.get('value')
+
+ asset_rules = getattr(self, 'overrides')
+ asset_rules = asset_rules.get('asset_rules') if isinstance(asset_rules, dict) else None
+ if asset_rules is None:
+ asset_rules = default
+
+ if cached:
+ cache.set(cache_key, { 'value': asset_rules }, 3600)
+
+ return asset_rules
+
+ def get_vis_rules(self, cached=False, default=None):
+ """
+ Attempts to resolve this Brand's `content_visibility` override attribute
+
+ Note:
+ A Brand's `content_visibility` may be one of:
+
+ a.) A "_falsy_" value, _e.g._ a `None` or `False` value, in which:
+ - Falsy values specifies that no `content_visibility` rules should be applied;
+ - _i.e._ that all content should be visible.
+
+ b.) Or, a `Literal` `str` value of either (a) `self` or (b) `allow_null`, such that:
+ - `self` → only content created for this `Brand` should be visible;
+ - `allow_null` → the `self` rule but allows all content not associated with a particular `Brand` to be rendered alongside it.
+
+ c.) Or, a `list[int]` describing the Brand IDs that should be visible on this domain alongside itself;
+
+ d.) Or, a `dict[str, Any]` with the following key-value pairs:
+ - `allow_null` → optionally specifies whether content not associated with a particular `Brand` should be visibile;
+ - `allowed_brands` → optionally specifies the Brand IDs whose content should also be visible on this domain.
+
+ Args:
+ cached (bool): optionally specify whether to retrieve the cached resultset; defaults to `False`
+ default (Any): optionally specify the default return value if the `content_visibility` attr is undefined; defaults to `None`
+
+ Returns:
+ This Brand's `content_visibility` (dict) rule if applicable, otherwise returns the specified `default` value
+ """
+ # Handle case where instance has yet to be saved
+ if self.id is None:
+ return default
+
+ # Build vis rules
+ cache_key = f'brands_vis-rules__{self.name}__cache' if cached else None
+ vis_rules = cache.get(cache_key) if cached else None
+ if vis_rules is not None:
+ return vis_rules.get('value')
+
+ vis_rules = getattr(self, 'overrides')
+ vis_rules = vis_rules.get('content_visibility') if isinstance(vis_rules, dict) else None
+ if isinstance(vis_rules, bool) and vis_rules:
+ vis_rules = { 'ids': [self.id], 'allow_null': False }
+ if isinstance(vis_rules, str):
+ vis_rules = vis_rules.lower()
+ if vis_rules in ('self', 'allow_null'):
+ vis_rules = {
+ 'ids': [self.id],
+ 'allow_null': vis_rules == 'allow_null'
+ }
+ else:
+ vis_rules = default
+ elif isinstance(vis_rules, list):
+ if self.id not in vis_rules:
+ vis_rules.insert(0, self.id)
+
+ vis_rules = { 'ids': [x for x in vis_rules if isinstance(x, int)], 'allow_null': False }
+ elif isinstance(vis_rules, dict):
+ allow_null = vis_rules.get('allow_null')
+ allowed_brands = vis_rules.get('allowed_brands')
+ if isinstance(allow_null, bool) or isinstance(allowed_brands, list):
+ allow_null = False if not isinstance(allow_null, bool) else allow_null
+ allowed_brands = [] if not allowed_brands else allowed_brands
+
+ if self.id not in allowed_brands:
+ allowed_brands.insert(0, self.id)
+
+ vis_rules = {
+ 'ids': [x for x in allowed_brands if isinstance(x, int)],
+ 'allow_null': allow_null
+ }
+ else:
+ vis_rules = default
+ elif (vis_rules is None or isinstance(vis_rules, bool)) and not vis_rules:
+ vis_rules = default
+
+ if cached:
+ cache.set(cache_key, { 'value': vis_rules }, 3600)
+
+ return vis_rules
+
+
+ '''Metadata'''
class Meta:
ordering = ('name', )
+ verbose_name = _('Brand')
+ verbose_name_plural = _('Brands')
+
+ '''Dunder methods'''
def __str__(self):
return self.name
diff --git a/CodeListLibrary_project/clinicalcode/models/CCI_CODES.py b/CodeListLibrary_project/clinicalcode/models/CCI_CODES.py
new file mode 100644
index 000000000..88580f1fc
--- /dev/null
+++ b/CodeListLibrary_project/clinicalcode/models/CCI_CODES.py
@@ -0,0 +1,25 @@
+from django.db import models
+from django.contrib.postgres.indexes import GinIndex
+
+class CCI_CODES(models.Model):
+ id = models.BigAutoField(auto_created=True, primary_key=True)
+ code = models.CharField(max_length=64, null=True, blank=True)
+ description = models.CharField(max_length=255, null=True, blank=True)
+ location = models.CharField(max_length=255, null=True, blank=True)
+ extent = models.CharField(max_length=255, null=True, blank=True)
+ status = models.CharField(max_length=255, null=True, blank=True)
+ created_date = models.DateTimeField(auto_now_add=True)
+
+ class Meta:
+ indexes = [
+ GinIndex(
+ name='hcci_cd_ln_gin_idx',
+ fields=['code'],
+ opclasses=['gin_trgm_ops']
+ ),
+ GinIndex(
+ name='hcci_lds_ln_gin_idx',
+ fields=['description'],
+ opclasses=['gin_trgm_ops']
+ ),
+ ]
diff --git a/CodeListLibrary_project/clinicalcode/models/Component.py b/CodeListLibrary_project/clinicalcode/models/Component.py
index bc677c67d..99ae3c39e 100644
--- a/CodeListLibrary_project/clinicalcode/models/Component.py
+++ b/CodeListLibrary_project/clinicalcode/models/Component.py
@@ -7,12 +7,14 @@
'''
from django.db import models
-from django.contrib.auth.models import User
from simple_history.models import HistoricalRecords
+from django.contrib.auth import get_user_model
from .Concept import Concept, HistoricalConcept
from .TimeStampedModel import TimeStampedModel
+User = get_user_model()
+
class Component(TimeStampedModel):
'''
Component of a concept.
diff --git a/CodeListLibrary_project/clinicalcode/models/Concept.py b/CodeListLibrary_project/clinicalcode/models/Concept.py
index 967a2cc6c..548caec92 100644
--- a/CodeListLibrary_project/clinicalcode/models/Concept.py
+++ b/CodeListLibrary_project/clinicalcode/models/Concept.py
@@ -3,16 +3,19 @@
A Concept contains a list of Components specified by inclusion/exclusion.
'''
-from django.contrib.auth.models import Group, User
+from django.contrib.auth.models import Group
from django.contrib.postgres.fields import ArrayField
from django.db import models
from django.template.defaultfilters import default
from simple_history.models import HistoricalRecords
+from django.contrib.auth import get_user_model
from .CodingSystem import CodingSystem
from .TimeStampedModel import TimeStampedModel
from ..entity_utils import constants
+User = get_user_model()
+
class Concept(TimeStampedModel):
id = models.AutoField(primary_key=True)
name = models.CharField(max_length=250)
diff --git a/CodeListLibrary_project/clinicalcode/models/ConceptCodeAttribute.py b/CodeListLibrary_project/clinicalcode/models/ConceptCodeAttribute.py
index 9f0be88a0..0e41ec801 100644
--- a/CodeListLibrary_project/clinicalcode/models/ConceptCodeAttribute.py
+++ b/CodeListLibrary_project/clinicalcode/models/ConceptCodeAttribute.py
@@ -6,14 +6,16 @@
---------------------------------------------------------------------------
'''
-from django.contrib.auth.models import User
from django.contrib.postgres.fields import ArrayField
from django.db import models
from simple_history.models import HistoricalRecords
+from django.contrib.auth import get_user_model
from .Concept import Concept
from .TimeStampedModel import TimeStampedModel
+User = get_user_model()
+
class ConceptCodeAttribute(TimeStampedModel):
'''
Store attributes of codes in a concept.
diff --git a/CodeListLibrary_project/clinicalcode/models/ConceptReviewStatus.py b/CodeListLibrary_project/clinicalcode/models/ConceptReviewStatus.py
index 6b6c22485..76a65c693 100644
--- a/CodeListLibrary_project/clinicalcode/models/ConceptReviewStatus.py
+++ b/CodeListLibrary_project/clinicalcode/models/ConceptReviewStatus.py
@@ -1,6 +1,8 @@
from django.db import models
-from django.contrib.auth.models import Group, User
from django.contrib.postgres.fields import ArrayField
+from django.contrib.auth import get_user_model
+
+User = get_user_model()
class ConceptReviewStatus(models.Model):
id = models.AutoField(primary_key=True)
diff --git a/CodeListLibrary_project/clinicalcode/models/DataSource.py b/CodeListLibrary_project/clinicalcode/models/DataSource.py
index a962097f0..055ff439d 100644
--- a/CodeListLibrary_project/clinicalcode/models/DataSource.py
+++ b/CodeListLibrary_project/clinicalcode/models/DataSource.py
@@ -1,9 +1,11 @@
-from django.contrib.auth.models import User
from django.db import models
from simple_history.models import HistoricalRecords
+from django.contrib.auth import get_user_model
from clinicalcode.models.TimeStampedModel import TimeStampedModel
+User = get_user_model()
+
class DataSource(TimeStampedModel):
"""
Data Source Model
@@ -22,11 +24,10 @@ 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)
+ 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/EntityClass.py b/CodeListLibrary_project/clinicalcode/models/EntityClass.py
index f368ddda3..bcaf0c02a 100644
--- a/CodeListLibrary_project/clinicalcode/models/EntityClass.py
+++ b/CodeListLibrary_project/clinicalcode/models/EntityClass.py
@@ -1,6 +1,8 @@
from django.db import models
-from django.contrib.auth.models import User
from django.utils.timezone import now
+from django.contrib.auth import get_user_model
+
+User = get_user_model()
class EntityClass(models.Model):
'''
diff --git a/CodeListLibrary_project/clinicalcode/models/GenericEntity.py b/CodeListLibrary_project/clinicalcode/models/GenericEntity.py
index 99d26674c..f856dde90 100644
--- a/CodeListLibrary_project/clinicalcode/models/GenericEntity.py
+++ b/CodeListLibrary_project/clinicalcode/models/GenericEntity.py
@@ -1,4 +1,4 @@
-from django.contrib.auth.models import Group, User
+from django.contrib.auth.models import Group
from django.contrib.postgres.fields import ArrayField
from django.db.models import JSONField
from django.db import models
@@ -6,12 +6,16 @@
from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVectorField
from django.core.exceptions import ValidationError
+from django.contrib.auth import get_user_model
from simple_history.models import HistoricalRecords
from .Template import Template
from .EntityClass import EntityClass
+from .Organisation import Organisation
from ..entity_utils import gen_utils, constants
+User = get_user_model()
+
class GenericEntityManager(models.Manager):
"""
Generic Entity Manager - responsible for transfering previous phenotype records to generic entities
@@ -66,12 +70,27 @@ 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")
+ 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.value, e.name) for e in constants.OWNER_PERMISSIONS],
+ default=constants.OWNER_PERMISSIONS.EDIT.value,
+ null=True,
+ blank=True
+ )
+ group_access = models.IntegerField(
+ choices=[(e.value, e.name) for e in constants.GROUP_PERMISSIONS],
+ default=constants.GROUP_PERMISSIONS.NONE.value
+ )
+ world_access = models.IntegerField(
+ choices=[(e.value, e.name) for e in constants.WORLD_ACCESS_PERMISSIONS],
+ default=constants.WORLD_ACCESS_PERMISSIONS.NONE.value,
+ 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/HDRNDataAsset.py b/CodeListLibrary_project/clinicalcode/models/HDRNDataAsset.py
new file mode 100644
index 000000000..2ea40b604
--- /dev/null
+++ b/CodeListLibrary_project/clinicalcode/models/HDRNDataAsset.py
@@ -0,0 +1,114 @@
+from django.db import models
+from django.http import HttpRequest
+from django.db.models import Q
+from django.core.paginator import EmptyPage, Paginator, Page
+from rest_framework.request import Request
+from django.utils.translation import gettext_lazy as _
+from django.contrib.postgres.fields import ArrayField
+from django.contrib.postgres.indexes import GinIndex
+
+from clinicalcode.entity_utils import gen_utils, model_utils, constants
+from clinicalcode.models.HDRNSite import HDRNSite
+from clinicalcode.models.HDRNJurisdiction import HDRNJurisdiction
+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
+ 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')
+ years = models.CharField(max_length=256, unique=False, null=True, blank=True)
+ scope = models.TextField(null=True, blank=True)
+ regions = models.ManyToManyField(HDRNJurisdiction, related_name='data_assets', 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.IntegerField(), blank=True, null=True) # Note: ref to `Tag`
+
+ @staticmethod
+ def get_verbose_names(*args, **kwargs):
+ return { 'verbose_name': HDRNDataAsset._meta.verbose_name, 'verbose_name_plural': HDRNDataAsset._meta.verbose_name_plural }
+
+ @staticmethod
+ def get_brand_records_by_request(request, params=None):
+ brand = model_utils.try_get_brand(request)
+ if brand is None or brand.name == 'HDRN':
+ records = HDRNDataAsset.objects.all()
+
+ if records is None:
+ return HDRNDataAsset.objects.none()
+
+ if not isinstance(params, dict):
+ params = { }
+
+ if isinstance(request, Request) and hasattr(request, 'query_params'):
+ params = { key: value for key, value in request.query_params.items() } | params
+ elif isinstance(request, HttpRequest) and hasattr(request, 'GET'):
+ params = { key: value for key, value in request.GET.dict().items() } | params
+
+ search = params.pop('search', None)
+ query = gen_utils.parse_model_field_query(HDRNDataAsset, params, ignored_fields=['description'])
+ if query is not None:
+ records = records.filter(**query)
+
+ if not gen_utils.is_empty_string(search) and len(search) >= 3:
+ records = records.filter(Q(name__icontains=search) | Q(description__icontains=search))
+
+ records = records.order_by('id')
+ return records
+
+ @staticmethod
+ def get_brand_paginated_records_by_request(request, params=None):
+ if not isinstance(params, dict):
+ params = { }
+
+ if isinstance(request, Request) and hasattr(request, 'query_params'):
+ params = { key: value for key, value in request.query_params.items() } | params
+ elif isinstance(request, HttpRequest) and hasattr(request, 'GET'):
+ params = { key: value for key, value in request.GET.dict().items() } | params
+
+ records = HDRNDataAsset.get_brand_records_by_request(request, params)
+
+ page = gen_utils.try_value_as_type(params.get('page'), 'int', default=1)
+ page = max(page, 1)
+
+ page_size = params.get('page_size', '1')
+ if page_size not in constants.PAGE_RESULTS_SIZE:
+ page_size = constants.PAGE_RESULTS_SIZE.get('1')
+ else:
+ page_size = constants.PAGE_RESULTS_SIZE.get(page_size)
+
+ if records is None:
+ return Page(HDRNDataAsset.objects.none(), 0, Paginator([], page_size, allow_empty_first_page=True))
+
+ pagination = Paginator(records, page_size, allow_empty_first_page=True)
+ try:
+ page_obj = pagination.page(page)
+ except EmptyPage:
+ page_obj = pagination.page(pagination.num_pages)
+ return page_obj
+
+ 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']),
+ ]
+ verbose_name = _('HDRN Data Asset')
+ verbose_name_plural = _('HDRN Data Assets')
+
+ 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..06b046dc4
--- /dev/null
+++ b/CodeListLibrary_project/clinicalcode/models/HDRNDataCategory.py
@@ -0,0 +1,92 @@
+from django.db import models
+from django.http import HttpRequest
+from django.db.models import Q
+from django.core.paginator import EmptyPage, Paginator, Page
+from rest_framework.request import Request
+from django.utils.translation import gettext_lazy as _
+from django.contrib.postgres.indexes import GinIndex
+
+from clinicalcode.entity_utils import gen_utils, model_utils, constants
+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)
+
+ @staticmethod
+ def get_verbose_names(*args, **kwargs):
+ return { 'verbose_name': HDRNDataCategory._meta.verbose_name, 'verbose_name_plural': HDRNDataCategory._meta.verbose_name_plural }
+
+ @staticmethod
+ def get_brand_records_by_request(request, params=None):
+ brand = model_utils.try_get_brand(request)
+ if brand is None or brand.name == 'HDRN':
+ records = HDRNDataCategory.objects.all()
+
+ if records is None:
+ return HDRNDataCategory.objects.none()
+
+ if not isinstance(params, dict):
+ params = { }
+
+ if isinstance(request, Request) and hasattr(request, 'query_params'):
+ params = { key: value for key, value in request.query_params.items() } | params
+ elif isinstance(request, HttpRequest) and hasattr(request, 'GET'):
+ params = { key: value for key, value in request.GET.dict().items() } | params
+
+ search = params.pop('search', None)
+ query = gen_utils.parse_model_field_query(HDRNDataCategory, params, ignored_fields=['description'])
+ if query is not None:
+ records = records.filter(**query)
+
+ if not gen_utils.is_empty_string(search) and len(search) >= 3:
+ records = records.filter(Q(name__icontains=search) | Q(description__icontains=search))
+
+ records = records.order_by('id')
+ return records
+
+ @staticmethod
+ def get_brand_paginated_records_by_request(request, params=None):
+ if not isinstance(params, dict):
+ params = { }
+
+ if isinstance(request, Request) and hasattr(request, 'query_params'):
+ params = { key: value for key, value in request.query_params.items() } | params
+ elif isinstance(request, HttpRequest) and hasattr(request, 'GET'):
+ params = { key: value for key, value in request.GET.dict().items() } | params
+
+ records = HDRNDataCategory.get_brand_records_by_request(request, params)
+
+ page = gen_utils.try_value_as_type(params.get('page'), 'int', default=1)
+ page = max(page, 1)
+
+ page_size = params.get('page_size', '1')
+ if page_size not in constants.PAGE_RESULTS_SIZE:
+ page_size = constants.PAGE_RESULTS_SIZE.get('1')
+ else:
+ page_size = constants.PAGE_RESULTS_SIZE.get(page_size)
+
+ if records is None:
+ return Page(HDRNDataCategory.objects.none(), 0, Paginator([], page_size, allow_empty_first_page=True))
+
+ pagination = Paginator(records, page_size, allow_empty_first_page=True)
+ try:
+ page_obj = pagination.page(page)
+ except EmptyPage:
+ page_obj = pagination.page(pagination.num_pages)
+ return page_obj
+
+ class Meta:
+ indexes = [
+ GinIndex(name='hdrn_dcnm_trgm_idx', fields=['name'], opclasses=['gin_trgm_ops']),
+ ]
+ verbose_name = _('HDRN Data Category')
+ verbose_name_plural = _('HDRN Data Categories')
+
+ def __str__(self):
+ return self.name
diff --git a/CodeListLibrary_project/clinicalcode/models/HDRNJurisdiction.py b/CodeListLibrary_project/clinicalcode/models/HDRNJurisdiction.py
new file mode 100644
index 000000000..4bf25e075
--- /dev/null
+++ b/CodeListLibrary_project/clinicalcode/models/HDRNJurisdiction.py
@@ -0,0 +1,89 @@
+from django.db import models
+from django.http import HttpRequest
+from django.db.models import Q
+from django.core.paginator import EmptyPage, Paginator, Page
+from rest_framework.request import Request
+from django.utils.translation import gettext_lazy as _
+
+from clinicalcode.entity_utils import gen_utils, model_utils, constants
+from clinicalcode.models.TimeStampedModel import TimeStampedModel
+
+class HDRNJurisdiction(TimeStampedModel):
+ """HDRN Jurisdictions: Provinces & Territories of Canada"""
+ id = models.AutoField(primary_key=True)
+ name = models.CharField(max_length=512, unique=True, null=False, blank=False)
+ abbreviation = models.CharField(max_length=256, null=True, blank=True)
+ description = models.TextField(null=True, blank=True)
+ metadata = models.JSONField(blank=True, null=True)
+
+ @staticmethod
+ def get_verbose_names(*args, **kwargs):
+ return { 'verbose_name': HDRNJurisdiction._meta.verbose_name, 'verbose_name_plural': HDRNJurisdiction._meta.verbose_name_plural }
+
+ @staticmethod
+ def get_brand_records_by_request(request, params=None):
+ brand = model_utils.try_get_brand(request)
+ if brand is None or brand.name == 'HDRN':
+ records = HDRNJurisdiction.objects.all()
+
+ if records is None:
+ return HDRNJurisdiction.objects.none()
+
+ if not isinstance(params, dict):
+ params = { }
+
+ if isinstance(request, Request) and hasattr(request, 'query_params'):
+ params = { key: value for key, value in request.query_params.items() } | params
+ elif isinstance(request, HttpRequest) and hasattr(request, 'GET'):
+ params = { key: value for key, value in request.GET.dict().items() } | params
+
+ search = params.pop('search', None)
+ query = gen_utils.parse_model_field_query(HDRNJurisdiction, params, ignored_fields=['description'])
+ if query is not None:
+ records = records.filter(**query)
+
+ if not gen_utils.is_empty_string(search) and len(search) >= 3:
+ records = records.filter(Q(name__icontains=search) | Q(description__icontains=search))
+
+ records = records.order_by('id')
+ return records
+
+ @staticmethod
+ def get_brand_paginated_records_by_request(request, params=None):
+ if not isinstance(params, dict):
+ params = { }
+
+ if isinstance(request, Request) and hasattr(request, 'query_params'):
+ params = { key: value for key, value in request.query_params.items() } | params
+ elif isinstance(request, HttpRequest) and hasattr(request, 'GET'):
+ params = { key: value for key, value in request.GET.dict().items() } | params
+
+ records = HDRNJurisdiction.get_brand_records_by_request(request, params)
+
+ page = gen_utils.try_value_as_type(params.get('page'), 'int', default=1)
+ page = max(page, 1)
+
+ page_size = params.get('page_size', '1')
+ if page_size not in constants.PAGE_RESULTS_SIZE:
+ page_size = constants.PAGE_RESULTS_SIZE.get('1')
+ else:
+ page_size = constants.PAGE_RESULTS_SIZE.get(page_size)
+
+ if records is None:
+ return Page(HDRNJurisdiction.objects.none(), 0, Paginator([], page_size, allow_empty_first_page=True))
+
+ pagination = Paginator(records, page_size, allow_empty_first_page=True)
+ try:
+ page_obj = pagination.page(page)
+ except EmptyPage:
+ page_obj = pagination.page(pagination.num_pages)
+ return page_obj
+
+ class Meta:
+ verbose_name = _('HDRN Jurisdiction')
+ verbose_name_plural = _('HDRN Jurisdictions')
+
+ def __str__(self):
+ if isinstance(self.abbreviation, str) and not gen_utils.is_empty_string(self.abbreviation):
+ return f'{self.name} ({self.abbreviation})'
+ 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..2d4a5bbeb
--- /dev/null
+++ b/CodeListLibrary_project/clinicalcode/models/HDRNSite.py
@@ -0,0 +1,91 @@
+from django.db import models
+from django.http import HttpRequest
+from django.db.models import Q
+from django.core.paginator import EmptyPage, Paginator, Page
+from rest_framework.request import Request
+from django.utils.translation import gettext_lazy as _
+
+from clinicalcode.entity_utils import gen_utils, model_utils, constants
+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)
+ abbreviation = models.CharField(max_length=256, null=True, blank=True)
+ description = models.TextField(null=True, blank=True)
+ metadata = models.JSONField(blank=True, null=True)
+
+ @staticmethod
+ def get_verbose_names(*args, **kwargs):
+ return { 'verbose_name': HDRNSite._meta.verbose_name, 'verbose_name_plural': HDRNSite._meta.verbose_name_plural }
+
+ @staticmethod
+ def get_brand_records_by_request(request, params=None):
+ brand = model_utils.try_get_brand(request)
+ if brand is None or brand.name == 'HDRN':
+ records = HDRNSite.objects.all()
+
+ if records is None:
+ return HDRNSite.objects.none()
+
+ if not isinstance(params, dict):
+ params = { }
+
+ if isinstance(request, Request) and hasattr(request, 'query_params'):
+ params = { key: value for key, value in request.query_params.items() } | params
+ elif isinstance(request, HttpRequest) and hasattr(request, 'GET'):
+ params = { key: value for key, value in request.GET.dict().items() } | params
+
+ search = params.pop('search', None)
+ query = gen_utils.parse_model_field_query(HDRNSite, params, ignored_fields=['description'])
+ if query is not None:
+ records = records.filter(**query)
+
+ if not gen_utils.is_empty_string(search) and len(search) >= 3:
+ records = records.filter(Q(name__icontains=search) | Q(description__icontains=search))
+
+ records = records.order_by('id')
+ return records
+
+ @staticmethod
+ def get_brand_paginated_records_by_request(request, params=None):
+ if not isinstance(params, dict):
+ params = { }
+
+ if isinstance(request, Request) and hasattr(request, 'query_params'):
+ params = { key: value for key, value in request.query_params.items() } | params
+ elif isinstance(request, HttpRequest) and hasattr(request, 'GET'):
+ params = { key: value for key, value in request.GET.dict().items() } | params
+
+ records = HDRNSite.get_brand_records_by_request(request, params)
+
+ page = gen_utils.try_value_as_type(params.get('page'), 'int', default=1)
+ page = max(page, 1)
+
+ page_size = params.get('page_size', '1')
+ if page_size not in constants.PAGE_RESULTS_SIZE:
+ page_size = constants.PAGE_RESULTS_SIZE.get('1')
+ else:
+ page_size = constants.PAGE_RESULTS_SIZE.get(page_size)
+
+ if records is None:
+ return Page(HDRNSite.objects.none(), 0, Paginator([], page_size, allow_empty_first_page=True))
+
+ pagination = Paginator(records, page_size, allow_empty_first_page=True)
+ try:
+ page_obj = pagination.page(page)
+ except EmptyPage:
+ page_obj = pagination.page(pagination.num_pages)
+ return page_obj
+
+ class Meta:
+ verbose_name = _('HDRN Site')
+ verbose_name_plural = _('HDRN Sites')
+
+ def __str__(self):
+ if isinstance(self.abbreviation, str) and not gen_utils.is_empty_string(self.abbreviation):
+ return self.abbreviation
+ return self.name
diff --git a/CodeListLibrary_project/clinicalcode/models/OntologyTag.py b/CodeListLibrary_project/clinicalcode/models/OntologyTag.py
index 7104d938e..62b89b33a 100644
--- a/CodeListLibrary_project/clinicalcode/models/OntologyTag.py
+++ b/CodeListLibrary_project/clinicalcode/models/OntologyTag.py
@@ -1,7 +1,6 @@
from django.apps import apps
from django.db import models, transaction, connection
-from django.db.models import F, Count, Max, Case, When, Exists, OuterRef
-from django.db.models.query import QuerySet
+from django.db.models import F, Count, Case, When, Exists, OuterRef
from django.db.models.functions import JSONObject
from django.contrib.postgres.search import SearchVectorField
from django.contrib.postgres.indexes import GinIndex
@@ -1241,15 +1240,15 @@ def query_typeahead(cls, searchterm = '', type_ids=None, result_limit = TYPEAHEA
node.name,
node.type_id,
node.properties,
- ts_rank_cd(node.search_vector, websearch_to_tsquery('pg_catalog.english', %(searchterm)s)) as score
+ ts_rank_cd(node.search_vector, to_tsquery('pg_catalog.english', replace(to_tsquery('pg_catalog.english', concat(regexp_replace(trim(%(searchterm)s), '\W+', ':* & ', 'gm'), ':*'))::text, '<->', '|'))) as score
from public.clinicalcode_ontologytag as node
where ((
search_vector
- @@ to_tsquery('pg_catalog.english', replace(websearch_to_tsquery('pg_catalog.english', %(searchterm)s)::text || ':*', '<->', '|'))
+ @@ to_tsquery('pg_catalog.english', replace(to_tsquery('pg_catalog.english', concat(regexp_replace(trim(%(searchterm)s), '\W+', ':* & ', 'gm'), ':*'))::text, '<->', '|'))
)
or (
- (relation_vector @@ to_tsquery('pg_catalog.english', replace(websearch_to_tsquery('pg_catalog.english', %(searchterm)s)::text || ':*', '<->', '|')))
- or (relation_vector @@ to_tsquery('pg_catalog.english', replace(websearch_to_tsquery('pg_catalog.english', %(searchterm)s)::text || ':*', '<->', '|')))
+ (relation_vector @@ to_tsquery('pg_catalog.english', replace(to_tsquery('pg_catalog.english', concat(regexp_replace(trim(%(searchterm)s), '\W+', ':* & ', 'gm'), ':*'))::text, '<->', '|')))
+ or (synonyms_vector @@ to_tsquery('pg_catalog.english', replace(to_tsquery('pg_catalog.english', concat(regexp_replace(trim(%(searchterm)s), '\W+', ':* & ', 'gm'), ':*'))::text, '<->', '|')))
)
)
''')
diff --git a/CodeListLibrary_project/clinicalcode/models/Organisation.py b/CodeListLibrary_project/clinicalcode/models/Organisation.py
new file mode 100644
index 000000000..ef629c431
--- /dev/null
+++ b/CodeListLibrary_project/clinicalcode/models/Organisation.py
@@ -0,0 +1,227 @@
+from django.db import models
+from django.http import HttpRequest
+from django.urls import reverse
+from django.utils import timezone
+from django.utils.text import slugify
+from django.contrib.auth import get_user_model
+from django.core.paginator import Page, EmptyPage, Paginator
+from django.db.models.query import QuerySet
+from rest_framework.request import Request
+
+import uuid
+import datetime
+
+from .Brand import Brand
+from ..entity_utils import constants, model_utils, gen_utils
+
+User = get_user_model()
+
+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=timezone.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 serialise_api(self):
+ if self.id is None:
+ return None
+
+ return {
+ 'id': self.id,
+ 'slug': self.slug,
+ 'name': self.name
+ }
+
+ 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=timezone.now, editable=False)
+
+ def __str__(self):
+ return f'user: {self.user}, org: {self.organisation}'
+
+ @staticmethod
+ def get_brand_records_by_request(request, params=None):
+ # Step 1: Get the Brand from the request
+ brand = model_utils.try_get_brand(request)
+
+ members = None
+ if brand is None:
+ # If no specific brand, return all organisation members
+ members = OrganisationMembership.objects.all()
+ elif isinstance(brand, Brand):
+ # Get organisations that have this brand associated through OrganisationAuthority
+
+ members = OrganisationMembership.objects.filter(organisation__brands__id=brand.id)
+
+ # If no members were found, return an empty queryset
+ if members is None:
+ return OrganisationMembership.objects.none()
+
+ # Handle the query params and search logic
+ if not isinstance(params, dict):
+ params = {}
+
+ if isinstance(request, Request) and hasattr(request, 'query_params'):
+ params = {key: value for key, value in request.query_params.items()} | params
+ elif isinstance(request, HttpRequest) and hasattr(request, 'GET'):
+ params = {key: value for key, value in request.GET.dict().items()} | params
+
+ # Apply additional filters based on the params (e.g., search)
+ query = gen_utils.parse_model_field_query(OrganisationMembership, params)
+ if query is not None:
+ members = members.filter(**query)
+
+ # Step 5: Order by id
+ members = members.order_by('id')
+
+ return members
+
+ @staticmethod
+ def get_brand_paginated_records_by_request(request, params=None):
+ if not isinstance(params, dict):
+ params = {}
+
+ if isinstance(request, Request) and hasattr(request, 'query_params'):
+ params = {key: value for key, value in request.query_params.items()} | params
+ elif isinstance(request, HttpRequest) and hasattr(request, 'GET'):
+ params = {key: value for key, value in request.GET.dict().items()} | params
+
+ records = OrganisationMembership.get_brand_records_by_request(request, params)
+
+ page = params.get('page', 1)
+ page = max(page, 1)
+
+ page_size = params.get('page_size', '1')
+ if page_size not in constants.PAGE_RESULTS_SIZE:
+ page_size = constants.PAGE_RESULTS_SIZE.get('1')
+ else:
+ page_size = constants.PAGE_RESULTS_SIZE.get(page_size)
+
+ if records is None:
+ return Page(QuerySet(), 0, Paginator([], page_size, allow_empty_first_page=True))
+
+ pagination = Paginator(records, page_size, allow_empty_first_page=True)
+ try:
+ page_obj = pagination.page(page)
+ except EmptyPage:
+ page_obj = pagination.page(pagination.num_pages)
+ return page_obj
+
+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=timezone.now, editable=False)
+
+ def is_expired(self):
+ date_expired = self.created + datetime.timedelta(days=constants.INVITE_TIMEOUT)
+ return date_expired <= timezone.now()
+
+ def is_sent(self):
+ return self.sent
+
+ def __str__(self):
+ return f'user={self.user}, org={self.organisation}'
diff --git a/CodeListLibrary_project/clinicalcode/models/PHYSICIAN_FEE_CODES.py b/CodeListLibrary_project/clinicalcode/models/PHYSICIAN_FEE_CODES.py
new file mode 100644
index 000000000..ce7087966
--- /dev/null
+++ b/CodeListLibrary_project/clinicalcode/models/PHYSICIAN_FEE_CODES.py
@@ -0,0 +1,22 @@
+from django.db import models
+from django.contrib.postgres.indexes import GinIndex
+
+class PHYSICIAN_FEE_CODES(models.Model):
+ id = models.BigAutoField(auto_created=True, primary_key=True)
+ code = models.CharField(max_length=64, null=True, blank=True)
+ description = models.CharField(max_length=255, null=True, blank=True)
+ created_date = models.DateTimeField(auto_now_add=True)
+
+ class Meta:
+ indexes = [
+ GinIndex(
+ name='hpfc_cd_ln_gin_idx',
+ fields=['code'],
+ opclasses=['gin_trgm_ops']
+ ),
+ GinIndex(
+ name='hpfc_lds_ln_gin_idx',
+ fields=['description'],
+ opclasses=['gin_trgm_ops']
+ ),
+ ]
diff --git a/CodeListLibrary_project/clinicalcode/models/Phenotype.py b/CodeListLibrary_project/clinicalcode/models/Phenotype.py
index 8b1bfd60a..22b1d549c 100644
--- a/CodeListLibrary_project/clinicalcode/models/Phenotype.py
+++ b/CodeListLibrary_project/clinicalcode/models/Phenotype.py
@@ -1,13 +1,16 @@
import datetime
-from django.contrib.auth.models import Group, User
+from django.contrib.auth.models import Group
from django.contrib.postgres.fields import ArrayField
from django.db.models import JSONField
from django.db import models
from simple_history.models import HistoricalRecords
+from django.contrib.auth import get_user_model
from .TimeStampedModel import TimeStampedModel
from ..entity_utils import constants
+User = get_user_model()
+
class Phenotype(TimeStampedModel):
"""
Phenotype Model
diff --git a/CodeListLibrary_project/clinicalcode/models/PhenotypeWorkingset.py b/CodeListLibrary_project/clinicalcode/models/PhenotypeWorkingset.py
index 56d8b4fd4..c23081984 100644
--- a/CodeListLibrary_project/clinicalcode/models/PhenotypeWorkingset.py
+++ b/CodeListLibrary_project/clinicalcode/models/PhenotypeWorkingset.py
@@ -3,15 +3,18 @@
A working set is a list of columns from a number of Concepts and Phenotypes
'''
-from django.contrib.auth.models import Group, User
+from django.contrib.auth.models import Group
from django.contrib.postgres.fields import ArrayField
from django.db.models import JSONField
from django.db import models
from simple_history.models import HistoricalRecords
+from django.contrib.auth import get_user_model
from .TimeStampedModel import TimeStampedModel
from ..entity_utils import constants
+User = get_user_model()
+
class PhenotypeWorkingset(TimeStampedModel):
id = models.CharField(primary_key=True, editable=False, default=None,max_length=50)
name = models.CharField(max_length=250)
diff --git a/CodeListLibrary_project/clinicalcode/models/PublishedConcept.py b/CodeListLibrary_project/clinicalcode/models/PublishedConcept.py
index a7eda5585..82695a2ee 100644
--- a/CodeListLibrary_project/clinicalcode/models/PublishedConcept.py
+++ b/CodeListLibrary_project/clinicalcode/models/PublishedConcept.py
@@ -1,9 +1,12 @@
-from django.contrib.auth.models import User
+
from django.db import models
from simple_history.models import HistoricalRecords
+from django.contrib.auth import get_user_model
from .Concept import Concept
+User = get_user_model()
+
class PublishedConcept(models.Model):
concept = models.ForeignKey(Concept, on_delete=models.CASCADE)
concept_history_id = models.IntegerField()
diff --git a/CodeListLibrary_project/clinicalcode/models/PublishedGenericEntity.py b/CodeListLibrary_project/clinicalcode/models/PublishedGenericEntity.py
index 23aec5edd..3d500572f 100644
--- a/CodeListLibrary_project/clinicalcode/models/PublishedGenericEntity.py
+++ b/CodeListLibrary_project/clinicalcode/models/PublishedGenericEntity.py
@@ -1,13 +1,15 @@
-from django.contrib.auth.models import User
from django.db import models
from simple_history.models import HistoricalRecords
from django.db import connection, transaction
+from django.contrib.auth import get_user_model
import enum
from .GenericEntity import GenericEntity
from ..entity_utils import constants
+User = get_user_model()
+
class PublishedGenericEntity(models.Model):
entity = models.ForeignKey(GenericEntity, on_delete=models.CASCADE)
entity_history_id = models.IntegerField(null=False)
diff --git a/CodeListLibrary_project/clinicalcode/models/PublishedPhenotype.py b/CodeListLibrary_project/clinicalcode/models/PublishedPhenotype.py
index fc2831812..4dc9695e4 100644
--- a/CodeListLibrary_project/clinicalcode/models/PublishedPhenotype.py
+++ b/CodeListLibrary_project/clinicalcode/models/PublishedPhenotype.py
@@ -1,9 +1,11 @@
-from django.contrib.auth.models import User
from django.db import models
from simple_history.models import HistoricalRecords
+from django.contrib.auth import get_user_model
from .Phenotype import Phenotype
+User = get_user_model()
+
class PublishedPhenotype(models.Model):
phenotype = models.ForeignKey(Phenotype, on_delete=models.CASCADE)
phenotype_history_id = models.IntegerField(null=False)
diff --git a/CodeListLibrary_project/clinicalcode/models/PublishedWorkingset.py b/CodeListLibrary_project/clinicalcode/models/PublishedWorkingset.py
index 646c6f270..adccdf7cc 100644
--- a/CodeListLibrary_project/clinicalcode/models/PublishedWorkingset.py
+++ b/CodeListLibrary_project/clinicalcode/models/PublishedWorkingset.py
@@ -1,9 +1,11 @@
-from django.contrib.auth.models import User
from django.db import models
from simple_history.models import HistoricalRecords
+from django.contrib.auth import get_user_model
from .PhenotypeWorkingset import PhenotypeWorkingset
+User = get_user_model()
+
class PublishedWorkingset(models.Model):
workingset = models.ForeignKey(PhenotypeWorkingset, on_delete=models.CASCADE)
workingset_history_id = models.IntegerField(null=False)
diff --git a/CodeListLibrary_project/clinicalcode/models/Statistics.py b/CodeListLibrary_project/clinicalcode/models/Statistics.py
index 31e61dd18..59fb92209 100644
--- a/CodeListLibrary_project/clinicalcode/models/Statistics.py
+++ b/CodeListLibrary_project/clinicalcode/models/Statistics.py
@@ -1,10 +1,12 @@
-from django.contrib.auth.models import User
from django.db.models import JSONField
from django.db import models
from simple_history.models import HistoricalRecords
+from django.contrib.auth import get_user_model
from clinicalcode.models.TimeStampedModel import TimeStampedModel
+User = get_user_model()
+
class Statistics(TimeStampedModel):
org = models.CharField(max_length=50)
type = models.CharField(max_length=50)
diff --git a/CodeListLibrary_project/clinicalcode/models/Tag.py b/CodeListLibrary_project/clinicalcode/models/Tag.py
index cb02fe71f..59e20d429 100644
--- a/CodeListLibrary_project/clinicalcode/models/Tag.py
+++ b/CodeListLibrary_project/clinicalcode/models/Tag.py
@@ -1,10 +1,17 @@
-from django.contrib.auth.models import User
from django.db import models
+from django.http import HttpRequest
from simple_history.models import HistoricalRecords
+from django.core.paginator import EmptyPage, Paginator, Page
+from rest_framework.request import Request
+from django.utils.translation import gettext_lazy as _
+from django.contrib.auth import get_user_model
from clinicalcode.models.Brand import Brand
+from clinicalcode.entity_utils import constants, gen_utils, filter_utils, model_utils
from clinicalcode.models.TimeStampedModel import TimeStampedModel
+User = get_user_model()
+
class Tag(TimeStampedModel):
default = 1
primary = 2
@@ -41,8 +48,149 @@ class Tag(TimeStampedModel):
history = HistoricalRecords()
+ @staticmethod
+ def get_verbose_names(subtype=None, *args, **kwargs):
+ if subtype == 'all':
+ return { 'verbose_name': _('Tags & Collections'), 'verbose_name_plural': _('Tags & Collections') }
+
+ is_str = isinstance(subtype, str)
+ type_id = gen_utils.parse_int(subtype) if is_str else subtype
+
+ is_valid = isinstance(type_id, int)
+ if not is_valid and is_str:
+ subtype = subtype.lower()
+ if subtype.startswith('tag'):
+ type_id = 1
+ elif subtype.startswith('collection'):
+ type_id = 2
+ else:
+ type_id = 1
+ elif not is_valid:
+ type_id = 1
+
+ if type_id == 1 or not (1 <= type_id <= 2):
+ verbose_name = _('Tag')
+ verbose_name_plural = _('Tags')
+ elif type_id == 2:
+ verbose_name = _('Collection')
+ verbose_name_plural = _('Collections')
+ return { 'verbose_name': verbose_name, 'verbose_name_plural': verbose_name_plural }
+
+ @staticmethod
+ def get_brand_assoc_queryset(brand=None, desired_tag=None):
+ if isinstance(desired_tag, int) and desired_tag in (Tag.tag, Tag.collection):
+ tag_type = desired_tag
+ tag_name = 'tags' if desired_tag == 1 else 'collections'
+ elif isinstance(desired_tag, str):
+ if desired_tag.lower() == 'tags':
+ tag_type = 1
+ tag_name = 'tags'
+ else:
+ tag_type = 2
+ tag_name = 'collections'
+ else:
+ tag_type = 1
+ tag_name = 'tags'
+
+ records = None
+ if brand:
+ source = constants.metadata.get(tag_name, {}) \
+ .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:
+ records = Tag.objects.filter(*result)
+
+ if records is None:
+ records = Tag.objects.filter(collection_brand=brand.id, tag_type=tag_type)
+ else:
+ records = Tag.objects.filter(tag_type=tag_type)
+
+ if records is None:
+ return Tag.objects.none()
+ return records
+
+ @staticmethod
+ def get_brand_records_by_request(request, params=None):
+ brand = model_utils.try_get_brand(request)
+
+ if not isinstance(params, dict):
+ params = { }
+
+ if isinstance(request, Request) and hasattr(request, 'query_params'):
+ params = { key: value for key, value in request.query_params.items() } | params
+ elif isinstance(request, HttpRequest) and hasattr(request, 'GET'):
+ params = { key: value for key, value in request.GET.dict().items() } | params
+
+ tag_type = params.pop('tag_type', None)
+ if params.get('all_tags', False) and tag_type is None:
+ records = (
+ Tag.get_brand_assoc_queryset(brand, 'tags') | \
+ Tag.get_brand_assoc_queryset(brand, 'collections')
+ )
+ else:
+ tag_type = tag_type if isinstance(tag_type, int) else 1
+ records = Tag.get_brand_assoc_queryset(brand, tag_type)
+
+ if records is None:
+ return Tag.objects.none()
+
+ search = params.pop('search', None)
+ query = gen_utils.parse_model_field_query(Tag, params, ignored_fields=['description'])
+ if query is not None:
+ records = records.filter(**query)
+
+ if not gen_utils.is_empty_string(search) and len(search) >= 3:
+ records = records.filter(description__icontains=search)
+
+ records = records.order_by('id')
+ return records
+
+ @staticmethod
+ def get_brand_paginated_records_by_request(request, params=None):
+ if not isinstance(params, dict):
+ params = { }
+
+ if isinstance(request, Request) and hasattr(request, 'query_params'):
+ params = { key: value for key, value in request.query_params.items() } | params
+ elif isinstance(request, HttpRequest) and hasattr(request, 'GET'):
+ params = { key: value for key, value in request.GET.dict().items() } | params
+
+ records = Tag.get_brand_records_by_request(request, params)
+ if records is None:
+ return Page(Tag.objects.none(), 0, Paginator([], page_size, allow_empty_first_page=True))
+
+ page = gen_utils.try_value_as_type(params.get('page'), 'int', default=1)
+ page = max(page, 1)
+
+ page_size = params.get('page_size', '1')
+ if page_size not in constants.PAGE_RESULTS_SIZE:
+ page_size = constants.PAGE_RESULTS_SIZE.get('1')
+ else:
+ page_size = constants.PAGE_RESULTS_SIZE.get(page_size)
+
+ pagination = Paginator(records, page_size, allow_empty_first_page=True)
+ try:
+ page_obj = pagination.page(page)
+ except EmptyPage:
+ page_obj = pagination.page(pagination.num_pages)
+ return page_obj
+
class Meta:
ordering = ('description', )
+ verbose_name = _('Tag')
+ verbose_name_plural = _('Tags')
def __str__(self):
return self.description
diff --git a/CodeListLibrary_project/clinicalcode/models/Template.py b/CodeListLibrary_project/clinicalcode/models/Template.py
index c3b5513d1..ed81b64e1 100644
--- a/CodeListLibrary_project/clinicalcode/models/Template.py
+++ b/CodeListLibrary_project/clinicalcode/models/Template.py
@@ -1,13 +1,22 @@
-from django.contrib.auth.models import User
-from django.db.models import JSONField
from django.db import models
+from django.http import HttpRequest
+from django.db.models import Q
from simple_history.models import HistoricalRecords
+from django.core.paginator import EmptyPage, Paginator, Page
+from rest_framework.request import Request
+from django.utils.translation import gettext_lazy as _
+from django.contrib.postgres.fields import ArrayField
+from django.contrib.auth import get_user_model
+from .Brand import Brand
from .EntityClass import EntityClass
from .TimeStampedModel import TimeStampedModel
+from clinicalcode.entity_utils import constants, gen_utils, model_utils
+
+User = get_user_model()
class Template(TimeStampedModel):
- '''
+ """
Template
@desc describes the structure of the data for that type of generic entity
and holds statistics information e.g.
@@ -17,24 +26,109 @@ class Template(TimeStampedModel):
also holds information relating to represents the filterable fields as a hasmap to improve performance
as well as ensuring order is kept through creating a 'layout_order' field and an 'order' field within
the template definition
- '''
+ """
''' Metadata '''
id = models.AutoField(primary_key=True)
name = models.CharField(max_length=250, unique=True)
description = models.TextField(blank=True, null=True)
- definition = JSONField(blank=True, null=True, default=dict)
- entity_class = models.ForeignKey(EntityClass, on_delete=models.SET_NULL, null=True, related_name="entity_class_type")
+ definition = models.JSONField(blank=True, null=True, default=dict)
+ entity_class = models.ForeignKey(EntityClass, on_delete=models.SET_NULL, null=True, related_name='entity_class_type')
template_version = models.IntegerField(null=True, editable=False)
hide_on_create = models.BooleanField(null=False, default=False)
+ ''' Brand behaviour '''
+ brands = ArrayField(models.IntegerField(), blank=True, null=True)
+
''' Instance data '''
- created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name="template_created")
- updated_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name="template_updated")
+ created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='template_created')
+ updated_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='template_updated')
history = HistoricalRecords()
+ ''' Static methods '''
+ @staticmethod
+ def get_verbose_names(*args, **kwargs):
+ return { 'verbose_name': Template._meta.verbose_name, 'verbose_name_plural': Template._meta.verbose_name_plural }
+
+ @staticmethod
+ def get_brand_records_by_request(request, params=None):
+ brand = model_utils.try_get_brand(request)
+
+ records = None
+ if brand is None:
+ records = Template.objects.all()
+ elif isinstance(brand, Brand):
+ vis_rules = brand.get_vis_rules()
+ if isinstance(vis_rules, dict):
+ allow_null = vis_rules.get('allow_null')
+ allowed_brands = vis_rules.get('ids')
+ if isinstance(allowed_brands, list) and isinstance(allow_null, bool) and allow_null:
+ records = Template.objects.filter((Q(brands__isnull=True) | Q(brands__len__lt=1)) | Q(brands__overlap=allowed_brands))
+ elif isinstance(allowed_brands, list):
+ records = Template.objects.filter(brands__overlap=allowed_brands)
+ elif isinstance(allow_null, bool) and allow_null:
+ records = Template.objects.filter((Q(brands__isnull=True) | Q(brands__len__lt=1)) | Q(brands__overlap=[brand.id]))
+
+ if records is None:
+ records = Template.objects.filter(brands__overlap=[brand.id])
+
+ if records is None:
+ return Template.objects.none()
+
+ if not isinstance(params, dict):
+ params = { }
+
+ if isinstance(request, Request) and hasattr(request, 'query_params'):
+ params = { key: value for key, value in request.query_params.items() } | params
+ elif isinstance(request, HttpRequest) and hasattr(request, 'GET'):
+ params = { key: value for key, value in request.GET.dict().items() } | params
+
+ search = params.get('search', None)
+ query = gen_utils.parse_model_field_query(Template, params, ignored_fields=['description'])
+ if query is not None:
+ records = records.filter(**query)
+
+ if not gen_utils.is_empty_string(search) and len(search) >= 3:
+ records = records.filter(Q(name__icontains=search) | Q(description__icontains=search))
+
+ records = records.order_by('id')
+ return records
+
+ @staticmethod
+ def get_brand_paginated_records_by_request(request, params=None):
+ if not isinstance(params, dict):
+ params = { }
+
+ if isinstance(request, Request) and hasattr(request, 'query_params'):
+ params = { key: value for key, value in request.query_params.items() } | params
+ elif isinstance(request, HttpRequest) and hasattr(request, 'GET'):
+ params = { key: value for key, value in request.GET.dict().items() } | params
+
+ records = Template.get_brand_records_by_request(request, params)
+
+ page = gen_utils.try_value_as_type(params.get('page'), 'int', default=1)
+ page = max(page, 1)
+
+ page_size = params.get('page_size', '1')
+ if page_size not in constants.PAGE_RESULTS_SIZE:
+ page_size = constants.PAGE_RESULTS_SIZE.get('1')
+ else:
+ page_size = constants.PAGE_RESULTS_SIZE.get(page_size)
+
+ if records is None:
+ return Page(Template.objects.none(), 0, Paginator([], page_size, allow_empty_first_page=True))
+
+ pagination = Paginator(records, page_size, allow_empty_first_page=True)
+ try:
+ page_obj = pagination.page(page)
+ except EmptyPage:
+ page_obj = pagination.page(pagination.num_pages)
+ return page_obj
+
+
+ ''' Public methods '''
def save(self, *args, **kwargs):
- '''
+ """
[!] Note:
1. The 'template_version' field is computed from JSONB data - it's non-editable.
When trying to query templates using historical records, the 'template_version' field can be used safely
@@ -42,10 +136,10 @@ def save(self, *args, **kwargs):
2. 'layout_order' fields are added to the template.definition when saving and can be queried using get_ordered_definition within template_utils
3. Entity type/prefix can be queried through template.entity_class
- '''
+ """
super(Template, self).save(*args, **kwargs)
-
+
def save_without_historical_record(self, *args, **kwargs):
self.skip_history_when_saving = True
try:
@@ -53,9 +147,15 @@ def save_without_historical_record(self, *args, **kwargs):
finally:
del self.skip_history_when_saving
return ret
-
+
+
+ ''' Meta '''
class Meta:
ordering = ('name', )
+ verbose_name = _('Template')
+ verbose_name_plural = _('Templates')
+
+ ''' Dunder methods '''
def __str__(self):
return self.name
diff --git a/CodeListLibrary_project/clinicalcode/models/WorkingSet.py b/CodeListLibrary_project/clinicalcode/models/WorkingSet.py
index 0a8e20dc2..4cd64f0bb 100644
--- a/CodeListLibrary_project/clinicalcode/models/WorkingSet.py
+++ b/CodeListLibrary_project/clinicalcode/models/WorkingSet.py
@@ -3,14 +3,17 @@
A working set is a list of columns from a number of Concepts.
'''
-from django.contrib.auth.models import Group, User
+from django.contrib.auth.models import Group
from django.db.models import JSONField
from django.db import models
from simple_history.models import HistoricalRecords
+from django.contrib.auth import get_user_model
from .TimeStampedModel import TimeStampedModel
from ..entity_utils import constants
+User = get_user_model()
+
class WorkingSet(TimeStampedModel):
name = models.CharField(max_length=250)
author = models.CharField(max_length=250)
diff --git a/CodeListLibrary_project/clinicalcode/models/WorkingSetTagMap.py b/CodeListLibrary_project/clinicalcode/models/WorkingSetTagMap.py
index 27eab08d1..942147eb6 100644
--- a/CodeListLibrary_project/clinicalcode/models/WorkingSetTagMap.py
+++ b/CodeListLibrary_project/clinicalcode/models/WorkingSetTagMap.py
@@ -1,11 +1,13 @@
-from django.contrib.auth.models import User
from django.db import models
from simple_history.models import HistoricalRecords
+from django.contrib.auth import get_user_model
from .Tag import Tag
from .WorkingSet import WorkingSet
from .TimeStampedModel import TimeStampedModel
+User = get_user_model()
+
class WorkingSetTagMap(TimeStampedModel):
workingset = models.ForeignKey(WorkingSet, on_delete=models.CASCADE)
tag = models.ForeignKey(Tag, on_delete=models.CASCADE)
diff --git a/CodeListLibrary_project/clinicalcode/models/__init__.py b/CodeListLibrary_project/clinicalcode/models/__init__.py
index b29c33a40..73954aced 100644
--- a/CodeListLibrary_project/clinicalcode/models/__init__.py
+++ b/CodeListLibrary_project/clinicalcode/models/__init__.py
@@ -43,7 +43,15 @@
from .Template import Template
from .GenericEntity import GenericEntity
from .PublishedGenericEntity import PublishedGenericEntity
+from .Organisation import (
+ Organisation,
+ OrganisationMembership,
+ OrganisationAuthority,
+ OrganisationInvite
+)
+from .CCI_CODES import CCI_CODES
+from .PHYSICIAN_FEE_CODES import PHYSICIAN_FEE_CODES
from .ATCDDD_CODES import ATCDDD_CODES
from .ICD10CA_CODES import ICD10CA_CODES
from .ICD10CM_CODES import ICD10CM_CODES
diff --git a/CodeListLibrary_project/clinicalcode/tasks.py b/CodeListLibrary_project/clinicalcode/tasks.py
index 381621a46..66d7f55fd 100644
--- a/CodeListLibrary_project/clinicalcode/tasks.py
+++ b/CodeListLibrary_project/clinicalcode/tasks.py
@@ -20,7 +20,7 @@ def send_review_email(request,data):
@shared_task(bind=True)
def send_scheduled_email(self):
- email_subject = 'Weekly email Concept Library'
+ email_subject = 'Weekly Email'
email_content = email_utils.get_scheduled_email_to_send()
owner_ids = list(set([c['owner_id'] for c in email_content]))
diff --git a/CodeListLibrary_project/clinicalcode/templatetags/breadcrumbs.py b/CodeListLibrary_project/clinicalcode/templatetags/breadcrumbs.py
index f89e0ed24..46af2881f 100644
--- a/CodeListLibrary_project/clinicalcode/templatetags/breadcrumbs.py
+++ b/CodeListLibrary_project/clinicalcode/templatetags/breadcrumbs.py
@@ -112,12 +112,16 @@ class BreadcrumbsNode(template.Node):
DEFAULT_STR = ''
def __init__(self, params, nodelist):
- self.request = template.Variable('request')
+ self.reqvar = template.Variable('request')
self.params = params
self.nodelist = nodelist
def __is_brand_token(self, token):
- return Brand.objects.filter(name__iexact=token).exists()
+ if not isinstance(token, str):
+ return False
+
+ element = next((x for x in Brand.all_instances() if x.name.lower() == token.lower()), None)
+ return element is not None
def __is_valid_token(self, token):
if self.__is_brand_token(token):
@@ -184,7 +188,7 @@ def map_resolver(self, path, rqst):
return ''
def render(self, context):
- rqst = self.request.resolve(context)
+ rqst = self.reqvar.resolve(context)
path = rqst.path.split('/')
if len(path) > 0:
diff --git a/CodeListLibrary_project/clinicalcode/templatetags/cards_dropdown_render.py b/CodeListLibrary_project/clinicalcode/templatetags/cards_dropdown_render.py
index 07e1b45fc..3c289a458 100644
--- a/CodeListLibrary_project/clinicalcode/templatetags/cards_dropdown_render.py
+++ b/CodeListLibrary_project/clinicalcode/templatetags/cards_dropdown_render.py
@@ -4,14 +4,17 @@
register = template.Library()
@register.inclusion_tag('components/navigation/dropdown_list_item.html', takes_context=True, name='card_render')
-def render_card(context,url_drop, *args, **kwargs):
- card_context = {"title": "", "description": "", "icon": "about_icon"}
-
- card_context["title"] = context['lnk'].get('title','')
- card_context["description"] = context['lnk'].get('description','')
- card_context["icon"] = context['lnk'].get('svg','')
- card_context["url"] = url_drop
- if isinstance(context['lnk'].get('page_name', ''), list):
- card_context["list_pages"] = context['lnk'].get('page_name','')
-
- return card_context
+def render_card(context, url_drop, *args, **kwargs):
+ lnk = context.get('lnk', {})
+ ctx = {
+ 'title': lnk.get('title', ''),
+ 'description': lnk.get('description', ''),
+ 'icon': lnk.get('svg',''),
+ 'url': url_drop,
+ }
+
+ pages = lnk.get('page_name')
+ if isinstance(pages, list):
+ ctx |= { 'list_pages': pages }
+
+ return ctx
diff --git a/CodeListLibrary_project/clinicalcode/templatetags/cl_extras.py b/CodeListLibrary_project/clinicalcode/templatetags/cl_extras.py
index 3b400f1b0..8fac346d8 100644
--- a/CodeListLibrary_project/clinicalcode/templatetags/cl_extras.py
+++ b/CodeListLibrary_project/clinicalcode/templatetags/cl_extras.py
@@ -7,8 +7,10 @@
from ..entity_utils import gen_utils
from ..entity_utils.constants import TypeStatus
+
register = template.Library()
+
@register.simple_tag(takes_context=True)
def render_og_tags(context, *args, **kwargs):
request = context['request']
@@ -20,8 +22,12 @@ def render_og_tags(context, *args, **kwargs):
title = settings.APP_TITLE
embed = settings.APP_EMBED_ICON.format(logo_path=settings.APP_LOGO_PATH)
else:
- title = brand.site_title
- embed = settings.APP_EMBED_ICON.format(logo_path=brand.logo_path)
+ title = brand.get('site_title') if isinstance(brand, dict) else brand.site_title
+ lpath = brand.get('logo_path') if isinstance(brand, dict) else brand.logo_path
+ if lpath is None or gen_utils.is_empty_string(lpath):
+ lpath = settings.APP_LOGO_PATH
+
+ embed = settings.APP_EMBED_ICON.format(logo_path=lpath)
desc = kwargs.pop('desc', settings.APP_DESC.format(app_title=title))
title = kwargs.pop('title', title)
@@ -53,6 +59,11 @@ def render_og_tags(context, *args, **kwargs):
)
)
+@register.filter
+def get_type(value):
+ """Resolves the type of the specified value"""
+ return type(value).__name__
+
@register.filter(name='from_phenotype')
def from_phenotype(value, index, default=''):
if index in value:
@@ -85,46 +96,42 @@ def has_group(user, group_name):
return user.groups.filter(name=group_name).exists()
@register.filter
-def islist(value):
+def is_list(value):
"""Check if value is of type list"""
- return type(value) == list
+ return isinstance(value, list)
@register.filter
-def tolist(value, arg):
+def to_list(value, arg):
"""Convert comma separated value to a list of type arg"""
-
if arg == "int":
return [int(t) for t in value.split(',')]
- else:
- return [str(t) for t in value.split(',')]
+ return [str(t) for t in value.split(',')]
@register.filter
-def toString(value):
+def to_string(value):
"""Convert value to string"""
return str(value)
@register.filter
-def addStr(value, arg):
+def add_str(value, arg):
"""concatenate value & arg"""
return str(value) + str(arg)
@register.filter
-def getBrandLogo(value):
+def get_brand_logo(value):
"""get brand logos"""
return f'/static/img/brands/{value}/apple-touch-icon.png'
@register.filter
def get_ws_type_name(type_int):
- '''
- get working set type name
- '''
+ """get working set type name"""
return str([t[1] for t in TypeStatus.Type_status if t[0]==type_int][0])
@register.filter
def get_title(txt, makeCapital=''):
- '''
+ """
get title case
- '''
+ """
txt = txt.replace('_', ' ')
txt = txt.title()
if makeCapital.strip() != '':
@@ -133,16 +140,12 @@ def get_title(txt, makeCapital=''):
@register.filter
def is_in_list(txt, list_values):
- '''
- check is value is in list
- '''
+ """check is value is in list"""
return (txt in [i.strip() for i in list_values.split(',')])
@register.filter
def concat_str(txt0, txt1):
- '''
- Safely concatenate the given string(s)
- '''
+ """Safely concatenate the given string(s)"""
if txt0 is not None and txt1 is not None:
return '%s %s' % (str(txt0), str(txt1),)
elif txt0 is not None:
@@ -153,9 +156,7 @@ def concat_str(txt0, txt1):
@register.filter
def concat_doi(details, doi):
- '''
- concat publications details + doi
- '''
+ """concat publications details + doi"""
details = str(details)
if doi is None or (isinstance(doi, str) and (len(doi.strip()) < 1 or doi.isspace())):
return details
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 sidemenu item for detail pages
- '''
- params = {
- # Any future modifiers
- }
-
- try:
- parsed = token.split_contents()[1:]
- if len(parsed) > 0 and parsed[0] == 'with':
- parsed = parsed[1:]
-
- for param in parsed:
- ctx = param.split('=')
- params[ctx[0]] = eval(ctx[1])
- except ValueError:
- raise TemplateSyntaxError('Unable to parse wizard aside renderer tag')
-
- nodelist = parser.parse(('endrender_wizard_sidemenu'))
- parser.delete_first_token()
- return EntityWizardAside(params, nodelist)
-
-
-class EntityWizardAside(template.Node):
- def __init__(self, params, nodelist):
- self.request = template.Variable('request')
- self.params = params
- self.nodelist = nodelist
-
- def render(self, context):
- request = self.request.resolve(context)
- output = ''
- template = context.get('template', None)
- if template is None:
- return output
-
- # We should be getting the FieldTypes.json related to the template
- detail_page_sections = []
- template_sections = template.definition.get('sections')
- template_sections.extend(constants.DETAIL_PAGE_APPENDED_SECTIONS)
- for section in template_sections:
- if section.get('hide_on_detail', False):
- continue
-
- if section.get('requires_auth', False):
- if not request.user.is_authenticated:
- #print('SECTION: requires_auth')
- continue
-
- if section.get('do_not_show_in_production', False):
- if (not settings.IS_DEMO and not settings.IS_DEVELOPMENT_PC):
- #print('SECTION: do_not_show_in_production')
- continue
-
- detail_page_sections.append(section)
-
- # still need to handle: section 'hide_if_empty' ???
-
- output = render_to_string(constants.DETAIL_WIZARD_ASIDE, {
- 'detail_page_sections': detail_page_sections
- })
-
- return output
-
-
-@register.tag(name='render_wizard_sections_detail_pg')
-def render_steps_wizard(parser, token):
- '''
- Responsible for rendering the sections for detail pages
- '''
- params = {
- # Any future modifiers
- }
-
- try:
- parsed = token.split_contents()[1:]
- if len(parsed) > 0 and parsed[0] == 'with':
- parsed = parsed[1:]
-
- for param in parsed:
- ctx = param.split('=')
- params[ctx[0]] = eval(ctx[1])
- except ValueError:
- raise TemplateSyntaxError('Unable to parse wizard aside renderer tag')
-
- nodelist = parser.parse(('endrender_wizard_sections_detail_pg'))
- parser.delete_first_token()
- return EntityWizardSections(params, nodelist)
-
-
-def get_data_sources(ds_ids, info, default=None):
- '''
- Tries to get the sourced value of data_sources id/name/url
- '''
- validation = template_utils.try_get_content(info, 'validation')
- if validation is None:
- return default
-
- try:
- source_info = validation.get('source')
- model = apps.get_model(app_label='clinicalcode', model_name=source_info.get('table'))
- # relative = 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
- # }
-
- if ds_ids:
- queryset = model.objects.filter(id__in=ds_ids)
- if queryset.exists():
- #queryset = model.objects.get(id__in = ds_ids)
- return queryset
-
- return default
- except:
- return default
-
-
-def get_template_creation_data(entity, layout, field, request=None, default=None):
- '''
- Used to retrieve assoc. data values for specific keys, e.g.
- concepts, in its expanded format for use with create/update pages
- '''
- data = template_utils.get_entity_field(entity, field)
- info = template_utils.get_layout_field(layout, field)
- if not info or not data:
- return default
-
- if info.get('is_base_field'):
- info = template_utils.try_get_content(constants.metadata, field)
-
- validation = template_utils.try_get_content(info, 'validation')
- if validation is None:
- return default
-
- field_type = template_utils.try_get_content(validation, 'type')
- if field_type is None:
- return default
-
- if field_type == 'concept':
- return concept_utils.get_concept_headers(data)
- elif field_type == 'int_array':
- source_info = validation.get('source')
- tree_models = source_info.get('trees') if isinstance(source_info, dict) else None
- model_source = source_info.get('model')
- if isinstance(tree_models, list) and isinstance(model_source, str):
- try:
- model = apps.get_model(app_label='clinicalcode', model_name=model_source)
- output = model.get_detail_data(node_ids=data, default=default)
- if isinstance(output, list):
- return output
- except Exception as e:
- # Logging
- return default
- if info.get('field_type') == 'data_sources':
- return get_data_sources(data, info, default=default)
-
- if template_utils.is_metadata(entity, field):
- return template_utils.get_metadata_value_from_source(entity, field, default=default)
-
- return template_utils.get_template_data_values(entity, layout, field, default=default)
-
-
-class EntityWizardSections(template.Node):
- SECTION_END = render_to_string(template_name=constants.DETAIL_WIZARD_SECTION_END)
-
- def __init__(self, params, nodelist):
- self.request = template.Variable('request')
- self.params = params
- self.nodelist = nodelist
-
- def __try_get_entity_value(self, template, entity, field):
- value = get_template_creation_data(entity, template, field, request=self.request, default=None)
- if value is None:
- return template_utils.get_entity_field(entity, field)
-
- return value
-
- def __try_render_item(self, **kwargs):
- try:
- html = render_to_string(**kwargs)
- except:
- return ''
- else:
- return html
-
- def __try_get_computed(self, request, field):
- struct = template_utils.get_layout_field(constants.metadata, field)
- if struct is None:
- return
-
- validation = template_utils.try_get_content(struct, 'validation')
- if validation is None:
- return
-
- if not validation.get('computed'):
- return
-
- if field == 'group':
- return self.user_groups
-
- def __append_section(self, output, section_content):
- if gen_utils.is_empty_string(section_content):
- return output
- return output + section_content + self.SECTION_END
-
- def __generate_wizard(self, request, context):
- output = ''
- template = context.get('template', None)
- entity = context.get('entity', None)
- if template is None:
- return output
-
- flat_ctx = context.flatten()
- is_prod_env = not settings.IS_DEMO and not settings.IS_DEVELOPMENT_PC
- is_unauthenticated = not request.user or not request.user.is_authenticated
-
- merged_definition = template_utils.get_merged_definition(template, default={})
- template_fields = template_utils.try_get_content(merged_definition, 'fields')
- template_fields.update(constants.DETAIL_PAGE_APPENDED_FIELDS)
- template.definition['fields'] = template_fields
-
- # We should be getting the FieldTypes.json related to the template
- field_types = constants.FIELD_TYPES
- template_sections = template.definition.get('sections')
- #template_sections.extend(constants.DETAIL_PAGE_APPENDED_SECTIONS)
- for section in template_sections:
- is_hidden = (
- section.get('hide_on_detail', False)
- or section.get('hide_on_detail', False)
- or (section.get('requires_auth', False) and is_unauthenticated)
- or (section.get('do_not_show_in_production', False) and is_prod_env)
- )
- if is_hidden:
- continue
-
- section['hide_description'] = True
- section_content = self.__try_render_item(template_name=constants.DETAIL_WIZARD_SECTION_START
- , request=request
- , context=flat_ctx | {'section': section})
-
- field_count = 0
- for field in section.get('fields'):
- template_field = template_utils.get_field_item(template.definition, 'fields', field)
- if not template_field:
- template_field = template_utils.try_get_content(constants.metadata, field)
-
- component = template_utils.try_get_content(field_types, template_field.get('field_type')) if template_field else None
- if component is None:
- continue
-
- active = template_field.get('active', False)
- is_hidden = (
- (isinstance(active, bool) and not active)
- or template_field.get('hide_on_detail')
- or (template_field.get('requires_auth', False) and is_unauthenticated)
- or (template_field.get('do_not_show_in_production', False) and is_prod_env)
- )
- if is_hidden:
- continue
-
- if template_field.get('is_base_field', False):
- template_field = constants.metadata.get(field) | template_field
-
- if template_utils.is_metadata(GenericEntity, field):
- field_data = template_utils.try_get_content(constants.metadata, field)
- else:
- field_data = template_utils.get_layout_field(template, field)
-
- component['field_name'] = field
- component['field_data'] = '' if field_data is None else field_data
-
- desc = template_utils.try_get_content(template_field, 'description')
- if desc is not None:
- component['description'] = desc
- component['hide_input_details'] = False
- else:
- component['hide_input_details'] = True
-
- # don't show field description in detail page
- component['hide_input_details'] = True
-
- component['hide_input_title'] = False
- if len(section.get('fields')) <= 1:
- # don't show field title if it is the only field in the section
- component['hide_input_title'] = True
-
- if entity:
- component['value'] = self.__try_get_entity_value(template, entity, field)
- else:
- component['value'] = ''
-
- if 'sort' in component['field_data'] and component['value'] is not None:
- component['value'] = sorted(component['value'], **component['field_data']['sort'])
-
- if template_field.get('hide_if_empty', False):
- comp_value = component.get('value')
- if comp_value is None or str(comp_value) == '' or comp_value == [] or comp_value == {}:
- continue
-
- output_type = component.get("output_type")
- uri = f'{constants.DETAIL_WIZARD_OUTPUT_DIR}/{output_type}.html'
- field_count += 1
- section_content += self.__try_render_item(template_name=uri, request=request,
- context=flat_ctx | {'component': component})
-
- if field_count > 0:
- output = self.__append_section(output, section_content)
-
- return output
-
- def render(self, context):
- if not isinstance(self.request, HttpRequest):
- self.request = self.request.resolve(context)
-
- if self.request and self.request.user is not None and not self.request.user.is_anonymous:
- self.user_groups = permission_utils.get_user_groups(self.request)
- else:
- self.user_groups = []
-
- return self.__generate_wizard(self.request, context)
diff --git a/CodeListLibrary_project/clinicalcode/templatetags/entity_publish_renderer.py b/CodeListLibrary_project/clinicalcode/templatetags/entity_publish_renderer.py
index b5cd6d49a..6a21767bf 100644
--- a/CodeListLibrary_project/clinicalcode/templatetags/entity_publish_renderer.py
+++ b/CodeListLibrary_project/clinicalcode/templatetags/entity_publish_renderer.py
@@ -2,7 +2,10 @@
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
+from .entity_renderer import get_template_entity_name
register = template.Library()
@@ -15,7 +18,7 @@ def render_errors_approval(context, *args, **kwargs):
if not context['is_entity_user'] and not context['is_moderator']:
- message = 'You must be the owner to publish.'
+ message = 'You must be the owner or have organisation permissions to publish.'
errors.append(message)
if not context['entity_has_data']:
@@ -23,15 +26,15 @@ def render_errors_approval(context, *args, **kwargs):
errors.append(message)
else:
if not context['is_allowed_view_children']:
- message = 'You must have view access to all Concepts/Phenotypes.'
+ message = 'You must have view access to all children of this entity to publish.'
errors.append(message)
if not context['all_not_deleted']:
- message = 'All Concepts/Phenotypes must not be deleted.'
+ message = 'All entities must not be deleted.'
errors.append(message)
if not context['all_are_published']:
- message = 'All Concepts/Phenotypes must be published.'
+ message = 'All entities must be published.'
errors.append(message)
return {'errors': errors}
@@ -39,36 +42,44 @@ 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")
- 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")
+ entity = context.get('entity')
+ request = context.get('request')
+
+ entity_cls_name = get_template_entity_name(entity.template.entity_class, entity.template)
+ publish_checks = publish_utils.check_entity_to_publish(request, entity.id, entity.history_id)
+ user_is_moderator = publish_checks['is_moderator']
+ user_is_publisher = publish_checks['is_publisher']
+ user_allowed_publish = publish_checks['allowed_to_publish']
+ user_entity_access = permission_utils.can_user_edit_entity(request, entity.id) #entity.owner == 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}),
+ 'url_decline': reverse('generic_entity_decline', kwargs={'pk': entity.id, 'history_id': entity.history_id}),
+ 'url_redirect': reverse('entity_history_detail', kwargs={'pk': entity.id, 'history_id': entity.history_id}),
}
if user_is_moderator:
if not context['live_ver_is_deleted']:
if context["approval_status"]== constants.APPROVAL_STATUS.PENDING and context["is_latest_pending_version"]:
button_context.update({'class_modal':"primary-btn text-warning bold dropdown-btn__label ",
- 'Button_type':"Approve",
- 'url': reverse('generic_entity_publish', kwargs={'pk': context['entity'].id, 'history_id': context['entity'].history_id}),
+ 'Button_type': "Approve",
+ 'url': reverse('generic_entity_publish', kwargs={'pk': entity.id, 'history_id': entity.history_id}),
'title': "Requires approval"
- })
+ })
+ elif context['approval_status'] == constants.APPROVAL_STATUS.REJECTED:
+ button_context.update({'class_modal':"primary-btn text-danger bold dropdown-btn__label",
+ 'url': reverse('generic_entity_publish', kwargs={'pk': entity.id, 'history_id': entity.history_id}),
+ 'Button_type': "Publish",
+ 'title': f"Approve declined {entity_cls_name}"
+ })
+ elif user_allowed_publish:
+ button_context.update({'class_modal':"primary-btn bold dropdown-btn__label ",
+ 'url': reverse('generic_entity_publish', kwargs={'pk': entity.id, 'history_id': entity.history_id}),
+ 'Button_type': "Publish",
+ 'title': "Publish immediately"
+ })
else:
- if context['approval_status'] == constants.APPROVAL_STATUS.REJECTED:
- button_context.update({'class_modal':"primary-btn text-danger bold dropdown-btn__label",
- 'url': reverse('generic_entity_publish', kwargs={'pk': context['entity'].id, 'history_id': context['entity'].history_id}),
- 'Button_type':"Publish",
- 'title': "Approve declined entity"
- })
- else:
- 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",
- 'title': "Publish immediately"
- })
+ button_context.update({ 'pub_btn_hidden': True })
else:
if context["is_published"] and context["approval_status"] == constants.APPROVAL_STATUS.APPROVED:
button_context.update({'class_modal':"primary-btn__text-success bold dropdown-btn__label",
@@ -78,41 +89,41 @@ def render_publish_button(context, *args, **kwargs):
else:
button_context.update({'class_modal':"primary-btn bold text-danger dropdown-btn__label",
'disabled': 'true',
- 'Button_type':"Entity is deleted",
- 'title': "Deleted Phenotypes cannot be published!"
+ 'Button_type': f"{entity_cls_name} is deleted",
+ 'title': f"Deleted {entity_cls_name}s cannot be published!"
})
return button_context
- elif user_entity_access:
- 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"]:
+ elif user_allowed_publish:
+ 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}),
+ 'url': reverse('generic_entity_publish', kwargs={'pk': entity.id, 'history_id': entity.history_id}),
'Button_type':"Publish",
'title': "Publish immediately"
})
else:
button_context.update({'class_modal':"primary-btn bold dropdown-btn__label",
'Button_type': "Request publication",
- 'url': reverse('generic_entity_request_publish', kwargs={'pk': context['entity'].id, 'history_id': context['entity'].history_id}),
- 'title': "Needs to be approved"
+ 'url': reverse('generic_entity_request_publish', kwargs={'pk': entity.id, 'history_id': entity.history_id}),
+ 'title': "Request publication"
})
- 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}),
+ 'url': reverse('generic_entity_publish', kwargs={'pk': entity.id, 'history_id': entity.history_id}),
'Button_type': "Publish",
'title': "Publish immediately"
})
else:
if context["is_published"] and context["approval_status"] == constants.APPROVAL_STATUS.APPROVED:
button_context.update({'class_modal':"primary-btn__text-success bold dropdown-btn__label",
- 'title': f"This version is already {constants.APPROVAL_STATUS.APPROVED.name.lower()} "
+ 'title': f"This version is already approved."
})
elif context["live_ver_is_deleted"]:
button_context.update({'class_modal':"primary-btn bold text-danger dropdown-btn__label",
'disabled': 'true',
'Button_type': "Entity is deleted",
- 'title': "Deleted Phenotypes cannot be published!"
+ 'title': f"Deleted {entity_cls_name}s cannot be published!"
})
elif context["approval_status"] == constants.APPROVAL_STATUS.REJECTED:
@@ -127,9 +138,12 @@ def render_publish_button(context, *args, **kwargs):
'disabled': 'true',
'Button_type': 'Pending Approval',
'title': "This version is pending approval.",
- 'url': reverse('generic_entity_publish', kwargs={'pk': context['entity'].id, 'history_id': context['entity'].history_id}),
+ 'url': reverse('generic_entity_publish', kwargs={'pk': entity.id, 'history_id': entity.history_id}),
})
else:
button_context.update({ 'pub_btn_hidden': True })
+
+ else:
+ button_context.update({ 'pub_btn_hidden': True })
- return button_context
+ return button_context
diff --git a/CodeListLibrary_project/clinicalcode/templatetags/entity_renderer.py b/CodeListLibrary_project/clinicalcode/templatetags/entity_renderer.py
index 0b3890feb..9a7e55c3b 100644
--- a/CodeListLibrary_project/clinicalcode/templatetags/entity_renderer.py
+++ b/CodeListLibrary_project/clinicalcode/templatetags/entity_renderer.py
@@ -1,28 +1,45 @@
+from copy import deepcopy
from django import template
+from datetime import datetime
+from django.apps import apps
from django.conf import settings
from django.urls import reverse
+from django.db.models import Model
from django.utils.html import _json_script_escapes as json_script_escapes
from jinja2.exceptions import TemplateSyntaxError, FilterArgumentError
from django.template.loader import render_to_string
-from django.utils.translation import gettext_lazy as _
from django.utils.safestring import mark_safe
-from datetime import datetime
+from django.utils.translation import gettext_lazy as _
import re
import json
-import warnings
+import inspect
+import numbers
+import logging
-from ..entity_utils import permission_utils, template_utils, search_utils, model_utils, create_utils, gen_utils, constants
from ..models.Brand import Brand
-from ..models.GenericEntity import GenericEntity
+from ..entity_utils import (
+ concept_utils, permission_utils, template_utils, search_utils,
+ model_utils, create_utils, gen_utils, constants
+)
+
register = template.Library()
+logger = logging.getLogger(__name__)
+
@register.simple_tag
def sort_by_alpha(arr, column="name", order="desc"):
"""
- Sorts an array of objects by the defined column, and orders by
- asc/desc given its params
+ Sorts a `list` of objects by the defined column, and orders by asc/desc given its params; erroneous inputs are caught and ignored
+
+ Args:
+ arr (list|any): an array of objects to sort
+ column (str): specify a column of the object to sort by; defaults to `name`
+ order (str): specify one of `asc` or `desc` to set the array sort order; defaults to `desc`
+
+ Returns:
+ The sorted (list) if applicable; returns the `arr` input argument if invalid
"""
sorted_arr = None
try:
@@ -32,49 +49,286 @@ def sort_by_alpha(arr, column="name", order="desc"):
sorted_arr = arr
return sorted_arr
+
+@register.simple_tag(takes_context=True)
+def get_brand_map_rules(context, brand=None, default=constants.DEFAULT_CONTENT_MAPPING):
+ if brand is None:
+ brand = model_utils.try_get_brand(context.get('request'))
+ elif isinstance(brand, str) and not gen_utils.is_empty_string(brand):
+ brand = model_utils.try_get_instance(Brand, name__iexact=brand)
+ elif isinstance(brand, int) and brand >= 0:
+ brand = model_utils.try_get_instance(Brand, pk=brand)
+ elif not isinstance(brand, Brand) and (not inspect.isclass(brand) or not issubclass(brand, Brand)):
+ brand = None
+
+ if brand is None:
+ return default
+
+ return brand.get_map_rules(default=default)
+
+
+@register.simple_tag(takes_context=True)
+def fmt_brand_mapped_string(context, target, brand=None, default=None):
+ brand = get_brand_map_rules(context, brand, default=None)
+ if brand is None:
+ if isinstance(default, str):
+ return default
+ brand = constants.DEFAULT_CONTENT_MAPPING if not isinstance(default, dict) else default
+ return target.format(**brand)
+
+
+@register.simple_tag(takes_context=True)
+def get_brand_mapped_string(context, target, default=None, brand=None):
+ if not isinstance(default, str):
+ raise Exception('Failed to map string, expected a default value of type str, got %s' % type(default).__name__)
+
+ if not isinstance(target, str):
+ raise Exception('Expected mapping key as str, got %s' % type(target).__name__)
+
+ brand = get_brand_map_rules(context, brand)
+ return brand.get(target, default)
+
+
@register.simple_tag
def get_brand_base_icons(brand):
+ """
+ Gets the brand-related favicon & apple-touch-icons; defaults to base icons if not applicable for this brand
+
+ Args:
+ brand (Brand|dict|None): the brand from which to resolve the info
+
+ Returns:
+ A (dict) with key-value pairs specifying the `favicon` and `apple` (`apple-touch-icon`) path
+ """
path = settings.APP_LOGO_PATH
- if brand and getattr(brand, 'logo_path'):
- path = brand.logo_path
+ if brand and hasattr(brand, 'logo_path') and getattr(brand, 'logo_path', None):
+ path = brand.logo_path if not gen_utils.is_empty_string(brand.logo_path) else path
return {
'favicon': path + 'favicon-32x32.png',
'apple': path + 'apple-touch-icon.png',
}
+
@register.simple_tag
def get_brand_base_title(brand):
"""
- Gets the brand-related site title if available, otherwise returns
- the APP_TITLE per settings.py
+ Gets the brand-related site title if available, otherwise returns the `APP_TITLE` per `settings.py`
+
+ Args:
+ brand (Brand|dict|None): the brand from which to resolve the info
+
+ Returns:
+ A (str) specifying the site title
"""
- if not brand or not getattr(brand, 'site_title'):
+ if isinstance(brand, dict):
+ title = brand.get('site_title', None)
+ elif isinstance(brand, Model):
+ title = getattr(brand, 'site_title', None) if hasattr(brand, 'site_title') else None
+ else:
+ title = None
+
+ if title is None or gen_utils.is_empty_string(title):
return settings.APP_TITLE
- return brand.site_title
+ return title
+
+
+@register.simple_tag
+def get_brand_base_desc(brand):
+ """
+ Gets the brand-related site description if available, otherwise returns the base embed description (see `APP_DESC` in `settings.py`)
+
+ Args:
+ brand (Brand|dict|None): the brand from which to resolve the info
+
+ Returns:
+ A (str) specifying the site description
+ """
+ if isinstance(brand, dict):
+ desc = brand.get('site_description', None)
+ elif isinstance(brand, Model):
+ desc = getattr(brand, 'site_description', None) if hasattr(brand, 'site_description') else None
+ else:
+ desc = None
+
+ if desc is None or gen_utils.is_empty_string(desc):
+ return settings.APP_DESC.format(app_title=settings.APP_TITLE)
+ return desc
+
+
+@register.simple_tag(takes_context=True)
+def get_brand_base_website(context, brand=None):
+ """
+ Gets the brand-related site description if available, otherwise returns the base embed description (see `APP_DESC` in `settings.py`)
+
+ Args:
+ brand (Brand|dict|None): the brand from which to resolve the info
+
+ Returns:
+ A (str) specifying the site description
+ """
+ request = context.get('request')
+ if brand is None:
+ brand = model_utils.try_get_brand(request)
+ elif isinstance(brand, str) and not gen_utils.is_empty_string(brand):
+ brand = model_utils.try_get_instance(Brand, name__iexact=brand)
+ elif isinstance(brand, int) and brand >= 0:
+ brand = model_utils.try_get_instance(Brand, pk=brand)
+ elif not isinstance(brand, dict) and not isinstance(brand, Brand) and (not inspect.isclass(brand) or not issubclass(brand, Brand)):
+ brand = None
+
+ if isinstance(brand, dict):
+ url = brand.get('website', None)
+ elif isinstance(brand, Model):
+ url = getattr(brand, 'website', None) if hasattr(brand, 'website') else None
+ else:
+ url = None
+
+ if url is not None and not gen_utils.is_empty_string(url):
+ return url
+
+ if brand is not None:
+ return f'/{brand.name}'
+ return request.build_absolute_uri()
+
+
+@register.simple_tag(takes_context=True)
+def get_brand_citation_req(context, brand=None):
+ """
+ Gets the brand-related citation requirement if available, otherwise returns the base embed description (see `APP_CITATION` in `settings.py`)
+
+ Args:
+ context (RequestContext): the page's request context (auto-prepended)
+ brand (Request|Brand|dict|None): the brand from which to resolve the info
+
+ Returns:
+ A (str) specifying the site citation requirement message
+ """
+ request = context.get('request')
+ if brand is None:
+ brand = model_utils.try_get_brand(request)
+ elif isinstance(brand, str) and not gen_utils.is_empty_string(brand):
+ brand = model_utils.try_get_instance(Brand, name__iexact=brand)
+ elif isinstance(brand, int) and brand >= 0:
+ brand = model_utils.try_get_instance(Brand, pk=brand)
+ elif not isinstance(brand, Brand) and (not inspect.isclass(brand) or not issubclass(brand, Brand)):
+ brand = None
+
+ if brand is not None:
+ map_rules = brand.get_map_rules()
+ citation = map_rules.get('citation')
+ website = map_rules.get('website')
+ else:
+ map_rules = constants.DEFAULT_CONTENT_MAPPING
+ citation = None
+ website = map_rules.get('website')
+
+ has_valid_website = isinstance(website, str) and not gen_utils.is_empty_string(website)
+ if isinstance(brand, dict):
+ title = brand.get('site_title', None)
+ website = brand.get('website', None) if not has_valid_website else website
+ elif isinstance(brand, Model):
+ title = getattr(brand, 'site_title', None) if hasattr(brand, 'site_title') else None
+ website = getattr(brand, 'website', None) if not has_valid_website and hasattr(brand, 'website') else website
+ else:
+ title = None
+ website = website if has_valid_website else None
+
+ if title is None or gen_utils.is_empty_string(title):
+ title = settings.APP_TITLE
+
+ if website is None or gen_utils.is_empty_string(website):
+ website = request.build_absolute_uri()
+
+ if not isinstance(citation, str) or gen_utils.is_empty_string(citation):
+ citation = settings.APP_CITATION
+
+ return citation.format(
+ **map_rules,
+ app_title=title,
+ brand_name=brand.name if brand else 'SAIL',
+ brand_website=website
+ )
+
@register.simple_tag
def get_brand_base_embed_desc(brand):
"""
- Gets the brand-related site desc if available, otherwise returns
- the APP_DESC per settings.py
+ Gets the brand-related embedding desc if available, otherwise returns the `APP_DESC` per `settings.py` (OG tags)
+
+ Note:
+ - Interpolated by the `Brand`'s `site_title` attribute
+
+ Args:
+ brand (Brand|dict|None): the brand from which to resolve the info
+
+ Returns:
+ A (str) specifying the embed description
"""
- if not brand or not getattr(brand, 'site_title'):
+ if isinstance(brand, dict):
+ title = brand.get('site_title', None)
+ elif isinstance(brand, Model):
+ title = getattr(brand, 'site_title', None) if hasattr(brand, 'site_title') else None
+ else:
+ title = None
+
+ if title is None or gen_utils.is_empty_string(title):
return settings.APP_DESC.format(app_title=settings.APP_TITLE)
- return settings.APP_DESC.format(app_title=brand.site_title)
+ return settings.APP_DESC.format(app_title=title)
+
@register.simple_tag
def get_brand_base_embed_img(brand):
"""
- Gets the brand-related site desc if available, otherwise returns
- the APP_DESC per settings.py
+ Gets the brand-related site open-graph embed image if applicable, otherwise returns the `APP_EMBED_ICON` per `settings.py`
+
+ Args:
+ brand (Brand|dict|None): the brand from which to resolve the info
+
+ Returns:
+ A (str) specifying the site embed icon
"""
- if not brand or not getattr(brand, 'logo_path'):
+ if isinstance(brand, dict):
+ path = brand.get('logo_path', None)
+ elif isinstance(brand, Model):
+ path = getattr(brand, 'logo_path', None) if hasattr(brand, 'logo_path') else None
+ else:
+ path = None
+
+ if path is None or gen_utils.is_empty_string(path):
return settings.APP_EMBED_ICON.format(logo_path=settings.APP_LOGO_PATH)
- return settings.APP_EMBED_ICON.format(logo_path=brand.logo_path)
+ return settings.APP_EMBED_ICON.format(logo_path=path)
+
+
+@register.simple_tag
+def get_template_entity_name(entity_class, template=None):
+ tmpl_def = None
+ if isinstance(template, Model) or inspect.isclass(template) and issubclass(template, Model):
+ tmpl_def = template.definition.get('template_details') if isinstance(template.definition, dict) else None
+ elif isinstance(template, dict):
+ tmpl_def = template.get('definition').get('template_details') if isinstance(template.get('definition'), dict) else None
+
+ if isinstance(tmpl_def, dict):
+ shortname = tmpl_def.get('shortname')
+ else:
+ shortname = None
+
+ if isinstance(shortname, str) and not gen_utils.is_empty_string(shortname):
+ return shortname
+ return entity_class.name
@register.simple_tag
def render_citation_block(entity, request):
+ """
+ Computes an example citation block for the given entity entity
+
+ Args:
+ entity (GenericEntity): some `GenericEntity` instance
+ request (RequestContext): the HTTP request context assoc. with this render
+
+ Returns:
+ A (str) specifying the citation block content
+ """
phenotype_id = f'{entity.id} / {entity.history_id}'
name = entity.name
author = entity.author
@@ -91,14 +345,32 @@ def render_citation_block(entity, request):
return f'{author}. *{phenotype_id} - {name}*. {site_name} [Online]. {updated}. Available from: [{url}]({url}). [Accessed {date}]'
+
@register.inclusion_tag('components/search/pagination/pagination.html', takes_context=True, name='render_entity_pagination')
-def render_pagination(context, *args, **kwargs):
+def render_pagination(context):
"""
Renders pagination button(s) for search pages
- - Provides page range so that it always includes the first and last page,
- and if available, provides the page numbers 1 page to the left and the right of the current page
+
+ Note:
+ - Provides page range so that it always includes the first and last page;
+ - And if available, provides the page numbers 1 page to the left and the right of the current page.
+
+ Args:
+ context (Context|dict): specify the rendering context assoc. with this component; see `TemplateContext`_
+
+ Returns:
+ A (dict) specifying the pagination options
+
+ .. _TemplateContext: https://docs.djangoproject.com/en/5.1/ref/templates/api/#django.template.Context
"""
- page_obj = context['page_obj']
+ page_obj = context.get('page_obj', None)
+ if page_obj is None:
+ return {
+ 'page': 1,
+ 'page_range': [1],
+ 'has_previous': False,
+ 'has_next': False,
+ }
page = page_obj.number
num_pages = page_obj.paginator.num_pages
@@ -134,17 +406,18 @@ def render_pagination(context, *args, **kwargs):
packet['pages'] = page_items
return packet
+
@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
+ 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
+ A (bool) that reflects membership status
"""
if args is None:
return False
@@ -155,62 +428,98 @@ def is_member(user, args):
return True
return False
+
@register.filter(name='jsonify')
-def jsonify(value, should_print=False):
- '''
+def jsonify(value, remove_userdata=True, 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.simple_tag
-def parse_as_json_object(value, remove_userdata=True, should_print=False):
- '''
- Attempts to dump a value to JSON
- '''
+ Args:
+ value (*): some JSONifiable-value, _e.g._ some `Model` instance, a `dict`, or `list`
+ remove_userdata (bool): optionally specify whether to remove userdata assoc. with some `Model` input instance; defaults to `True`
+ should_print (bool): optionally specify whether to print-debug the value before dumping it; defaults to `False`
+
+ Returns:
+ A (str) specifying the citation block content
+ """
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, remove_userdata=remove_userdata)
-@register.filter(name='trimmed')
-def trimmed(value):
- return re.sub(r'\s+', '_', value).lower()
+
+@register.filter(name='shrink_underscore')
+def shrink_underscore(value):
+ """
+ Replaces the whitespace of strings with an underscore, and performs a lower case transform
+
+ Args:
+ value (str): the `str` value to transform
+
+ Returns:
+ The transformed (str) value if applicable; otherwise returns an empty `str`
+ """
+ return re.sub(r'\s+', '_', value).lower() if isinstance(value, str) else ''
+
@register.filter(name='stylise_number')
-def stylise_number(n):
+def stylise_number(value):
"""
- Stylises a number so that it adds a comma delimiter for numbers greater than 1000
+ Stylises (transforms) a number such that it contains a comma delimiter for numbers greater than 1000, _e.g._ `1,000`, or `1,000,000` _etc_
+
+ Args:
+ value (numbers.Number|str): the number or representation of a number to stylise
+
+ Returns:
+ The stylised (str) value if applicable; otherwise returns an empty `str`
"""
- if n is not None:
- return '{:,}'.format(n)
- return ''
+ if isinstance(value, str):
+ try:
+ test = float(value)
+ except ValueError:
+ value = ''
+ else:
+ value = int(test) if test.is_integer() else test
+
+ if isinstance(value, numbers.Number):
+ value = '{:,}'.format(value)
+
+ return value if isinstance(value, str) else ''
+
@register.filter(name='stylise_date')
-def stylise_date(date):
+def stylise_date(value):
"""
- Stylises a datetime object in the YY-MM-DD format
+ Stylises a datetime object in the `YY-MM-DD` format
+
+ Args:
+ value (datetime): the date to format
+
+ Returns:
+ The stylised (str) value if applicable; otherwise returns an empty `str`
"""
- return date.strftime('%Y-%m-%d')
+ return value.strftime('%Y-%m-%d') if isinstance(value, datetime) else ''
+
@register.simple_tag(name='truncate')
-def truncate(value, lim=0, ending=None):
+def truncate(value, lim=10, 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
+ Truncates a string if its length is greater than the limit; can append an ending, _e.g._ an ellipsis, by passing the 'ending' parameter
+
+ Args:
+ value (str|*): some value to truncate; note that this value is coerced into a `str` before being truncated
+ lim (int): optionally specify the max length of the `str`; defaults to `10`
+ ending (str|None): optionally specify a suffix to append to the resulting `str`; defaults to `None`
+
+ Returns:
+ The truncated (str) if applicable; otherwise returns an empty `str`
"""
+ lim = lim if isinstance(lim, numbers.Number) else 0
if lim <= 0:
return value
@@ -225,30 +534,37 @@ def truncate(value, lim=0, ending=None):
else:
return truncated
+
@register.simple_tag(name='render_field_value')
-def render_field_value(entity, layout, field, through=None):
+def render_field_value(entity, layout, field, through=None, default=''):
"""
- Responsible for rendering fields after transforming them using their respective layouts
- - in the case of 'type' (in this case, phenotype clinical types) where pk__eq=1 would be 'Disease or Syndrome'
- instead of returning the pk, it would return the field's string representation from either (a) its source or (b) the options parameter
-
- - in the case of 'coding_system', it would read each individual element within the ArrayField,
- and return a rendered output based on the 'desired_output' parameter
- OR
- it would render output based on the 'through' parameter, which points to a component to be rendered
+ Responsible for rendering fields after transforming them using their respective layouts, such that:
+ - In the case of `type` (in this case, phenotype clinical types) where `pk__eq=1` would be "_Disease or Syndrome_" instead of returning the `pk`, it would return the field's string representation from either (a) its source or (b) the options parameter;
+
+ - In the case of `coding_system`, it would read each individual element within the `ArrayField`, and return a rendered output based on the `desired_output` parameter ***OR*** it would render output based on the `through` parameter, which points to a component to be rendered.
+
+ Args:
+ entity (GenericEntity): some entity from which to resolve the field value
+ layout (dict): the entity's template data
+ field (str): the name of the field to resolve
+ through (str|None): optionally specify the through field target, if applicable; defaults to `None`
+ default (Any): optionally specify the default value; defaults to an empty (str) `''`
+
+ Returns:
+ The renderable (str) value resolved from this entity's field value
"""
data = template_utils.get_entity_field(entity, field)
info = template_utils.get_layout_field(layout, field)
-
if not info or not data:
- return ''
+ return default
validation = template_utils.try_get_content(info, 'validation')
if validation is None:
- return ''
+ return default
+
field_type = template_utils.try_get_content(validation, 'type')
if field_type is None:
- return ''
+ return default
if field_type == 'enum' or field_type == 'int':
output = template_utils.get_template_data_values(entity, layout, field, default=None)
@@ -261,32 +577,54 @@ def render_field_value(entity, layout, field, through=None):
if values is not None:
if through is not None:
# Use override template
- return ''
+ return default
else:
# Use desired output
- return ''
+ return default
+
+ return default
- return ''
@register.simple_tag(name='renderable_field_values')
def renderable_field_values(entity, layout, field):
"""
Gets the field's value from an entity, compares it with it's expected layout (per the template), and returns
- a list of values that relate to that field
- e.g. in the case of CodingSystems it would return [{name: 'ICD-10', value: 1}] where 'value' is the PK
+ a list of values that relate to that field; _e.g._ in the case of CodingSystems it would return `[{name: 'ICD-10', value: 1}]` where `value` is the PK
+
+ Args:
+ entity (GenericEntity): some entity from which to resolve the field value
+ layout (dict): the entity's template data
+ field (str): the name of the field to resolve
+
+ Returns:
+ The resolved (Any)-typed value from the entity's field
"""
- if template_utils.is_metadata(entity, field):
+ info = template_utils.get_template_field_info(layout, field)
+ if info.get('is_metadata'):
# handle metadata e.g. collections, tags etc
- return template_utils.get_metadata_value_from_source(entity, field, default=[])
+ return template_utils.get_metadata_value_from_source(entity, field, field_info=info, layout=layout, default=[])
return template_utils.get_template_data_values(entity, layout, field, default=[])
+
+@register.simple_tag
+def get_str_validation(component):
+ val = { 'has_range': False, 'has_min': False }
+ validation = component.get('validation') if isinstance(component, dict) else None
+ if not isinstance(validation, dict):
+ return val
+
+ val.update({
+ 'has_min': isinstance(validation.get('length'), int),
+ 'has_range': isinstance(validation.get('length'), list) and len(validation.get('length')) >= 2,
+ })
+ return val
+
+
@register.tag(name="to_json_script")
def render_jsonified_object(parser, token):
"""
- Attempts to dump a value to JSON
- and render it as a HTML element in
- the form of:
+ Attempts to dump a value to JSON and render it as a HTML element in the form of:
```html
```
- Example usage:
-
+ Example:
+
```html
{% url 'some_url_var' as some_variable %}
{% test_jsonify some_jsonifiable_content some-attribute="some_value" other-attribute=some_variable %}
```
+
+ Args:
+ parser (template.Parser): the Django template tag parser (supplied by renderer)
+ token (template.Token): the processed Django template token (supplied by HTML renderer)
+
+ Kwargs:
+ should_print (bool): optionally specify whether to print-debug the value before dumping it; defaults to `False`
+ remove_userdata (bool): optionally specify whether to remove userdata assoc. with some `Model` input instance; defaults to `True`
+ attributes (**kwargs): optionally specify a set of attributes to be applied to the rendered `` node
+
+ Returns:
+ A (JsonifiedNode), a subclass of `template.Node`, to be rendered by Django's template renderer
"""
kwargs = {
'should_print': False,
@@ -332,11 +682,9 @@ def render_jsonified_object(parser, token):
return JsonifiedNode(content, attributes, **kwargs)
+
class JsonifiedNode(template.Node):
- """
- Renders the JSON node given the parameters
- called from `render_jsonified_object`
- """
+ """Renders the JSON node given the parameters called from `render_jsonified_object`"""
def __init__(self, content, attributes, **kwargs):
# opts
self.should_print = kwargs.pop('should_print', False)
@@ -347,6 +695,7 @@ def __init__(self, content, attributes, **kwargs):
self.attributes = attributes
def render(self, context):
+ """Inherited method to render the nodes"""
content = self.content.resolve(context)
if self.should_print:
@@ -372,12 +721,31 @@ def render(self, context):
content_string = mark_safe(content_string.translate(json_script_escapes))
return mark_safe(f'')
+
@register.tag(name='render_entity_cards')
def render_entities(parser, token):
"""
Responsible for rendering the entity cards on a search page
- - Uses the entity's template to determine how to render the card (e.g. which to use)
- - Each card is rendered with its own context pertaining to that entity
+ - Uses the entity's template to determine how to render the card (_e.g._ which to use);
+ - Each card is rendered with its own context pertaining to that entity.
+
+ Note:
+ - This tag uses the `TemplateContext`_ to render the cards
+
+ Example:
+ ```html
+ {% render_entity_cards %}
+ {% endrender_entity_cards %}
+ ```
+
+ Args:
+ parser (template.Parser): the Django template tag parser (supplied by renderer)
+ token (template.Token): the processed Django template token (supplied by HTML renderer)
+
+ Returns:
+ A (EntityCardsNode), a subclass of `template.Node`, to be rendered by Django's template renderer
+
+ .. _TemplateContext: https://docs.djangoproject.com/en/5.1/ref/templates/api/#django.template.Context
"""
params = {
# Any future params that modifies behaviour
@@ -398,14 +766,20 @@ def render_entities(parser, token):
parser.delete_first_token()
return EntityCardsNode(params, nodelist)
+
class EntityCardsNode(template.Node):
+ """Renders the cards associated with an entity on the search page"""
def __init__(self, params, nodelist):
- self.request = template.Variable('request')
+ self.reqvar = template.Variable('request')
self.params = params
self.nodelist = nodelist
def render(self, context):
- request = self.request.resolve(context)
+ """Inherited method to render the nodes"""
+ user = context.get('user')
+ request = self.reqvar.resolve(context)
+ request.user = user
+
entities = context['page_obj'].object_list
layouts = context['layouts']
@@ -414,23 +788,35 @@ 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')
def render_filters(parser, token):
"""
Responsible for rendering filters for entities on the search pages
+
+ Note:
+ - This tag uses the `TemplateContext`_ to render the filters
+
+ Example:
+ ```html
+ {% render_entity_filters %}
+ {% endrender_entity_filters %}
+ ```
+
+ Args:
+ parser (template.Parser): the Django template tag parser (supplied by renderer)
+ token (template.Token): the processed Django template token (supplied by HTML renderer)
+
+ Returns:
+ A (EntityFiltersNode), a subclass of `template.Node`, to be rendered by Django's template renderer
+
+ .. _TemplateContext: https://docs.djangoproject.com/en/5.1/ref/templates/api/#django.template.Context
"""
params = {
# Any future modifiers
@@ -451,16 +837,16 @@ def render_filters(parser, token):
parser.delete_first_token()
return EntityFiltersNode(params, nodelist)
+
class EntityFiltersNode(template.Node):
+ """Renders the filters on the search page"""
def __init__(self, params, nodelist):
- self.request = template.Variable('request')
+ self.reqvar = template.Variable('request')
self.params = params
self.nodelist = nodelist
def __try_compile_reference(self, context, field, structure):
- """
- Attempts to compile the reference data for a metadata field
- """
+ """Attempts to compile the reference data for a metadata field"""
if field == 'template':
layouts = context.get('layouts', None)
modifier = {
@@ -469,12 +855,10 @@ def __try_compile_reference(self, context, field, structure):
else:
modifier = None
- return search_utils.get_source_references(structure, default=[], modifier=modifier)
+ return search_utils.get_source_references(structure, default=[], modifier=modifier, request=self.request)
def __check_excluded_brand_collections(self, context, field, current_brand, options):
- """
- Checks and removes Collections excluded from filters
- """
+ """Checks and removes Collections excluded from filters"""
updated_options = options
if field == 'collections':
if current_brand == '' or current_brand == 'ALL':
@@ -492,10 +876,7 @@ def __check_excluded_brand_collections(self, context, field, current_brand, opti
return updated_options
def __render_metadata_component(self, context, field, structure):
- """
- Renders a metadata field, as defined by constants.py
- """
- request = self.request.resolve(context)
+ """Renders a metadata field, as defined by constants.py"""
filter_info = search_utils.get_filter_info(field, structure)
if not filter_info:
return ''
@@ -506,26 +887,25 @@ def __render_metadata_component(self, context, field, structure):
options = None
if 'compute_statistics' in structure:
- current_brand = request.CURRENT_BRAND or 'ALL'
+ current_brand = self.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)
+ # options = self.__check_excluded_brand_collections(context, field, current_brand, options)
if options is None:
validation = template_utils.try_get_content(structure, 'validation')
if validation is not None:
if 'source' in validation:
options = self.__try_compile_reference(context, field, structure)
-
+
+ if options is None or (isinstance(options, list) and len(options) < 1):
+ return ''
+
filter_info['options'] = options
context['filter_info'] = filter_info
return render_to_string(f'{constants.FILTER_DIRECTORY}/{component}.html', context.flatten())
def __render_template_component(self, context, field, structure, layout):
- """
- Renders a component for a template field after computing its reference data
- as defined by its validation & field type
- """
- request = self.request.resolve(context)
+ """Renders a component for a template field after computing its reference data as defined by its validation & field type"""
filter_info = search_utils.get_filter_info(field, structure)
if not filter_info:
return ''
@@ -534,7 +914,7 @@ def __render_template_component(self, context, field, structure, layout):
if component is None:
return ''
- current_brand = request.CURRENT_BRAND or 'ALL'
+ current_brand = self.request.CURRENT_BRAND or 'ALL'
statistics = search_utils.try_get_template_statistics(filter_info.get('field'), brand=current_brand)
if statistics is None or len(statistics) < 1:
return ''
@@ -547,9 +927,7 @@ def __render_template_component(self, context, field, structure, layout):
return render_to_string(f'{constants.FILTER_DIRECTORY}/{component}.html', context.flatten())
def __generate_metadata_filters(self, context, is_single_search=False):
- """
- Generates the filters for all metadata fields within a template
- """
+ """Generates the filters for all metadata fields within a template"""
output = ''
for field, structure in constants.metadata.items():
search = template_utils.try_get_content(structure, 'search')
@@ -564,9 +942,7 @@ def __generate_metadata_filters(self, context, is_single_search=False):
return output
def __generate_template_filters(self, context, output, layouts):
- """
- Generates a filter for each field of a template
- """
+ """Generates a filter for each field of a template"""
layout = next((x for x in layouts.values()), None)
if not template_utils.is_layout_safe(layout):
return output
@@ -589,6 +965,11 @@ def __generate_template_filters(self, context, output, layouts):
return output
def render(self, context):
+ """Inherited method to render the nodes"""
+ self.user = context.get('user')
+ self.request = self.reqvar.resolve(context)
+ self.request.user = self.user
+
entity_type = context.get('entity_type', None)
layouts = context.get('layouts', None)
if layouts is None:
@@ -605,15 +986,29 @@ def render(self, context):
return output
+
@register.tag(name='render_wizard_navigation')
def render_aside_wizard(parser, token):
"""
- Responsible for rendering the navigation item for create pages
- """
- params = {
- # Any future modifiers
- }
+ Responsible for rendering the `` navigation item for create pages & detail pages
+ Example:
+ ```html
+ {% render_wizard_navigation %}
+ {% endrender_wizard_navigation %}
+ ```
+
+ Args:
+ parser (template.Parser): the Django template tag parser (supplied by renderer)
+ token (template.Token): the processed Django template token (supplied by HTML renderer)
+
+ Kwargs:
+ detail_pg (bool): optionally specify whether to render this aside menu for the detail page; defaults to `False`
+
+ Returns:
+ A (EntityWizardAside), a subclass of `template.Node`, to be rendered by Django's template renderer
+ """
+ params = { 'detail_pg': False }
try:
parsed = token.split_contents()[1:]
if len(parsed) > 0 and parsed[0] == 'with':
@@ -629,19 +1024,42 @@ def render_aside_wizard(parser, token):
parser.delete_first_token()
return EntityWizardAside(params, nodelist)
+
class EntityWizardAside(template.Node):
+ """Responsible for rendering the aside component of the steps wizard"""
def __init__(self, params, nodelist):
- self.request = template.Variable('request')
+ self.reqvar = template.Variable('request')
self.params = params
self.nodelist = nodelist
-
- def render(self, context):
- output = ''
- template = context.get('template', None)
- if template is None:
- return output
-
- sections = template.definition.get('sections')
+
+ def __render_detail(self, context, tmpl):
+ # We should be getting the FieldTypes.json related to the template
+ request = self.request
+ detail_page_sections = []
+ template_sections = tmpl.definition.get('sections')
+ template_sections.extend(constants.DETAIL_PAGE_APPENDED_SECTIONS)
+ for section in template_sections:
+ if section.get('hide_on_detail', False):
+ continue
+
+ if section.get('requires_auth', False) and not request.user.is_authenticated:
+ continue
+
+ if section.get('do_not_show_in_production', False) and (not settings.IS_DEMO and not settings.IS_DEVELOPMENT_PC):
+ continue
+
+ detail_page_sections.append(section)
+
+ # still need to handle: section 'hide_if_empty' ???
+
+ output = render_to_string(constants.DETAIL_WIZARD_ASIDE, {
+ 'detail_page_sections': detail_page_sections
+ })
+
+ return output
+
+ def __render_create(self, context, tmpl):
+ sections = tmpl.definition.get('sections')
if sections is None:
return ''
@@ -653,15 +1071,46 @@ def render(self, context):
})
return output
+
+ def render(self, context):
+ """Inherited method to render the nodes"""
+ self.user = context.get('user')
+ self.request = self.reqvar.resolve(context)
+ self.request.user = self.user
+
+ tmpl = context.get('template', None)
+ if tmpl is None:
+ return ''
+
+ is_detail_pg = self.params.get('detail_pg', False)
+ if is_detail_pg:
+ return self.__render_detail(context, tmpl)
+
+ return self.__render_create(context, tmpl)
+
@register.tag(name='render_wizard_sections')
def render_steps_wizard(parser, token):
"""
- Responsible for rendering the sections for create pages
+ Responsible for rendering the ` ` sections for create & detail pages
+
+ Example:
+ ```html
+ {% render_wizard_sections %}
+ {% endrender_wizard_sections %}
+ ```
+
+ Args:
+ parser (template.Parser): the Django template tag parser (supplied by renderer)
+ token (template.Token): the processed Django template token (supplied by HTML renderer)
+
+ Kwargs:
+ detail_pg (bool): optionally specify whether to render this component for the detail page; defaults to `False`
+
+ Returns:
+ A subclass of (template.Node) to be rendered by Django's template renderer, representing either a (EntityCreateWizardSections) or a (EntityDetailWizardSections)
"""
- params = {
- # Any future modifiers
- }
+ params = { 'detail_pg': False }
try:
parsed = token.split_contents()[1:]
@@ -676,21 +1125,24 @@ def render_steps_wizard(parser, token):
nodelist = parser.parse(('endrender_wizard_sections'))
parser.delete_first_token()
- return EntityWizardSections(params, nodelist)
-class EntityWizardSections(template.Node):
+ if params.get('detail_pg', False):
+ return EntityDetailWizardSections(params, nodelist)
+ return EntityCreateWizardSections(params, nodelist)
+
+
+class EntityCreateWizardSections(template.Node):
+ """Responsible for rendering the sections associated with the create steps wizard"""
SECTION_END = render_to_string(template_name=constants.CREATE_WIZARD_SECTION_END)
def __init__(self, params, nodelist):
- self.request = template.Variable('request')
+ self.reqvar = template.Variable('request')
self.params = params
self.nodelist = nodelist
- def __try_get_entity_value(self, request, template, entity, field):
- """
- Attempts to safely generate the creation data for field within a template
- """
- value = create_utils.get_template_creation_data(request, entity, template, field, default=None)
+ def __try_get_entity_value(self, tmpl, entity, field, info=None):
+ """Attempts to safely generate the creation data for field within a template"""
+ value = create_utils.get_template_creation_data(self.request, entity, tmpl, field, default=None, info=info)
if value is None:
return template_utils.get_entity_field(entity, field)
@@ -698,23 +1150,20 @@ def __try_get_entity_value(self, request, template, entity, field):
return value
def __try_render_item(self, **kwargs):
- """
- Attempts to safely render the HTML to string and sinks exceptions
- """
+ """Attempts to safely render the HTML to string and sinks exceptions"""
try:
html = render_to_string(**kwargs)
except Exception as e:
- if settings.DEBUG:
- warnings.warn(str(e))
+ logger.warning(str(e))
return ''
else:
return html
- def __try_get_props(self, template, field):
- """
- Attempts to safely get the properties of a validation field, if present
- """
- struct = template_utils.get_layout_field(template, field)
+ def __try_get_props(self, tmpl, field, struct=None):
+ """Attempts to safely get the properties of a validation field, if present"""
+ if struct is None:
+ struct = template_utils.get_layout_field(tmpl, field)
+
if not isinstance(struct, dict):
return
@@ -723,11 +1172,11 @@ def __try_get_props(self, template, field):
return
return validation.get('properties')
- def __try_get_computed(self, request, field):
- """
- Attempts to safely parse computed fields
- """
- struct = template_utils.get_layout_field(constants.metadata, field)
+ def __try_get_computed(self, field, struct=None):
+ """Attempts to safely parse computed fields"""
+ if struct is None:
+ struct = template_utils.get_layout_field(constants.metadata, field)
+
if struct is None:
return
@@ -739,39 +1188,40 @@ def __try_get_computed(self, request, field):
return
# append other computed fields if required
- if field == 'group':
- return permission_utils.get_user_groups(request)
+ if field == 'organisation' or field == 'group':
+ return permission_utils.get_user_organisations(self.request)
return
- def __apply_mandatory_property(self, template, field):
+ def __apply_properties(self, component, tmpl, _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
+ validation = template_utils.try_get_content(tmpl, 'validation')
+ 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):
+ """Appends the given section to the current output target"""
if gen_utils.is_empty_string(section_content):
return output
return output + section_content + self.SECTION_END
- def __generate_wizard(self, request, context):
- """
- Generates the creation wizard template
- """
+ def __generate_wizard(self, context):
+ """Generates the creation wizard template"""
output = ''
- template = context.get('template', None)
+ tmpl = context.get('template', None)
entity = context.get('entity', None)
- if template is None:
+ if tmpl is None:
return output
field_types = constants.FIELD_TYPES
- sections = template.definition.get('sections')
+ sections = tmpl.definition.get('sections')
if sections is None:
return ''
@@ -779,14 +1229,15 @@ def __generate_wizard(self, request, context):
sections.extend(constants.APPENDED_SECTIONS)
for section in sections:
- section_content = self.__try_render_item(template_name=constants.CREATE_WIZARD_SECTION_START, request=request, context=context.flatten() | { 'section': section })
+ section_content = self.__try_render_item(
+ template_name=constants.CREATE_WIZARD_SECTION_START,
+ request=self.request,
+ context=context.flatten() | { 'section': section }
+ )
for field in section.get('fields'):
- if template_utils.is_metadata(GenericEntity, field):
- template_field = constants.metadata.get(field)
- else:
- template_field = template_utils.get_field_item(template.definition, 'fields', field)
-
+ field_info = template_utils.get_template_field_info(tmpl, field)
+ template_field = field_info.get('field')
if not template_field:
continue
@@ -796,20 +1247,20 @@ def __generate_wizard(self, request, context):
if template_field.get('hide_on_create'):
continue
+
+ if template_field.get('hide_non_org_managed'):
+ current_brand = model_utils.try_get_brand(self.request)
+ current_brand = current_brand if current_brand and current_brand.org_user_managed else None
+ if current_brand is None:
+ continue
component = template_utils.try_get_content(field_types, template_field.get('field_type'))
if component is None:
continue
- if template_utils.is_metadata(GenericEntity, field):
- field_data = template_utils.try_get_content(constants.metadata, field)
- else:
- field_data = template_utils.get_layout_field(template, field)
-
- if field_data is None:
- continue
+ component = deepcopy(component)
component['field_name'] = field
- component['field_data'] = field_data
+ component['field_data'] = template_field
desc = template_utils.try_get_content(template_field, 'description')
if desc is not None:
@@ -818,42 +1269,315 @@ def __generate_wizard(self, request, context):
else:
component['hide_input_details'] = True
- is_metadata = template_utils.is_metadata(GenericEntity, field)
- field_struct = template_utils.get_layout_field(constants.metadata if is_metadata else template, field)
- will_hydrate = field_struct is not None and field_struct.get('hydrated', False)
+ is_metadata = field_info.get('is_metadata')
+ will_hydrate = template_field.get('hydrated', False)
options = None
if not will_hydrate:
if is_metadata:
- options = template_utils.get_template_sourced_values(constants.metadata, field, request=request)
+ options = template_utils.get_template_sourced_values(
+ constants.metadata,
+ field,
+ request=self.request,
+ struct=template_field
+ )
if options is None:
- options = self.__try_get_computed(request, field)
+ options = self.__try_get_computed(field, struct=template_field)
else:
- options = template_utils.get_template_sourced_values(template, field, request=request)
+ options = template_utils.get_template_sourced_values(tmpl, field, request=self.request, struct=template_field)
if options is not None:
component['options'] = options
- field_properties = self.__try_get_props(template, field)
+ field_properties = self.__try_get_props(tmpl, field, struct=template_field)
if field_properties is not None:
component['properties'] = field_properties
-
+
if entity:
- component['value'] = self.__try_get_entity_value(request, template, entity, field)
+ component['value'] = self.__try_get_entity_value(tmpl, entity, field, info=field_info)
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 })
+ section_content += self.__try_render_item(template_name=uri, context=context.flatten() | { 'component': component })
output = self.__append_section(output, section_content)
return output
def render(self, context):
- """
- Renders the wizard
- """
- request = self.request.resolve(context)
- return self.__generate_wizard(request, context)
+ """Inherited method to render the nodes"""
+ self.user = context.get('user')
+ self.request = self.reqvar.resolve(context)
+ self.request.user = self.user
+ return self.__generate_wizard(context)
+
+
+## NOTE:
+## - Need to ask M.E. to document the following at some point
+##
+
+def get_data_sources(ds_ids, info, default=None):
+ """Tries to get the sourced value of data_sources id/name/url"""
+ validation = template_utils.try_get_content(info, 'validation')
+ if validation is None:
+ return default
+
+ try:
+ source_info = validation.get('source')
+ model = apps.get_model(app_label='clinicalcode', model_name=source_info.get('table'))
+ if ds_ids:
+ queryset = model.objects.filter(id__in=ds_ids)
+ if queryset.exists():
+ return queryset
+ except:
+ return default
+ else:
+ return default
+
+
+def get_template_creation_data(entity, layout, field, request=None, default=None, info=None):
+ """Used to retrieve assoc. data values for specific keys, e.g. concepts, in its expanded format for use with create/update pages"""
+ if info is None:
+ info = template_utils.get_template_field_info(layout, field)
+
+ data = template_utils.get_entity_field(entity, field)
+ if not info or not data:
+ return default
+
+ field_info = info.get('field')
+ validation = template_utils.try_get_content(field_info, 'validation')
+ if validation is None:
+ return default
+
+ field_type = template_utils.try_get_content(validation, 'type')
+ if field_type is None:
+ return default
+
+ if field_type == 'concept':
+ return concept_utils.get_concept_headers(data)
+ elif field_type == 'int_array':
+ source_info = validation.get('source')
+ tree_models = source_info.get('trees') if isinstance(source_info, dict) else None
+ model_source = source_info.get('model') if isinstance(source_info, dict) else None
+ if isinstance(tree_models, list) and isinstance(model_source, str):
+ try:
+ model = apps.get_model(app_label='clinicalcode', model_name=model_source)
+ output = model.get_detail_data(node_ids=data, default=default)
+ if isinstance(output, list):
+ return output
+ except:
+ # Logging
+ return default
+ elif field_type == 'related_entities':
+ if isinstance(data, list) and len(data) > 0:
+ props = validation.get('properties') if isinstance(validation.get('properties'), dict) else None
+ display = props.get('display') if props else None
+
+ if not isinstance(display, list):
+ return default
+
+ anchor = props.get('anchor') if props else None
+ target = anchor.get('target') if anchor and isinstance(anchor.get('target'), str) else None
+
+ result = []
+ for item in data:
+ name = ''
+ for i, key in enumerate(display):
+ res = item.get(key, '')
+ if i == 0:
+ name = str(res)
+ elif i == 1:
+ name += '/' + str(res)
+ else:
+ name += ' - ' + str(res)
+
+ ref = item.get(target) if target else None
+ result.append({
+ 'anchor': anchor.get('name'),
+ 'target': ref,
+ 'label': name,
+ })
+ return result if len(result) > 0 else None
+ return default
+ elif field_type == 'var_data':
+ if isinstance(data, dict) and len(data.keys()) > 0:
+ options = validation.get('options') if validation else None
+
+ result = []
+ for key, item in data.items():
+ typed = item.get('type')
+ opt = options.get(key) if options else None
+ value = None
+ if opt:
+ fmt = opt.get('format')
+ if fmt:
+ value = fmt.format(**item.get('value')) if isinstance(item.get('value'), dict) else fmt.format(value=item.get('value'))
+
+ if value is None:
+ value = item.get('value')
+ if typed.endswith('_range'):
+ suffix = '%' if 'percentage' in typed else ''
+ value = '{v0}{suffix} - {v1}{suffix}'.format(v0=value[0], v1=value[1], suffix=suffix)
+ elif 'percentage' in typed:
+ value = '{0}%'.format(value)
+ else:
+ value = str(value)
+
+ typed = typed.replace('_', ' ').title()
+ result.append({ 'name': item.get('name'), 'type': typed, 'value': value, 'description': item.get('description') })
+
+ return result if len(result) > 0 else None
+ return default
+
+ if field_info.get('field_type') == 'data_sources':
+ return get_data_sources(data, field_info, default=default)
+
+ if info.get('is_metadata'):
+ return template_utils.get_metadata_value_from_source(entity, field, field_info=info, layout=layout, default=default)
+
+ return template_utils.get_template_data_values(entity, layout, field, default=default)
+
+
+class EntityDetailWizardSections(template.Node):
+ """Renders the detail page template sections"""
+ SECTION_END = render_to_string(template_name=constants.DETAIL_WIZARD_SECTION_END)
+
+ def __init__(self, params, nodelist):
+ self.user = None
+ self.reqvar = template.Variable('request')
+ self.params = params
+ self.nodelist = nodelist
+
+ def __try_get_entity_value(self, tmpl, entity, field, info=None):
+ value = get_template_creation_data(entity, tmpl, field, request=self.request, default=None, info=info)
+ if value is None:
+ return template_utils.get_entity_field(entity, field)
+
+ return value
+
+ def __try_render_item(self, **kwargs):
+ try:
+ html = render_to_string(**kwargs)
+ except:
+ return ''
+ else:
+ return html
+
+ def __append_section(self, output, section_content):
+ if gen_utils.is_empty_string(section_content):
+ return output
+ return output + section_content + self.SECTION_END
+
+ def __generate_wizard(self, context):
+ output = ''
+ tmpl = context.get('template', None)
+ entity = context.get('entity', None)
+ if tmpl is None:
+ return output
+
+ flat_ctx = context.flatten()
+ is_prod_env = not settings.IS_DEMO and not settings.IS_DEVELOPMENT_PC
+ is_unauthenticated = not self.request.user or self.request.user.is_anonymous
+
+ merged_definition = template_utils.get_merged_definition(tmpl, default={})
+ template_fields = template_utils.try_get_content(merged_definition, 'fields')
+ template_fields.update(constants.DETAIL_PAGE_APPENDED_FIELDS)
+ tmpl.definition['fields'] = template_fields
+
+ # We should be getting the FieldTypes.json related to the template
+ field_types = constants.FIELD_TYPES
+ template_sections = tmpl.definition.get('sections')
+ #template_sections.extend(constants.DETAIL_PAGE_APPENDED_SECTIONS)
+ for section in template_sections:
+ is_hidden = (
+ section.get('hide_on_detail', False)
+ or section.get('hide_on_detail', False)
+ or (section.get('requires_auth', False) and is_unauthenticated)
+ or (section.get('do_not_show_in_production', False) and is_prod_env)
+ )
+ if is_hidden:
+ continue
+
+ section['hide_description'] = True
+ section_content = self.__try_render_item(
+ template_name=constants.DETAIL_WIZARD_SECTION_START,
+ request=self.request,
+ context=flat_ctx | {'section': section}
+ )
+
+ field_count = 0
+ for field in section.get('fields'):
+ field_info = template_utils.get_template_field_info(tmpl, field)
+ template_field = field_info.get('field')
+ if not template_field:
+ continue
+
+ component = template_utils.try_get_content(field_types, template_field.get('field_type')) if template_field else None
+ if component is None:
+ continue
+
+ component = deepcopy(component)
+ active = template_field.get('active', False)
+ is_hidden = (
+ (isinstance(active, bool) and not active)
+ or template_field.get('hide_on_detail')
+ or (template_field.get('requires_auth', False) and is_unauthenticated)
+ or (template_field.get('do_not_show_in_production', False) and is_prod_env)
+ )
+ if is_hidden:
+ continue
+
+ component['field_name'] = field
+ component['field_data'] = '' if template_field is None else template_field
+
+ desc = template_utils.try_get_content(template_field, 'description')
+ if desc is not None:
+ component['description'] = desc
+ component['hide_input_details'] = False
+ else:
+ component['hide_input_details'] = True
+
+ # don't show field description in detail page
+ component['hide_input_details'] = True
+
+ component['hide_input_title'] = False
+ if len(section.get('fields')) <= 1:
+ # don't show field title if it is the only field in the section
+ component['hide_input_title'] = True
+
+ if entity:
+ component['value'] = self.__try_get_entity_value(tmpl, entity, field, info=field_info)
+ else:
+ component['value'] = ''
+
+ sorting_behaviour = component.get('field_data', {}).get('sort')
+ if isinstance(sorting_behaviour, dict) and isinstance(sorting_behaviour.get('key'), str) and component['value'] is not None:
+ component['value'] = sorted(component['value'], key=lambda x: x.get(sorting_behaviour.get('key')))
+
+ if template_field.get('hide_if_empty', False):
+ comp_value = component.get('value')
+ if comp_value is None or str(comp_value) == '' or comp_value == [] or comp_value == {}:
+ continue
+
+ output_type = component.get("output_type")
+ uri = f'{constants.DETAIL_WIZARD_OUTPUT_DIR}/{output_type}.html'
+ field_count += 1
+ section_content += self.__try_render_item(
+ template_name=uri, request=self.request,
+ context=flat_ctx | {'component': component}
+ )
+
+ if field_count > 0:
+ output = self.__append_section(output, section_content)
+
+ return output
+
+ def render(self, context):
+ """Inherited method to render the nodes"""
+ self.user = context.get('user')
+ self.request = self.reqvar.resolve(context)
+ self.request.user = self.user
+ return self.__generate_wizard(context)
diff --git a/CodeListLibrary_project/clinicalcode/tests/conftest.py b/CodeListLibrary_project/clinicalcode/tests/conftest.py
index e0b241c9d..d9b232400 100644
--- a/CodeListLibrary_project/clinicalcode/tests/conftest.py
+++ b/CodeListLibrary_project/clinicalcode/tests/conftest.py
@@ -6,11 +6,12 @@
from datetime import datetime
from django.db import connection
from django.utils.timezone import make_aware
-from django.contrib.auth.models import User, Group
+from django.contrib.auth.models import Group
from selenium import webdriver
from selenium.webdriver import Keys
from selenium.webdriver.common.by import By
from selenium.webdriver.support.wait import WebDriverWait
+from django.contrib.auth import get_user_model
from clinicalcode.models import Brand
from clinicalcode.models import Concept
@@ -25,6 +26,9 @@
from cll.test_settings import REMOTE_TEST_HOST, REMOTE_TEST, chrome_options
+User = get_user_model()
+
+
@pytest.fixture
def generate_user(create_groups):
"""
@@ -43,7 +47,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)
@@ -63,6 +67,10 @@ def generate_user(create_groups):
'edit_group_user': egp_user,
}
+ for uobj in users.values():
+ setattr(uobj, 'BRAND_OBJECT', {})
+ setattr(uobj, 'CURRENT_BRAND', '')
+
yield users
# Clean up the users after the tests are finished
@@ -125,12 +133,13 @@ def generate_entity_session(template, generate_user, brands=None):
name='TEST_%s_Entity' % status.name,
author=user.username,
status=ENTITY_STATUS.DRAFT.value,
- publish_status=APPROVAL_STATUS.ANY.value,
+ publish_status=status.value,
template=template,
template_version=1,
template_data=template_data,
created_by=user,
- world_access=WORLD_ACCESS_PERMISSIONS.VIEW
+ world_access=WORLD_ACCESS_PERMISSIONS.VIEW,
+ owner=user
)
record = {'entity': entity}
diff --git a/CodeListLibrary_project/clinicalcode/tests/constants/test_template.json b/CodeListLibrary_project/clinicalcode/tests/constants/test_template.json
index 6e104ccdd..6c65710f2 100644
--- a/CodeListLibrary_project/clinicalcode/tests/constants/test_template.json
+++ b/CodeListLibrary_project/clinicalcode/tests/constants/test_template.json
@@ -166,7 +166,7 @@
"field_type": "daterange",
"validation": {
"type": "string",
- "regex": "(?:\\d+/|\\d+)+[\\s+]?-[\\s+]?(?:\\d+/|\\d+)+",
+ "regex": "[\\S\\s]*?(\\d+(?:-\\d+)*)[^\\d]*",
"mandatory": false
},
"description": "If this Phenotype is only applicable within a limited time period, please specify that here (optional)."
@@ -339,7 +339,7 @@
"do_not_show_in_production": true
},
{
- "title": "Clinical Code List",
+ "title": "Clinical Codelist",
"fields": [
"concept_information"
],
diff --git a/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_auth_user.py b/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_auth_user.py
index 9d53929d1..511c206f8 100644
--- a/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_auth_user.py
+++ b/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_auth_user.py
@@ -26,7 +26,8 @@ def test_user_with_access(self, generate_user, user_type, live_server, generate_
user = generate_user[user_type]
client = Client(
- username=user.username, password=user.username + "password",
+ username=user.username,
+ password=user.username + "password",
url=live_server.url
)
@@ -40,13 +41,14 @@ def test_user_with_access(self, generate_user, user_type, live_server, generate_
normal_user = generate_user[normal_user_type]
normal_user_client = Client(
- username=normal_user.username, password=normal_user.username + "password",
+ username=normal_user.username,
+ password=normal_user.username + "password",
url=live_server.url
)
pheno_ver_normal_user = normal_user_client.phenotypes.get_versions('PH2')
- print("Phenotype PH2 with group/world access:", pheno_ver_normal_user)
- assert pheno_ver_normal_user != []
+ print("Inaccessible to other user (non-group):", pheno_ver_normal_user)
+ assert pheno_ver_normal_user == []
non_auth_client = Client(public=True, url=live_server.url)
non_auth_user = non_auth_client.phenotypes.get_versions('PH2')
diff --git a/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_collection_components.py b/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_collection_components.py
index 93cbc3720..1310ca494 100644
--- a/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_collection_components.py
+++ b/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_collection_components.py
@@ -6,7 +6,7 @@
import pytest
-@pytest.mark.django_db
+@pytest.mark.django_db(reset_sequences=True,transaction=True)
@pytest.mark.usefixtures('setup_webdriver')
class TestCollectionComponents:
@@ -19,11 +19,11 @@ def test_collection_redirects(self, login, logout, generate_user, user_type, liv
login(self.driver, user.username, user.username + 'password')
user_details = user_type if user else 'Anonymous'
- self.driver.get(live_server + reverse('search_phenotypes'))
+ self.driver.get(live_server + reverse('search_entities'))
element = None
try:
- element = self.driver.find_element(By.CSS_SELECTOR, '''a.referral-card__title[href='%s']''' % reverse('my_collection'))
+ element = self.driver.find_element(By.CSS_SELECTOR, '''.referral-card[data-target='%s']''' % reverse('my_collection'))
except Exception as e:
if not isinstance(e, NoSuchElementException):
raise e
diff --git a/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_detail_components.py b/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_detail_components.py
index ce6048b86..23365b306 100644
--- a/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_detail_components.py
+++ b/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_detail_components.py
@@ -1,10 +1,13 @@
+
from django.urls import reverse
-from selenium.common.exceptions import NoSuchElementException
+from selenium.webdriver.support import expected_conditions
+from selenium.common.exceptions import NoSuchElementException, TimeoutException
from selenium.webdriver.common.by import By
+from selenium.webdriver.support.ui import WebDriverWait
import pytest
-@pytest.mark.django_db(reset_sequences=True,transaction=True)
+@pytest.mark.django_db
@pytest.mark.usefixtures('setup_webdriver')
class TestDetailComponents:
@@ -28,6 +31,7 @@ def test_anonymous_publication_presence(self, live_server):
('moderator_user', 'REQUESTED'),
('owner_user', 'ANY')
])
+ @pytest.mark.skip(reason="Legacy")
def test_publish_btn_presence(self, login, logout, generate_entity_session, user_type, entity_status, live_server):
users = generate_entity_session['users']
user = users.get(user_type)
@@ -35,20 +39,25 @@ def test_publish_btn_presence(self, login, logout, generate_entity_session, user
entities = generate_entity_session['entities']
record = entities.get(entity_status)
entity = record.get('entity')
+ print('Test Pub Ent:', entity, '|', entity.owner, '|', user)
+ historical = entity.history.latest()
login(self.driver, user.username, user.username + 'password')
- self.driver.get(live_server + reverse('entity_detail_shortcut', kwargs={ 'pk' : entity.id }))
+ print(f'Test Pub: PH{entity.id}/V{historical.history_id}')
+ self.driver.get(live_server + reverse('entity_history_detail', kwargs={ 'pk' : entity.id, 'history_id': historical.history_id }))
+ present = False
try:
- self.driver.find_element(By.ID, 'publish-btn')
+ wait = WebDriverWait(self.driver, 5)
+ wait.until(expected_conditions.presence_of_element_located((By.ID, 'publish-btn')))
except Exception as e:
if not isinstance(e, NoSuchElementException):
raise e
present = False
else:
present = True
- finally:
- assert present, f'Publication button not visible for {user_type} when approval_status={entity_status}!'
+
+ assert present, f'Publication button not visible for {user_type} when approval_status={entity_status}!'
logout(self.driver)
diff --git a/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_moderation_components.py b/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_moderation_components.py
index 3827aebcc..fa6166541 100644
--- a/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_moderation_components.py
+++ b/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_moderation_components.py
@@ -6,13 +6,13 @@
import pytest
-@pytest.mark.django_db
+@pytest.mark.django_db(reset_sequences=True)
@pytest.mark.usefixtures('setup_webdriver')
class TestModerationComponents:
COMPONENT_VISIBILITY_RULES = ['moderator_user', 'super_user']
@pytest.mark.functional_test
- @pytest.mark.parametrize('user_type', [None, 'normal_user', 'moderator_user', 'super_user'])
+ @pytest.mark.parametrize('user_type', [None, 'normal_user'])
def test_moderation_redirect(self, login, logout, generate_user, user_type, live_server):
user = None
if isinstance(user_type, str):
@@ -30,7 +30,7 @@ def test_moderation_redirect(self, login, logout, generate_user, user_type, live
if not isinstance(e, TimeoutException):
raise e
finally:
- assert 'permission denied' in self.driver.title.lower(), \
+ assert 'Moderation' not in self.driver.title.lower(), \
f'Failed to present 403 status to {user_details} for Moderation page'
else:
expected_url = None
@@ -61,12 +61,12 @@ def test_moderation_component_presence(self, login, logout, generate_user, user_
login(self.driver, user.username, user.username + 'password')
user_details = user_type if user else 'Anonymous'
- self.driver.get(live_server + reverse('search_phenotypes'))
+ self.driver.get(live_server + reverse('search_entities'))
desired_visibility = user_type in self.COMPONENT_VISIBILITY_RULES
component_presence = False
try:
- elem = self.driver.find_element(By.CSS_SELECTOR, '''a.referral-card__title[href='%s']''' % reverse('moderation_page'))
+ elem = self.driver.find_element(By.CSS_SELECTOR, '''.referral-card[data-target='%s']''' % reverse('moderation_page'))
component_presence = elem is not None
except Exception as e:
if not isinstance(e, NoSuchElementException):
diff --git a/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_search_filters.py b/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_search_filters.py
index 4c0688372..da74412b0 100644
--- a/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_search_filters.py
+++ b/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_search_filters.py
@@ -1,31 +1,44 @@
-import time
from django.urls import reverse
-import pytest
+from selenium.webdriver.support import expected_conditions as EC
+from selenium.common.exceptions import NoSuchElementException, TimeoutException
from selenium.webdriver.common.by import By
+from selenium.webdriver.support.ui import WebDriverWait
+import time
+import pytest
-@pytest.mark.django_db
+@pytest.mark.django_db(reset_sequences=True,transaction=True)
@pytest.mark.usefixtures("setup_webdriver")
class TestSearchFilters:
@pytest.mark.functional_test
@pytest.mark.parametrize('user_type', ['super_user'])
def test_tags_filter(self, login, logout, generate_user, user_type, live_server):
- user = generate_user[user_type]
+ user = None
+ if isinstance(user_type, str):
+ user = generate_user[user_type]
+ login(self.driver, user.username, user.username + 'password')
# generate_entity.created_by = generate_user[user_type] this needed to test the page for at least some data
- login(self.driver, user.username, user.username + "password")
+ self.driver.get(live_server + reverse('search_entities'))
+
+ uname = self.driver.find_element(By.CLASS_NAME, 'text-username').text if user is not None else user_type
+ print(f"Current username: {uname}")
- self.driver.get(live_server + reverse('search_phenotypes'))
+ try:
+ wait = WebDriverWait(self.driver, 5)
+ accordion = wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, '.accordion[data-field="tags"] label')))
+ accordion.click()
- print(f"Current username:{self.driver.find_element(By.CLASS_NAME, 'text-username').text}")
+ time.sleep(1)
- accordion = self.driver.find_element(By.XPATH, "/html/body/main/div/div/aside/div[2]/div[4]")
- time.sleep(5)
- accordion.click()
- checkboxes = self.driver.find_elements(By.XPATH, "//input[(@class='checkbox-item') and (@aria-label = 'Tags')]")
+ checkboxes = self.driver.find_elements(By.CSS_SELECTOR, 'input[data-field="tags"]')
+ for checkbox in checkboxes:
+ assert checkbox.is_enabled() is True
- for checkbox in checkboxes:
- assert checkbox.is_enabled() is True
+ except Exception as e:
+ if not isinstance(e, TimeoutException):
+ raise e
- logout(self.driver)
+ if user is not None:
+ logout(self.driver)
diff --git a/CodeListLibrary_project/clinicalcode/tests/unit_tests/test_publishing.py b/CodeListLibrary_project/clinicalcode/tests/unit_tests/test_publishing.py
index c2fdb7bcc..b38c1857b 100644
--- a/CodeListLibrary_project/clinicalcode/tests/unit_tests/test_publishing.py
+++ b/CodeListLibrary_project/clinicalcode/tests/unit_tests/test_publishing.py
@@ -9,7 +9,7 @@
from clinicalcode.views.Publish import Publish, RequestPublish
-@pytest.mark.django_db
+@pytest.mark.django_db(reset_sequences=True,transaction=True)
class TestPublishing:
def __build_http_request(self, user, url='', resolver_name='', resolver_kwargs=None, method='GET'):
@@ -23,7 +23,8 @@ def __build_http_request(self, user, url='', resolver_name='', resolver_kwargs=N
request.method = method
request.session = { }
request.IS_HDRUK_EXT = '0'
- request.CURRENT_BRAND = ''
+ setattr(request, 'BRAND_OBJECT', {})
+ setattr(request, 'CURRENT_BRAND', '')
return request
@@ -34,6 +35,7 @@ def __build_http_request(self, user, url='', resolver_name='', resolver_kwargs=N
('owner_user', 'ANY'),
('moderator_user', 'ANY'),
])
+ @pytest.mark.skip(reason="Legacy")
def test_publish_request_get(self, generate_entity_session, user_type, entity_status, live_server):
user = None
if isinstance(user_type, str):
@@ -50,6 +52,7 @@ def test_publish_request_get(self, generate_entity_session, user_type, entity_st
entity_history_id = entity.history.first().history_id
entity_kwargs = { 'pk': entity_id, 'history_id': entity_history_id }
+ print('Test PostPub Ent:', entity, '|', entity.owner, '|', user)
request = self.__build_http_request(
user,
url=live_server,
@@ -101,6 +104,7 @@ def test_publish_request_get(self, generate_entity_session, user_type, entity_st
('owner_user', 'ANY'),
('moderator_user', 'ANY'),
])
+ @pytest.mark.skip(reason="Legacy")
def test_publish_request_post(self, generate_entity_session, user_type, entity_status, live_server):
user = None
if isinstance(user_type, str):
@@ -117,6 +121,7 @@ def test_publish_request_post(self, generate_entity_session, user_type, entity_s
entity_history_id = entity.history.first().history_id
entity_kwargs = { 'pk': entity_id, 'history_id': entity_history_id }
+ print('Test PostPub Ent:', entity, '|', entity.owner, '|', user)
request = self.__build_http_request(
user,
url=live_server,
@@ -157,6 +162,7 @@ def test_publish_request_post(self, generate_entity_session, user_type, entity_s
('owner_user', 'ANY'),
('moderator_user', 'ANY'),
])
+ @pytest.mark.skip(reason="Legacy")
def test_publish_approve_get(self, generate_entity_session, user_type, entity_status, live_server):
user = None
if isinstance(user_type, str):
@@ -224,6 +230,7 @@ def test_publish_approve_get(self, generate_entity_session, user_type, entity_st
('owner_user', 'ANY'),
('moderator_user', 'ANY'),
])
+ @pytest.mark.skip(reason="Legacy")
def test_publish_approve_post(self, generate_entity_session, user_type, entity_status, live_server):
user = None
if isinstance(user_type, str):
diff --git a/CodeListLibrary_project/clinicalcode/urls.py b/CodeListLibrary_project/clinicalcode/urls.py
index f6f4a1a34..c421a8699 100644
--- a/CodeListLibrary_project/clinicalcode/urls.py
+++ b/CodeListLibrary_project/clinicalcode/urls.py
@@ -1,18 +1,22 @@
-'''
- URL Configuration for the Clinical-Code application.
-
- Pages appear as Working-sets, Concepts and Components within a Concept.
-'''
+"""Static URL Configuration for the Clinical-Code application."""
from django.conf import settings
from django.urls import re_path as url
-from django.views.generic.base import RedirectView
-from django.contrib.auth import views as auth_views
+from clinicalcode.views.dashboard import BrandAdmin
from clinicalcode.views.DocumentationViewer import DocumentationViewer
-from clinicalcode.views import (View, Admin, adminTemp,
- GenericEntity, Profile, Moderation,
- Publish, Decline, site)
+
+from clinicalcode.views import (
+ site, View, Admin, adminTemp,
+ GenericEntity, Moderation, Profile, Organisation
+)
+
+from clinicalcode.views.dashboard.targets import (
+ BrandTarget, UserTarget, OrganisationTarget,
+ TemplateTarget, TagTarget, HDRNSiteTarget,
+ HDRNCategoryTarget, HDRNDataAssetTarget, HDRNJurisdictionTarget
+)
+
# Main
urlpatterns = [
@@ -27,7 +31,7 @@
url(r'^privacy-and-cookie-policy/$', View.cookiespage, name='privacy_and_cookie_policy'),
## Technical
- url(r'^reference-data/$', View.reference_data, name='reference_data'),
+ url(r'^reference-data/$', View.ReferenceData.as_view(), name='reference_data'),
url(r'^technical_documentation/$', View.technicalpage, name='technical_documentation'),
## Cookies
@@ -35,32 +39,57 @@
## About pages
url(r'^about/(?P([A-Za-z0-9\-\_]+))/$', View.brand_about_index_return, name='about_page'),
-
- ## Changing password(s)
- url(
- route='^change-password/$',
- view=auth_views.PasswordChangeView.as_view(),
- name='password_change',
- kwargs={ 'post_change_redirect': 'concept_library_home' }
- ),
-
- # GenericEnities (Phenotypes)
- ## Search
- url(r'^phenotypes/$', GenericEntity.EntitySearchView.as_view(), name='search_phenotypes'),
- # url(r'^phenotypes/(?P([A-Za-z0-9\-]+))/?$', GenericEntity.EntitySearchView.as_view(), name='search_phenotypes'),
-
- ## Detail
- url(r'^phenotypes/(?P\w+)/$', RedirectView.as_view(pattern_name='entity_detail'), name='entity_detail_shortcut'),
- url(r'^phenotypes/(?P\w+)/detail/$', GenericEntity.generic_entity_detail, name='entity_detail'),
- url(r'^phenotypes/(?P\w+)/version/(?P\d+)/detail/$', GenericEntity.generic_entity_detail, name='entity_history_detail'),
- 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'),
+ ## Moderation
+ url(r'^moderation/$', Moderation.EntityModeration.as_view(), name='moderation_page'),
- ## Profile
- url(r'profile/$', Profile.MyCollection.as_view(), name='my_profile'),
- url(r'profile/collection/$', Profile.MyCollection.as_view(), name='my_collection'),
+ ## 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'),
+ url(r'^profile/organisations/$', Profile.MyOrganisations.as_view(), name='my_organisations'),
+
+ ## 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'),
+ url(r'^org/invite/(?P([\w\d\-\_]+))/?$', Organisation.OrganisationInviteView.as_view(), name='view_invite_organisation'),
+
+ # Brand
+ ## Brand Administration
+ ### Endpoints: dashboard view controllers
+ url(r'^dashboard/$', BrandAdmin.BrandDashboardView.as_view(), name=BrandAdmin.BrandDashboardView.reverse_name),
+ url(r'^dashboard/view/overview/$', BrandAdmin.BrandOverviewView.as_view(), name=BrandAdmin.BrandOverviewView.reverse_name),
+ url(r'^dashboard/view/inventory/$', BrandAdmin.BrandInventoryView.as_view(), name=BrandAdmin.BrandInventoryView.reverse_name),
+
+ ### Endpoints: dashboard model administration
+ url(r'^dashboard/target/brand/$', BrandTarget.BrandEndpoint.as_view(), name=BrandTarget.BrandEndpoint.reverse_name_default),
+
+ url(r'^dashboard/target/users/$', UserTarget.UserEndpoint.as_view(), name=UserTarget.UserEndpoint.reverse_name_default),
+ url(r'^dashboard/target/users/(?P\w+)/$', UserTarget.UserEndpoint.as_view(), name=UserTarget.UserEndpoint.reverse_name_retrieve),
+
+ url(r'^dashboard/target/organisations/$', OrganisationTarget.OrganisationEndpoint.as_view(), name=OrganisationTarget.OrganisationEndpoint.reverse_name_default),
+ url(r'^dashboard/target/organisations/(?P\w+)/$', OrganisationTarget.OrganisationEndpoint.as_view(), name=OrganisationTarget.OrganisationEndpoint.reverse_name_retrieve),
+
+ url(r'^dashboard/target/template/$', TemplateTarget.TemplateEndpoint.as_view(), name=TemplateTarget.TemplateEndpoint.reverse_name_default),
+ url(r'^dashboard/target/template/(?P\w+)/$', TemplateTarget.TemplateEndpoint.as_view(), name=TemplateTarget.TemplateEndpoint.reverse_name_retrieve),
+
+ url(r'^dashboard/target/tags/$', TagTarget.TagEndpoint.as_view(), name=TagTarget.TagEndpoint.reverse_name_default),
+ url(r'^dashboard/target/tags/(?P\w+)/$', TagTarget.TagEndpoint.as_view(), name=TagTarget.TagEndpoint.reverse_name_retrieve),
+
+ url(r'^dashboard/target/sites/$', HDRNSiteTarget.HDRNSiteEndpoint.as_view(), name=HDRNSiteTarget.HDRNSiteEndpoint.reverse_name_default),
+ url(r'^dashboard/target/sites/(?P\w+)/$', HDRNSiteTarget.HDRNSiteEndpoint.as_view(), name=HDRNSiteTarget.HDRNSiteEndpoint.reverse_name_retrieve),
+ url(r'^dashboard/target/jurisdictions/$', HDRNJurisdictionTarget.HDRNJurisdictionEndpoint.as_view(), name=HDRNJurisdictionTarget.HDRNJurisdictionEndpoint.reverse_name_default),
+ url(r'^dashboard/target/jurisdictions/(?P\w+)/$', HDRNJurisdictionTarget.HDRNJurisdictionEndpoint.as_view(), name=HDRNJurisdictionTarget.HDRNJurisdictionEndpoint.reverse_name_retrieve),
+ url(r'^dashboard/target/category/$', HDRNCategoryTarget.HDRNCategoryEndpoint.as_view(), name=HDRNCategoryTarget.HDRNCategoryEndpoint.reverse_name_default),
+ url(r'^dashboard/target/category/(?P\w+)/$', HDRNCategoryTarget.HDRNCategoryEndpoint.as_view(), name=HDRNCategoryTarget.HDRNCategoryEndpoint.reverse_name_retrieve),
+ url(r'^dashboard/target/data_assets/$', HDRNDataAssetTarget.HDRNDataAssetEndpoint.as_view(), name=HDRNDataAssetTarget.HDRNDataAssetEndpoint.reverse_name_default),
+ url(r'^dashboard/target/data_assets/(?P\w+)/$', HDRNDataAssetTarget.HDRNDataAssetEndpoint.as_view(), name=HDRNDataAssetTarget.HDRNDataAssetEndpoint.reverse_name_retrieve),
+
+ # GenericEnities (GenericEntity)
## Selection service(s)
url(r'^query/(?P\w+)/?$', GenericEntity.EntityDescendantSelection.as_view(), name='entity_descendants'),
@@ -71,24 +100,13 @@
## 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'),
url(r'^create/(?P[\d]+)/?$', GenericEntity.CreateEntityView.as_view(), name='create_phenotype'),
url(r'^update/(?P\w+)/(?P\d+)/?$', GenericEntity.CreateEntityView.as_view(), name='update_phenotype'),
-
- ## Publication
- url(r'^phenotypes/(?P\w+)/(?P\d+)/publish/$', Publish.Publish.as_view(),name='generic_entity_publish'),
- url(r'^phenotypes/(?P\w+)/(?P\d+)/decline/$', Decline.EntityDecline.as_view(),name='generic_entity_decline'),
- url(r'^phenotypes/(?P\w+)/(?P\d+)/submit/$', Publish.RequestPublish.as_view(),name='generic_entity_request_publish'),
]
+
# Add sitemaps & robots if required (only for HDRUK .org site (or local dev.) -- check is done in site.py)
urlpatterns += [
url(r'^robots.txt/$', site.robots_txt, name='robots.txt'),
@@ -115,9 +133,13 @@
# 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'),
+ url(r'^adminTemp/admin_convert_entity_groups/$', adminTemp.admin_convert_entity_groups, name='admin_convert_entity_groups'),
+ url(r'^adminTemp/admin_fix_icd_ca_cm_codes/$', adminTemp.admin_fix_icd_ca_cm_codes, name='admin_fix_icd_ca_cm_codes'),
]
diff --git a/CodeListLibrary_project/clinicalcode/urls_account.py b/CodeListLibrary_project/clinicalcode/urls_account.py
new file mode 100644
index 000000000..fd0218fcc
--- /dev/null
+++ b/CodeListLibrary_project/clinicalcode/urls_account.py
@@ -0,0 +1,64 @@
+"""URLs & Views _assoc._ with user accounts and their management."""
+
+from django.urls import re_path as url
+from django.contrib.auth import views as auth_views
+
+from clinicalcode.views.Account import AccountPasswordResetView, AccountResetConfirmView, AccountManagementResultView, AccountPasswordResetForm
+
+# Account interface
+urlpatterns = [
+ ## Sign in/out
+ url('login/', auth_views.LoginView.as_view(), name='login'),
+ url('logout/', auth_views.LogoutView.as_view(), name='logout'),
+
+ ## Reset password request
+ url(
+ route=r'^password_reset/$',
+ view=AccountPasswordResetView.as_view(template_name='registration/request_reset.html'),
+ name='password_reset',
+ ),
+ url(
+ route=r'^password_reset/done/$',
+ view=AccountManagementResultView.as_view(
+ template_title='Password Reset Request',
+ template_target='registration/messages/reset_requested.html',
+ ),
+ name='password_reset_done'
+ ),
+
+ ## Reset password impl
+ url(
+ route=r'^reset/(?P[_\-A-Za-z0-9+\/=]+)/(?P[_\-A-Za-z0-9+\/=]+)/$',
+ view=AccountResetConfirmView.as_view(template_name='registration/reset_form.html'),
+ name='password_reset_confirm',
+ ),
+ url(
+ route=r'^reset/done/$',
+ view=AccountManagementResultView.as_view(
+ requires_auth=True,
+ template_title='Password Reset Success',
+ template_target='registration/messages/reset_done.html',
+ template_prompt_signin=False,
+ template_incl_redirect=False,
+ ),
+ name='password_reset_complete'
+ ),
+
+ ## Change password
+ url(
+ route=r'^password_change/$',
+ view=auth_views.PasswordChangeView.as_view(template_name='registration/change_form.html'),
+ name='password_change'
+ ),
+ url(
+ route=r'^password_change/done/$',
+ view=AccountManagementResultView.as_view(
+ requires_auth=True,
+ template_title='Password Change Success',
+ template_target='registration/messages/change_done.html',
+ template_prompt_signin=False,
+ template_incl_redirect=False,
+ ),
+ name='password_change_done'
+ ),
+]
diff --git a/CodeListLibrary_project/clinicalcode/views/Account.py b/CodeListLibrary_project/clinicalcode/views/Account.py
new file mode 100644
index 000000000..85ba7af21
--- /dev/null
+++ b/CodeListLibrary_project/clinicalcode/views/Account.py
@@ -0,0 +1,236 @@
+"""Views relating to Django User account management."""
+
+from django.conf import settings
+from django.urls import reverse_lazy
+from django.http import HttpResponseRedirect, JsonResponse
+from django.core.mail import EmailMultiAlternatives
+from django.utils.http import urlsafe_base64_decode
+from django.contrib.auth import login as auth_login
+from django.contrib.auth import get_user_model
+from django.template.loader import render_to_string
+from django.core.exceptions import PermissionDenied, ImproperlyConfigured, ValidationError
+from django.utils.decorators import method_decorator
+from django.utils.translation import gettext_lazy as _
+from django.contrib.auth.views import PasswordResetView
+from django.contrib.auth.forms import SetPasswordForm, PasswordResetForm
+from django.views.generic.edit import FormView
+from django.views.generic.base import TemplateView
+from django.contrib.auth.views import PasswordContextMixin
+from django.contrib.auth.tokens import default_token_generator
+from django.views.decorators.csrf import csrf_protect
+from django.views.decorators.cache import never_cache
+from django.views.decorators.debug import sensitive_post_parameters
+from django.contrib.auth.decorators import login_not_required
+
+import logging
+
+from clinicalcode.entity_utils import email_utils, model_utils
+
+
+# State
+logger = logging.getLogger(__name__)
+
+UserModel = get_user_model()
+
+
+# Const
+INTERNAL_RESET_SESSION_TOKEN = '_password_reset_token'
+
+
+# Forms
+class AccountPasswordResetForm(PasswordResetForm):
+ """See Django :form:`PasswordResetForm` base"""
+ def __init__(self, *args, **kwargs):
+ if not hasattr(self, 'request'):
+ self.request = kwargs.pop('request', None) if 'request' in kwargs else None
+
+ super(AccountPasswordResetForm, self).__init__(*args, **kwargs)
+
+ def send_mail(
+ self,
+ subject_template_name,
+ email_template_name,
+ context,
+ from_email,
+ to_email,
+ html_email_template_name=None,
+ ):
+ """Sends the password reset e-mail"""
+ request = getattr(self, 'request', None) if hasattr(self, 'request') else None
+
+ brand = model_utils.try_get_brand(request)
+ brand_title = model_utils.try_get_brand_string(brand, 'site_title', default='Concept Library')
+
+ email_subject = f'{brand_title} - Password Reset'
+ email_content = render_to_string(
+ 'clinicalcode/email/password_reset_email.html',
+ context,
+ request=request
+ )
+
+ user = context.get('user')
+ uname = user.username if user else None
+ logger.info(f'Sending AccPWD Reset, with target: User')
+
+ if not settings.IS_DEVELOPMENT_PC or settings.HAS_MAILHOG_SERVICE:
+ try:
+ branded_imgs = email_utils.get_branded_email_images(brand)
+
+ msg = EmailMultiAlternatives(email_subject, email_content, settings.DEFAULT_FROM_EMAIL, to=[to_email])
+ msg.content_subtype = 'related'
+ msg.attach_alternative(email_content, 'text/html')
+
+ msg.attach(email_utils.attach_image_to_email(branded_imgs.get('apple', 'img/email_images/apple-touch-icon.jpg'), 'mainlogo'))
+ msg.attach(email_utils.attach_image_to_email(branded_imgs.get('logo', 'img/email_images/combine.jpg'), 'sponsors'))
+ msg.send()
+ except Exception as e:
+ logger.error(f'AccPWD exception {e}')
+ logger.exception(
+ 'Failed to send password reset email to %s', context['user'].pk
+ )
+ else:
+ logger.info(f'Successfully sent AccPWD email with target: User')
+ return True
+ else:
+ logger.info(f'[DEMO] Successfully sent AccPWD email with target: User')
+ return True
+
+
+# Views
+@method_decorator(login_not_required, name='dispatch')
+class AccountPasswordResetView(PasswordResetView):
+ """See Django :form:`PasswordResetView` base"""
+ form_class = AccountPasswordResetForm
+
+ def get_form_kwargs(self):
+ kwargs = super(AccountPasswordResetView, self).get_form_kwargs()
+ kwargs['request'] = self.request
+ return kwargs
+
+ @method_decorator(csrf_protect)
+ def dispatch(self, *args, **kwargs):
+ return super().dispatch(*args, **kwargs)
+
+
+class AccountManagementResultView(TemplateView):
+ """
+ Simple account management result page.
+
+ Note:
+ - Intention is to display success/failure message relating to `Django Account Management `__ registration pages;
+ - The content of this page can be modified using the `as_view()` method of the `TemplateView` class.
+ """
+ requires_auth = False
+ template_name = 'registration/management_result.html'
+
+ template_title = _('Account Management')
+ template_header = None
+ template_target = None
+ template_message = '%(msg)s
' % { 'msg': _('Account property changed') }
+ template_prompt_signin = True
+ template_incl_redirect = True
+
+ def dispatch(self, request, *args, **kwargs):
+ if self.requires_auth and (not request.user or not request.user.is_authenticated):
+ raise PermissionDenied
+ return super().dispatch(request, *args, **kwargs)
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ return context | {
+ 'template_title': self.template_title,
+ 'template_header': self.template_header,
+ 'template_target': self.template_target,
+ 'template_message': self.template_message,
+ 'template_prompt_signin': self.template_prompt_signin and not self.requires_auth,
+ 'template_incl_redirect': self.template_incl_redirect,
+ }
+
+
+class AccountResetConfirmView(PasswordContextMixin, FormView):
+ """Password reset form & view."""
+
+ title = _('Enter new password')
+ form_class = SetPasswordForm
+ success_url = reverse_lazy('password_reset_complete')
+ template_name = 'registration/reset_form.html'
+
+ post_reset_login = True
+ post_reset_login_backend = None
+
+ reset_url_token = 'set-password'
+ token_generator = default_token_generator
+
+ @method_decorator(sensitive_post_parameters())
+ @method_decorator(never_cache)
+ def dispatch(self, *args, **kwargs):
+ if 'uidb64' not in kwargs or 'token' not in kwargs:
+ raise ImproperlyConfigured(
+ 'The URL path must contain \'uidb64\' and \'token\' parameters.'
+ )
+
+ self.validlink = False
+ self.user = self.get_user(kwargs['uidb64'])
+
+ if self.user is not None:
+ token = kwargs['token']
+ if token == self.reset_url_token:
+ session_token = self.request.session.get(INTERNAL_RESET_SESSION_TOKEN)
+ if self.token_generator.check_token(self.user, session_token):
+ self.validlink = True
+ return super().dispatch(*args, **kwargs)
+ else:
+ if self.token_generator.check_token(self.user, token):
+ self.request.session[INTERNAL_RESET_SESSION_TOKEN] = token
+ redirect_url = self.request.path.replace(
+ token, self.reset_url_token
+ )
+ return HttpResponseRedirect(redirect_url)
+
+ return self.render_to_response(self.get_context_data())
+
+ def get_user(self, uidb64):
+ try:
+ uid = urlsafe_base64_decode(uidb64).decode()
+ pk = UserModel._meta.pk.to_python(uid)
+ user = UserModel._default_manager.get(pk=pk)
+ except (
+ TypeError,
+ ValueError,
+ OverflowError,
+ UserModel.DoesNotExist,
+ ValidationError,
+ ):
+ user = None
+ return user
+
+ def get_form_kwargs(self):
+ kwargs = super().get_form_kwargs()
+ kwargs['user'] = self.user
+ return kwargs
+
+ def form_invalid(self, form):
+ response = super().form_invalid(form)
+ if self.request.accepts('text/html'):
+ return response
+ return JsonResponse(form.errors, status=400)
+
+ def form_valid(self, form):
+ user = form.save()
+ del self.request.session[INTERNAL_RESET_SESSION_TOKEN]
+ if self.post_reset_login:
+ auth_login(self.request, user, self.post_reset_login_backend)
+ return super().form_valid(form)
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ if self.validlink:
+ context['validlink'] = True
+ else:
+ context.update({
+ 'form': None,
+ 'title': _('Password reset unsuccessful'),
+ 'validlink': False,
+ })
+
+ return context
diff --git a/CodeListLibrary_project/clinicalcode/views/Admin.py b/CodeListLibrary_project/clinicalcode/views/Admin.py
index 083016e48..195690c5d 100644
--- a/CodeListLibrary_project/clinicalcode/views/Admin.py
+++ b/CodeListLibrary_project/clinicalcode/views/Admin.py
@@ -1,19 +1,21 @@
-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 django.contrib.auth.models import AnonymousUser
+from django.contrib.auth.decorators import login_required
-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,21 @@
### 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 = {
+ stat = stats_utils.collect_statistics(request)
+ return render(request, 'clinicalcode/admin/run_statistics.html', {
'successMsg': ['Filter statistics for Concepts/Phenotypes saved'],
- }
-
- return render(request, 'clinicalcode/admin/run_statistics.html', context)
+ 'stat': stat,
+ })
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
@@ -55,45 +52,147 @@ def run_homepage_statistics(request):
'clinicalcode/admin/run_statistics.html',
{
'successMsg': ['Homepage statistics saved'],
- 'stat': stat
+ 'stat': stat,
}
)
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 +201,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 +262,7 @@ def run_datasource_sync(request):
'successMsg': ['HDR-UK datasources synced'],
'result': results
}
+
if isinstance(results, str):
message = {
'errorMsg': [results]
@@ -172,6 +277,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 +286,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/CodeListLibrary_project/clinicalcode/views/Decline.py b/CodeListLibrary_project/clinicalcode/views/Decline.py
index 97c0d50fe..4b17bebe4 100644
--- a/CodeListLibrary_project/clinicalcode/views/Decline.py
+++ b/CodeListLibrary_project/clinicalcode/views/Decline.py
@@ -53,7 +53,7 @@ def post(self, request, pk, history_id):
data['form_is_valid'] = True
data['approval_status'] = constants.APPROVAL_STATUS.REJECTED
data['entity_name_requested'] = GenericEntity.history.get(id=pk, history_id=history_id).name
- data = publish_utils.form_validation(request, data, history_id, pk, entity, checks)
+ data = publish_utils.form_validation(request, data, history_id, pk, published_entity, checks)
except Exception as e:
#print(e)
data['form_is_valid'] = False
diff --git a/CodeListLibrary_project/clinicalcode/views/GenericEntity.py b/CodeListLibrary_project/clinicalcode/views/GenericEntity.py
index e4236d102..feb9c04ab 100644
--- a/CodeListLibrary_project/clinicalcode/views/GenericEntity.py
+++ b/CodeListLibrary_project/clinicalcode/views/GenericEntity.py
@@ -1,59 +1,60 @@
-'''
+"""
---------------------------------------------------------------------------
GENERIC-ENTITY VIEW
---------------------------------------------------------------------------
-'''
+"""
from django.urls import reverse
-from django.contrib import messages
-from django.core.exceptions import BadRequest
-from django.contrib.auth.decorators import login_required
-from django.core.exceptions import PermissionDenied
from django.http import HttpResponseNotFound, HttpResponseBadRequest
-from django.http.response import HttpResponse, JsonResponse, Http404
+from collections import OrderedDict
+from django.contrib import messages
from django.shortcuts import render, redirect
-from django.template.loader import render_to_string
+from rest_framework.views import APIView
from django.views.generic import TemplateView
+from django.http.response import HttpResponse, JsonResponse, Http404
+from django.core.exceptions import BadRequest, PermissionDenied
+from django.template.loader import render_to_string
from django.utils.decorators import method_decorator
-from rest_framework.views import APIView
from rest_framework.decorators import schema
-from collections import OrderedDict
+from django.contrib.auth.decorators import login_required
import csv
import json
-import logging
import time
+import logging
from ..entity_utils import (concept_utils, entity_db_utils, permission_utils,
template_utils, gen_utils, model_utils,
create_utils, search_utils, constants)
from clinicalcode.views import View
+from clinicalcode.api.views.View import get_canonical_path_by_brand
from clinicalcode.models.Concept import Concept
from clinicalcode.models.Template import Template
from clinicalcode.models.OntologyTag import OntologyTag
from clinicalcode.models.CodingSystem import CodingSystem
from clinicalcode.models.GenericEntity import GenericEntity
from clinicalcode.models.PublishedGenericEntity import PublishedGenericEntity
-from clinicalcode.api.views.View import get_canonical_path_by_brand
+
logger = logging.getLogger(__name__)
+
class EntitySearchView(TemplateView):
"""
Entity single search view
- - Responsible for:
- -> Managing context of template and which entities to render
- -> SSR of entities at initial GET request based on request params
- -> AJAX-driven update of template based on request params (through JsonResponse)
+
+ Note:
+ Responsibilities:
+ - Managing context of template and which entities to render
+ - SSR of entities at initial GET request based on request params
+ - AJAX-driven update of template based on request params (through JsonResponse)
"""
template_name = 'clinicalcode/generic_entity/search/search.html'
result_template = 'components/search/results.html'
pagination_template = 'components/search/pagination_container.html'
def get_context_data(self, *args, **kwargs):
- '''
- Provides contextful data to template based on request parameters
- '''
+ """Provides contextful data to template based on request parameters"""
context = super(EntitySearchView, self).get_context_data(*args, **kwargs)
request = self.request
@@ -86,13 +87,14 @@ def get(self, request, *args, **kwargs):
"""
Manages get requests to this view
- @note if search_filtered is passed as a parameter (through a fetch req),
- the GET request will return the pagination and results
- for hotreloading relevant search results instead of forcing
- a page reload
+ Note:
+ If `search_filtered` is passed as a parameter (through a fetch req),
+ the `GET` request will return the pagination and results
+ for hotreloading relevant search results instead of forcing
+ a page reload.
- in reality, we should change this to a JSON Response at some point
- and make the client render it rather than wasting server resources
+ In reality, we should change this to a `JSON` Response at some point
+ and make the client render it rather than wasting server resources.
"""
context = self.get_context_data(*args, **kwargs)
filtered = gen_utils.try_get_param(request, 'search_filtered', None)
@@ -106,23 +108,20 @@ def get(self, request, *args, **kwargs):
return render(request, self.template_name, context)
+
@schema(None)
class EntityDescendantSelection(APIView):
"""
- Selection Service View
- @desc API-like view for internal services to discern
- template-related information and to retrieve
- entity descendant data via search
-
- @note Could be moved to API in future?
+ API-like view for internal services to discern template-related information and to retrieve entity descendant data via search.
+
+ TODO:
+ Could be moved to API in future?
"""
fetch_methods = ['get_filters', 'get_results']
''' Private methods '''
def __get_template(self, template_id):
- """
- Attempts to get the assoc. template if available or raises a bad request
- """
+ """Attempts to get the assoc. template if available or raises a bad request"""
template = model_utils.try_get_instance(Template, pk=template_id)
if template is None:
raise BadRequest('Template ID is invalid')
@@ -131,15 +130,11 @@ def __get_template(self, template_id):
''' View methods '''
@method_decorator([login_required, permission_utils.redirect_readonly])
def dispatch(self, request, *args, **kwargs):
- """
- @desc Dispatch view if not in read-only and user is authenticated
- """
+ """Dispatch view if not in read-only and user is authenticated"""
return super(EntityDescendantSelection, self).dispatch(request, *args, **kwargs)
def get_context_data(self, *args, **kwargs):
- """
- @desc Provides contextual data
- """
+ """Provides contextual data"""
context = { }
request = self.request
@@ -149,10 +144,7 @@ def get_context_data(self, *args, **kwargs):
return context | { 'template_id': template_id }
def get(self, request, *args, **kwargs):
- """
- @desc Handles GET requests made by the client and directs
- the params to the appropriate method given the fetch target
- """
+ """Handles GET requests made by the client and directs the params to the appropriate method given the fetch target"""
if gen_utils.is_fetch_request(request):
method = gen_utils.handle_fetch_request(request, self, *args, **kwargs)
return method(request, *args, **kwargs)
@@ -160,9 +152,7 @@ def get(self, request, *args, **kwargs):
''' Fetch methods '''
def get_filters(self, request, *args, **kwargs):
- """
- @desc Gets the filter specification for this template
- """
+ """Gets the filter specification for this template"""
context = self.get_context_data(*args, **kwargs)
template = self.__get_template(context.get('template_id'))
@@ -175,23 +165,19 @@ def get_filters(self, request, *args, **kwargs):
})
def get_results(self, request, *args, **kwargs):
- """
- @desc Gets the search results for the desired template
- after applying query params
- """
+ """Gets the search results for the desired template after applying query params"""
context = self.get_context_data(*args, **kwargs)
template_id = context.get('template_id')
result = search_utils.get_template_entities(request, template_id)
return JsonResponse(result)
+
class CreateEntityView(TemplateView):
"""
- Entity Create View
- @desc Used to create entities
+ Used to create entities
- @note CreateView isn't used due to the requirements
- of having a form dynamically created to
- reflect the dynamic model.
+ Note:
+ - `CreateView` isn't used due to the requirements of having a form dynamically created to reflect the dynamic model.
"""
fetch_methods = [
'search_codes', 'get_options', 'import_rule', 'import_concept',
@@ -205,9 +191,7 @@ class CreateEntityView(TemplateView):
''' View methods '''
@method_decorator([login_required, permission_utils.redirect_readonly])
def dispatch(self, request, *args, **kwargs):
- """
- @desc Dispatch view
- """
+ """Dispatch view"""
return super(CreateEntityView, self).dispatch(request, *args, **kwargs)
def get_context_data(self, *args, **kwargs):
@@ -220,11 +204,10 @@ def get_context_data(self, *args, **kwargs):
@method_decorator([login_required, permission_utils.redirect_readonly])
def get(self, request, *args, **kwargs):
"""
- @desc Handles get requests by determining whether it was made
- through the fetch method, or accessed via a browser.
-
- If requested via browser, will render a view. Otherwise
- will respond with appropriate method, if applicable.
+ Handles get requests by determining whether it was made through the fetch method, or accessed via a browser.
+
+ Note:
+ - If requested via browser, will render a view. Otherwise will respond with appropriate method, if applicable.
"""
if gen_utils.is_fetch_request(request):
method = gen_utils.handle_fetch_request(request, self, *args, **kwargs)
@@ -235,7 +218,7 @@ def get(self, request, *args, **kwargs):
@method_decorator([login_required, permission_utils.redirect_readonly])
def post(self, request, *args, **kwargs):
"""
- @desc Handles form submissions for both:
+ Handles form submissions for both:
- creating
- updating
"""
@@ -277,11 +260,10 @@ def post(self, request, *args, **kwargs):
''' Main view render '''
def render_view(self, request, *args, **kwargs):
"""
- @desc Template and entity is tokenised in the URL - providing the latter requires
- users to be permitted to modify that particular entity.
+ Template and entity is tokenised in the URL - providing the latter requires users to be permitted to modify that particular entity.
- If no entity_id is passed, a creation form is returned, otherwise the user is
- redirected to an update form.
+ Note:
+ - If no `entity_id` is passed, a creation form is returned, otherwise the user is redirected to an update form.
"""
context = self.get_context_data(*args, **kwargs)
@@ -289,6 +271,9 @@ def render_view(self, request, *args, **kwargs):
template_id = kwargs.get('template_id')
entity_id = kwargs.get('entity_id')
if template_id is None and entity_id is None:
+ if not permission_utils.can_user_create_entities(request):
+ raise PermissionDenied
+
return self.select_form(request, context)
# Send to create form if template_id is selected
@@ -297,6 +282,10 @@ def render_view(self, request, *args, **kwargs):
template = model_utils.try_get_instance(Template, pk=template_id)
if template is None or template.hide_on_create:
raise Http404
+
+ if not permission_utils.can_user_create_entities(request):
+ raise PermissionDenied
+
return self.create_form(request, context, template)
# Send to update form if entity_id is selected
@@ -305,11 +294,11 @@ def render_view(self, request, *args, **kwargs):
entity = create_utils.try_validate_entity(request, entity_id, entity_history_id)
if not entity:
raise Http404
-
+
template = entity.template
if template is None:
raise Http404
-
+
return self.update_form(request, context, template, entity)
# Raise 400 if no param matches views
@@ -317,25 +306,19 @@ def render_view(self, request, *args, **kwargs):
''' Forms '''
def select_form(self, request, context):
- """
- @desc Renders the template selection form
- """
+ """Renders the template selection form"""
context['entity_data'] = create_utils.get_createable_entities(request)
return render(request, self.templates.get('select'), context)
def create_form(self, request, context, template):
- """
- @desc Renders the entity create form
- """
+ """Renders the entity create form"""
context['metadata'] = constants.metadata
context['template'] = template
context['form_method'] = constants.FORM_METHODS.CREATE
return render(request, self.templates.get('form'), context)
def update_form(self, request, context, template, entity):
- """
- @desc Renders the entity update form
- """
+ """Renders the entity update form"""
context['metadata'] = constants.metadata
context['template'] = template
context['entity'] = entity
@@ -347,10 +330,7 @@ def update_form(self, request, context, template, entity):
''' Fetch methods '''
def import_rule(self, request, *args, **kwargs):
- """
- @desc GET request made by client to retrieve the codelist assoc.
- with the concept they are attempting to import as a rule
- """
+ """GET request made by client to retrieve the codelist _assoc._ with the concept they are attempting to import as a rule"""
concept_id = gen_utils.try_get_param(request, 'concept_id')
concept_version_id = gen_utils.try_get_param(request, 'concept_version_id')
if concept_id is None or concept_version_id is None:
@@ -376,10 +356,7 @@ def import_rule(self, request, *args, **kwargs):
})
def import_concept(self, request, *args, **kwargs):
- """
- @desc GET request made by client to retrieve codelists assoc.
- with the concepts they are attempting to import as top-level objects
- """
+ """GET request made by client to retrieve codelists _assoc._ with the concepts they are attempting to import as top-level objects"""
concept_ids = gen_utils.try_get_param(request, 'concept_ids')
concept_version_ids = gen_utils.try_get_param(request, 'concept_version_ids')
if concept_ids is None or concept_version_ids is None:
@@ -411,9 +388,8 @@ def query_ontology_record(self, request, *args, **kwargs):
See: :func:`~clinicalcode.models.OntologyTag.OntologyTag.get_creation_data~`
- Query Params:
- node_id (int): the node id
-
+ QueryParams:
+ node_id (int): the node id
type_id (str|int): the node's type id
Returns:
@@ -448,8 +424,7 @@ def query_ontology_typeahead(self, request, *args, **kwargs):
See: :func:`~clinicalcode.models.OntologyTag.OntologyTag.query_typeahead~`
Query Params:
- search (str): some web query search term; defaults to an empty `str`
-
+ search (str): some web query search term; defaults to an empty `str`
type_ids (str|int|int[]): narrow the resultset by specifying the ontology type ids; defaults to `None`
Returns:
@@ -476,12 +451,7 @@ def query_ontology_typeahead(self, request, *args, **kwargs):
return JsonResponse({ 'result': OntologyTag.query_typeahead(searchterm, type_ids) })
def get_options(self, request, *args, **kwargs):
- """
- @desc GET request made by client to retrieve all available
- options for a given field within its template
-
- Atm, it is exclusively used to retrieve Coding Systems
- """
+ """GET request made by client to retrieve all available options for a given field within its template. Atm, it is exclusively used to retrieve Coding Systems"""
template_id = gen_utils.parse_int(gen_utils.try_get_param(request, 'template'), default=None)
if not template_id:
return gen_utils.jsonify_response(message='Invalid template parameter', code=400, status='false')
@@ -494,26 +464,32 @@ def get_options(self, request, *args, **kwargs):
if field is None or gen_utils.is_empty_string(field):
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)
+ info = template_utils.get_template_field_info(template, field)
+ struct = info.get('field')
+ if info.get('is_metadata'):
+ default_value = None
+
+ if struct is not None:
+ validation = template_utils.try_get_content(struct, 'validation')
+ if validation is not None:
+ default_value = [] if validation.get('type') == 'int_array' else default_value
+
+ options = template_utils.get_template_sourced_values(constants.metadata, field, request=request, default=default_value, struct=struct)
else:
- options = template_utils.get_template_sourced_values(template, field, request=request)
-
+ options = template_utils.get_template_sourced_values(template, field, request=request, struct=struct)
+
if options is None:
return gen_utils.jsonify_response(message='Invalid field parameter, does not exist or is not an optional parameter', code=400, status='false')
- options = model_utils.append_coding_system_data(options)
+ if field == 'coding_system':
+ options = model_utils.append_coding_system_data(options)
+
return JsonResponse({
'result': options
})
def search_codes(self, request, *args, **kwargs):
- """
- @desc GET request made by client to search a codelist given its coding id,
- a search term, and the relevant template
-
- e.g. entity/{update|create}/?search=C1&coding_system=4&template=1
- """
+ """GET request made by client to search a codelist given its coding id, a search term, and the relevant template, _e.g._ `entity/{update|create}/?search=C1&coding_system=4&template=1`"""
template_id = gen_utils.parse_int(gen_utils.try_get_param(request, 'template'), default=None)
if not template_id:
return gen_utils.jsonify_response(message='Invalid template parameter', code=400, status='false')
@@ -551,13 +527,13 @@ def search_codes(self, request, *args, **kwargs):
'result': codelist
})
+
class RedirectConceptView(TemplateView):
"""
- [!] Note: Used to maintain legacy URLs where users could visit concepts//detail
-
- @desc Redirects requests to the phenotype page, assuming a phenotype owner
- can be resolved from the child Concept
+ Redirects requests to the phenotype page, assuming a phenotype owner can be resolved from the child Concept
+ Note:
+ - Used to maintain legacy URLs where users could visit `concepts/C/detail`
"""
# URL Name of the detail page
@@ -570,31 +546,61 @@ def get(self, request, *args, **kwargs):
2. Will then try to find its Phenotype owner
3. Finally, redirect the user to the Phenotype page
"""
+ brand = model_utils.try_get_brand(request)
+ if brand is not None:
+ brand_mapping = brand.get_map_rules()
+ else:
+ brand_mapping = constants.DEFAULT_CONTENT_MAPPING
+
+ tx_concept = brand_mapping.get('concept', 'Concept')
+ tx_phenotype = brand_mapping.get('phenotype', 'Phenotype')
+
concept_id = gen_utils.parse_int(kwargs.get('pk'), default=None)
if concept_id is None:
- raise Http404
-
+ return View.notify_err(
+ request,
+ title='Bad Request - Invalid ID',
+ status_code=400,
+ details=[f'The {tx_concept} ID you supplied is invalid. If we sent you here, please contact us and let us know.']
+ )
+
concept = model_utils.try_get_instance(Concept, id=concept_id)
if concept is None:
- raise Http404
-
+ return View.notify_err(
+ request,
+ title=f'Page Not Found - Missing {tx_concept}',
+ status_code=404,
+ details=[f'Sorry but it looks like this {tx_concept} doesn\'t exist. If we sent you here, please contact us and let us know.']
+ )
+
entity_owner = concept.phenotype_owner
if entity_owner is None:
- raise Http404
+ return View.notify_err(
+ request,
+ title='Page Not Found - Failed To Resolve',
+ status_code=404,
+ details=[f'Sorry but it looks like this {tx_concept} isn\'t currently associated with a {tx_phenotype}. Please use the API to access the contents of this {tx_concept}.']
+ )
return redirect(reverse(self.ENTITY_DETAIL_VIEW, kwargs={ 'pk': entity_owner.id }))
+
def generic_entity_detail(request, pk, history_id=None):
- '''
- Display the detail of a generic entity at a point in time.
- '''
+ """Display the detail of a generic entity at a point in time."""
# validate pk param
+ brand = model_utils.try_get_brand(request)
+ if brand is not None:
+ brand_mapping = brand.get_map_rules()
+ else:
+ brand_mapping = constants.DEFAULT_CONTENT_MAPPING
+
+ tx_phenotype = brand_mapping.get('phenotype', 'Phenotype')
if not model_utils.get_entity_id(pk):
return View.notify_err(
request,
- title='Bad request',
+ title='Bad Request - Invalid ID',
status_code=400,
- details=['Invalid Phenotype ID']
+ details=[f'The {tx_phenotype} ID you supplied is invalid. If we sent you here, please contact us and let us know.']
)
# find latest accessible entity for given pk if historical id not specified
@@ -603,13 +609,30 @@ def generic_entity_detail(request, pk, history_id=None):
entities = permission_utils.get_accessible_entities(request, pk=pk)
if not entities.exists():
- raise Http404
+ return View.notify_err(
+ request,
+ title=f'Page Not Found - Missing {tx_phenotype}',
+ status_code=404,
+ details=[f'Sorry but it looks like this {tx_phenotype} doesn\'t exist. If we sent you here, please contact us and let us know.']
+ )
else:
history_id = entities.first().history_id
- accessibility = permission_utils.get_accessible_detail_entity(request, pk, history_id)
- if not accessibility or not accessibility.get('view_access'):
- raise PermissionDenied
+ accessibility, err = permission_utils.get_accessible_detail_entity(request, pk, history_id)
+ if err is not None:
+ return View.notify_err(
+ request,
+ title=err.get('title'),
+ status_code=err.get('status_code'),
+ details=[err.get('message')]
+ )
+ elif not accessibility or not accessibility.get('view_access'):
+ return View.notify_err(
+ request,
+ title='Forbidden - Permission Denied',
+ status_code=403,
+ details=[f'Sorry but it looks like this {tx_phenotype} hasn\'t been made accessible to you yet. Please contact the author of this {tx_phenotype} to grant you access.']
+ )
entity = accessibility.get('historical_entity')
live_entity = accessibility.get('entity')
@@ -629,7 +652,7 @@ def generic_entity_detail(request, pk, history_id=None):
published_historical_ids = entity_dataset.get('published_ids', [])
template = entity.template.history.filter(template_version=entity.template_version).latest()
- entity_class = template.entity_class.name
+ entity_class = template.entity_class
if request.user.is_authenticated:
can_edit = accessibility.get('edit_access', False)
@@ -668,10 +691,7 @@ def generic_entity_detail(request, pk, history_id=None):
def get_history_table_data(request, pk):
- """"
- get history table data for the template
- """
-
+ """"Gets history table data for the template"""
versions = GenericEntity.objects.get(pk=pk).history.all()
historical_versions = []
@@ -697,7 +717,6 @@ def get_history_table_data(request, pk):
if ver.approval_status != constants.APPROVAL_STATUS.ANY:
ver.approval_status_label = [s.name for s in constants.APPROVAL_STATUS if s == ver.approval_status][0]
-
if request.user.is_authenticated:
if permission_utils.can_user_edit_entity(request, pk) or permission_utils.can_user_view_entity(request, pk):
historical_versions.append(ver)
@@ -709,15 +728,24 @@ def get_history_table_data(request, pk):
historical_versions.append(ver)
return historical_versions
-
-
+
+
def export_entity_codes_to_csv(request, pk, history_id=None):
- """
- Return a csv file of codes for a clinical-coded phenotype for a specific historical version.
- """
- if history_id is None:
- # get the latest version/ or latest published version
- history_id = permission_utils.try_get_valid_history_id(request, GenericEntity, pk)
+ """Returns a csv file of codes for a clinical-coded phenotype for a specific historical version."""
+
+ # get the latest version/ or latest published version
+ if not isinstance(history_id, int):
+ entities = permission_utils.get_accessible_entities(request, pk=pk)
+
+ if not entities.exists():
+ return View.notify_err(
+ request,
+ title='Page Not Found - Missing Codelist',
+ status_code=404,
+ details=['Sorry but it looks like this codelist doesn\'t exist.']
+ )
+ else:
+ history_id = entities.first().history_id
# validate access for login and public site
permission_utils.validate_access_to_view(request, pk, history_id)
@@ -758,7 +786,7 @@ def export_entity_codes_to_csv(request, pk, history_id=None):
'creation_date': time.strftime("%Y%m%dT%H%M%S")
}
response = HttpResponse(content_type='text/csv')
- response['Content-Disposition'] = ('attachment; filename="phenotype_%(phenotype_id)s_ver_%(history_id)s_concepts_%(creation_date)s.csv"' % my_params)
+ response['Content-Disposition'] = ('attachment; filename="%(phenotype_id)s_ver_%(history_id)s_codelists_%(creation_date)s.csv"' % my_params)
writer = csv.writer(response)
diff --git a/CodeListLibrary_project/clinicalcode/views/Moderation.py b/CodeListLibrary_project/clinicalcode/views/Moderation.py
index 43d2cb177..515edec7c 100644
--- a/CodeListLibrary_project/clinicalcode/views/Moderation.py
+++ b/CodeListLibrary_project/clinicalcode/views/Moderation.py
@@ -1,13 +1,17 @@
from django.views.generic import TemplateView
from django.shortcuts import render
from django.db.models import Subquery, OuterRef
-from django.contrib.auth.models import User, Group
+from django.contrib.auth.models import Group
from django.contrib.auth.decorators import login_required
from django.utils.decorators import method_decorator
from django.core.exceptions import PermissionDenied
+from django.contrib.auth import get_user_model
+from ..models.Organisation import Organisation
from ..entity_utils import permission_utils, constants
+User = get_user_model()
+
class EntityModeration(TemplateView):
template_name = 'clinicalcode/moderation/index.html'
template_fields = [
@@ -21,7 +25,7 @@ def __annotate_fields(self, queryset):
annotated = queryset.annotate(
group_name=Subquery(
- Group.objects.filter(id=OuterRef('group_id')).values('name')
+ Organisation.objects.filter(id=OuterRef('organisation_id')).values('name')
),
owner_name=Subquery(
User.objects.filter(id=OuterRef('owner')).values('username')
diff --git a/CodeListLibrary_project/clinicalcode/views/Organisation.py b/CodeListLibrary_project/clinicalcode/views/Organisation.py
new file mode 100644
index 000000000..a4b0eb232
--- /dev/null
+++ b/CodeListLibrary_project/clinicalcode/views/Organisation.py
@@ -0,0 +1,723 @@
+from datetime import datetime
+from django.utils.timezone import make_aware
+from django.views.generic import TemplateView, CreateView, UpdateView
+from django.shortcuts import render, redirect
+from django.conf import settings
+from django.db.models import F, When, Case, Value, Subquery, OuterRef
+from django.db import transaction, IntegrityError
+from django.contrib.auth.models import 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, connection
+from django.core.exceptions import BadRequest, PermissionDenied
+from django.contrib.auth import get_user_model
+
+import logging
+
+from ..models.GenericEntity import GenericEntity
+from ..models.Brand import Brand
+from ..models.Organisation import Organisation, OrganisationMembership, OrganisationAuthority, OrganisationInvite
+from ..forms.OrganisationForms import OrganisationCreateForm, OrganisationManageForm
+from ..entity_utils import permission_utils, model_utils, gen_utils, constants, email_utils
+
+logger = logging.getLogger(__name__)
+
+''' Create Organisation '''
+
+User = get_user_model()
+
+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)
+
+''' Manage Organisation '''
+
+class OrganisationManageView(UpdateView):
+ model = Organisation
+ template_name = 'clinicalcode/organisation/manage.html'
+ form_class = OrganisationManageForm
+ fetch_methods = [
+ 'change_user_role', 'delete_member', 'invite_member',
+ 'cancel_invite', 'get_reloaded_data'
+ ]
+
+ @method_decorator([login_required, permission_utils.redirect_readonly])
+ def dispatch(self, request, *args, **kwargs):
+ user = request.user
+ slug = kwargs.get('slug')
+
+ has_access = permission_utils.has_member_org_access(
+ user,
+ slug,
+ constants.ORGANISATION_ROLES.ADMIN
+ )
+ if not has_access:
+ raise PermissionDenied
+
+ 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_page_data(self, request):
+ user = request.user
+ roles = [{ 'name': x.name, 'value': x.value } for x in constants.ORGANISATION_ROLES]
+
+ members = self.object.members.through.objects \
+ .filter(organisation_id=self.object.id) \
+ .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()
+ ),
+ username=Subquery(
+ User.objects.filter(
+ id=OuterRef('user_id')
+ )
+ .values('username')
+ )
+ ) \
+ .distinct('user_id') \
+ .values('id', 'user_id', 'organisation_id', 'username', 'role', 'role_name')
+ members = list(members)
+
+ invites = OrganisationInvite.objects.filter(
+ organisation_id=self.object.id,
+ outcome__in=[
+ constants.ORGANISATION_INVITE_STATUS.ACTIVE,
+ constants.ORGANISATION_INVITE_STATUS.SEEN
+ ]
+ ) \
+ .annotate(
+ username=Subquery(
+ User.objects.filter(
+ id=OuterRef('user_id')
+ )
+ .values('username')
+ )
+ ) \
+ .values('id', 'user_id', 'username')
+ invites = list(invites)
+
+ user_list = permission_utils.get_brand_related_users(request) \
+ .exclude(
+ id__in=(
+ [user.id, self.object.owner_id] +
+ [member.get('user_id') for member in members] +
+ [invite.get('user_id') for invite in invites]
+ )
+ ) \
+ .values('id', 'username')
+ user_list = list(user_list)
+
+ return {
+ 'roles': roles,
+ 'members': members,
+ 'invites': {
+ 'users': user_list,
+ 'active': invites,
+ 'oid': self.object.id
+ }
+ }
+
+ def get_context_data(self, **kwargs):
+ self.object = self.get_object()
+
+ context = super(OrganisationManageView, self).get_context_data(**kwargs)
+ context.update(instance=self.object)
+
+ return context | self.get_page_data(self.request)
+
+ 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)
+
+ @method_decorator([login_required, permission_utils.redirect_readonly])
+ def get(self, request, *args, **kwargs):
+ if gen_utils.is_fetch_request(request):
+ target = request.headers.get('X-Target', None)
+ if target is not None and target in self.fetch_methods:
+ target = getattr(self, target)
+ return target(request, *args, **kwargs)
+
+ return super().get(request, *args, **kwargs)
+
+ def get_reloaded_data(self, request, *args, **kwargs):
+ self.object = self.get_object()
+
+ ctx = self.get_page_data(request)
+ return JsonResponse(ctx)
+
+ @method_decorator([login_required, permission_utils.redirect_readonly])
+ def post(self, request, *args, **kwargs):
+ if gen_utils.is_fetch_request(request):
+ target = request.headers.get('X-Target', None)
+ if target is not None and target in self.fetch_methods:
+ target = getattr(self, target)
+ return target(request, *args, **kwargs)
+
+ return super().post(request, *args, **kwargs)
+
+ def change_user_role(self, request, *args, **kwargs):
+ body = gen_utils.get_request_body(request)
+
+ uid = body.get('uid')
+ oid = body.get('oid')
+ rid = body.get('rid')
+ if not isinstance(uid, int) or not isinstance(oid, int) or not isinstance(rid, int):
+ return gen_utils.jsonify_response(code=400)
+
+ if uid == request.user.id:
+ return gen_utils.jsonify_response(code=400)
+
+ roles = [x.value for x in constants.ORGANISATION_ROLES]
+ if rid not in roles:
+ return gen_utils.jsonify_response(code=400)
+
+ membership = OrganisationMembership.objects.filter(
+ user_id=uid,
+ organisation_id=oid
+ )
+ if not membership.exists():
+ return gen_utils.jsonify_response(code=400)
+ membership = membership.first()
+
+ try:
+ membership.role = rid
+ membership.save()
+ except IntegrityError as e:
+ logger.warning(
+ f'Integrity error when attempting to change organisation roles: {body}> with err: {e}'
+ )
+ return gen_utils.jsonify_response(code=400)
+
+ return gen_utils.jsonify_response(code=200)
+
+ def delete_member(self, request, *args, **kwargs):
+ body = gen_utils.get_request_body(request)
+
+ uid = body.get('uid')
+ oid = body.get('oid')
+ if not isinstance(uid, int) or not isinstance(oid, int):
+ return gen_utils.jsonify_response(code=400)
+
+ if uid == request.user.id:
+ return gen_utils.jsonify_response(code=400)
+
+ membership = OrganisationMembership.objects.filter(
+ user_id=uid,
+ organisation_id=oid
+ )
+ if not membership.exists():
+ return gen_utils.jsonify_response(code=400)
+ membership = membership.first()
+
+ try:
+ membership.delete()
+ except Exception as e:
+ logger.warning(
+ f'Integrity error when attempting to remove user from organisation: {body}> with err: {e}'
+ )
+ return gen_utils.jsonify_response(code=400)
+
+ return gen_utils.jsonify_response(code=200)
+
+ def invite_member(self, request, *args, **kwargs):
+ body = gen_utils.get_request_body(request)
+
+ uid = body.get('id')
+ oid = body.get('oid')
+ if not isinstance(uid, int) or not isinstance(oid, int):
+ return gen_utils.jsonify_response(code=400)
+
+ membership = OrganisationMembership.objects.filter(
+ user_id=uid,
+ organisation_id=oid
+ )
+ if membership.exists():
+ return gen_utils.jsonify_response(code=400)
+
+ current_invite = OrganisationInvite.objects.filter(
+ user_id=uid,
+ organisation_id=oid
+ ) \
+ .exclude(
+ outcome__in=[
+ constants.ORGANISATION_INVITE_STATUS.EXPIRED,
+ constants.ORGANISATION_INVITE_STATUS.ACCEPTED,
+ constants.ORGANISATION_INVITE_STATUS.REJECTED
+ ]
+ )
+ if current_invite.exists():
+ return gen_utils.jsonify_response(code=400)
+
+ try:
+ invite = OrganisationInvite.objects.create(
+ user_id=uid,
+ organisation_id=oid
+ )
+
+ has_sent = email_utils.send_invite_email(request, invite)
+ if has_sent:
+ invite.sent = True
+ invite.save()
+ except Exception as e:
+ logger.warning(
+ f'Integrity error when attempting to invite user to organisation: {body}> with err: {e}'
+ )
+ return gen_utils.jsonify_response(code=400)
+
+ return gen_utils.jsonify_response(code=200)
+
+ def cancel_invite(self, request, *args, **kwargs):
+ body = gen_utils.get_request_body(request)
+
+ uid = body.get('uid')
+ oid = body.get('oid')
+ if not isinstance(uid, int) or not isinstance(oid, int):
+ return gen_utils.jsonify_response(code=400)
+
+ current_invite = OrganisationInvite.objects.filter(
+ user_id=uid,
+ organisation_id=oid
+ ) \
+ .exclude(
+ outcome__in=[
+ constants.ORGANISATION_INVITE_STATUS.EXPIRED,
+ constants.ORGANISATION_INVITE_STATUS.REJECTED
+ ]
+ )
+ if current_invite.exists():
+ current_invite = current_invite.first()
+
+ try:
+ current_invite.outcome = constants.ORGANISATION_INVITE_STATUS.REJECTED
+ current_invite.save()
+ except IntegrityError as e:
+ logger.warning(
+ f'Integrity error when attempting to delete organisation invite: {body}> with err: {e}'
+ )
+ return gen_utils.jsonify_response(code=400)
+
+ return gen_utils.jsonify_response(code=200)
+
+''' View Organisation '''
+
+class OrganisationView(TemplateView):
+ template_name = 'clinicalcode/organisation/view.html'
+ fetch_methods = [
+ 'leave_organisation'
+ ]
+
+ 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
+
+ user = request.user if request.user and not request.user.is_anonymous else None
+
+ current_brand = model_utils.try_get_brand(request)
+ current_brand = current_brand if current_brand and current_brand.org_user_managed else None
+
+ 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 \
+ .filter(organisation_id=organisation.id) \
+ .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()
+ )
+ ) \
+ .distinct('user_id')
+
+ is_owner = False
+ is_member = False
+ is_admin = False
+ if user:
+ is_owner = organisation.owner_id == user.id
+
+ membership = organisation.organisationmembership_set.filter(user_id=user.id)
+ if membership.exists():
+ membership = membership.first()
+
+ is_member = True
+ is_admin = membership.role >= constants.ORGANISATION_ROLES.ADMIN
+
+ entity_brand_clause = ''
+ user_perms_sql = f'''
+ select memb.user_id, memb.organisation_id, memb.role
+ from public.auth_user as uau
+ join public.clinicalcode_organisationmembership as memb
+ on uau.id = memb.user_id
+ join public.clinicalcode_organisation as org
+ on memb.organisation_id = org.id
+ where memb.organisation_id = {organisation.id}
+ and memb.user_id = {user.id if user else 'null'}
+ union
+ select uau.id as user_id, org.id as organisation_id, 3 as role
+ from public.auth_user as uau
+ join public.clinicalcode_organisation as org
+ on org.owner_id = uau.id
+ where uau.id = {user.id if user else 'null'}
+ '''
+ if current_brand is not None:
+ entity_brand_clause = f'and ge.brands && array[{current_brand.id}]'
+
+ user_perms_sql = f'''
+ select memb.user_id, memb.organisation_id, memb.role
+ from public.auth_user as uau
+ join public.clinicalcode_organisationmembership as memb
+ on uau.id = memb.user_id
+ join public.clinicalcode_organisation as org
+ on memb.organisation_id = org.id
+ join public.clinicalcode_organisationauthority as auth
+ on auth.organisation_id = org.id
+ where memb.organisation_id = {organisation.id}
+ and memb.user_id = {user.id if user else 'null'}
+ and auth.brand_id = {current_brand.id}
+ union
+ select uau.id as user_id, org.id as organisation_id, 3 as role
+ from public.auth_user as uau
+ join public.clinicalcode_organisation as org
+ on org.owner_id = uau.id
+ where uau.id = {user.id if user else 'null'}
+ '''
+
+ sql = f'''
+ with user_perms as (
+ {user_perms_sql}
+ ),
+ entities as (
+ select hge.id, hge.history_id, hge.name, hge.updated,
+ hge.publish_status, ge.organisation_id, ge.is_deleted,
+ row_number() over (partition by hge.id order by hge.history_id desc) as rn
+ from public.clinicalcode_historicalgenericentity as hge
+ join public.clinicalcode_genericentity as ge
+ on hge.id = ge.id
+ where ge.organisation_id = {organisation.id}
+ {entity_brand_clause}
+ ),
+ published as (
+ select id, history_id, name, updated, publish_status
+ from (
+ select *,
+ min(rn) over (partition by entities.id) as min_rn
+ from entities
+ where publish_status = {constants.APPROVAL_STATUS.APPROVED}
+ ) as sq
+ where rn = min_rn
+ ),
+ draft as (
+ select id, history_id, name, updated, publish_status
+ from (
+ select entities.*,
+ min(entities.rn) over (partition by entities.id) as min_rn
+ from entities
+ join user_perms
+ on entities.organisation_id = user_perms.organisation_id
+ left join published
+ on entities.id = published.id
+ and published.history_id > entities.history_id
+ where entities.publish_status != {constants.APPROVAL_STATUS.APPROVED}
+ and published.id is null
+ and entities.is_deleted is null or entities.is_deleted = false
+ ) as sq
+ where rn = min_rn
+ ),
+ moderated as (
+ select entities.id, entities.history_id, entities.name,
+ entities.updated, entities.publish_status
+ from entities
+ join user_perms
+ on entities.organisation_id = user_perms.organisation_id
+ where user_perms.role >= 2
+ and entities.publish_status in (
+ {constants.APPROVAL_STATUS.REQUESTED}, {constants.APPROVAL_STATUS.PENDING}
+ )
+ )
+ select json_build_object(
+ 'published', (
+ select json_agg(json_build_object(
+ 'id', id,
+ 'history_id', history_id,
+ 'name', name,
+ 'updated', updated,
+ 'publish_status', publish_status
+ )) from published
+ ),
+ 'draft', (
+ select json_agg(json_build_object(
+ 'id', id,
+ 'history_id', history_id,
+ 'name', name,
+ 'updated', updated,
+ 'publish_status', publish_status
+ )) from draft
+ ),
+ 'moderated', (
+ select json_agg(json_build_object(
+ 'id', id,
+ 'history_id', history_id,
+ 'name', name,
+ 'updated', updated,
+ 'publish_status', publish_status
+ )) from moderated
+ )
+ ) as "data"
+ '''
+ entities = None
+ with connection.cursor() as cursor:
+ cursor.execute(sql)
+
+ columns = [col[0] for col in cursor.description]
+ entities = [dict(zip(columns, row)) for row in cursor.fetchall()][0]
+ entities = entities.get('data')
+
+ return context | {
+ 'instance': organisation,
+ 'is_owner': is_owner,
+ 'is_member': is_member,
+ 'is_admin': is_admin,
+ 'members': members,
+ 'published': entities.get('published'),
+ 'draft': entities.get('draft'),
+ 'moderated': entities.get('moderated')
+ }
+
+ 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)
+
+ @method_decorator([login_required, permission_utils.redirect_readonly])
+ def post(self, request, *args, **kwargs):
+ if gen_utils.is_fetch_request(request):
+ target = request.headers.get('X-Target', None)
+ if target is not None and target in self.fetch_methods:
+ target = getattr(self, target)
+ return target(request, *args, **kwargs)
+
+ return super().post(request, *args, **kwargs)
+
+ def leave_organisation(self, request, *args, **kwargs):
+ context = self.get_context_data(*args, **kwargs)
+ user = request.user if request.user and not request.user.is_anonymous else None
+
+ if context.get('is_owner') or context.get('is_member') == False:
+ return gen_utils.jsonify_response(code=400)
+
+ slug = context.get('slug')
+ membership = OrganisationMembership.objects.filter(
+ organisation__slug=slug, user_id=user.id
+ )
+ if not membership.exists():
+ return gen_utils.jsonify_response(code=404)
+ membership = membership.first()
+
+ try:
+ membership.delete()
+ except Exception as e:
+ logger.warning(
+ f'Integrity error when attempting to remove user from organisation: {slug}> with err: {e}'
+ )
+ return gen_utils.jsonify_response(code=400)
+
+ return gen_utils.jsonify_response(code=200, status='true')
+
+''' View Invite '''
+
+class OrganisationInviteView(TemplateView):
+ template_name = 'clinicalcode/organisation/invite.html'
+ fetch_methods = [
+ 'invitation_reponse'
+ ]
+
+ @method_decorator([login_required, permission_utils.redirect_readonly])
+ def dispatch(self, request, *args, **kwargs):
+ uuid = kwargs.get('uuid')
+ if gen_utils.is_empty_string(uuid):
+ return BadRequest
+
+ invite = OrganisationInvite.objects.all() \
+ .filter(
+ id=uuid
+ )
+ if not invite.exists():
+ return BadRequest
+ invite = invite.first()
+
+ user = request.user
+ if not user:
+ return BadRequest
+
+ if invite.user_id != user.id:
+ return BadRequest
+
+ if invite.is_expired():
+ return BadRequest
+
+ if invite.outcome not in [constants.ORGANISATION_INVITE_STATUS.ACTIVE, constants.ORGANISATION_INVITE_STATUS.SEEN]:
+ return BadRequest
+
+ return super(OrganisationInviteView, self).dispatch(request, *args, **kwargs)
+
+ def get_context_data(self, *args, **kwargs):
+ context = super(OrganisationInviteView, self).get_context_data(*args, **kwargs)
+ request = self.request
+
+ uuid = kwargs.get('uuid')
+ if not gen_utils.is_empty_string(uuid):
+ invite = OrganisationInvite.objects.all() \
+ .filter(
+ id=uuid
+ )
+ if invite.exists():
+ invite = invite.first()
+
+ return context | {
+ 'instance': invite.organisation,
+ 'invite': invite
+ }
+
+ raise Http404('No organisation invite found')
+
+ def get(self, request, *args, **kwargs):
+ context = self.get_context_data(*args, **kwargs)
+ return render(request, self.template_name, context)
+
+ @method_decorator([login_required, permission_utils.redirect_readonly])
+ def post(self, request, *args, **kwargs):
+ if gen_utils.is_fetch_request(request):
+ target = request.headers.get('X-Target', None)
+ if target is not None and target in self.fetch_methods:
+ target = getattr(self, target)
+ return target(request, *args, **kwargs)
+
+ return super().post(request, *args, **kwargs)
+
+ def invitation_reponse(self, request, *args, **kwargs):
+ context = self.get_context_data(*args, **kwargs)
+ user = request.user if request.user and not request.user.is_anonymous else None
+ body = gen_utils.get_request_body(request)
+
+ invitation_response = body.get('result')
+ if invitation_response is None:
+ return gen_utils.jsonify_response(code=400)
+
+ invite = context.get('invite')
+ if not invite or not isinstance(invite, OrganisationInvite):
+ return gen_utils.jsonify_response(code=500)
+
+ if user.id != invite.user_id:
+ return gen_utils.jsonify_response(code=400)
+
+ current_membership = OrganisationMembership.objects.filter(
+ organisation_id=invite.organisation_id,
+ user_id=invite.user_id
+ )
+ if current_membership.exists():
+ return gen_utils.jsonify_response(code=400)
+
+ try:
+ if invitation_response:
+ membership = OrganisationMembership.objects.create(
+ user_id=invite.user_id,
+ organisation_id=invite.organisation_id
+ )
+
+ invite.outcome = constants.ORGANISATION_INVITE_STATUS.ACCEPTED
+ invite.save()
+ else:
+ invite.outcome = constants.ORGANISATION_INVITE_STATUS.REJECTED
+ invite.save()
+ except Exception as e:
+ logger.warning(
+ f'Integrity error when attempting to remove user from organisation: {body}> with err: {e}'
+ )
+ return gen_utils.jsonify_response(code=400)
+
+ return gen_utils.jsonify_response(code=200, status='true')
diff --git a/CodeListLibrary_project/clinicalcode/views/Profile.py b/CodeListLibrary_project/clinicalcode/views/Profile.py
index f9218c34d..73bbfc763 100644
--- a/CodeListLibrary_project/clinicalcode/views/Profile.py
+++ b/CodeListLibrary_project/clinicalcode/views/Profile.py
@@ -3,16 +3,20 @@
from django.views.generic import TemplateView
from django.shortcuts import render
from django.conf import settings
-from django.db.models import Subquery, OuterRef
-from django.contrib.auth.models import User, Group
+from django.db.models import Q, Subquery, OuterRef
+from django.contrib.auth.models import Group
from django.contrib.auth.decorators import login_required
from django.utils.decorators import method_decorator
from django.http.response import JsonResponse
+from django.contrib.auth import get_user_model
from ..forms.ArchiveForm import ArchiveForm
from ..models.GenericEntity import GenericEntity
+from ..models.Organisation import Organisation
from ..entity_utils import permission_utils, model_utils, gen_utils
+User = get_user_model()
+
class MyProfile(TemplateView):
template_name = 'clinicalcode/profile/index.html'
@@ -36,21 +40,6 @@ class MyCollection(TemplateView):
'id', 'name', 'history_id', 'updated', 'owner_name',
'group_name', 'publish_status', 'is_deleted'
]
-
- def __annotate_fields(self, queryset):
- if not queryset:
- return list()
-
- annotated = queryset.annotate(
- group_name=Subquery(
- Group.objects.filter(id=OuterRef('group_id')).values('name')
- ),
- owner_name=Subquery(
- User.objects.filter(id=OuterRef('owner')).values('username')
- )
- )
-
- return list(annotated.values(*self.template_fields))
@method_decorator([login_required, permission_utils.redirect_readonly])
def dispatch(self, request, *args, **kwargs):
@@ -60,13 +49,8 @@ def get_context_data(self, *args, **kwargs):
context = super(MyCollection, self).get_context_data(*args, **kwargs)
request = self.request
- content = self.__annotate_fields(
- permission_utils.get_editable_entities(request)
- )
-
- archived_content = self.__annotate_fields(
- permission_utils.get_editable_entities(request, only_deleted=True)
- )
+ content = permission_utils.get_editable_entities(request)
+ archived_content = permission_utils.get_editable_entities(request, only_deleted=True)
form = None
if not settings.CLL_READ_ONLY:
@@ -106,7 +90,7 @@ def __try_restore_entity(self, request, entity_id):
if entity is None:
return JsonResponse({
'success': False,
- 'message': 'Phenotype ID is not valid',
+ 'message': 'Entity ID is not valid',
})
if not permission_utils.can_user_edit_entity(request, entity_id):
@@ -156,3 +140,40 @@ def __try_archive_entity(self, request):
return JsonResponse({
'success': True,
})
+
+class MyOrganisations(TemplateView):
+ template_name = 'clinicalcode/profile/my_organisations.html'
+
+ @method_decorator([login_required, permission_utils.redirect_readonly])
+ def dispatch(self, request, *args, **kwargs):
+ return super(MyOrganisations, self).dispatch(request, *args, **kwargs)
+
+ def get_context_data(self, *args, **kwargs):
+ context = super(MyOrganisations, self).get_context_data(*args, **kwargs)
+ request = self.request
+ user = request.user
+
+ current_brand = model_utils.try_get_brand(request)
+ is_brand_managed = current_brand.org_user_managed if current_brand else False
+
+ owned_orgs = Organisation.objects.filter(
+ owner_id=user.id
+ ) \
+ .values('id', 'name', 'slug')
+ owned_orgs = list(owned_orgs)
+
+ member_orgs = Organisation.objects.filter(
+ members__id__exact=user.id
+ ) \
+ .values('id', 'name', 'slug')
+ member_orgs = list(member_orgs)
+
+ return context | {
+ 'is_brand_managed': is_brand_managed,
+ 'owned_orgs': owned_orgs,
+ 'member_orgs': member_orgs
+ }
+
+ 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/clinicalcode/views/Publish.py b/CodeListLibrary_project/clinicalcode/views/Publish.py
index 1d9c35cd5..534938bba 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.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)
- 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,19 +145,26 @@ 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.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())
+ 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
data['entity_name_requested'] = GenericEntity.history.get(id=pk, history_id=history_id).name
- data = publish_utils.form_validation(request, data, history_id, pk, entity, conditions)
+ data = publish_utils.form_validation(request, data, history_id, pk, published_entity, conditions)
elif conditions['approval_status'] == constants.APPROVAL_STATUS.REJECTED:
#filter by declined ws
@@ -176,7 +190,7 @@ def moderator_publish(self,request,history_id,pk,conditions,data):
data['form_is_valid'] = True
data['entity_name_requested'] = GenericEntity.history.get(id=pk, history_id=history_id).name
#send message to the client
- data = publish_utils.form_validation(request, data, history_id, pk, entity, conditions)
+ data = publish_utils.form_validation(request, data, history_id, pk, published_entity, conditions)
else:
published_entity = PublishedGenericEntity(entity=entity,entity_history_id=history_id, moderator_id=request.user.id,
@@ -228,6 +242,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)
@@ -250,7 +265,7 @@ def post(self,request, pk, history_id):
data['form_is_valid'] = True
data['approval_status'] = constants.APPROVAL_STATUS.PENDING
data['entity_name_requested'] = GenericEntity.history.get(id=pk, history_id=history_id).name
- data = publish_utils.form_validation(self.request, data, history_id, pk, entity, checks)
+ data = publish_utils.form_validation(self.request, data, history_id, pk, published_entity, checks)
except Exception as e:
logger.warning('Failed POST with error: %s' % (str(e),))
diff --git a/CodeListLibrary_project/clinicalcode/views/View.py b/CodeListLibrary_project/clinicalcode/views/View.py
index 60a6a858b..993a27539 100644
--- a/CodeListLibrary_project/clinicalcode/views/View.py
+++ b/CodeListLibrary_project/clinicalcode/views/View.py
@@ -4,15 +4,17 @@
---------------------------------------------------------------------------
"""
from django.conf import settings
-from django.contrib import messages
-from django.core.exceptions import PermissionDenied
-from django.core.mail import BadHeaderError, EmailMultiAlternatives
from django.http import HttpResponse
-from django.http.response import Http404
+from django.contrib import messages
from django.shortcuts import render
+from django.core.mail import BadHeaderError, EmailMultiAlternatives
+from django.core.cache import cache
+from django.http.response import Http404, JsonResponse
+from django.core.exceptions import PermissionDenied
+from django.views.generic.base import TemplateView
-import logging
import sys
+import logging
import requests
from ..forms.ContactUsForm import ContactForm
@@ -22,11 +24,15 @@
from ..models.CodingSystem import CodingSystem
from ..models.DataSource import DataSource
from ..models.Statistics import Statistics
-from ..models.OntologyTag import OntologyTag
+from ..models.Template import Template
+
+from ..entity_utils import (
+ gen_utils, template_utils, constants,
+ model_utils, sanitise_utils, create_utils
+)
-from ..entity_utils import gen_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__)
@@ -34,18 +40,33 @@
# --------------------------------------------------------------------------
# Brand / Homepages incl. about
# --------------------------------------------------------------------------
-def get_brand_index_stats(request, brand):
- if Statistics.objects.all().filter(org__iexact=brand, type__iexact='landing-page').exists():
- stat = Statistics.objects.filter(org__iexact=brand, type__iexact='landing-page')
- if stat.exists():
- stat = stat.order_by('-modified').first()
- stats = stat.stat if stat else None
- else:
- from ..entity_utils.stats_utils import save_homepage_stats
- # update stat
- stat_obj = save_homepage_stats(request, brand)
- stats = stat_obj[0]
- return stats
+def get_brand_index_stats(request, brand_name='ALL'):
+ """
+ Attempts to resolve the index page statistics for the given Brand; defaults to `ALL`
+
+ Args:
+ request (RequestContext): the HTTP request context
+ brand_name (str): the name of the brand to query
+
+ Returns:
+ A (dict) containing the statistics for the specified brand
+ """
+ cache_key = f'idx_stats__{brand_name}__cache'
+
+ brand_stats = cache.get(cache_key)
+ if brand_stats is None:
+ brand_stats = Statistics.objects.filter(org__iexact=brand_name, type__iexact='landing-page')
+ if brand_stats.exists():
+ brand_stats = brand_stats.order_by('-modified').first().stat
+
+ if not isinstance(brand_stats, dict):
+ from ..entity_utils.stats_utils import save_homepage_stats
+ brand_stats = save_homepage_stats(request, brand_name)
+ brand_stats = brand_stats[0]
+
+ cache.set(cache_key, brand_stats, 3600)
+
+ return brand_stats
def index(request):
@@ -54,14 +75,13 @@ def index(request):
Assigns brand defined in the Django Admin Portal under "index_path".
If brand is not available it will rely on the default index path.
"""
+ brand = request.BRAND_OBJECT
index_path = settings.INDEX_PATH
- brand = Brand.objects.filter(name__iexact=settings.CURRENT_BRAND)
# if the index_ function doesn't exist for the current brand force render of the default index_path
try:
- if not brand.exists():
+ if not brand or not isinstance(brand, Brand):
return index_home(request, index_path)
- brand = brand.first()
return getattr(sys.modules[__name__], 'index_%s' % brand)(request, brand.index_path)
except:
return index_home(request, index_path)
@@ -69,10 +89,21 @@ def index(request):
def index_home(request, index_path):
stats = get_brand_index_stats(request, 'ALL')
- brands = Brand.objects.all().values('name', 'description')
+ brand = model_utils.try_get_brand(request)
+
+ cache_key = f'idx_brand__{brand.name}__cache' if brand is not None else 'idx_brand__cache'
+ brand_descriptors = cache.get(cache_key)
+ if brand_descriptors is None:
+ brand_descriptors = list(Brand.all_instances().values('id', 'name', 'description', 'website'))
+ if brand is not None:
+ index = [ x.get('id') for x in brand_descriptors ].index(brand.id)
+ first = brand_descriptors.pop(index)
+ brand_descriptors.insert(0, first)
+
+ cache.set(cache_key, brand_descriptors, 3600)
return render(request, index_path, {
- 'known_brands': brands,
+ 'known_brands': brand_descriptors,
'published_concept_count': stats.get('published_concept_count'),
'published_phenotype_count': stats.get('published_phenotype_count'),
'published_clinical_codes': stats.get('published_clinical_codes'),
@@ -125,10 +156,8 @@ def brand_about_index_return(request, pg_name):
Returns:
HttpResponse: The rendered template response.
"""
- brand = Brand.objects.filter(name__iexact=settings.CURRENT_BRAND)
-
+ brand = request.BRAND_OBJECT
try:
- brand = brand.first()
# Retrieve the 'about_menu' JSON from Django
about_pages_dj_data = brand.about_menu
@@ -236,11 +265,13 @@ def contact_us(request):
from_email = form.cleaned_data['from_email']
message = form.cleaned_data['message']
category = form.cleaned_data['categories']
- email_subject = 'Concept Library - New Message From %s' % name
+
+ brand_title = model_utils.try_get_brand_string(request.BRAND_OBJECT, 'site_title', default='Concept Library')
+ email_subject = '%s - New Message From %s' % (brand_title, name)
try:
html_content = \
- 'New Message from Concept Library Website ' \
+ 'New Message from {site} Website ' \
'Name: ' \
'{name}' \
' ' \
@@ -252,7 +283,11 @@ def contact_us(request):
' ' \
' Tell us about your Enquiry: ' \
'{message}'.format(
- name=name, from_email=from_email, category=category, message=message
+ site=brand_title,
+ name=name,
+ from_email=from_email,
+ category=category,
+ message=sanitise_utils.sanitise_value(message, default='[Sanitisation failure]')
)
if not settings.IS_DEVELOPMENT_PC or settings.HAS_MAILHOG_SERVICE:
@@ -308,24 +343,151 @@ def check_recaptcha(request):
return False
+class ReferenceData(TemplateView):
+ EXCLUDED_FIELDS = ['ontology', 'data_sources', 'brands']
+
+ template_name = 'clinicalcode/about/reference_data.html'
+
+ def get_sourced_values(self, request, template, field):
+ field_info = template_utils.get_template_field_info(template, field)
+ template_field = field_info.get('field')
+ if not template_field:
+ return None
+
+ is_metadata = field_info.get('is_metadata')
+ will_hydrate = template_field.get('hydrated', False)
+
+ options = None
+ if not will_hydrate:
+ if is_metadata:
+ options = template_utils.get_template_sourced_values(
+ constants.metadata, field, struct=template_field
+ )
+ if options is None:
+ options = self.try_get_computed(
+ field,
+ struct=template_field
+ )
+ else:
+ options = template_utils.get_template_sourced_values(
+ template, field, struct=template_field
+ )
+ return options
+
+ def get_template_data(self, request, template_id, default=None):
+ template = model_utils.try_get_instance(Template, pk=template_id)
+ if template is None:
+ return default
+
+ template_fields = template_utils.try_get_content(
+ template_utils.get_merged_definition(template, default={}),
+ 'fields'
+ )
+ if template_fields is None:
+ return default
+
+ result = []
+ for field, definition in template_fields.items():
+ if field in self.EXCLUDED_FIELDS:
+ continue
+
+ is_active = template_utils.try_get_content(definition, 'active')
+ if not is_active:
+ continue
+
+ validation = template_utils.try_get_content(definition, 'validation')
+ if validation is None:
+ continue
+
+ field_type = template_utils.try_get_content(validation, 'type')
+ if field_type is None:
+ continue
+
+ formatted_field = {
+ 'name': template_utils.try_get_content(definition, 'title'),
+ 'description': template_utils.try_get_content(definition, 'description')
+ }
+
+ is_option = template_utils.try_get_content(validation, 'options')
+ if is_option is not None:
+ if field_type not in ['enum', 'int_array']:
+ continue
+
+ field_values = template_utils.get_template_sourced_values(
+ template, field
+ )
+ if field_values is not None:
+ formatted_field |= {
+ 'options': field_values
+ }
+ result.append(formatted_field)
+
+ return result
+
+ def get_templates(self, request):
+ templates = create_utils.get_createable_entities(request)
+
+ result = {}
+ for template in templates.get('templates'):
+ template_id = template.get('id')
+ template_name = template.get('name')
+ template_description = template.get('description')
+
+ result[template_name] = {
+ 'id': template_id,
+ 'description': template_description
+ }
+
+ return result
+
+ def get_context_data(self, *args, **kwargs):
+ context = super(ReferenceData, self).get_context_data(*args, **kwargs)
+ request = self.request
+
+ templates = self.get_templates(request)
+
+ data = None
+ if templates is not None:
+ default_template = next(iter(templates.values()))
+ data = self.get_template_data(
+ request, default_template.get('id')
+ )
+
+ return context | {
+ 'ontology_groups': [x.value for x in ONTOLOGY_TYPES],
+ 'templates': templates,
+ 'default_data': data
+ }
-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')
-
- 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),
- 'ontology_groups': [x.value for x in ONTOLOGY_TYPES]
- }
-
- return render(request, 'clinicalcode/about/reference_data.html', context)
+ def get(self, request, *args, **kwargs):
+ context = self.get_context_data(*args, **kwargs)
+ return render(request, self.template_name, context)
+
+ def options(self, request, *args, **kwargs):
+ body = gen_utils.get_request_body(request)
+ if not isinstance(body, dict):
+ return gen_utils.jsonify_response(
+ code=400,
+ message='Invalid, no body included with request'
+ )
+
+ template_id = gen_utils.try_value_as_type(
+ body.get('template_id', None), 'int', default=None
+ )
+
+ if template_id is None:
+ return gen_utils.jsonify_response(
+ code=400,
+ message='Invalid, expected integer-like `template_id` property'
+ )
+
+ template_data = self.get_template_data(request, template_id)
+ if template_data is None:
+ return gen_utils.jsonify_response(
+ code=404,
+ message='Failed to find template associated with the given `template_id` of `%d`' % template_id
+ )
+
+ return JsonResponse({
+ 'data': template_data
+ })
diff --git a/CodeListLibrary_project/clinicalcode/views/adminTemp.py b/CodeListLibrary_project/clinicalcode/views/adminTemp.py
index bce9a64d1..cc52478b3 100644
--- a/CodeListLibrary_project/clinicalcode/views/adminTemp.py
+++ b/CodeListLibrary_project/clinicalcode/views/adminTemp.py
@@ -1,32 +1,68 @@
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 difflib import SequenceMatcher as SM
+from functools import partial
+from django.db import transaction, 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 User, 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.Organisation import Organisation, OrganisationMembership
+
+from clinicalcode.models.HDRNSite import HDRNSite
+from clinicalcode.models.HDRNJurisdiction import HDRNJurisdiction
+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 +108,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 +759,298 @@ 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,
+ }
+ )
+
+ overrides = {
+ # Dashboard statistics retrieval
+ "stats_context": "^/HDRN.*$",
+ # Dashboard renderable / manageable assets
+ "asset_rules": {
+ # Global assets
+ "template": {"model": "Template", "target": "TemplateTarget", "endpoint": "TemplateEndpoint"},
+ "tags": {"model": "Tag", "target": "TagTarget", "subtype": "all", "endpoint": "TagEndpoint"},
+ # HDRN specific assets
+ "sites": {"model": "HDRNSite", "target": "HDRNSiteTarget", "endpoint": "HDRNSiteEndpoint"},
+ "category": {"model": "HDRNDataCategory", "target": "HDRNDataCategoryTarget", "endpoint": "HDRNDataCategoryEndpoint"},
+ "data_assets": {"model": "HDRNDataAsset", "target": "HDRNDataAssetTarget", "endpoint": "HDRNDataAssetEndpoint"},
+ "jurisdictions": {"model": "HDRNJurisdiction", "target": "HDRNJurisdictionTarget", "endpoint": "HDRNJurisdictionEndpoint"}
+ },
+ # Controls how Brand ctx is computed for entities on creation
+ "ignore_collection_ctx": True,
+ # Controls the visibility of Templates and related content (e.g. Collections, Tags, etc)
+ "content_visibility": None,
+ # Controls entity name mapping
+ "content_mapping": {
+ "phenotype": "Concept",
+ "phenotype_url": "concepts",
+ "concept": "Codelist",
+ "concept_url": "codelists",
+ },
+ }
+
+ brand_data = {
+ 'name': 'HDRN',
+ 'site_title': 'Concept Dictionary',
+ 'description': (
+ 'Health Data Research Network Canada (HDRN Canada) is a pan-Canadian network of member '
+ 'organizations that either hold linkable health and health-related data for entire populations '
+ 'and/or have mandates and roles relating directly to access or use of those data. '
+ ),
+ 'website': 'https://www.hdrn.ca',
+ 'logo_path': 'img/brands/HDRN/',
+ 'index_path': 'clinicalcode/index.html',
+ 'footer_images': [
+ {"url": "https://www.hdrn.ca", "brand": "HDRN",
+ "image_src": "img/Footer_logos/HDRN_logo.png"},
+ {"url": "https://conceptlibrary.saildatabank.com/", "brand": "Concept Library",
+ "image_src": "img/Footer_logos/concept_library_on_white.png"},
+ {"url": "http://saildatabank.com", "brand": "SAIL Databank ",
+ "image_src": "img/Footer_logos/SAIL_alt_logo_on_white.png"}
+ ],
+ 'overrides': overrides,
+ 'org_user_managed': True,
+ 'is_administrable': True,
+ }
+
+ with transaction.atomic():
+ brand = Brand.objects.filter(name__iexact='HDRN')
+ if brand.exists():
+ brand = brand.first()
+ brand.__dict__.update(**brand_data)
+ brand.save()
+ else:
+ brand = Brand.objects.create(**brand_data)
+
+ 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.get('name'), abbreviation=v.get('abbreviation'))
+ for v in data
+ ])
+ case 'jurisdictions':
+ '''HDRNJurisdiction'''
+ result = HDRNJurisdiction.objects.bulk_create([
+ HDRNJurisdiction(name=v.get('name'), abbreviation=v.get('abbreviation'))
+ for v in data
+ ])
+ case 'categories':
+ '''HDRNDataCategory'''
+ result = HDRNDataCategory.objects.bulk_create([HDRNDataCategory(name=v) for v in data])
+ case 'tags':
+ '''Tag (tag_type=1)'''
+ result = Tag.objects.bulk_create([Tag(description=v, tag_type=1, collection_brand=brand) for v in data])
+ case 'collections':
+ '''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 })
+
+ now = make_aware(datetime.now())
+ assets = input_data.get('assets')
+ to_create = []
+ to_regions = []
+
+ for data in assets:
+ site = data.get('site')
+ cats = data.get('data_categories')
+ regions = data.get('region')
+
+ if isinstance(cats, list):
+ cats = [models.get('tags')[v - 1].id for v in cats if models.get('tags')[v - 1] is not None]
+
+ if isinstance(regions, str):
+ rlkup = models.get('jurisdictions')
+
+ reg = []
+ regions = regions.split(',')
+ for region in regions:
+ match = [{ 'item': x, 'score': SM(None, region.strip(), x.name).ratio() } for x in rlkup]
+ match.sort(key=lambda x:x.get('score'), reverse=True)
+
+ match = match[0] if len(match) > 0 else None
+ if match and match.get('item') is not None:
+ reg.append(match.get('item'))
+
+ if len(reg) > 0:
+ regions = reg
+ else:
+ regions = None
+ to_regions.append(regions)
+
+ 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'),
+ 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) })
+
+ for i, asset in enumerate(models.get('assets')):
+ regions = to_regions[i]
+ if not isinstance(regions, list):
+ continue
+ asset.regions.set(regions)
+
+ 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 +1287,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()
@@ -1324,6 +1639,173 @@ def admin_fix_breathe_dt(request):
}
)
+@login_required
+def admin_convert_entity_groups(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_convert_entity_groups'),
+ 'action_title': 'Convert groups to organisations',
+ 'hide_phenotype_options': True,
+ }
+ )
+
+ # post
+ if request.method != 'POST':
+ raise BadRequest('Invalid')
+
+ GROUP_LOOKUP = {
+ 2: 32,
+ 6: 60,
+ 7: 9,
+ 12: 197,
+ 13: 240
+ }
+
+ entities = GenericEntity.objects \
+ .filter(group_id__isnull=False)
+
+ entity_groups = entities \
+ .order_by('group_id') \
+ .distinct('group_id') \
+ .values_list('group_id', flat=True)
+
+ groups = Group.objects \
+ .filter(id__in=list(entity_groups))
+
+ for entity_group in entity_groups:
+ owner_id = GROUP_LOOKUP.get(entity_group)
+ if not owner_id:
+ continue
+
+ current_owner = User.objects.filter(id=owner_id)
+ if not current_owner.exists():
+ continue
+ current_owner = current_owner.first()
+
+ current_group = Group.objects.filter(id=entity_group)
+ if not current_group.exists():
+ continue
+ current_group = current_group.first()
+
+ current_members = User.objects.filter(groups=current_group) \
+ .exclude(id=owner_id)
+
+ org = Organisation.objects.create(
+ id=entity_group,
+ name=current_group.name,
+ owner=current_owner
+ )
+ for current_member in current_members:
+ member = OrganisationMembership.objects.create(
+ user_id=current_member.id,
+ organisation_id=org.id
+ )
+
+ current_entities = entities.filter(group_id=entity_group)
+ for entity in current_entities:
+ entity.organisation = org
+ entity.save()
+
+ return render(
+ request,
+ 'clinicalcode/adminTemp/admin_temp_tool.html',
+ {
+ 'pk': -10,
+ 'rowsAffected' : { '1': 'ALL'},
+ 'action_title': 'Convert groups to organisations',
+ 'hide_phenotype_options': True,
+ }
+ )
+
+@login_required
+def admin_fix_icd_ca_cm_codes(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_fix_icd_ca_cm_codes'),
+ 'action_title': 'Fix ICD-10 CA/CM codes',
+ 'hide_phenotype_options': True,
+ }
+ )
+
+ # post
+ if request.method != 'POST':
+ raise BadRequest('Invalid')
+
+ with connection.cursor() as cursor:
+ cursor.execute('''
+ insert into public.clinicalcode_icd10cm_codes
+ select (select max("id") from public.clinicalcode_icd10cm_codes) + row_number() over (order by code) as id,
+ code, description,
+ CURRENT_TIMESTAMP as created_date
+ from (
+ select replace(src.code, '.', '') as code,
+ src.description
+ from public.clinicalcode_icd10_codes_and_titles_and_metadata as src
+ left join public.clinicalcode_icd10cm_codes as trg
+ on lower(replace(src.code, '.', '')) = lower(replace(trg.code, '.', ''))
+ where length(replace(src.code, '.', '')) = 3
+ and trg.code is null
+ group by src.code, src.description
+ )
+ order by code;
+ ''')
+
+ cursor.execute('''
+ insert into public.clinicalcode_icd10ca_codes
+ select (select max("id") from public.clinicalcode_icd10ca_codes) + row_number() over (order by code) as id,
+ code, description, long_desc,
+ CURRENT_TIMESTAMP as created_date
+ from (
+ select replace(src.code, '.', '') as code,
+ left(src.description, 128) as description,
+ src.description as long_desc
+ from public.clinicalcode_icd10_codes_and_titles_and_metadata as src
+ left join public.clinicalcode_icd10ca_codes as trg
+ on lower(replace(src.code, '.', '')) = lower(replace(trg.code, '.', ''))
+ where length(replace(src.code, '.', '')) = 3
+ and trg.code is null
+ group by src.code, left(src.description, 128), src.description
+ )
+ order by code;
+ ''')
+
+ return render(
+ request,
+ 'clinicalcode/adminTemp/admin_temp_tool.html',
+ {
+ 'pk': -10,
+ 'rowsAffected' : { '1': 'ALL'},
+ 'action_title': 'Fix ICD-10 CA/CM codes',
+ 'hide_phenotype_options': True,
+ }
+ )
+
def get_serial_id():
count_all = GenericEntity.objects.count()
if count_all:
diff --git a/CodeListLibrary_project/clinicalcode/views/dashboard/BrandAdmin.py b/CodeListLibrary_project/clinicalcode/views/dashboard/BrandAdmin.py
new file mode 100644
index 000000000..5eef479b8
--- /dev/null
+++ b/CodeListLibrary_project/clinicalcode/views/dashboard/BrandAdmin.py
@@ -0,0 +1,416 @@
+"""Brand Administration View(s) & Request Handling."""
+from datetime import datetime
+from django.db import connection
+from django.apps import apps
+from django.conf import settings
+from django.shortcuts import render
+from django.core.cache import cache
+from rest_framework.views import APIView
+from django.views.generic import TemplateView
+from rest_framework.response import Response
+from django.utils.decorators import method_decorator
+from rest_framework.decorators import schema
+from django.contrib.staticfiles import finders
+from django.contrib.staticfiles.storage import staticfiles_storage
+
+import os
+import math
+import logging
+import psycopg2
+
+from clinicalcode.views import View
+from clinicalcode.models import Brand
+from clinicalcode.entity_utils import gen_utils, model_utils, permission_utils
+
+
+logger = logging.getLogger(__name__)
+
+
+def get_or_resolve_assets(brand=None, cache_timeout=3600):
+ """
+ Attempts to resolve the known quick access assets available for the specified Brand, either by retrieving it from the cache store or, if not found, by computing it.
+
+ Args:
+ brand (:model:`Brand`|None): the Request-specified Brand context
+ cache_timeout (int|None): optionally specify the cache timeout; defaults to 3600
+
+ Returns:
+ A (dict) describing the quick access items
+ """
+ cache_key = brand.name if isinstance(brand, Brand) else 'all'
+ cache_key = f'dashboard-asset-summary__{cache_key}__cache'
+
+ if not isinstance(cache_timeout, int) or not math.isfinite(cache_timeout) or math.isnan(cache_timeout):
+ cache_timeout = 0
+ else:
+ cache_timeout = max(cache_timeout, 0)
+
+ assets = cache.get(cache_key)
+ if assets is not None:
+ return assets.get('value')
+
+ if brand is not None:
+ assets = brand.get_asset_rules(default={})
+ else:
+ assets = {}
+
+ for key, asset in assets.items():
+ model = asset.get('model')
+ if model is None:
+ assets.pop(key, None)
+ continue
+
+ details = None
+ try:
+ model = apps.get_model(app_label='clinicalcode', model_name=model)
+ if hasattr(model, 'get_verbose_names'):
+ details = model.get_verbose_names(subtype=asset.get('subtype', key))
+ elif hasattr(model, '_meta') and getattr(model, '_meta', None) is not None:
+ meta = getattr(model, '_meta')
+ verbose_name = meta.verbose_name if hasattr(meta, 'verbose_name') else None
+ verbose_name_plural = meta.verbose_name_plural if hasattr(meta, 'verbose_name_plural') else None
+ details = {
+ 'verbose_name': verbose_name if isinstance(verbose_name, str) else model.__name__,
+ 'verbose_name_plural': verbose_name_plural if isinstance(verbose_name_plural, str) else (f'{model.__name__}s'),
+ }
+ else:
+ details = {
+ 'verbose_name': model.__name__,
+ 'verbose_name_plural': f'{model.__name__}s',
+ }
+ except:
+ assets.pop(key, None)
+ continue
+ else:
+ if details is not None:
+ asset.update({ 'details': details })
+
+ if cache_timeout > 0:
+ cache.set(cache_key, { 'value': assets }, cache_timeout)
+ return assets
+
+
+class BrandDashboardView(TemplateView):
+ """
+ Dashboard View for Brand Administration.
+
+ :template:`clinicalcode/dashboard/index.html`
+ """
+ reverse_name = 'brand_dashboard'
+ template_name = 'clinicalcode/dashboard/index.html'
+ preferred_logos = ['logo-transparent.png', 'apple-touch-icon.png']
+
+ def __get_admin_logo_target(self, brand=None):
+ """
+ Resolves the best Brand-related logo to use for the Dashboard view
+
+ Args:
+ brand (:model:`Brand`|Dict[str, Any]): optionally specify the Brand from which to resolve the logo; defaults to `None`
+
+ Returns:
+ A (str) describing the static storage relative path to the image file
+ """
+ if isinstance(brand, dict):
+ path = brand.get('logo_path', None)
+ elif isinstance(brand, Brand):
+ path = getattr(brand, 'logo_path', None) if hasattr(brand, 'logo_path') else None
+ else:
+ path = None
+
+ if not isinstance(path, str) or gen_utils.is_empty_string(path):
+ path = settings.APP_LOGO_PATH
+
+ for fname in self.preferred_logos:
+ trg_path = os.path.join(path, fname)
+ abs_path = staticfiles_storage.path(trg_path)
+ if abs_path is not None and staticfiles_storage.exists(abs_path):
+ return trg_path
+
+ abs_path = finders.find(trg_path)
+ if abs_path is not None and os.path.isfile(abs_path):
+ return trg_path
+ return None
+
+ @method_decorator([permission_utils.redirect_readonly, permission_utils.brand_admin_required])
+ def dispatch(self, request, *args, **kwargs):
+ """
+ Request-Response Middleman.
+
+ .. Note::
+ Decporated such that it only dispatches when:
+ - The app isn't in a read-only state;
+ - The Brand context is administrable;
+ - And either (a) the user is a superuser, or (b) the user is authenticated & is a brand administrator of the current Brand.
+ """
+ return super().dispatch(request, *args, **kwargs)
+
+ def get_context_data(self, *args, **kwargs):
+ """
+ Resolves the View context data.
+
+ Args:
+ *args: Variable length argument list.
+ **kwargs: Arbitrary keyword arguments.
+
+ Kwargs:
+ brand (:model:`Brand`): the current HttpRequest's :model:`Brand` context
+
+ Returns:
+ The resulting Template context (`Dict[str, Any]` _OR_ :py:class:`Context`)
+ """
+ brand = kwargs.get('brand')
+ context = super().get_context_data(*args, **kwargs)
+ return context | {
+ 'brand': brand,
+ 'logo_path': self.__get_admin_logo_target(brand),
+ }
+
+ def get(self, request, *args, **kwargs):
+ """
+ Display a :model:`clinicalcode.Brand` administration dashboard.
+
+ .. Context::
+
+ ``logo_path``
+ A (str) specifying the static path to the branded logo
+
+ ``brand``
+ A (Brand|None) specifying the current Brand instance
+
+ .. Template::
+
+ :template:`clinicalcode/dashboard/index.html`
+
+ .. Reverse::
+ `brand_dashboard`
+ """
+ brand = model_utils.try_get_brand(request)
+ if brand is None:
+ return View.notify_err(
+ request,
+ title='Bad Request - Invalid Brand',
+ status_code=400,
+ details=['You cannot administrate a Brand on an unknown domain.']
+ )
+
+ context = self.get_context_data(*args, **kwargs, brand=brand)
+ return render(request, self.template_name, context)
+
+
+@schema(None)
+class BrandInventoryView(APIView):
+ """Brand Inventory APIView for the Brand Dashboard, used to retrieve administrable Data Models"""
+ reverse_name = 'brand_dashboard_inventory'
+ permission_classes = [permission_utils.IsReadOnlyRequest & permission_utils.IsNotGateway & permission_utils.IsBrandAdmin]
+
+ # Statistics summary cache duration (in seconds)
+ CACHE_TIMEOUT = 3600
+
+ def get(self, request):
+ """GET request handler for BrandInventoryView"""
+ brand = model_utils.try_get_brand(request)
+
+ return Response({
+ 'assets': get_or_resolve_assets(brand),
+ })
+
+
+@schema(None)
+class BrandOverviewView(APIView):
+ """Dashboard Landing Overview APIView, used to retrieve the interaction & accessible data for the Brand Dashboard"""
+ # View behaviour
+ reverse_name = 'brand_dashboard_overview'
+ permission_classes = [permission_utils.IsReadOnlyRequest & permission_utils.IsNotGateway & permission_utils.IsBrandAdmin]
+
+ # Statistics summary cache duration (in seconds)
+ CACHE_TIMEOUT = 3600
+
+ def get(self, request):
+ """GET request handler for BrandOverviewView"""
+ brand = model_utils.try_get_brand(request)
+ summary = self.__get_or_compute_summary(brand)
+
+ return Response({
+ 'assets': get_or_resolve_assets(brand),
+ 'summary': summary,
+ })
+
+ def __get_or_compute_summary(self, brand=None):
+ """
+ Attempts to resolve the statistics summary for the specified Brand, either by retrieving it from the cache store or, if not found, by computing it.
+
+ Args:
+ brand (:model:`Brand`|None): the Request-specified Brand context
+
+ Returns:
+ A (dict) describing the statistics/analytics summary
+ """
+ cache_key = brand.name if isinstance(brand, Brand) else 'all'
+ cache_key = f'dashboard-stat-summary__{cache_key}__cache'
+
+ summary = cache.get(cache_key)
+ if summary is not None:
+ return summary.get('value')
+
+ summary = self.__compute_summary(brand)
+ cache.set(cache_key, { 'value': summary }, self.CACHE_TIMEOUT)
+ return summary
+
+ def __compute_summary(self, brand):
+ """
+ Attempts to compute the statistics summary for the specified Brand
+
+ .. Note::
+ Computes the following:
+ - No. of Phenotypes created in the last 7 days
+ - No. of Phenotypes edited in the last 7 days
+ - No. of Phenotypes published in the last 7 days
+ - No. of unique Daily Active Users today
+ - No. of unique Monthly Active Users for this month
+ - No. of page hits over the last 7 days
+
+ These can be modified by overrides defined for each brand, _e.g._ ...
+ - HDRN: `{"stats_context": "^/HDRN.*$"}`
+ - HDRUK: `{"stats_context": "^(?!/HDRN)", "content_visibility": {"allow_null": true, "allowed_brands": [1, 2, 3]}}`
+
+ Args:
+ brand (:model:`Brand`|None): the Request-specified Brand context
+
+ Returns:
+ A (dict) describing the statistics/analytics summary
+ """
+ query_params = {}
+ stats_context = None
+ content_visibility = None
+
+ if brand:
+ content_visibility = brand.get_vis_rules()
+
+ overrides = getattr(brand, 'overrides', None)
+ stats_context = overrides.get('stats_context') if isinstance(overrides, dict) else None
+ if not isinstance(stats_context, str) or gen_utils.is_empty_string(stats_context):
+ stats_context = '^\/%s.*$' % brand.name
+ query_params.update({ 'stats_ctx': stats_context })
+
+ with connection.cursor() as cursor:
+ if isinstance(content_visibility, dict):
+ allowed_brands = content_visibility.get('ids')
+ allowed_brands = allowed_brands if isinstance(allowed_brands, list) and len(allowed_brands) > 0 else None
+ allowed_null_brand = content_visibility.get('allow_null')
+ query_params.update({ 'brand_ids': allowed_brands if allowed_brands is not None else [brand.id] })
+
+ if allowed_brands and allowed_null_brand:
+ content_visibility = psycopg2.sql.SQL('''and (ge.brands is null or array_length(ge.brands, 1) < 1 or ge.brands && %(brand_ids)s::int[])''')
+ elif allowed_brands:
+ content_visibility = psycopg2.sql.SQL('''and (ge.brands is not null and ge.brands && %(brand_ids)s::int[])''')
+ elif allowed_null_brand:
+ content_visibility = psycopg2.sql.SQL('''and (ge.brands is null or array_length(ge.brands, 1) < 1)''')
+ elif brand:
+ query_params.update({ 'brand_ids': [brand.id] })
+ content_visibility = psycopg2.sql.SQL('''and (ge.brands is not null and ge.brands && %(brand_ids)s::int[])''')
+ else:
+ content_visibility = psycopg2.sql.SQL('')
+
+ if stats_context is not None:
+ stats_context = psycopg2.sql.SQL('''and (req.url ~ %(stats_ctx)s)''')
+ else:
+ stats_context = psycopg2.sql.SQL('')
+
+ sql = psycopg2.sql.Composed([
+ # Visible phenotypes
+ psycopg2.sql.SQL('''
+ with
+ pheno_vis as (
+ select ge.*
+ from public.clinicalcode_genericentity as ge
+ where (ge.is_deleted is null or ge.is_deleted = false)
+ '''),
+ content_visibility,
+ psycopg2.sql.SQL('''
+ ),
+ '''),
+ # Visible requests
+ psycopg2.sql.SQL('''
+ request_vis as (
+ select req.*,
+ (case
+ when req.user_id is not null then req.user_id::text
+ else null
+ end) as uid
+ from public.easyaudit_requestevent as req
+ where req.method in ('GET', 'POST', 'PUT')
+ '''),
+ stats_context,
+ psycopg2.sql.SQL('''
+ ),
+ '''),
+ # Count created
+ psycopg2.sql.SQL('''
+ pheno_created as (
+ select count(ge.*) as cnt
+ from pheno_vis as ge
+ where ge.created >= date_trunc('day', now()) - interval '7 day'
+ ),
+ '''),
+ # Count edits
+ psycopg2.sql.SQL('''
+ pheno_edited as (
+ select count(hge.*) as cnt
+ from pheno_vis as ge
+ join public.clinicalcode_historicalgenericentity as hge
+ using (id)
+ where hge.history_date >= date_trunc('day', now()) - interval '7 day'
+ ),
+ '''),
+ # Count publications
+ psycopg2.sql.SQL('''
+ pheno_pubs as (
+ select count(pge.*) as cnt
+ from pheno_vis as ge
+ join public.clinicalcode_publishedgenericentity as pge
+ on ge.id = pge.entity_id
+ where pge.approval_status = 2
+ and pge.modified >= date_trunc('day', now()) - interval '7 day'
+ ),
+ '''),
+ # Count DAU
+ psycopg2.sql.SQL('''
+ unq_dau as (
+ select count(distinct coalesce(req.uid, req.remote_ip, '')) as cnt
+ from request_vis as req
+ where req.datetime >= date_trunc('day', now())
+ ),
+ '''),
+ # Count MAU
+ psycopg2.sql.SQL('''
+ unq_mau as (
+ select count(distinct coalesce(req.uid, req.remote_ip, '')) as cnt
+ from request_vis as req
+ where req.datetime >= date_trunc('month', now())
+ ),
+ '''),
+ # Count page hits last 7 day
+ psycopg2.sql.SQL('''
+ page_hits as (
+ select count(*) as cnt
+ from request_vis as req
+ where req.datetime >= date_trunc('day', now()) - interval '7 day'
+ )
+ '''),
+ # Collect summary
+ psycopg2.sql.SQL('''
+ select
+ (select cnt from pheno_created) as created,
+ (select cnt from pheno_edited) as edited,
+ (select cnt from pheno_pubs) as published,
+ (select cnt from unq_dau) as dau,
+ (select cnt from unq_mau) as mau,
+ (select cnt from page_hits) as hits
+ ''')
+ ])
+
+ cursor.execute(sql, params=query_params)
+ columns = [col[0] for col in cursor.description]
+
+ result = { 'data': dict(zip(columns, row)) for row in cursor.fetchall() }
+ return result | { 'timestamp': datetime.now().isoformat() }
diff --git a/CodeListLibrary_project/clinicalcode/views/dashboard/__init__.py b/CodeListLibrary_project/clinicalcode/views/dashboard/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/CodeListLibrary_project/clinicalcode/views/dashboard/targets/BaseTarget.py b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/BaseTarget.py
new file mode 100644
index 000000000..970669051
--- /dev/null
+++ b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/BaseTarget.py
@@ -0,0 +1,296 @@
+"""Brand Dashboard: Base extensible/abstract classes"""
+from django.http import HttpRequest
+from rest_framework import status, generics, mixins, serializers, exceptions, fields
+from django.db.models import Model
+from rest_framework.request import Request
+from rest_framework.response import Response
+from django.utils.functional import classproperty
+
+import inspect
+import builtins
+
+from clinicalcode.entity_utils import permission_utils, model_utils, gen_utils
+
+
+"""Default Model `pk` field filter, _e.g._ the `ID` integer primary key"""
+DEFAULT_LOOKUP_FIELD = 'pk'
+
+
+class BaseSerializer(serializers.ModelSerializer):
+ """Extensible serializer class for Target(s)"""
+
+ # Getters
+ @property
+ def form_fields(self):
+ instance = getattr(self, 'instance') if hasattr(self, 'instance') else None
+
+ form = {}
+ for name, field in self.fields.items():
+ group = None
+ for k, v in vars(field).items():
+ if k.startswith('_'):
+ continue
+
+ value = v
+ if v == fields.empty or isinstance(v, fields.empty):
+ value = None
+ elif v is not None and type(v).__name__ != '__proxy__' and (type(v).__name__ == 'type' or ((hasattr(v, '__name__') or hasattr(v, '__class__') or inspect.isclass(v)) and not type(v).__name__ in dir(builtins))):
+ value = type(v).__name__
+
+ if group is None:
+ group = {}
+ group.update({ k: value })
+
+ trg = field
+ if isinstance(trg, serializers.ListSerializer):
+ trg = trg.child
+
+ disp = getattr(trg, '_str_display') if hasattr(trg, '_str_display') else None
+ if isinstance(disp, str) and not gen_utils.is_empty_string(disp):
+ group.update({ 'str_display': disp })
+
+ if hasattr(trg, 'resolve_options') and callable(getattr(trg, 'resolve_options', None)):
+ group.update({ 'value_options': trg.resolve_options() })
+ elif hasattr(trg, 'get_choices') and callable(getattr(trg, 'get_choices', None)):
+ group.update({ 'value_options': trg.get_choices() })
+
+ if hasattr(trg, 'resolve_format') and callable(getattr(trg, 'resolve_format', None)):
+ group.update({ 'value_format': trg.resolve_format() })
+
+ if isinstance(field, (serializers.ListField, serializers.ListSerializer)):
+ group.update({ 'type': type(field.child).__qualname__, 'subtype': type(field).__qualname__ })
+ else:
+ group.update({ 'type': type(field).__qualname__, 'subtype': None })
+
+ form.update({ name: group })
+ return form
+
+ # Private utility methods
+ def _get_user(self):
+ request = self.context.get('request')
+ if request and hasattr(request, 'user'):
+ return request.user
+ return None
+
+ def _get_brand(self):
+ request = self.context.get('request')
+ if request:
+ return model_utils.try_get_brand(request)
+ return None
+
+ @staticmethod
+ def _update(instance, validated_data):
+ """
+ Dynamically updates a model instance, setting Many-to-Many fields first if present.
+ @param instance:
+ @param validated_data:
+ @return:
+ """
+
+ # Handle ManyToMany fields
+ # Loop through fields and set values dynamically
+ for field in instance._meta.get_fields(include_parents=True):
+ field_name = field.name
+
+ # Skip fields not in validated_data
+ if field_name not in validated_data:
+ continue
+
+ value = validated_data.pop(field_name)
+
+ # Handle ManyToMany relationships dynamically
+ if field.many_to_many:
+ getattr(instance, field_name).set(value)
+ else:
+ setattr(instance, field_name, value)
+ return instance
+
+ @staticmethod
+ def _create(model_class, validated_data):
+ """
+ Dynamically create a new model instance, setting Many-to-Many fields first if present.
+
+ Args:
+ model_class (Model): The Django model class to create an instance for.
+ validated_data (dict): The validated data for creation.
+
+ Returns:
+ Model: The newly created instance.
+ """
+ # Step 1: Extract Many-to-Many fields
+ m2m_data = {
+ field.name: validated_data.pop(field.name)
+ for field in model_class._meta.get_fields(include_parents=True)
+ if field.many_to_many and field.name in validated_data
+ }
+
+ # Step 2: Create the instance with remaining fields
+ instance = model_class.objects.create(**validated_data)
+
+ # Step 3: Set Many-to-Many fields
+ for field_name, value in m2m_data.items():
+ getattr(instance, field_name).set(value)
+
+ return instance
+
+
+class BaseEndpoint(
+ generics.GenericAPIView,
+ mixins.RetrieveModelMixin,
+ mixins.UpdateModelMixin,
+ mixins.ListModelMixin,
+ mixins.CreateModelMixin
+):
+ """Extensible endpoint class for TargetEndpoint(s)"""
+
+ # QuerySet kwargs
+ filter = None
+
+ # View behaviour
+ permission_classes = [permission_utils.IsBrandAdmin & permission_utils.IsNotGateway]
+
+ # Exclude endpoint(s) from swagger
+ swagger_schema = None
+
+ # Properties
+ @classproperty
+ def lookup_field(cls):
+ if hasattr(cls, '_lookup_field'):
+ lookup = getattr(cls, '_lookup_field')
+ if isinstance(lookup, str) and not gen_utils.is_empty_string(lookup):
+ return lookup
+ elif hasattr(cls, 'model'):
+ model = getattr(cls, 'model', None)
+ if inspect.isclass(model) or not issubclass(model, Model):
+ return model._meta.pk.name
+ return DEFAULT_LOOKUP_FIELD
+
+ # Mixin views
+ def retrieve(self, request, *args, **kwargs):
+ try:
+ instance = self.get_object(*args, **kwargs)
+ serializer = self.get_serializer(instance)
+ response = { 'data': serializer.data }
+ self._format_list_data(response, serializer=serializer)
+ except Exception as e:
+ return Response(
+ data={ 'detail': str(e) },
+ status=status.HTTP_400_BAD_REQUEST
+ )
+ else:
+ return Response(response)
+
+ def list(self, request, *args, **kwargs):
+ params = getattr(self, 'filter', None)
+ params = params if isinstance(params, dict) else None
+
+ page_obj = self.model.get_brand_paginated_records_by_request(request, params=params)
+
+ results = self.serializer_class(page_obj.object_list, many=True)
+ response = self._format_list_data({
+ 'detail': self._format_page_details(page_obj),
+ 'results': results.data,
+ })
+
+ return Response(response)
+
+ # Mixin methods
+ def get_queryset(self, *args, **kwargs):
+ params = getattr(self, 'filter', None)
+ if isinstance(params, dict):
+ params = kwargs | params
+ else:
+ params = kwargs
+
+ return self.model.get_brand_records_by_request(self.request, params=params)
+
+ def get_object(self, *args, **kwargs):
+ inst = self.get_queryset().filter(*args, **kwargs)
+ if not inst.exists():
+ raise exceptions.NotFound(f'A {self.model._meta.model_name} matching the given parameters does not exist.')
+
+ return inst.first()
+
+ def update(self, request, *args, **kwargs):
+ """Overrides the update mixin"""
+ partial = kwargs.pop('partial', False)
+ instance = self.get_object(*args, **kwargs) # Now uses kwargs dynamically
+ serializer = self.get_serializer(instance, data=request.data, partial=partial)
+ serializer.is_valid(raise_exception=True)
+ self.perform_update(serializer)
+ return Response(serializer.data)
+
+ # Private methods
+ def _get_query_params(self, request, kwargs=None):
+ params = getattr(self, 'filter', None)
+ params = params if isinstance(params, dict) else { }
+ if not isinstance(kwargs, dict):
+ kwargs = { }
+
+ if isinstance(request, Request) and hasattr(request, 'query_params'):
+ params = { key: value for key, value in request.query_params.items() } | kwargs | params
+ elif isinstance(request, HttpRequest) and hasattr(request, 'GET'):
+ params = { key: value for key, value in request.GET.dict().items() } | kwargs | params
+
+ return params
+
+ def _format_list_data(self, response, serializer=None):
+ if serializer is None:
+ serializer = self.get_serializer()
+
+ renderable = { 'form': serializer.form_fields }
+ show_fields = getattr(serializer, '_list_fields', None) if hasattr(serializer, '_list_fields') else None
+ show_fields = show_fields if isinstance(show_fields, list) else None
+ if not isinstance(show_fields, list):
+ show_fields = [self.model._meta.pk.name]
+
+ item_fields = getattr(serializer, '_item_fields', None) if hasattr(serializer, '_item_fields') else None
+ if not isinstance(item_fields, list):
+ item_fields = [
+ k
+ for k, v in renderable.get('form').items()
+ if isinstance(v.get('style'), dict) and v.get('style').get('data-itemdisplay', True)
+ ]
+
+ features = getattr(serializer, '_features', None) if hasattr(serializer, '_features') else None
+ if not isinstance(features, dict):
+ features = None
+
+ response.update(renderable=renderable | { 'fields': show_fields, 'order': item_fields, 'features': features })
+ return response
+
+ def _format_page_details(self, page_obj):
+ num_pages = page_obj.paginator.num_pages
+ page = min(page_obj.number, num_pages)
+
+ detail = {
+ 'page': page,
+ 'total_pages': num_pages,
+ 'page_size': page_obj.paginator.per_page,
+ 'has_previous': page_obj.has_previous(),
+ 'has_next': page_obj.has_next(),
+ 'max_results': page_obj.paginator.count,
+ }
+
+ if num_pages <= 9:
+ detail.update(pages=set(range(1, num_pages + 1)))
+ else:
+ page_items = []
+ min_page = page - 1
+ max_page = page + 1
+ if min_page <= 1:
+ min_page = 1
+ max_page = min(page + 2, num_pages)
+ else:
+ page_items += [1, 'divider']
+
+ if max_page > num_pages:
+ min_page = max(page - 2, 1)
+ max_page = min(page, num_pages)
+
+ page_items += list(range(min_page, max_page + 1))
+ if num_pages not in page_items:
+ page_items += ['divider', num_pages]
+ detail.update(pages=page_items)
+
+ return detail
diff --git a/CodeListLibrary_project/clinicalcode/views/dashboard/targets/BrandTarget.py b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/BrandTarget.py
new file mode 100644
index 000000000..f8b2fda5f
--- /dev/null
+++ b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/BrandTarget.py
@@ -0,0 +1,273 @@
+"""Brand Dashboard: API endpoints relating to Template model"""
+from rest_framework import status, serializers
+from django.contrib.auth import get_user_model
+from django.utils.timezone import make_aware
+from rest_framework.response import Response
+
+import datetime
+
+from .UserTarget import UserSerializer
+from .BaseTarget import BaseSerializer, BaseEndpoint
+from clinicalcode.models.Brand import Brand
+from clinicalcode.entity_utils import model_utils
+
+
+User = get_user_model()
+
+
+class BrandSerializer(BaseSerializer):
+ """
+ Serializer for the `Brand` model. This serializer handles serialization, validation, and
+ creation/updating of Brand objects. It is used to manage the Brand data when making API
+ requests (GET, PUT).
+ """
+
+ # Fields
+ admins = UserSerializer(
+ many=True,
+ help_text=(
+ 'Specifies a set of Users to be designated as administrators, this grants each '
+ 'of them access to this dashboard.'
+ )
+ )
+
+ # Appearance
+ _str_display = 'name'
+ _list_fields = ['id', 'name']
+ _item_fields = ['id', 'name', 'website', 'description', 'site_title', 'site_description', 'admins']
+
+ # Metadata
+ class Meta:
+ model = Brand
+ # Fields that should be included in the serialized output
+ exclude = [
+ 'logo_path', 'index_path', 'about_menu',
+ 'allowed_tabs', 'footer_images', 'is_administrable',
+ 'collections_excluded_from_filters', 'created', 'modified',
+ 'org_user_managed', 'users',
+ ]
+ extra_kwargs = {
+ # RO
+ 'id': { 'read_only': True, 'required': False },
+ 'name': { 'read_only': True, 'required': False },
+ 'logo_path': { 'read_only': True, 'required': False },
+ 'index_path': { 'read_only': True, 'required': False },
+ 'about_menu': { 'read_only': True, 'required': False },
+ 'allowed_tabs': { 'read_only': True, 'required': False },
+ 'footer_images': { 'read_only': True, 'required': False },
+ 'is_administrable': { 'read_only': True, 'required': False },
+ 'collections_excluded_from_filters': { 'read_only': True, 'required': False },
+ # WO
+ 'created': { 'write_only': True, 'read_only': False, 'required': False },
+ 'modified': { 'write_only': True, 'read_only': False, 'required': False },
+ 'created_by': { 'write_only': True, 'read_only': False, 'required': False },
+ 'updated_by': { 'write_only': True, 'read_only': False, 'required': False },
+ # WO | RO
+ 'overrides': { 'help_text': 'Overrides website behaviour for this specific Brand, please seek Administrator advice before modifying.' },
+ 'description': { 'style': { 'as_type': 'TextField' }, 'help_text': 'Human-friendly description of the Brand (appears on the home page)' },
+ 'website': { 'help_text': 'Specifies the Brand\'s website (used for back-linking)' },
+ 'site_title': { 'help_text': 'Specifies the title of the website, e.g. as it appears within the browser tab' },
+ 'site_description': { 'help_text': 'Optionally specify the site description metadata tag, e.g. as it appears on search engines' },
+ }
+
+ # GET
+ def to_representation(self, instance):
+ """
+ Convert a Brand instance into a JSON-serializable dictionary.
+
+ Ensures that the `collections_excluded_from_filters` field is always returned
+ as a list, even if it is empty or None.
+
+ Args:
+ instance (Brand): The Brand instance to serialize.
+
+ Returns:
+ dict: The serialized Brand data.
+ """
+ if isinstance(instance, list):
+ instance = self.Meta.model.objects.filter(pk__in=instance)
+ instance = instance if instance.exists() else None
+ elif isinstance(instance, int):
+ instance = self.Meta.model.objects.filter(pk=instance)
+ if instance.exists():
+ instance = instance.first()
+ else:
+ instance = None
+
+ if instance is not None:
+ data = super(BrandSerializer, self).to_representation(instance)
+ return data
+ return None
+
+ def resolve_format(self):
+ return { 'type': 'ForeignKey' }
+
+ def resolve_options(self):
+ return list(self.Meta.model.objects.all().values('name', 'pk'))
+
+ # POST / PUT
+ def create(self, validated_data):
+ """
+ Create and save a new Brand instance using the validated data.
+
+ Args:
+ validated_data (dict): The validated data for creating the Brand.
+
+ Returns:
+ Brand: The newly created Brand instance.
+ """
+ return self._create(self.Meta.model, validated_data)
+
+ def update(self, instance, validated_data):
+ """
+ Update an existing Brand instance with the validated data.
+
+ Updates each field of the instance with the corresponding validated data and
+ saves the changes. Additionally, updates the `modified` timestamp to the current time.
+
+ Args:
+ instance (Brand): The Brand instance to update.
+ validated_data (dict): The validated data to update the Brand with.
+
+ Returns:
+ Brand: The updated Brand instance.
+ """
+ instance = self._update(instance, validated_data)
+ instance.modified = make_aware(datetime.datetime.now()) # Set `modified` timestamp
+ instance.save()
+ return instance
+
+ # Instance & Field validation
+ def validate(self, data):
+ """
+ Validate the provided data before creating or updating a Brand.
+
+ Args:
+ data (dict): The data to validate.
+
+ Returns:
+ dict: The validated data.
+ """
+ user = self._get_user()
+ instance = getattr(self, 'instance') if hasattr(self, 'instance') else None
+
+ prev_users = instance.users.all() if instance is not None else User.objects.none()
+ prev_admins = instance.admins.all() if instance is not None else User.objects.none()
+
+ users = data.get('users') if isinstance(data.get('users'), list) else prev_users
+ users = list(User.objects.filter(id__in=users))
+ if user is not None and not next((x for x in users if x.id != user.id), None):
+ users.append(user)
+
+ admins = data.get('admins') if isinstance(data.get('admins'), list) else prev_admins
+ admins = list(User.objects.filter(id__in=admins))
+ if user is not None and not next((x for x in admins if x.id != user.id), None):
+ admins.append(user)
+
+ data.update({
+ 'users': users,
+ 'admins': admins,
+ })
+
+ return data
+
+
+class BrandEndpoint(BaseEndpoint):
+ """
+ API endpoint for managing `Brand` resources.
+
+ This view handles API requests related to the `Brand` model, including retrieving,
+ updating, and creating Brand instances. It uses the `BrandSerializer` to handle
+ serialization and validation.
+ """
+
+ model = Brand
+ fields = []
+ serializer_class = BrandSerializer
+ queryset = Brand.objects.all()
+
+ reverse_name_default = 'brand_target' # Default reverse URL name for this endpoint
+
+ def get_queryset(self, *args, **kwargs):
+ """
+ Override the `get_queryset` method to return the queryset for the `Brand` model.
+
+ Args:
+ *args: Positional arguments passed to the method.
+ **kwargs: Keyword arguments passed to the method.
+
+ Returns:
+ QuerySet: A queryset of all Brand objects.
+ """
+ return self.queryset
+
+ def get(self, request, *args, **kwargs):
+ """
+ Handle GET requests to retrieve the current brand.
+
+ This method retrieves the current brand associated with the request and
+ returns its data serialized via `BrandSerializer`.
+
+ Args:
+ request (Request): The incoming request.
+
+ Returns:
+ Response: The serialized data of the current brand.
+ """
+ current_brand = model_utils.try_get_brand(request)
+ if current_brand is None:
+ return Response(
+ data={ 'detail': 'Unknown Brand context' },
+ status=status.HTTP_400_BAD_REQUEST
+ )
+ kwargs.update(pk=current_brand.id)
+ return self.retrieve(request, *args, **kwargs)
+
+ def put(self, request, *args, **kwargs):
+ """
+ Handle PUT requests to update the current brand.
+
+ This method retrieves the current brand associated with the request, validates
+ the data, and updates the brand's information.
+
+ Args:
+ request (Request): The incoming request.
+ *args: Positional arguments passed to the method.
+ **kwargs: Keyword arguments passed to the method.
+
+ Returns:
+ Response: The response containing the updated brand data.
+ """
+ partial = kwargs.pop('partial', False)
+ instance = model_utils.try_get_brand(request)
+
+ serializer = self.get_serializer(instance, data=request.data, partial=partial)
+ try:
+ serializer.is_valid(raise_exception=True)
+ except serializers.ValidationError as e:
+ if isinstance(e.detail, dict):
+ detail = {k: v for k, v in e.detail.items() if k not in ('users', 'admins')}
+ if len(detail) > 0:
+ raise serializers.ValidationError(detail=detail)
+ except Exception as e:
+ raise e
+
+ data = serializer.data
+ data = self.get_serializer(instance).validate(data)
+
+ admins = data.pop('admins', [])
+ users = data.pop('users', [])
+
+ instance.__dict__.update(**data)
+ instance.save()
+
+ instance.admins.set(admins)
+ instance.users.set(users)
+
+ return Response(self.get_serializer(instance).data)
+
+ def post(self, request, *args, **kwargs):
+ """
+ Handle POST requests to create a new Brand instance.
+ """
+ raise Response(status=status.HTTP_403_PERMISSION_DENIED)
diff --git a/CodeListLibrary_project/clinicalcode/views/dashboard/targets/HDRNCategoryTarget.py b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/HDRNCategoryTarget.py
new file mode 100644
index 000000000..d81184898
--- /dev/null
+++ b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/HDRNCategoryTarget.py
@@ -0,0 +1,100 @@
+"""Brand Dashboard: API endpoints relating to Template model"""
+import datetime
+
+from rest_framework import status
+from django.utils.timezone import make_aware
+from rest_framework.response import Response
+
+from .BaseTarget import BaseSerializer, BaseEndpoint
+from clinicalcode.entity_utils import gen_utils
+from clinicalcode.models.HDRNDataCategory import HDRNDataCategory
+
+
+class HDRNCategorySerializer(BaseSerializer):
+ """"""
+
+ # Appearance
+ _str_display = 'name'
+ _list_fields = ['id', 'name']
+ _item_fields = ['id', 'name', 'description']
+
+ # Metadata
+ class Meta:
+ model = HDRNDataCategory
+ exclude = ['created', 'modified']
+ extra_kwargs = {
+ # RO
+ 'id': { 'read_only': True, 'required': False },
+ # WO
+ 'created': { 'write_only': True, 'read_only': False, 'required': False },
+ 'modified': { 'write_only': True, 'read_only': False, 'required': False },
+ # WO | RO
+ 'description': { 'style': { 'as_type': 'TextField' } },
+ 'metadata': { 'help_text': 'Optionally specify a JSON object describing metadata related to this entity.' },
+ }
+
+ # GET
+ def to_representation(self, instance):
+ if isinstance(instance, list):
+ instance = self.Meta.model.objects.filter(pk__in=instance)
+ instance = instance if instance.exists() else None
+ elif isinstance(instance, int):
+ instance = self.Meta.model.objects.filter(pk=instance)
+ if instance.exists():
+ instance = instance.first()
+ else:
+ instance = None
+
+ if instance is not None:
+ data = super(HDRNCategorySerializer, self).to_representation(instance)
+ return data
+ return None
+
+ def resolve_format(self):
+ return { 'type': 'ForeignKey' }
+
+ def resolve_options(self):
+ return list(self.Meta.model.objects.all().values('name', 'pk'))
+
+ # POST / PUT
+ def create(self, validated_data):
+ return self._create(self.Meta.model, validated_data)
+
+ def update(self, instance, validated_data):
+ instance = self._update(instance, validated_data)
+ instance.modified = make_aware(datetime.datetime.now()) # Set `modified` timestamp
+ instance.save()
+ return instance
+
+
+class HDRNCategoryEndpoint(BaseEndpoint):
+ """API views for the `HDRNSite` model"""
+ model = HDRNDataCategory
+ fields = []
+ queryset = HDRNDataCategory.objects.all()
+ serializer_class = HDRNCategorySerializer
+
+ reverse_name_default = 'hdrn_category_target'
+ reverse_name_retrieve = 'hdrn_category_target_with_id'
+
+ # Endpoint methods
+ def get(self, request, *args, **kwargs):
+ inst_id = kwargs.get('pk', None)
+ if inst_id:
+ inst_id = gen_utils.try_value_as_type(inst_id, 'int')
+ if inst_id is None:
+ return Response(
+ data={'detail': 'Expected int-like `pk` parameter'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ kwargs.update(pk=inst_id)
+ return self.retrieve(request, *args, **kwargs)
+
+ return self.list(request, *args, **kwargs)
+
+ def put(self, request, *args, **kwargs):
+ return self.update(request, partial=True, *args, **kwargs)
+
+ def post(self, request, *args, **kwargs):
+ return self.create(request, *args, **kwargs)
diff --git a/CodeListLibrary_project/clinicalcode/views/dashboard/targets/HDRNDataAssetTarget.py b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/HDRNDataAssetTarget.py
new file mode 100644
index 000000000..7c0a6d92f
--- /dev/null
+++ b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/HDRNDataAssetTarget.py
@@ -0,0 +1,224 @@
+import datetime
+
+from django.utils.timezone import make_aware
+from rest_framework import status, serializers
+from rest_framework.response import Response
+
+from .BaseTarget import BaseEndpoint, BaseSerializer
+from .HDRNSiteTarget import HDRNSiteSerializer
+from .HDRNCategoryTarget import HDRNCategorySerializer
+from .HDRNJurisdictionTarget import HDRNJurisdictionSerializer
+from clinicalcode.entity_utils import gen_utils
+from clinicalcode.models.HDRNSite import HDRNSite
+from clinicalcode.models.HDRNJurisdiction import HDRNJurisdiction
+from clinicalcode.models.HDRNDataAsset import HDRNDataAsset
+from clinicalcode.models.HDRNDataCategory import HDRNDataCategory
+
+
+class HDRNDataAssetSerializer(BaseSerializer):
+ """
+ Serializer for HDRN Data Asset.
+ """
+
+ # Fields
+ site = HDRNSiteSerializer(many=False, required=False)
+ regions = HDRNJurisdictionSerializer(many=True, required=False)
+ data_categories = HDRNCategorySerializer(many=True, required=False)
+
+ # Appearance
+ _str_display = 'name'
+ _list_fields = ['id', 'name']
+ _item_fields = ['id', 'name', 'description']
+
+ # Metadata
+ class Meta:
+ model = HDRNDataAsset
+ exclude = ['created', 'modified']
+ extra_kwargs = {
+ # RO
+ 'id': { 'read_only': True, 'required': False },
+ # WO
+ 'created': { 'write_only': True, 'read_only': False, 'required': False },
+ 'modified': { 'write_only': True, 'read_only': False, 'required': False },
+ # RO | WO
+ 'name': { 'min_length': 3, 'required': True },
+ 'site': { 'required': False },
+ 'data_categories': { 'required': False },
+ 'scope': { 'style': { 'as_type': 'TextField' } },
+ 'purpose': { 'style': { 'as_type': 'TextField' } },
+ 'description': { 'style': { 'as_type': 'TextField' } },
+ 'collection_period': { 'style': { 'as_type': 'TextField' } },
+ }
+
+ # GET
+ def resolve_options(self):
+ return list(self.Meta.model.objects.all().values('name', 'pk'))
+
+ # POST / PUT
+ def create(self, validated_data):
+ """
+ Method to create a new HDRNDataAsset instance.
+ """
+ return self._create(self.Meta.model, validated_data)
+
+ def update(self, instance, validated_data):
+ """
+ Update an existing HDRNDataAsset instance.
+ """
+ instance = self._update(instance, validated_data)
+ instance.modified = make_aware(datetime.datetime.now()) # Set `modified` timestamp
+ instance.save()
+ return instance
+
+ # Instance & Field validation
+ def validate(self, data):
+ """
+ Custom validation method for `HDRNDataAsset` fields.
+ """
+ # Validate `data_categories` field (should be a list of integers)
+ data_categories = data.get('data_categories', [])
+ if isinstance(data_categories, list) and not all(isinstance(i, int) for i in data_categories):
+ raise serializers.ValidationError({
+ 'data_categories': 'Data Categories, if provided, must be a list of pk.'
+ })
+ elif isinstance(data_categories, list):
+ data_categories = HDRNDataCategory.objects.filter(pk__in=data_categories)
+ if data_categories is None or not data_categories.exists():
+ raise serializers.ValidationError({
+ 'data_categories': 'Failed to find specified `data_categories`'
+ })
+ data_categories = list(data_categories.values_list('id', flat=True))
+ else:
+ data_categories = None
+
+ # Regions: M2M
+ regions = data.get('regions', [])
+ if isinstance(regions, list) and not all(isinstance(i, int) for i in regions):
+ raise serializers.ValidationError({
+ 'regions': 'Regions, if provided, must be a list of known HDRN Jurisdictions.'
+ })
+ elif isinstance(regions, list):
+ regions = HDRNJurisdiction.objects.filter(pk__in=regions)
+ if regions is None or not regions.exists():
+ raise serializers.ValidationError({
+ 'regions': 'Failed to find specified `regions`'
+ })
+
+ regions = list(regions)
+ else:
+ regions = None
+
+ # Site: FK
+ site = data.get('site', None)
+ if site is not None and not isinstance(site, int):
+ raise serializers.ValidationError({
+ 'site': 'Site, if provided, must be a valid `pk` value'
+ })
+ elif isinstance(site, int):
+ site = HDRNSite.objects.filter(pk=site)
+ if site is None or not site.exists():
+ raise serializers.ValidationError({
+ 'site': 'Found no existing object at specified `site` pk'
+ })
+ site = site.first()
+ else:
+ site = None
+
+ uuid = data.get('hdrn_uuid')
+ if not gen_utils.is_valid_uuid(uuid):
+ uuid = None
+
+ data.update({
+ 'site': site,
+ 'regions': regions,
+ 'hdrn_uuid': uuid,
+ 'data_categories': data_categories,
+ })
+
+ return data
+
+
+class HDRNDataAssetEndpoint(BaseEndpoint):
+ """API views for the HDRN Data Asset model."""
+
+ model = HDRNDataAsset
+ queryset = HDRNDataAsset.objects.all()
+ serializer_class = HDRNDataAssetSerializer
+
+ reverse_name_default = 'hdrn_data_asset_target'
+ reverse_name_retrieve = 'hdrn_data_asset_target_with_id'
+
+ def get(self, request, *args, **kwargs):
+ """
+ Handle GET requests.
+ Retrieves the list of HDRNDataAsset instances or a single instance by ID.
+ """
+ inst_id = kwargs.get('pk', None)
+ if inst_id:
+ # Convert pk to integer and validate
+ inst_id = gen_utils.try_value_as_type(inst_id, 'int')
+ if inst_id is None:
+ return Response(
+ data={'detail': 'Expected int-like `pk` parameter'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ kwargs.update(pk=inst_id)
+ return self.retrieve(request, *args, **kwargs)
+
+ return self.list(request, *args, **kwargs)
+
+ def put(self, request, *args, **kwargs):
+ """
+ Handle PUT requests to update an HDRNDataAsset instance.
+ """
+ partial = kwargs.pop('partial', False)
+ instance = self.get_object(*args, **kwargs)
+
+ serializer = self.get_serializer(instance, data=request.data, partial=partial)
+ try:
+ serializer.is_valid(raise_exception=True)
+ except serializers.ValidationError as e:
+ if isinstance(e.detail, dict):
+ detail = {k: v for k, v in e.detail.items() if k not in ('site', 'data_categories', 'regions')}
+ if len(detail) > 0:
+ raise serializers.ValidationError(detail=detail)
+ except Exception as e:
+ raise e
+
+ data = serializer.data
+ data = self.get_serializer().validate(data)
+
+ regions = data.pop('regions', [])
+
+ instance.__dict__.update(**data)
+ instance.site = data.get('site')
+ instance.save()
+
+ instance.regions.set(regions)
+
+ return Response(self.get_serializer(instance).data)
+
+ def post(self, request, *args, **kwargs):
+ """
+ Handle POST requests to create a new HDRNDataAsset instance.
+ """
+ serializer = self.get_serializer(data=request.data)
+ try:
+ serializer.is_valid(raise_exception=True)
+ except serializers.ValidationError as e:
+ if isinstance(e.detail, dict):
+ detail = {k: v for k, v in e.detail.items() if k not in ('site', 'data_categories', 'regions')}
+ if len(detail) > 0:
+ raise serializers.ValidationError(detail=detail)
+ except Exception as e:
+ raise e
+
+ data = serializer.data
+ data = self.get_serializer().validate(data)
+ regions = data.pop('regions', [])
+
+ instance, _ = self.model.objects.get_or_create(**data)
+ instance.regions.set(regions)
+
+ return Response(self.get_serializer(instance).data)
diff --git a/CodeListLibrary_project/clinicalcode/views/dashboard/targets/HDRNJurisdictionTarget.py b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/HDRNJurisdictionTarget.py
new file mode 100644
index 000000000..7fb329db8
--- /dev/null
+++ b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/HDRNJurisdictionTarget.py
@@ -0,0 +1,84 @@
+"""Brand Dashboard: API endpoints relating to Template model"""
+import datetime
+
+from django.utils.timezone import make_aware
+from rest_framework import status
+from rest_framework.response import Response
+
+from clinicalcode.entity_utils import gen_utils
+from clinicalcode.models.HDRNJurisdiction import HDRNJurisdiction
+from .BaseTarget import BaseSerializer, BaseEndpoint
+
+
+class HDRNJurisdictionSerializer(BaseSerializer):
+ """"""
+
+ # Appearance
+ _str_display = 'name'
+ _list_fields = ['id', 'name', 'abbreviation']
+ _item_fields = ['id', 'name', 'abbreviation', 'description']
+
+ # Metadata
+ class Meta:
+ model = HDRNJurisdiction
+ exclude = ['created', 'modified']
+ extra_kwargs = {
+ # RO
+ 'id': { 'read_only': True, 'required': False },
+ # WO
+ 'created': { 'write_only': True, 'read_only': False, 'required': False },
+ 'modified': { 'write_only': True, 'read_only': False, 'required': False },
+ # WO | RO
+ 'description': { 'style': { 'as_type': 'TextField' } },
+ 'metadata': { 'help_text': 'Optionally specify a JSON object describing metadata related to this entity.' },
+ }
+
+ # GET
+ def resolve_format(self):
+ return { 'type': 'ForeignKey' }
+
+ def resolve_options(self):
+ return list(self.Meta.model.objects.all().values('name', 'pk'))
+
+ # POST / PUT
+ def create(self, validated_data):
+ return self._create(self.Meta.model, validated_data)
+
+ def update(self, instance, validated_data):
+ instance = self._update(instance, validated_data)
+ instance.modified = make_aware(datetime.datetime.now()) # Set `modified` timestamp
+ instance.save()
+ return instance
+
+
+class HDRNJurisdictionEndpoint(BaseEndpoint):
+ """API views for the `HDRNJurisdiction` model"""
+ model = HDRNJurisdiction
+ fields = []
+ queryset = HDRNJurisdiction.objects.all()
+ serializer_class = HDRNJurisdictionSerializer
+
+ reverse_name_default = 'hdrn_jurisdiction_target'
+ reverse_name_retrieve = 'hdrn_jurisdiction_target_with_id'
+
+ # Endpoint methods
+ def get(self, request, *args, **kwargs):
+ inst_id = kwargs.get('pk', None)
+ if inst_id:
+ inst_id = gen_utils.try_value_as_type(inst_id, 'int')
+ if inst_id is None:
+ return Response(
+ data={'detail': 'Expected int-like `pk` parameter'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ kwargs.update(pk=inst_id)
+ return self.retrieve(request, *args, **kwargs)
+
+ return self.list(request, *args, **kwargs)
+
+ def put(self, request, *args, **kwargs):
+ return self.update(request, partial=True, *args, **kwargs)
+
+ def post(self, request, *args, **kwargs):
+ return self.create(request, *args, **kwargs)
diff --git a/CodeListLibrary_project/clinicalcode/views/dashboard/targets/HDRNSiteTarget.py b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/HDRNSiteTarget.py
new file mode 100644
index 000000000..4f3a474ed
--- /dev/null
+++ b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/HDRNSiteTarget.py
@@ -0,0 +1,84 @@
+"""Brand Dashboard: API endpoints relating to Template model"""
+import datetime
+
+from django.utils.timezone import make_aware
+from rest_framework import status
+from rest_framework.response import Response
+
+from clinicalcode.entity_utils import gen_utils
+from clinicalcode.models.HDRNSite import HDRNSite
+from .BaseTarget import BaseSerializer, BaseEndpoint
+
+
+class HDRNSiteSerializer(BaseSerializer):
+ """"""
+
+ # Appearance
+ _str_display = 'name'
+ _list_fields = ['id', 'name', 'abbreviation']
+ _item_fields = ['id', 'name', 'abbreviation', 'description']
+
+ # Metadata
+ class Meta:
+ model = HDRNSite
+ exclude = ['created', 'modified']
+ extra_kwargs = {
+ # RO
+ 'id': { 'read_only': True, 'required': False },
+ # WO
+ 'created': { 'write_only': True, 'read_only': False, 'required': False },
+ 'modified': { 'write_only': True, 'read_only': False, 'required': False },
+ # WO | RO
+ 'description': { 'style': { 'as_type': 'TextField' } },
+ 'metadata': { 'help_text': 'Optionally specify a JSON object describing metadata related to this entity.' },
+ }
+
+ # GET
+ def resolve_format(self):
+ return { 'type': 'ForeignKey' }
+
+ def resolve_options(self):
+ return list(self.Meta.model.objects.all().values('name', 'pk'))
+
+ # POST / PUT
+ def create(self, validated_data):
+ return self._create(self.Meta.model, validated_data)
+
+ def update(self, instance, validated_data):
+ instance = self._update(instance, validated_data)
+ instance.modified = make_aware(datetime.datetime.now()) # Set `modified` timestamp
+ instance.save()
+ return instance
+
+
+class HDRNSiteEndpoint(BaseEndpoint):
+ """API views for the `HDRNSite` model"""
+ model = HDRNSite
+ fields = []
+ queryset = HDRNSite.objects.all()
+ serializer_class = HDRNSiteSerializer
+
+ reverse_name_default = 'hdrn_site_target'
+ reverse_name_retrieve = 'hdrn_site_target_with_id'
+
+ # Endpoint methods
+ def get(self, request, *args, **kwargs):
+ inst_id = kwargs.get('pk', None)
+ if inst_id:
+ inst_id = gen_utils.try_value_as_type(inst_id, 'int')
+ if inst_id is None:
+ return Response(
+ data={'detail': 'Expected int-like `pk` parameter'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ kwargs.update(pk=inst_id)
+ return self.retrieve(request, *args, **kwargs)
+
+ return self.list(request, *args, **kwargs)
+
+ def put(self, request, *args, **kwargs):
+ return self.update(request, partial=True, *args, **kwargs)
+
+ def post(self, request, *args, **kwargs):
+ return self.create(request, *args, **kwargs)
diff --git a/CodeListLibrary_project/clinicalcode/views/dashboard/targets/OrganisationTarget.py b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/OrganisationTarget.py
new file mode 100644
index 000000000..74e817f26
--- /dev/null
+++ b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/OrganisationTarget.py
@@ -0,0 +1,459 @@
+"""Brand Dashboard: API endpoints relating to Organisation model"""
+from django.db import transaction
+from rest_framework import status, serializers
+from django.db.models import Q, F
+from django.contrib.auth import get_user_model
+from django.core.paginator import EmptyPage, Paginator, Page
+from rest_framework.response import Response
+
+from .UserTarget import UserSerializer
+from .BaseTarget import BaseSerializer, BaseEndpoint
+from .BrandTarget import BrandSerializer
+from clinicalcode.entity_utils import constants, gen_utils, model_utils
+from clinicalcode.models.Brand import Brand
+from clinicalcode.models.Organisation import Organisation, OrganisationAuthority, OrganisationMembership
+
+
+User = get_user_model()
+
+
+class OrganisationAuthoritySerializer(BaseSerializer):
+ """Responsible for serialising the `Organisation.brands` through-field model and to handle PUT/POST validation"""
+
+ # Fields
+ brand = BrandSerializer(many=False)
+
+ # Appearance
+ _str_display = 'id'
+
+ # Metadata
+ class Meta:
+ model = OrganisationAuthority
+ fields = '__all__'
+ extra_kwargs = {
+ # RO
+ 'id': { 'read_only': True, 'required': False },
+ }
+
+ # GET
+ def resolve_format(self):
+ return {
+ 'type': 'through',
+ 'component': 'OrgAuthoritySelector',
+ 'fields': {
+ 'brand': 'ForeignKey',
+ 'can_post': 'BooleanField',
+ 'can_moderate': 'BooleanField',
+ }
+ }
+
+ def resolve_options(self):
+ return {
+ 'brand': list(Brand.objects.all().values('name', 'pk')),
+ }
+
+
+class OrganisationMembershipSerializer(BaseSerializer):
+ """Responsible for serialising the `Organisation.members` through-field model and to handle PUT/POST validation"""
+
+ # Fields
+ user = UserSerializer(many=False)
+ role = serializers.ChoiceField(choices=[(e.value, e.name) for e in constants.ORGANISATION_ROLES])
+
+ # Appearance
+ _str_display = 'id'
+
+ # Metadata
+ class Meta:
+ model = OrganisationMembership
+ fields = '__all__'
+ extra_kwargs = {
+ # RO
+ 'id': { 'read_only': True, 'required': False },
+ 'joined': { 'read_only': True, 'required': False },
+ # WO | RO
+ 'description': { 'style': { 'as_type': 'TextField' } },
+ }
+
+ # GET
+ def resolve_format(self):
+ return {
+ 'type': 'through',
+ 'component': 'OrgMemberSelector',
+ 'fields': {
+ 'user': 'ForeignKey',
+ 'role': 'ChoiceField',
+ }
+ }
+
+ def resolve_options(self):
+ brand = self._get_brand()
+
+ records = None
+ if brand is not None:
+ vis_rules = brand.get_vis_rules()
+ if isinstance(vis_rules, dict):
+ allow_null = vis_rules.get('allow_null')
+ allowed_brands = vis_rules.get('ids')
+ if isinstance(allowed_brands, list) and isinstance(allow_null, bool) and allow_null:
+ records = User.objects.filter(Q(accessible_brands__id__isnull=True) | Q(accessible_brands__id__in=allowed_brands))
+ elif isinstance(allowed_brands, list):
+ records = User.objects.filter(accessible_brands__id__in=allowed_brands)
+ elif isinstance(allow_null, bool) and allow_null:
+ records = User.objects.filter(Q(accessible_brands__id__isnull=True) | Q(accessible_brands__id__in=[brand.id]))
+
+ if records is None:
+ records = User.objects.filter(accessible_brands__id=brand.id)
+ else:
+ records = User.objects.all()
+
+ return {
+ 'role': [{ 'name': e.name, 'pk': e.value } for e in constants.ORGANISATION_ROLES],
+ 'user': list(records.annotate(name=F('username')).values('name', 'pk')) if records is not None else [],
+ }
+
+
+class OrganisationSerializer(BaseSerializer):
+ """Responsible for serialising the `Organisation` model and to handle PUT/POST validation"""
+
+ # Fields
+ owner = UserSerializer(
+ required=True,
+ many=False,
+ help_text=(
+ 'The owner of an organisation is automatically given administrative privileges '
+ 'within an Organisation and does not need to be included as a Member.'
+ )
+ )
+ brands = OrganisationAuthoritySerializer(
+ source='organisationauthority_set',
+ many=True,
+ help_text=(
+ 'Specifies the visibility & the control this Organisation has on different Brands.'
+ )
+ )
+ members = OrganisationMembershipSerializer(
+ source='organisationmembership_set',
+ many=True,
+ help_text=(
+ 'Describes a set of users associated with this Organisation and the role they play within it. '
+ 'Note that the owner of an Organisation does not need to be included as a member of an Organisation, '
+ 'they are automatically assigned an Administrative role.'
+ )
+ )
+
+ # Appearance
+ _str_display = 'name'
+ _list_fields = ['id', 'name', 'owner']
+ _item_fields = [
+ 'id', 'slug', 'name',
+ 'description', 'website', 'email',
+ 'owner', 'members', 'brands',
+ ]
+
+ # Metadata
+ class Meta:
+ model = Organisation
+ exclude = ['created']
+ extra_kwargs = {
+ # RO
+ 'id': { 'read_only': True, 'required': False },
+ 'slug': { 'read_only': True, 'required': False },
+ # WO
+ 'created': { 'write_only': True, 'required': False },
+ # WO | RO
+ 'email': { 'help_text': 'Specifies this Organisation\'s e-mail address (optional).'},
+ 'website': { 'help_text': 'Specifies this Organisation\'s website (optional).'},
+ }
+
+ # Instance & Field validation
+ def validate(self, data):
+ """
+ Validate the provided data before creating or updating a Brand.
+
+ Args:
+ data (dict): The data to validate.
+
+ Returns:
+ dict: The validated data.
+ """
+ instance = getattr(self, 'instance') if hasattr(self, 'instance') else None
+ current_brand = self._get_brand()
+
+ prev_brands = instance.admins.all() if instance is not None else OrganisationAuthority.objects.none()
+ prev_members = instance.members.all() if instance is not None else OrganisationMembership.objects.none()
+
+ data_brands = data.get('brands') if isinstance(data.get('brands'), list) else None
+ if isinstance(data_brands, list):
+ brands = []
+ for x in data_brands:
+ brand = Brand.objects.filter(id=x.get('brand_id', -1))
+ if not brand.exists():
+ continue
+
+ brands.append({
+ 'brand': brand.first(),
+ 'can_post': not not x.get('can_post', False),
+ 'can_moderate': not not x.get('can_moderate', False),
+ })
+
+ if current_brand and not next((x for x in brands if x.get('brand').id == current_brand.id), None):
+ brands.append({
+ 'brand': current_brand,
+ 'can_post': False,
+ 'can_moderate': False,
+ })
+ else:
+ brands = prev_brands
+
+ data_members = data.get('members') if isinstance(data.get('members'), list) else None
+ if isinstance(data_members, list):
+ members = []
+ for x in data_members:
+ user = User.objects.filter(id=x.get('user_id', -1))
+ if not user.exists():
+ continue
+
+ members.append({
+ 'user': user.first(),
+ 'role': x.get('role', 0),
+ })
+ else:
+ members = prev_members
+
+ owner = data.get('owner', instance.owner if instance is not None else None)
+ if isinstance(owner, int):
+ owner = User.objects.filter(id=owner)
+ if owner is not None or owner.exists():
+ owner = owner.first()
+
+ if not isinstance(owner, User):
+ raise serializers.ValidationError({
+ 'owner': 'The `owner` field must be supplied.'
+ })
+
+ data.update({
+ 'owner': owner,
+ 'brands': brands,
+ 'members': members,
+ })
+
+ return data
+
+class OrganisationEndpoint(BaseEndpoint):
+ """Responsible for API views relating to `Organisation` model accessed via Brand dashboard"""
+
+ # Metadata
+ model = Organisation
+ fields = []
+ queryset = Organisation.objects.all()
+ serializer_class = OrganisationSerializer
+
+ # View behaviour
+ reverse_name_default = 'brand_user_target'
+ reverse_name_retrieve = 'brand_user_target_with_id'
+
+ # Endpoint methods
+ def get(self, request, *args, **kwargs):
+ inst_id = kwargs.get('pk', None)
+ if inst_id:
+ inst_id = gen_utils.try_value_as_type(inst_id, 'int')
+ if inst_id is None:
+ return Response(
+ data={ 'detail': 'Expected int-like `pk` parameter' },
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ kwargs.update(pk=inst_id)
+ return self.retrieve(request, *args, **kwargs)
+
+ return self.list(request, *args, **kwargs)
+
+ def put(self, request, *args, **kwargs):
+ """
+ Handle PUT requests to update an Organisation instance.
+ """
+ partial = kwargs.pop('partial', False)
+ instance = self.get_object(*args, **kwargs)
+
+ serializer = self.get_serializer(instance, data=request.data, partial=partial)
+ try:
+ serializer.is_valid(raise_exception=True)
+ except serializers.ValidationError as e:
+ if isinstance(e.detail, dict):
+ detail = {k: v for k, v in e.detail.items() if k not in ('owner', 'brands', 'members')}
+ if len(detail) > 0:
+ raise serializers.ValidationError(detail=detail)
+ except Exception as e:
+ raise e
+
+ try:
+ data = serializer.data
+ data = self.get_serializer().validate(data)
+
+ brands = data.pop('brands', None)
+ members = data.pop('members', None)
+
+ with transaction.atomic():
+ instance.__dict__.update(**data)
+ instance.owner = data.get('owner')
+ instance.save()
+
+ if isinstance(brands, list):
+ instance.brands.clear()
+
+ brands = Organisation.brands.through.objects.bulk_create([
+ Organisation.brands.through(**({ 'organisation': instance } | obj)) for obj in brands
+ ])
+ elif not brands is None:
+ instance.brands.set(brands)
+ else:
+ instance.brands.set([])
+
+ if isinstance(members, list):
+ instance.members.clear()
+
+ members = Organisation.members.through.objects.bulk_create([
+ Organisation.members.through(**({ 'organisation': instance } | obj)) for obj in members
+ ])
+ elif not members is None:
+ instance.members.set(members)
+ else:
+ instance.members.set([])
+ except Exception as e:
+ raise e
+ else:
+ return Response(self.get_serializer(instance).data)
+
+ def post(self, request, *args, **kwargs):
+ """
+ Handle POST requests to create a new Organisation instance.
+ """
+ serializer = self.get_serializer(data=request.data)
+ try:
+ serializer.is_valid(raise_exception=True)
+ except serializers.ValidationError as e:
+ if isinstance(e.detail, dict):
+ detail = {k: v for k, v in e.detail.items() if k not in ('owner', 'brands', 'members')}
+ if len(detail) > 0:
+ raise serializers.ValidationError(detail=detail)
+ except Exception as e:
+ raise e
+
+ try:
+ data = serializer.data
+ data = self.get_serializer().validate(data)
+
+ brands = data.pop('brands', None)
+ members = data.pop('members', None)
+
+ with transaction.atomic():
+ instance, created = self.model.objects.get_or_create(**data)
+ if created:
+ if isinstance(brands, list):
+ instance.brands.clear()
+
+ brands = Organisation.brands.through.objects.bulk_create([
+ Organisation.brands.through(**({ 'organisation': instance } | obj)) for obj in brands
+ ])
+ elif not brands is None:
+ instance.brands.set(brands)
+ else:
+ instance.brands.set([])
+
+ if isinstance(members, list):
+ instance.members.clear()
+
+ members = Organisation.members.through.objects.bulk_create([
+ Organisation.members.through(**({ 'organisation': instance } | obj)) for obj in members
+ ])
+ elif not members is None:
+ instance.members.set(members)
+ else:
+ instance.members.set([])
+ except Exception as e:
+ raise e
+ else:
+ return Response(self.get_serializer(instance).data)
+
+ # Override queryset
+ def list(self, request, *args, **kwargs):
+ params = self._get_query_params(request)
+
+ page = gen_utils.try_value_as_type(params.get('page'), 'int', default=1)
+ page = max(page, 1)
+
+ page_size = params.get('page_size', '1')
+ if page_size not in constants.PAGE_RESULTS_SIZE:
+ page_size = constants.PAGE_RESULTS_SIZE.get('1')
+ else:
+ page_size = constants.PAGE_RESULTS_SIZE.get(page_size)
+
+ records = self.get_queryset(request, **params)
+ if records is None:
+ page_obj = Page(Organisation.objects.none(), 0, Paginator([], page_size, allow_empty_first_page=True))
+ else:
+ records = records.order_by('id')
+ pagination = Paginator(records, page_size, allow_empty_first_page=True)
+ try:
+ page_obj = pagination.page(page)
+ except EmptyPage:
+ page_obj = pagination.page(pagination.num_pages)
+
+ results = self.serializer_class(page_obj.object_list, many=True)
+ response = self._format_list_data({
+ 'detail': self._format_page_details(page_obj),
+ 'results': results.data,
+ })
+
+ self._format_list_data(response)
+ return Response(response)
+
+ def get_queryset(self, *args, **kwargs):
+ request = self.request
+
+ params = getattr(self, 'filter', None)
+ if isinstance(params, dict):
+ params = kwargs | params
+ else:
+ params = kwargs
+
+ brand = model_utils.try_get_brand(request)
+ records = None
+ if brand is not None:
+ vis_rules = brand.get_vis_rules()
+ if isinstance(vis_rules, dict):
+ allow_null = vis_rules.get('allow_null')
+ allowed_brands = vis_rules.get('ids')
+ if isinstance(allowed_brands, list) and isinstance(allow_null, bool) and allow_null:
+ records = Organisation.objects.filter(
+ Q(brands__isnull=allow_null) | \
+ Q(brands__in=allowed_brands)
+ )
+ elif isinstance(allowed_brands, list):
+ records = Organisation.objects.filter(brands__in=allowed_brands)
+ elif isinstance(allow_null, bool) and allow_null:
+ records = Organisation.objects.filter(Q(brands__isnull=True) | Q(brands__in=[brand.id]))
+
+ if records is None:
+ records = Organisation.objects.filter(brands__id__contains=brand.id)
+ else:
+ records = Organisation.objects.all()
+
+ page = gen_utils.try_value_as_type(params.get('page'), 'int', default=1)
+ page = max(page, 1)
+
+ search = params.get('search', None)
+ query = gen_utils.parse_model_field_query(Organisation, params, ignored_fields=['description'])
+
+ if query is not None:
+ records = records.filter(**query)
+
+ if not gen_utils.is_empty_string(search) and len(search) >= 3:
+ records = records.filter(
+ Q(name__icontains=search) | \
+ Q(email__icontains=search) | \
+ Q(description__icontains=search)
+ )
+
+ return records
diff --git a/CodeListLibrary_project/clinicalcode/views/dashboard/targets/PeopleTarget.py b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/PeopleTarget.py
new file mode 100644
index 000000000..c817e5af0
--- /dev/null
+++ b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/PeopleTarget.py
@@ -0,0 +1,71 @@
+from functools import partial
+
+from rest_framework import status, serializers
+from rest_framework.response import Response
+
+from clinicalcode.entity_utils import gen_utils, constants
+from clinicalcode.models.Organisation import OrganisationMembership
+from .BaseTarget import BaseSerializer, BaseEndpoint
+
+
+class OrganisationMembershipSerializer(BaseSerializer):
+
+ class Meta:
+ model = OrganisationMembership
+ fields = ['id', 'user', 'organisation', 'role', 'joined']
+
+ def to_representation(self, instance):
+ data = super(OrganisationMembershipSerializer, self).to_representation(instance)
+ return data
+
+ def create(self, validated_data):
+
+ return self._create(self.Meta.model, validated_data)
+
+ def update(self, instance, validated_data):
+ instance = self._update(instance, validated_data)
+ instance.save()
+ return instance
+
+
+ def validate(self, data):
+ role = data.get('role')
+ if role not in [e.value for e in constants.ORGANISATION_ROLES]:
+ raise serializers.ValidationError(f"Invalid role.")
+
+ return data
+
+class PeopleEndpoint(BaseEndpoint):
+ """Responsible for API views relating to `Template` model accessed via Brand dashboard"""
+
+ model = OrganisationMembership
+ fields = []
+ queryset = OrganisationMembership.objects.all()
+ serializer_class = OrganisationMembershipSerializer
+
+ # View behaviour
+ reverse_name_default = 'brand_people_target'
+ # View behaviour
+ reverse_name_retrieve = 'brand_people_target_with_id'
+
+ # Endpoint methods
+ def get(self, request, *args, **kwargs):
+ inst_id = kwargs.get('pk', None)
+ if inst_id:
+ inst_id = gen_utils.try_value_as_type(inst_id, 'int')
+ if inst_id is None:
+ return Response(
+ data={'detail': 'Expected int-like `pk` parameter'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ kwargs.update(pk=inst_id)
+ return self.retrieve(request, *args, **kwargs)
+
+ return self.list(request, *args, **kwargs)
+
+ def put(self, request, *args, **kwargs):
+ return self.update(request, partial=True, *args, **kwargs)
+
+ def post(self, request, *args, **kwargs):
+ return self.create(request, *args, **kwargs)
\ No newline at end of file
diff --git a/CodeListLibrary_project/clinicalcode/views/dashboard/targets/TagTarget.py b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/TagTarget.py
new file mode 100644
index 000000000..684280b3c
--- /dev/null
+++ b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/TagTarget.py
@@ -0,0 +1,140 @@
+"""Brand Dashboard: API endpoints relating to Template model"""
+from rest_framework import status, serializers
+from django.utils.timezone import make_aware
+from rest_framework.response import Response
+
+import datetime
+
+from .BaseTarget import BaseSerializer, BaseEndpoint
+from clinicalcode.models.Tag import Tag
+from clinicalcode.models.Brand import Brand
+from clinicalcode.entity_utils import gen_utils
+
+
+class TagSerializer(BaseSerializer):
+ """Responsible for serialising the `Brand` model and to handle PUT/POST validation"""
+
+ # Fields
+ description = serializers.CharField(max_length=50, label='Name')
+ display = serializers.ChoiceField(choices=Tag.DISPLAY_CHOICES, help_text='This descriptor hints the colour temperature used to display the tag (e.g. via search page).')
+ tag_type = serializers.ChoiceField(choices=Tag.TAG_TYPES, help_text='This field determines whether this item is to be considered a \'Collection\' or a \'Tag\'.')
+
+ # Appearance
+ _str_display = 'name'
+ _list_fields = ['id', 'description', 'tag_type']
+ _item_fields = ['id', 'description', 'tag_type', 'display']
+
+ # Metadata
+ class Meta:
+ model = Tag
+ exclude = ['collection_brand', 'created_by', 'updated_by', 'created', 'modified']
+ extra_kwargs = {
+ # RO
+ 'id': { 'read_only': True, 'required': False },
+ # WO
+ 'created': { 'write_only': True, 'read_only': False, 'required': False },
+ 'modified': { 'write_only': True, 'read_only': False, 'required': False },
+ 'created_by': { 'write_only': True, 'required': False },
+ 'updated_by': { 'write_only': True, 'required': False },
+ }
+
+ # GET
+ def to_representation(self, instance):
+ data = super(TagSerializer, self).to_representation(instance)
+ return data
+
+ def resolve_options(self):
+ return list(self.Meta.model.get_brand_assoc_queryset(self._get_brand(), 'all').values('name', 'pk', 'tag_type'))
+
+ # POST / PUTx
+ def create(self, validated_data):
+ user = self._get_user()
+ validated_data.update({
+ 'created_by': user,
+ 'updated_by': user,
+ })
+ return self._create(self.Meta.model, validated_data)
+
+ def update(self, instance, validated_data):
+ instance = self._update(instance, validated_data)
+ instance.modified = make_aware(datetime.datetime.now()) # Set `modified` timestamp
+ instance.updated_by = self._get_user()
+ instance.save()
+ return instance
+
+ # Instance & Field validation
+ def validate(self, data):
+ current_brand = self._get_brand()
+ instance = getattr(self, 'instance') if hasattr(self, 'instance') else None
+
+ data_brand = data.get('collection_brand')
+ tag_type = data.get('tag_type')
+ display = data.get('display')
+
+ if current_brand is not None:
+ if instance is not None:
+ data_brand = instance.collection_brand if instance.collection_brand is not None else current_brand.id
+ else:
+ data_brand = current_brand.id
+ elif not isinstance(data_brand, (Brand, int)):
+ data_brand = instance.collection_brand
+
+ if isinstance(data_brand, int):
+ data_brand = Brand.objects.filter(pk=data_brand)
+ if data_brand is None or not data_brand.exists():
+ raise serializers.ValidationError({
+ 'collection_brand': 'Invalid Brand'
+ })
+ data_brand = data_brand.first()
+
+ data.update(collection_brand=data_brand)
+
+ if display is not None and display not in dict(self.Meta.model.DISPLAY_CHOICES).keys():
+ raise serializers.ValidationError({
+ 'display': 'Invalid display choice.'
+ })
+ if tag_type not in dict(self.Meta.model.TAG_TYPES).keys():
+ raise serializers.ValidationError({
+ 'tag_type': 'Invalid Tag Type'
+ })
+
+ return data
+
+
+class TagEndpoint(BaseEndpoint):
+ """Responsible for API views relating to `Template` model accessed via Brand dashboard"""
+
+ # QuerySet kwargs
+ filter = { 'all_tags': True }
+
+ # Metadata
+ model = Tag
+ fields = []
+ queryset = Tag.objects.all()
+ serializer_class = TagSerializer
+
+ # View behaviour
+ reverse_name_default = 'brand_tag_target'
+ reverse_name_retrieve = 'brand_tag_target_with_id'
+
+ # Endpoint methods
+ def get(self, request, *args, **kwargs):
+ inst_id = kwargs.get('pk', None)
+ if inst_id:
+ inst_id = gen_utils.try_value_as_type(inst_id, 'int')
+ if inst_id is None:
+ return Response(
+ data={'detail': 'Expected int-like `pk` parameter'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ kwargs.update(pk=inst_id)
+ return self.retrieve(request, *args, **kwargs)
+
+ return self.list(request, *args, **kwargs)
+
+ def put(self, request, *args, **kwargs):
+ return self.update(request, partial=True, *args, **kwargs)
+
+ def post(self, request, *args, **kwargs):
+ return self.create(request, *args, **kwargs)
diff --git a/CodeListLibrary_project/clinicalcode/views/dashboard/targets/TemplateTarget.py b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/TemplateTarget.py
new file mode 100644
index 000000000..21bf6da7c
--- /dev/null
+++ b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/TemplateTarget.py
@@ -0,0 +1,304 @@
+"""Brand Dashboard: API endpoints relating to Template model"""
+from rest_framework import status, serializers
+from django.contrib.auth import get_user_model
+from django.utils.timezone import make_aware
+from rest_framework.response import Response
+
+import json
+import datetime
+
+from .BaseTarget import BaseSerializer, BaseEndpoint
+from .BrandTarget import BrandSerializer
+from clinicalcode.entity_utils import gen_utils, template_utils
+from clinicalcode.models.Brand import Brand
+from clinicalcode.models.Template import Template
+from clinicalcode.models.EntityClass import EntityClass
+
+
+User = get_user_model()
+
+
+# Const
+TEMPLATE_NOTE_DESC = (
+ 'Please note that a Template\'s name, description, version and other metadata are defined by a Template\'s definition.'
+ ' These attributes can be specified within a Template\'s definition field, specifically its `template_details` property'
+ ' - please see the Template documentation for more information.'
+)
+
+
+class EntityClassSerializer(BaseSerializer):
+ """"""
+
+ # Appearance
+ _str_display = 'name'
+ _list_fields = ['id', 'name']
+ _item_fields = ['id', 'name', 'description']
+
+ # Metadata
+ class Meta:
+ model = EntityClass
+ exclude = ['created_by', 'modified_by', 'created', 'modified', 'entity_count']
+ extra_kwargs = {
+ # RO
+ 'id': { 'read_only': True, 'required': False },
+ 'entity_prefix': { 'read_only': True, 'required': False },
+ # WO
+ 'created': { 'write_only': True, 'read_only': False, 'required': False },
+ 'modified': { 'write_only': True, 'read_only': False, 'required': False },
+ 'created_by': { 'write_only': True, 'read_only': False, 'required': False },
+ 'modified_by': { 'write_only': True, 'read_only': False, 'required': False },
+ }
+
+ # GET
+ def resolve_format(self):
+ return { 'type': 'ForeignKey' }
+
+ def resolve_options(self):
+ return list(self.Meta.model.objects.all().values('name', 'pk'))
+
+
+class TemplateSerializer(BaseSerializer):
+ """Responsible for serialising the `Template` model and to handle PUT/POST validation"""
+
+ # Fields
+ brands = BrandSerializer(many=True, help_text='Specifies which Brands can use & interact with this Template and its descendants.')
+ entity_class = EntityClassSerializer(help_text='Specifies how to categorise this Template & determines entity behaviour.')
+
+ # Appearance
+ _str_display = 'name'
+ _list_fields = ['id', 'name', 'template_version']
+ _item_fields = ['id', 'definition', 'entity_class', 'brands']
+ _features = {
+ 'create': {
+ 'note': TEMPLATE_NOTE_DESC
+ },
+ 'update': {
+ 'note': TEMPLATE_NOTE_DESC
+ },
+ }
+
+ # Metadata
+ class Meta:
+ model = Template
+ exclude = ['created_by', 'updated_by', 'created', 'modified']
+ extra_kwargs = {
+ # RO
+ 'id': { 'read_only': True, 'required': False },
+ 'name': { 'read_only': True, 'required': False },
+ 'description': { 'read_only': True, 'required': False },
+ # WO
+ 'created': { 'write_only': True, 'read_only': False, 'required': False },
+ 'modified': { 'write_only': True, 'read_only': False, 'required': False },
+ 'created_by': { 'write_only': True, 'read_only': False, 'required': False },
+ 'updated_by': { 'write_only': True, 'read_only': False, 'required': False },
+ 'entity_class': { 'write_only': True, 'read_only': False, 'required': False },
+ # WO | RO
+ 'definition': { 'required': True, 'help_text': 'Specifies the fields, datatypes, and features associated with this Template.' },
+ 'hide_on_create': { 'required': False, 'help_text': 'Specifies whether to hide this Template from the Create interface.' },
+ }
+
+ # GET
+ def to_representation(self, instance):
+ data = super(TemplateSerializer, self).to_representation(instance)
+ definition = data.get('definition')
+ if not instance or not hasattr(instance, 'pk') or not isinstance(definition, dict):
+ return data
+
+ details = definition.get('template_details')
+ if isinstance(details, dict):
+ details['name'] = details.get('name', '')
+ details['description'] = details.get('description', '')
+
+ data['definition'] = template_utils.get_ordered_definition(definition, clean_fields=True)
+ return data
+
+ # Instance & Field validation
+ def validate(self, data):
+ user = self._get_user()
+ instance = getattr(self, 'instance') if hasattr(self, 'instance') else None
+ current_brand = self._get_brand()
+
+ entity_class = data.get('entity_class', instance.entity_class if instance else None)
+ if entity_class is not None:
+ if isinstance(entity_class, int):
+ entity_class = EntityClass.objects.filter(pk=entity_class)
+ if entity_class is None or not entity_class.exists():
+ raise serializers.ValidationError({
+ 'entity_class': 'Found no existing object at specified `entity_class` pk.'
+ })
+ entity_class = entity_class.first()
+ elif not isinstance(entity_class, EntityClass):
+ entity_class = None
+
+ if entity_class is None:
+ raise serializers.ValidationError({
+ 'entity_class': 'Required `entity_class` field of `pk` type is invalid JSON, or is missing.'
+ })
+
+ if instance is not None:
+ definition = data.get('definition', instance.definition)
+ else:
+ definition = data.get('definition')
+
+ if isinstance(definition, str):
+ try:
+ definition = json.loads(definition)
+ except:
+ raise serializers.ValidationError({
+ 'definition': 'Required JSONField `definition` is invalid'
+ })
+
+ if not isinstance(definition, dict):
+ raise serializers.ValidationError({
+ 'definition': 'Required JSONField `definition` is missing'
+ })
+
+ try:
+ json.dumps(definition)
+ except:
+ raise serializers.ValidationError({
+ 'definition': 'Template definition is not valid JSON'
+ })
+
+ definition = template_utils.get_ordered_definition(definition, clean_fields=True)
+
+ template_fields = definition.get('fields')
+ template_details = definition.get('template_details')
+ template_sections = definition.get('sections')
+ if not isinstance(template_fields, dict):
+ raise serializers.ValidationError({
+ 'definition': 'Template `definition` field requires a `fields` key-value pair of type `dict`'
+ })
+ elif not isinstance(template_details, dict):
+ raise serializers.ValidationError({
+ 'definition': 'Template `definition` field requires a `template_details` key-value pair of type `dict`'
+ })
+ elif not isinstance(template_sections, list):
+ raise serializers.ValidationError({
+ 'definition': 'Template `definition` field requires a `sections` key-value pair of type `list`'
+ })
+
+ name = template_details.get('name')
+ if not isinstance(name, str) or gen_utils.is_empty_string(name):
+ raise serializers.ValidationError({
+ 'definition': 'Template requires that the `definition->template_details.name` field be a non-empty string'
+ })
+
+ brands = data.get('brands')
+ if isinstance(brands, Brand):
+ brands = [brands.id]
+ elif isinstance(brands, int):
+ brands = [brands]
+
+ if isinstance(brands, list):
+ brands = [x.id if isinstance(x, Brand) else x for x in brands if isinstance(x, (Brand, int))]
+ if current_brand and not current_brand.id in brands:
+ brands.append(current_brand.id)
+ elif current_brand:
+ brands = instance.brands if instance is not None and isinstance(instance.brands, list) else []
+ if current_brand and not current_brand.id in brands:
+ brands.append(current_brand.id)
+ elif instance:
+ brands = instance.brands
+
+ data.update({
+ 'name': template_details.get('name', instance.name if instance else ''),
+ 'description': template_details.get('description', instance.name if instance else ''),
+ 'template_version': template_details.get('version', instance.template_version if instance else 1),
+ 'brands': brands,
+ 'definition': self.__apply_def_ordering(definition),
+ 'description': template_details.get('description', ''),
+ 'created_by': instance.user if instance is not None else user,
+ 'updated_by': user,
+ 'entity_class': entity_class,
+ 'modified': make_aware(datetime.datetime.now()),
+ })
+
+ return data
+
+ # Private utility methods
+ def __apply_def_ordering(self, definition):
+ order = []
+ for field in definition['fields']:
+ definition['fields'][field]['order'] = len(order)
+ order.append(field)
+
+ definition['layout_order'] = order
+ return definition
+
+
+class TemplateEndpoint(BaseEndpoint):
+ """Responsible for API views relating to `Template` model accessed via Brand dashboard"""
+
+ # Metadata
+ model = Template
+ fields = []
+ queryset = Template.objects.all()
+ serializer_class = TemplateSerializer
+
+ # View behaviour
+ reverse_name_default = 'brand_template_target'
+ reverse_name_retrieve = 'brand_template_target_with_id'
+
+ # Endpoint methods
+ def get(self, request, *args, **kwargs):
+ inst_id = kwargs.get('pk', None)
+ if inst_id:
+ inst_id = gen_utils.try_value_as_type(inst_id, 'int')
+ if inst_id is None:
+ return Response(
+ data={ 'detail': 'Expected int-like `pk` parameter' },
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ kwargs.update(pk=inst_id)
+ return self.retrieve(request, *args, **kwargs)
+
+ return self.list(request, *args, **kwargs)
+
+ def put(self, request, *args, **kwargs):
+ """
+ Handle PUT requests to update a Template instance.
+ """
+ partial = kwargs.pop('partial', False)
+ instance = self.get_object(*args, **kwargs)
+
+ serializer = self.get_serializer(instance, data=request.data, partial=partial)
+ try:
+ serializer.is_valid(raise_exception=True)
+ except serializers.ValidationError as e:
+ if isinstance(e.detail, dict):
+ detail = {k: v for k, v in e.detail.items() if k not in ('brands', 'entity_class')}
+ if len(detail) > 0:
+ raise serializers.ValidationError(detail=detail)
+ except Exception as e:
+ raise e
+
+ data = serializer.data
+ data = self.get_serializer().validate(data)
+ instance.__dict__.update(**data)
+ instance.entity_class = data.get('entity_class')
+ instance.save()
+
+ return Response(self.get_serializer(instance).data)
+
+ def post(self, request, *args, **kwargs):
+ """
+ Handle POST requests to create a new Template instance.
+ """
+ serializer = self.get_serializer(data=request.data)
+ try:
+ serializer.is_valid(raise_exception=True)
+ except serializers.ValidationError as e:
+ if isinstance(e.detail, dict):
+ detail = {k: v for k, v in e.detail.items() if k not in ('brands', 'entity_class')}
+ if len(detail) > 0:
+ raise serializers.ValidationError(detail=detail)
+ except Exception as e:
+ raise e
+
+ data = serializer.data
+ data = self.get_serializer().validate(data)
+
+ instance, _ = self.model.objects.get_or_create(**data)
+ return Response(self.get_serializer(instance).data)
diff --git a/CodeListLibrary_project/clinicalcode/views/dashboard/targets/UserTarget.py b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/UserTarget.py
new file mode 100644
index 000000000..3e50cf6ee
--- /dev/null
+++ b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/UserTarget.py
@@ -0,0 +1,349 @@
+"""Brand Dashboard: API endpoints relating to default Django User model"""
+from django.conf import settings
+from rest_framework import status, serializers, exceptions
+from django.db.models import Q, F
+from django.core.mail import EmailMultiAlternatives
+from django.utils.http import urlsafe_base64_encode
+from django.contrib.auth import get_user_model
+from django.utils.encoding import force_bytes
+from django.core.paginator import EmptyPage, Paginator, Page
+from django.template.loader import render_to_string
+from rest_framework.response import Response
+from django.utils.regex_helper import _lazy_re_compile
+from django.contrib.auth.models import Group
+from django.contrib.auth.tokens import default_token_generator
+
+import re
+import uuid
+import logging
+
+from .BaseTarget import BaseSerializer, BaseEndpoint
+from clinicalcode.entity_utils import constants, gen_utils, model_utils, email_utils, permission_utils
+
+
+logger = logging.getLogger(__name__)
+
+
+User = get_user_model()
+
+
+class UserSerializer(BaseSerializer):
+ """Responsible for serialising the `User` model and to handle PUT/POST validation"""
+
+ # Const
+ MODERATOR_GROUP = Group.objects.get(name__iexact='Moderators')
+ EMAIL_PATTERN = _lazy_re_compile(r'\b([^\s\/@:"]+)(?<=[\w])@(\S+)\.([\w]+)\b', re.MULTILINE | re.IGNORECASE)
+
+ # Fields
+ is_moderator = serializers.BooleanField(default=False, initial=False, help_text='Specifies whether this User is a global moderator (unrelated to organisations/sites)')
+
+ # Appearance
+ _str_display = 'username'
+ _list_fields = ['id', 'username', 'first_name', 'last_name']
+ _item_fields = ['id', 'username', 'first_name', 'last_name', 'email', 'date_joined', 'last_login']
+ _features = {
+ 'create': {
+ 'note': (
+ 'Please note that creating a new User account will send an e-mail to the specified address. '
+ 'The e-mail will contain a link for the User to follow, prompting them to enter their password.\n\n'
+ 'Beware that the e-mail might appear in the User\'s spam folder, please recommend this as a resolution if the e-mail cannot be found.'
+ )
+ },
+ 'update': {
+ 'actionbar': ['reset_pwd'],
+ },
+ }
+
+ # Metadata
+ class Meta:
+ model = User
+ fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_moderator']
+ extra_kwargs = {
+ # RO
+ 'id': { 'read_only': True, 'required': False },
+ 'last_login': { 'read_only': True, 'required': False },
+ 'date_joined': { 'read_only': True, 'required': False },
+ # WO
+ 'groups': { 'write_only': True, 'required': False },
+ 'password': { 'write_only': True },
+ 'is_superuser': { 'read_only': True, 'required': False },
+ 'is_staff': { 'write_only': True, 'required': False },
+ 'is_active': { 'write_only': True, 'required': False },
+ 'user_permissions': { 'write_only': True, 'required': False },
+ # RO | WO
+ 'email': { 'required': True },
+ }
+
+ # GET
+ def to_representation(self, instance):
+ data = super(UserSerializer, self).to_representation(instance)
+ data.update({
+ 'is_moderator': permission_utils.is_member(instance, 'Moderators'),
+ 'is_superuser': instance.is_superuser,
+ })
+ return data
+
+ def resolve_format(self):
+ return { 'type': 'ForeignKey' }
+
+ def resolve_options(self):
+ brand = self._get_brand()
+
+ records = None
+ if brand is not None:
+ vis_rules = brand.get_vis_rules()
+ if isinstance(vis_rules, dict):
+ allow_null = vis_rules.get('allow_null')
+ allowed_brands = vis_rules.get('ids')
+ if isinstance(allowed_brands, list) and isinstance(allow_null, bool) and allow_null:
+ records = User.objects.filter(Q(accessible_brands__id__isnull=True) | Q(accessible_brands__id__in=allowed_brands))
+ elif isinstance(allowed_brands, list):
+ records = User.objects.filter(accessible_brands__id__in=allowed_brands)
+ elif isinstance(allow_null, bool) and allow_null:
+ records = User.objects.filter(Q(accessible_brands__id__isnull=True) | Q(accessible_brands__id__in=[brand.id]))
+
+ if records is None:
+ records = User.objects.filter(accessible_brands__id=brand.id)
+ else:
+ records = User.objects.all()
+
+ if records is None:
+ return list()
+
+ return list(records.annotate(name=F('username')).values('name', 'pk'))
+
+ def send_pwd_email(self, request, user):
+ if not isinstance(user, User) or not hasattr(user, 'id') or not isinstance(user.id, int):
+ raise exceptions.APIException('User model must be saved before sending the reset e-mail.')
+
+ brand = self._get_brand()
+ email = getattr(user, 'email')
+ username = getattr(user, 'username')
+ logger.info(f'Sending DashPWD Reset, with target: User')
+
+ if not isinstance(email, str) or gen_utils.is_empty_string(email) or not self.EMAIL_PATTERN.match(email):
+ raise exceptions.APIException(f'Failed to match e-mail pattern for User\'s known e-mail: {email}')
+
+ user_pk_bytes = force_bytes(User._meta.pk.value_to_string(user))
+ brand_title = model_utils.try_get_brand_string(brand, 'site_title', default='Concept Library')
+
+ email_subject = f'{brand_title} - Account Invite'
+ email_content = render_to_string(
+ 'clinicalcode/email/account_inv_email.html',
+ {
+ 'uid': urlsafe_base64_encode(user_pk_bytes),
+ 'token': default_token_generator.make_token(user),
+ 'username': username,
+ },
+ request=request
+ )
+
+ if not settings.IS_DEVELOPMENT_PC or settings.HAS_MAILHOG_SERVICE:
+ try:
+ branded_imgs = email_utils.get_branded_email_images(brand)
+
+ msg = EmailMultiAlternatives(
+ email_subject,
+ email_content,
+ settings.DEFAULT_FROM_EMAIL,
+ to=[email]
+ )
+ msg.content_subtype = 'related'
+ msg.attach_alternative(email_content, 'text/html')
+
+ msg.attach(email_utils.attach_image_to_email(branded_imgs.get('apple', 'img/email_images/apple-touch-icon.jpg'), 'mainlogo'))
+ msg.attach(email_utils.attach_image_to_email(branded_imgs.get('logo', 'img/email_images/combine.jpg'), 'sponsors'))
+ msg.send()
+ except Exception as e:
+ raise exceptions.APIException(f'Failed to send emails to:\n- Targets: {email}\n-Error: {str(e)}')
+ else:
+ logger.info(f'Successfully sent DashPWD email with target: User')
+ return True
+ else:
+ logger.info(f'[DEMO] Successfully sent DashPWD email with target: User')
+ return True
+
+ # POST / PUT
+ def create(self, validated_data):
+ request = self.context.get('request')
+
+ validated_data.update({
+ 'is_staff': False,
+ 'is_active': True,
+ 'is_superuser': False,
+ 'password': uuid.uuid4(),
+ })
+
+ is_mod = validated_data.pop('is_moderator', False)
+ user = self._create(self.Meta.model, validated_data)
+ user.save()
+
+ brand = self._get_brand()
+ if brand is not None:
+ rel = brand.users.filter(id=user.id)
+ if not rel.exists():
+ brand.users.add(user)
+
+ if isinstance(is_mod, bool) and is_mod:
+ user.groups.add(self.MODERATOR_GROUP)
+
+ self.send_pwd_email(request, user)
+ return user
+
+ def update(self, instance, validated_data):
+ brand = self._get_brand()
+
+ is_mod = validated_data.pop('is_moderator', False)
+ user = self._update(instance, validated_data)
+ user.save()
+
+ if brand is not None:
+ rel = brand.users.filter(id=user.id)
+ if not rel.exists():
+ brand.users.add(user)
+
+ if isinstance(is_mod, bool):
+ if is_mod:
+ user.groups.add(self.MODERATOR_GROUP)
+ elif user.groups.filter(pk=self.MODERATOR_GROUP.pk).exists():
+ user.groups.remove(self.MODERATOR_GROUP)
+
+ return user
+
+ # Instance & Field validation
+ def validate(self, data):
+ instance = getattr(self, 'instance') if hasattr(self, 'instance') else None
+
+ email = data.get('email', instance.email if instance is not None else None)
+ if not isinstance(email, str):
+ raise exceptions.ValidationError({
+ 'email': f'Failed to match e-mail pattern for User\'s known e-mail: {email}'
+ })
+
+ if gen_utils.is_empty_string(email) or not self.EMAIL_PATTERN.match(email):
+ raise exceptions.ValidationError({
+ 'email': f'Failed to match e-mail regex pattern for User\'s known e-mail'
+ })
+
+ data.update(email=email)
+ return data
+
+
+class UserEndpoint(BaseEndpoint):
+ """Responsible for API views relating to `User` model accessed via Brand dashboard"""
+
+ # Metadata
+ model = User
+ fields = []
+ queryset = User.objects.all()
+ serializer_class = UserSerializer
+
+ # View behaviour
+ reverse_name_default = 'brand_user_target'
+ reverse_name_retrieve = 'brand_user_target_with_id'
+
+ # Endpoint methods
+ def get(self, request, *args, **kwargs):
+ inst_id = kwargs.get('pk', None)
+ if inst_id:
+ inst_id = gen_utils.try_value_as_type(inst_id, 'int')
+ if inst_id is None:
+ return Response(
+ data={ 'detail': 'Expected int-like `pk` parameter' },
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ kwargs.update(pk=inst_id)
+ return self.retrieve(request, *args, **kwargs)
+
+ return self.list(request, *args, **kwargs)
+
+ def put(self, request, *args, **kwargs):
+ target = request.headers.get('X-Target', None)
+ if isinstance(target, str) and target.lower() == 'reset_pwd':
+ instance = self.get_object(*args, **kwargs)
+ if isinstance(instance, User) and not instance.is_superuser:
+ self.get_serializer().send_pwd_email(request, instance)
+ return Response({ 'sent': True, 'user_id': instance.id })
+
+ return self.update(request, *args, **kwargs)
+
+ def post(self, request, *args, **kwargs):
+ return self.create(request, *args, **kwargs)
+
+ # Override queryset
+ def list(self, request, *args, **kwargs):
+ params = self._get_query_params(request)
+
+ page = gen_utils.try_value_as_type(params.get('page'), 'int', default=1)
+ page = max(page, 1)
+
+ page_size = params.get('page_size', '1')
+ if page_size not in constants.PAGE_RESULTS_SIZE:
+ page_size = constants.PAGE_RESULTS_SIZE.get('1')
+ else:
+ page_size = constants.PAGE_RESULTS_SIZE.get(page_size)
+
+ records = self.get_queryset(request, **params)
+ if records is None:
+ page_obj = Page(User.objects.none(), 0, Paginator([], page_size, allow_empty_first_page=True))
+ else:
+ records = records.order_by('id')
+ pagination = Paginator(records, page_size, allow_empty_first_page=True)
+ try:
+ page_obj = pagination.page(page)
+ except EmptyPage:
+ page_obj = pagination.page(pagination.num_pages)
+
+ results = self.serializer_class(page_obj.object_list, many=True)
+ response = self._format_list_data({
+ 'detail': self._format_page_details(page_obj),
+ 'results': results.data,
+ })
+
+ self._format_list_data(response)
+ return Response(response)
+
+ def get_queryset(self, *args, **kwargs):
+ request = self.request
+
+ params = getattr(self, 'filter', None)
+ if isinstance(params, dict):
+ params = kwargs | params
+ else:
+ params = kwargs
+
+ brand = model_utils.try_get_brand(request)
+ records = None
+ if brand is not None:
+ vis_rules = brand.get_vis_rules()
+ if isinstance(vis_rules, dict):
+ allow_null = vis_rules.get('allow_null')
+ allowed_brands = vis_rules.get('ids')
+ if isinstance(allowed_brands, list) and isinstance(allow_null, bool) and allow_null:
+ records = User.objects.filter(Q(accessible_brands__id__isnull=True) | Q(accessible_brands__id__in=allowed_brands))
+ elif isinstance(allowed_brands, list):
+ records = User.objects.filter(accessible_brands__id__in=allowed_brands)
+ elif isinstance(allow_null, bool) and allow_null:
+ records = User.objects.filter(Q(accessible_brands__id__isnull=True) | Q(accessible_brands__id__in=[brand.id]))
+
+ if records is None:
+ records = User.objects.filter(accessible_brands__id=brand.id)
+ else:
+ records = User.objects.all()
+
+ search = params.get('search', None)
+ query = gen_utils.parse_model_field_query(User, params, ignored_fields=['password'])
+
+ if query is not None:
+ records = records.filter(**query)
+
+ if not gen_utils.is_empty_string(search) and len(search) >= 3:
+ records = records.filter(
+ Q(username__icontains=search) | \
+ Q(first_name__icontains=search) | \
+ Q(last_name__icontains=search) | \
+ Q(email__icontains=search)
+ )
+
+ return records
diff --git a/CodeListLibrary_project/clinicalcode/views/dashboard/targets/__init__.py b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/CodeListLibrary_project/clinicalcode/views/site.py b/CodeListLibrary_project/clinicalcode/views/site.py
index 777a358ca..d54f0999a 100644
--- a/CodeListLibrary_project/clinicalcode/views/site.py
+++ b/CodeListLibrary_project/clinicalcode/views/site.py
@@ -11,40 +11,30 @@
@require_GET
def robots_txt(request):
- if not(settings.IS_HDRUK_EXT == "1" or settings.IS_DEVELOPMENT_PC):
+ if settings.IS_HDRUK_EXT != "1" or not settings.IS_DEVELOPMENT_PC:
raise PermissionDenied
-
-
+
lines = [
"User-Agent: *",
"Allow: /",
]
-
- # sitemap
- # site = "https://conceptlibrary.saildatabank.com"
- # if settings.IS_HDRUK_EXT == "1":
- # site = "https://phenotypes.healthdatagateway.org"
- #
- # lines += ["Sitemap22: " + site + "/sitemap.xml"]
lines += ["Sitemap: " + request.build_absolute_uri(reverse('concept_library_home')).replace('http://' , 'https://') + "sitemap.xml"]
-
return HttpResponse("\n".join(lines), content_type="text/plain")
@require_GET
def get_sitemap(request):
- if not(settings.IS_HDRUK_EXT == "1" or settings.IS_DEVELOPMENT_PC):
+ if settings.IS_HDRUK_EXT != "1" or not settings.IS_DEVELOPMENT_PC:
raise PermissionDenied
links = [
(request.build_absolute_uri(reverse('concept_library_home')), cur_time, "1.00"),
(request.build_absolute_uri(reverse('concept_library_home2')), cur_time, "1.00"),
- (request.build_absolute_uri(reverse('search_phenotypes')), cur_time, "1.00"),
+ (request.build_absolute_uri(reverse('search_entities')), cur_time, "1.00"),
(request.build_absolute_uri(reverse('reference_data')), cur_time, "1.00"),
(request.build_absolute_uri(reverse('login')), cur_time, "1.00"),
]
-
-
+
# About pages
# brand/main about pages
if settings.IS_HDRUK_EXT == "1" or settings.IS_DEVELOPMENT_PC:
@@ -56,24 +46,15 @@ def get_sitemap(request):
('https://phenotypes.healthdatagateway.org/about/hdruk_about_publications/', cur_time, "0.80"),
('https://phenotypes.healthdatagateway.org/about/breathe/', cur_time, "0.80"),
('https://phenotypes.healthdatagateway.org/about/bhf_data_science_centre/', cur_time, "0.80"),
- # ('https://phenotypes.healthdatagateway.org/about/eurolinkcat/', cur_time, "0.80"),
-
+ ('https://phenotypes.healthdatagateway.org/about/eurolinkcat/', cur_time, "0.80"),
]
-
- # # privacy /terms /cookies
- # links += [
- # (request.build_absolute_uri(reverse('cookies_settings')), cur_time, "0.40"),
- # (request.build_absolute_uri(reverse('terms')), cur_time, "0.40"),
- # (request.build_absolute_uri(reverse('privacy_and_cookie_policy')), cur_time, "0.40"),
- # (request.build_absolute_uri(reverse('technical_documentation')), cur_time, "0.40"),
- # ]
# contact us page
if not settings.CLL_READ_ONLY:
links += [
(request.build_absolute_uri(reverse('contact_us')), cur_time, "1.00"),
]
-
+
# API
links += [
(request.build_absolute_uri(reverse('api:root')), cur_time, "1.00"),
@@ -84,9 +65,8 @@ def get_sitemap(request):
]
# add links of published concepts/phenotypes
- #if settings.CURRENT_BRAND != "":
links += get_published_phenotypes_and_concepts(request)
-
+
links_str = """
""" + t[2] + """
"""
-
- links_str += " "
-
+ links_str += ""
return HttpResponse(links_str, content_type="application/xml")
diff --git a/CodeListLibrary_project/cll/settings.py b/CodeListLibrary_project/cll/settings.py
index e5de1c4ce..bab7c5d31 100644
--- a/CodeListLibrary_project/cll/settings.py
+++ b/CodeListLibrary_project/cll/settings.py
@@ -104,7 +104,8 @@ def get_env_value(env_variable, cast=None, default=Symbol('None')):
''' Application base '''
APP_TITLE = 'Concept Library'
-APP_DESC = 'The {app_title} is a system for storing, managing, sharing, and documenting clinical code lists in health research.'
+APP_DESC = 'The {app_title} is a system for storing, managing, sharing, and documenting clinical codelists in health research.'
+APP_CITATION = 'Users should cite the {app_title} in all publications, presentations and reports as follows: “{brand_name} {app_title}, website: {brand_website} . ”'
APP_LOGO_PATH = 'img/'
APP_EMBED_ICON = '{logo_path}embed_img.png'
INDEX_PATH = 'clinicalcode/index.html'
@@ -138,7 +139,10 @@ def get_env_value(env_variable, cast=None, default=Symbol('None')):
''' Application variables '''
-# separate settings for different environments
+# Test-related config
+REMOTE_TEST = get_env_value('REMOTE_TEST', cast='bool', default=False)
+
+# Env config
IS_DEMO = get_env_value('IS_DEMO', cast='bool')
CLINICALCODE_SESSION_ID = 'concept'
@@ -162,7 +166,7 @@ def get_env_value(env_variable, cast=None, default=Symbol('None')):
# Allowed application hots
ALLOWED_HOSTS = [i.strip() for i in get_env_value('ALLOWED_HOSTS').split(',')]
-ROOT_URLCONF = 'cll.urls'
+ROOT_URLCONF = 'cll.urls_brand'
DATA_UPLOAD_MAX_MEMORY_SIZE = None
# Setup support for proxy headers
@@ -197,9 +201,23 @@ def get_env_value(env_variable, cast=None, default=Symbol('None')):
## Brand related settings
IS_HDRUK_EXT = '0'
+BRAND_OBJECT = {}
CURRENT_BRAND = ''
CURRENT_BRAND_WITH_SLASH = ''
-BRAND_OBJECT = {}
+
+## Brand variant URL resolver overrides
+BRAND_VAR_REFERENCE = {
+ 'default': {
+ 'urls': {
+ 'phenotypes': 'phenotypes',
+ },
+ },
+ 'HDRN': {
+ 'urls': {
+ 'phenotypes': 'concepts',
+ },
+ }
+}
## Graph settings
GRAPH_MODELS = {
@@ -271,24 +289,27 @@ def get_env_value(env_variable, cast=None, default=Symbol('None')):
]
INSTALLED_APPS = INSTALLED_APPS + [
+ # Base
'django.contrib.postgres',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
+ # Apps
'clinicalcode',
'cll',
+ # Extensions
'simple_history',
- 'rest_framework',
- # 'mod_wsgi.server',
'markdownify.apps.MarkdownifyConfig',
'cookielaw',
+ # API
+ 'drf_yasg',
+ 'rest_framework',
+ # Site
'django_celery_results',
'django_celery_beat',
- # 'rest_framework_swagger',
- 'drf_yasg',
- 'django.contrib.sitemaps',
+ 'django.contrib.sitemaps', # 'mod_wsgi.server',
# SCSS
'sass_processor',
# Compressor
@@ -297,7 +318,7 @@ def get_env_value(env_variable, cast=None, default=Symbol('None')):
'django_minify_html',
]
-if not CLL_READ_ONLY and not IS_GATEWAY_PC:
+if not CLL_READ_ONLY and not IS_GATEWAY_PC and not REMOTE_TEST:
INSTALLED_APPS += [
# Engagelens-related
'easyaudit'
@@ -305,7 +326,6 @@ def get_env_value(env_variable, cast=None, default=Symbol('None')):
# ==============================================================================#
-
''' Middleware '''
MIDDLEWARE = [
@@ -327,9 +347,11 @@ def get_env_value(env_variable, cast=None, default=Symbol('None')):
'clinicalcode.middleware.brands.BrandMiddleware',
# Handle user session expiry
'clinicalcode.middleware.sessions.SessionExpiryMiddleware',
+ # Handle exceptions
+ 'clinicalcode.middleware.exceptions.ExceptionMiddleware',
]
-if not CLL_READ_ONLY and not IS_GATEWAY_PC:
+if not CLL_READ_ONLY and not IS_GATEWAY_PC and not REMOTE_TEST:
MIDDLEWARE += [
# Engagelens-related
'easyaudit.middleware.easyaudit.EasyAuditMiddleware',
@@ -341,7 +363,7 @@ def get_env_value(env_variable, cast=None, default=Symbol('None')):
# Keep ModelBackend around for per-user permissions and a local superuser.
# Don't check AD on development PCs due to network connection
-if IS_DEVELOPMENT_PC or (not ENABLE_LDAP_AUTH):
+if IS_DEVELOPMENT_PC or not ENABLE_LDAP_AUTH:
AUTHENTICATION_BACKENDS = [
# 'django_auth_ldap.backend.LDAPBackend',
'django.contrib.auth.backends.ModelBackend',
@@ -425,7 +447,6 @@ def get_env_value(env_variable, cast=None, default=Symbol('None')):
'svg': 'clinicalcode.templatetags.svg',
'breadcrumbs': 'clinicalcode.templatetags.breadcrumbs',
'entity_renderer': 'clinicalcode.templatetags.entity_renderer',
- 'detail_pg_renderer': 'clinicalcode.templatetags.detail_pg_renderer',
}
},
},
@@ -521,9 +542,13 @@ def get_env_value(env_variable, cast=None, default=Symbol('None')):
},
},
'loggers': {
+ '': {
+ 'level': 'INFO',
+ 'handlers': ['console'],
+ },
'django': {
'handlers': ['console'],
- 'level': os.getenv('DJANGO_LOG_LEVEL', 'INFO'),
+ 'level': 'INFO',
},
},
}
@@ -567,12 +592,46 @@ def get_env_value(env_variable, cast=None, default=Symbol('None')):
# ==============================================================================#
+''' Easyaudit settings '''
+
+# Ignores `Request` event signals in favour of custom implementation
+#
+# Note:
+# - See custom override setting(s) below, prefixed `OVERIDE_EASY_AUDIT_*``
+#
+DJANGO_EASY_AUDIT_WATCH_REQUEST_EVENTS = False
+
+# Overrides `Request` event signal URL registration
+OVERRIDE_EASY_AUDIT_IGNORE_URLS = {
+ # The following URL patterns will be ignored for all branded sites
+ 'all_brands': [
+ # Ignore non-consumer usage
+ r'^/admin/',
+ r'^/adminTemp/',
+ r'^/dashboard/',
+
+ # Ignore healthchecks
+ r'^/api/v1/health'
+
+ # Ignore bots & crawlers
+ r'^/sitemap.xml',
+ r'^/robots.txt',
+
+ # Ignore static file requests
+ r'^/media/',
+ r'^/static/',
+ r'^/favicon.ico',
+ ],
+}
+
+# ==============================================================================#
+
''' Installed application settings '''
# General settings
## Django auth settings -> Redirect to home URL after login (Default redirects to /accounts/profile/)
-LOGIN_REDIRECT_URL = reverse_lazy('search_phenotypes')
+LOGIN_REDIRECT_URL = reverse_lazy('search_entities')
LOGIN_URL = reverse_lazy('login')
LOGOUT_URL = reverse_lazy('logout')
@@ -644,6 +703,9 @@ def get_env_value(env_variable, cast=None, default=Symbol('None')):
## Celery beat settings
CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler'
+## Task queue
+ENABLE_DEMO_TASK_QUEUE = get_env_value('ENABLE_DEMO_TASK_QUEUE', cast='bool', default=False)
+
## Swagger settings
## SWAGGER_SETTINGS = { 'JSON_EDITOR': True, }
SWAGGER_TITLE = 'Concept Library API'
diff --git a/CodeListLibrary_project/cll/static/img/Footer_logos/HDRN_logo.png b/CodeListLibrary_project/cll/static/img/Footer_logos/HDRN_logo.png
new file mode 100644
index 000000000..e0b1f14f4
Binary files /dev/null and b/CodeListLibrary_project/cll/static/img/Footer_logos/HDRN_logo.png differ
diff --git a/CodeListLibrary_project/cll/static/img/brands/HDRN/apple-touch-icon.png b/CodeListLibrary_project/cll/static/img/brands/HDRN/apple-touch-icon.png
new file mode 100644
index 000000000..06bc556c0
Binary files /dev/null and b/CodeListLibrary_project/cll/static/img/brands/HDRN/apple-touch-icon.png differ
diff --git a/CodeListLibrary_project/cll/static/img/brands/HDRN/favicon-32x32.png b/CodeListLibrary_project/cll/static/img/brands/HDRN/favicon-32x32.png
new file mode 100644
index 000000000..600951d71
Binary files /dev/null and b/CodeListLibrary_project/cll/static/img/brands/HDRN/favicon-32x32.png differ
diff --git a/CodeListLibrary_project/cll/static/img/brands/HDRN/header_logo.png b/CodeListLibrary_project/cll/static/img/brands/HDRN/header_logo.png
new file mode 100644
index 000000000..e0b1f14f4
Binary files /dev/null and b/CodeListLibrary_project/cll/static/img/brands/HDRN/header_logo.png differ
diff --git a/CodeListLibrary_project/cll/static/img/brands/HDRN/logo-transparent.png b/CodeListLibrary_project/cll/static/img/brands/HDRN/logo-transparent.png
new file mode 100644
index 000000000..4ec7c94d2
Binary files /dev/null and b/CodeListLibrary_project/cll/static/img/brands/HDRN/logo-transparent.png differ
diff --git a/CodeListLibrary_project/cll/static/img/brands/HDRN/logo.jpg b/CodeListLibrary_project/cll/static/img/brands/HDRN/logo.jpg
new file mode 100644
index 000000000..3d29ca306
Binary files /dev/null and b/CodeListLibrary_project/cll/static/img/brands/HDRN/logo.jpg differ
diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/accessibility.js b/CodeListLibrary_project/cll/static/js/clinicalcode/accessibility.js
index cdb3ceb1c..9a3bb3a6b 100644
--- a/CodeListLibrary_project/cll/static/js/clinicalcode/accessibility.js
+++ b/CodeListLibrary_project/cll/static/js/clinicalcode/accessibility.js
@@ -1,13 +1,3 @@
-/**
- * CL_ACCESSIBILITY_KEYS
- * @desc Keycodes used for accessibility on elements
- * that are not necessarily meant to be accessible by default
- */
-const CL_ACCESSIBILITY_KEYS = {
- // Enter, to activate click elements via accessibility readers
- 'ENTER': 13,
-}
-
/**
* Main thread
* @desc listens to key events to see if the user has utilised any accessibility keys,
@@ -28,8 +18,8 @@ domReady.finally(() => {
document.addEventListener('keydown', e => {
const elem = document.activeElement;
- const code = e.keyIdentifier || e.which || e.keyCode;
- if (code !== CL_ACCESSIBILITY_KEYS.ENTER) {
+ const code = e.code;
+ if (!code.endsWith('Enter') || elem.disabled) {
return;
}
@@ -37,14 +27,14 @@ domReady.finally(() => {
elem.click();
} else if (elem.matches('[role="dropdown"]')) {
const radio = elem.querySelector('input[type="radio"]');
- if (radio) {
+ if (radio && !radio.disabled) {
radio.checked = !radio.checked;
}
} else if (elem.matches('[type="checkbox"]')) {
- elem.checked = !elem.checked;
+ elem.click();
} else if (elem.matches('[role="collapsible"]')) {
const collapsible = elem.querySelector('input[type="checkbox"]');
- if (collapsible) {
+ if (collapsible && !collapsible.disabled) {
collapsible.checked = !collapsible.checked;
}
}
diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/autocomplete.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/autocomplete.js
new file mode 100644
index 000000000..a1f986db3
--- /dev/null
+++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/autocomplete.js
@@ -0,0 +1,328 @@
+/**
+ * Class wrapping & managing an autocomplete component
+ *
+ * @note can be combined with `components/fuzzyQuery.js` for FTS
+ *
+ * @example
+ * const options = [{ id: 1, term: 'hello' }, { id: 2, term: 'world' }];
+ *
+ * const element = new Autocomplete({
+ * rootNode: document.querySelector('.autocomplete-container'),
+ * inputNode: document.querySelector('.autocomplete-input'),
+ * resultsNode: document.querySelector('.autocomplete-results'),
+ * searchFn: (input) => {
+ * if (input.length <= 3) {
+ * return [];
+ * }
+ *
+ * return options.filter(x => x.term.toLocaleLowerCase().startsWith(input.toLocaleLowerCase()));
+ * },
+ * });
+ *
+ * @class
+ * @constructor
+ */
+export class Autocomplete {
+ /**
+ * @desc a set of `aria-autocomplete` values specifying inline autocomplete behaviour
+ * @see {@link https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-autocomplete|aria-autocomplete}
+ * @type {Array}
+ * @static
+ * @private
+ */
+ static #AriaAutocompleteInline = ['both', 'inline'];
+
+ /**
+ * @desc a set of disposable functions to clean up this class, executed on class disposal
+ * @type {Array}
+ * @private
+ */
+ #disposables = [];
+
+ /**
+ * @param {object} param0 constructor args
+ * @param {HTMLElement} param0.rootNode the autocomplete component container element
+ * @param {HTMLElement} param0.inputNode the autocomplete text input element
+ * @param {HTMLElement} param0.resultsNode the autocomplete results dropdown container element
+ * @param {Function} param0.searchFn a function to evaluate the search term, returning an array matching the specified input query
+ * @param {boolean} [param0.shouldAutoSelect=false] optionally specify whether to automatically select the element; defaults to `false`
+ * @param {boolean|null} [param0.shouldAutoInline=null] optionally specify whether to provide inline autocomplete, this _will_ be derived from the element `aria-autocomplete` tag if not specified; defaults to `null` (i.e. false unless element is tagged as such)
+ * @param {Function} [param0.onShow=Function] optionally specify a function to be called when the results are shown; defaults to a nullable callable
+ * @param {Function} [param0.onHide=Function] optionally specify a function to be called when the results are hidden; defaults to a nullable callable
+ */
+ constructor({
+ rootNode,
+ inputNode,
+ resultsNode,
+ searchFn,
+ shouldAutoSelect = false,
+ shouldAutoInline = null,
+ onShow = () => {},
+ onHide = () => {},
+ }) {
+ this.rootNode = rootNode;
+ this.inputNode = inputNode;
+ this.resultsNode = resultsNode;
+ this.searchFn = searchFn;
+ this.shouldAutoSelect = shouldAutoSelect;
+ this.onShow = onShow;
+ this.onHide = onHide;
+ this.activeIndex = -1;
+ this.resultsCount = 0;
+ this.showResults = false;
+
+ if (typeof shouldAutoInline !== 'boolean') {
+ const ariaAutocomplete = this.inputNode.getAttribute('aria-autocomplete');
+ this.hasInlineAutocomplete = stringHasChars(ariaAutocomplete)
+ ? Autocomplete.#AriaAutocompleteInline.includes(ariaAutocomplete.toLowerCase())
+ : false;
+ } else {
+ this.hasInlineAutocomplete = shouldAutoInline;
+ }
+ this.inputNode.setAttribute('aria-autocomplete', shouldAutoInline ? 'both' : 'list');
+
+ // Setup events
+ const focusHnd = this.#handleFocus.bind(this);
+ const keyUpHnd = this.#handleKeyup.bind(this);
+ const keyDownHnd = this.#handleKeydown.bind(this);
+ const resClickHnd = this.#handleResultClick.bind(this);
+ const docClickHnd = this.#handleDocumentClick.bind(this);
+ document.body.addEventListener('click', docClickHnd);
+ this.resultsNode.addEventListener('click', resClickHnd);
+ this.inputNode.addEventListener('focus', focusHnd);
+ this.inputNode.addEventListener('keyup', keyUpHnd);
+ this.inputNode.addEventListener('keydown', keyDownHnd);
+
+ // Cleanup
+ this.#disposables.push(() => {
+ document.body.removeEventListener('click', docClickHnd);
+ this.resultsNode.removeEventListener('click', resClickHnd);
+ this.inputNode.removeEventListener('focus', focusHnd);
+ this.inputNode.removeEventListener('keyup', keyUpHnd);
+ this.inputNode.removeEventListener('keydown', keyDownHnd);
+ });
+ }
+
+
+ /*************************************
+ * *
+ * Public *
+ * *
+ *************************************/
+
+ getItemAt(index) {
+ return this.resultsNode.querySelector(`#autocomplete-result-${index}`);
+ }
+
+ selectItem(node) {
+ if (node) {
+ this.inputNode.value = node.innerText;
+ this.hideResults();
+ }
+ }
+
+ checkSelection() {
+ if (this.activeIndex < 0) {
+ return;
+ }
+
+ const activeItem = this.getItemAt(this.activeIndex);
+ this.selectItem(activeItem);
+ }
+
+ autocompleteItem() {
+ const autocompletedItem = this.resultsNode.querySelector('.selected');
+ const input = this.inputNode.value;
+ if (!autocompletedItem || !input) {
+ return;
+ }
+
+ const autocomplete = autocompletedItem.innerText;
+ if (input !== autocomplete) {
+ this.inputNode.value = autocomplete;
+ this.inputNode.setSelectionRange(input.length, autocomplete.length);
+ }
+ }
+
+ updateResults() {
+ const input = this.inputNode.value;
+ const results = this.searchFn(input);
+ this.hideResults();
+
+ if (results.length === 0) {
+ return;
+ }
+
+ clearAllChildren(this.resultsNode);
+
+ for (let index = 0; index < results.length; ++index) {
+ const result = results[index];
+ const isSelected = this.shouldAutoSelect && index === 0;
+ if (isSelected) {
+ this.activeIndex = 0;
+ }
+
+ createElement('li', {
+ text: String(result),
+ class: `autocomplete-result${isSelected ? ' selected' : ''}`,
+ attr: { id: `autocomplete-result-${index}` },
+ aria: { selected: !!isSelected },
+ parent: this.resultsNode,
+ });
+ }
+
+ this.resultsNode.classList.remove('hidden');
+ this.rootNode.setAttribute('aria-expanded', true);
+ this.resultsCount = results.length;
+ this.shown = true;
+ this.onShow();
+ }
+
+ hideResults() {
+ this.shown = false;
+ this.activeIndex = -1;
+ this.resultsCount = 0;
+
+ this.resultsNode.classList.add('hidden');
+ this.rootNode.setAttribute('aria-expanded', 'false');
+ this.inputNode.setAttribute('aria-activedescendant', '');
+ clearAllChildren(this.resultsNode);
+
+ this.onHide();
+ }
+
+ dispose() {
+ let disposable;
+ for (let i = this.#disposables.length; i > 0; i--) {
+ disposable = this.#disposables.pop();
+ if (typeof disposable !== 'function') {
+ continue;
+ }
+
+ disposable();
+ }
+ }
+
+
+ /*************************************
+ * *
+ * Events *
+ * *
+ *************************************/
+ #handleDocumentClick(event) {
+ if (event.target === this.inputNode || this.rootNode.contains(event.target)) {
+ return;
+ }
+
+ this.hideResults();
+ }
+
+ #handleKeyup(event) {
+ const { code } = event
+ switch (code) {
+ case 'ArrowUp':
+ case 'ArrowDown':
+ case 'Escape':
+ case 'Enter':
+ event.preventDefault();
+ return;
+
+ default:
+ this.updateResults();
+ break;
+ }
+
+ if (!this.hasInlineAutocomplete || code === 'Backspace') {
+ return;
+ }
+
+ this.autocompleteItem();
+ }
+
+ #handleKeydown(event) {
+ let activeItem, activeIndex;
+ activeIndex = this.activeIndex;
+
+ const { code } = event;
+ if (code === 'Escape') {
+ this.hideResults();
+ this.inputNode.value = '';
+ return;
+ }
+
+ if (this.resultsCount < 1) {
+ if (!this.hasInlineAutocomplete || (code !== 'ArrowDown' & code !== 'ArrowUp')) {
+ return;
+ }
+
+ this.updateResults();
+ }
+
+ const prevActive = this.getItemAt(activeIndex);
+ switch (code) {
+ case 'ArrowUp':
+ if (activeIndex <= 0) {
+ activeIndex = this.resultsCount - 1;
+ } else {
+ activeIndex -= 1;
+ }
+ break;
+
+ case 'ArrowDown':
+ if (activeIndex === -1 || activeIndex >= this.resultsCount - 1) {
+ activeIndex = 0;
+ } else {
+ activeIndex += 1;
+ }
+ break;
+
+ case 'Enter':
+ activeItem = this.getItemAt(activeIndex);
+ this.selectItem(activeItem);
+ return;
+
+ case 'Tab':
+ this.checkSelection();
+ this.hideResults();
+ return;
+
+ default:
+ return;
+ }
+
+ event.preventDefault();
+ activeItem = this.getItemAt(activeIndex);
+ this.activeIndex = activeIndex;
+
+ if (prevActive) {
+ prevActive.classList.remove('selected');
+ prevActive.setAttribute('aria-selected', 'false');
+ }
+
+ if (activeItem) {
+ this.inputNode.setAttribute('aria-activedescendant', `autocomplete-result-${activeIndex}`);
+ activeItem.classList.add('selected');
+
+ if (activeItem.getAttribute('aria-selected') !== 'true') {
+ scrollContainerTo(this.resultsNode, activeItem);
+ }
+ activeItem.setAttribute('aria-selected', 'true');
+
+ if (this.hasInlineAutocomplete) {
+ this.inputNode.value = activeItem.innerText;
+ }
+ } else {
+ this.inputNode.setAttribute('aria-activedescendant', '');
+ }
+ }
+
+ #handleFocus(event) {
+ this.updateResults();
+ }
+
+ #handleResultClick(event) {
+ if (event.target && event.target.nodeName === 'LI') {
+ this.selectItem(event.target);
+ }
+ }
+};
diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/doubleRangeSlider.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/doubleRangeSlider.js
new file mode 100644
index 000000000..a70b5547e
--- /dev/null
+++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/doubleRangeSlider.js
@@ -0,0 +1,318 @@
+import { parseAsFieldType } from '../forms/entityCreator/utils.js';
+
+/**
+ * @class DoubleRangeSlider
+ * @desc handler for DoubleRangeSlider components
+ *
+ * @param {string|node} obj The ID of the input element or the input element itself
+ * @param {object} data Should describe the properties & validation assoc. with this component
+ *
+ * @return {object} An interface to control the behaviour of the component
+ */
+export default class DoubleRangeSlider {
+ /**
+ * @desc describes the types that can be managed by this slider
+ * @static
+ * @constant
+ */
+ static AllowedTypes = ['int', 'float', 'decimal', 'numeric', 'percentage'];
+
+ constructor(obj, data) {
+ if (typeof obj === 'string') {
+ this.id = id;
+ this.element = document.getElementById(id);
+ } else {
+ this.element = obj;
+ if (typeof this.element !== 'undefined') {
+ this.id = this.element.getAttribute('id');
+ }
+ }
+
+ this.value = null;
+ this.dirty = false;
+
+ this.data = data;
+ this.data.properties = DoubleRangeSlider.ComputeProperties(this.data?.properties);
+
+ this.#initialise();
+ }
+
+ /**
+ * @desc attempts to parse, validate, and compute the properties of the range slider
+ * @static
+ *
+ * @param {any} props the range properties, if available
+ *
+ * @returns {Record} the resulting range props
+ */
+ static ComputeProperties(props) {
+ props = isObjectType(props) ? props : { };
+
+ let valueType = props?.type;
+ let valueStep = typeof props?.step === 'string' ? Number(props?.step) : props.step;
+
+ let hasValueType = false;
+ let hasValidStep = typeof valueStep === 'number' && !isNaN(valueStep) && Number.isFinite(valueStep);
+ if (!isObjectType(props)) {
+ props = { min: 0, max: 100, step: 1, type: 'int' };
+ }
+
+ if (stringHasChars(valueType)) {
+ valueType = valueType.toLowerCase();
+ valueType = DoubleRangeSlider.AllowedTypes.includes(valueType) ? valueType : null;
+ hasValueType = typeof valueType === 'string';
+ }
+
+ let min = typeof props.min === 'string' ? Number(props.min) : props.min;
+ let max = typeof props.max === 'string' ? Number(props.max) : props.max;
+
+ const validMin = typeof min === 'number' && !isNaN(min) && Number.isFinite(min);
+ const validMax = typeof max === 'number' && !isNaN(max) && Number.isFinite(max);
+
+ if (!hasValidStep) {
+ if (hasValueType) {
+ valueStep = valueType === 'int' ? 1 : 0.1;
+ } else if (validMin) {
+ const precision = String(min).split('.')?.[1]?.length || 0;
+ valueStep = Math.pow(10, -precision);
+ } else {
+ valueStep = 1;
+ }
+ }
+
+ if (validMin && validMax) {
+ let tmp = Math.max(min, max);
+ min = Math.min(min, max);
+ max = tmp;
+ } else if (validMin) {
+ max = min + (hasValidStep ? valueStep : 1)*100;
+ } else if (validMax) {
+ min = max - (hasValidStep ? valueStep : 1)*100;
+ } else {
+ min = 0;
+ max = valueStep*100;
+ }
+ props.min = min;
+ props.max = max;
+
+ if (!hasValueType) {
+ let precision = hasValidStep ? step : (isNullOrUndefined(min) ? min : 0);
+ precision = String(precision).split('.')?.[1]?.length || 0;
+ valueType = precision === 0 ? 'int' : 'float';
+ }
+
+ props.type = valueType;
+ props.step = valueStep;
+ return props;
+ }
+
+ /*************************************
+ * *
+ * Getter *
+ * *
+ *************************************/
+ /**
+ * getValue
+ * @desc gets the current value of the component
+ * @returns {any} the value selected via its options data
+ */
+ getValue() {
+ return this.value;
+ }
+
+ /**
+ * getElement
+ * @returns {node} the assoc. element
+ */
+ getElement() {
+ return this.element;
+ }
+
+ /**
+ * isDirty
+ * @returns {bool} returns the dirty state of this component
+ */
+ isDirty() {
+ return this.dirty;
+ }
+
+ /*************************************
+ * *
+ * Setter *
+ * *
+ *************************************/
+ /**
+ * makeDirty
+ * @desc informs the top-level parent that we're dirty
+ * and updates our internal dirty state
+ * @return {object} return this for chaining
+ */
+ makeDirty() {
+ window.entityForm.makeDirty();
+ this.dirty = true;
+ return this;
+ }
+
+ /**
+ * setProgressBar
+ * @desc sets the current value of the component
+ * @param {any} value the value that should be selected from its list of options
+ */
+ setProgressBar() {
+ const distance = this.data.properties.max - this.data.properties.min;
+ const pos0 = (this.value.min / distance) * 100,
+ pos1 = (this.value.max / distance) * 100;
+
+ this.elements.progressBar.style.background = `
+ linear-gradient(
+ to right,
+ var(--color-accent-semi-transparent) ${pos0}%,
+ var(--color-accent-primary) ${pos0}%,
+ var(--color-accent-primary) ${pos1}%,
+ var(--color-accent-semi-transparent) ${pos1}%
+ )
+ `;
+ }
+
+ /**
+ * setValue
+ * @desc sets the current value of the component
+ * @param {any} value the value that should be selected from its list of options
+ */
+ setValue(value) {
+ const { min, max, type, step } = this.data.properties;
+ if (this.value) {
+ this.dirty = !value || value.min != this.value.min || this.max != this.value.max;
+ }
+
+ if (!isObjectType(value)) {
+ this.value = { min, max };
+ return this;
+ }
+
+ let { min: vmin, max: vmax } = value;
+ vmin = parseAsFieldType({ validation: { type }}, value.min);
+ vmax = parseAsFieldType({ validation: { type }}, value.max);
+
+ vmin = (!isNullOrUndefined(vmin) && !!vmin.success) ? vmin.value : min;
+ vmax = (!isNullOrUndefined(vmax) && !!vmax.success) ? vmax.value : max;
+ value = { min: vmin, max: vmax };
+
+ vmin = Math.min(value.min, value.max);
+ vmax = Math.max(value.min, value.max);
+
+ if (type === 'int') {
+ vmin = Math.trunc(vmin);
+ vmax = Math.trunc(vmax);
+ } else {
+ const m = Math.pow(10, String(step).split('.')?.[1]?.length || 0);
+ vmin = Math.round(vmin * m) / m;
+ vmax = Math.round(vmax * m) / m;
+ }
+
+ value.min = clampNumber(vmin, min, max);
+ value.max = clampNumber(vmax, min, max);
+ this.value = value;
+
+ this.elements.inputs.min.value = value.min;
+ this.elements.inputs.max.value = value.max;
+ this.elements.sliders.min.value = value.min;
+ this.elements.sliders.max.value = value.max;
+
+ this.setProgressBar();
+ fireChangedEvent(this.element);
+ return this;
+ }
+
+ /*************************************
+ * *
+ * Private *
+ * *
+ *************************************/
+ /**
+ * initialise
+ * @desc private method to initialise & render the component
+ */
+ #initialise() {
+ const progress = this.element.querySelector('#progress-bar');
+
+ const minValueInput = this.element.querySelector('#min-value');
+ minValueInput.min = this.data.properties.min;
+ minValueInput.max = this.data.properties.max;
+ minValueInput.step = this.data.properties.step;
+ minValueInput.value = this.data.properties.min;
+ minValueInput.addEventListener('blur', this.#onChangeCallback.bind(this));
+ minValueInput.addEventListener('change', this.#onChangeCallback.bind(this));
+
+ const maxValueInput = this.element.querySelector('#max-value');
+ maxValueInput.min = this.data.properties.min;
+ maxValueInput.max = this.data.properties.max;
+ maxValueInput.step = this.data.properties.step;
+ maxValueInput.value = this.data.properties.max;
+ maxValueInput.addEventListener('blur', this.#onChangeCallback.bind(this));
+ maxValueInput.addEventListener('change', this.#onChangeCallback.bind(this));
+
+ const minValueSlider = this.element.querySelector('#min-slider');
+ minValueSlider.min = this.data.properties.min;
+ minValueSlider.max = this.data.properties.max;
+ minValueSlider.step = this.data.properties.step;
+ minValueSlider.value = this.data.properties.min;
+ minValueSlider.addEventListener('input', this.#onChangeCallback.bind(this));
+
+ const maxValueSlider = this.element.querySelector('#max-slider');
+ maxValueSlider.min = this.data.properties.min;
+ maxValueSlider.max = this.data.properties.max;
+ maxValueSlider.step = this.data.properties.step;
+ maxValueSlider.value = this.data.properties.max;
+ maxValueSlider.addEventListener('input', this.#onChangeCallback.bind(this));
+
+ this.elements = {
+ progressBar: progress,
+ inputs: { min: minValueInput, max: maxValueInput },
+ sliders: { min: minValueSlider, max: maxValueSlider }
+ }
+
+ let value = this.data?.value;
+ if (!isNullOrUndefined(value) && !isNullOrUndefined(value.min) && !isNullOrUndefined(value.max)) {
+ this.setValue(value);
+ } else {
+ this.setValue(null);
+ }
+ }
+
+ /*************************************
+ * *
+ * Events *
+ * *
+ *************************************/
+ /**
+ * onChangeCallback
+ * @desc handles the change event for all related checkboxes
+ * @param {event} e the change event of an element
+ */
+ #onChangeCallback(e) {
+ let target;
+ if (e.type === 'blur') {
+ target = e.originalTarget;
+ } else {
+ target = e.target;
+ }
+
+ if (!target || !this.element.contains(target) || !target.matches('input[type="range"], input[type="number"]')) {
+ return;
+ }
+
+ let { type } = this.data.properties || { };
+ type = stringHasChars(type) ? type : 'int';
+
+ const dataTarget = target.getAttribute('data-target');
+ const currentValue = { min: this.value.min, max: this.value.max };
+ if (type === 'int') {
+ currentValue[dataTarget] = parseInt(target.value);
+ } else {
+ currentValue[dataTarget] = parseFloat(target.value);
+ }
+
+ this.setValue(currentValue);
+ }
+}
diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/dropdown.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/dropdown.js
index f6ca04b26..08c414ffd 100644
--- a/CodeListLibrary_project/cll/static/js/clinicalcode/components/dropdown.js
+++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/dropdown.js
@@ -1,15 +1,3 @@
-/**
- * DROPDOWN_KEYS
- * @desc Keycodes used to navigate through dropdown
- */
-const DROPDOWN_KEYS = {
- // Navigate dropdown
- 'DOWN': 40,
- 'UP': 38,
- // Exit component
- 'ESC': 27,
-};
-
/**
* createDropdownSelectionElement
* @desc Creates an accessible dropdown element
@@ -26,9 +14,9 @@ const createDropdownSelectionElement = (element) => {
});
const btn = createElement('button', {
+ 'type': 'button',
'className': 'dropdown-selection__button',
'data-value': '',
- 'type': 'button'
});
const title = createElement('span', {
@@ -85,10 +73,10 @@ const createDropdownSelectionElement = (element) => {
return;
}
- const code = e.keyIdentifier || e.which || e.keyCode;
+ const code = e.code;
switch (code) {
- case DROPDOWN_KEYS.UP:
- case DROPDOWN_KEYS.DOWN:
+ case 'ArrowUp':
+ case 'ArrowDown':
let target = e.target;
if (container.contains(target)) {
e.stopPropagation();
@@ -99,13 +87,13 @@ const createDropdownSelectionElement = (element) => {
target = children[0];
}
- let index = children.indexOf(target) + (DROPDOWN_KEYS.UP == code ? -1 : 1);
+ let index = children.indexOf(target) + (code === 'ArrowUp' ? -1 : 1);
index = index > children.length - 1 ? 0 : (index < 0 ? children.length - 1 : index);
children[index].focus();
}
break;
- case DROPDOWN_KEYS.ESC:
+ case 'Escape':
list.classList.remove('active');
break;
diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/entitySelector.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/entitySelector.js
index 279c63bd6..4e7b33099 100644
--- a/CodeListLibrary_project/cll/static/js/clinicalcode/components/entitySelector.js
+++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/entitySelector.js
@@ -23,25 +23,38 @@ const getDescriptor = (description, name) => {
/**
* createGroup
* @desc method to interpolate a card using a template
- * @param {node} container the container element
- * @param {string} template the template fragment
- * @param {number} id the id of the associated element
- * @param {string} title string, as formatted by the `getDescriptor` method
+ *
+ * @param {node} container the container element
+ * @param {string} template the template fragment
+ * @param {number} entityLen the num. of creatable entities
+ * @param {number} id the id of the associated element
+ * @param {string} title string, as formatted by the `getDescriptor` method
* @param {string} description string, as formatted by the `getDescriptor` method
+ *
* @returns {node} the interpolated element after appending to the container node
*
*/
-const createGroup = (container, template, id, title, description) => {
- description = getDescriptor(description, title);
+const createGroup = (container, template, entityLen, id, title, description) => {
+ let descCls;
+ if (entityLen > 1) {
+ title = title.toLocaleUpperCase();
+ descCls = '';
+ description = getDescriptor(description, title);
+ } else {
+ title = 'Please select:'
+ descCls = 'hide';
+ description = '';
+ }
const html = interpolateString(template, {
'id': id,
- 'title': title.toLocaleUpperCase(),
+ 'title': title,
'description': description,
+ 'descCls': descCls,
});
const doc = parseHTMLFromString(html, true);
- return container.appendChild(doc.body.children[0]);
+ return container.appendChild(doc[0]);
}
/**
@@ -67,7 +80,7 @@ const createCard = (container, template, id, type, hint, title, description) =>
});
const doc = parseHTMLFromString(html, true);
- return container.appendChild(doc.body.children[0]);
+ return container.appendChild(doc[0]);
}
/**
@@ -122,7 +135,8 @@ const initialiseSelector = (formData) => {
const entities = datasets?.data?.entities;
if (!isNullOrUndefined(entities)) {
- for (let i = 0; i < entities.length; ++i) {
+ const entityLen = entities.length;
+ for (let i = 0; i < entityLen; ++i) {
let entity = entities[i];
const available = datasets?.data?.templates.filter(item => item.entity_class__id == entity.id);
if (available.length < 1) {
@@ -132,6 +146,7 @@ const initialiseSelector = (formData) => {
let group = createGroup(
groupContainer,
templates?.group,
+ entityLen,
entity.id,
entity.name,
entity.description
diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/fuzzyQuery.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/fuzzyQuery.js
index f507b6c88..9a3de5817 100644
--- a/CodeListLibrary_project/cll/static/js/clinicalcode/components/fuzzyQuery.js
+++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/fuzzyQuery.js
@@ -1,29 +1,26 @@
/**
- * @class FuzzyQuery
- * @desc A static class that uses Levenshtein distance to search a haystack.
- *
- * e.g.
- ```js
- import FuzzyQuery from '../components/fuzzyQuery.js';
-
- // i.e. some haystack of item(s)
- const haystack = [
- 'some_item1',
- 'some_item2',
- 'another_thing',
- 'another_thing_1',
- ];
-
- // e.g. some search string
- const query = 'some_item';
-
- // ...attempt to search haystack
- const results = FuzzyQuery.Search(haystack, query, FuzzyQuery.Results.Sort, FuzzyQuery.Transformers.IgnoreCase);
- console.log(results); // --> Of result: ['some_item1', 'some_item2']
-
- ```
- *
- */
+ * @class FuzzyQuery
+ * @desc A static class that uses Levenshtein distance to search a haystack.
+ *
+ * @example
+ * import FuzzyQuery from '../components/fuzzyQuery.js';
+ *
+ * // i.e. some haystack of item(s)
+ * const haystack = [
+ * 'some_item1',
+ * 'some_item2',
+ * 'another_thing',
+ * 'another_thing_1',
+ * ];
+ *
+ * // e.g. some search string
+ * const query = 'some_item';
+ *
+ * // ...attempt to search haystack
+ * const results = FuzzyQuery.Search(haystack, query, FuzzyQuery.Results.Sort, FuzzyQuery.Transformers.IgnoreCase);
+ * console.log(results); // --> Of result: ['some_item1', 'some_item2']
+ *
+ */
export default class FuzzyQuery {
/**
* @desc transformers are preprocessors that modify both the haystack and the needle prior to fuzzy matching
@@ -147,13 +144,14 @@ export default class FuzzyQuery {
let results = [];
for (let i = 0; i < haystack.length; i++) {
let item = String(haystack[i]);
+ let comp = item;
if (typeof transformer === 'function') {
- item = transformer(item);
+ comp = transformer(comp);
}
-
- if (FuzzyQuery.Match(item, query)) {
+
+ if (FuzzyQuery.Match(comp, query)) {
if (sort) {
- results.push({ item: item, score: FuzzyQuery.Distance(item, query) });
+ results.push({ item: item, score: FuzzyQuery.Distance(comp, query) });
} else {
results.push({ item: item });
}
diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/groupedEnumSelector.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/groupedEnumSelector.js
index d3c9354fd..6bcaf44ca 100644
--- a/CodeListLibrary_project/cll/static/js/clinicalcode/components/groupedEnumSelector.js
+++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/groupedEnumSelector.js
@@ -58,11 +58,13 @@ export default class GroupedEnum {
for (let i = 0; i < checked.length; ++i) {
checked[i].checked = false;
}
+
return this;
}
+
+ const matchedGroup = this.data?.properties.find(x => x.result === value);
this.value = value;
- let matchedGroup = this.data?.properties.find(x => x.result === value);
if (!isNullOrUndefined(matchedGroup)) {
const inputs = this.element.querySelectorAll('input');
for (let i = 0; i < inputs.length; ++i) {
@@ -99,9 +101,12 @@ export default class GroupedEnum {
// Build renderables
for (let i = 0; i < this.data?.options.length; ++i) {
const option = this.data?.options[i];
- const group = this.data?.properties.find(x => x.result == option.value);
- if (!isNullOrUndefined(group)) {
- continue
+ const properties = this.data?.properties
+ if (!isNullOrUndefined(properties)) {
+ const group = properties.find(x => x.result == option.value);
+ if (!isNullOrUndefined(group)) {
+ continue
+ }
}
const item = this.#createCheckbox(
@@ -110,7 +115,7 @@ export default class GroupedEnum {
option.value
);
- // assign changed behaviour
+ // Assign changed behaviour
const checkbox = item.querySelector('input');
checkbox.addEventListener('change', this.#onChangeCallback.bind(this));
}
@@ -119,11 +124,12 @@ export default class GroupedEnum {
let value = this.data?.value?.[0]?.value;
if (!isNullOrUndefined(value)) {
this.setValue(value);
- } else {
- const defaultValue = this.element.getAttribute('data-default');
- if (!isNullOrUndefined(defaultValue)) {
- this.setValue(defaultValue);
- }
+ return this;
+ }
+
+ const defaultValue = this.element.getAttribute('data-default');
+ if (!isNullOrUndefined(defaultValue)) {
+ this.setValue(defaultValue);
}
}
@@ -138,27 +144,53 @@ export default class GroupedEnum {
* @param {event} e the change event of an element
*/
#onChangeCallback(e) {
- const checked = this.element.querySelectorAll('input:checked')
- const selected = Array.prototype.slice.call(checked)
- .map(node => {
- return this.data?.options.find(x => x.value == node.getAttribute('data-value'))
- });
+ const checked = this.element.querySelectorAll('input:checked');
+ const selectedValues = Array.prototype.slice.call(checked)
+ .reduce((res, node) => {
+ const item = this.data?.options?.find?.(x => x.value == node.getAttribute('data-value'));
+ if (isNullOrUndefined(item)) {
+ node.checked = false;
+ return res;
+ }
- // Select a group if a match is found
+ res.push(item.value);
+ return res;
+ }, []);
+
+ // Det. groups, if any
let matchedGroup;
- let selectedValues = selected.map(x => x?.value);
- for (let i = 0; i < this.data?.properties.length; ++i) {
- let group = this.data?.properties[i];
- if (isNullOrUndefined(group?.when)) {
- continue;
- }
-
- if (isArrayEqual(selectedValues, group?.when)) {
- matchedGroup = group;
- break
+ let selectedGroup;
+
+ const target = e.target;
+ const properties = this.data?.properties;
+ const singleSelected = selectedValues.length === 1;
+ if (!isNullOrUndefined(properties)) {
+ for (let i = 0; i < properties.length; ++i) {
+ let group = properties[i];
+ if (isNullOrUndefined(group?.when)) {
+ continue;
+ }
+
+ if (isArrayEqual(selectedValues, group?.when)) {
+ matchedGroup = group;
+ break;
+ }
+
+ if (!target.checked) {
+ const test = [...selectedValues];
+ const indx = target.getAttribute('data-value');
+ if (!test.includes(indx)) {
+ test.push(indx);
+ }
+
+ if (isArrayEqual(test, group.when)) {
+ selectedGroup = group;
+ }
+ }
}
}
+ // Select a group if a match is found
if (!isNullOrUndefined(matchedGroup)) {
for (let i = 0; i < checked.length; ++i) {
let checkbox = checked[i];
@@ -175,21 +207,32 @@ export default class GroupedEnum {
}
// None found, clear current selection & apply state of our current checkbox
- const target = e.currentTarget;
+ let desiredValue;
+ if (target.checked) {
+ desiredValue = target.getAttribute('data-value');
+ } else if (singleSelected && selectedGroup) {
+ desiredValue = selectedValues.shift();
+ } else {
+ desiredValue = null;
+ }
+
for (let i = 0; i < checked.length; ++i) {
let checkbox = checked[i];
- if (target == checkbox) {
+ if (!isNullOrUndefined(desiredValue) && singleSelected && selectedGroup) {
+ if (target !== checkbox && !selectedGroup.when.includes(checkbox.getAttribute('data-value'))) {
+ checkbox.checked = false;
+ }
+
+ continue;
+ }
+
+ if (target === checkbox) {
continue;
}
checkbox.checked = false;
}
-
- if (target.checked) {
- this.value = target.getAttribute('data-value');
- } else {
- this.value = null;
- }
+ this.value = desiredValue;
}
/*************************************
@@ -212,6 +255,6 @@ export default class GroupedEnum {
`
const doc = parseHTMLFromString(html, true);
- return this.element.appendChild(doc.body.children[0]);
+ return this.element.appendChild(doc[0]);
}
}
diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/listEnumSelector.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/listEnumSelector.js
new file mode 100644
index 000000000..e8d4fe2ea
--- /dev/null
+++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/listEnumSelector.js
@@ -0,0 +1,220 @@
+/**
+ * @class ListEnum
+ * @desc handler for ListEnum components, where:
+ * - Selection of individual values via checkboxes
+ * @param {string/node} obj The ID of the input element or the input element itself
+ * @param {object} data Should contain both (1) the available options and (2) the available groups
+ * @return {object} An interface to control the behaviour of the component
+ *
+ */
+export default class ListEnum {
+ constructor(obj, data, defaultValue) {
+ if (typeof obj === 'string') {
+ this.id = id;
+ this.element = document.getElementById(id);
+ } else {
+ this.element = obj;
+ if (typeof this.element !== 'undefined') {
+ this.id = this.element.getAttribute('id');
+ }
+ }
+
+ this.value = [];
+ this.data = data;
+ this.#initialise()
+ }
+
+ /*************************************
+ * *
+ * Getter *
+ * *
+ *************************************/
+ /**
+ * getValue
+ * @desc gets the current value of the component
+ * @returns {any} the value selected via its options data
+ */
+ getValue() {
+ return this.value;
+ }
+
+ /*************************************
+ * *
+ * Setter *
+ * *
+ *************************************/
+ /**
+ * setValue
+ * @desc sets the current value of the component
+ * @param {any} value the value that should be selected from its list of options
+ */
+ setValue(value) {
+ if (isNullOrUndefined(value)) {
+ this.value = [];
+
+ const checked = this.element.querySelectorAll('input:checked')
+ for (let i = 0; i < checked.length; ++i) {
+ checked[i].checked = false;
+ }
+ return this;
+ }
+ this.value = value;
+
+ const inputs = this.element.querySelectorAll('input');
+ for (let i = 0; i < inputs.length; ++i) {
+ let val = inputs[i].getAttribute('data-value');
+ inputs[i].checked = value.includes(val);
+ }
+
+ return this;
+ }
+
+ /*************************************
+ * *
+ * Private *
+ * *
+ *************************************/
+ /**
+ * initialise
+ * @desc private method to initialise & render the component
+ */
+ #initialise() {
+ // Build renderables
+ let opts = Array.isArray(this?.data?.options) ? this.data.options : [];
+ opts = opts.sort((a, b) => a.name.localeCompare(b.name));
+ this.data.options = opts;
+
+ let noneSelector = this?.data?.properties?.none_selector;
+ if (!isNullOrUndefined(noneSelector)) {
+ const idx = opts.findIndex(x => x?.value === noneSelector);
+ if (idx >= 0) {
+ noneSelector = opts.splice(idx, 1)[0];
+ opts.push(noneSelector);
+ }
+ }
+
+ for (let i = 0; i < opts.length; ++i) {
+ const option = opts[i];
+ const item = this.#createCheckbox(
+ `${option.name}-${option.value}`,
+ option.name,
+ option.value
+ );
+
+ // assign changed behaviour
+ const checkbox = item.querySelector('input');
+ checkbox.addEventListener('change', this.#onChangeCallback.bind(this));
+ }
+
+ // Assign default value
+ let value = this.data.value;
+ if (Array.isArray(value) && value.length > 0) {
+ value = value.map(x => x.value)
+ this.setValue(value);
+ } else {
+ this.setValue(null);
+ }
+ }
+
+ /*************************************
+ * *
+ * Events *
+ * *
+ *************************************/
+ /**
+ * onChangeCallback
+ * @desc handles the change event for all related checkboxes
+ * @param {event} e the change event of an element
+ */
+ #onChangeCallback(e) {
+ const checked = this.element.querySelectorAll('input:checked')
+ const selected = Array.prototype.slice.call(checked)
+ .map(node => {
+ return this.data?.options.find(x => x.value == node.getAttribute('data-value'))
+ });
+
+ let selectedValues = selected.map(x => x?.value);
+
+ let deselectExcept = null;
+ let properties = this.data?.properties?.groups;
+ if (!isNullOrUndefined(properties)) {
+ for (let i = 0; i < properties.length; ++i) {
+ let group = properties[i];
+ if (isNullOrUndefined(group?.when)) {
+ continue;
+ }
+
+ if (isNullOrUndefined(group?.result)) {
+ continue;
+ }
+
+ if (group.result == 'deselect') {
+ if (this.value[0] === group?.when) {
+ this.value = this.value.filter((el) => {
+ return el !== group?.when;
+ });
+
+ for (let i = 0; i < checked.length; ++i) {
+ let checkbox = checked[i];
+ let checkboxValue = checkbox.getAttribute('data-value');
+ if (checkboxValue === group?.when) {
+ checkbox.checked = false;
+ }
+ }
+ continue;
+ }
+
+ if (selectedValues.includes(group?.when)) {
+ deselectExcept = group?.when;
+ }
+ }
+ }
+ }
+
+ if (!isNullOrUndefined(deselectExcept)) {
+ for (let i = 0; i < checked.length; ++i) {
+ let checkbox = checked[i];
+ let checkboxValue = checkbox.getAttribute('data-value');
+
+ checkbox.checked = checkboxValue === deselectExcept;
+ }
+
+ this.value = [deselectExcept];
+ return;
+ }
+
+ const target = e.currentTarget;
+ const targetValue = target.getAttribute('data-value');
+ if (target.checked) {
+ this.value.push(targetValue);
+ this.value.sort();
+ } else {
+ this.value = this.value.filter((el) => {
+ return el !== targetValue;
+ });
+ }
+ }
+
+ /*************************************
+ * *
+ * Render *
+ * *
+ *************************************/
+ /**
+ * createCheckbox
+ * @desc creates a checkbox element
+ * @param {string} id html attr
+ * @param {string} title the display title
+ * @param {any} value the value of the input
+ * @returns {node} a checkbox element
+ */
+ #createCheckbox(id, title, value) {
+ const html = `
+
+ ${title}
+
`
+
+ const doc = parseHTMLFromString(html, true);
+ return this.element.appendChild(doc[0]);
+ }
+}
diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/modal.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/modal.js
index 4a63966e6..cb871051a 100644
--- a/CodeListLibrary_project/cll/static/js/clinicalcode/components/modal.js
+++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/modal.js
@@ -49,6 +49,7 @@ const PROMPT_MODAL_SIZES = {
Small: 'sm',
Medium: 'md',
Large: 'lg',
+ XLarge: 'xl',
};
/**
@@ -60,6 +61,7 @@ const PROMPT_DEFAULT_PARAMS = {
title: 'Modal',
content: '',
showFooter: true,
+ showActionSpinner: true,
size: PROMPT_MODAL_SIZES.Medium,
buttons: PROMPT_BUTTONS_DEFAULT,
onRender: () => { },
@@ -100,7 +102,7 @@ class CancellablePromise {
*
*/
class ModalResult {
- constructor(name, type, data) {
+ constructor(name, type = PROMPT_BUTTON_TYPES.REJECT, data = null) {
this.name = name;
this.type = type;
this.data = data;
@@ -111,60 +113,58 @@ class ModalResult {
* @class ModalFactory
* @desc A window-level instance to create modals
*
- * e.g.
- ```js
- const ModalFactory = window.ModalFactory;
- ModalFactory.create({
- id: 'test-dialog',
- title: 'Hello',
- content: 'Hello
',
- buttons: [
- {
- name: 'Cancel',
- type: ModalFactory.ButtonTypes.REJECT,
- html: ` `,
- },
- {
- name: 'Reject',
- type: ModalFactory.ButtonTypes.REJECT,
- html: ` `,
- },
- {
- name: 'Confirm',
- type: ModalFactory.ButtonTypes.CONFIRM,
- html: ` `,
- },
- {
- name: 'Accept',
- type: ModalFactory.ButtonTypes.CONFIRM,
- html: ` `,
- },
- ]
- })
- .then((result) => {
- // e.g. user pressed a button that has type=ModalFactory.ButtonTypes.CONFIRM
- const name = result.name;
- if (name == 'Confirm') {
- console.log('[success] user confirmed', result);
- } else if (name == 'Accept') {
- console.log('[success] user accepted', result);
- }
- })
- .catch((result) => {
- // An error occurred somewhere (unrelated to button input)
- if (!(result instanceof ModalFactory.ModalResults)) {
- return console.error(result);
- }
-
- // e.g. user pressed a button that has type=ModalFactory.ButtonTypes.REJECT
- const name = result.name;
- if (name == 'Cancel') {
- console.log('[failure] user cancelled', result);
- } else if (name == 'Reject') {
- console.log('[failure] rejected', result);
- }
- });
- ```
+ * @example
+ * const ModalFactory = window.ModalFactory;
+ * ModalFactory.create({
+ * id: 'test-dialog',
+ * title: 'Hello',
+ * content: 'Hello
',
+ * buttons: [
+ * {
+ * name: 'Cancel',
+ * type: ModalFactory.ButtonTypes.REJECT,
+ * html: ` `,
+ * },
+ * {
+ * name: 'Reject',
+ * type: ModalFactory.ButtonTypes.REJECT,
+ * html: ` `,
+ * },
+ * {
+ * name: 'Confirm',
+ * type: ModalFactory.ButtonTypes.CONFIRM,
+ * html: ` `,
+ * },
+ * {
+ * name: 'Accept',
+ * type: ModalFactory.ButtonTypes.CONFIRM,
+ * html: ` `,
+ * },
+ * ]
+ * })
+ * .then((result) => {
+ * // e.g. user pressed a button that has type=ModalFactory.ButtonTypes.CONFIRM
+ * const name = result.name;
+ * if (name == 'Confirm') {
+ * console.log('[success] user confirmed', result);
+ * } else if (name == 'Accept') {
+ * console.log('[success] user accepted', result);
+ * }
+ * })
+ * .catch((result) => {
+ * // An error occurred somewhere (unrelated to button input)
+ * if (!(result instanceof ModalFactory.ModalResults)) {
+ * return console.error(result);
+ * }
+ *
+ * // e.g. user pressed a button that has type=ModalFactory.ButtonTypes.REJECT
+ * const name = result.name;
+ * if (name == 'Cancel') {
+ * console.log('[failure] user cancelled', result);
+ * } else if (name == 'Reject') {
+ * console.log('[failure] rejected', result);
+ * }
+ * });
*
*/
class ModalFactory {
@@ -174,7 +174,42 @@ class ModalFactory {
ModalResults = ModalResult;
constructor() {
- this.modal = null;
+ this.modals = { };
+
+ document.addEventListener('keyup', (e) => {
+ let modal = Object.values(this.modals)
+ .filter(x => !!x.escape)
+ .sort((a, b) => {
+ const d0 = a?.timestamp || 0;
+ const d1 = b?.timestamp || 0;
+ return d0 > d1 ? -1 : (d0 < d1 ? 1 : 0);
+ })
+ .shift();
+
+ if (isObjectType(modal)) {
+ modal.escape(e);
+ }
+ });
+ }
+
+ /**
+ * @param {string|null} [ref=null] optionally specify the modal ref name
+ *
+ * @returns {object|null} either (a) the base modal descriptor, or (b) the modal by the specified name if provided
+ */
+ getModal(ref = null) {
+ let state = this.modals;
+ if (typeof ref === 'string') {
+ state = state[ref];
+ } else {
+ state = state.__base;
+ }
+
+ if (!isObjectType(state)) {
+ return null;
+ }
+
+ return state;
}
/**
@@ -192,25 +227,33 @@ class ModalFactory {
*
*/
create(options) {
- options = options || { };
- this.closeCurrentModal();
+ options = isObjectType(options) ? options : { };
+
+ const modalRef = stringHasChars(options.ref) ? options.ref : '__base';
+ this.closeCurrentModal(modalRef);
try {
options = mergeObjects(options, PROMPT_DEFAULT_PARAMS);
const { id, title, content, showFooter, buttons, size } = options;
-
- const html = interpolateString(PROMPT_DEFAULT_CONTAINER, { id: id, title: title, content: content, size: size });
+ const html = interpolateString(PROMPT_DEFAULT_CONTAINER, {
+ id: id,
+ size: size,
+ title: title,
+ content: content,
+ });
+
+ const modalState = { };
const doc = parseHTMLFromString(html, true);
const currentHeight = window.scrollY;
- const modal = document.body.appendChild(doc.body.children[0]);
+ const modal = document.body.appendChild(doc[0]);
const container = modal.querySelector('.target-modal__container');
let footer;
if (showFooter) {
footer = createElement('div', {
- class: 'target-modal__footer',
id: 'target-modal-footer',
+ class: 'target-modal__footer',
});
footer = container.appendChild(footer);
@@ -221,10 +264,10 @@ class ModalFactory {
for (let i = 0; i < buttons.length; ++i) {
let button = buttons[i];
let item = parseHTMLFromString(button.html, true);
- item = footer.appendChild(item.body.children[0]);
+ item = footer.appendChild(item[0]);
item.innerText = button.name;
item.setAttribute('aria-label', button.name);
-
+
footerButtons.push({
name: button.name,
type: button.type,
@@ -233,12 +276,23 @@ class ModalFactory {
}
}
- const closeModal = (method, details) => {
- document.body.classList.remove('modal-open');
- window.scrollTo({ top: currentHeight, left: window.scrollX, behaviour: 'instant'});
- modal.remove();
- history.pushState("", document.title, window.location.pathname + window.location.search);
- this.modal = null;
+ let escapeHnd, closeModal, spinner;
+ closeModal = (method, details) => {
+ delete this.modals[modalRef];
+
+ if (Object.values(this.modals).length > 0) {
+ modal.remove();
+ } else {
+ document.body.classList.remove('modal-open');
+ window.scrollTo({ top: currentHeight, left: window.scrollX, behaviour: 'instant'});
+ modal.remove();
+ history.pushState('', document.title, window.location.pathname + window.location.search);
+ }
+ modalState.isActioning = false;
+
+ if (!isNullOrUndefined(spinner)) {
+ spinner?.remove?.();
+ }
if (!method) {
return;
@@ -255,36 +309,94 @@ class ModalFactory {
for (let i = 0; i < footerButtons.length; ++i) {
let btn = footerButtons[i];
btn.element.addEventListener('click', (e) => {
+ if (modalState.isActioning) {
+ return;
+ }
+ modalState.isActioning = true;
+
switch (btn.type) {
case this.ButtonTypes.CONFIRM: {
- let data;
- if (options.beforeAccept) {
- data = options.beforeAccept(modal);
+ let closure;
+ if (!options.beforeAccept) {
+ closure = Promise.resolve();
+ } else {
+ if (options.showActionSpinner) {
+ spinner = document.body.querySelector(':scope > .loading-spinner');
+ if (isNullOrUndefined(spinner)) {
+ spinner = startLoadingSpinner();
+ }
+ }
+
+ closure = Promise.resolve(options.beforeAccept(modal));
}
-
- closeModal(resolve, Object.assign({}, btn, { data: data }));
+
+ closure
+ .then(data => {
+ if (!!data && data instanceof ModalResult && data.name === 'Cancel') {
+ return;
+ }
+
+ closeModal(resolve, Object.assign({}, btn, { data }));
+ })
+ .catch(e => {
+ console.error(`[Modal] Failed to confirm modal selection with err:\n\n${e}\n`);
+ })
+ .finally(_ => {
+ const spinElem = spinner;
+ spinner = null;
+
+ if (!isNullOrUndefined(spinElem)) {
+ spinElem?.remove?.();
+ }
+ modalState.isActioning = false;
+ });
} break;
case this.ButtonTypes.REJECT:
- default: {
closeModal(reject, btn);
- } break;
+ break;
+
+ default:
+ modalState.isActioning = false;
+ break;
}
});
- }
+ };
const exit = modal.querySelector('#modal-close-btn');
if (exit) {
exit.addEventListener('click', (e) => {
e.preventDefault();
+
+ if (modalState.isActioning) {
+ return;
+ }
+ modalState.isActioning = true;
+
closeModal(reject);
});
}
-
+
+ escapeHnd = (e) => {
+ if (modalState.isActioning) {
+ return;
+ }
+
+ const activeFocusElem = document.activeElement;
+ if (!!activeFocusElem && activeFocusElem.matches('input, textarea, button, select')) {
+ return;
+ }
+
+ if (e.code === 'Escape') {
+ modalState.isActioning = true;
+ closeModal(reject);
+ }
+ };
+
// Show the modal
- createElement('a', { href: `#${id}` }).click();
+ createElement('a', { href: `#${options.id}` }).click();
window.scrollTo({ top: currentHeight, left: window.scrollX, behaviour: 'instant'});
-
+
// Inform screen readers of alert
modal.setAttribute('aria-hidden', false);
modal.setAttribute('role', 'alert');
@@ -298,11 +410,13 @@ class ModalFactory {
}
});
- this.modal = {
- element: modal,
- promise: promise,
- close: closeModal,
- };
+ modalState.close = closeModal;
+ modalState.escape = escapeHnd;
+ modalState.element = modal;
+ modalState.promise = promise;
+ modalState.timestamp = Date.now();
+ modalState.isActioning = false;
+ this.modals[modalRef] = modalState;
return promise;
}
@@ -313,17 +427,30 @@ class ModalFactory {
/**
* closeCurrentModal
- * @desc closes the current modal and resolves its associated
- * promise
+ * @desc closes the current modal and resolves its associated promise
+ *
+ * @param {string|null} [ref=null] optionally specify the modal ref name
*
*/
- closeCurrentModal() {
- if (isNullOrUndefined(this.modal)) {
+ closeCurrentModal(ref = null) {
+ let state = this.modals;
+ if (typeof ref === 'string') {
+ state = state[ref];
+ } else {
+ state = state.__base;
+ }
+
+ if (!isObjectType(state)) {
+ return;
+ }
+
+ const { promise, close, isActioning } = state;
+ if (isActioning) {
return;
}
- const { modal, promise, close: closeModal } = this.modal;
- closeModal(promise.cancel);
+ state.isActioning = true;
+ close(promise.cancel);
}
};
diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/navigation.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/navigation.js
index 4997ce22d..ca7e6afb2 100644
--- a/CodeListLibrary_project/cll/static/js/clinicalcode/components/navigation.js
+++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/navigation.js
@@ -85,7 +85,7 @@ const initHamburgerMenu = () => {
*/
const submenuMobile = () => {
// JavaScript for submenu behavior
- const navText = document.querySelector('.nav-dropdown__text');
+ const navText = document.querySelector('.nav-dropdown__text a');
const submenu = document.querySelector('.nav-dropdown__content');
const avataText = document.querySelector('.avatar-content');
@@ -187,8 +187,12 @@ const setNavigation = (navbar) => {
// match by link
let href = link.getAttribute('href');
+ if (!stringHasChars(href)) {
+ continue;
+ }
+
href = href.replace(/\/$/, '').toLocaleLowerCase();
-
+
const dist = FuzzyQuery.Distance(path, href);
if (typeof closest === 'undefined' || dist < distance) {
distance = dist;
@@ -202,7 +206,7 @@ const setNavigation = (navbar) => {
}
const manageBrandTargets = () => {
- const elements = document.querySelectorAll('.userBrand');
+ const elements = [...document.querySelectorAll('.userBrand')];
const brandSource = document.querySelector('script[type="application/json"][name="brand-targets"]');
if (elements.length < 1 || isNullOrUndefined(brandSource)) {
return;
@@ -219,7 +223,67 @@ const manageBrandTargets = () => {
isProductionRoot = ['true', '1'].indexOf(isProductionRoot.toLowerCase()) >= 0;
}
- const handleBrandTarget = (e) => getBrandUrlTarget(brandTargets, isProductionRoot, e.target, oldRoot, path);
+ const tryGetBrandNavMap = (ref) => {
+ const map = isHtmlObject(ref)
+ ? ref.querySelector('script[type="application/json"][name="brand-mapping"]')
+ : null;
+
+ let parsed = null;
+ if (!isNullOrUndefined(map)) {
+ try {
+ parsed = JSON.parse(map.innerText);
+ }
+ catch { }
+
+ if (!isNullOrUndefined(parsed) && !isObjectType(parsed)) {
+ parsed = null;
+ }
+ }
+
+ return parsed;
+ }
+
+ const handleBrandTarget = (e) => {
+ let current = getCurrentBrandPrefix();
+ if (current.startsWith('/')) {
+ current = current.substring(1);
+ }
+ current = current.toUpperCase();
+
+ const trg = e.target;
+ const ref = elements.find(x => (stringHasChars(x.getAttribute('value')) ? x.getAttribute('value').toUpperCase() : '') === current);
+
+ const m0 = tryGetBrandNavMap(ref);
+ const m1 = tryGetBrandNavMap(trg);
+ if (!isNullOrUndefined(m0) && !isNullOrUndefined(m1)) {
+ const pathIndex = brandTargets.indexOf(oldRoot.toUpperCase()) == -1 ? 0 : 1;
+ const pathTarget = path.split('/').slice(pathIndex);
+ const pathRoot = pathTarget?.[0];
+ if (stringHasChars(pathRoot)) {
+ let uRes = Object.entries(m0).find(([k, v]) => k.endsWith('_url') && v === pathRoot);
+ uRes = (Array.isArray(uRes) && uRes.length >= 2)
+ ? m1?.[uRes[0]]
+ : null;
+
+ if (stringHasChars(uRes) && uRes !== pathRoot) {
+ pathTarget[0] = uRes;
+
+ uRes = (
+ pathIndex
+ ? [oldRoot, ...pathTarget]
+ : pathTarget
+ )
+ .join('/');
+
+ navigateBrandTargetURL(brandTargets, isProductionRoot, trg, oldRoot, uRes);
+ return;
+ }
+ }
+ }
+
+ navigateBrandTargetURL(brandTargets, isProductionRoot, trg, oldRoot, path);
+ };
+
for (const element of elements) {
element.addEventListener('click', handleBrandTarget);
}
diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/numberInput.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/numberInput.js
new file mode 100644
index 000000000..4157429bc
--- /dev/null
+++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/numberInput.js
@@ -0,0 +1,141 @@
+domReady.finally(() => {
+ const ctrlKeys = [
+ 'KeyA', 'KeyF', 'KeyX',
+ 'KeyZ', 'KeyY', 'KeyC',
+ 'Backspace', 'Delete'
+ ];
+
+ createGlobalListener(
+ 'inputs.number:keydown',
+ 'input.number-input__group-input[type="number"]',
+ (e) => {
+ const target = e.target;
+ const keyCode = e.which ?? e.keyCode ?? e.keyIdentifier;
+ if (!keyCode || (keyCode >= 8 && keyCode <= 46) || (e.ctrlKey && ctrlKeys.includes(e.code))) {
+ return true;
+ }
+
+ if (e.shiftKey) {
+ e.preventDefault();
+ return false;
+ }
+
+ let datatype = target.getAttribute('data-type');
+ if (!stringHasChars(datatype)) {
+ datatype = 'numeric';
+ } else {
+ datatype = datatype.trim().toLowerCase();
+ }
+
+ let allowed = (
+ (keyCode >= 48 && keyCode <= 57) ||
+ (keyCode >= 96 && keyCode <= 105) ||
+ keyCode === 173
+ );
+
+ if (datatype !== 'int') {
+ allowed = allowed || keyCode === 190;
+ }
+
+ if (!allowed) {
+ e.preventDefault();
+ return true;
+ }
+
+ return false;
+ }
+ );
+
+ createGlobalListener(
+ 'inputs.number:paste',
+ 'input.number-input__group-input[type="number"]',
+ (e) => {
+ e.preventDefault();
+
+ let paste = (e.clipboardData || window.clipboardData).getData('text');
+ paste = paste.toUpperCase().trim().replace(/[^\-\d\.]/gmi, '');
+ paste = Number(paste)
+
+ if (!isNaN(paste)) {
+ e.target.value = paste
+ } else {
+ e.target.value = null;
+ }
+
+ return false;
+ }
+ );
+
+ createGlobalListener(
+ 'inputs.number:click',
+ 'button.number-input__group-action',
+ (e) => {
+ e.preventDefault();
+
+ const trg = e.target;
+ const opr = trg.getAttribute('data-op').trim().toLowerCase();
+
+ const input = trg?.parentElement?.parentElement?.querySelector?.('input.number-input__group-input');
+ if (!input) {
+ return true;
+ }
+
+ let datatype = input.getAttribute('data-type');
+ if (!stringHasChars(datatype)) {
+ datatype = 'numeric';
+ } else {
+ datatype = datatype.trim().toLowerCase();
+ }
+
+ let step = input.getAttribute('data-step');
+ step = stringHasChars(step) ? step.trim() : input.getAttribute('step')?.trim?.();
+
+ let value, diff;
+ switch (datatype) {
+ case 'int': {
+ diff = parseInt(step);
+ if (isNaN(diff)) {
+ diff = 1;
+ step = '1';
+ }
+
+ value = parseInt(input.value.trim());
+ } break;
+
+ case 'float':
+ case 'numeric':
+ case 'decimal':
+ case 'percentage': {
+ diff = parseFloat(step);
+ if (isNaN(diff)) {
+ diff = 0.1;
+ step = '0.1';
+ }
+
+ value = parseFloat(input.value.trim());
+ } break;
+
+ default:
+ value = null;
+ break;
+ }
+
+ if (isNullOrUndefined(value) || isNaN(value)) {
+ value = 0;
+ }
+
+ value += diff*(opr === 'decrement' ? -1 : 1);
+ if (datatype === 'int') {
+ value = Math.trunc(value);
+ } else {
+ const m = Math.pow(10, step.split('.')?.[1]?.length || 0);
+ value = Math.round(value * m) / m;
+ }
+
+ input.value = value;
+ fireChangedEvent(input);
+
+ return false;
+ }
+ );
+});
diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/popoverMenu.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/popoverMenu.js
new file mode 100644
index 000000000..805285d17
--- /dev/null
+++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/popoverMenu.js
@@ -0,0 +1,222 @@
+/**
+ * @desc closes all popover menus except for the specified target
+ *
+ * @param {Array} managed an array of objects defining a set of popover menu item descriptors
+ * @param {HTMLElement|null} target optionally specify a popover menu to ignore
+ */
+export const closePopovers = (managed, target) => {
+ if (!Array.isArray(managed)) {
+ return;
+ }
+
+ if (!target) {
+ for (let i = 0; i < managed.length; ++i) {
+ const group = managed[i];
+ group.menu.setAttribute('hidden', false);
+ group.menu.setAttribute('aria-live', 'off');
+ group.menu.setAttribute('aria-hidden', 'true');
+ group.toggleBtn.setAttribute('aria-live', 'off');
+ }
+
+ return;
+ }
+
+ for (let i = 0; i < managed.length; ++i) {
+ const group = managed[i];
+ if (group.item === target || group.item.contains(target)) {
+ continue;
+ }
+
+ group.menu.setAttribute('hidden', false);
+ group.menu.setAttribute('aria-live', 'off');
+ group.menu.setAttribute('aria-hidden', 'true');
+ group.toggleBtn.setAttribute('aria-live', 'off');
+ }
+};
+
+/**
+ * @desc initialises & manages events relating to the `.popover-menu` class
+ *
+ * @param {object} param0 popover behaviour opts
+ * @param {HTMLElement} param0.parent optionally a HTMLElement in which to find popover menu item(s); defaults to `document`
+ * @param {Function|null} param0.callback optionally specify a callback to be called when a menu item is clicked; defaults to `null`
+ * @param {boolean} param0.observeMutations optionally specify whether to observe the addition & removal of popover menu items; defaults to `false`
+ * @param {boolean} param0.observeDescendants optionally specify whether to observe the descendant subtree when observing descendants; defaults to `false`
+ *
+ * @returns {Function|null} either (a) a cleanup disposable `function`; or (b) a `null` value if no valid menus were found
+ */
+export const managePopoverMenu = ({
+ parent = document,
+ callback = null,
+ observeMutations = false,
+ observeDescendants = false,
+} = {}) => {
+ const managed = [];
+ const popoverMenus = parent.querySelectorAll('[data-controlledby="popover-menu"]');
+ for (let i = 0; i < popoverMenus.length; ++i) {
+ const item = popoverMenus[i];
+ const menu = item.querySelector('[data-role="menu"]');
+ const toggleBtn = item.querySelector('[data-role="toggle"]');
+ if (isNullOrUndefined(menu) || isNullOrUndefined(toggleBtn)) {
+ continue;
+ }
+
+ managed.push({ item, menu, toggleBtn });
+ }
+
+ if (!observeMutations && managed.length < 1) {
+ return null;
+ }
+
+ // Listen to interaction(s)
+ const disposables = [];
+ const popoverDisposable = createGlobalListener(
+ 'popover.toggle:click',
+ '[data-controlledby="popover-menu"] [data-role="toggle"]',
+ (e) => {
+ const group = !!e.target ? managed.find(x => e.target === x.toggleBtn) : null;
+ if (!group) {
+ return;
+ }
+
+ const { menu, toggleBtn } = group;
+ const disabled = !toggleBtn.disabled ? toggleBtn.getAttribute('disabled') === 'true' : false;
+ if (disabled) {
+ return;
+ }
+
+ e.preventDefault();
+
+ const state = isVisibleObj(menu);
+ if (state) {
+ menu.setAttribute('hidden', false);
+ menu.setAttribute('aria-live', 'off');
+ menu.setAttribute('aria-hidden', 'true');
+ toggleBtn.setAttribute('aria-live', 'assertive');
+ } else {
+ menu.setAttribute('aria-live', 'assertive');
+ menu.setAttribute('aria-hidden', 'false');
+ toggleBtn.setAttribute('aria-live', 'off');
+ }
+ }
+ );
+ disposables.push(popoverDisposable);
+
+ // Initialise menu listener (if applicable)
+ if (typeof callback === 'function') {
+ const menuDisposable = createGlobalListener(
+ 'popover.toggle:click',
+ '[data-controlledby="popover-menu"] [data-role="menu"] [data-role="button"]',
+ (e) => {
+ const trg = e.target;
+ const group = !!trg ? managed.find(x => x.item.contains(trg)) : null;
+ if (!group) {
+ return;
+ }
+
+ const { toggleBtn } = group;
+ const disabled = (
+ (trg.disabled || toggleBtn.getAttribute('disabled') === 'true') ||
+ (!toggleBtn.disabled ? toggleBtn.getAttribute('disabled') === 'true' : false)
+ );
+
+ if (disabled) {
+ return;
+ }
+
+ callback(e, group, (trg) => closePopovers(managed, trg));
+ }
+ );
+ disposables.push(menuDisposable);
+ }
+
+ // Blur / Closure interaction(s)
+ const blurHandler = (e) => {
+ const type = e.type;
+ switch (type) {
+ case 'click': {
+ const trg = e.target;
+ if (managed.some(x => x.item.contains(trg))) {
+ return;
+ }
+
+ closePopovers(managed, null);
+ } break;
+
+ case 'focusout': {
+ closePopovers(managed, e.relatedTarget);
+ } break;
+
+ case 'visibilitychange': {
+ closePopovers(managed, null);
+ } break;
+
+ default:
+ break;
+ }
+ };
+
+ window.addEventListener('click', blurHandler);
+ window.addEventListener('focusout', blurHandler);
+ document.addEventListener('visibilitychange', blurHandler);
+
+ disposables.push(() => {
+ window.removeEventListener('click', blurHandler);
+ window.removeEventListener('focusout', blurHandler);
+ document.removeEventListener('visibilitychange', blurHandler);
+ });
+
+ // Observe addition & removal of menu item(s)
+ if (observeMutations) {
+ const observer = new MutationObserver((muts) => {
+ for (let i = 0; i < muts.length; ++i) {
+ const added = muts[i].addedNodes;
+ const removed = muts[i].removedNodes;
+ for (let j = 0; j < added.length; ++j) {
+ const node = added[j]
+ if (!isHtmlObject(node) || !node.matches('[data-controlledby="popover-menu"]')) {
+ continue;
+ }
+
+ const index = managed.findIndex(x => x.item === node);
+ if (index < 0) {
+ const menu = node.querySelector('[data-role="menu"]');
+ const toggleBtn = node.querySelector('[data-role="toggle"]');
+ if (isNullOrUndefined(menu) || isNullOrUndefined(toggleBtn)) {
+ continue;
+ }
+
+ managed.push({ item: node, menu: menu, toggleBtn: toggleBtn });
+ }
+ }
+
+ for (let j = 0; j < removed.length; ++j) {
+ const node = removed[j]
+ if (!node.matches('[data-controlledby="popover-menu"]')) {
+ continue;
+ }
+
+ const index = managed.findIndex(x => x.item === node);
+ if (index >= 0) {
+ managed.splice(index, 1);
+ }
+ }
+ }
+ });
+
+ observer.observe(parent, { subtree: observeDescendants, childList: true });
+ disposables.push(() => observer.disconnect());
+ }
+
+ return () => {
+ let disposable;
+ for (let i = disposables.length; i > 0; --i) {
+ disposable = disposables.pop();
+ if (typeof disposable !== 'function') {
+ continue;
+ }
+
+ disposable();
+ }
+ };
+};
diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/singleSlider.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/singleSlider.js
new file mode 100644
index 000000000..5a824c56c
--- /dev/null
+++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/singleSlider.js
@@ -0,0 +1,444 @@
+import { parseAsFieldType, resolveRangeOpts } from '../forms/entityCreator/utils.js';
+
+/**
+ * A `number`, or `string`, describing a numeric object.
+ * @typedef {(number|string)} NumberLike
+ */
+
+/**
+ * A list of known numeric types that can are managed by the {@link SingleSlider} instance
+ * @typedef {'int'|'float'|'decimal'|'numeric'|'percentage'} NumericFormat
+ */
+
+/**
+ * Properties assoc. with slider inputs; used to constrain and/or to compute the slider value
+ * @typedef {Object} SliderProps
+ * @property {NumberLike} min the minimum value of the slider
+ * @property {NumberLike} max the maximum value of the slider
+ * @property {NumberLike} step specifies the slider increment
+ * @property {NumericFormat} type specifies how the numeric value will be formatted
+ */
+
+/**
+ * Slider initialisation data
+ * @typedef {Object} SliderData
+ * @property {NumberLike} value specifies the initial slider value
+ * @property {SliderProps} properties specifies the properties of the slider, see {@link SliderProps}
+ */
+
+/**
+ * @desc computes the properties assoc. with a slider
+ *
+ * @param {?SliderData} data an object containing the properties
+ * @param {?Array} [allowedTypes] optionally specify an arr of allowed data types
+ *
+ * @returns {SliderData} the computed properties
+ */
+const computeParams = (data, allowedTypes = null) => {
+ if (!Array.isArray(allowedTypes)) {
+ allowedTypes = ['int', 'float', 'decimal', 'numeric', 'percentage'];
+ }
+
+ const hasData = isObjectType(data)
+ data = hasData ? data : { };
+
+ let props = data?.properties;
+ if (!isObjectType(props)) {
+ props = { min: 0, max: 1, step: 0.1, type: 'float' };
+ }
+
+ let valueType = props?.type;
+ let hasValueType = stringHasChars(valueType);
+ if (hasValueType) {
+ valueType = valueType.toLowerCase();
+ valueType = allowedTypes.includes(valueType) ? valueType : null;
+ hasValueType = typeof valueType === 'string';
+ }
+
+ if (!hasValueType) {
+ let example = props?.step;
+ if (!isSafeNumber(example)) {
+ example = [...Object.values(props)].concat([props?.value]).find(x => isSafeNumber(x));
+ example = isSafeNumber(example) ? example : 0.1;
+ }
+
+ example = tryParseNumber(example)
+ valueType = example.type;
+
+ if (!allowedTypes.includes(valueType)) {
+ throw new Error('Failed to resolve valid type');
+ }
+ hasValueType = true;
+ }
+
+ let valueStep = typeof props?.step === 'string' ? Number(props?.step) : props.step;
+ let hasValidStep = isSafeNumber(valueStep);
+
+ let min = typeof props.min === 'string' ? Number(props.min) : props.min;
+ let max = typeof props.max === 'string' ? Number(props.max) : props.max;
+
+ const validMin = isSafeNumber(min);
+ const validMax = isSafeNumber(max);
+
+ if (!hasValidStep) {
+ if (hasValueType) {
+ valueStep = valueType.includes('int') ? 1 : 0.1;
+ } else if (validMin || validMax) {
+ const precision = String(validMin ? min : max).split('.')?.[1]?.length || 0;
+ valueStep = Math.pow(10, -precision);
+ } else {
+ valueStep = 1;
+ }
+ }
+
+ if (validMin && validMax) {
+ let tmp = Math.max(min, max);
+ min = Math.min(min, max);
+ max = tmp;
+ } else if (validMin) {
+ max = min + (hasValidStep ? valueStep : 1)*100;
+ } else if (validMax) {
+ min = max - (hasValidStep ? valueStep : 1)*100;
+ } else {
+ min = 0;
+ max = valueStep*100;
+ }
+ props.min = min;
+ props.max = max;
+
+ if (!hasValueType) {
+ let precision = hasValidStep ? step : (isNullOrUndefined(min) ? min : 0);
+ precision = String(precision).split('.')?.[1]?.length || 0;
+ valueType = precision === 0 ? 'int' : 'float';
+ }
+
+ props.type = valueType;
+ props.step = valueStep;
+
+ data.props = props;
+ data.value = clampNumber(
+ isSafeNumber(data?.value) ? data.value : 0,
+ props.min,
+ props.max
+ );
+
+ return data;
+}
+
+/**
+ * A class that instantiates and manages a slider to select a single numeric value
+ * @class
+ * @alias module:SingleSlider
+ */
+export default class SingleSlider {
+ /**
+ * @desc describes the types that can be managed by this slider
+ * @type {Array}
+ * @static
+ * @constant
+ * @readonly
+ */
+ static AllowedTypes = Object.freeze(['int', 'float', 'decimal', 'numeric', 'percentage']);
+
+ /**
+ * @desc describes the HTMLElements = by way of their query selectors - assoc. with this instance
+ * @type {Record}
+ * @static
+ * @constant
+ */
+ static #Composition = {
+ input: '#value-input',
+ slider: '#slider-input',
+ progressBar: '#progress-bar',
+ };
+
+ /**
+ * @desc a Recordset containing a set of assoc. HTML frag templates
+ * @type {!Record>}
+ * @private
+ */
+ #templates = null;
+
+ /**
+ * @param {HTMLElement|string} obj Either (a) a HTMLElement assoc. with this instance, or (b) a query selector string to locate said element
+ * @param {Partial} data Should describe the properties & validation assoc. with this component, see {@link SliderData}
+ */
+ constructor(obj, data) {
+ let element;
+ if (isHtmlObject(obj)) {
+ element = obj;
+ } else if (typeof obj === 'string') {
+ if (!isValidSelector(obj)) {
+ throw new Error(`Query selector of Param is invalid`);
+ }
+
+ element = document.querySelector(obj);
+ }
+
+ if (!isHtmlObject(element)) {
+ throw new Error(`Failed to locate a valid assoc. HTMLElement with Params`);
+ }
+
+ /**
+ * @desc the ID attribute assoc. with this instance's `element` (`HTMLElement`), if applicable
+ * @type {?string}
+ * @default null
+ * @public
+ */
+ this.id = element.getAttribute('id') ?? null;
+
+ /**
+ * @desc the `HTMLElement` assoc. with this instance
+ * @type {!HTMLElement}
+ * @public
+ */
+ this.element = element;
+
+ /**
+ * @desc
+ * @type {!SliderData}
+ * @public
+ */
+ this.data = computeParams(data, SingleSlider.AllowedTypes);
+
+ /**
+ * @desc the current numeric value selected by the client
+ * @type {!number}
+ * @public
+ */
+ this.value = this.data.value;
+
+ /**
+ * @desc
+ * @type {!boolean}
+ * @default false
+ * @public
+ */
+ this.dirty = false;
+
+ /**
+ * @desc a Recordset containing the elements assoc. with this instance
+ * @type {!Record}
+ * @public
+ */
+ this.elements = this.#initialiseElements();
+ }
+
+
+ /*************************************
+ * *
+ * Getter *
+ * *
+ *************************************/
+ /**
+ * getValue
+ * @desc gets the current value of the component
+ * @returns {any} the value selected via its options data
+ */
+ getValue() {
+ return this.value;
+ }
+
+ /**
+ * getElement
+ * @returns {node} the assoc. element
+ */
+ getElement() {
+ return this.element;
+ }
+
+ /**
+ * isDirty
+ * @returns {bool} returns the dirty state of this component
+ */
+ isDirty() {
+ return this.dirty;
+ }
+
+
+ /*************************************
+ * *
+ * Setter *
+ * *
+ *************************************/
+ /**
+ * makeDirty
+ * @desc informs the top-level parent that we're dirty
+ * and updates our internal dirty state
+ * @return {object} return this for chaining
+ */
+ makeDirty() {
+ window.entityForm.makeDirty();
+ this.dirty = true;
+ return this;
+ }
+
+ /**
+ * setProgressBar
+ * @desc sets the current value of the component
+ * @param {any} value the value that should be selected from its list of options
+ */
+ setProgressBar() {
+ const distance = this.data.properties.max - this.data.properties.min;
+ const position = (this.value / distance) * 100;
+
+ this.elements.progressBar.style.background = `
+ linear-gradient(
+ to right,
+ var(--color-accent-semi-transparent) 0%,
+ var(--color-accent-primary) 0%,
+ var(--color-accent-primary) ${position}%,
+ var(--color-accent-semi-transparent) ${position}%
+ )
+ `;
+ }
+
+ /**
+ * @desc updates this instance's value
+ * @note
+ * - will ignore non-safe numbers
+ * - will constrain the number as specified by this instance's props
+ *
+ * @param {NumberLike} val some number-like value
+ *
+ * @returns {boolean} reflecting whether this instance's value was updating or not
+ */
+ setValue(val) {
+ const { min, max, type } = this.data.properties;
+ const parsed = parseAsFieldType({ validation: { type, properties: { min, max } } }, val)
+ if (!parsed || !parsed?.success) {
+ return false;
+ }
+
+ val = parsed.value;
+ if (!isSafeNumber(val)) {
+ return false;
+ }
+
+ const elements = this.elements;
+ elements.input.value = val;
+ elements.slider.value = val;
+ this.value = val;
+ this.setProgressBar();
+
+ if (val !== this.value) {
+ this.makeDirty();
+ }
+
+ return true;
+ }
+
+
+ /*************************************
+ * *
+ * Private *
+ * *
+ *************************************/
+ #collectTemplates() {
+ let templates = this.#templates;
+ if (isObjectType(templates)) {
+ return templates;
+ }
+
+ templates = { }
+ this.#templates = templates;
+
+ const elem = this.element.parentElement;
+ elem.querySelectorAll('template[data-name]').forEach(v => {
+ let name = v.getAttribute('data-name');
+ let view = v.getAttribute('data-view');
+ if (!stringHasChars(view)) {
+ view = 'base';
+ }
+
+ let group = templates?.[view];
+ if (!group) {
+ group = { };
+ templates[view] = group;
+ }
+
+ group[name] = v;
+ });
+
+ return templates;
+ }
+
+ #initialiseElements() {
+ const elem = this.element;
+ const data = this.data;
+ const props = data.properties;
+
+ const value = this.value;
+ const range = resolveRangeOpts(props.type, props);
+
+ const fieldValidation = { validation: { type: props.type } };
+ if (!isNullOrUndefined(range.values.min) && !isNullOrUndefined(range.values.max)) {
+ fieldValidation.validation.range = [range.values.min, range.values.max];
+ }
+
+ const templates = this.#collectTemplates();
+ composeTemplate(templates.inputs.number, {
+ params: {
+ id: 'value-input',
+ ref: 'value',
+ type: props.type,
+ step: range.attr.step,
+ label: 'Value',
+ btnStep: range.values.step,
+ rangemin: range.attr.min,
+ rangemax: range.attr.max,
+ value: value,
+ placeholder: 'Number value...',
+ disabled: '',
+ mandatory: true,
+ },
+ render: (obj) => {
+ obj = obj.shift();
+ obj = elem.appendChild(obj);
+
+ const input = obj.querySelector('input');
+ input.value = value;
+
+ input.addEventListener('change', (e) => {
+ let val = parseAsFieldType(fieldValidation, input.value);
+ if (!!val && val?.success) {
+ val = val.value;
+ } else {
+ val = this.value;
+ }
+
+ this.setValue(val);
+ });
+ },
+ });
+
+ const tree = { };
+ for (const [name, selector] of Object.entries(SingleSlider.#Composition)) {
+ const obj = elem.querySelector(selector);
+ if (!isHtmlObject(obj)) {
+ throw new Error(`Failed to find assoc. Element for Obj...\n${String(elem)}`);
+ }
+ tree[name] = obj;
+ }
+
+ tree.slider.min = props.min;
+ tree.slider.max = props.max;
+ tree.slider.step = props.step;
+ tree.slider.value = props.min;
+
+ tree.slider.addEventListener('input', (e) => {
+ let trg = e.target;
+ let val = parseAsFieldType(fieldValidation, trg.value);
+ if (!!val && val?.success) {
+ val = val.value;
+ } else {
+ val = this.value;
+ }
+
+ this.setValue(val);
+ });
+
+ this.setValue(this.value);
+ return tree;
+ }
+};
diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/tagify.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/tagify.js
index 63e4ab8be..d48f20390 100644
--- a/CodeListLibrary_project/cll/static/js/clinicalcode/components/tagify.js
+++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/tagify.js
@@ -12,20 +12,6 @@ const TAGIFY__DELAY = 1;
*/
const TAGIFY__TIMEOUT = 10;
-/**
- * TAGIFY__KEYCODES
- * @desc Keycodes used to navigate through the tag dropdown, and to add/remove tags
- */
-const TAGIFY__KEYCODES = {
- // Add tag
- 'ENTER': 13,
- // Remove tag
- 'BACK': 8,
- // Navigate tag dropdown
- 'DOWN': 40,
- 'UP': 38,
-};
-
/**
* TAGIFY__TAG_OPTIONS
* @desc Available options for the tagify component.
@@ -34,18 +20,25 @@ const TAGIFY__KEYCODES = {
*/
const TAGIFY__TAG_OPTIONS = {
// A predefined list of tags that can be used for autocomplete, or to control the input provided by the user
- 'items': [ ],
+ items: [ ],
// Whether to use the value or the name keys for autocomplete and tag selected components
- 'useValue': false,
+ useValue: false,
// Whether to perform autocomplete from a predefined list of items
- 'autocomplete': false,
+ autocomplete: false,
// Whether to allow users to input duplicate tags
- 'allowDuplicates': false,
- // Determines whether the user is restricted to the items within the predefined items list, or can input their own
- 'restricted': false,
+ allowDuplicates: false,
// Determines whether to show tooltips
// [!] Note: This option requires tooltipFactory.js as a dependency
- 'showTooltips': true,
+ showTooltips: true,
+ // Component behaviour
+ behaviour: {
+ // Determines whether the user is restricted to the items within the predefined items list, or can input their own
+ freeform: false,
+ // Describes how to format the tag when displaying it
+ format: {
+ component: '{name}',
+ },
+ },
};
/**
@@ -58,32 +51,37 @@ const TAGIFY__TAG_OPTIONS = {
* [!] Note: After the addition or removal of a tag, a custom event is dispatched to
* a the Tagify instance's element through the 'TagChanged' hook
*
- * e.g.
- ```js
- import Tagify from '../components/tagify.js';
-
- const tags = [
- {
- name: 'SomeTagName',
- value: 'SomeTagValue',
- },
- {
- name: 'SomeTagName',
- value: 'SomeTagValue',
- }
- ];
-
- const tagComponent = new Tagify('phenotype-tags', {
- 'autocomplete': true,
- 'useValue': false,
- 'allowDuplicates': false,
- 'restricted': true,
- 'items': tags,
- });
- ```
+ * @example
+ * import Tagify from '../components/tagify.js';
+ *
+ * const tags = [
+ * {
+ * name: 'SomeTagName',
+ * value: 'SomeTagValue',
+ * },
+ * {
+ * name: 'SomeTagName',
+ * value: 'SomeTagValue',
+ * }
+ * ];
+ *
+ * const tagComponent = new Tagify('phenotype-tags', {
+ * items: tags,
+ * useValue: false,
+ * restricted: true,
+ * autocomplete: true,
+ * allowDuplicates: false,
+ * });
*
*/
export default class Tagify {
+ /**
+ * @desc
+ * @type {Array}
+ * @private
+ */
+ #disposables = [];
+
constructor(obj, options, phenotype) {
this.uuid = generateUUID();
@@ -102,9 +100,9 @@ export default class Tagify {
this.fieldName = this.element.getAttribute('data-field');
}
- this.isBackspace = false;
- this.tags = [ ];
+ this.tags = [];
this.currentFocus = -1;
+ this.isBackspaceState = false;
this.#initialise(options, phenotype);
}
@@ -117,12 +115,35 @@ export default class Tagify {
/**
* getActiveTags
* @desc method to retrieve current tag data
- * @returns {list} a list of objects describing active tags
+ * @returns {Array} a list of objects describing active tags
*/
getActiveTags() {
return this.tags;
}
+ /**
+ * getDataValue
+ * @desc method to retrieve current tag data array
+ * @returns {Array} a list of data value(s)
+ */
+ getDataValue() {
+ if (!this.options?.behaviour?.freeform) {
+ return this.tags.reduce((res, x) => {
+ if (!isNullOrUndefined(x?.value)) {
+ res.push(x.value);
+ }
+
+ return res;
+ }, []);
+ }
+
+ return this.tags.reduce((res, x) => {
+ res.push({ name: x?.name, value: typeof x?.value === 'number' ? x.value : null });
+ return res;
+ }, []);
+ }
+
+
/*************************************
* *
* Setter *
@@ -136,7 +157,14 @@ export default class Tagify {
* @return {object} Returns a tag object
*/
addTag(name, value) {
- if (this.options.restricted) {
+ if (!stringHasChars(name)) {
+ return false;
+ }
+
+ name = strictSanitiseString(name);
+ value = typeof value === 'string' ? strictSanitiseString(value) : value;
+
+ if (!this.options?.behaviour?.freeform) {
const index = this.options.items.map(e => e.name.toLocaleLowerCase()).indexOf(name.toLocaleLowerCase());
if (index < 0) {
return false;
@@ -154,7 +182,7 @@ export default class Tagify {
if (elem) {
this.#wobbleElement(elem);
}
-
+
return false;
}
}
@@ -171,7 +199,7 @@ export default class Tagify {
return tag;
}
-
+
/**
* removeTag
* @desc removes a tag from the current list of tags
@@ -211,6 +239,24 @@ export default class Tagify {
delete this;
}
+ /**
+ * dispose
+ * @desc disposes events & objs assoc. with this cls
+ */
+ dispose() {
+ let disposable;
+ for (let i = this.#disposables.length; i > 0; i--) {
+ disposable = this.#disposables.pop();
+ if (typeof disposable !== 'function') {
+ continue;
+ }
+
+ disposable();
+ }
+
+ this.destroy();
+ }
+
/**
* getElement
* @desc returns this instance's target element, which can be used to determine whether a tag
@@ -232,15 +278,32 @@ export default class Tagify {
* @param {event} e the associated event
*/
#onClick(e) {
+ const trg = document.activeElement;
+ if (isNullOrUndefined(this.container) || isNullOrUndefined(trg) || !this.container.contains(trg)) {
+ return;
+ }
e.preventDefault();
- if (e.target.className == 'tag__remove') {
- this.removeTag(tryGetRootElement(e.target, 'tag'));
+ if (trg.className == 'tag__remove') {
+ this.removeTag(tryGetRootElement(trg, '.tag'));
}
-
this.field.focus();
}
+ /**
+ * onFocusIn
+ * @desc when the input box is focused by the client
+ *
+ * @param {event} e the associated event
+ */
+ #onFocusIn(e) {
+ this.#deselectHighlighted();
+
+ if (this.options.autocomplete) {
+ this.#tryPopulateAutocomplete();
+ }
+ }
+
/**
* onFocusLost
* @desc when the input box loses focus
@@ -248,14 +311,13 @@ export default class Tagify {
*/
#onFocusLost(e) {
this.#deselectHighlighted();
-
- const target = e.relatedTarget;
- if (target && target.classList.contains('autocomplete-item')) {
- const name = target.getAttribute('data-name');
+
+ const { relatedTarget } = e;
+ if (!!relatedTarget && this.autocomplete.contains(relatedTarget) && relatedTarget.classList.contains('autocomplete-item')) {
+ const name = relatedTarget.getAttribute('data-name');
this.addTag(name);
}
this.field.value = '';
-
this.#clearAutocomplete();
this.autocomplete.classList.remove('show');
}
@@ -263,6 +325,7 @@ export default class Tagify {
/**
* onKeyDown
* @desc handles events assoc. with the input box receiving a key down event
+ *
* @param {event} e the associated event
*/
#onKeyDown(e) {
@@ -270,82 +333,84 @@ export default class Tagify {
const target = e.target;
if (e.target.id == this.uuid) {
let name = target.value.trim();
- const code = e.which || e.keyCode;
+
+ const code = e.code;
switch (code) {
- case TAGIFY__KEYCODES.ENTER: {
+ case 'Enter':
+ case 'NumpadEnter': {
e.preventDefault();
e.stopPropagation();
if (this.currentFocus >= 0) {
name = this.#getFocusedName();
}
-
+
if (name === '') {
this.#deselectHighlighted();
break;
}
-
+
target.blur();
target.value = '';
-
+
this.addTag(name);
this.#clearAutocomplete(true);
-
+
if (this.timer)
clearTimeout(this.timer);
-
+
this.timer = setTimeout(() => {
target.focus();
}, TAGIFY__TIMEOUT);
} break;
-
- case TAGIFY__KEYCODES.BACK: {
+
+ case 'Backspace': {
if (name === '') {
this.#clearAutocomplete(true);
-
- if (!this.isBackspace) {
+
+ if (!this.isBackspaceState) {
this.#popTag();
}
- } else {
- if (this.options.autocomplete) {
- this.#tryPopulateAutocomplete(name);
- }
+
+ break;
+ }
+
+ if (this.options.autocomplete) {
+ this.#tryPopulateAutocomplete(name);
}
} break;
-
- case TAGIFY__KEYCODES.UP:
- case TAGIFY__KEYCODES.DOWN: {
+
+ case 'ArrowUp':
+ case 'ArrowDown': {
if (this.autocomplete.classList.contains('show')) {
e.preventDefault();
- this.currentFocus += (code == TAGIFY__KEYCODES.UP ? -1 : 1);
+ this.currentFocus += (code === 'ArrowUp' ? -1 : 1);
this.#focusAutocompleteElement();
}
} break;
-
+
+ case 'Tab':
+ this.#deselectHighlighted();
+ this.#clearAutocomplete(true);
+ if (!e.shiftKey) {
+ focusNextElement(this.field, 'next');
+ }
+ break;
+
default: {
this.#deselectHighlighted();
-
+
if (this.options.autocomplete) {
this.#tryPopulateAutocomplete(name);
}
-
} break;
}
}
- this.isBackspace = false;
+ this.isBackspaceState = false;
}, TAGIFY__DELAY)
}
- /**
- * onKeyUp
- * @desc handles events assoc. with the input box receiving a key up event
- * @param {event} e the associated event
- */
- #onKeyUp(e) {
-
- }
-
/*************************************
* *
* Private *
@@ -354,27 +419,28 @@ export default class Tagify {
/**
* initialise
* @desc responsible for the main initialisation & render of this component
+ *
* @param {dict} options the option parameter
* @param {dict|*} phenotype optional initialisation template
*/
async #initialise(options, phenotype) {
this.container = createElement('div', {
- 'className': 'tags-root-container',
+ className: 'tags-root-container',
});
this.tagbox = createElement('div', {
- 'className': 'tags-container',
+ className: 'tags-container',
});
this.autocomplete = createElement('div', {
- 'className': 'tags-autocomplete-container filter-scrollbar',
+ className: 'tags-autocomplete-container filter-scrollbar',
});
this.field = createElement('input', {
- 'type': 'text',
- 'className': 'tags-input-field',
- 'id': this.uuid,
- 'placeholder': this.element.placeholder || '',
+ id: this.uuid,
+ type: 'text',
+ className: 'tags-input-field',
+ placeholder: this.element.placeholder || '',
});
this.tagbox.appendChild(this.field);
@@ -386,11 +452,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);
+ }
});
}
@@ -400,13 +471,15 @@ export default class Tagify {
* through the input box using the backspace key
*/
#popTag() {
- if (this.isBackspace)
+ if (this.isBackspaceState) {
return;
-
- this.isBackspace = true;
- if (this.tags.length <= 0)
+ }
+
+ this.isBackspaceState = true;
+ if (this.tags.length <= 0) {
return;
-
+ }
+
const index = this.tags.length - 1;
const tag = this.tags[index];
if (!tag.element.classList.contains('tag__highlighted')) {
@@ -423,10 +496,16 @@ export default class Tagify {
* @desc binds the associated events to the rendered components
*/
#bindEvents() {
- this.container.addEventListener('click', this.#onClick.bind(this), false);
+ this.field.addEventListener('focusin', this.#onFocusIn.bind(this), false);
this.tagbox.addEventListener('focusout', this.#onFocusLost.bind(this), false);
this.tagbox.addEventListener('keydown', this.#onKeyDown.bind(this), false);
- this.tagbox.addEventListener('keyup', this.#onKeyUp.bind(this), false);
+
+ const clickHnd = this.#onClick.bind(this);
+ document.addEventListener('click', clickHnd);
+
+ this.#disposables.push(() => {
+ document.removeEventListener('click', clickHnd)
+ });
}
/**
@@ -445,7 +524,8 @@ export default class Tagify {
* @param {dict} phenotype the initialisation template
*/
async #buildOptions(options, phenotype) {
- this.options = mergeObjects(options, TAGIFY__TAG_OPTIONS);
+ options = isRecordType(options) ? options : { };
+ this.options = mergeObjects(options, TAGIFY__TAG_OPTIONS, true, true);
const hasFieldName = !isStringEmpty(this.fieldName);
const needsInitialiser = !isNullOrUndefined(phenotype) && (isNullOrUndefined(options?.items) || options?.items.length < 1);
@@ -454,17 +534,44 @@ export default class Tagify {
parameter: this.fieldName,
template: phenotype.template.id,
});
-
- const response = await fetch(
+
+ let hasWarnedClient = false;
+ const response = await fetchWithCtrl(
`${getCurrentURL()}?` + parameters,
{
method: 'GET',
headers: {
'X-Target': 'get_options',
'X-Requested-With': 'XMLHttpRequest',
- 'Cache-Control': 'max-age=28800',
- 'Pragma': 'max-age=28800',
- }
+ 'Cache-Control': 'max-age=300',
+ 'Pragma': 'max-age=300',
+ },
+ },
+ {
+ retries: 5,
+ backoff: 100,
+ onRetry: (retryCount, remainingTries) => {
+ if (!hasWarnedClient && retryCount >= 2) {
+ hasWarnedClient = true;
+ window.ToastFactory.push({
+ type: 'danger',
+ message: `We're struggling to connect to the server, if this persists you might not be able to save the form.`,
+ duration: 3500,
+ });
+ }
+ },
+ onError: (_err, _retryCount, remainingTries) => {
+ if (hasWarnedClient && remainingTries < 1) {
+ window.ToastFactory.push({
+ type: 'danger',
+ message: `We've not been able to connect to the server, please refresh the page and try again.`,
+ duration: 7000,
+ });
+ }
+
+ return true;
+ },
+ beforeAccept: (response, _retryCount, _remainingTries) => response.ok,
}
);
@@ -477,6 +584,14 @@ export default class Tagify {
throw new Error(`Expected tagify init data to be an object with a result array, got ${dataset}`);
}
+ if (hasWarnedClient) {
+ window.ToastFactory.push({
+ type: 'success',
+ message: `We have reestablished a connection with the server.`,
+ duration: 2000,
+ });
+ }
+
this.options.items = dataset.result;
}
@@ -497,6 +612,37 @@ export default class Tagify {
return '';
}
+ /**
+ * tryFmtName
+ * @desc attempts to format the tag item per the template field info
+ *
+ * @param {string|object} data the data assoc. with the tag
+ *
+ * @return {string} element name
+ */
+ #tryFmtName(data) {
+ let format = this.options?.behaviour?.format;
+ format = isRecordType(format) && stringHasChars(format?.component)
+ ? format.component
+ : '{name}';
+
+ let params;
+ if (isRecordType(data)) {
+ params = data;
+ } else {
+ params = { name: data };
+ }
+
+ let res;
+ try {
+ res = pyFormat(format, params);
+ } catch (e) {
+ res = stringHasChars(params?.name) ? params.name : 'TAG';
+ }
+
+ return res;
+ }
+
/*************************************
* *
* Render *
@@ -510,25 +656,34 @@ export default class Tagify {
* @returns {node} the tag
*/
#createTag(name, value) {
+ let label = this.options.items.find(x => x.name.toLocaleLowerCase() === name.toLocaleLowerCase());
+ if (!isNullOrUndefined(label)) {
+ label = this.#tryFmtName(label);
+ } else {
+ label = name;
+ }
+
const tag = createElement('div', {
- 'className': 'tag',
- 'data-value': value,
- 'innerHTML': `${name} × `
+ data: { value: value },
+ className: 'tag',
+ innerHTML: {
+ src: `${label} × `,
+ noSanitise: true,
+ },
});
this.tagbox.insertBefore(tag, this.field);
this.tags.push({
- 'element': tag,
- 'name': name,
- 'value': value,
+ name: name,
+ value: value,
+ element: tag,
});
if (this.options.showTooltips) {
- window.TooltipFactory.addTooltip(tag.querySelector('button'), 'Remove Tag', 'left');
+ window.TooltipFactory.addElement(tag.querySelector('button'), 'Remove Item', 'up');
}
this.#updateElement();
-
return tag;
}
@@ -550,14 +705,11 @@ export default class Tagify {
*/
#wobbleElement(elem) {
const method = getTransitionMethod();
- if (typeof method === 'undefined')
+ if (typeof method === 'undefined') {
return;
-
- elem.addEventListener(method, function handle(e) {
- elem.classList.remove('tag__wobble');
- elem.removeEventListener(e.type, handle, false);
- }, false);
+ }
+ elem.addEventListener(method, (e) => elem.classList.remove('tag__wobble'), { once: true });
elem.classList.add('tag__wobble');
}
@@ -588,14 +740,14 @@ export default class Tagify {
children[i].classList.remove('autocomplete-item__highlighted');
}
}
-
+
/**
* focusAutoCompleteElement
* @desc focuses an element in the autocomplete list by selecting the element by its index (based on sel id)
*/
#focusAutocompleteElement() {
this.#popFocusedElement();
-
+
const children = this.autocomplete.children;
const childLength = children.length;
this.currentFocus = this.currentFocus < 0 ? (childLength - 1) : (this.currentFocus >= childLength ? 0 : this.currentFocus);
@@ -604,7 +756,9 @@ export default class Tagify {
const element = children[this.currentFocus];
element.classList.add('autocomplete-item__highlighted');
- this.autocomplete.scrollTop = element.offsetTop;
+ if (!isScrolledIntoView(element, this.autocomplete, element.offsetHeight*0.5)) {
+ this.autocomplete.scrollTop = element.offsetTop;
+ }
}
}
@@ -616,19 +770,21 @@ export default class Tagify {
#generateAutocompleteElements(results) {
for (let i = 0; i < results.length; ++i) {
const data = results[i];
- const item = createElement('button', {
- 'className': 'autocomplete-item',
- 'data-value': data.value,
- 'data-name': data.name,
- });
-
- const text = createElement('span', {
- 'className': 'autocomplete-item__title',
- 'innerHTML': data.name,
+ createElement('a', {
+ class: 'autocomplete-item',
+ href: '#',
+ dataset: {
+ name: data.name,
+ value: data.value,
+ },
+ childNodes: [
+ createElement('span', {
+ className: 'autocomplete-item__title',
+ innerText: this.#tryFmtName(data),
+ })
+ ],
+ parent: this.autocomplete
});
-
- item.appendChild(text);
- this.autocomplete.appendChild(item);
}
}
@@ -636,40 +792,46 @@ export default class Tagify {
* tryPopulateAutocomplete
* @desc tries to determine which elements need to be rendered through fuzzymatching,
* and then renders the autocomplete elements
- * @param {string} value the search term to consider
+ *
+ * @param {string} [value=''] the search term to consider
+ *
* @returns
*/
- #tryPopulateAutocomplete(value) {
- if (value === '' || this.options.items.length <= 0) {
+ #tryPopulateAutocomplete(value = '') {
+ if (this.options.items.length < 1) {
this.#clearAutocomplete(true);
return;
}
- if (!this.haystack) {
- this.haystack = this.options.items.map(e => e.name);
- }
-
- let results = FuzzyQuery.Search(this.haystack, value, FuzzyQuery.Results.SORT, FuzzyQuery.Transformers.IgnoreCase);
- results.sort((a, b) => {
- if (a.score === b.score) {
- return 0;
- } else if (a.score > b.score) {
- return 1;
- } else if (a.score < b.score) {
- return -1;
+ let results;
+ if (stringHasChars(value)) {
+ if (!this.haystack) {
+ this.haystack = this.options.items.map(e => e.name);
}
- });
- results = results.map((e) => {
- const item = this.options.items.find(x => x.name.toLocaleLowerCase() === e.item.toLocaleLowerCase());
- return item;
- });
-
+ results = FuzzyQuery.Search(this.haystack, value, FuzzyQuery.Results.SORT, FuzzyQuery.Transformers.IgnoreCase);
+ results.sort((a, b) => {
+ if (a.score === b.score) {
+ return 0;
+ } else if (a.score > b.score) {
+ return 1;
+ } else if (a.score < b.score) {
+ return -1;
+ }
+ });
+
+ results = results.map((e) => {
+ const item = this.options.items.find(x => x.name.toLocaleLowerCase() === e.item.toLocaleLowerCase());
+ return item;
+ });
+ } else {
+ results = this.options.items.slice(0, this.options.items.length);
+ }
+
if (results.length > 0) {
this.#clearAutocomplete(false);
this.autocomplete.classList.add('show');
this.#generateAutocompleteElements(results);
-
return;
}
diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/virtualisedList/debouncedTask.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/tasks/debouncedTask.js
similarity index 65%
rename from CodeListLibrary_project/cll/static/js/clinicalcode/components/virtualisedList/debouncedTask.js
rename to CodeListLibrary_project/cll/static/js/clinicalcode/components/tasks/debouncedTask.js
index f9190451e..c17d5ef25 100644
--- a/CodeListLibrary_project/cll/static/js/clinicalcode/components/virtualisedList/debouncedTask.js
+++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/tasks/debouncedTask.js
@@ -1,7 +1,8 @@
/**
- * @class DebouncedTask
- * @desc Extensible fn to throttle / debounce method calls
+ * Extensible fn class to throttle / debounce method calls
*
+ * @class
+ * @constructor
*/
export default class DebouncedTask extends Function {
handle = null;
@@ -11,15 +12,37 @@ export default class DebouncedTask extends Function {
result = null;
params = { ctx: undefined, args: undefined };
lastCalled = 0;
+ resetSubsequent = false;
- constructor(task, delay) {
+ /**
+ * @param {Function} task some function to be called after the specified delay
+ * @param {number} [delay=100] optionally specify the debounce duration, i.e. the delay time, in milliseconds, before calling the fn
+ * @param {boolean} [resetSubsequent=false] optionally specify whether to reset the timeout after each subsequent call, otherwise the call time is dependent on when the initial call was made; defaults to `false`
+ */
+ constructor(task, delay = 100, resetSubsequent = false) {
assert(typeof(task) === 'function', `Expected fn for task but got "${typeof(task)}"`);
super();
+
this.task = task;
this.delay = (typeof(delay) === 'number' && !isNaN(delay)) ? Math.max(0, delay) : this.delay;
+ this.resetSubsequent = !!resetSubsequent;
+
+ const res = new Proxy(this, {
+ get: (target, key) => {
+ if (target?.[key]) {
+ return target[key];
+ } else {
+ return target.__inherit__[key];
+ }
+ },
+ apply: (target, thisArg, args) => {
+ return target?.__calL__.apply(target, args);
+ }
+ });
+ res.__inherit__ = DebouncedTask;
- return Object.setPrototypeOf(this.__proto__.__call__.bind(this), new.target.prototype);
+ return res;
}
@@ -65,9 +88,9 @@ export default class DebouncedTask extends Function {
if (isNullOrUndefined(hnd)) {
return;
}
+ this.handle = null;
clearTimeout(hnd);
- this.handle = null;
return this;
}
@@ -88,23 +111,25 @@ export default class DebouncedTask extends Function {
const delay = this.delay;
const elapsed = now - this.lastCalled;
- if (elapsed < delay && elapsed > 0) {
- let hnd = this?.handle;
- if (hnd) {
- clearTimeout(hnd);
- }
+ let hnd = this?.handle;
+ if (!!hnd) {
+ this.handle = null;
+ clearTimeout(hnd);
+ }
- this.handle = setTimeout(() => this.deferredCall(true), elapsed - delay);
+ if (elapsed < delay && elapsed > 0) {
+ this.handle = setTimeout(() => { this.deferredCall(true) }, elapsed - delay);
return;
}
- this.handle = null;
const { ctx, args = undefined } = this.params;
this.params.ctx = undefined;
this.params.args = undefined;
this.result = this.task.apply(ctx, args);
- this.lastCalled = now;
+ if (!this.resetSubsequent) {
+ this.lastCalled = now;
+ }
}
@@ -125,7 +150,7 @@ export default class DebouncedTask extends Function {
* @returns the last result (if any)
*
*/
- __call__(...args) {
+ __calL__(...args) {
const { ctx } = this.params;
if (ctx && this !== ctx) {
throw new Error('[DebouncedTask] Context mismatch');
@@ -134,8 +159,16 @@ export default class DebouncedTask extends Function {
this.params.ctx = this;
this.params.args = args;
+ const now = performance.now();
+ if (this.resetSubsequent) {
+ this.clear();
+ this.lastCalled = now;
+ }
+
if (isNullOrUndefined(this.handle)) {
- this.handle = setTimeout(this.deferredCall.bind(this), this.delay);
+ this.handle = setTimeout(() => {
+ this.deferredCall.bind(this)();
+ }, this.delay);
}
return this.result;
diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/virtualisedList/deferredThreadGroup.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/tasks/deferredThreadGroup.js
similarity index 92%
rename from CodeListLibrary_project/cll/static/js/clinicalcode/components/virtualisedList/deferredThreadGroup.js
rename to CodeListLibrary_project/cll/static/js/clinicalcode/components/tasks/deferredThreadGroup.js
index 3043f7ae6..16b0d5dea 100644
--- a/CodeListLibrary_project/cll/static/js/clinicalcode/components/virtualisedList/deferredThreadGroup.js
+++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/tasks/deferredThreadGroup.js
@@ -1,9 +1,9 @@
/**
- * @class DeferredThreadGroup
- * @desc Deferred group of threads, similar impl. to requestAnimationFrame
- *
- * See ref @ https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame
+ * Class describing a deferred group of threads, similar impl. to requestAnimationFrame
+ * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame|RAF}
*
+ * @class
+ * @constructor
*/
export default class DeferredThreadGroup {
#iHnd = 0;
@@ -57,13 +57,13 @@ export default class DeferredThreadGroup {
this.#silentlyThrow(e);
}
}
- }, Math.round(timeout))
+ }, Math.round(timeout));
}
const id = ++this.#iHnd;
this.#queue.push({ handle: id, callback: callback });
- return id
+ return id;
}
/**
diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/templateDetailRenderer.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/templateDetailRenderer.js
index ebc16f307..00870dfc4 100644
--- a/CodeListLibrary_project/cll/static/js/clinicalcode/components/templateDetailRenderer.js
+++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/templateDetailRenderer.js
@@ -43,7 +43,10 @@ const renderTemplateDetail = (parent, data) => {
const container = createElement('div', {
'className': 'template-detail__container',
- 'innerHTML': templateDetail
+ 'innerHTML': {
+ src: templateDetail,
+ noSanitise: true,
+ }
});
parent.appendChild(container);
diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/toastNotification.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/toastNotification.js
index 5e571d5a9..fa704f028 100644
--- a/CodeListLibrary_project/cll/static/js/clinicalcode/components/toastNotification.js
+++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/toastNotification.js
@@ -7,16 +7,45 @@ import TouchHandler from './touchHandler.js';
*
* The factory is accessible through the window object via window.ToastFactory
*
- * e.g.
- ```js
- window.ToastFactory.push({
- type: 'warning',
- message: 'Some warning message',
- duration: 2000, // 2s
- });
- ```
+ * @example
+ * window.ToastFactory.push({
+ * type: 'warning',
+ * message: 'Some warning message',
+ * duration: 2000, // 2s
+ * });
+ *
*/
class ToastNotificationFactory {
+ static Types = {
+ // Display success
+ success: 'success',
+
+ // Display caution
+ warn: 'warning',
+ caution: 'warning',
+ warning: 'warning',
+
+ // Display error
+ error: 'danger',
+ danger: 'danger',
+
+ // Display info
+ info: 'information',
+ information: 'information',
+
+ // Palette-based
+ primary: 'primary',
+ secondary: 'secondary',
+ tertiary: 'tertiary',
+
+ // Misc.
+ debug: 'anchor',
+ anchor: 'anchor',
+
+ bubble: 'highlight',
+ highlight: 'bubble',
+ };
+
constructor() {
this.#createContainer();
}
@@ -101,6 +130,15 @@ class ToastNotificationFactory {
* @returns {node} the toast notification
*/
#createToast(type, content, duration) {
+ if (!stringHasChars(type)) {
+ type = 'information';
+ }
+
+ type = type.toLowerCase();
+ type = ToastNotificationFactory.Types?.[type]
+ ? ToastNotificationFactory.Types[type]
+ : 'information'
+
const toast = createElement('div', {
'className': `toast toast--${type}`,
'role': 'alert',
diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/tooltipFactory.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/tooltipFactory.js
index 23924655e..ef834a6bc 100644
--- a/CodeListLibrary_project/cll/static/js/clinicalcode/components/tooltipFactory.js
+++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/tooltipFactory.js
@@ -7,7 +7,7 @@
*
* e.g.
```js
- window.TooltipFactory.addTooltip(
+ window.TooltipFactory.addElement(
// the element to observe
document.querySelector('.some-element'),
@@ -46,15 +46,36 @@ class TooltipFactory {
* *
*************************************/
/**
- * addTooltip
+ * addElement
* @desc adds a tooltip to an element
- * @param {node} elem the element we wish to observe
- * @param {string} tip the tooltip to present to the client
- * @param {string} direction the direction of the tooltip when active [right, left, up, down]
+ * @param {node} elem the element we wish to observe
+ * @param {string} tip optionally specify the tooltip to present to the client; defaults to data attribute of name `data-tipcontent` if not specified
+ * @param {string} direction optionally specify the direction of the tooltip when active [right, left, up, down]; defaults to `data-tipdirection` if not specified, or `right` if that fails
*/
- addTooltip(elem, tip, direction) {
+ addElement(elem, tip = null, direction = null) {
+ let trg = elem.getAttribute('data-tiptarget');
+ if (trg === 'parent') {
+ trg = elem.parentNode;
+ } else {
+ trg = elem;
+ }
+
const uuid = generateUUID();
- elem.setAttribute('data-tooltip', uuid);
+ trg.setAttribute('data-tooltip', uuid);
+
+ if (typeof tip !== 'string') {
+ tip = elem.getAttribute('data-tipcontent');
+ }
+
+ tip = strictSanitiseString(tip);
+ if (!stringHasChars(tip)) {
+ return;
+ }
+
+ if (typeof direction !== 'string') {
+ direction = elem.getAttribute('data-tipdirection');
+ direction = stringHasChars(direction) ? direction : 'right';
+ }
let tooltip = this.#createTooltip(tip, direction);
tooltip = this.element.appendChild(tooltip);
@@ -62,56 +83,92 @@ class TooltipFactory {
this.#tooltips[uuid] = tooltip;
+ const showTooltip = () => {
+ if (isNullOrUndefined(tooltip)) {
+ return;
+ }
+
+ tooltip.style.setProperty('display', 'block');
+
+ let span = tooltip.querySelector('span');
+ let height = window.getComputedStyle(span, ':after').getPropertyValue('height');
+ height = height.matchAll(/(\d+)px/gm);
+ height = Array.from(height, x => parseInt(x[1]))
+ .filter(x => !isNaN(x))
+ .shift();
+ height = height || 0;
+
+ const rect = trg.getBoundingClientRect();
+ switch (direction) {
+ case 'up': {
+ tooltip.style.left = `${rect.left + rect.width / 2}px`;
+ tooltip.style.top = `${rect.top + height / 2 + height / 4}px`;
+ } break;
+
+ case 'down': {
+ tooltip.style.left = `${rect.left + rect.width / 2}px`;
+ tooltip.style.top = `${rect.top + rect.height / 2 - height / 4}px`;
+ } break;
+
+ case 'right': {
+ tooltip.style.left = `${rect.left + rect.width}px`;
+ tooltip.style.top = `${rect.top + rect.height / 2 - height / 4}px`;
+ } break;
+
+ case 'left': {
+ tooltip.style.left = `${rect.left}px`;
+ tooltip.style.top = `${rect.top + rect.height / 2 - height / 4}px`;
+ } break;
+
+ default: {
+ tooltip.style.left = `${rect.left + rect.width / 2}px`;
+ tooltip.style.top = `${rect.top + rect.height - height / 4}px`;
+ } break;
+ }
+ };
+
+ const hideTooltip = () => {
+ if (!isNullOrUndefined(tooltip)) {
+ window.removeEventListener('focusout', blurTooltip, { once: true });
+ window.removeEventListener('pointermove', blurTooltip, { once: true });
+ window.removeEventListener('resize', blurTooltip, { once: true });
+
+ tooltip.style.setProperty('display', 'none');
+ }
+ };
+
+ const blurTooltip = (e) => {
+ const type = (!!e && typeof e === 'object' && 'type' in e) ? e.type : null;
+ if (type !== 'focusout' && document.activeElement === this.focusElement) {
+ document.activeElement.blur();
+ }
+
+ hideTooltip();
+ };
+
const methods = {
- enter: (e) => {
- if (isNullOrUndefined(tooltip)) {
+ longpress: (e) => {
+ if (e.pointerType !== 'touch') {
return;
}
- tooltip.style.setProperty('display', 'block');
-
- let span = tooltip.querySelector('span');
- let height = window.getComputedStyle(span, ':after').getPropertyValue('height');
- height = height.matchAll(/(\d+)px/gm);
- height = Array.from(height, x => parseInt(x[1]))
- .filter(x => !isNaN(x))
- .shift();
- height = height || 0;
-
- const rect = elem.getBoundingClientRect();
- switch (direction) {
- case 'up': {
- tooltip.style.left = `${rect.left + rect.width / 2}px`;
- tooltip.style.top = `${rect.top + height / 2}px`;
- } break;
- case 'down': {
- tooltip.style.left = `${rect.left + rect.width / 2}px`;
- tooltip.style.top = `${rect.top + rect.height - height / 4}px`;
- } break;
- case 'right': {
- tooltip.style.left = `${rect.left + rect.width}px`;
- tooltip.style.top = `${rect.top + rect.height - height / 4}px`;
- } break;
- case 'left': {
- tooltip.style.left = `${rect.left}px`;
- tooltip.style.top = `${rect.top + rect.height - height / 4}px`;
- } break;
- default: {
- tooltip.style.left = `${rect.left + rect.width / 2}px`;
- tooltip.style.top = `${rect.top + rect.height - height / 4}px`;
- } break;
- }
- },
- leave: (e) => {
- if (isNullOrUndefined(tooltip)) {
- return;
- }
- tooltip.style.setProperty('display', 'none');
+
+ e.preventDefault();
+
+ showTooltip();
+
+ this.focusElement.focus();
+ window.addEventListener('resize', blurTooltip, { once: true });
+ window.addEventListener('focusout', blurTooltip, { once: true });
+ window.addEventListener('pointerdown', blurTooltip, { once: true });
},
+ enter: () => showTooltip(),
+ leave: () => hideTooltip(),
};
this.#handlers[uuid] = methods;
- elem.addEventListener('mouseenter', methods.enter);
- elem.addEventListener('mouseleave', methods.leave);
+ trg.addEventListener('mouseenter', methods.enter);
+ trg.addEventListener('mouseleave', methods.leave);
+ trg.addEventListener('contextmenu', methods.longpress);
}
/**
@@ -139,6 +196,7 @@ class TooltipFactory {
if (!isNullOrUndefined(methods)) {
elem.removeEventListener('mouseenter', methods.enter);
elem.removeEventListener('mouseleave', methods.leave);
+ elem.removeEventListener('contextmenu', methods.longpress);
}
this.#handlers[uuid] = null;
@@ -159,6 +217,21 @@ class TooltipFactory {
className: 'tooltip-container',
});
+ const focusElem = document.createElement('input');
+ focusElem.setAttribute('id', 'ctx-focusable');
+ focusElem.setAttribute('type', 'text');
+ focusElem.setAttribute('aria-live', 'off');
+ focusElem.setAttribute('aria-hidden', 'true');
+ focusElem.style.display = 'block';
+ focusElem.style.position = 'absolute';
+ focusElem.style.width = '0';
+ focusElem.style.height = '0';
+ focusElem.style.opacity = 0;
+ focusElem.style.overflow = 'hidden';
+
+ this.element.appendChild(focusElem);
+ this.focusElement = focusElem;
+
document.body.prepend(this.element);
}
@@ -170,8 +243,11 @@ class TooltipFactory {
*/
#createTooltip(tip, direction) {
const container = createElement('div', {
- 'className': 'tooltip-container__item',
- 'innerHTML': ` `
+ className: 'tooltip-container__item',
+ innerHTML: {
+ src: ` `,
+ noSanitise: true,
+ }
});
return container;
diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/virtualisedList/index.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/virtualisedList/index.js
index 1dee69093..fbe8b5747 100644
--- a/CodeListLibrary_project/cll/static/js/clinicalcode/components/virtualisedList/index.js
+++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/virtualisedList/index.js
@@ -1,7 +1,3 @@
-/**
- * Aggregated export
- */
+export * as vlConst from './constants.js';
export { default } from './virtualList.js';
-export { default as DebouncedTask } from './debouncedTask.js';
-export { default as DeferredThreadGroup } from './deferredThreadGroup.js';
diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/virtualisedList/virtualList.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/virtualisedList/virtualList.js
index 0c8b1ca72..38d25fe79 100644
--- a/CodeListLibrary_project/cll/static/js/clinicalcode/components/virtualisedList/virtualList.js
+++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/virtualisedList/virtualList.js
@@ -1,7 +1,8 @@
-import DebouncedTask from './debouncedTask.js';
-import DeferredThreadGroup from './deferredThreadGroup.js';
import * as Constants from './constants.js';
+import DebouncedTask from '../tasks/debouncedTask.js';
+import DeferredThreadGroup from '../tasks/deferredThreadGroup.js';
+
/**
* boundaryComparator
* @desc default comparator for binary search
diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/cookies/cookie-settings.js b/CodeListLibrary_project/cll/static/js/clinicalcode/cookies/cookie-settings.js
index 05757fd80..7764db9c5 100644
--- a/CodeListLibrary_project/cll/static/js/clinicalcode/cookies/cookie-settings.js
+++ b/CodeListLibrary_project/cll/static/js/clinicalcode/cookies/cookie-settings.js
@@ -15,7 +15,7 @@ const cookieSettings = (privacyurl) => {
Otherwise, you can consent to our use of cookies by clicking "Save selection" .
- For more information about what information is collected and how it is shared with our partners, please read our Privacy and cookie policy .
+ For more information about what information is collected and how it is shared with our partners, please read our Privacy and cookie policy .
@@ -42,7 +42,7 @@ const cookieSettings = (privacyurl) => {
})
.catch((result) => {
// An error occurred somewhere (unrelated to button input)
- if (!(result instanceof ModalFactory.ModalResults)) {
+ if (!!result && !(result instanceof ModalFactory.ModalResults)) {
return console.error(result);
}
});
diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/cookies/generateGtag.js b/CodeListLibrary_project/cll/static/js/clinicalcode/cookies/generateGtag.js
index bea15059b..91bcc2b51 100644
--- a/CodeListLibrary_project/cll/static/js/clinicalcode/cookies/generateGtag.js
+++ b/CodeListLibrary_project/cll/static/js/clinicalcode/cookies/generateGtag.js
@@ -8,7 +8,6 @@ const generateTag = (type,configId, parameters) => {
gtag("js", new Date());
gtag("consent", type , parameters);
gtag("config", configId);
- console.log("gtag", type, configId, parameters);
};
const removeGATags = () => {
diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/data/conceptUtils.js b/CodeListLibrary_project/cll/static/js/clinicalcode/data/conceptUtils.js
index 05540eea8..75d60c55f 100644
--- a/CodeListLibrary_project/cll/static/js/clinicalcode/data/conceptUtils.js
+++ b/CodeListLibrary_project/cll/static/js/clinicalcode/data/conceptUtils.js
@@ -84,6 +84,7 @@ const applyCodelistsFromConcepts = (conceptData, options) => {
fixedColumns: false,
classes: {
wrapper: 'overflow-table-constraint',
+ container: 'datatable-container slim-scrollbar',
},
data: {
headings: headings,
diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/attributeSelectionService.js b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/attributeSelectionService.js
deleted file mode 100644
index 454f6d8d3..000000000
--- a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/attributeSelectionService.js
+++ /dev/null
@@ -1,1334 +0,0 @@
-/**
- * CSEL_VIEWS
- * @desc describes the view states of this component
- */
-
-const CSEL_VIEWS = {
- // The search view whereby users can select Concepts
- ATTRIBUTE_TABLE: 0,
-
- // The current selection view, only accessible when allowMultiple flag is set to true
- SELECTION: 1,
-};
-
-/**
- * CSEL_EVENTS
- * @desc used internally to track final state of dialogue
- */
-const CSEL_EVENTS = {
- // When a dialogue is cancelled without changes
- CANCELLED: 0,
-
- // When a dialogue is confirmed, with or without changes
- CONFIRMED: 1,
-};
-
-/**
- * CSEL_OPTIONS
- * @desc Available options for this component,
- * where each of the following options are used as default values
- * and are appended automatically if not overriden.
- */
-const CSEL_OPTIONS = {
- // Which template to query when retrieving accessible entities
- template: 1,
-
- concept_data: null,
-
- // Allow more than a single Concept to be selected
- allowMultiple: true,
-
- // Whether to remember the selection when previously opened
- // [!] Note: Only works when allowMultiple flag is set to true
- maintainSelection: true,
-
- // Flag to determine whether we scroll to the top of the result page when pagination occurs
- scrollOnResultChange: true,
-
- // The title of the prompt
- promptTitle: "Import Concepts",
-
- // The confirm button text
- promptConfirm: "Confirm",
-
- // The cancel button text
- promptCancel: "Cancel",
-
- // The size of the prompt (ModalFactory.ModalSizes.%s, i.e., {sm, md, lg})
- promptSize: "lg",
-
- // The message shown when no items are selected
- noneSelectedMessage: "You haven't selected any attributes yet",
-};
-
-/**
- * CSEL_BUTTONS
- * @desc The styleguide for the prompt's buttons
- */
-const CSEL_BUTTONS = {
- CONFIRM:
- '
',
- CANCEL:
- '
',
-};
-
-const CSEL_UTILITY_BUTTONS = {
- DELETE_BUTTON:
- '
',
-};
-
-/**
- * CSEL_INTERFACE
- * @desc defines the HTML used to render the selection interface
- */
-const CSEL_INTERFACE = {
- // Main dialogue modal
- DIALOGUE:
- ' \
-
\
-
\
-
',
-
- // Tabbed views when allowMultiple flag is active
- TAB_VIEW:
- ' \
-
\
-
\
- Attributed Concepts \
- All attributes \
-
\
-
\
-
\
-
',
-
- SELECTION_VIEW:
- ' \
-
',
-
- ATTRIBUTE_ACCORDION:
- ' \
-
\
-
\
-
\
- ${title} \
- \
-
\
- ${content} \
- \
-
',
-};
-
-
-/**
- * AttributeSelectionService class provides methods to manage and manipulate attribute data for clinical concepts.
- * It allows for the creation, validation, and rendering of attribute data within a dialogue interface.
- *
- * @class
- * @example
- * const service = new AttributeSelectionService(options);
- * service.show();
- */
-export class AttributeSelectionService {
- static Views = CSEL_VIEWS;
-
- constructor(options) {
- this.options = mergeObjects(options || {}, CSEL_OPTIONS);
- this.attribute_component = options.attribute_component;
-
- this.temporarly_concept_data = JSON.parse(
- JSON.stringify(options.concept_data)
- );
-
- for (let j = 0; j < this.temporarly_concept_data.length; j++) {
- if (this.temporarly_concept_data[j].attributes) {
- this.temporarly_concept_data[j].attributes.forEach((attribute) => {
- attribute.id = this.#generateUUID();
- attribute.type = this.#typeDeconversion(attribute.type);
- });
- } else {
- if (this.temporarly_concept_data[0].attributes) {
- this.temporarly_concept_data[j].attributes =
- this.temporarly_concept_data[0].attributes.map((attribute) => ({
- ...attribute,
- value: " ",
- }));
- } else {
- this.temporarly_concept_data[j].attributes = [];
- this.temporarly_concept_data[j].attributes.push({
- id: this.#generateUUID(),
- name: "Attribute test name",
- type: "1",
- value: " ",
- });
- }
- }
- }
- this.attribute_data = [];
- this.temporarly_concept_data[0].attributes.forEach((attribute) => {
- this.attribute_data.push({
- id: attribute.id,
- name: attribute.name,
- type: attribute.type,
- });
- });
- }
-
- /*************************************
- * *
- * Getter *
- * *
- *************************************/
- /**
- /**
- * getSelection
- * @desc gets the currently selected concepts
- * @returns {array} the assoc. data
- */
- getAttributeData() {
- return this.attribute_data;
- }
-
- /**
- * isOpen
- * @desc reflects whether the dialogue is currently open
- * @returns {boolean} whether the dialogue is open
- */
- isOpen() {
- return !!this.dialogue;
- }
-
- /**
- * getDialogue
- * @desc get currently active dialogue, if any
- * @returns {object} the dialogue and assoc. elems/methods
- */
- getDialogue() {
- return this.dialogue;
- }
-
- /**
- * isSelected
- * @param {number} childId
- * @param {number} childVersion
- * @returns {boolean} that reflects the selected state of a Concept
- */
- isSelected(childId, childVersion) {
- if (isNullOrUndefined(this.dialogue?.data)) {
- return false;
- }
-
- return !!this.dialogue.data.find((item) => {
- return item.id == childId && item.history_id == childVersion;
- });
- }
-
- /*************************************
- * *
- * Public *
- * *
- *************************************/
- /**
- * show
- * @desc shows the dialogue
- * @param {enum|int} view the view to open the modal with
- * @param {object|null} params query parameters to be provided to server to modify Concept results
- * @returns {promise} a promise that resolves if the selection was confirmed, otherwise rejects
- */
- show(view = CSEL_VIEWS.ATTRIBUTE_TABLE, params) {
- params = params || {};
-
- // Reject immediately if we currently have a dialogue open
- if (this.dialogue) {
- return Promise.reject();
- }
-
- return new Promise((resolve, reject) => {
- this.#buildDialogue(params);
- this.#renderView(view);
- this.#createGridTable(this.temporarly_concept_data);
- const tableElement = document.querySelector("#tab-content table");
- this.#invokeGridElements(tableElement);
-
- this.dialogue.element.addEventListener("selectionUpdate", (e) => {
- this.close();
-
- const detail = e.detail;
- const eventType = detail.type;
- const data = detail.data;
- switch (eventType) {
- case CSEL_EVENTS.CONFIRMED:
- {
- if (
- this.options.allowMultiple &&
- this.options.maintainSelection
- ) {
- this.attribute_data = data;
- }
-
- if (this.options.allowMultiple) {
- resolve(data);
- return;
- }
- resolve(data?.[0]);
- }
- break;
-
- case CSEL_EVENTS.CANCELLED:
- {
- reject();
- }
- break;
-
- default:
- break;
- }
- });
-
- this.dialogue.show();
- });
- }
-
- /**
- * close
- * @desc closes the dialogue if active
- */
- close() {
- if (this.dialogue) {
- this.dialogue.close();
- }
-
- return this;
- }
-
- /*************************************
- * *
- * Render *
- * *
- *************************************/
- /**
- * buildDialogue
- * @desc renders the top-level modal according to the options given
- * @param {object} params the given query params
- * @returns {object} the dialogue object as assigned to this.dialogue
- */
- #buildDialogue(params) {
- // create dialogue
- const currentHeight = window.scrollY;
- let html = interpolateString(CSEL_INTERFACE.DIALOGUE, {
- id: 'attribute-concepts',
- promptTitle: this.options?.promptTitle,
- promptSize: this.options?.promptSize,
- hidden: "false",
- },true);
-
- let doc = parseHTMLFromString(html,true);
- let modal = document.body.appendChild(doc[0]);
-
- // create footer
- let footer = createElement("div", {
- id: "target-modal-footer",
- class: "target-modal__footer",
- });
-
- const container = modal.querySelector(".target-modal__container");
- footer = container.appendChild(footer);
-
- // create buttons
- const buttons = {};
- let confirmBtn = parseHTMLFromString(CSEL_BUTTONS.CONFIRM,true);
- confirmBtn = footer.appendChild(confirmBtn[0]);
- confirmBtn.innerText = this.options.promptConfirm;
-
- let cancelBtn = parseHTMLFromString(CSEL_BUTTONS.CANCEL,true);
- cancelBtn = footer.appendChild(cancelBtn[0]);
- cancelBtn.innerText = this.options.promptCancel;
-
- buttons["confirm"] = confirmBtn;
- buttons["cancel"] = cancelBtn;
-
- // initiate main event handling
- buttons?.confirm.addEventListener("click", this.#handleConfirm.bind(this));
- buttons?.cancel.addEventListener("click", this.#handleCancel.bind(this));
-
- // create content handler
- const body = container.querySelector("#target-modal-content");
- if (this.options?.allowMultiple) {
- body.classList.add("target-modal__body--no-pad");
- body.classList.add("target-modal__body--constrained");
- }
-
- let contentContainer = body;
- if (this.options.allowMultiple) {
- html = CSEL_INTERFACE.TAB_VIEW;
- doc = parseHTMLFromString(html,true);
- contentContainer = body.appendChild(doc[0]);
-
- const tabs = contentContainer.querySelectorAll("button.tab-view__tab");
- for (let i = 0; i < tabs.length; ++i) {
- tabs[i].addEventListener("click", this.#changeTabView.bind(this));
- }
-
- contentContainer = contentContainer.querySelector("#tab-content");
- }
-
- // build dialogue
- this.dialogue = {
- // data
- data: this.options?.maintainSelection ? this.attribute_data : [],
- params: params,
- view: CSEL_VIEWS.ATTRIBUTE_TABLE,
-
- // dialogue elements
- element: modal,
- buttons: buttons,
- content: contentContainer,
-
- // dialogue methods
- show: () => {
- createElement("a", { href: `#attribute-concepts` }).click();
- window.scrollTo({
- top: currentHeight,
- left: window.scrollX,
- behaviour: "instant",
- });
-
- // inform screen readers of alert
- modal.setAttribute("aria-hidden", false);
- modal.setAttribute("role", "alert");
- modal.setAttribute("aria-live", true);
-
- // stop body scroll
- document.body.classList.add("modal-open");
- },
- close: () => {
- this.dialogue = null;
-
- document.body.classList.remove("modal-open");
- modal.remove();
- history.replaceState({}, document.title, "#");
- window.scrollTo({
- top: currentHeight,
- left: window.scrollX,
- behaviour: "instant",
- });
- },
- };
-
- return this.dialogue;
- }
-
- /**
- * Creates and renders a grid table with the provided concept data.
- *
- * This method initializes a grid table using the Grid.js library and populates it with the given concept data.
- * If the concept data contains attributes, it transforms the data to include these attributes and updates the grid table configuration.
- *
- * @param {Array} concept_data - An array of concept objects. Each concept object should have the following structure:
- * {
- * is_new: {boolean}, // Indicates if the concept is new
- * details: {
- * name: {string}, // The name of the concept
- * phenotype_owner: {string}, // The owner of the phenotype
- * phenotype_owner_history_id: {number}, // The history ID of the phenotype owner
- * },
- * concept_id: {number}, // The ID of the concept
- * attributes: {Array} // An array of attribute objects, each with a 'value' property
- * }
- */
- #createGridTable(concept_data) {
- document.getElementById("tab-content").innerHTML = "";
-
- const table = new gridjs.Grid({
- columns: ["Concept"],
- data: concept_data.map((concept) => [
- concept.is_new
- ? `${concept.details.name}`
- : `${concept.details.phenotype_owner}/${concept.details.phenotype_owner_history_id}/${concept.concept_id} - ${concept.details.name}`,
- ]),
- }).render(document.getElementById("tab-content"));
-
- if (!concept_data.every((concept) => !concept.attributes)) {
- const transformedData = [];
- for (let i = 0; i < concept_data.length; i++) {
- const concept = concept_data[i];
- const rowData = [
- concept.is_new
- ? `${concept.details.name}`
- : `${concept.details.phenotype_owner}/${concept.details.phenotype_owner_history_id}/${concept.concept_id} - ${concept.details.name}`,
- ];
- if (concept.attributes) {
- for (let j = 0; j < concept.attributes.length; j++) {
- let attribute = concept.attributes[j];
- attribute.attributes = (cell) => {
- if (cell) {
- let styleText;
- let innertText;
- if (cell.trim() === "") {
- styleText = "color: grey;";
- innertText = "Enter value";
- }
- return {
- style: "cursor: pointer;" + styleText,
- contenteditable: true,
- innerText: innertText,
- onfocus: (e) => {
- if (e.target.innerText === "Enter value") {
- e.target.innerText = "";
- e.target.style.color = "black";
- }
- },
- onblur: (e) => {
- if (e.target.innerText.trim() === "") {
- e.target.innerText = "Enter value";
- e.target.style.color = "grey";
- } else {
- e.target.style.color = "black";
- }
- },
- };
- }
- };
- rowData.push(concept.attributes[j].value);
- }
- }
- transformedData.push(rowData);
- }
- const columns = ["Concept"];
- if (concept_data[0].attributes) {
- concept_data[0].attributes.forEach((attribute) => {
- columns.push(attribute);
- });
- }
-
- table
- .updateConfig({
- columns: columns,
- data: transformedData,
- })
- .forceRender();
- }
- }
-
- /**
- * Adds event listeners to table cells for editing and updates the concept data accordingly.
- *
- * @param {HTMLElement} tableElement - The table element containing the rows and cells to be edited.
- */
- #addCellEditListeners(tableElement) {
- const rows = tableElement.querySelector("tbody").childNodes;
-
- const changedCell = [];
- for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
- const row = rows[rowIndex];
- const columns = row.querySelectorAll("td");
-
- for (let columnIndex = 1; columnIndex < columns.length; columnIndex++) {
- // Skip the first column (Concept)
- const tempCol = columns[columnIndex];
- if (tempCol.innerText !== "" && tempCol.innerText !== "Enter value") {
- changedCell.push({
- row: rowIndex,
- column: columnIndex,
- value: tempCol.innerText,
- });
- } else {
- changedCell.push({
- row: rowIndex,
- column: columnIndex,
- value: " ",
- });
- }
- }
- }
- for (let i = 0; i < changedCell.length; i++) {
- const cell = changedCell[i];
- const concept = this.temporarly_concept_data[cell.row];
- concept.attributes[cell.column - 1] = {
- id: concept.attributes[cell.column - 1].id,
- value: cell.value,
- name: concept.attributes[cell.column - 1].name,
- type: concept.attributes[cell.column - 1].type,
- };
- }
- }
-
- /**
- * Validates the input value based on the specified type and attribute name.
- *
- * @param {string} targetInput - The input value to be validated.
- * @param {string} type - The type of the attribute (e.g., "1" for integer, "2" for string, "3" for float).
- * @param {string} attributeName - The name of the attribute being validated.
- * @param {number} rIndex - The row index of the attribute in the table.
- * @returns {boolean} - Returns true if the input is valid, otherwise false.
- */
- #cellValidation(targetInput, type, attributeName, rIndex) {
- if (targetInput.trim() === "") {
- return true;
- }
- if (!/^[a-zA-Z0-9]+$/.test(targetInput) && type === "2") {
- this.#pushToast({
- type: "danger",
- message: `Attribute ${attributeName} with row index ${rIndex} is not a string`,
- });
- return false;
- }
- if (isNaN(targetInput) && type === "1") {
- this.#pushToast({
- type: "danger",
- message: `Attribute ${attributeName} with row index ${rIndex} is not an integer`,
- });
- return false;
- }
- if (!/^[-+]?[0-9]*\.?[0-9]+$/.test(targetInput) && type === "3") {
- this.#pushToast({
- type: "danger",
- message: `Attribute ${attributeName} with row index ${rIndex} is not a float`,
- });
- return false;
- }
- return true;
- }
-
- /**
- * renderView
- * @desc renders the given view
- * @param {enum|int} view the view to render within the active dialogue
- */
- #renderView(view) {
- if (!this.isOpen()) {
- return;
- }
-
- if (!this.options.allowMultiple && view == CSEL_VIEWS.SELECTION) {
- view = CSEL_VIEWS.ATTRIBUTE_TABLE;
- }
- this.dialogue.view = view;
-
- const content = this.dialogue?.content;
- if (!isNullOrUndefined(content)) {
- content.innerHTML = "";
- }
-
- if (this.options.allowMultiple) {
- this.#pushActiveTab(view);
- }
-
- switch (view) {
- case CSEL_VIEWS.ATTRIBUTE_TABLE:
- {
- this.#createGridTable(this.temporarly_concept_data);
- const tableElement = document.querySelector("#tab-content table");
- this.#invokeGridElements(tableElement);
- }
- break;
-
- case CSEL_VIEWS.SELECTION:
- {
- this.#renderSelectionView();
- }
- break;
-
- default:
- break;
- }
- }
-
- /**
- * renderSelectionView
- * @desc renders the selection view where users can manage their currently selected concepts
- */
- #renderSelectionView() {
- // Draw page
- let html = interpolateString(CSEL_INTERFACE.SELECTION_VIEW, {
- noneSelectedMessage: this.options?.noneSelectedMessage,
- },true);
-
- let doc = parseHTMLFromString(html,true);
- let page = this.dialogue.content.appendChild(doc[0]);
- this.dialogue.page = page;
-
- this.#paintSelectionAttributes();
- }
-
- /**
- * pushActiveTab
- * @desc updates the tab view objects when allowMultiple flag is true
- * @param {int|enum} view an enum of CSEL_VIEWS
- */
- #pushActiveTab(view) {
- let tabs = this.dialogue.element.querySelectorAll("button.tab-view__tab");
- for (let i = 0; i < tabs.length; ++i) {
- let tab = tabs[i];
- let relative = tab.getAttribute("id");
- if (!CSEL_VIEWS.hasOwnProperty(relative)) {
- continue;
- }
-
- relative = CSEL_VIEWS[relative];
- if (relative == view) {
- tab.classList.add("active");
- } else {
- tab.classList.remove("active");
- }
- }
- }
-
- /*************************************
- * *
- * Events *
- * *
- *************************************/
-
- /**
- * handleConfirm
- * @desc handles the confirmation btn
- * @param {event} e the assoc. event
- */
- #handleConfirm(e) {
- if (!this.isOpen()) {
- return;
- }
-
- // Clean up the attributes in concept_data and validate
- let validated = true;
-
- for (let i = 0; i < this.temporarly_concept_data.length; i++) {
- const concept = this.temporarly_concept_data[i];
- if (concept.attributes) {
- for (let j = 0; j < concept.attributes.length; j++) {
- const attribute = concept.attributes[j];
- if (
- !this.#cellValidation(
- attribute.value,
- attribute.type,
- attribute.name,
- i + 1
- )
- ) {
- validated = false;
- }
- }
- }
- }
-
- if (validated) {
- this.temporarly_concept_data.forEach((concept) => {
- if (concept.attributes) {
- concept.attributes = concept.attributes.map((attr) => ({
- name: attr.name,
- value: attr.value,
- type: this.#typeConversion(attr.type),
- }));
- }
- });
-
- this.options.concept_data = JSON.parse(
- JSON.stringify(this.temporarly_concept_data)
- );
-
- const event = new CustomEvent("selectionUpdate", {
- detail: {
- data: this.options.concept_data,
- type: CSEL_EVENTS.CONFIRMED,
- },
- });
- this.dialogue?.element.dispatchEvent(event);
- } else {
- return;
- }
- }
-
- /**
- * handleCancel
- * @desc handles the cancel/exit btn
- * @param {event} e the assoc. event
- */
- #handleCancel(e) {
- if (!this.isOpen()) {
- return;
- }
-
- const event = new CustomEvent("selectionUpdate", {
- detail: {
- type: CSEL_EVENTS.CANCELLED,
- },
- });
- this.dialogue?.element.dispatchEvent(event);
- }
-
- /**
- * Generates a UUID (Universally Unique Identifier).
- * The UUID is in the format of "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".
- *
- * @returns {string} A randomly generated UUID.
- */
- #generateUUID() {
- return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(
- /[xy]/g,
- function (c) {
- var r = (Math.random() * 16) | 0,
- v = c == "x" ? r : (r & 0x3) | 0x8;
- return v.toString(16);
- }
- );
- }
-
- /**
- * Handles the creation of a new attribute.
- *
- * This method is triggered by an event and performs the following steps:
- * 1. Disables the "add attribute" button.
- * 2. Parses the attribute component and generates a unique ID for the new attribute.
- * 3. Interpolates the attribute component with the unique ID.
- * 4. If there are no existing attributes, it creates a new attribute row and appends it to the page.
- * 5. If there are existing attributes, it creates a new attribute row and appends it to the page.
- * 6. Initializes a new attribute object with default values.
- * 7. Invokes methods to handle attribute inputs and buttons.
- *
- * @param {Event} e - The event object that triggered the attribute creation.
- * @private
- */
- #handleAttributeCreation(e) {
- const page = this.dialogue.page;
- page.querySelector("#add-attribute-btn").setAttribute("disabled", true);
- const noneAvailable = page.querySelector("#no-items-selected");
-
- let attribute_progress = parseHTMLFromString(this.attribute_component,true);
- const uniqueId = this.#generateUUID();
- attribute_progress = interpolateString(this.attribute_component, {
- id: uniqueId,
- });
-
- if (this.attribute_data.length <= 0) {
- let attributerow = interpolateString(CSEL_INTERFACE.ATTRIBUTE_ACCORDION, {
- id: uniqueId,
- title: `New attribute value`,
- content: attribute_progress,
- },true);
- let doc = parseHTMLFromString(attributerow);
- noneAvailable.classList.remove("show");
- page.appendChild(doc[0]);
- } else {
- let attributerow = interpolateString(CSEL_INTERFACE.ATTRIBUTE_ACCORDION, {
- id: uniqueId,
- title: "New attribute value",
- content: attribute_progress,
- },true);
- let doc = parseHTMLFromString(attributerow);
- page.appendChild(doc[0]);
- }
-
- let attribute = {
- id: uniqueId,
- name: "",
- type: "-1",
- value: " ",
- };
-
- attribute = this.#invokeAttributeInputs(attribute, page);
-
- this.#invokeAttributeButtons(attribute, page);
- }
-
- /**
- * Invokes event listeners for attribute buttons on the given page.
- *
- * @param {Object} attribute - The attribute object containing the id.
- * @param {HTMLElement} page - The page element where the buttons are located.
- * @private
- */
- #invokeAttributeButtons(attribute, page) {
- const confirmChanges = page.querySelector(
- "#confirm-changes-" + attribute.id
- );
- confirmChanges.addEventListener("click", () =>
- this.#handleConfirmEditor(attribute)
- );
-
- const cancelChanges = page.querySelector("#cancel-changes-" + attribute.id);
- cancelChanges.addEventListener("click", () =>
- this.#handleCancelEditor(attribute)
- );
-
- if (page.querySelector("#children-button-" + attribute.id)) {
- const deleteAttributeButton = page.querySelector(
- "#children-button-" + attribute.id
- );
- deleteAttributeButton.addEventListener("click", () =>
- this.#deleteAttribute(attribute)
- );
- }
- }
-
- /**
- * Invokes grid elements within the specified table element.
- *
- * This method adds event listeners to each cell in the table to handle focus and blur events.
- * When a cell gains focus and contains the text "Enter value", the text is cleared.
- * When a cell loses focus and is empty, the text "Enter value" is set.
- * Additionally, an input event listener is added to the table element to call the
- * `#addCellEditListeners` method.
- *
- * @param {HTMLElement} tableElement - The table element containing the grid cells.
- * @private
- */
- #invokeGridElements(tableElement) {
- if (tableElement) {
- tableElement.querySelectorAll("td").forEach((cell) => {
- cell.addEventListener("focus", (e) => {
- if (e.target.innerText === "Enter value") {
- e.target.innerText = "";
- }
- });
-
- cell.addEventListener("blur", (e) => {
- if (e.target.innerText.trim() === "") {
- e.target.innerText = "Enter value";
- }
- });
- });
- tableElement.addEventListener("input", (e) => {
- this.#addCellEditListeners(tableElement);
- });
- }
- }
-
- /**
- * Invokes attribute inputs and sets up event listeners for attribute name and type changes.
- *
- * @param {Object} attribute - The attribute object to be updated.
- * @param {HTMLElement} page - The page element containing the input fields.
- * @returns {Object} The updated attribute object.
- */
- #invokeAttributeInputs(attribute, page) {
- const attribute_name_input = page.querySelector(
- "#attribute-name-input-" + attribute.id
- );
- const attribute_type = page.querySelector(
- "#attribute-type-" + attribute.id
- );
-
- attribute_type.addEventListener("change", () => {
- attribute.type = attribute_type.value;
- });
-
- attribute_name_input.addEventListener("input", () => {
- attribute.name = `${attribute_name_input.value}`;
- });
-
- return attribute;
- }
-
- /**
- * Converts a given type identifier to its corresponding type string.
- *
- * @param {string} type - The type identifier to be converted. Expected values are:
- * "1" for integer (INT),
- * "2" for string (STRING),
- * "3" for float (FLOAT).
- * @returns {string} The corresponding type string ("INT", "STRING", "FLOAT").
- */
- #typeConversion(type) {
- switch (type) {
- case "1":
- return "INT";
- case "2":
- return "STRING";
- case "3":
- return "FLOAT";
- }
- }
-
- /**
- * Converts a given type to its corresponding string representation.
- *
- * @param {string} type - The type to be converted. Possible values are "INT", "STRING", and "FLOAT".
- * @returns {string} - The string representation of the type. "1" for "INT", "2" for "STRING", and "3" for "FLOAT".
- */
- #typeDeconversion(type) {
- switch (type) {
- case "INT":
- return "1";
- case "STRING":
- return "2";
- case "FLOAT":
- return "3";
- }
- }
-
- /**
- * Deletes the specified attribute from the attribute data and updates the UI accordingly.
- *
- * @param {Object} attribute - The attribute object to be deleted.
- * @param {number} attribute.id - The unique identifier of the attribute.
- * @param {string} attribute.name - The name of the attribute.
- *
- * @private
- */
- #deleteAttribute(attribute) {
- const page = this.dialogue.page;
-
- const accordion = page.querySelector(
- "#attribute-accordion-" + attribute.id
- );
-
- const noneAvailable = page.querySelector("#no-items-selected");
-
- const indexToDelete = this.attribute_data.findIndex(
- (attr) => attr.id === attribute.id
- );
- if (indexToDelete !== -1) {
- // Remove the attribute from attribute_data
- this.attribute_data.splice(indexToDelete, 1);
-
- // Remove the related attributes from concept_data
- this.temporarly_concept_data.forEach((concept) => {
- if (concept.attributes) {
- concept.attributes = concept.attributes.filter(
- (attr) => attr.name !== attribute.name
- );
- }
- });
-
- // Remove the accordion element
- accordion.remove();
-
- // Show the "no items selected" message if there are no attributes left
- if (
- this.attribute_data.length <= 0 &&
- page.querySelectorAll(".fill-accordion").length <= 0
- ) {
- noneAvailable.classList.add("show");
- }
-
- this.#pushToast({
- type: "danger",
- message: "Attribute has been deleted",
- });
- }
- }
-
- /**
- * Displays a toast notification with the specified type, message, and duration.
- *
- * @param {Object} options - The options for the toast notification.
- * @param {string} [options.type="information"] - The type of the toast notification (e.g., "information", "error").
- * @param {string|null} [options.message=null] - The message to display in the toast notification.
- * @param {string|number} [options.duration="5000"] - The duration for which the toast notification should be displayed (in milliseconds).
- */
- #pushToast({ type = "information", message = null, duration = "5000" }) {
- if (isNullOrUndefined(message)) {
- return;
- }
-
- window.ToastFactory.push({
- type: type,
- message: message,
- duration: Math.max(duration, "444"),
- });
- }
-
- /**
- * Handles the confirmation of the attribute editor.
- * Validates the attribute data, updates or adds the attribute to the attribute_data,
- * updates the concept_data with the updated attribute, and updates the UI accordingly.
- *
- * @param {Object} attribute - The attribute object to be confirmed.
- * @param {string} attribute.id - The unique identifier of the attribute.
- * @param {string} attribute.name - The name of the attribute.
- * @param {string} attribute.type - The type of the attribute.
- * @private
- */
- #handleConfirmEditor(attribute) {
- // Validate the concept data
- if (!attribute || attribute.name === "") {
- this.#pushToast({
- type: "danger",
- message: "Attribute name cannot be empty",
- });
- return;
- }
-
- if (attribute.type === "-1") {
- this.#pushToast({ type: "danger", message: "Please select a type" });
- return;
- }
-
- // Check if the attribute already exists in attribute_data
- const existingAttributeIndex = this.attribute_data.findIndex(
- (attr) => attr.id === attribute.id
- );
-
- if (existingAttributeIndex !== -1) {
- // Update the existing attribute with the new name and type if they have changed
- const existingAttribute = this.attribute_data[existingAttributeIndex];
- if (
- existingAttribute.name !== attribute.name ||
- existingAttribute.type !== attribute.type
- ) {
- existingAttribute.name = attribute.name;
- existingAttribute.type = attribute.type;
- }
- } else {
- const existingAttributeIndex = this.attribute_data.findIndex(
- (attr) => attr.name.trim() === attribute.name.trim()
- );
- if (existingAttributeIndex !== -1) {
- this.#pushToast({
- type: "danger",
- message: "Attribute cannot have the same name as another attribute",
- });
- return;
- }
- // Add the new attribute to attribute_data if it doesn't exist
- this.attribute_data.push(attribute);
- this.#pushToast({
- type: "success",
- message: "Attribute added successfully",
- });
- }
-
- // Update the concept_data with the updated attribute
- this.temporarly_concept_data.forEach((concept) => {
- if (!concept.hasOwnProperty("attributes")) {
- concept.attributes = [];
- }
- const existingConceptAttributeIndex = concept.attributes.findIndex(
- (attr) => attr.name === attribute.name
- );
- if (existingAttributeIndex !== -1) {
- // Update the existing attribute in concept.attributes with the new name and type if they have changed
- const existingConceptAttribute =
- concept.attributes[existingAttributeIndex];
- if (
- existingConceptAttribute.name !== attribute.name ||
- existingConceptAttribute.type !== attribute.type
- ) {
- existingConceptAttribute.name = attribute.name;
- existingConceptAttribute.type = attribute.type;
- }
- } else {
- // Add the new attribute to concept.attributes if it doesn't exist
- concept.attributes.push(attribute);
- }
- });
-
- // Update the accordion label with the new attribute details
- const accordion = this.dialogue.page.querySelector(
- "#attribute-accordion-" + attribute.id
- );
- const accordionLabel = accordion.querySelector(
- "#children-label-" + attribute.id
- );
- let accordionDeleteButton = interpolateString(
- CSEL_UTILITY_BUTTONS.DELETE_BUTTON,
- {
- id: attribute.id,
- }
- );
- const deleteButtonElement = parseHTMLFromString(accordionDeleteButton)[0];
- accordionLabel.textContent = "";
- accordionLabel.insertBefore(deleteButtonElement, accordionLabel.firstChild);
- accordionLabel.appendChild(
- document.createTextNode(
- ` ${attribute.name} - ${this.#typeConversion(attribute.type)}`
- )
- );
-
- // Close the accordion and re-enable the add attribute button
- accordionLabel.click();
- this.dialogue.page
- .querySelector("#add-attribute-btn")
- .removeAttribute("disabled");
-
- this.dialogue.page
- .querySelector("#children-button-" + attribute.id)
- .addEventListener("click", () => {
- this.#deleteAttribute(attribute);
- });
- }
-
- /**
- * Handles the cancellation of the attribute editor.
- *
- * This method performs the following actions:
- * - Retrieves various elements related to the attribute from the page.
- * - If there are no attributes in `this.attribute_data`, it resets the input fields, removes the accordion,
- * shows a "none available" message, and enables the "add attribute" button.
- * - If there are attributes but the input fields are empty or invalid, it removes the accordion and enables
- * the "add attribute" button.
- * - If the input fields are valid, it simulates a click on the accordion's children label.
- *
- * @param {Object} attribute - The attribute object containing the attribute's id.
- */
- #handleCancelEditor(attribute) {
- const page = this.dialogue.page;
- const attribute_name_input = page.querySelector(
- "#attribute-name-input-" + attribute.id
- );
- const attribute_type = page.querySelector(
- "#attribute-type-" + attribute.id
- );
- const accordion = page.querySelector(
- "#attribute-accordion-" + attribute.id
- );
- const noneAvailable = page.querySelector("#no-items-selected");
-
- if (this.attribute_data.length <= 0) {
- attribute_name_input.value = null;
- attribute_type.value = -1;
- accordion.remove();
- noneAvailable.classList.add("show");
- page.querySelector("#add-attribute-btn").removeAttribute("disabled");
- } else {
- if (attribute_name_input.value === "" || attribute_type.value === "-1") {
- accordion.remove();
- page.querySelector("#add-attribute-btn").removeAttribute("disabled");
- }
- accordion.querySelector("#children-label-" + attribute.id).click();
- }
- }
-
- /**
- * changeTabView
- * @desc handles the tab buttons
- * @param {event} e the assoc. event
- */
- #changeTabView(e) {
- const target = e.target;
- const desired = target.getAttribute("id");
- if (target.classList.contains("active")) {
- return;
- }
-
- if (!desired || !CSEL_VIEWS.hasOwnProperty(desired)) {
- return;
- }
-
- this.#renderView(CSEL_VIEWS[desired]);
- }
-
- /**
- * Paints the selection attributes on the page.
- *
- * This method updates the UI to reflect the current state of attribute selection.
- * It hides or shows elements based on whether attributes are selected and sets up
- * event listeners for attribute creation and deletion.
- *
- * @private
- * @method #paintSelectionAttributes
- * @memberof AttributeSelectionService
- *
- * @returns {void}
- */
- #paintSelectionAttributes() {
- const page = this.dialogue.page;
- if (
- !this.dialogue?.view == CSEL_VIEWS.SELECTION ||
- isNullOrUndefined(page)
- ) {
- return;
- }
-
- const content = page.querySelector("#item-list");
- const noneAvailable = page.querySelector("#no-items-selected");
- if (isNullOrUndefined(content) || isNullOrUndefined(noneAvailable)) {
- return;
- }
-
- const hasSelectedItems =
- !isNullOrUndefined(this.attribute_data) && this.attribute_data.length > 0;
-
- // Display none available if no items selected
- let addAttributeButton = page.querySelector("#add-attribute-btn");
- if (!hasSelectedItems) {
- content.classList.add("hide");
- noneAvailable.classList.add("show");
-
- if (addAttributeButton) {
- addAttributeButton.addEventListener("click", () => {
- this.#handleAttributeCreation(this);
- });
- }
- return;
- } else {
- content.classList.remove("hide");
- noneAvailable.classList.remove("show");
-
- for (let i = 0; i < this.attribute_data.length; ++i) {
- let attribute = this.attribute_data[i];
- let attribute_progress = interpolateString(this.attribute_component, {
- id: attribute.id,
- });
-
- attribute_progress = parseHTMLFromString(attribute_progress,true)[0];
-
- attribute_progress.querySelector("#attribute-name-input-" + attribute.id).setAttribute("value", attribute.name);
- attribute_progress.querySelector("#attribute-type-" + attribute.id).querySelector(`option[value="${attribute.type}"]`).setAttribute("selected", true);
-
- let attributerow = interpolateString(
- CSEL_INTERFACE.ATTRIBUTE_ACCORDION,
- {
- id: attribute.id,
- title: `${attribute.name} - ${this.#typeConversion(
- attribute.type
- )}`,
- content: attribute_progress.outerHTML,
- }
- );
-
- let doc = parseHTMLFromString(attributerow)[0];
-
- const accordionLabel = doc.querySelector(
- "#children-label-" + attribute.id
- );
- let accordionDeleteButton = interpolateString(
- CSEL_UTILITY_BUTTONS.DELETE_BUTTON,
- {
- id: attribute.id,
- }
- );
- const deleteButtonElement = parseHTMLFromString(accordionDeleteButton)[0];
- accordionLabel.textContent = "";
- accordionLabel.insertBefore(
- deleteButtonElement,
- accordionLabel.firstChild
- );
- accordionLabel.appendChild(
- document.createTextNode(
- `${attribute.name} - ${this.#typeConversion(attribute.type)}`
- )
- );
-
- page.appendChild(doc);
-
- attribute = this.#invokeAttributeInputs(attribute, page);
- this.#invokeAttributeButtons(attribute, page);
- }
- if (addAttributeButton) {
- addAttributeButton.addEventListener("click", () => {
- this.#handleAttributeCreation(this);
- });
- }
- }
- }
-}
diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/conceptCreator.js b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/conceptCreator.js
index 451bc03fa..0752ab148 100644
--- a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/conceptCreator.js
+++ b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/conceptCreator.js
@@ -1,5 +1,4 @@
import { parse as parseCSV } from '../../../lib/csv.min.js';
-import { AttributeSelectionService } from './attributeSelectionService.js';
import { ConceptSelectionService } from './conceptSelectionService.js';
/**
@@ -46,7 +45,7 @@ const tryCleanCodingItem = (val, row, col) => {
return;
}
- return strictSanitiseString(val.replace(/^\s+|\s+$/gm, ''));
+ return strictSanitiseString(val.toString().replace(/^\s+|\s+$/gm, ''));
}
/**
@@ -120,6 +119,14 @@ const tryParseCodingExcelFile = (file) => {
content = tryCleanCodingFile(content);
return content;
})
+ .catch(error => {
+ console.error(`Failed to retrieve codes from file: ${error})`);
+ window.ToastFactory.push({
+ type: 'error',
+ message: `Failed to retrieve codes from file: ${error})`,
+ duration: 4000,
+ });
+ });
}
/**
@@ -207,15 +214,6 @@ const CONCEPT_CREATOR_SOURCE_TYPES = {
},
}
-/**
- * CONCEPT_CREATOR_KEYCODES
- * @desc Keycodes used by Concept Creator
- */
-const CONCEPT_CREATOR_KEYCODES = {
- // To modify rule name, apply search term etc
- ENTER: 13,
-}
-
/**
* CONCEPT_CREATOR_FILE_UPLOAD
* @desc File upload settings for file rule
@@ -307,17 +305,22 @@ const CONCEPT_CREATOR_TEXT = {
// Rule deletion prompt
RULE_DELETION: {
title: 'Are you sure?',
- content: '
Are you sure you want to delete this Ruleset from your Concept?
',
+ content: '
Are you sure you want to delete this Ruleset from your ${brandMapping.concept}?
',
},
// Concept deletion prompt
CONCEPT_DELETION: {
title: 'Are you sure?',
- content: '
Are you sure you want to delete this Concept from your Phenotype?
',
+ content: '
Are you sure you want to delete this ${brandMapping.concept} from your ${brandMapping.phenotype}?
',
+ },
+ // Concept deletion prompt
+ CODING_CHANGE: {
+ title: 'Are you sure?',
+ content: '
Are you sure you want to change your coding system? Any changes you\'ve made so far will be deleted.
',
},
// Toast to inform user to close editor
- REQUIRE_EDIT_CLOSURE: 'Please close the editor before trying to delete a Concept.',
+ REQUIRE_EDIT_CLOSURE: 'Please close the editor before trying to delete a ${brandMapping.concept}.',
// Toast for Concept name validation
- REQUIRE_CONCEPT_NAME: 'You need to name your Concept before saving',
+ REQUIRE_CONCEPT_NAME: 'You need to name your ${brandMapping.concept} before saving',
// Toast for Concept CodingSystem validation
REQUIRE_CODING_SYSTEM: 'You need to select a coding system before saving!',
// Toast to inform the user that no exclusionary codes were addded since they aren't present in an inclusionary rule
@@ -335,21 +338,19 @@ const CONCEPT_CREATOR_TEXT = {
// Toast to inform the user there was an error when trying to upload their code file
NO_CODE_FILE_MATCH: 'Unable to parse uploaded file. Please try again.',
// Toast to inform the user that the codes from the imported concept(s) were added
- ADDED_CONCEPT_CODES: 'Added ${code_len} codes via Concept Import',
+ ADDED_CONCEPT_CODES: 'Added ${code_len} codes via ${brandMapping.concept} Import',
// Toast to inform the user there was an error when trying to upload their code file
- NO_CONCEPT_MATCH: 'We were unable to add this Concept. Please try again.',
+ NO_CONCEPT_MATCH: 'We were unable to add this ${brandMapping.concept}. Please try again.',
// Toast to inform the user they tried to import non-distinct top-level concepts
CONCEPT_IMPORTS_ARE_PRESENT: 'Already imported ${failed}',
// Toast to inform the user they tried to import non-distinct rule-level concepts
- CONCEPT_RULE_IS_PRESENT: 'You have already imported this Concept as a rule',
+ CONCEPT_RULE_IS_PRESENT: 'You have already imported this ${brandMapping.concept} as a rule',
// Toast to inform successful update to new concept version
- CONCEPT_UPDATE_SUCCESS: 'Updated Concept to Version ${version}',
+ CONCEPT_UPDATE_SUCCESS: 'Updated ${brandMapping.concept} to Version ${version}',
// Toast to inform failed update to new concept version
- CONCEPT_UPDATE_FAILED: 'Failed to update Concept, please try again.',
+ CONCEPT_UPDATE_FAILED: 'Failed to update ${brandMapping.concept}, please try again.',
}
-
-
/**
* @class ConceptCreator
* @desc A class that can be used to control concept creation
@@ -441,7 +442,7 @@ export default class ConceptCreator {
* @returns {string|boolean} returns the title of this component if present, otherwise returns false
*/
getTitle() {
- const group = tryGetRootElement(this.element, 'phenotype-progress__item');
+ const group = tryGetRootElement(this.element, '.phenotype-progress__item');
if (!isNullOrUndefined(group)) {
const title = group.querySelector('.phenotype-progress__item-title');
if (!isNullOrUndefined(title)) {
@@ -519,36 +520,23 @@ export default class ConceptCreator {
* @returns {promise} that can be used as a Thenable if required
*/
tryImportConcepts() {
+ const brandMapping = this.parent.mapping;
const prompt = new ConceptSelectionService({
- promptTitle: 'Import Concepts',
+ promptTitle: `Import ${brandMapping.concept}`,
+ mapping: brandMapping,
template: this.template?.id,
entity_id: this.entity?.id,
entity_history_id: this.entity?.history_id,
allowMultiple: true,
+ noneSelectedMessage: `You haven't selected any ${brandMapping.concept}s yet`,
});
-
return prompt.show()
.then((data) => {
return this.#tryRetrieveCodelists(data);
});
}
- tryCallAttributeSettings() {
- const prompt = new AttributeSelectionService({
- promptTitle: 'Attribute settings',
- attribute_component: this.templates['attribute-component'],
- template: this.template?.id,
- entity_id: this.entity?.id,
- entity_history_id: this.entity?.history_id,
- concept_data: this.data,
- });
-
- return prompt.show()
- .then((data) => {
- this.data = data;
- });
- }
/**
* tryPromptConceptRuleImport
* @desc tries to prompt the user to import a Concept as a rule,
@@ -568,8 +556,9 @@ export default class ConceptCreator {
return Promise.reject();
}
+ const brandMapping = this.parent.mapping;
const prompt = new ConceptSelectionService({
- promptTitle: `Import Concept as Rule (${codingSystemName})`,
+ promptTitle: `Import ${brandMapping.concept} as Rule (${codingSystemName})`,
template: this.template?.id,
allowMultiple: false,
entity_id: this.entity?.id,
@@ -696,10 +685,6 @@ export default class ConceptCreator {
* @returns {promise} a promise that resolves with the template's option/source data if successful
*/
tryQueryOptionsParameter(param) {
- if (!isNullOrUndefined(this.coding_data)) {
- return Promise.resolve(this.coding_data);
- }
-
const parameters = new URLSearchParams({
parameter: param,
template: this.template?.id,
@@ -715,11 +700,7 @@ export default class ConceptCreator {
}
}
)
- .then(response => response.json())
- .then(response => {
- this.coding_data = response?.result;
- return this.coding_data;
- });
+ .then(response => response.json());
}
/**
@@ -861,10 +842,6 @@ export default class ConceptCreator {
const importBtn = this.element.querySelector('#import-concept-btn');
importBtn.addEventListener('click', this.#handleConceptImporting.bind(this));
-
- const addAttrBtn = this.element.querySelector('#add-concept-attribute-btn');
- this.#hideAttributeSettingsButton(this.data.length <= 0);
- addAttrBtn.addEventListener('click', this.#handleAttributeSettings.bind(this));
}
/*************************************
@@ -1143,15 +1120,6 @@ export default class ConceptCreator {
noConcepts.classList[hide ? 'remove' : 'add']('show');
}
- #hideAttributeSettingsButton(hide) {
- const addAttrBtn = this.element.querySelector('#add-concept-attribute-btn');
- if (hide) {
- addAttrBtn.setAttribute('disabled','disabled');
- } else {
- addAttrBtn.removeAttribute('disabled');
- }
- }
-
/**
* collapseConcepts
* @desc method to collapse all concept accordions
@@ -1177,7 +1145,7 @@ export default class ConceptCreator {
* @param {boolean} forceUpdate whether to force update the codelist
*/
#toggleConcept(target, forceUpdate) {
- const conceptGroup = tryGetRootElement(target, 'concept-list__group');
+ const conceptGroup = tryGetRootElement(target, '.concept-list__group');
const conceptId = conceptGroup.getAttribute('data-concept-id');
const historyId = conceptGroup.getAttribute('data-concept-history-id');
@@ -1196,7 +1164,6 @@ export default class ConceptCreator {
// Render codelist
let dataset = this.data.filter(concept => concept.concept_version_id == historyId && concept.concept_id == conceptId);
dataset = dataset.shift();
- console.log(dataset);
return this.#tryRenderCodelist(container, dataset);
}
@@ -1217,7 +1184,6 @@ export default class ConceptCreator {
containerList.innerHTML = '';
this.#toggleNoConceptBox(this.data.length > 0);
- this.#hideAttributeSettingsButton(this.data.length <= 0);
if (this.data.length > 0) {
for (let i = 0; i < this.data.length; ++i) {
@@ -1243,9 +1209,14 @@ export default class ConceptCreator {
* @returns {node} the rendered concept group
*/
#tryRenderConceptComponent(concept) {
+ let urlTarget = this?.parent?.mapping?.phenotype_url;
+ if (!stringHasChars(urlTarget)) {
+ urlTarget = 'phenotypes';
+ }
+
const template = this.templates['concept-item'];
const access = this.#deriveEditAccess(concept);
- const phenotype_version_url = `${window.location.origin}/phenotypes/${concept.details.phenotype_owner}/version/${concept.details.phenotype_owner_history_id}/detail`;
+ const phenotype_version_url = `${getBrandedHost()}/${urlTarget}/${concept.details.phenotype_owner}/version/${concept.details.phenotype_owner_history_id}/detail`;
const isImportedItem = concept?.details?.phenotype_owner && !!concept?.details?.requested_entity_id && concept?.details?.phenotype_owner !== concept?.details?.requested_entity_id;
const html = interpolateString(template, {
@@ -1266,7 +1237,7 @@ export default class ConceptCreator {
const containerList = this.element.querySelector('#concept-content-list');
const doc = parseHTMLFromString(html, true);
- const conceptItem = containerList.appendChild(doc.body.children[0]);
+ const conceptItem = containerList.appendChild(doc[0]);
conceptItem.setAttribute('live', true);
const headerButtons = conceptItem.querySelectorAll('#concept-accordion-header span[role="button"]');
@@ -1371,36 +1342,73 @@ export default class ConceptCreator {
*/
#fetchCodingOptions(dataset) {
// Fetch coding system from server
- const promise = this.tryQueryOptionsParameter('coding_system')
- .then(codingSystems => {
- // Build
option HTML
- let options = interpolateString(CONCEPT_CREATOR_DEFAULTS.CODING_DEFAULT_HIDDEN_OPTION, {
- 'is_unselected': codingSystems.length < 1,
- });
+ let promise;
+ if (Array.isArray(this.coding_data) && this.coding_data.length > 0) {
+ promise = Promise.resolve({ result: this.coding_data });
+ } else {
+ promise = this.tryQueryOptionsParameter('coding_system');
+ }
- // Sort alphabetically in desc. order
- codingSystems.sort((a, b) => {
- if (a.name < b.name) {
- return -1;
- }
+ let codingSystems;
+ return new Promise((resolve, reject) => {
+ promise
+ .then((response) => {
+ codingSystems = response?.result;
+ if (!Array.isArray(codingSystems)) {
+ reject(new Error(
+ 'Failed to fetch valid Coding Systems from server',
+ { cause: { type: 'fetch', detail: 'coding_system', msg: 'Failed to retrieve coding systems, please try again.' }
+ }));
- return (a.name > b.name) ? 1 : 0;
- });
-
- // Build each coding system option
- for (let i = 0; i < codingSystems.length; ++i) {
- const item = codingSystems[i];
- options += interpolateString(CONCEPT_CREATOR_DEFAULTS.CODING_DEFAULT_ACTIVE_OPTION, {
- 'is_selected': item.value == dataset?.coding_system?.id,
- 'name': item.name,
- 'value': item.value,
+ return;
+ }
+
+ // Build
option HTML
+ let options = interpolateString(CONCEPT_CREATOR_DEFAULTS.CODING_DEFAULT_HIDDEN_OPTION, {
+ 'is_unselected': codingSystems.length < 1,
});
- }
+
+ // Sort alphabetically in desc. order
+ codingSystems.sort((a, b) => {
+ if (a.name < b.name) {
+ return -1;
+ }
+
+ return (a.name > b.name) ? 1 : 0;
+ });
+
+ // Build each coding system option
+ for (let i = 0; i < codingSystems.length; ++i) {
+ const item = codingSystems[i];
+ options += interpolateString(CONCEPT_CREATOR_DEFAULTS.CODING_DEFAULT_ACTIVE_OPTION, {
+ 'is_selected': item.value == dataset?.coding_system?.id,
+ 'name': item.name,
+ 'value': item.value,
+ });
+ }
+
+ resolve(options);
+ })
+ .catch(e => {
+ if (isNullOrUndefined(e) || !(e instanceof Error)) {
+ e = new Error('Failed to retrieve coding system from server.');
+ }
+ if (!isNullOrUndefined(e) && e instanceof Error && !isRecordType(e?.cause)) {
+ e.cause = {
+ type: 'fetch',
+ detail: 'coding_system',
+ msg: 'Failed to fetch coding systems, please try again.'
+ };
+ }
+
+ reject(e);
+ });
+ })
+ .then((options) => {
+ this.coding_data = codingSystems;
return options;
});
-
- return promise;
}
/**
@@ -1494,20 +1502,8 @@ export default class ConceptCreator {
}
}
- // Only disable/enable the coding selector if not updated via the change event
- if (ignoreSelection) {
- return;
- }
-
- // Don't allow users to reselect the coding system once we've created at least 1 rule + selected a system
- const selector = editor.querySelector('#coding-system-select')
- selector.disabled = hasCodingSystem;
-
- // Only enable to change event if no coding system is present
- if (hasCodingSystem) {
- return;
- }
- selector.addEventListener('change', this.#handleCodingSelection.bind(this));
+ const selector = editor.querySelector('#coding-system-select');
+ selector.disabled = !this.state.data?.is_new;
}
/**
@@ -1538,7 +1534,7 @@ export default class ConceptCreator {
});
const doc = parseHTMLFromString(html, true);
- const item = ruleList.appendChild(doc.body.children[0]);
+ const item = ruleList.appendChild(doc[0]);
const input = item.querySelector('input[data-item="rule"]');
// Add handler for each rule type, otherwise disable element
@@ -1717,6 +1713,7 @@ export default class ConceptCreator {
],
classes: {
wrapper: 'overflow-table-constraint',
+ container: 'datatable-container slim-scrollbar',
},
data: {
headings: [
@@ -1742,11 +1739,12 @@ export default class ConceptCreator {
/**
* tryRenderEditor [async]
* @desc async method to render the editor when a user enters the editor state
- * @param {node} conceptGroup the concept group node related to the Concept being edited
- * @param {object} dataset the concept dataset
+ * @param {node} conceptGroup the concept group node related to the Concept being edited
+ * @param {object} dataset the concept dataset
+ * @param {object} codingSystems a set of available coding systems (selected by the concept)
* @returns {node} the editor element
*/
- async #tryRenderEditor(conceptGroup, dataset) {
+ async #tryRenderEditor(conceptGroup, dataset, codingSystems) {
const conceptId = conceptGroup.getAttribute('data-concept-id');
const historyId = conceptGroup.getAttribute('data-concept-history-id');
@@ -1761,23 +1759,22 @@ export default class ConceptCreator {
accordion.classList.add('is-open');
conceptGroup.setAttribute('editing', true);
- const systemOptions = await this.#fetchCodingOptions(dataset);
const template = this.templates['concept-editor'];
const html = interpolateString(template, {
'concept_name': strictSanitiseString(dataset?.details?.name),
'coding_system_id': dataset?.coding_system?.id,
- 'coding_system_options': systemOptions,
+ 'coding_system_options': codingSystems,
'has_inclusions': false,
'has_exclusions': false,
});
const doc = parseHTMLFromString(html, true);
- const editor = conceptGroup.appendChild(doc.body.children[0]);
+ const editor = conceptGroup.appendChild(doc[0]);
this.state.data = dataset;
this.state.editor = editor;
this.state.element = conceptGroup;
this.state.editing = { id: conceptId, history_id: historyId };
- this.#applyRulesetState({ id: dataset?.coding_system?.id, editor: editor});
+ this.#applyRulesetState({ id: dataset?.coding_system?.id, editor: editor });
this.#tryRenderRulesets();
// Handle name changing
@@ -1797,6 +1794,10 @@ export default class ConceptCreator {
const confirmChanges = editor.querySelector('#confirm-changes');
confirmChanges.addEventListener('click', this.#handleConfirmEditor.bind(this));
+
+ // Handle coding system selector
+ const selector = editor.querySelector('#coding-system-select');
+ selector.addEventListener('change', this.#handleCodingSelection.bind(this));
// Render codelist
this.#tryRenderAggregatedCodelist();
@@ -1907,7 +1908,13 @@ export default class ConceptCreator {
}
new Promise((resolve, reject) => {
- window.ModalFactory.create(CONCEPT_CREATOR_TEXT.RULE_DELETION)
+ window.ModalFactory.create({
+ title: CONCEPT_CREATOR_TEXT.RULE_DELETION.title,
+ content: interpolateString(
+ CONCEPT_CREATOR_TEXT.RULE_DELETION.content,
+ { brandMapping: this.parent.mapping }
+ )
+ })
.then(resolve)
.catch(reject);
})
@@ -1937,14 +1944,89 @@ export default class ConceptCreator {
}
const target = e.target;
- const selection = target.options[target.selectedIndex];
- this.state.data.coding_system = {
- id: parseInt(selection.value),
- name: selection.text,
- description: selection.text,
- };
+ const dataset = this.state.data;
+ const selectedIndex = target.selectedIndex;
+ const currentSelection = dataset?.coding_system?.id ?? null;
+
+ let selection = target.options[selectedIndex];
+ if (isNullOrUndefined(selection)) {
+ if (isNullOrUndefined(currentSelection)) {
+ selection = selectedIndex;
+ } else {
+ for (let i = 0; i < target.options.length; ++i) {
+ const opt = target.options[i];
+ if (parseInt(opt.value) === currentSelection) {
+ selection = i
+ break;
+ }
+ }
+ }
+
+ target.selectedIndex = selection;
+ return;
+ }
- this.#applyRulesetState({ id: selection.value, editor: this.state.editor, ignoreSelection: true });
+ const codingId = parseInt(selection.value);
+ if (codingId === currentSelection) {
+ return;
+ }
+
+ const hasUnsavedWork = !isNullOrUndefined(dataset) && (dataset?.aggregatedStateView?.length || dataset?.component?.length);
+ const hasCurrentSelection = !isNullOrUndefined(currentSelection);
+
+ let promise;
+ if (hasCurrentSelection && hasUnsavedWork) {
+ promise = window.ModalFactory.create(CONCEPT_CREATOR_TEXT.CODING_CHANGE);
+ } else {
+ promise = new Promise((resolve) => resolve());
+ }
+
+ promise
+ .then(async () => {
+ if (hasCurrentSelection) {
+ dataset?.components?.splice?.(0, dataset?.components?.length);
+ dataset?.aggregatedStateView?.splice(0, dataset?.aggregatedStateView?.length);
+ }
+
+ dataset.coding_system = {
+ id: codingId,
+ name: selection.text,
+ description: selection.text,
+ selectedIndex: selectedIndex,
+ };
+
+ this.#applyRulesetState({ id: codingId, editor: this.state.editor });
+
+ await this.#recalculateExclusionaryRules();
+ this.#tryRenderRulesets();
+ this.#tryRenderAggregatedCodelist(true);
+ })
+ .catch((e) => {
+ if (!(e instanceof ModalFactory.ModalResults)) {
+ window.ToastFactory.push({
+ type: 'error',
+ message: 'Failed to change codelist, please try again.',
+ duration: 4000,
+ });
+
+ return console.error(e);
+ }
+
+ const action = e.name;
+ if (hasCurrentSelection && (action === 'Reject' || action === 'Cancel')) {
+ selection = selectedIndex;
+
+ for (let i = 0; i < target.options.length; ++i) {
+ const opt = target.options[i];
+ if (parseInt(opt.value) === currentSelection) {
+ selection = i;
+ break;
+ }
+ }
+
+ target.selectedIndex = selection;
+ }
+ });
}
/**
@@ -1961,6 +2043,8 @@ export default class ConceptCreator {
e.stopPropagation();
const value = strictSanitiseString(input.value);
+ input.value = value;
+
if (!input.checkValidity() || isNullOrUndefined(value) || isStringEmpty(value)) {
input.classList.add('fill-accordion__name-input--invalid');
return;
@@ -1983,13 +2067,13 @@ export default class ConceptCreator {
const searchBtn = input.parentNode.querySelector('.code-text-input__icon');
if (!isNullOrUndefined(searchBtn)) {
searchBtn.addEventListener('click', (e) => {
- input.dispatchEvent(new KeyboardEvent('keyup', { keyCode: CONCEPT_CREATOR_KEYCODES.ENTER }));
+ input.dispatchEvent(new KeyboardEvent('keyup', { code: 'Enter', keyCode: 13 }));
});
}
input.addEventListener('keyup', (e) => {
- const code = e.keyIdentifier || e.which || e.keyCode;
- if (code != CONCEPT_CREATOR_KEYCODES.ENTER) {
+ const code = e.code;
+ if (code !== 'Enter') {
return;
}
@@ -2188,7 +2272,13 @@ export default class ConceptCreator {
.then(result => {
spinner = startLoadingSpinner();
if (!this.#isConceptRuleImportDistinct(result, logicalType)) {
- this.#pushToast({ type: 'danger', message: CONCEPT_CREATOR_TEXT.CONCEPT_RULE_IS_PRESENT});
+ this.#pushToast({
+ type: 'danger',
+ message: interpolateString(
+ CONCEPT_CREATOR_TEXT.CONCEPT_RULE_IS_PRESENT,
+ { brandMapping: this.parent.mapping }
+ )
+ });
return;
}
@@ -2199,9 +2289,10 @@ export default class ConceptCreator {
this.#tryAddNewRule(logicalType, sourceType, result);
this.#pushToast({
type: 'success',
- message: interpolateString(CONCEPT_CREATOR_TEXT.ADDED_CONCEPT_CODES, {
- code_len: result?.codelist.length.toLocaleString(),
- })
+ message: interpolateString(
+ CONCEPT_CREATOR_TEXT.ADDED_CONCEPT_CODES,
+ { brandMapping: this.parent.mapping, code_len: result?.codelist.length.toLocaleString() }
+ )
});
return;
@@ -2214,7 +2305,13 @@ export default class ConceptCreator {
})
.catch(e => {
if (!isNullOrUndefined(e)) {
- this.#pushToast({ type: 'danger', message: CONCEPT_CREATOR_TEXT.NO_CONCEPT_MATCH});
+ this.#pushToast({
+ type: 'danger',
+ message: interpolateString(
+ CONCEPT_CREATOR_TEXT.NO_CONCEPT_MATCH,
+ { brandMapping: this.parent.mapping }
+ )
+ });
console.error(e);
return;
}
@@ -2243,6 +2340,8 @@ export default class ConceptCreator {
#handleConceptNameChange(e) {
const input = e.target;
const value = strictSanitiseString(input.value);
+ input.value = value;
+
if (!input.checkValidity() || isNullOrUndefined(value) || isStringEmpty(value)) {
return;
}
@@ -2259,7 +2358,7 @@ export default class ConceptCreator {
* @param {event} e the associated event
*/
#handleCancelEditor(e) {
- const conceptGroup = tryGetRootElement(e.target, 'concept-list__group');
+ const conceptGroup = tryGetRootElement(e.target, '.concept-list__group');
this.tryCloseEditor()
.then((res) => {
this.#toggleConcept(conceptGroup);
@@ -2281,12 +2380,24 @@ export default class ConceptCreator {
// Validate the concept data
if (isNullOrUndefined(data?.details?.name) || isStringEmpty(data?.details?.name)) {
- this.#pushToast({ type: 'danger', message: CONCEPT_CREATOR_TEXT.REQUIRE_CONCEPT_NAME });
+ this.#pushToast({
+ type: 'danger',
+ message: interpolateString(
+ CONCEPT_CREATOR_TEXT.REQUIRE_CONCEPT_NAME,
+ { brandMapping: this.parent.mapping }
+ ),
+ });
return;
}
if (isNullOrUndefined(data?.coding_system)) {
- this.#pushToast({ type: 'danger', message: CONCEPT_CREATOR_TEXT.REQUIRE_CODING_SYSTEM});
+ this.#pushToast({
+ type: 'danger',
+ message: interpolateString(
+ CONCEPT_CREATOR_TEXT.REQUIRE_CODING_SYSTEM,
+ { brandMapping: this.parent.mapping }
+ ),
+ });
return;
}
@@ -2352,7 +2463,6 @@ export default class ConceptCreator {
this.#tryRenderConceptComponent(data);
}
this.#toggleNoConceptBox(this.data.length > 0);
- this.#hideAttributeSettingsButton(this.data.length <= 0);
if (failedImports.length > 0) {
this.#pushToast({
@@ -2364,32 +2474,23 @@ export default class ConceptCreator {
}
})
.catch((e) => {
- if (!isNullOrUndefined(e)) {
- console.error(e);
+ if (!!e && !(e instanceof ModalFactory.ModalResults)) {
+ console.error(`[ConceptCreator->handleConceptImporting] Failed with err:\n\n${e}\n`);
}
})
}
- /**
- * Calling the attribute settings
- * @param {*} e
- */
- #handleAttributeSettings(e){
- this.tryCloseEditor()
- .then(() => {
- return this.tryCallAttributeSettings();
- })
- }
-
-
/**
* handleConceptCreation
* @desc handles the creation of a concept when the user selects the 'Add Concept' button
* @param {event} e the associated event
*/
#handleConceptCreation(e) {
+ let spinner;
this.tryCloseEditor()
- .then(() => {
+ .then(async () => {
+ spinner = startLoadingSpinner();
+
const conceptIncrement = this.#getNextConceptCount();
const concept = {
is_new: true,
@@ -2397,17 +2498,60 @@ export default class ConceptCreator {
concept_version_id: generateUUID(),
components: [ ],
details: {
- name: `Concept ${conceptIncrement}`,
+ name: `Codelist ${conceptIncrement}`,
has_edit_access: true,
},
+ };
+
+ const codingRequest = await this.#fetchCodingOptions(concept)
+ .then(r => {
+ return { success: true, result: r };
+ })
+ .catch(e => {
+ if (!isNullOrUndefined(e) && e instanceof Error) {
+ const cause = e?.cause;
+ if (isRecordType(cause) && typeof cause?.message === 'string') {
+ return { success: false, result: e };
+ }
+ }
+
+ return {
+ success: false,
+ result: new Error(
+ 'Failed to resolve data from server',
+ { cause: { type: 'fetch', detail: 'coding_system', msg: 'Failed to fetch results from server, please try again.' }}
+ ),
+ };
+ });
+
+ if (!codingRequest?.success) {
+ window.ToastFactory.push({
+ type: 'error',
+ message: codingRequest.result.cause.msg,
+ duration: 4000,
+ });
+
+ throw codingRequest.result;
+ }
+
+ if (!isNullOrUndefined(spinner)) {
+ spinner?.remove?.();
}
const conceptGroup = this.#tryRenderConceptComponent(concept);
- this.#tryRenderEditor(conceptGroup, concept);
+ this.#tryRenderEditor(conceptGroup, concept, codingRequest.result);
this.#toggleNoConceptBox(true);
- this.#hideAttributeSettingsButton(this.data.length <= 0);
})
- .catch(() => { /* User does not want to lose progress, sink edit request */ })
+ .catch((e) => {
+ if (!!e && !(e instanceof ModalFactory.ModalResults)) {
+ console.error(`[ConceptCreator->handleConceptCreation] Failed with err:\n\n${e}\n`);
+ }
+ })
+ .finally(() => {
+ if (!isNullOrUndefined(spinner)) {
+ spinner?.remove?.();
+ }
+ });
}
/**
@@ -2417,11 +2561,13 @@ export default class ConceptCreator {
*/
#handleEditing(target) {
// If editing, prompt before continuing
+ let spinner, failed;
return this.tryCloseEditor()
- .then((res) => {
- const spinner = startLoadingSpinner();
+ .then(async (res) => {
+ spinner = startLoadingSpinner();
+
const [id, history_id] = res || [ ];
- const conceptGroup = tryGetRootElement(target, 'concept-list__group');
+ const conceptGroup = tryGetRootElement(target, '.concept-list__group');
const conceptId = conceptGroup.getAttribute('data-concept-id');
const historyId = conceptGroup.getAttribute('data-concept-history-id');
@@ -2435,10 +2581,55 @@ export default class ConceptCreator {
let dataset = this.data.filter(concept => concept.concept_version_id == historyId && concept.concept_id == conceptId);
dataset = deepCopy(dataset.shift());
- this.#tryRenderEditor(conceptGroup, dataset);
- spinner.remove();
+ const codingRequest = await this.#fetchCodingOptions(dataset)
+ .then(r => {
+ return { success: true, result: r };
+ })
+ .catch(e => {
+ if (!isNullOrUndefined(e) && e instanceof Error) {
+ const cause = e?.cause;
+ if (isRecordType(cause) && typeof cause?.message === 'string') {
+ return { success: false, result: e };
+ }
+ }
+
+ return {
+ success: false,
+ result: new Error(
+ 'Failed to resolve data from server',
+ { cause: { type: 'fetch', detail: 'coding_system', msg: 'Failed to fetch results from server, please try again.' }}
+ ),
+ };
+ });
+
+ if (!codingRequest?.success) {
+ failed = true;
+ window.ToastFactory.push({
+ type: 'error',
+ message: codingRequest.result.cause.msg,
+ duration: 4000,
+ });
+
+ throw codingRequest.result;
+ }
+
+ if (!isNullOrUndefined(spinner)) {
+ spinner?.remove?.();
+ }
+
+ this.#tryRenderEditor(conceptGroup, dataset, codingRequest.result);
})
- .catch(() => { /* User does not want to lose progress, sink edit request */ })
+ .catch((e) => {
+ /* User does not want to lose progress, sink edit request */
+ if (!!e && !(e instanceof ModalFactory.ModalResults)) {
+ console.error(`[ConceptCreator->handleEditing] Failed with err:\n\n${e}\n`);
+ }
+ })
+ .finally(() => {
+ if (spinner) {
+ spinner.remove();
+ }
+ });
}
/**
@@ -2449,15 +2640,24 @@ export default class ConceptCreator {
*/
#handleDeletion(target) {
if (this.state.editing) {
- this.#pushToast({ type: 'danger', message: CONCEPT_CREATOR_TEXT.REQUIRE_EDIT_CLOSURE });
+ this.#pushToast({ type: 'danger', message: interpolateString(
+ CONCEPT_CREATOR_TEXT.REQUIRE_EDIT_CLOSURE,
+ { brandMapping: this.parent.mapping }
+ ) });
return;
}
return new Promise((resolve, reject) => {
- window.ModalFactory.create(CONCEPT_CREATOR_TEXT.CONCEPT_DELETION).then(resolve).catch(reject);
+ window.ModalFactory.create({
+ title: CONCEPT_CREATOR_TEXT.CONCEPT_DELETION.title,
+ content: interpolateString(
+ CONCEPT_CREATOR_TEXT.CONCEPT_DELETION.content,
+ { brandMapping: this.parent.mapping }
+ )
+ }).then(resolve).catch(reject);
})
.then(() => {
- const conceptGroup = tryGetRootElement(target, 'concept-list__group');
+ const conceptGroup = tryGetRootElement(target, '.concept-list__group');
const conceptId = conceptGroup.getAttribute('data-concept-id');
const historyId = conceptGroup.getAttribute('data-concept-history-id');
@@ -2535,20 +2735,24 @@ export default class ConceptCreator {
this.#tryUpdateRenderConceptComponents(updatedConcept.concept_id, updatedConcept.concept_version_id, true);
this.#pushToast({
type: 'success',
- message: interpolateString(CONCEPT_CREATOR_TEXT.CONCEPT_UPDATE_SUCCESS, {
- version: updatedConcept.concept_version_id.toString(),
- })
+ message: interpolateString(
+ CONCEPT_CREATOR_TEXT.CONCEPT_RULE_IS_PRESENT,
+ { brandMapping: this.parent.mapping, version: updatedConcept.concept_version_id.toString() }
+ )
});
})
.catch((e) => {
- if (!isNullOrUndefined(e)) {
- console.error(e);
- }
+ if (!!e && !(e instanceof ModalFactory.ModalResults)) {
+ this.#pushToast({
+ type: 'danger',
+ message: interpolateString(
+ CONCEPT_CREATOR_TEXT.CONCEPT_UPDATE_FAILED,
+ { brandMapping: this.parent.mapping }
+ )
+ });
- this.#pushToast({
- type: 'danger',
- message: CONCEPT_CREATOR_TEXT.CONCEPT_UPDATE_FAILED
- });
+ console.error(`[ConceptCreator->handleConceptImportUpdate] Failed with err:\n\n${e}\n`);
+ }
});
}
}
diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/conceptSelectionService.js b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/conceptSelectionService.js
index 450b5a173..76a2c53d6 100644
--- a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/conceptSelectionService.js
+++ b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/conceptSelectionService.js
@@ -33,11 +33,6 @@ const CSEL_BEHAVIOUR = {
// Defines the output format behaviour of datetime objects
DATE_FORMAT: 'YYYY-MM-DD',
- // Describes keycodes for filter-related events
- KEY_CODES: {
- ENTER: 13,
- },
-
// Describes non-numerical data-value targets for pagination buttons
PAGINATION: {
NEXT: 'next',
@@ -111,6 +106,12 @@ const CSEL_OPTIONS = {
// Whether to cache the resulting queries for quicker,
// albeit possibly out of date, Phenotypes and their assoc. Concepts
useCachedResults: false,
+
+ // Brand text mapping
+ mapping: {
+ phenotype: 'Phenotype',
+ concept: 'Concept',
+ },
};
/**
@@ -143,8 +144,8 @@ const CSEL_INTERFACE = {
TAB_VIEW: ' \
\
\
- Search Concepts \
- Selected Concepts \
+ Search ${brandMapping.concept} \
+ Selected ${brandMapping.concept} \
\
\
\
@@ -154,7 +155,7 @@ const CSEL_INTERFACE = {