diff --git a/api/permissions.py b/api/permissions.py index e296bf823cb0b21b2f05572b3cc9d04939f9df55..2153980d9d6bb4202438b406d6bc07aa97dbfbfb 100644 --- a/api/permissions.py +++ b/api/permissions.py @@ -76,3 +76,14 @@ class DataPushPermission(permissions.BasePermission): except models.UserPermissionLink.DoesNotExist: # Permission must have been granted explicitly return False + + +class IsAdminOrReadOnly(permissions.BasePermission): + """ + Grant admins write access - all others get read-only. + """ + def has_permission(self, request, view): + return bool( + request.method in permissions.SAFE_METHODS or + request.user.is_superuser + ) diff --git a/api/urls.py b/api/urls.py index 9f39481a759622de22ae9645d632b145cae4ba29..7adf2a1dc491d88f2a58f71435974eb1c6ea4054 100644 --- a/api/urls.py +++ b/api/urls.py @@ -1,16 +1,43 @@ from django.urls import include, path -from rest_framework import routers +from rest_framework_nested import routers -from .views import datasources as datasource_views +from .views.datasources import ( + DataSourceApiViewset, + MetadataItemApiViewset +) +from .views.quality import ( + QualityCriterionApiViewset, + QualityLevelApiViewset, + QualityRulesetApiViewset +) app_name = 'api' # Register ViewSets router = routers.DefaultRouter() -router.register('datasources', datasource_views.DataSourceApiViewset) +router.register('datasources', DataSourceApiViewset, base_name='datasource') +router.register('rulesets', QualityRulesetApiViewset, base_name='rulesets') + +datasource_router = routers.NestedSimpleRouter(router, 'datasources', lookup='datasource') +datasource_router.register('metadata_items', MetadataItemApiViewset, base_name='metadata-item') + +ruleset_router = routers.NestedSimpleRouter(router, 'rulesets', lookup='ruleset') +ruleset_router.register('levels', QualityLevelApiViewset, base_name='levels') + +level_router = routers.NestedSimpleRouter(ruleset_router, 'levels', lookup='level') +level_router.register('criteria', QualityCriterionApiViewset, base_name='criteria') urlpatterns = [ path('', include(router.urls)), + + path('', + include(datasource_router.urls)), + + path('', + include(ruleset_router.urls)), + + path('', + include(level_router.urls)), ] diff --git a/api/views/__init__.py b/api/views/__init__.py index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..4231549fb1eab823cefba2c086e854ce663eedf7 100644 --- a/api/views/__init__.py +++ b/api/views/__init__.py @@ -0,0 +1,7 @@ +from .datasources import DataSourceApiViewset + +from .quality import ( + QualityCriterionApiViewset, + QualityLevelApiViewset, + QualityRulesetApiViewset +) \ No newline at end of file diff --git a/api/views/datasources.py b/api/views/datasources.py index 793acbb21ba9fa9a3a3f41ec939a12b416f96c6e..a5d2f9c45c9705cfa57c390889600e4934c802dc 100644 --- a/api/views/datasources.py +++ b/api/views/datasources.py @@ -6,8 +6,10 @@ import csv import json import typing +from django.contrib.auth import get_user_model from django.db.models import ObjectDoesNotExist from django.http import HttpResponse, JsonResponse +from django.shortcuts import get_object_or_404 from rest_framework import decorators, request, response, viewsets from requests.exceptions import HTTPError @@ -18,6 +20,18 @@ from datasources.connectors.base import DatasetNotFoundError from provenance import models as prov_models +class MetadataItemApiViewset(viewsets.ModelViewSet): + serializer_class = serializers.MetadataItemSerializer + permission_classes = [permissions.IsAdminOrReadOnly] + + def get_queryset(self): + return models.MetadataItem.objects.filter(datasource=self.kwargs['datasource_pk']) + + def perform_create(self, serializer): + datasource = get_object_or_404(models.DataSource, pk=self.kwargs['datasource_pk']) + serializer.save(datasource=datasource) + + class DataSourceApiViewset(viewsets.ReadOnlyModelViewSet): """ Provides views for: @@ -28,6 +42,9 @@ class DataSourceApiViewset(viewsets.ReadOnlyModelViewSet): /api/datasources/<int>/ Retrieve a single :class:`datasources.models.DataSource`. + /api/datasources/<int>/quality/ + Get the quality level of a :class:`datasources.models.DataSource` using the current ruleset. + /api/datasources/<int>/prov/ Retrieve PROV records related to a :class:`datasources.models.DataSource`. @@ -164,7 +181,21 @@ class DataSourceApiViewset(viewsets.ReadOnlyModelViewSet): serializer = self.get_serializer(queryset, many=True) return response.Response(serializer.data) - @decorators.action(detail=True, permission_classes=[permissions.ProvPermission]) + @decorators.action(detail=True, permission_classes=[permissions.ViewPermission]) + def quality(self, request, pk=None): + """ + View for /api/datasources/<int>/quality/ + + Get the quality level of a data source using the current ruleset. + """ + ruleset = get_user_model().get_quality_ruleset() + instance = self.get_object() + + return response.Response({ + 'quality': ruleset(instance), + }, status=200) + + @decorators.action(detail=True, permission_classes=[permissions.ProvPermission], name='datasource-quality') def prov(self, request, pk=None): """ View for /api/datasources/<int>/prov/ diff --git a/api/views/quality.py b/api/views/quality.py new file mode 100644 index 0000000000000000000000000000000000000000..f55fe5b4ad66271ed5de47109f809dce1b1dff94 --- /dev/null +++ b/api/views/quality.py @@ -0,0 +1,52 @@ +""" +This module contains the API for data quality rulesets. +""" + +from django.shortcuts import get_object_or_404 + +from rest_framework import viewsets + + +from datasources import models, serializers +from .. import permissions + + +class QualityRulesetApiViewset(viewsets.ModelViewSet): + serializer_class = serializers.QualityRulesetSerializer + permission_classes = [permissions.IsAdminOrReadOnly] + + def get_queryset(self): + return models.QualityRuleset.objects.all() + + +class QualityLevelApiViewset(viewsets.ModelViewSet): + serializer_class = serializers.QualityLevelSerializer + permission_classes = [permissions.IsAdminOrReadOnly] + + def get_queryset(self): + return models.QualityLevel.objects.filter(ruleset=self.kwargs['ruleset_pk']) + + def get_object(self): + return self.get_queryset().get(level=self.kwargs['pk']) + + def perform_create(self, serializer): + ruleset = get_object_or_404(models.QualityRuleset, pk=self.kwargs['ruleset_pk']) + serializer.save(ruleset=ruleset) + + +class QualityCriterionApiViewset(viewsets.ModelViewSet): + queryset = models.QualityCriterion.objects.all() + serializer_class = serializers.QualityCriterionSerializer + permission_classes = [permissions.IsAdminOrReadOnly] + + def get_queryset(self): + return models.QualityCriterion.objects.filter( + quality_level__ruleset=self.kwargs['ruleset_pk'], + quality_level__level=self.kwargs['level_pk'] + ) + + def perform_create(self, serializer): + level = get_object_or_404(models.QualityLevel, + ruleset=self.kwargs['ruleset_pk'], + level=self.kwargs['level_pk']) + serializer.save(quality_level=level) diff --git a/datasources/admin.py b/datasources/admin.py index c2419c0629b2ba98784c3ecfee0fcd8309da7548..b65be07898221fa3ab2d85e98f6fd0452d7b5d0a 100644 --- a/datasources/admin.py +++ b/datasources/admin.py @@ -46,3 +46,18 @@ class DataSourceAdmin(admin.ModelAdmin): form.instance.owner = request.user super().save_model(request, obj, form, change) + + +@admin.register(models.QualityRuleset) +class QualityRulesetAdmin(admin.ModelAdmin): + pass + + +@admin.register(models.QualityLevel) +class QualityLevelAdmin(admin.ModelAdmin): + pass + + +@admin.register(models.QualityCriterion) +class QualityCriterionAdmin(admin.ModelAdmin): + pass diff --git a/datasources/forms.py b/datasources/forms.py index e3678de99976f8f6f5c6ad0184d4db9b95e8058d..774ae7895e679d2293205a9ba23efc2361ce8bb0 100644 --- a/datasources/forms.py +++ b/datasources/forms.py @@ -97,12 +97,6 @@ class PermissionGrantForm(forms.ModelForm): fields = ['granted', 'push_granted'] -class MetadataFieldForm(forms.ModelForm): - class Meta: - model = models.MetadataItem - fields = ['field', 'value'] - - class LicenceForm(forms.ModelForm): class Meta: model = models.Licence diff --git a/datasources/migrations/0032_add_quality_rules.py b/datasources/migrations/0032_add_quality_rules.py new file mode 100644 index 0000000000000000000000000000000000000000..65198fd8dd0fe6d21198cacc8bae5c07504338fc --- /dev/null +++ b/datasources/migrations/0032_add_quality_rules.py @@ -0,0 +1,61 @@ +# Generated by Django 2.0.13 on 2019-04-10 12:47 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('datasources', '0031_default_connector_name'), + ] + + operations = [ + migrations.CreateModel( + name='QualityCriterion', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('weight', models.FloatField(default=1)), + ('metadata_field', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='datasources.MetadataField')), + ], + ), + migrations.CreateModel( + name='QualityLevel', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('level', models.PositiveSmallIntegerField()), + ('threshold', models.FloatField(blank=True, null=True)), + ], + ), + migrations.CreateModel( + name='QualityRuleset', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=63)), + ('short_name', models.CharField(blank=True, max_length=63, unique=True)), + ('version', models.CharField(max_length=63)), + ], + ), + migrations.AlterUniqueTogether( + name='qualityruleset', + unique_together={('name', 'version')}, + ), + migrations.AddField( + model_name='qualitylevel', + name='ruleset', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='levels', to='datasources.QualityRuleset'), + ), + migrations.AddField( + model_name='qualitycriterion', + name='quality_level', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='criteria', to='datasources.QualityLevel'), + ), + migrations.AlterUniqueTogether( + name='qualitylevel', + unique_together={('ruleset', 'level')}, + ), + migrations.AlterUniqueTogether( + name='qualitycriterion', + unique_together={('quality_level', 'metadata_field')}, + ), + ] diff --git a/datasources/migrations/0033_quality_levels_ordered.py b/datasources/migrations/0033_quality_levels_ordered.py new file mode 100644 index 0000000000000000000000000000000000000000..25fe97975b7d833c0589d51d9d1974e61a11a0f0 --- /dev/null +++ b/datasources/migrations/0033_quality_levels_ordered.py @@ -0,0 +1,21 @@ +# Generated by Django 2.0.13 on 2019-04-10 14:07 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('datasources', '0032_add_quality_rules'), + ] + + operations = [ + migrations.AlterModelOptions( + name='qualitycriterion', + options={'verbose_name_plural': 'quality criteria'}, + ), + migrations.AlterModelOptions( + name='qualitylevel', + options={'ordering': ['level']}, + ), + ] diff --git a/datasources/models/__init__.py b/datasources/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..2e459ef74f8149af14bcd1ee0d76219b19a48d59 --- /dev/null +++ b/datasources/models/__init__.py @@ -0,0 +1,23 @@ +""" +This package contains the :class:`DataSource` model representing an internal or +external data source and the models necessary to support it. +""" + +from .datasource import ( + Licence, + UserPermissionLevels, + UserPermissionLink, + DataSource +) + +from .metadata import ( + MetadataField, + MetadataItem +) + + +from .quality import ( + QualityRuleset, + QualityLevel, + QualityCriterion +) diff --git a/datasources/models.py b/datasources/models/datasource.py similarity index 83% rename from datasources/models.py rename to datasources/models/datasource.py index e7c1b70fcfbba5e81ebb70ab6a16b56fd0097c4d..aad5cdeafe3d47f007bfdf7b14d6782e5fd8d7a4 100644 --- a/datasources/models.py +++ b/datasources/models/datasource.py @@ -8,7 +8,6 @@ import json import typing from django.conf import settings -from django.core import validators from django.db import models from django.urls import reverse @@ -60,91 +59,6 @@ class Licence(models.Model): return reverse('datasources:licence.detail', kwargs={'pk': self.pk}) -class MetadataField(models.Model): - """ - A metadata field that can be dynamically added to a data source. - - Operational MetadataFields are those which have some associated code within PEDASI. - They should be present within any deployment of PEDASI. - - Current operational metadata fields are (by short_name): - - data_query_param - - indexed_field - """ - #: Name of the field - name = models.CharField(max_length=MAX_LENGTH_NAME, - unique=True, - blank=False, null=False) - - #: Short text identifier for the field - short_name = models.CharField( - max_length=MAX_LENGTH_NAME, - validators=[ - validators.RegexValidator( - r'^[a-zA-Z][a-zA-Z0-9_]*\Z', - 'Short name must begin with a letter and consist only of letters, numbers and underscores.', - 'invalid' - ) - ], - unique=True, - blank=False, null=False - ) - - #: Does the field have an operational effect within PEDASI? - operational = models.BooleanField(default=False, - blank=False, null=False) - - def __str__(self): - return self.name - - @classmethod - def load_inline_fixtures(cls): - """ - Create any instances required for the functioning of PEDASI. - - This is called from within the AppConfig. - """ - fixtures = ( - ('data_query_param', 'data_query_param', True), - ('indexed_field', 'indexed_field', True), - ) - - for name, short_name, operational in fixtures: - obj, created = cls.objects.get_or_create( - name=name, - short_name=short_name - ) - obj.operational = operational - obj.save() - - -class MetadataItem(models.Model): - """ - The value of the metadata field on a given data source. - """ - #: The value of this metadata field - value = models.CharField(max_length=MAX_LENGTH_REASON, - blank=True, null=False) - - #: To which field does this relate? - field = models.ForeignKey(MetadataField, - related_name='values', - on_delete=models.PROTECT, - blank=False, null=False) - - #: To which data source does this relate? - datasource = models.ForeignKey('DataSource', - related_name='metadata_items', - on_delete=models.CASCADE, - blank=False, null=False) - - class Meta: - unique_together = (('field', 'datasource', 'value'),) - - def __str__(self): - return self.value - - @enum.unique class UserPermissionLevels(enum.IntEnum): """ diff --git a/datasources/models/metadata.py b/datasources/models/metadata.py new file mode 100644 index 0000000000000000000000000000000000000000..b018132ea274e67ba1457dd18a47bbdd5f1fb3ba --- /dev/null +++ b/datasources/models/metadata.py @@ -0,0 +1,94 @@ +""" +This module contains models for dynamic assignment of metadata to data sources. +""" + +from django.core import validators +from django.db import models + +from core.models import MAX_LENGTH_NAME +from .datasource import DataSource, MAX_LENGTH_REASON + + +class MetadataField(models.Model): + """ + A metadata field that can be dynamically added to a data source. + + Operational MetadataFields are those which have some associated code within PEDASI. + They should be present within any deployment of PEDASI. + + Current operational metadata fields are (by short_name): + - data_query_param + - indexed_field + """ + #: Name of the field + name = models.CharField(max_length=MAX_LENGTH_NAME, + unique=True, + blank=False, null=False) + + #: Short text identifier for the field + short_name = models.CharField( + max_length=MAX_LENGTH_NAME, + validators=[ + validators.RegexValidator( + r'^[a-zA-Z][a-zA-Z0-9_]*\Z', + 'Short name must begin with a letter and consist only of letters, numbers and underscores.', + 'invalid' + ) + ], + unique=True, + blank=False, null=False + ) + + #: Does the field have an operational effect within PEDASI? + operational = models.BooleanField(default=False, + blank=False, null=False) + + def __str__(self): + return self.name + + @classmethod + def load_inline_fixtures(cls): + """ + Create any instances required for the functioning of PEDASI. + + This is called from within the AppConfig. + """ + fixtures = ( + ('data_query_param', 'data_query_param', True), + ('indexed_field', 'indexed_field', True), + ) + + for name, short_name, operational in fixtures: + obj, created = cls.objects.get_or_create( + name=name, + short_name=short_name + ) + obj.operational = operational + obj.save() + + +class MetadataItem(models.Model): + """ + The value of the metadata field on a given data source. + """ + #: The value of this metadata field + value = models.CharField(max_length=MAX_LENGTH_REASON, + blank=True, null=False) + + #: To which field does this relate? + field = models.ForeignKey(MetadataField, + related_name='values', + on_delete=models.PROTECT, + blank=False, null=False) + + #: To which data source does this relate? + datasource = models.ForeignKey(DataSource, + related_name='metadata_items', + on_delete=models.CASCADE, + blank=False, null=False) + + class Meta: + unique_together = (('field', 'datasource', 'value'),) + + def __str__(self): + return self.value diff --git a/datasources/models/quality.py b/datasources/models/quality.py new file mode 100644 index 0000000000000000000000000000000000000000..b5bc6bd207badb85cb8ce8ed3d4340e9a5155755 --- /dev/null +++ b/datasources/models/quality.py @@ -0,0 +1,143 @@ +""" +This module contains models related to the quality assessment of data sources. +""" +import itertools + +from django.db import models + +from core.models import MAX_LENGTH_NAME +from .datasource import DataSource +from .metadata import MetadataField + + +class QualityRuleset(models.Model): + """ + A ruleset for assessing the quality of a data source. + """ + class Meta: + unique_together = (('name', 'version',),) + + # Prevent template engine from trying to call the model + do_not_call_in_templates = True + + #: Name of the ruleset + name = models.CharField(max_length=MAX_LENGTH_NAME, + blank=False, null=False) + + #: Short text identifier + short_name = models.CharField(max_length=MAX_LENGTH_NAME, + unique=True, blank=True, null=False) + + #: Ruleset version - distinguishes successive versions of the same set + version = models.CharField(max_length=MAX_LENGTH_NAME, + blank=False, null=False) + + def __call__(self, datasource: DataSource) -> int: + """ + Evaluate a data source to get its quality level under this ruleset. + + :param datasource: Data source to assess + :return: Quality level of data source + """ + # Get list of all levels that the data source passes - stop when a fail is encountered + passes = list( + itertools.takewhile( + lambda x: x(datasource), + self.levels.all() + ) + ) + + try: + # Highest passed level will be the last in the list + return passes[-1].level + + except IndexError: + return 0 + + def __str__(self): + return '{0} - {1}'.format(self.name, self.version) + + +class QualityLevel(models.Model): + """ + A set of criteria that is required to grant a particular quality level. + """ + class Meta: + unique_together = (('ruleset', 'level'),) + ordering = ['level'] + + # Prevent template engine from trying to call the model + do_not_call_in_templates = True + + #: Which ruleset does this level belong to? + ruleset = models.ForeignKey(QualityRuleset, related_name='levels', + on_delete=models.CASCADE, + blank=False, null=False) + + #: What level is this? + level = models.PositiveSmallIntegerField(blank=False, null=False) + + #: Threshold level that must be exceeded by criteria weights to pass this level + threshold = models.FloatField(blank=True, null=True) + + def __call__(self, datasource: DataSource, rtol: float = 1e-3) -> bool: + """ + Does a data source pass the criteria for this quality level? + + :param datasource: Data source to assess + :param rtol: Relative tolerance to compare floating point threshold + :return: Passes this quality level? + """ + threshold = self.threshold + if threshold is None: + threshold = self.criteria.aggregate(models.Sum('weight'))['weight__sum'] + + total = sum(criterion(datasource) for criterion in self.criteria.all()) + + # Compare using relative tolerance to account for floating point error + return total >= (threshold * (1 - rtol)) + + def __str__(self): + return '{0} - level {1}'.format(self.ruleset, self.level) + + +class QualityCriterion(models.Model): + """ + A weighted criterion to determine whether a data source meets a certain quality level. + """ + class Meta: + unique_together = (('quality_level', 'metadata_field'),) + verbose_name_plural = 'quality criteria' + + # Prevent template engine from trying to call the model + do_not_call_in_templates = True + + #: Which quality level does this criterion belong to? + quality_level = models.ForeignKey(QualityLevel, related_name='criteria', + on_delete=models.CASCADE, + blank=False, null=False) + + #: Which metadata field represents this criterion? + metadata_field = models.ForeignKey(MetadataField, related_name='+', + on_delete=models.PROTECT, + blank=False, null=False) + + #: What proportion of the quality level does this criterion provide? + weight = models.FloatField(default=1, + blank=False, null=False) + + def __call__(self, datasource: DataSource) -> float: + """ + Weight provided to the quality level based on passing or failing this criterion. + + :param datasource: Data source to assess + :return: Weight provided to the quality level from passing or failing this criterion + """ + return self.weight if datasource.metadata_items.filter(field=self.metadata_field).exists() else 0 + + def __str__(self): + return '{0} - weight {1}'.format(self.metadata_field, self.weight) + + + + diff --git a/datasources/serializers.py b/datasources/serializers.py index 232ecd4725377d62613a487c429cd879db21d5e3..2d73fc3d5c613a749396bc03a4295fb4ef0ac3ec 100644 --- a/datasources/serializers.py +++ b/datasources/serializers.py @@ -16,8 +16,6 @@ class MetadataFieldSerializer(serializers.ModelSerializer): class MetadataItemSerializer(serializers.ModelSerializer): - field = MetadataFieldSerializer(read_only=True) - class Meta: model = models.MetadataItem fields = ['field', 'value'] @@ -40,3 +38,25 @@ class DataSourceSerializer(serializers.ModelSerializer): 'encrypted_docs_url', 'metadata_items' ] + + +class QualityCriterionSerializer(serializers.ModelSerializer): + class Meta: + model = models.QualityCriterion + fields = ['weight', 'metadata_field'] + + +class QualityLevelSerializer(serializers.ModelSerializer): + criteria = QualityCriterionSerializer(many=True, read_only=True) + + class Meta: + model = models.QualityLevel + fields = ['level', 'threshold', 'criteria'] + + +class QualityRulesetSerializer(serializers.ModelSerializer): + levels = QualityLevelSerializer(many=True, read_only=True) + + class Meta: + model = models.QualityRuleset + fields = '__all__' diff --git a/datasources/static/js/metadata.js b/datasources/static/js/metadata.js index e05914501082d1d28d7b232df888db58053968c2..89a3ec747c6789c720752d19f31f9e7154396ffa 100644 --- a/datasources/static/js/metadata.js +++ b/datasources/static/js/metadata.js @@ -1,6 +1,7 @@ "use strict"; let metadataUrl = null; +let ratingUrl = null; /** @@ -13,8 +14,14 @@ function setMetadataUrl(url) { metadataUrl = url; } +function setRatingUrl(url) { + "use strict"; + ratingUrl = url; +} + function getCookie(name) { + "use strict"; const re = new RegExp("(^| )" + name + "=([^;]+)"); const match = document.cookie.match(re); if (match) { @@ -23,30 +30,33 @@ function getCookie(name) { } -function tableAppendRow(table, values, id=null) { - const row = table.insertRow(-1); - if (id !== null) { - row.id = id; - } +/** + * Create a new metadata item or update an existing one. + * + * @param event Form event + */ +function submitMetadataForm(event) { + "use strict"; - for (const value of values) { - const cell = row.insertCell(-1); - const text = document.createTextNode(value); - cell.appendChild(text); - } + const metadataIdField = event.target.getElementsByClassName("fieldMetadataId")[0]; + const metadataValueIdField = event.target.getElementsByClassName("fieldMetadataValueId")[0]; + const valueField = event.target.getElementsByClassName("fieldMetadataValue")[0]; - return row; -} + let url = metadataUrl; + let method = "POST"; + if (metadataValueIdField && metadataValueIdField.value) { + url = metadataUrl + metadataValueIdField.value.toString() + "/"; + method = "PUT"; + } -function postMetadata() { fetch( - metadataUrl, + url, { - method: "POST", + method: method, body: JSON.stringify({ - field: document.getElementById("id_field").value, - value: document.getElementById("id_value").value + field: metadataIdField.value, + value: valueField.value }), headers: { "Accept": "application/json", @@ -54,68 +64,90 @@ function postMetadata() { "X-CSRFToken": getCookie("csrftoken") } } - ).then( - response => response.json() - ).then( - function (response) { - if (response.status === "success") { - const table = document.getElementById("tableMetadata"); - const field = response.data.field; - const value = response.data.value; - - const row = tableAppendRow( - table, - [field, value], - "metadata-row-" + response.data.field_short + "-" + value - ); - - const buttonCell = row.insertCell(-1); - const button = document.createElement("button"); - - button.id = "btn-" + field + "-" + value; - button.classList.add("btn", "btn-sm", "btn-danger", "float-right"); - button.addEventListener( - "click", - function () { - const f = response.data.field_short; - const v = value; - deleteMetadata(f, v); - } - ); - button.textContent = "Delete"; - buttonCell.appendChild(button); - row.appendChild(buttonCell); - } + + ).then(function (response) { + if (!response.ok) { + throw new Error("Request failed: " + response.statusText); } - ).catch( - function (e) { - console.log(e); + + valueField.dataset.currentValue = valueField.value; + window.location.reload(); + + }).catch(function (error) { + console.log(error); + + if (valueField.dataset.currentValue !== null) { + valueField.value = valueField.dataset.lastValue; } - ) + + }); } -function deleteMetadata(field, value) { +/** + * Delete an existing metadata item. + * + * @param event Form event + */ +function deleteMetadataForm(event) { + "use strict"; + + const metadataIdField = event.target.getElementsByClassName("fieldMetadataValueId")[0]; + fetch( - metadataUrl, + metadataUrl + metadataIdField.value.toString() + "/", { method: "DELETE", - body: JSON.stringify({ - field: field, - value: value - }), headers: { "Accept": "application/json", - "Content-Type": "application/json", "X-CSRFToken": getCookie("csrftoken") } } - ).then( - response => response.json() + ).then(function (response) { - if (response.status === "success") { - const row = document.getElementById("metadata-row-" + field + "-" + value); - row.parentNode.removeChild(row); + if (!response.ok) { + throw new Error("Request failed: " + response.statusText); } - }) + window.location.reload(); + + }).catch(function (error) { + console.log(error); + + }); +} + +/** + * Update the star rating display. + * + * @param url Url to get quality level for a data source - defaults to global setting 'ratingUrl' + * @param badgeId Id of badge element to populate with stars - defaults to 'qualityLevelBadge' + */ +function updateLevelBadge(url, badgeId) { + "use strict"; + + if (!url) { + url = ratingUrl; + } + + if (!badgeId) { + badgeId = "qualityLevelBadge"; + } + + fetch(url).then( + (response) => response.json() + + ).then(function (data) { + const levelBadge = document.getElementById(badgeId); + + // Clear existing rating + while (levelBadge.hasChildNodes()) { + levelBadge.removeChild(levelBadge.lastChild); + } + + for (let i = 0; i < data.quality; i++) { + const star = document.createElement("i"); + star.classList.add("fas", "fa-star"); + levelBadge.appendChild(star); + } + }); } diff --git a/datasources/templates/datasources/datasource/detail.html b/datasources/templates/datasources/datasource/detail.html index d2a4c823b24511ab37f10a27af890603d72f5564..ab1c44aa2f02d474a7142aa56674adb288d2bc8e 100644 --- a/datasources/templates/datasources/datasource/detail.html +++ b/datasources/templates/datasources/datasource/detail.html @@ -2,15 +2,9 @@ {% load bootstrap4 %} {% block extra_head %} - {% if metadata_field_form %} - {% load static %} + {% load static %} - <script type="application/javascript" src="{% static 'js/metadata.js' %}"></script> - - <script type="application/javascript"> - setMetadataUrl("/datasources/{{ datasource.pk }}/metadata"); - </script> - {% endif %} + <script type="application/javascript" src="{% static 'js/metadata.js' %}"></script> {% endblock %} {% block content %} @@ -32,23 +26,28 @@ <div class="col-md-10 col-sm-8"> <h2> {{ datasource.name }} - {# TODO make this a template tag #} - {% if datasource.licence %} - <small> + <small> + {% if datasource.licence %} <a href="{% url 'datasources:licence.detail' pk=datasource.licence.pk %}" class="badge badge-info" data-toggle="tooltip" data-placement="bottom" title="{{ datasource.licence.name }}"> {{ datasource.licence.short_name }} </a> - </small> - {% else %} - <small> + {% else %} <span class="badge badge-warning" data-toggle="tooltip" data-placement="bottom" title="No Licence"> No Licence </span> - </small> - {% endif %} + {% endif %} + + <span id="qualityLevelBadge" class="badge badge-primary"></span> + + <script type="application/javascript"> + document.addEventListener("DOMContentLoaded", function () { + updateLevelBadge("{% url 'api:datasource-quality' pk=datasource.pk %}"); + }); + </script> + </small> </h2> {% if datasource.description %} @@ -69,6 +68,9 @@ <a href="{% url 'datasources:datasource.access.manage' pk=datasource.pk %}" class="btn btn-block btn-primary" role="button">Manage Access</a> + <a href="{% url 'datasources:datasource.metadata' pk=datasource.pk %}" + class="btn btn-block btn-secondary" role="button">Manage Metadata</a> + <a href="{% url 'datasources:datasource.edit' datasource.id %}" class="btn btn-block btn-success" role="button">Edit</a> @@ -128,9 +130,6 @@ <thead> <th scope="col" class="w-25 border-0"></th> <th scope="col" class="border-0"></th> - {% if metadata_field_form %} - <th scope="col" class="w-25 border-0"></th> - {% endif %} </thead> <tbody> @@ -138,36 +137,10 @@ <tr id="metadata-row-{{ metadata_item.field.short_name }}-{{ metadata_item.value }}"> <td>{{ metadata_item.field.name }}</td> <td>{{ metadata_item.value }}</td> - - {% if metadata_field_form %} - <td> - <button role="button" - onclick="deleteMetadata('{{ metadata_item.field.short_name }}','{{ metadata_item.value }}')" - class="btn btn-sm btn-danger float-right">Delete</button> - </td> - {% endif %} </tr> {% endfor %} </tbody> </table> - - {% if metadata_field_form %} - <form id="formMetadata" class="form" action="javascript:postMetadata();"> - {% csrf_token %} - - <div class="row"> - <div class="col-3"> - {% bootstrap_field metadata_field_form.field layout='inline' %} - </div> - <div class="col-6"> - {% bootstrap_field metadata_field_form.value layout='inline' %} - </div> - <div class="col-3"> - <button type="submit" class="btn btn-block btn-success">Add</button> - </div> - </div> - </form> - {% endif %} </div> </div> diff --git a/datasources/templates/datasources/datasource/list.html b/datasources/templates/datasources/datasource/list.html index 771bf6d7d6315eef819457cb24251cffd2c0dd95..f06cac0638415f4568030eae77312e20809ee06c 100644 --- a/datasources/templates/datasources/datasource/list.html +++ b/datasources/templates/datasources/datasource/list.html @@ -1,6 +1,12 @@ {% extends "base.html" %} {% load bootstrap4 %} +{% block extra_head %} + {% load static %} + + <script type="application/javascript" src="{% static 'js/metadata.js' %}"></script> +{% endblock %} + {% block content %} <nav aria-label="breadcrumb"> <ol class="breadcrumb"> @@ -56,6 +62,17 @@ No Licence </span> {% endif %} + <span id="qualityLevelBadge{{ datasource.pk }}" class="badge badge-primary"></span> + + <script type="application/javascript"> + // TODO Should this be a single function to loop over all data sources? + document.addEventListener("DOMContentLoaded", function () { + updateLevelBadge( + "{% url 'api:datasource-quality' pk=datasource.pk %}", + "qualityLevelBadge{{ datasource.pk }}" + ); + }); + </script> </p> <p class="pl-5"> {{ datasource.description|truncatechars:120 }} diff --git a/datasources/templates/datasources/datasource/metadata.html b/datasources/templates/datasources/datasource/metadata.html index 9cf8b36525b3410f05e3882aaa7821ada2b3c5af..99e5b8559a72ef6e12deb3a63dc8f9fc7a00384e 100644 --- a/datasources/templates/datasources/datasource/metadata.html +++ b/datasources/templates/datasources/datasource/metadata.html @@ -1,6 +1,17 @@ {% extends "base.html" %} {% load bootstrap4 %} +{% block extra_head %} + {% load static %} + + <script type="application/javascript" src="{% static 'js/metadata.js' %}"></script> + + <script type="application/javascript"> + setMetadataUrl("{% url 'api:metadata-item-list' datasource_pk=datasource.pk %}"); + setRatingUrl("{% url 'api:datasource-quality' pk=datasource.pk %}"); + </script> +{% endblock %} + {% block content %} <nav aria-label="breadcrumb"> <ol class="breadcrumb"> @@ -19,11 +30,19 @@ </ol> </nav> - <h2>View Data Source - {{ datasource.name }}</h2> + <h2> + View Data Source - {{ datasource.name }} - <p> - Owner: <a href="#" role="link">{{ datasource.owner }}</a> - </p> + <small> + <span id="qualityLevelBadge" class="badge badge-primary"></span> + + <script type="application/javascript"> + document.addEventListener("DOMContentLoaded", function () { + updateLevelBadge(); + }); + </script> + </small> + </h2> {% if datasource.description %} <p>{{ datasource.description }}</p> @@ -31,46 +50,164 @@ <hr> - <table class="table"> - <thead class="thead"> - <tr> - <th>Relation</th> - <th>Values</th> - </tr> - </thead> + {% load datasource_extras %} + <h2>Quality Metadata</h2> + + <table class="table"> <tbody> - {% for relation, values in metadata.items %} + + {% for level in ruleset.levels.all %} <tr> - <td>{{ relation }}</td> - <td> - {% for value in values %} - <p>{{ value }}</p> - {% endfor %} - </td> + <th class="row"> + <div class="col-md-3"> + Level {{ level.level }} + </div> + + <div class="col-md-1"> + {{ level.threshold }} + </div> + </th> </tr> + + {% for criterion in level.criteria.all %} + {% with metadata_items|access:criterion.metadata_field.short_name as metadata_field_group %} + <tr> + <td> + <div class="row"> + <div class="col-md-3"> + <span class="form-text">{{ criterion.metadata_field.name }}</span> + </div> + + <div class="col-md-1"> + {{ criterion.weight }} + </div> + + <div class="col-md-8"> + {% for item in metadata_field_group.list %} + <form class="row" + onsubmit="submitMetadataForm(event); event.preventDefault();" + onreset="deleteMetadataForm(event); event.preventDefault();"> + <div class="col-md-8"> + <input type="hidden" class="fieldDatasourceId" readonly + value="{{ datasource.id }}"> + + <input type="hidden" class="fieldMetadataId" readonly + value="{{ metadata_field_group.field.id }}"> + + <input type="hidden" class="fieldMetadataValueId" readonly + value="{{ item.id }}"> + + <input type="text" class="form-control mb-2 fieldMetadataValue" + value="{{ item.value }}" data-last-value="{{ item.value }}"> + </div> + + <div class="col-md-2"> + <button type="submit" class="btn btn-block btn-success mb-2">Save</button> + </div> + + <div class="col-md-2"> + <button type="reset" class="btn btn-block btn-danger mb-2">Delete</button> + </div> + </form> + {% endfor %} + + <form class="row" + onsubmit="submitMetadataForm(event); event.preventDefault();"> + <div class="col-md-8"> + <input type="hidden" class="fieldDatasourceId" readonly + value="{{ datasource.id }}"> + + <input type="hidden" class="fieldMetadataId" readonly + value="{{ criterion.metadata_field.id }}"> + + <input type="text" class="form-control mb-2 fieldMetadataValue"> + </div> + + <div class="col-md-2"> + <button type="submit" class="btn btn-block btn-success mb-2">Save</button> + </div> + </form> + + </div> + </div> + </td> + </tr> + {% endwith %} + + {% endfor %} + {% endfor %} + </tbody> </table> <hr> - <table class="table"> - <thead class="thead"> - <tr> - <th>URL</th> - <th>Metadata</th> - </tr> - </thead> + <h2>All Metadata</h2> + <table class="table"> <tbody> - {% for dataset, dataset_metadata in datasets.items %} - <tr> - <td>{{ dataset }}</td> - <td>{{ dataset_metadata }}</td> - </tr> - {% endfor %} + {% for metadata_field in metadata_fields %} + {% with metadata_items|access:metadata_field.short_name as metadata_field_group %} + <tr> + <td> + <div class="row"> + <div class="col-md-4"> + <span class="form-text">{{ metadata_field.name }}</span> + </div> + + <div class="col-md-8"> + {% for item in metadata_field_group.list %} + <form class="row" + onsubmit="submitMetadataForm(event); event.preventDefault();" + onreset="deleteMetadataForm(event); event.preventDefault();"> + <div class="col-md-8"> + <input type="hidden" class="fieldDatasourceId" readonly + value="{{ datasource.id }}"> + + <input type="hidden" class="fieldMetadataId" readonly + value="{{ metadata_field_group.field.id }}"> + + <input type="hidden" class="fieldMetadataValueId" readonly + value="{{ item.id }}"> + + <input type="text" class="form-control mb-2 fieldMetadataValue" + value="{{ item.value }}" data-last-value="{{ item.value }}"> + </div> + + <div class="col-md-2"> + <button type="submit" class="btn btn-block btn-success mb-2">Save</button> + </div> + + <div class="col-md-2"> + <button type="reset" class="btn btn-block btn-danger mb-2">Delete</button> + </div> + </form> + {% endfor %} + + <form class="row" + onsubmit="submitMetadataForm(event); event.preventDefault();"> + <div class="col-md-8"> + <input type="hidden" class="fieldDatasourceId" readonly + value="{{ datasource.id }}"> + + <input type="hidden" class="fieldMetadataId" readonly + value="{{ metadata_field.id }}"> + + <input type="text" class="form-control mb-2 fieldMetadataValue"> + </div> + + <div class="col-md-2"> + <button type="submit" class="btn btn-block btn-success mb-2">Save</button> + </div> + </form> + </div> + </div> + </td> + </tr> + {% endwith %} + {% endfor %} </tbody> </table> - -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/datasources/templatetags/__init__.py b/datasources/templatetags/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/datasources/templatetags/datasource_extras.py b/datasources/templatetags/datasource_extras.py new file mode 100644 index 0000000000000000000000000000000000000000..997794bf550e95734d2c7d87cf7a9a26fcb44d22 --- /dev/null +++ b/datasources/templatetags/datasource_extras.py @@ -0,0 +1,27 @@ +from django.template import Library + +register = Library() + + +@register.filter +def access(value, arg): + """ + Template filter to access a dictionary by a key. + + :param value: Dictionary to access + :param arg: Key to look up + :return: Value of key in dictionary + """ + try: + return value[arg] + + except KeyError: + return None + + except TypeError: + # Is a GroupedResult not a dictionary + for item in value: + if item.field.short_name == arg: + return item + + return None diff --git a/datasources/tests/test_quality.py b/datasources/tests/test_quality.py new file mode 100644 index 0000000000000000000000000000000000000000..c71af1a62fa1e0aec1cddd28e72ed8247859fc0c --- /dev/null +++ b/datasources/tests/test_quality.py @@ -0,0 +1,154 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase + +from datasources import models + + +class QualityRulesetTest(TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.user = get_user_model().objects.create_user( + username='Test User 1' + ) + + cls.metadata_field = models.MetadataField.objects.create( + name='Test Metadata Field 1', + short_name='TMF1' + ) + + def setUp(self): + super().setUp() + + self.ruleset = models.QualityRuleset.objects.create( + name='Test Ruleset 1', + short_name='TR1v1', + version='1' + ) + + self.datasource = models.DataSource.objects.create( + name='Test Data Source 1', + owner=self.user + ) + + def test_create_ruleset(self): + ruleset = models.QualityRuleset.objects.get( + short_name='TR1v1' + ) + + self.assertEqual('Test Ruleset 1', ruleset.name) + self.assertEqual('TR1v1', ruleset.short_name) + self.assertEqual('1', ruleset.version) + + return ruleset + + def test_ruleset_eval_null(self): + """ + Test that an empty ruleset identifies any datasource as level 0. + """ + level = self.ruleset(self.datasource) + + self.assertEqual(0, level) + + def test_ruleset_create_level(self): + """ + Test that levels can be added to rulesets. + """ + self.ruleset.levels.create( + level=1 + ) + + self.assertEqual(1, self.ruleset.levels.count()) + + def test_ruleset_create_criterion(self): + """ + Test that criteria can be added to quality levels. + """ + level_1 = self.ruleset.levels.create( + level=1 + ) + + level_1.criteria.create( + metadata_field=self.metadata_field + ) + + self.assertEqual(1, level_1.criteria.count()) + + def test_ruleset_eval_criterion(self): + """ + Test that criteria are evaluated correctly. + + Should return weight contributed to level threshold. + """ + level_1 = self.ruleset.levels.create( + level=1, + threshold=1 + ) + + criterion = level_1.criteria.create( + metadata_field=self.metadata_field, + weight=1 + ) + + self.assertEqual(1, level_1.criteria.count()) + + self.assertAlmostEqual(0, criterion(self.datasource)) + + self.datasource.metadata_items.create( + field=self.metadata_field, + value='' + ) + + self.assertAlmostEqual(1, criterion(self.datasource)) + + def test_ruleset_eval_level(self): + """ + Test that quality levels are evaluated correctly. + + Should return whether data source passes level threshold. + """ + level_1 = self.ruleset.levels.create( + level=1, + threshold=1 + ) + + level_1.criteria.create( + metadata_field=self.metadata_field, + weight=1 + ) + + self.assertFalse(level_1(self.datasource)) + + self.datasource.metadata_items.create( + field=self.metadata_field, + value='' + ) + + self.assertTrue(level_1(self.datasource)) + + def test_ruleset_eval(self): + """ + Test that rulesets are evaluated correctly. + + Should return quality level of data source. + """ + level_1 = self.ruleset.levels.create( + level=1, + threshold=1 + ) + + level_1.criteria.create( + metadata_field=self.metadata_field, + weight=1 + ) + + self.assertEqual(0, self.ruleset(self.datasource)) + + self.datasource.metadata_items.create( + field=self.metadata_field, + value='' + ) + + self.assertEqual(1, self.ruleset(self.datasource)) + diff --git a/datasources/urls.py b/datasources/urls.py index f8f94d360cdfd75d0baed7a309fe37363be74c2a..0974dc716b7efeaf8cbf9f67546f2b5d85db3925 100644 --- a/datasources/urls.py +++ b/datasources/urls.py @@ -26,7 +26,7 @@ urlpatterns = [ name='datasource.delete'), path('<int:pk>/metadata', - views.datasource.DataSourceMetadataAjaxView.as_view(), + views.datasource.DataSourceMetadataView.as_view(), name='datasource.metadata'), path('<int:pk>/explorer', diff --git a/datasources/views/datasource.py b/datasources/views/datasource.py index 1ea6417e764043f439af276d276805c6458c6426..0b2b58eb3b231ea7d9e06668044acb52145d4ac3 100644 --- a/datasources/views/datasource.py +++ b/datasources/views/datasource.py @@ -1,4 +1,7 @@ +from collections import namedtuple + from django.contrib import messages +from django.contrib.auth import get_user_model from django.contrib.auth.mixins import PermissionRequiredMixin from django.http import HttpResponse from django.urls import reverse_lazy @@ -36,8 +39,6 @@ class DataSourceDetailView(DetailView): context = super().get_context_data(**kwargs) context['has_edit_permission'] = self.request.user.is_superuser or self.request.user == self.object.owner - if context['has_edit_permission']: - context['metadata_field_form'] = forms.MetadataFieldForm() try: context['is_catalogue'] = self.object.is_catalogue @@ -132,64 +133,31 @@ class DataSourceDataSetSearchView(HasPermissionLevelMixin, DetailView): return context -class DataSourceMetadataAjaxView(OwnerPermissionMixin, APIView): +FieldValueSet = namedtuple('FieldValueSet', ['field', 'list']) + + +class DataSourceMetadataView(OwnerPermissionMixin, DetailView): model = models.DataSource + template_name = 'datasources/datasource/metadata.html' + context_object_name = 'datasource' - # Don't redirect to login page if unauthorised - raise_exception = True - - class MetadataSerializer(serializers.ModelSerializer): - class Meta: - model = models.MetadataItem - fields = '__all__' - - def get_object(self, pk): - return self.model.objects.get(pk=pk) - - def post(self, request, pk, format=None): - """ - Create a new MetadataItem associated with this DataSource. - """ - datasource = self.get_object(pk) - if 'datasource' not in request.data: - request.data['datasource'] = datasource.pk - - serializer = self.MetadataSerializer(data=request.data) - - if serializer.is_valid(): - obj = serializer.save() - - return Response({ - 'status': 'success', - 'data': { - 'datasource': datasource.pk, - 'field': obj.field.name, - 'field_short': obj.field.short_name, - 'value': obj.value, - } - }) - - return Response({'status': 'failure'}, status=400) - - def delete(self, request, pk, format=None): - """ - Delete a MetadataItem associated with this DataSource. - """ - datasource = self.get_object(pk) - if 'datasource' not in request.data: - request.data['datasource'] = datasource.pk - - metadata_item = models.MetadataItem.objects.get( - datasource=datasource, - field__short_name=self.request.data['field'], - value=self.request.data['value'] - ) + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) - metadata_item.delete() + context['ruleset'] = get_user_model().get_quality_ruleset() + context['metadata_fields'] = models.MetadataField.objects.all() - return Response({ - 'status': 'success' - }) + items = [] + present_fields = {item.field for item in self.object.metadata_items.all()} + for field in present_fields: + items.append( + FieldValueSet(field, [ + item for item in self.object.metadata_items.all() if item.field == field + ]) + ) + context['metadata_items'] = items + + return context class DataSourceExplorerView(HasPermissionLevelMixin, DetailView): diff --git a/profiles/models.py b/profiles/models.py index 738fa71aaca9294d9327cb4476253935c4d89972..6afff109d5152d7ddcb09a2db92691c91172d12e 100644 --- a/profiles/models.py +++ b/profiles/models.py @@ -6,6 +6,8 @@ from django.urls import reverse from rest_framework.authtoken.models import Token +from datasources.models.quality import QualityRuleset + class User(AbstractUser): """ @@ -35,3 +37,12 @@ class User(AbstractUser): """ self.auth_token.delete() + # TODO ruleset should be configurable by user + @staticmethod + def get_quality_ruleset(): + try: + return QualityRuleset.objects.first() + + except QualityRuleset.DoesNotExist: + return None + diff --git a/profiles/templates/index.html b/profiles/templates/index.html index 732e933b68e7d3cb73856451f423433a64d5ed08..152b53de15c013cdc697f45e91ce115ae4864e3b 100644 --- a/profiles/templates/index.html +++ b/profiles/templates/index.html @@ -3,7 +3,10 @@ {% block extra_head %} {% load static %} + <link rel="stylesheet" href="{% static 'css/masthead.css' %}"> + + <script type="application/javascript" src="{% static 'js/metadata.js' %}"></script> {% endblock %} {% block pre_content %} @@ -148,7 +151,22 @@ style="width: 150px;"> <div class="card-body d-flex flex-column pb-2"> - <h5 class="card-title">{{ datasource.name }}</h5> + <h5 class="card-title"> + {{ datasource.name }} + + <span id="qualityLevelBadge{{ datasource.pk }}" + class="badge badge-primary float-right"></span> + + <script type="application/javascript"> + // TODO Should this be a single function to loop over all data sources? + document.addEventListener("DOMContentLoaded", function () { + updateLevelBadge( + "{% url 'api:datasource-quality' pk=datasource.pk %}", + "qualityLevelBadge{{ datasource.pk }}" + ); + }); + </script> + </h5> <p class="card-text">{{ datasource.description|truncatechars:120 }}</p> <p class="card-text text-right mt-auto"> diff --git a/requirements.txt b/requirements.txt index 4edb81020e50bcc916a03754f8089bb582e121e1..8945d1d5ddd9dcd08e4c71477e0c6789ebbf1095 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,6 +14,7 @@ django-cors-headers==2.4.0 django-haystack==2.8.1 djangorestframework==3.9.1 docutils==0.14 +drf-nested-routers==0.91 idna==2.7 imagesize==1.0.0 isodate==0.6.0