diff --git a/api/urls.py b/api/urls.py index 935b8990a186d720feaca690ba0b897ac0ed8444..7adf2a1dc491d88f2a58f71435974eb1c6ea4054 100644 --- a/api/urls.py +++ b/api/urls.py @@ -2,8 +2,11 @@ from django.urls import include, path from rest_framework_nested import routers -from .views import ( +from .views.datasources import ( DataSourceApiViewset, + MetadataItemApiViewset +) +from .views.quality import ( QualityCriterionApiViewset, QualityLevelApiViewset, QualityRulesetApiViewset @@ -13,9 +16,12 @@ app_name = 'api' # Register ViewSets router = routers.DefaultRouter() -router.register('datasources', 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') @@ -26,6 +32,9 @@ urlpatterns = [ path('', include(router.urls)), + path('', + include(datasource_router.urls)), + path('', include(ruleset_router.urls)), diff --git a/api/views/datasources.py b/api/views/datasources.py index db34262e852d486124022cb8838f119c92a0f630..8ec1c6dfdc438b2cee91b824e060dd485b5d51f2 100644 --- a/api/views/datasources.py +++ b/api/views/datasources.py @@ -9,6 +9,7 @@ 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 +19,18 @@ from datasources import models, serializers 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: diff --git a/datasources/serializers.py b/datasources/serializers.py index 5b923a3ec18ad4a9e745f89ac00d2a8949dbf84b..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'] diff --git a/datasources/static/js/metadata.js b/datasources/static/js/metadata.js index 517865ae4134c111730f863856f8c9a626dc7d28..887c4a3b06d0faaaed11e1a030cc6a289155798e 100644 --- a/datasources/static/js/metadata.js +++ b/datasources/static/js/metadata.js @@ -29,13 +29,31 @@ function getCookie(name) { } } -function updateMetadata(metadataIdField, valueField) { + +/** + * Create a new metadata item or update an existing one. + * + * @param event Form event + */ +function submitMetadataForm(event) { "use strict"; + const metadataIdField = event.target.getElementsByClassName("fieldMetadataId")[0]; + const metadataValueIdField = event.target.getElementsByClassName("fieldMetadataValueId")[0]; + const valueField = event.target.getElementsByClassName("fieldMetadataValue")[0]; + + let url = metadataUrl; + let method = "POST"; + + if (metadataValueIdField && metadataValueIdField.value) { + url = metadataUrl + metadataValueIdField.value.toString() + "/"; + method = "PUT"; + } + fetch( - metadataUrl, + url, { - method: "POST", + method: method, body: JSON.stringify({ field: metadataIdField.value, value: valueField.value @@ -48,75 +66,69 @@ function updateMetadata(metadataIdField, valueField) { } ).then(function (response) { - if (response.status !== 200) { - throw new Error("Request failed") + if (!response.ok) { + throw new Error("Request failed: " + response.statusText); } valueField.dataset.currentValue = valueField.value; + window.location.reload(); - return response.json() + }).catch(function (error) { + console.log(error); + + if (valueField.dataset.currentValue !== null) { + valueField.value = valueField.dataset.lastValue; + } - }).then(function (response) { - console.log(response); }); } -function deleteMetadata(metadataIdField, valueField) { +/** + * 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: metadataIdField.value, - value: valueField.dataset.currentValue - }), headers: { "Accept": "application/json", - "Content-Type": "application/json", "X-CSRFToken": getCookie("csrftoken") } } ).then(function (response) { - if (response.status !== 200) { - throw new Error("Request failed"); + if (!response.ok) { + throw new Error("Request failed: " + response.statusText); } - valueField.dataset.currentValue = ""; - - return response.json(); - - }).catch( - error => console.log(error) - ) -} - -function submitMetadataForm(event) { - "use strict"; - - const metdataIdField = event.target.getElementsByClassName("fieldMetadataId")[0]; - const valueField = event.target.getElementsByClassName("fieldMetadataValue")[0]; + window.location.reload(); - if (valueField.value) { - updateMetadata(metdataIdField, valueField); - } else { - deleteMetadata(metdataIdField, valueField); - } + }).catch(function (error) { + console.log(error); - updateLevelBadge(); + }); } +/** + * Update the star rating display. + */ function updateLevelBadge() { "use strict"; fetch(ratingUrl).then( - response => response.json() + (response) => response.json() ).then(function (data) { const levelBadge = document.getElementById("qualityLevelBadge"); + // Clear existing rating while (levelBadge.hasChildNodes()) { levelBadge.removeChild(levelBadge.lastChild); } diff --git a/datasources/templates/datasources/datasource/detail.html b/datasources/templates/datasources/datasource/detail.html index 9fb47628180b2b76edf5b0277d9bab17e80b189e..64d16d5cfd49e7da72ab704ae490c3abe4770347 100644 --- a/datasources/templates/datasources/datasource/detail.html +++ b/datasources/templates/datasources/datasource/detail.html @@ -73,6 +73,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> diff --git a/datasources/templates/datasources/datasource/metadata.html b/datasources/templates/datasources/datasource/metadata.html index bce2d60f0c7ce78f645c2c13b1dcda0412f5709f..99e5b8559a72ef6e12deb3a63dc8f9fc7a00384e 100644 --- a/datasources/templates/datasources/datasource/metadata.html +++ b/datasources/templates/datasources/datasource/metadata.html @@ -7,7 +7,7 @@ <script type="application/javascript" src="{% static 'js/metadata.js' %}"></script> <script type="application/javascript"> - setMetadataUrl("/datasources/{{ datasource.pk }}/metadata"); + setMetadataUrl("{% url 'api:metadata-item-list' datasource_pk=datasource.pk %}"); setRatingUrl("{% url 'api:datasource-quality' pk=datasource.pk %}"); </script> {% endblock %} @@ -50,47 +50,163 @@ <hr> - <table id="" class="table"> + {% load datasource_extras %} + + <h2>Quality Metadata</h2> + + <table class="table"> <tbody> - {% load datasource_extras %} {% for level in ruleset.levels.all %} <tr> - <th>Level {{ level.level }}</th> + <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 %} - <tr> - <td> - <form class="row" - onsubmit="submitMetadataForm(event); event.preventDefault();"> - <div class="col-md-4 mb-2"> - <input type="hidden" class="fieldDatasourceId" readonly - value="{{ datasource.id }}"> + {% 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 %} - <input type="text" class="form-control-plaintext" readonly - value="{{ criterion.metadata_field.name }}"> + {% endfor %} - <input type="hidden" class="fieldMetadataId" readonly - value="{{ criterion.metadata_field.id }}"> - </div> + {% endfor %} + + </tbody> + </table> + + <hr> + + <h2>All Metadata</h2> - <div class="col-md-6 mb-2"> - {% with metadata|access:criterion.metadata_field.short_name|default_if_none:'' as metadata_value %} - <input id="input-{{ criterion.metadata_field.short_name }}" - type="text" class="form-control fieldMetadataValue" - value="{{ metadata_value }}" - data-current-value="{{ metadata_value }}"> - {% endwith %} + <table class="table"> + <tbody> + {% 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-2 mb-2"> - <button type="submit" class="btn btn-block btn-success">Save</button> + <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> - </form> + </div> </td> </tr> - {% endfor %} + {% endwith %} {% endfor %} </tbody> </table> diff --git a/datasources/templatetags/datasource_extras.py b/datasources/templatetags/datasource_extras.py index 717e4d16d2ef80f3fe912879a594fc3ce32acb97..997794bf550e95734d2c7d87cf7a9a26fcb44d22 100644 --- a/datasources/templatetags/datasource_extras.py +++ b/datasources/templatetags/datasource_extras.py @@ -17,3 +17,11 @@ def access(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/urls.py b/datasources/urls.py index 0c800d6f7ec786c4dc6a49fb536ac1de11c8d44b..0974dc716b7efeaf8cbf9f67546f2b5d85db3925 100644 --- a/datasources/urls.py +++ b/datasources/urls.py @@ -26,12 +26,8 @@ urlpatterns = [ name='datasource.delete'), path('<int:pk>/metadata', - views.datasource.DataSourceMetadataAjaxView.as_view(), - name='datasource.metadata'), - - path('<int:pk>/metadata-dash', views.datasource.DataSourceMetadataView.as_view(), - name='datasource.metadata.dash'), + name='datasource.metadata'), path('<int:pk>/explorer', views.datasource.DataSourceExplorerView.as_view(), diff --git a/datasources/views/datasource.py b/datasources/views/datasource.py index 57b223034630ac8941669c476b6de3f067116373..0b2b58eb3b231ea7d9e06668044acb52145d4ac3 100644 --- a/datasources/views/datasource.py +++ b/datasources/views/datasource.py @@ -1,3 +1,5 @@ +from collections import namedtuple + from django.contrib import messages from django.contrib.auth import get_user_model from django.contrib.auth.mixins import PermissionRequiredMixin @@ -131,64 +133,7 @@ class DataSourceDataSetSearchView(HasPermissionLevelMixin, DetailView): return context -class DataSourceMetadataAjaxView(OwnerPermissionMixin, APIView): - model = models.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=self.request.data['field'], - value=self.request.data['value'] - ) - - metadata_item.delete() - - return Response({ - 'status': 'success' - }) +FieldValueSet = namedtuple('FieldValueSet', ['field', 'list']) class DataSourceMetadataView(OwnerPermissionMixin, DetailView): @@ -200,9 +145,17 @@ class DataSourceMetadataView(OwnerPermissionMixin, DetailView): context = super().get_context_data(**kwargs) context['ruleset'] = get_user_model().get_quality_ruleset() - context['metadata'] = { - item.field.short_name: item.value for item in self.object.metadata_items.all() - } + context['metadata_fields'] = models.MetadataField.objects.all() + + 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