diff --git a/.gitignore b/.gitignore index 72c9e73f1f170fe9163385cc9b354d8fe3f641f0..a3b5a62c365b2eaddbcdec14aad1e4caf1d2a157 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ .env /deploy/.env* /deploy/.deployment-key* +/Makefile +/inventory.yml # Database db.sqlite3 @@ -11,6 +13,7 @@ db.sqlite3 /venv *.pyc __pycache__/ +/report.html # Editor settings .idea/ @@ -20,6 +23,7 @@ __pycache__/ playbook.retry ubuntu-bionic-18.04-cloudimg-console.log /static/ +/whoosh_index/ # Documentation /docs/build/ diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000000000000000000000000000000000000..7edff46fb1a40f25b9a0b7ec2b8e5fc1646fcd89 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,3 @@ +[FORMAT] + +max-line-length=120 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..a3595a90584187fd0d5f77cb6b7099e5c8967935 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 University of Southampton + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index d3f6e8061053db38e2f01bcb02d1857868ffad3e..642e0fd4b7c13c340aae01a159e8b3d7b358cd26 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,14 @@ PEDASI can be run using the Django development server by `python manage.py runse ### In Production This repository contains an Ansible `playbook.yml` file which will perform a full install of PEDASI onto a -clean host, or update an existing PEDASI instance if it was previously deployed with the same script. - -To deploy using production settings you must create an Ansible inventory file and set `production=True` for -the machine you wish to deploy to. +clean host running Ubuntu 18.04 LTS, or update an existing PEDASI instance if it was previously deployed with the same script. + +To deploy using production settings you must: +* Create an Ansible inventory file and set `production=True` for the machine you wish to deploy to +* Create an SSH key and register it as a deployment key on the PEDASI GitHub project + * Move the SSH private key file to `deploy/.deployment-key` +* Create a configuration file (see below) `deploy/.env.prod` +* Run the Ansible deployment script `ansible-playbook -v -i inventory.yml playbook.yml -u <remote_username>` ## Configuring PEDASI diff --git a/prov/__init__.py b/api/__init__.py similarity index 100% rename from prov/__init__.py rename to api/__init__.py diff --git a/api/apps.py b/api/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..d87006dd60ff626eefdf7f8ffaa3998d7f99615a --- /dev/null +++ b/api/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + name = 'api' diff --git a/api/permissions.py b/api/permissions.py new file mode 100644 index 0000000000000000000000000000000000000000..e296bf823cb0b21b2f05572b3cc9d04939f9df55 --- /dev/null +++ b/api/permissions.py @@ -0,0 +1,78 @@ +""" +Permission check classes to be used with djangorestframework API. +""" + +from rest_framework import permissions + +from datasources import models + + +class BaseUserPermission(permissions.BasePermission): + """ + Base permission check. Permissions should override the `permission_level` property. + """ + message = 'You do not have permission to access this resource.' + permission_level = models.UserPermissionLevels.NONE + + def has_object_permission(self, request, view, obj): + return obj.has_permission_level(request.user, self.permission_level) + + +class ViewPermission(BaseUserPermission): + """ + Assert that a user has the :class:`models.UserPermissionLevels.VIEW` permission. + """ + message = 'You do not have permission to access this resource.' + permission_level = models.UserPermissionLevels.VIEW + + +class MetadataPermission(BaseUserPermission): + """ + Assert that a user has the :class:`models.UserPermissionLevels.META` permission. + """ + message = 'You do not have permission to access the metadata of this resource.' + permission_level = models.UserPermissionLevels.META + + +class DataPermission(BaseUserPermission): + """ + Assert that a user has the :class:`models.UserPermissionLevels.DATA` permission. + """ + message = 'You do not have permission to access the data of this resource.' + permission_level = models.UserPermissionLevels.DATA + + +class ProvPermission(BaseUserPermission): + """ + Assert that a user has the :class:`models.UserPermissionLevels.PROV` permission. + """ + message = 'You do not have permission to access the prov data of this resource.' + permission_level = models.UserPermissionLevels.PROV + + +class DataPushPermission(permissions.BasePermission): + """ + Permission mixin to prevent access to POST and PUT methods by users who do not have the correct permission flag. + """ + message = 'You do not have permission to push data to this resource.' + + def has_object_permission(self, request, view, obj): + # Bypass if not pushing data + if request.method not in {'POST', 'PUT'}: + return True + + # Owner always has permission + if request.user == obj.owner: + return True + + try: + permission = models.UserPermissionLink.objects.get( + user=request.user, + datasource=obj + ) + + return permission.push_granted + + except models.UserPermissionLink.DoesNotExist: + # Permission must have been granted explicitly + return False diff --git a/api/tests.py b/api/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..29c3688fb587fcbb4eb3cc85a690d1558f81de0b --- /dev/null +++ b/api/tests.py @@ -0,0 +1,585 @@ +import typing + +from django.contrib.auth import get_user_model +from django.test import Client, TestCase + +from rest_framework.authtoken.models import Token +from rest_framework.test import APIClient + +from datasources import models + + +class RootApiTest(TestCase): + @classmethod + def setUpTestData(cls): + cls.user = get_user_model().objects.create_user('Test API User', password='Test API Password') + cls.token, created = Token.objects.get_or_create(user=cls.user) + + def test_auth_rejected(self): + """ + Test that we get an authentication failure if not providing a token. + """ + client = APIClient() + + response = client.get('/api/') + + self.assertEqual(response.status_code, 401) # 401 Unauthorized + + def test_force_auth(self): + """ + Test simply that we can access the API using forced authentication. + + This 'authentication' method is used for the API tests. + """ + client = APIClient() + client.force_authenticate(self.user) + + response = client.get('/api/') + + self.assertEqual(response.status_code, 200) + + def test_session_auth(self): + """ + Test simply that we can access the API using session-based authentication. + + This authentication method is used to access the API explorer within the PEDASI UI. + """ + client = APIClient() + client.login(username='Test API User', password='Test API Password') + + response = client.get('/api/') + + self.assertEqual(response.status_code, 200) + + def test_token_auth(self): + """ + Test simply that we can access the API using token based authentication. + + This authentication method is used to access the API from an external application. + """ + client = APIClient() + client.credentials(HTTP_AUTHORIZATION='Token ' + self.token.key) + + response = client.get('/api/') + + self.assertEqual(response.status_code, 200) + + +class DataSourceApiTest(TestCase): + @classmethod + def setUpTestData(cls): + cls.user = get_user_model().objects.create_user('Test API User') + + def setUp(self): + self.client = APIClient() + self.client.force_authenticate(self.user) + + self.test_name = 'Test DataSource' + self.test_url = 'https://example.com/test' + + def tearDown(self): + try: + self.model.delete() + except AttributeError: + pass + + def test_root_api_datasource_exists(self): + """ + Test that a 'datasources' entry appears in the API root endpoint. + """ + response = self.client.get('/api/') + self.assertEqual(response.status_code, 200) + + self.assertIn('datasources', response.json().keys()) + + def _assert_datasource_correct(self, datasource: typing.Dict): + """ + Helper function: assert that a :class:`DataSource` received via the API is correct. + + :param datasource: :class:`DataSource` received via API + """ + self.assertIn('name', datasource) + self.assertEqual(self.test_name, datasource['name']) + + self.assertIn('description', datasource) + self.assertEqual('', datasource['description']) + + self.assertIn('url', datasource) + self.assertEqual(self.test_url, datasource['url']) + + def test_api_datasource_list(self): + """ + Test the :class:`DataSource` API list functionality. + """ + response = self.client.get('/api/datasources/') + self.assertEqual(response.status_code, 200) + + self.assertEqual(len(response.json()), 0) + + self.model = models.DataSource.objects.create( + name=self.test_name, + owner=self.user, + url=self.test_url + ) + + response = self.client.get('/api/datasources/') + + self.assertEqual(len(response.json()), 1) + + datasource = response.json()[0] + self._assert_datasource_correct(datasource) + + def test_api_datasource_get(self): + """ + Test the :class:`DataSource` API get one functionality. + """ + response = self.client.get('/api/datasources/1/') + self.assertEqual(response.status_code, 404) + + self.model = models.DataSource.objects.create( + name=self.test_name, + owner=self.user, + url=self.test_url + ) + + response = self.client.get('/api/datasources/{}/'.format(self.model.pk)) + self.assertEqual(response.status_code, 200) + + datasource = response.json() + self._assert_datasource_correct(datasource) + + +class DataSourceApiFilterTest(TestCase): + datasources = [] + + @classmethod + def setUpTestData(cls): + owner = get_user_model().objects.create_user('Test API Owner') + cls.client = APIClient() + cls.client.force_login(owner) + + cls.test_name = 'Filter' + test_url = 'https://api.iotuk.org.uk/iotOrganisation' + + metadata_field = models.MetadataField.objects.create( + name='Filter field', + short_name='filter_field' + ) + + cls.datasources = [ + models.DataSource.objects.create( + name=cls.test_name, + owner=owner, + url=test_url + ), + models.DataSource.objects.create( + name=cls.test_name + '-yes', + owner=owner, + url=test_url + ), + models.DataSource.objects.create( + name=cls.test_name + '-no', + owner=owner, + url=test_url + ) + ] + + cls.datasources[1].metadata_items.create( + field=metadata_field, + value='yes' + ) + + cls.datasources[2].metadata_items.create( + field=metadata_field, + value='no' + ) + + def test_no_filter(self): + response = self.client.get('/api/datasources/') + self.assertEqual(response.status_code, 200) + + contents = response.json() + self.assertEqual(len(contents), 3) + + def test_filter_yes(self): + response = self.client.get('/api/datasources/?filter_field=yes') + self.assertEqual(response.status_code, 200) + + contents = response.json() + self.assertEqual(len(contents), 1) + self.assertEqual(self.test_name + '-yes', contents[0]['name']) + + def test_filter_no(self): + response = self.client.get('/api/datasources/?filter_field=no') + self.assertEqual(response.status_code, 200) + + contents = response.json() + self.assertEqual(len(contents), 1) + self.assertEqual(self.test_name + '-no', contents[0]['name']) + + +class DataSourceApiPermissionsTest(TestCase): + @classmethod + def setUpTestData(cls): + cls.user = get_user_model().objects.create_user('Test API User') + cls.owner = get_user_model().objects.create_user('Test API Owner') + + def setUp(self): + self.client = APIClient() + self.client.force_authenticate(self.user) + + self.owner_client = Client() + self.owner_client.force_login(self.owner) + + self.test_name = 'Permissions' + # TODO don't rely on external URL for testing + self.test_url = 'https://api.iotuk.org.uk/iotOrganisation' + + def tearDown(self): + try: + self.model.delete() + except AttributeError: + pass + + def _grant_permission(self, level: models.UserPermissionLevels): + response = self.owner_client.post('/datasources/{}/access/grant'.format(self.model.pk), + data={ + 'user': self.user.pk, + 'granted': level.value, + }, + headers={ + 'Accept': 'application/json' + }) + # TODO make this return a proper response code for AJAX-like requests + self.assertEqual(response.status_code, 302) + self.assertNotIn('login', response.url) + + def test_datasource_permission_view(self): + """ + Test that permissions are correctly handled when attempting to view a data source. + """ + self.model = models.DataSource.objects.create( + name=self.test_name, + owner=self.owner, + url=self.test_url, + plugin_name='DataSetConnector', + public_permission_level=models.UserPermissionLevels.NONE + ) + + url = '/api/datasources/{}/'.format(self.model.pk) + + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + self._grant_permission(models.UserPermissionLevels.VIEW) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + self._grant_permission(models.UserPermissionLevels.META) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + self._grant_permission(models.UserPermissionLevels.DATA) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + self._grant_permission(models.UserPermissionLevels.PROV) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_datasource_permission_meta(self): + """ + Test that permissions are correctly handled when attempting to get metadata from a data source. + """ + self.model = models.DataSource.objects.create( + name=self.test_name, + owner=self.owner, + url=self.test_url, + plugin_name='DataSetConnector', + public_permission_level=models.UserPermissionLevels.NONE + ) + + url = '/api/datasources/{}/metadata/'.format(self.model.pk) + + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + self._grant_permission(models.UserPermissionLevels.VIEW) + + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + self._grant_permission(models.UserPermissionLevels.META) + + response = self.client.get(url) + # This data connector does not provide metadata + self.assertEqual(response.status_code, 400) + + self._grant_permission(models.UserPermissionLevels.DATA) + + response = self.client.get(url) + self.assertEqual(response.status_code, 400) + + self._grant_permission(models.UserPermissionLevels.PROV) + + response = self.client.get(url) + self.assertEqual(response.status_code, 400) + + def test_datasource_permission_data(self): + """ + Test that permissions are correctly handled when attempting to get data from a data source. + """ + self.model = models.DataSource.objects.create( + name=self.test_name, + owner=self.owner, + url=self.test_url, + plugin_name='DataSetConnector', + public_permission_level=models.UserPermissionLevels.NONE + ) + + url = '/api/datasources/{}/data/?year=2018'.format(self.model.pk) + + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + self._grant_permission(models.UserPermissionLevels.VIEW) + + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + self._grant_permission(models.UserPermissionLevels.META) + + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + self._grant_permission(models.UserPermissionLevels.DATA) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + self._grant_permission(models.UserPermissionLevels.PROV) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_datasource_permission_prov(self): + """ + Test that permissions are correctly handled when attempting to get PROV data from a data source. + """ + self.model = models.DataSource.objects.create( + name=self.test_name, + owner=self.owner, + url=self.test_url, + plugin_name='DataSetConnector', + public_permission_level=models.UserPermissionLevels.NONE + ) + + url = '/api/datasources/{}/prov/'.format(self.model.pk) + + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + self._grant_permission(models.UserPermissionLevels.VIEW) + + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + self._grant_permission(models.UserPermissionLevels.META) + + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + self._grant_permission(models.UserPermissionLevels.DATA) + + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + self._grant_permission(models.UserPermissionLevels.PROV) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + +class DataSourceApiIoTUKTest(TestCase): + @classmethod + def setUpTestData(cls): + cls.user = get_user_model().objects.create_user('Test API User') + + def setUp(self): + self.client = APIClient() + self.client.force_authenticate(self.user) + + self.test_name = 'IoTUK' + self.test_url = 'https://api.iotuk.org.uk/iotOrganisation' + + def tearDown(self): + try: + self.model.delete() + except AttributeError: + pass + + def test_api_datasource_get(self): + """ + Test the :class:`DataSource` API get one functionality. + """ + response = self.client.get('/api/datasources/1/') + self.assertEqual(response.status_code, 404) + + self.model = models.DataSource.objects.create( + name=self.test_name, + owner=self.user, + url=self.test_url, + plugin_name='DataSetConnector' + ) + + response = self.client.get('/api/datasources/{}/'.format(self.model.pk)) + self.assertEqual(response.status_code, 200) + + datasource = response.json() + + self.assertIn('name', datasource) + self.assertEqual(self.test_name, datasource['name']) + + self.assertIn('description', datasource) + self.assertEqual('', datasource['description']) + + self.assertIn('url', datasource) + self.assertEqual(self.test_url, datasource['url']) + + def test_api_datasource_get_data(self): + """ + Test the :class:`DataSource` API functionality to retrieve data. + """ + response = self.client.get('/api/datasources/1/data/') + self.assertEqual(response.status_code, 404) + + self.model = models.DataSource.objects.create( + name=self.test_name, + owner=self.user, + url=self.test_url, + plugin_name='DataSetConnector' + ) + + response = self.client.get('/api/datasources/{}/data/'.format(self.model.pk)) + # Query should fail since IoTUK requires query filters + self.assertEqual(response.status_code, 400) + + response = self.client.get('/api/datasources/{}/data/?year=2017'.format(self.model.pk)) + self.assertEqual(response.status_code, 200) + + data = response.json() + + self.assertIn('results', data) + self.assertLessEqual(1, data['results']) + self.assertIn('data', data) + self.assertLessEqual(1, len(data['data'])) + + +class DataSourceApiHyperCatTest(TestCase): + test_name = 'HyperCat' + plugin_name = 'HyperCat' + test_url = 'https://api.cityverve.org.uk/v1/cat/polling-station' + dataset = 'https://api.cityverve.org.uk/v1/entity/polling-station/5' + + @classmethod + def setUpTestData(cls): + from decouple import config + + cls.user = get_user_model().objects.create_user('Test API User') + + cls.api_key = config('HYPERCAT_CISCO_API_KEY') + + cls.model = models.DataSource.objects.create( + name=cls.test_name, + owner=cls.user, + url=cls.test_url, + api_key=cls.api_key, + plugin_name=cls.plugin_name, + auth_method=models.DataSource.determine_auth_method(cls.test_url, cls.api_key) + ) + + def setUp(self): + self.client = APIClient() + self.client.force_authenticate(self.user) + + def test_api_datasource_get(self): + """ + Test the :class:`DataSource` API get one functionality. + """ + response = self.client.get('/api/datasources/{}/'.format(self.model.pk)) + self.assertEqual(response.status_code, 200) + + datasource = response.json() + + self.assertIn('name', datasource) + self.assertEqual(self.test_name, datasource['name']) + + self.assertIn('description', datasource) + self.assertEqual('', datasource['description']) + + self.assertIn('url', datasource) + self.assertEqual(self.test_url, datasource['url']) + + def test_api_datasource_get_metadata(self): + """ + Test the :class:`DataSource` API functionality to retrieve metadata. + """ + response = self.client.get('/api/datasources/{}/metadata/'.format(self.model.pk)) + self.assertEqual(response.status_code, 200) + + data = response.json() + + self.assertIn('status', data) + self.assertEqual('success', data['status']) + self.assertIn('data', data) + self.assertLessEqual(1, len(data['data'])) + # TODO test contents of 'data' list + + def test_api_datasource_get_datasets(self): + """ + Test the :class:`DataSource` API functionality to retrieve the list of datasets. + """ + response = self.client.get('/api/datasources/{}/datasets/'.format(self.model.pk)) + self.assertEqual(response.status_code, 200) + + data = response.json() + + self.assertIn('status', data) + self.assertEqual('success', data['status']) + self.assertIn('data', data) + self.assertLessEqual(1, len(data['data'])) + # TODO test contents of 'data' list + + def test_api_datasource_get_dataset_metadata(self): + """ + Test the :class:`DataSource` API functionality to retrieve dataset metadata. + """ + response = self.client.get('/api/datasources/{}/datasets/{}/metadata/'.format(self.model.pk, self.dataset)) + self.assertEqual(response.status_code, 200) + + data = response.json() + + self.assertIn('status', data) + self.assertEqual('success', data['status']) + self.assertIn('data', data) + self.assertLessEqual(1, len(data['data'])) + # TODO test contents of 'data' list + + def test_api_datasource_get_dataset_data(self): + """ + Test the :class:`DataSource` API functionality to retrieve dataset data. + """ + response = self.client.get('/api/datasources/{}/datasets/{}/data/'.format(self.model.pk, self.dataset)) + self.assertEqual(response.status_code, 200) + + data = response.json() + self.assertTrue(data) + # TODO test content + + +if __name__ == '__main__': + TestCase.run() diff --git a/api/urls.py b/api/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..9f39481a759622de22ae9645d632b145cae4ba29 --- /dev/null +++ b/api/urls.py @@ -0,0 +1,16 @@ +from django.urls import include, path + +from rest_framework import routers + +from .views import datasources as datasource_views + +app_name = 'api' + +# Register ViewSets +router = routers.DefaultRouter() +router.register('datasources', datasource_views.DataSourceApiViewset) + +urlpatterns = [ + path('', + include(router.urls)), +] diff --git a/prov/migrations/__init__.py b/api/views/__init__.py similarity index 100% rename from prov/migrations/__init__.py rename to api/views/__init__.py diff --git a/api/views/datasources.py b/api/views/datasources.py new file mode 100644 index 0000000000000000000000000000000000000000..0061d50c7344ccdc9d266e68bb47023ebc58bf7f --- /dev/null +++ b/api/views/datasources.py @@ -0,0 +1,318 @@ +""" +This module contains the API endpoint viewset defining the PEDASI Application API. +""" + +import csv +import json +import typing + +from django.db.models import ObjectDoesNotExist +from django.http import HttpResponse, JsonResponse +from rest_framework import decorators, request, response, viewsets + +from .. import permissions +from datasources import models, serializers +from provenance import models as prov_models + + +class DataSourceApiViewset(viewsets.ReadOnlyModelViewSet): + """ + Provides views for: + + /api/datasources/ + List all :class:`DataSource`s + + /api/datasources/<int>/ + Retrieve a single :class:`DataSource` + + /api/datasources/<int>/prov/ + Retrieve PROV records related to a :class:`DataSource` + + /api/datasources/<int>/metadata/ + Retrieve :class:`DataSource` metadata via API call to data source URL + + /api/datasources/<int>/data/ + Retrieve :class:`DataSource` data via API call to data source URL + + /api/datasources/<int>/datasets/ + Retrieve :class:`DataSource` list of data sets via API call to data source URL + + /api/datasources/<int>/datasets/<href>/metadata/ + Retrieve :class:`DataSource` metadata for a single dataset via API call to data source URL + + /api/datasources/<int>/datasets/<href>/metadata/ + Retrieve :class:`DataSource` data for a single dataset via API call to data source URL + """ + queryset = models.DataSource.objects.all() + serializer_class = serializers.DataSourceSerializer + permission_classes = [permissions.ViewPermission] + + def _create_prov_entry(self, instance: models.DataSource) -> None: + """ + Create a PROV entry linking the data source and the authenticated user. + """ + # TODO should PROV distinguish between data and metadata accesses? + # TODO should we create PROV records for requests that failed + + try: + # Is the user actually a proxy for an application? + application = self.request.user.application_proxy + + prov_models.ProvWrapper.create_prov( + instance, + self.request.user.get_uri(), + application=application, + activity_type=prov_models.ProvActivity.ACCESS + ) + + except ObjectDoesNotExist: + # Normal (non-proxy) user + prov_models.ProvWrapper.create_prov( + instance, + self.request.user.get_uri(), + activity_type=prov_models.ProvActivity.ACCESS + ) + + except AttributeError: + # No logged in user - but has passed permission checks - data source must be public + pass + + def _filter_by_metadata(self, queryset): + """ + Query filter to filter data sources by variable metadata. + + Query parameters are key value pairs of the metadata field short name and the metadata value. + + :return: Filtered queryset + """ + for key, value in self.request.query_params.items(): + # The key 'search' is used to activate filters.SearchFilter - don't interfere with it + if key == 'search': + continue + + queryset = queryset.filter(metadata_items__field__short_name=key, + metadata_items__value=value) + + return queryset + + def try_passthrough_response(self, + map_response: typing.Callable[..., HttpResponse], + error_message: str, + dataset: str = None) -> HttpResponse: + """ + Attempt to pass a response from the data connector using the function `map_response`. + + If the data connectors raises an error (AttributeError or NotImplementedError) then return an error response. + + :param map_response: Function to get response from data connector - must return HttpResponse + :param error_message: Error message in case data connector raises an error + :param dataset: Dataset to access within data source + :return: HttpResponse from data connector or error response + """ + instance = self.get_object() + + with instance.data_connector as data_connector: + # Are there any query params to pass on? + params = self.request.query_params + if not params: + params = None + + if dataset is not None: + data_connector = data_connector[dataset] + + # Record this action in PROV + if not instance.prov_exempt: + self._create_prov_entry(instance) + + try: + return map_response(data_connector, params) + + except (AttributeError, NotImplementedError): + data = { + 'status': 'error', + 'message': error_message, + } + return response.Response(data, status=400) + + def list(self, request, *args, **kwargs): + """ + List the queryset after filtering by request query parameters for data source metadata. + """ + queryset = self._filter_by_metadata(self.get_queryset()) + + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(queryset, many=True) + return response.Response(serializer.data) + + @decorators.action(detail=True, permission_classes=[permissions.ProvPermission]) + def prov(self, request, pk=None): + """ + View for /api/datasources/<int>/prov/ + + Retrieve PROV records related to a :class:`DataSource`. + """ + instance = self.get_object() + + data = { + # Get all ProvEntry's related to this instance and encode them as JSON + 'prov': [json.loads(record.to_json()) for record in prov_models.ProvWrapper.filter_model_instance(instance)] + } + + # Record this action in PROV + if not instance.prov_exempt: + self._create_prov_entry(instance) + + return response.Response(data, status=200) + + @decorators.action(detail=True, permission_classes=[permissions.MetadataPermission]) + def metadata(self, request, pk=None): + """ + View for /api/datasources/<int>/metadata/ + + Retrieve :class:`DataSource` metadata via API call to data source URL. + """ + def map_response(data_connector, params): + data = { + 'status': 'success', + 'data': data_connector.get_metadata(params=params) + } + return response.Response(data, status=200) + + return self.try_passthrough_response(map_response, + 'Data source does not provide metadata') + + @decorators.action(detail=True, methods=['GET'], + permission_classes=[permissions.DataPermission, permissions.DataPushPermission]) + def data(self, request, pk=None): + """ + View for /api/datasources/<int>/data/ + + Retrieve :class:`DataSource` data via API call to data source URL. + """ + def map_response(data_connector, params): + r = data_connector.get_response(params=params) + try: + return HttpResponse(r.text, status=r.status_code, + content_type=r.headers.get('content-type')) + + except AttributeError: + # Should be a Django response already + return r + + return self.try_passthrough_response(map_response, + 'Data source does not provide data') + + @data.mapping.post + def post_data(self, request: request.Request, pk=None): + """ + Add data to this data source. Only applicable to internal data sources. + + Data can be added either as JSON body text or as a POSTed CSV file. + """ + instance = self.get_object() + + try: + with instance.data_connector as data_connector: + if request.FILES: + for filename, f in request.FILES.items(): + # TODO read in chunks + # TODO don't assume utf-8 + data = f.read().decode('utf-8').splitlines() + reader = csv.DictReader(data) + + data_connector.post_data(reader) + + else: + data_connector.post_data(request.data) + + # Rebuild index + index_fields = instance.metadata_items.filter(field__short_name='indexed_field').values_list('value') + if index_fields: + data_connector.clean_data(index_fields=index_fields) + + # Record this action in PROV + if not instance.prov_exempt: + self._create_prov_entry(instance) + + except AttributeError: + # Connector has no 'post_data' method + return JsonResponse({ + 'status': 'error', + 'message': 'Data source does not support writing of data' + }, status=405) + + return JsonResponse({ + 'status': 'success', + 'data': None, + }) + + @data.mapping.put + def put_data(self, request: request.Request, pk=None): + instance = self.get_object() + + with instance.data_connector as data_connector: + # Remove all existing data + data_connector.clear_data() + + return self.post_data(request, pk) + + @decorators.action(detail=True, permission_classes=[permissions.MetadataPermission]) + def datasets(self, request, pk=None): + """ + View for /api/datasources/<int>/datasets/ + + Retrieve :class:`DataSource` list of data sets via API call to data source URL. + """ + def map_response(data_connector, params): + data = { + 'status': 'success', + 'data': data_connector.get_datasets(params=params) + } + return response.Response(data, status=200) + + return self.try_passthrough_response(map_response, + 'Data source does not contain datasets') + + # TODO URL pattern here uses pre Django 2 format + @decorators.action(detail=True, + url_path='datasets/(?P<href>.*)/metadata', + permission_classes=[permissions.MetadataPermission]) + def dataset_metadata(self, request, pk=None, **kwargs): + """ + View for /api/datasources/<int>/datasets/<href>/metadata/ + + Retrieve :class:`DataSource` metadata for a single dataset via API call to data source URL. + """ + def map_response(data_connector, params): + data = { + 'status': 'success', + 'data': data_connector.get_metadata(params=params) + } + return response.Response(data, status=200) + + return self.try_passthrough_response(map_response, + 'Data source does not provide metadata', + dataset=self.kwargs['href']) + + # TODO URL pattern here uses pre Django 2 format + @decorators.action(detail=True, + url_path='datasets/(?P<href>.*)/data', + permission_classes=[permissions.DataPermission]) + def dataset_data(self, request, pk=None, **kwargs): + """ + View for /api/datasources/<int>/datasets/<href>/data/ + + Retrieve :class:`DataSource` data for a single dataset via API call to data source URL. + """ + def map_response(data_connector, params): + r = data_connector.get_response(params=params) + return HttpResponse(r.text, status=r.status_code, + content_type=r.headers.get('content-type')) + + return self.try_passthrough_response(map_response, + 'Data source does not provide data', + dataset=self.kwargs['href']) diff --git a/applications/migrations/0004_application_access_control.py b/applications/migrations/0004_application_access_control.py new file mode 100644 index 0000000000000000000000000000000000000000..9dd0e1622053a5a7a32e38eb59279d89a7deffec --- /dev/null +++ b/applications/migrations/0004_application_access_control.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.8 on 2018-09-25 12:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('applications', '0003_remove_owner_from_forms'), + ] + + operations = [ + migrations.AddField( + model_name='application', + name='access_control', + field=models.BooleanField(default=False), + ), + ] diff --git a/applications/migrations/0005_one_to_one_group.py b/applications/migrations/0005_one_to_one_group.py new file mode 100644 index 0000000000000000000000000000000000000000..5ef4ebfbadd2bd92122a98e9cf05c2fedd063a0d --- /dev/null +++ b/applications/migrations/0005_one_to_one_group.py @@ -0,0 +1,25 @@ +# Generated by Django 2.0.8 on 2018-09-25 12:35 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('auth', '0009_alter_user_last_name_max_length'), + ('applications', '0004_application_access_control'), + ] + + operations = [ + migrations.AddField( + model_name='application', + name='users_group', + field=models.OneToOneField(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='auth.Group'), + ), + migrations.AddField( + model_name='application', + name='users_group_requested', + field=models.OneToOneField(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='auth.Group'), + ), + ] diff --git a/applications/migrations/0006_unset_url_required.py b/applications/migrations/0006_unset_url_required.py new file mode 100644 index 0000000000000000000000000000000000000000..d2adeb094ba578e2735ddfbea34bc01822b40621 --- /dev/null +++ b/applications/migrations/0006_unset_url_required.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.8 on 2018-10-29 13:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('applications', '0005_one_to_one_group'), + ] + + operations = [ + migrations.AlterField( + model_name='application', + name='url', + field=models.URLField(blank=True), + ), + ] diff --git a/applications/migrations/0007_application_proxy_user.py b/applications/migrations/0007_application_proxy_user.py new file mode 100644 index 0000000000000000000000000000000000000000..8d39c09ad7f02857fa8d8a8e1930e57dcbb035ab --- /dev/null +++ b/applications/migrations/0007_application_proxy_user.py @@ -0,0 +1,21 @@ +# Generated by Django 2.0.8 on 2018-11-01 11:43 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('applications', '0006_unset_url_required'), + ] + + operations = [ + migrations.AddField( + model_name='application', + name='proxy_user', + field=models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='application_proxy', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/applications/migrations/0008_proxy_user_onetoone.py b/applications/migrations/0008_proxy_user_onetoone.py new file mode 100644 index 0000000000000000000000000000000000000000..c76f649cd47c4df4299a7c13c7f84812b2e8bb78 --- /dev/null +++ b/applications/migrations/0008_proxy_user_onetoone.py @@ -0,0 +1,20 @@ +# Generated by Django 2.0.8 on 2018-11-01 15:37 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('applications', '0007_application_proxy_user'), + ] + + operations = [ + migrations.AlterField( + model_name='application', + name='proxy_user', + field=models.OneToOneField(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='application_proxy', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/applications/migrations/0009_application_is_deleted.py b/applications/migrations/0009_application_is_deleted.py new file mode 100644 index 0000000000000000000000000000000000000000..64d2a82b91fc306543af8a01dc8b2fdd2343ac3b --- /dev/null +++ b/applications/migrations/0009_application_is_deleted.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.8 on 2019-01-11 14:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('applications', '0008_proxy_user_onetoone'), + ] + + operations = [ + migrations.AddField( + model_name='application', + name='is_deleted', + field=models.BooleanField(default=False, editable=False), + ), + ] diff --git a/applications/models.py b/applications/models.py index a9cd7e8826ecad0bd5386d15eae2fd051d46a664..8d1b36f1b6164689154979751cb49e50ab9e9a64 100644 --- a/applications/models.py +++ b/applications/models.py @@ -1,8 +1,13 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from django.conf import settings from django.db import models from django.urls import reverse +from django.utils.text import slugify -from pedasi.common.base_models import BaseAppDataModel +from rest_framework.authtoken.models import Token + +from core.models import BaseAppDataModel, SoftDeletionManager class Application(BaseAppDataModel): @@ -14,6 +19,11 @@ class Application(BaseAppDataModel): * A data / metadata visualisation tool * A data / metadata analysis pipeline """ + objects = SoftDeletionManager() + + #: Address at which the API may be accessed + url = models.URLField(blank=True, null=False) + #: User who has responsibility for this application owner = models.ForeignKey(settings.AUTH_USER_MODEL, limit_choices_to={ @@ -24,6 +34,106 @@ class Application(BaseAppDataModel): editable=False, blank=False, null=False) + #: Proxy user which this application will act as + proxy_user = models.OneToOneField(settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name='application_proxy', + editable=False, + blank=True, null=True) + + #: Group of users who have read / use access to this data source / application + users_group = models.OneToOneField(Group, + on_delete=models.SET_NULL, + related_name='+', + editable=False, + blank=True, null=True) + + #: Groups of users who have requested access to this data source / application + users_group_requested = models.OneToOneField(Group, + on_delete=models.SET_NULL, + related_name='+', + editable=False, + blank=True, null=True) + + #: Do users require explicit permission to use this application? + access_control = models.BooleanField(default=False, + blank=False, null=False) + + #: Has this object been soft deleted? + is_deleted = models.BooleanField(default=False, + editable=False, blank=False, null=False) + + def delete(self, using=None, keep_parents=False): + """ + Soft delete this object. + """ + self.is_deleted = True + self.save() + + @property + def _access_group_name(self): + return str(type(self)) + ' ' + self.name + ' Users' + + def save(self, **kwargs): + # Create access control groups if they do not exist + # Make sure their names match self.name if they do exist + if self.access_control: + if self.users_group: + # Update existing group name + self.users_group.name = self._access_group_name + self.users_group.save() + + else: + self.users_group, created = Group.objects.get_or_create( + name=self._access_group_name + ) + + if self.users_group_requested: + # Update existing group name + self.users_group_requested.name = self._access_group_name + ' Requested' + self.users_group_requested.save() + + else: + self.users_group_requested, created = Group.objects.get_or_create( + name=self._access_group_name + ' Requested' + ) + + if not self.proxy_user: + self.proxy_user = self._get_proxy_user() + + super().save(**kwargs) + + def has_view_permission(self, user: settings.AUTH_USER_MODEL) -> bool: + """ + Does a user have permission to use this application? + + :param user: User to check + :return: User has permission? + """ + if not self.access_control: + return True + if self.owner == user: + return True + + return self.users_group.user_set.filter(pk=user.pk).exists() + + def _get_proxy_user(self) -> settings.AUTH_USER_MODEL: + """ + Create a new proxy user for this application or return the existing one. + + :return: Instance of user model + """ + if self.proxy_user: + return self.proxy_user + + proxy_username = 'application-proxy-' + slugify(self.name) + proxy_user = get_user_model().objects.create_user(proxy_username) + + # Create an API access token for the proxy user + Token.objects.create(user=proxy_user) + + return proxy_user + def get_absolute_url(self): return reverse('applications:application.detail', kwargs={'pk': self.pk}) diff --git a/applications/search_indexes.py b/applications/search_indexes.py new file mode 100644 index 0000000000000000000000000000000000000000..8885278fdd0dced7f8fd13276106231619dd40d5 --- /dev/null +++ b/applications/search_indexes.py @@ -0,0 +1,10 @@ +from haystack import indexes + +from . import models + + +class ApplicationIndex(indexes.SearchIndex, indexes.Indexable): + text = indexes.CharField(document=True, use_template=True) + + def get_model(self): + return models.Application diff --git a/applications/templates/applications/application/create.html b/applications/templates/applications/application/create.html new file mode 100644 index 0000000000000000000000000000000000000000..941514fd37ada74e79d676afe75cd44211ee5501 --- /dev/null +++ b/applications/templates/applications/application/create.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% load bootstrap4 %} + +{% block content %} + <nav aria-label="breadcrumb"> + <ol class="breadcrumb"> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'index' %}">Home</a> + </li> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'applications:application.list' %}">Applications</a> + </li> + <li class="breadcrumb-item active" aria-current="page"> + New Application + </li> + </ol> + </nav> + + <h2 class="pb-3">New Application</h2> + + <form class="form" method="post" action=""> + {% csrf_token %} + + {% bootstrap_form form %} + + <input type="submit" class="btn btn-success" value="Create"> + </form> + +{% endblock %} diff --git a/applications/templates/applications/application/delete.html b/applications/templates/applications/application/delete.html new file mode 100644 index 0000000000000000000000000000000000000000..83d00cde27c4efb78184af047985b97a89a61ac3 --- /dev/null +++ b/applications/templates/applications/application/delete.html @@ -0,0 +1,70 @@ +{% extends "base.html" %} +{% load bootstrap4 %} + +{% block content %} + <nav aria-label="breadcrumb"> + <ol class="breadcrumb"> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'index' %}">Home</a> + </li> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'applications:application.list' %}">Applications</a> + </li> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'applications:application.detail' pk=application.pk %}">{{ application.name }}</a> + </li> + <li class="breadcrumb-item active" aria-current="page"> + Delete + </li> + </ol> + </nav> + + <div class="row"> + <div class="col-md-10 col-sm-8"> + <h2>{{ application.name }}</h2> + + {% if application.description %} + {{ application.description|linebreaks }} + {% endif %} + </div> + </div> + + <table class="table"> + <thead> + <th scope="col" class="w-25 border-0"></th> + <th scope="col" class="border-0"></th> + </thead> + + <tbody> + <tr> + <td>Owner</td> + <td> + {{ application.owner }} + </td> + </tr> + <tr> + <td>URL</td> + <td>{{ application.url }}</td> + </tr> + {% if api_key %} + <tr> + <td>API Key</td> + <td>{{ api_key }}</td> + </tr> + {% endif %} + </tbody> + </table> + + <div class="alert alert-danger"> + <p><b>Are you sure you want to delete this application?</b></p> + + <form class="form" method="post"> + {% csrf_token %} + + <input type="submit" role="button" class="btn btn-danger" value="Delete"> + + <a role="button" class="btn btn-info" + href="{% url 'applications:application.detail' pk=application.pk %}">Cancel</a> + </form> + </div> +{% endblock %} \ No newline at end of file diff --git a/applications/templates/applications/application/detail-no-access.html b/applications/templates/applications/application/detail-no-access.html new file mode 100644 index 0000000000000000000000000000000000000000..5b4e257c4f908865c02640cf170fd040ca9fd6bc --- /dev/null +++ b/applications/templates/applications/application/detail-no-access.html @@ -0,0 +1,98 @@ +{% extends "base.html" %} +{% load bootstrap4 %} + +{% block extra_head %} + <script src="https://cdn.jsdelivr.net/npm/js-cookie@2.2.0/src/js.cookie.min.js"></script> +{% endblock %} + +{% block content %} + <nav aria-label="breadcrumb"> + <ol class="breadcrumb"> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'index' %}">Home</a> + </li> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'applications:application.list' %}">Applications</a> + </li> + <li class="breadcrumb-item active" aria-current="page"> + {{ application.name }} + </li> + </ol> + </nav> + + <div class="row"> + <div class="col-md-10 col-sm-8"> + <h2>{{ application.name }}</h2> + + {% if application.description %} + {{ application.description|linebreaks }} + {% endif %} + </div> + + <div class="col-md-2 col-sm-4"> + {% if application.users_group_requested in request.user.groups.all %} + <button id="btn-request-access" disabled + class="btn btn-block btn-primary" role="button">Access Requested</button> + + {% elif not request.user.is_authenticated %} + You must be logged in to request access. + + {% else %} + <button id="btn-request-access" onclick="requestAccess()" + class="btn btn-block btn-primary" role="button">Request Access</button> + + <script type="application/javascript"> + function requestAccess(){ + $.ajax({ + url: '{% url 'applications:application.manage-access.user' pk=application.pk user_pk=request.user.pk %}', + headers: { + 'X-CSRFToken': Cookies.get('csrftoken') + }, + method: 'PUT', + success: function(result, status, xhr){ + const btn = document.getElementById('btn-request-access'); + btn.textContent = 'Access Requested'; + btn.disabled = true; + } + }) + } + </script> + {% endif %} + </div> + </div> + + <div class="alert alert-warning"> + You do not have permission to access this resource. + + {% if not request.user.is_authenticated %} + You must be logged in to request access. + + {% elif request.user in application.users_group_requested.user_set.all %} + Please wait for your request to be approved. + + {% else %} + You may request access using the 'Request Access' button. + {% endif %} + </div> + + <table class="table"> + <thead> + <th scope="col" class="w-25 border-0"></th> + <th scope="col" class="border-0"></th> + </thead> + + <tbody> + <tr> + <td>Owner</td> + <td> + {{ application.owner }} + </td> + </tr> + <tr> + <td>URL</td> + <td>{{ application.url }}</td> + </tr> + </tbody> + </table> + +{% endblock %} diff --git a/applications/templates/applications/application/detail.html b/applications/templates/applications/application/detail.html index 2d1ee3af757fa25ca40ce8ef9af6c58d60fb0bf4..0fbb2f8e611251755affcdc606791b825d690244 100644 --- a/applications/templates/applications/application/detail.html +++ b/applications/templates/applications/application/detail.html @@ -16,19 +16,68 @@ </ol> </nav> - <h2>View Application - {{ application.name }}</h2> + <div class="row"> + <div class="col-md-10 col-sm-8"> + <h2>{{ application.name }}</h2> - <p> - Owner: <a href="#" role="link">{{ application.owner }}</a> - </p> + {% if application.description %} + {{ application.description|linebreaks }} + {% endif %} + </div> - {% if application.description %} - <p>{{ application.description }}</p> - {% endif %} + <div class="col-md-2 col-sm-4"> + {% if application.access_control %} + <a href="{% url 'applications:application.manage-access' pk=application.pk %}" + class="btn btn-block btn-primary" role="button">Manage Access</a> + {% endif %} - <a href="{% url 'admin:applications_application_change' application.id %}" - class="btn btn-success" role="button">Edit</a> - <a href="{% url 'admin:applications_application_delete' application.id %}" - class="btn btn-danger" role="button">Delete</a> + {% if has_edit_permission %} + <a href="{% url 'applications:application.edit' pk=application.pk %}" + class="btn btn-block btn-success" role="button">Edit</a> + + <a href="{% url 'applications:application.delete' pk=application.pk %}" + class="btn btn-block btn-danger" role="button">Delete</a> + {% endif %} + </div> + </div> + + <table class="table"> + <thead> + <th scope="col" class="w-25 border-0"></th> + <th scope="col" class="border-0"></th> + </thead> + + <tbody> + <tr> + <td>Owner</td> + <td> + {{ application.owner }} + </td> + </tr> + <tr> + <td>URL</td> + <td>{{ application.url }}</td> + </tr> + {% if api_key %} + <tr> + <td>API Key</td> + <td>{{ api_key }}</td> + </tr> + {% endif %} + </tbody> + </table> + + <div class="row justify-content-center pt-5"> + <div class="col-4"> + <script type="application/javascript"> + function launchApp() { + const win = window.open("{{ application.url }}", "_blank"); + win.focus(); + } + </script> + + <button role="button" onclick="launchApp();" class="btn btn-info btn-lg btn-block">Launch App</button> + </div> + </div> {% endblock %} \ No newline at end of file diff --git a/applications/templates/applications/application/list.html b/applications/templates/applications/application/list.html index 5b7c3c6d37ccb1d2a526c1c2d6f661c455621205..afa921813e5bb05e75f2d22cbe8607bdca7d54eb 100644 --- a/applications/templates/applications/application/list.html +++ b/applications/templates/applications/application/list.html @@ -15,34 +15,55 @@ <h2>Applications</h2> - <a href="{% url 'admin:applications_application_add' %}" - class="btn btn-success" role="button">Create Application</a> + {% if perms.applications.add_application %} + <div class="row"> + <div class="col-md-6 mx-auto"> + <a href="{% url 'applications:application.add' %}" + class="btn btn-block btn-success" role="button">New Application</a> + </div> + </div> + {% endif %} <div class="mt-3"></div> - <table class="table"> + <table class="table table-hover"> <thead class="thead"> <tr> - <th>Name</th> - <th> - <i class="fa fa-user" aria-hidden="true"></i> + <th class="w-75">Name</th> + <th class="w-auto"> + <i class="fas fa-user" aria-hidden="true"></i> </th> - <th></th> + <th class="w-auto">Access</th> + <th class="w-auto"></th> </tr> </thead> <tbody> {% for application in applications %} <tr> - <td>{{ application.name }}</td> <td> + <p> + <b>{{ application.name }}</b> + </p> + <p class="pl-5"> + {{ application.description|truncatechars:120 }} + </p> + </td> + <td class="align-middle"> {% if application.owner == request.user %} - <i class="fa fa-user" aria-hidden="true"></i> + <i class="fas fa-user" aria-hidden="true" + data-toggle="tooltip" data-placement="top" title="My application"></i> {% endif %} </td> - <td> + <td class="align-middle"> + {% if application.access_control %} + <i class="fas fa-lock fa-lg" + data-toggle="tooltip" data-placement="top" title="Application has access controls"></i> + {% endif %} + </td> + <td class="align-middle"> <a href="{% url 'applications:application.detail' pk=application.pk %}" - class="btn btn-primary" role="button">Detail</a> + class="btn btn-block btn-secondary" role="button">Detail</a> </td> </tr> {% empty %} @@ -52,4 +73,12 @@ {% endfor %} </tbody> </table> -{% endblock %} \ No newline at end of file +{% endblock %} + +{% block extra_body %} + <script type="application/javascript"> + $(function () { + $("[data-toggle='tooltip']").tooltip() + }) + </script> +{% endblock %} diff --git a/applications/templates/applications/application/manage_access.html b/applications/templates/applications/application/manage_access.html new file mode 100644 index 0000000000000000000000000000000000000000..c5d393664632d646fe8f72da7da3395047d7888a --- /dev/null +++ b/applications/templates/applications/application/manage_access.html @@ -0,0 +1,145 @@ +{% extends "base.html" %} +{% load bootstrap4 %} + +{% block extra_head %} + <script src="https://cdn.jsdelivr.net/npm/js-cookie@2.2.0/src/js.cookie.min.js"></script> +{% endblock %} + +{% block content %} + <nav aria-label="breadcrumb"> + <ol class="breadcrumb"> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'index' %}">Home</a> + </li> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'applications:application.list' %}">Applications</a> + </li> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'applications:application.detail' pk=application.pk %}">{{ application.name }}</a> + </li> + <li class="breadcrumb-item active" aria-current="page"> + Manage Access + </li> + </ol> + </nav> + + <h2>{{ application.name }}</h2> + + {% if application.description %} + <p>{{ application.description }}</p> + {% endif %} + + <hr/> + + <h2>Requests for Access</h2> + + <table id="requested-user-table" class="table"> + <thead class="thead"> + <tr> + <th>Username</th> + <th></th> + <th></th> + </tr> + </thead> + + <tbody> + {% for user in application.users_group_requested.user_set.all %} + <tr id="requested-user-{{ user.pk }}"> + <td>{{ user.username }}</td> + <td> + <button onclick="userGrantAccess( + '{% url 'applications:application.manage-access.user' pk=application.pk user_pk=user.pk %}', + {{ user.pk }} + )" + class="btn btn-success" + role="button">Approve</button> + </td> + <td> + <button onclick="userRemoveAccess( + '{% url 'applications:application.manage-access.user' pk=application.pk user_pk=user.pk %}', + {{ user.pk }} + )" + class="btn btn-danger" + role="button">Reject</button> + </td> + </tr> + {% empty %} + <tr><td>No requests</td></tr> + {% endfor %} + </tbody> + </table> + + <hr/> + + <h2>Approved Users</h2> + + <table id="approved-user-table" class="table"> + <thead class="thead"> + <tr> + <th>Username</th> + <th></th> + <th></th> + </tr> + </thead> + + <tbody> + {% for user in application.users_group.user_set.all %} + <tr id="approved-user-{{ user.pk }}"> + <td>{{ user.username }}</td> + <td></td> + <td> + <button onclick="userRemoveAccess( + '{% url 'applications:application.manage-access.user' pk=application.pk user_pk=user.pk %}', + {{ user.pk }} + )" + class="btn btn-danger" + role="button">Remove</button> + </td> + </tr> + {% empty %} + <tr><td>No approved users</td></tr> + {% endfor %} + </tbody> + </table> + + <script type="application/javascript"> + function userGrantAccess(url, userPk){ + $.ajax({ + {#url: '{% url 'applications:application.manage-access.user' pk=application.pk user_pk=request.user.pk %}',#} + url: url, + headers: { + 'X-CSRFToken': Cookies.get('csrftoken') + }, + method: 'PUT', + success: function(result, status, xhr){ + document.getElementById('requested-user-' + userPk.toString()).remove(); + + // TODO if table is empty add 'table is empty' row + } + }) + } + + function userRemoveAccess(url, userPk){ + $.ajax({ + {#url: '{% url 'applications:application.manage-access.user' pk=application.pk user_pk=request.user.pk %}',#} + url: url, + headers: { + 'X-CSRFToken': Cookies.get('csrftoken') + }, + method: 'DELETE', + success: function(result, status, xhr){ + try { + document.getElementById('approved-user-' + userPk.toString()).remove(); + } catch (err) {} + + try { + document.getElementById('requested-user-' + userPk.toString()).remove(); + } catch (err) {} + } + + // TODO if table is empty add 'table is empty' row + }) + } + </script> + +{% endblock %} \ No newline at end of file diff --git a/applications/templates/applications/application/update.html b/applications/templates/applications/application/update.html new file mode 100644 index 0000000000000000000000000000000000000000..63efd0ded6201f5814743877a0faf4ac2f986754 --- /dev/null +++ b/applications/templates/applications/application/update.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% load bootstrap4 %} + +{% block content %} + <nav aria-label="breadcrumb"> + <ol class="breadcrumb"> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'index' %}">Home</a> + </li> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'applications:application.list' %}">Applications</a> + </li> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'applications:application.detail' pk=application.pk %}">{{ application.name }}</a> + </li> + <li class="breadcrumb-item active" aria-current="page"> + Edit Application + </li> + </ol> + </nav> + + <h2 class="pb-3">Edit Application</h2> + + <form class="form" method="post" action=""> + {% csrf_token %} + + {% bootstrap_form form %} + + <input type="submit" class="btn btn-success" value="Update"> + </form> + +{% endblock %} diff --git a/applications/templates/search/indexes/applications/application_text.txt b/applications/templates/search/indexes/applications/application_text.txt new file mode 100644 index 0000000000000000000000000000000000000000..f0c6d75533b053a1ed74cef679afdeffdb975e33 --- /dev/null +++ b/applications/templates/search/indexes/applications/application_text.txt @@ -0,0 +1,3 @@ +{{ object.name }} +{{ object.owner.get_full_name }} +{{ object.description }} diff --git a/applications/tests.py b/applications/tests.py index 5630863647d15857b0de33f33a599c6150c522c9..730a6bd570056d2fafd32fae79c7f0fc04f1d768 100644 --- a/applications/tests.py +++ b/applications/tests.py @@ -1,9 +1,30 @@ +from django.contrib.auth import get_user_model from django.test import TestCase from . import models class ApplicationModelTest(TestCase): + @classmethod + def setUpTestData(cls): + cls.user_model = get_user_model() + cls.user = cls.user_model.objects.create_user('test') + def test_string_representation(self): - application = models.Application(name='Test Application') + application = models.Application(name='Test Application', + owner=self.user) self.assertEqual(str(application), application.name) + + def test_db_store(self): + """ + Test that Applications are able to be stored in a database. + + Catches regression when making changes to database routers. + """ + application = models.Application.objects.create(name='Test Application', + owner=self.user) + + application_read, created = models.Application.objects.get_or_create(pk=application.pk) + + self.assertFalse(created) + self.assertEqual(application, application_read) diff --git a/applications/urls.py b/applications/urls.py index a0b216e6364a9a24eb73910241c4f5bd3ebe3c86..1007f30ae0e53ddfa5607a16e98ac58e2de83276 100644 --- a/applications/urls.py +++ b/applications/urls.py @@ -9,7 +9,27 @@ urlpatterns = [ views.ApplicationListView.as_view(), name='application.list'), + path('add', + views.ApplicationCreateView.as_view(), + name='application.add'), + path('<int:pk>/', views.ApplicationDetailView.as_view(), name='application.detail'), + + path('<int:pk>/edit', + views.ApplicationUpdateView.as_view(), + name='application.edit'), + + path('<int:pk>/delete', + views.ApplicationDeleteView.as_view(), + name='application.delete'), + + path('<int:pk>/manage-access', + views.ApplicationManageAccessView.as_view(), + name='application.manage-access'), + + path('<int:pk>/manage-access/users/<int:user_pk>', + views.ApplicationManageAccessView.as_view(), + name='application.manage-access.user'), ] diff --git a/applications/views.py b/applications/views.py index 17ee205de6b64318050759fb655bcaef4ea10d0c..77dc4788c3a5cc04af7d50b6d29a5e6b3e5f04c3 100644 --- a/applications/views.py +++ b/applications/views.py @@ -1,7 +1,14 @@ +from django.contrib.auth.mixins import PermissionRequiredMixin +from django.urls import reverse_lazy from django.views.generic.detail import DetailView +from django.views.generic.edit import CreateView, DeleteView, UpdateView from django.views.generic.list import ListView +from rest_framework.authtoken.models import Token + from . import models +from core.permissions import OwnerPermissionMixin +from core.views import ManageAccessView class ApplicationListView(ListView): @@ -10,7 +17,69 @@ class ApplicationListView(ListView): context_object_name = 'applications' +class ApplicationCreateView(PermissionRequiredMixin, CreateView): + model = models.Application + template_name = 'applications/application/create.html' + context_object_name = 'application' + + fields = '__all__' + permission_required = 'applications.add_application' + + def form_valid(self, form): + try: + owner = form.instance.owner + + except models.Application.owner.RelatedObjectDoesNotExist: + form.instance.owner = self.request.user + + return super().form_valid(form) + + +class ApplicationUpdateView(OwnerPermissionMixin, UpdateView): + model = models.Application + template_name = 'applications/application/update.html' + context_object_name = 'application' + + fields = '__all__' + + +class ApplicationDeleteView(OwnerPermissionMixin, DeleteView): + model = models.Application + template_name = 'applications/application/delete.html' + context_object_name = 'application' + + success_url = reverse_lazy('applications:application.list') + + class ApplicationDetailView(DetailView): model = models.Application template_name = 'applications/application/detail.html' context_object_name = 'application' + + def get_template_names(self): + if not self.object.has_view_permission(self.request.user): + return ['applications/application/detail-no-access.html'] + return super().get_template_names() + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context['has_edit_permission'] = self.request.user.is_superuser or self.request.user == self.object.owner + + if self.request.user == self.object.owner or self.request.user.is_superuser: + context['api_key'] = Token.objects.get(user=self.object.proxy_user) + + return context + + +class ApplicationManageAccessView(OwnerPermissionMixin, ManageAccessView): + """ + Manage a user's access to a Application. + + On GET request will display the access management page. + Accepts PUT and DELETE requests to add a user to, or remove a user from the access group. + Request responses follow JSend specification (see http://labs.omniti.com/labs/jsend). + """ + model = models.Application + template_name = 'applications/application/manage_access.html' + context_object_name = 'application' diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/core/apps.py b/core/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..26f78a8e673340121f68a92930e2830bc58d269d --- /dev/null +++ b/core/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + name = 'core' diff --git a/core/models.py b/core/models.py new file mode 100644 index 0000000000000000000000000000000000000000..b4b3378fc764471fe2e47141030eae086e92b461 --- /dev/null +++ b/core/models.py @@ -0,0 +1,61 @@ +""" +This module contains models for functionality common to both Application and DataSource models. +""" + +import abc + +from django.db import models + + +#: Length of CharFields used to hold the names of objects +MAX_LENGTH_NAME = 63 + +MAX_LENGTH_API_KEY = 127 + +MAX_LENGTH_PATH = 255 + + +class SoftDeletionManager(models.Manager): + """ + Manager for soft-deletable objects. Filters out objects which have `is_deleted` set. + """ + def get_queryset(self): + return super().get_queryset().filter(is_deleted=False) + + +class BaseAppDataModel(models.Model): + """ + The parent class of the Application and DataSource models - providing common functionality. + + This class is an abstract model and will not create a corresponding DB table. + """ + #: Friendly name of this application + name = models.CharField(max_length=MAX_LENGTH_NAME, + blank=False, null=False) + + #: A brief description + description = models.TextField(blank=True, null=False) + + # TODO replace this with an admin group + @property + @abc.abstractmethod + def owner(self): + """ + User responsible for this data source / application. + """ + raise NotImplementedError + + @abc.abstractmethod + def get_absolute_url(self): + """ + Return URL at which this object may be viewed. + + Method must be implemented by inheriting classes. + """ + raise NotImplementedError + + def __str__(self): + return self.name + + class Meta: + abstract = True diff --git a/core/permissions.py b/core/permissions.py new file mode 100644 index 0000000000000000000000000000000000000000..ddd75cb0fb2d056faef6e45de3525a501b4b2876 --- /dev/null +++ b/core/permissions.py @@ -0,0 +1,6 @@ +from django.contrib.auth.mixins import UserPassesTestMixin + + +class OwnerPermissionMixin(UserPassesTestMixin): + def test_func(self): + return self.request.user == self.get_object().owner or self.request.user.is_superuser diff --git a/pedasi/common/plugin.py b/core/plugin.py similarity index 86% rename from pedasi/common/plugin.py rename to core/plugin.py index 8235e0f55ec9312772ee5523ce0c98a0d4881f86..76b1fe1bbe8c2c0b4e332c549a6c9222e736021f 100644 --- a/pedasi/common/plugin.py +++ b/core/plugin.py @@ -1,3 +1,7 @@ +""" +This module contains functionality for configurable plugins. +""" + import abc import importlib import typing @@ -29,7 +33,7 @@ class Plugin(abc.ABCMeta): else: cls._plugins[name] = cls - def get_plugin(cls, class_name: str) -> type: + def get_plugin(cls, class_name: str) -> typing.Type: """ Get a plugin class by name. @@ -56,4 +60,6 @@ class Plugin(abc.ABCMeta): # This causes a call to the metaclass __init__ method which registers the plugin importlib.import_module(str(plugin_dir).replace('/', '.') + '.' + module_name) - + @property + def plugin_choices(cls) -> typing.List[typing.Tuple[str, str]]: + return [(name, name) for name, plugin in cls._plugins.items()] diff --git a/core/views.py b/core/views.py new file mode 100644 index 0000000000000000000000000000000000000000..08adea1765d4a2206885ddefef8a0ec5dcd40d42 --- /dev/null +++ b/core/views.py @@ -0,0 +1,100 @@ +""" +This module contains views for behaviour common to both Application and DataSource models. +""" + +from django.contrib.auth import get_user_model +from django.http import JsonResponse, HttpResponseBadRequest, HttpResponseForbidden +from django.views.generic.detail import DetailView + + +class ManageAccessView(DetailView): + """ + Manage a user's access to a resource. + + On GET request will display the access management page. + Accepts PUT and DELETE requests to add a user to, or remove a user from the access group. + Request responses follow JSend specification (see http://labs.omniti.com/labs/jsend). + """ + def put(self, request, *args, **kwargs): + """ + Add a user to the access group for a resource. + + If the request is performed by the resource owner or by staff: add them directly to the access group, + If the request is performed by the user themselves: add them to the 'access requested' group, + Else reject the request. + + :param request: + :param args: + :param kwargs: + :return: + """ + self.request = request + self.object = self.get_object() + + user = get_user_model().objects.get(pk=kwargs['user_pk']) + access_group = self.object.users_group + request_group = self.object.users_group_requested + + if self.request.user == self.object.owner or self.request.user.is_superuser: + # If request is from resource owner or admin, add user to access group + access_group.user_set.add(user) + request_group.user_set.remove(user) + + elif self.request.user == user: + if access_group.user_set.filter(id=user.id).exists(): + return HttpResponseBadRequest( + JsonResponse({ + 'status': 'fail', + 'message': 'You already have access to this resource', + }) + ) + + # If user is requesting for themselves, add them to 'access requested' group + request_group.user_set.add(user) + + else: + return HttpResponseForbidden( + JsonResponse({ + 'status': 'fail', + 'message': 'You do not have permission to set access for this user', + }) + ) + + return JsonResponse({ + 'status': 'success', + 'data': { + 'user': { + 'pk': user.pk + }, + }, + }) + + def delete(self, request, *args, **kwargs): + self.request = request + self.object = self.get_object() + + user = get_user_model().objects.get(pk=kwargs['user_pk']) + access_group = self.object.users_group + request_group = self.object.users_group_requested + + if self.request.user == user or self.request.user == self.object.owner or self.request.user.is_staff: + # Users can remove themselves, be removed by the resource owner, or by staff + access_group.user_set.remove(user) + request_group.user_set.remove(user) + + else: + return HttpResponseForbidden( + JsonResponse({ + 'status': 'fail', + 'message': 'You do not have permission to set access for this user', + }) + ) + + return JsonResponse({ + 'status': 'success', + 'data': { + 'user': { + 'pk': user.pk + }, + }, + }) diff --git a/datasources/admin.py b/datasources/admin.py index e794f215a1d27040ff700fafaec379f40bdf626a..f98162f754fafea55bc3d7fccae6ec20095a71e6 100644 --- a/datasources/admin.py +++ b/datasources/admin.py @@ -1,11 +1,17 @@ from django.contrib import admin -from . import models +from . import forms, models + + +@admin.register(models.MetadataField) +class MetadataFieldAdmin(admin.ModelAdmin): + pass @admin.register(models.DataSource) class DataSourceAdmin(admin.ModelAdmin): readonly_fields = ['owner'] + form = forms.DataSourceForm def has_change_permission(self, request, obj=None) -> bool: """ @@ -35,6 +41,7 @@ class DataSourceAdmin(admin.ModelAdmin): """ try: owner = form.instance.owner + except models.DataSource.owner.RelatedObjectDoesNotExist: form.instance.owner = request.user diff --git a/datasources/connectors/__init__.py b/datasources/connectors/__init__.py index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..38be2bb0a3b75b6eee596797991dd4a522f57777 100644 --- a/datasources/connectors/__init__.py +++ b/datasources/connectors/__init__.py @@ -0,0 +1 @@ +from .base import BaseDataConnector, DataCatalogueConnector, DataSetConnector \ No newline at end of file diff --git a/datasources/connectors/base.py b/datasources/connectors/base.py index 50005c0a74c17068ddf5e97b4e082069cc12245f..38589688d1e97427b2f5252e9433d73e998c18af 100644 --- a/datasources/connectors/base.py +++ b/datasources/connectors/base.py @@ -1,8 +1,74 @@ +""" +This module contains base classes for data connectors. + +Data connectors are the component of PEDASI which interacts directly with data provider APIs. +""" + import abc +from collections import abc as collections_abc +from collections import OrderedDict +import enum import typing +import requests +import requests.auth + +from core import plugin + + +@enum.unique +class AuthMethod(enum.IntEnum): + """ + Authentication method to be used when performing a request to the external API. + """ + # Does not require authentication + NONE = -1 + + # Unknown - assume no authentication if a request is sent + UNKNOWN = 0 + + # HTTPBasicAuth from Requests + BASIC = 1 + + # Same as HTTPBasicAuth but key is already b64 encoded + HEADER = 2 + + @classmethod + def choices(cls): + return tuple((i.value, i.name) for i in cls) + + +class HttpHeaderAuth(requests.auth.HTTPBasicAuth): + """ + Requests Auth provider. + + The same as HttpBasicAuth - but don't convert to base64 + + Used for e.g. Cisco HyperCat API + """ + def __call__(self, r): + r.headers['Authorization'] = self.username + return r + + +REQUEST_AUTH_FUNCTIONS = OrderedDict([ + (AuthMethod.NONE, None), + (AuthMethod.UNKNOWN, None), + (AuthMethod.BASIC, requests.auth.HTTPBasicAuth), + (AuthMethod.HEADER, HttpHeaderAuth), +]) + + +class RequestCounter: + def __init__(self, count: int = 0): + self._count = count -from pedasi.common import plugin + def __iadd__(self, other: int): + self._count += other + return self + + def count(self): + return self._count class BaseDataConnector(metaclass=plugin.Plugin): @@ -13,63 +79,174 @@ class BaseDataConnector(metaclass=plugin.Plugin): * A single dataset * A data catalogue - a collection of datasets + """ + #: Does this data connector represent a data catalogue containing multiple datasets? + is_catalogue = None - TODO: + #: Help string to be shown when a data provider is choosing a connector + description = None - * Should this connector interface be able to handle data catalogues and datasets - or should we create a new connector for datasets within a catalogue? - * What other operations do we need? - """ def __init__(self, location: str, - api_key: typing.Optional[str] = None): + api_key: typing.Optional[str] = None, + auth: typing.Optional[typing.Callable] = None, + **kwargs): self.location = location self.api_key = api_key + self.auth = auth - @abc.abstractmethod - def get_data(self, - dataset: typing.Optional[str] = None, - query_params: typing.Optional[typing.Mapping[str, str]] = None): + self._request_counter = RequestCounter() + + @property + def request_count(self): + return self._request_counter.count() + + def get_metadata(self, + params: typing.Optional[typing.Mapping[str, str]] = None): """ - Get data from this source using the appropriate API. + Get metadata from this source using the appropriate API. - :param dataset: Optional dataset for which to get data - :param query_params: Optional query parameter filters - :return: Requested data + :param params: Optional query parameter filters + :return: Requested metadata + """ + try: + if self._metadata is not None: + return self._metadata + + except AttributeError: + pass + + raise NotImplementedError('This data connector does not provide metadata') + + def get_response(self, + params: typing.Optional[typing.Mapping[str, str]] = None): + """ + Transparently return a response from a source API. + + :param params: Optional query parameter filters + :return: Requested data / metadata - response is passed transparently + """ + return self._get_auth_request(self.location, + params=params) + + def _get_auth_request(self, url, **kwargs): + self._request_counter += 1 + + if self.auth is None: + return requests.get(url, **kwargs) + + return requests.get(url, + auth=self.auth(self.api_key, ''), + **kwargs) + + +class ReadOnlyInternalDataConnector(abc.ABC): + """ + Abstract mixin representing a connector for an internally hosted data source which is read only. + """ + @abc.abstractmethod + def clean_data(self, **kwargs): + """ + Clean, validate or otherwise structure the data contained within this data source. """ raise NotImplementedError -class DataConnectorContainsDatasets: +class InternalDataConnector(ReadOnlyInternalDataConnector): """ - Mixin class indicating that the plugin represents a data source containing - multiple datasets. + Abstract mixin representing a connector for an internally hosted data source which is able to be written to. """ @abc.abstractmethod - def get_datasets(self, - query_params: typing.Optional[typing.Mapping[str, str]] = None): + def clear_data(self): + """ + Clear all data from this data source. + """ + raise NotImplementedError + + @abc.abstractmethod + def post_data(self, data: typing.Union[typing.MutableMapping[str, str], + typing.List[typing.MutableMapping[str, str]]]): """ - Get the list of all dataset identifiers contained within this source - using the appropriate API. + Add data to this data source. - :param query_params: Optional query parameter filters - :return: All dataset identifiers + :param data: Data to add """ raise NotImplementedError -class DataConnectorHasMetadata: +class DataCatalogueConnector(BaseDataConnector, collections_abc.Mapping): """ - Mixin class indicating the the plugin represents a data source with metadata. + Base class of data connectors which provide access to a data catalogue. """ + #: Does this data connector represent a data catalogue containing multiple datasets? + is_catalogue = True + + def get_data(self, + params: typing.Optional[typing.Mapping[str, str]] = None): + raise TypeError('Data catalogues contain only metadata. You must select a dataset.') + @abc.abstractmethod - def get_metadata(self, - dataset: typing.Optional[str] = None, - query_params: typing.Optional[typing.Mapping[str, str]] = None): + def get_datasets(self, + params: typing.Optional[typing.Mapping[str, str]] = None) -> typing.List[str]: """ - Get metadata from this source using the appropriate API. + Get the list of datasets provided by this catalogue. - :param dataset: Optional dataset for which to get metadata - :param query_params: Optional query parameter filters - :return: Requested metadata + :param params: Query parameters to pass to data source API + :return: List of datasets provided by this catalogue """ raise NotImplementedError + + @abc.abstractmethod + def __getitem__(self, item: str) -> BaseDataConnector: + raise NotImplementedError + + def __iter__(self): + return iter(self.get_datasets()) + + def __len__(self): + return len(self.get_datasets()) + + +class DataSetConnector(BaseDataConnector): + """ + Base class of data connectors which provide access to a single dataset. + + Metadata may be passed to the constructor if it has been collected from a previous source + otherwise attempting to retrieve metadata will raise NotImplementedError. + + If you wish to connect to a source that provides metadata itself, you must create a new + connector class which inherits from this one. + """ + #: Does this data connector represent a data catalogue containing multiple datasets? + is_catalogue = False + + #: Help string to be shown when a data provider is choosing a connector + description = ( + 'This connector is the default option and should be used when accessing an API at a single endpoint ' + 'which may or may not accept query parameters.' + ) + + def __init__(self, location: str, + api_key: typing.Optional[str] = None, + auth: typing.Optional[typing.Callable] = None, + metadata: typing.Optional = None): + super().__init__(location, api_key, auth=auth) + + self._metadata = metadata + + def get_data(self, + params: typing.Optional[typing.Mapping[str, str]] = None): + """ + Retrieve the data from this source. + + If the data is JSON formatted it will be parsed into a dictionary - otherwise it will + be passed as plain text. + + :param params: Query parameters to be passed through to the data source API + :return: Data source data + """ + response = self.get_response(params) + + if 'json' in response.headers['Content-Type']: + return response.json() + + return response.text diff --git a/datasources/connectors/csv.py b/datasources/connectors/csv.py new file mode 100644 index 0000000000000000000000000000000000000000..2aa9cb2a1c61ed7afd1b168975b1b5b0f9770104 --- /dev/null +++ b/datasources/connectors/csv.py @@ -0,0 +1,178 @@ +""" +Connectors for handling CSV data. +""" + +import csv +import json +import typing + +from django.http import JsonResponse + +import mongoengine +from mongoengine import context_managers + +from .base import DataSetConnector, InternalDataConnector + + +class CsvConnector(DataSetConnector): + """ + Data connector for retrieving data from CSV files. + """ + def get_metadata(self, + params: typing.Optional[typing.Mapping[str, str]] = None): + """ + Return a JSON response from a CSV file. + + :param params: Query params - ignored + :return: Metadata + """ + with open(self.location, 'r') as csvfile: + # Requires a header row + reader = csv.DictReader(csvfile) + return reader.fieldnames + + def get_response(self, + params: typing.Optional[typing.Mapping[str, str]] = None): + """ + Return a JSON response from a CSV file. + + CSV file must have a header row with column titles. + + :param params: Optional query parameter filters + :return: Requested data + """ + try: + with open(self.location, 'r') as csvfile: + # Requires a header row + reader = csv.DictReader(csvfile) + + if params is None: + params = {} + + rows = [] + for row in reader: + for key, value in params.items(): + try: + if row[key].strip() != value.strip(): + break + + except KeyError: + # The filter field isn't in the data so no row can satisfy it + break + + else: + # All filters match + rows.append(dict(row)) + + return JsonResponse({ + 'status': 'success', + 'data': rows, + }) + + except UnicodeDecodeError: + return JsonResponse({ + 'status': 'error', + 'message': 'Invalid CSV file', + }, status=500) + + +class CsvRow(mongoengine.DynamicDocument): + """ + MongoDB dynamic document to store CSV data. + + Store in own database - distinct from PROV data. + Collection must be changed manually when managing CsvRows since all connectors use the same backing model. + """ + meta = { + 'db_alias': 'internal_data', + } + + +def _type_convert(val): + """ + Attempt to convert a value into a numeric type. + + :param val: Value to attempt to convert + :return: Converted value or unmodified value if conversion was not possible + """ + for conv in (int, float): + try: + return conv(val) + + except ValueError: + pass + + return val + + +class CsvToMongoConnector(InternalDataConnector, DataSetConnector): + """ + Data connector representing an internally hosted data source, backed by MongoDB. + + This connector allows data to be pushed as well as retrieved. + """ + id_field_alias = '__id' + + def clean_data(self, **kwargs): + index_fields = kwargs.get('index_fields', None) + + if index_fields is None: + return + + if isinstance(index_fields, str): + index_fields = [index_fields] + + with context_managers.switch_collection(CsvRow, self.location) as collection: + for index_field in index_fields: + collection.create_index(index_field, background=True) + + def clear_data(self): + with context_managers.switch_collection(CsvRow, self.location) as collection: + collection.objects.delete() + + def post_data(self, data: typing.Union[typing.MutableMapping[str, str], + typing.List[typing.MutableMapping[str, str]]]): + def create_document(row: typing.MutableMapping[str, str]): + kwargs = {key: _type_convert(val) for key, val in row.items()} + + # Can't store field 'id' in document - rename it + if 'id' in kwargs: + kwargs[self.id_field_alias] = kwargs.pop('id') + + return kwargs + + # Put data in collection belonging to this data source + with context_managers.switch_collection(CsvRow, self.location) as collection: + collection = collection._get_collection() + + try: + # Data is a dictionary - a single row + collection.insert_one(create_document(data)) + + except AttributeError: + # Data is a list of dictionaries - multiple rows + documents = (create_document(row) for row in data) + collection.insert_many(documents) + + def get_response(self, + params: typing.Optional[typing.Mapping[str, str]] = None): + # TODO accept parameters provided twice as an inclusive OR + if params is None: + params = {} + params = {key: _type_convert(val) for key, val in params.items()} + + with context_managers.switch_collection(CsvRow, self.location) as collection: + records = collection.objects.filter(**params) + + # To get dictionary from MongoEngine records we need to go via JSON string + data = json.loads(records.exclude('_id').to_json()) + + # Couldn't store field 'id' in document - recover it + for item in data: + if self.id_field_alias in item: + item['id'] = item.pop(self.id_field_alias) + + return JsonResponse({ + 'status': 'success', + 'data': data, + }) diff --git a/datasources/connectors/entity.py b/datasources/connectors/entity.py new file mode 100644 index 0000000000000000000000000000000000000000..e66f621815a4e987e1079de475835a455aeffd14 --- /dev/null +++ b/datasources/connectors/entity.py @@ -0,0 +1,123 @@ +""" +This module contains connectors for receiving data via Cisco's Entity API. +""" + +import typing + +from .base import BaseDataConnector, DataCatalogueConnector, DataSetConnector + + +class CiscoEntityConnector(DataCatalogueConnector): + """ + Data connector for retrieving data or metadata from Cisco's Entity API. + """ + dataset_connector_class = DataSetConnector + + def __init__(self, location: str, + api_key: typing.Optional[str] = None, + auth: typing.Optional[typing.Callable] = None, + metadata: typing.Optional[typing.Mapping] = None): + super().__init__(location, api_key=api_key, auth=auth) + + self._response = None + self._metadata = metadata + + def __getitem__(self, item: str) -> BaseDataConnector: + params = { + 'href': item + } + + # Use cached response if we have one + response = self._get_response(params=params) + + dataset_item = self._get_item_by_key_value( + response, + 'uri', + item + ) + + if 'timeseries' in item: + return self.dataset_connector_class(item, self.api_key, + auth=self.auth, + metadata=dataset_item) + + return type(self)(item, self.api_key, + auth=self.auth, + metadata=dataset_item) + + def items(self, + params=None) -> typing.ItemsView: + """ + Get key-value pairs of dataset ID to dataset connector for datasets contained within this catalogue. + + :param params: Query parameters to be passed through to the data source API + :return: Dictionary ItemsView over datasets + """ + # Use cached response if we have one + response = self._get_response(params) + + connector_dict = { + item['uri']: self.dataset_connector_class(item['uri'], self.api_key, + auth=self.auth, + metadata=item) + # Response JSON is a list of entities + for item in response + } + + return connector_dict.items() + + def get_metadata(self, + params: typing.Optional[typing.Mapping[str, str]] = None): + if self._metadata is None: + raise NotImplementedError + + return self._metadata + + def get_datasets(self, + params: typing.Optional[typing.Mapping[str, str]] = None) -> typing.List[str]: + # Use cached response if we have one + response = self._get_response(params=params) + + datasets = [] + if len(response) == 1 and 'timeseries' in response[0]: + # Response is one entity which should contain data series? + # TODO is it always 'timeseries'? + for item in response[0]['timeseries']: + datasets.append(item['uri']) + + else: + for item in response: + datasets.append(item['uri']) + + return datasets + + @staticmethod + def _get_item_by_key_value(collection: typing.Iterable[typing.Mapping], + key: str, value) -> typing.Mapping: + matches = [item for item in collection if item[key] == value] + + if not matches: + raise KeyError + elif len(matches) > 1: + raise ValueError('Multiple items were found') + + return matches[0] + + def _get_response(self, params: typing.Optional[typing.Mapping[str, str]] = None) -> typing.Mapping: + # Use cached response if we have one + # TODO should we use cached responses? + if self._response is not None and params is None: + # Ignore params - they only filter - we already have everything + response = self._response + else: + response = self._get_auth_request(self.location, + params=params) + response.raise_for_status() + return response.json() + + def __enter__(self): + self._response = self._get_response() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + pass diff --git a/datasources/connectors/hypercat.py b/datasources/connectors/hypercat.py index 8616c0ff014bc703eed8eef38f2be75095f7dc95..33bf26a091caa4fe66407d0001f92715794e0d05 100644 --- a/datasources/connectors/hypercat.py +++ b/datasources/connectors/hypercat.py @@ -1,81 +1,104 @@ +""" +This module contains data connector classes for retrieving data from HyperCat catalogues. +""" + import typing -import requests -import requests.auth +from .base import BaseDataConnector, DataCatalogueConnector, DataSetConnector -from datasources.connectors.base import BaseDataConnector, DataConnectorContainsDatasets, DataConnectorHasMetadata +class HyperCat(DataCatalogueConnector): + """ + Data connector for retrieving data or metadata from a HyperCat catalogue. + """ + dataset_connector_class = DataSetConnector -class HyperCat(DataConnectorContainsDatasets, DataConnectorHasMetadata, BaseDataConnector): def __init__(self, location: str, - api_key: typing.Optional[str] = None): - super().__init__(location, api_key=api_key) + api_key: typing.Optional[str] = None, + auth: typing.Optional[typing.Callable] = None): + super().__init__(location, api_key=api_key, auth=auth) self._response = None - def get_data(self, - dataset: typing.Optional[str] = None, - query_params: typing.Optional[typing.Mapping[str, str]] = None): - if dataset is None: - raise ValueError('When requesting data from a HyperCat catalogue you must provide a dataset href.') - - location = dataset - r = requests.get(location, - params=query_params, - auth=requests.auth.HTTPBasicAuth(self.api_key, '')) - return r.text - - def get_datasets(self, - query_params: typing.Optional[typing.Mapping[str, str]] = None): - response = self._response - if response is None: - response = self._get_response(query_params) - - return [item['href'] for item in response['items']] - - # TODO should this be able to return metadata for multiple datasets at once? - # TODO should there be a different method for getting catalogue metadata? + def __getitem__(self, item: str) -> BaseDataConnector: + params = { + 'href': item + } + + response = self._get_response(params) + + dataset_item = self._get_item_by_key_value( + response['items'], + 'href', + item + ) + metadata = dataset_item['item-metadata'] + + try: + try: + content_type = self._get_item_by_key_value( + metadata, + 'rel', + 'urn:X-hypercat:rels:isContentType' + )['val'] + + except KeyError: + content_type = self._get_item_by_key_value( + metadata, + 'rel', + 'urn:X-hypercat:rels:containsContentType' + )['val'] + + if content_type == 'application/vnd.hypercat.catalogue+json': + return type(self)(location=item, + api_key=self.api_key, + auth=self.auth) + + except (KeyError, ValueError): + # Has no or multiple values for content type - is not a catalogue + pass + + return self.dataset_connector_class(item, self.api_key, + auth=self.auth, + metadata=metadata) + + def items(self, + params=None) -> typing.ItemsView: + """ + Get key-value pairs of dataset ID to dataset connector for datasets contained within this catalogue. + + :param params: Query parameters to be passed through to the data source API + :return: Dictionary ItemsView over datasets + """ + response = self._get_response(params) + + return { + item['href']: self.dataset_connector_class(item['href'], self.api_key, + auth=self.auth, + metadata=item['item-metadata']) + for item in response['items'] + }.items() + + # TODO this gets the entire HyperCat contents so is slow on the BT HyperCat API - ~1s def get_metadata(self, - dataset: typing.Optional[str] = None, - query_params: typing.Optional[typing.Mapping[str, str]] = None): - if query_params is None: - query_params = {} - - if dataset is not None: - # Copy so we can update without side effect - query_params = dict(query_params) - query_params['href'] = dataset - - # Use cached response if we have one - response = self._response - if response is None: - response = self._get_response(query_params) - - if dataset is None: - metadata = response['catalogue-metadata'] + params: typing.Optional[typing.Mapping[str, str]] = None): + response = self._get_response(params) - else: - dataset_item = self._get_item_by_key_value( - response['items'], - 'href', - dataset - ) - metadata = dataset_item['item-metadata'] + return response['catalogue-metadata'] - metadata_dict = {} - for item in metadata: - relation = item['rel'] - value = item['val'] + def get_datasets(self, + params: typing.Optional[typing.Mapping[str, str]] = None) -> typing.List[str]: + response = self._get_response(params=params) - if relation not in metadata_dict: - metadata_dict[relation] = [] - metadata_dict[relation].append(value) + datasets = [] + for item in response['items']: + datasets.append(item['href']) - return metadata_dict + return datasets @staticmethod def _get_item_by_key_value(collection: typing.Iterable[typing.Mapping], - key: str, value) -> typing.Mapping: + key: str, value: typing.Any) -> typing.Mapping: matches = [item for item in collection if item[key] == value] if not matches: @@ -85,39 +108,21 @@ class HyperCat(DataConnectorContainsDatasets, DataConnectorHasMetadata, BaseData return matches[0] - def _get_response(self, query_params: typing.Optional[typing.Mapping[str, str]] = None): - # r = requests.get(self.location, params=query_params) - r = self._get_auth_request(self.location, - query_params=query_params) - return r.json() - - def _get_auth_request(self, url, **kwargs): - return requests.get(url, - auth=requests.auth.HTTPBasicAuth(self.api_key, ''), - **kwargs) + def _get_response(self, params: typing.Optional[typing.Mapping[str, str]] = None) -> typing.Mapping: + # Use cached response if we have one + # TODO should we use cached responses? + if self._response is not None and params is None: + # Ignore params - they only filter - we already have everything + response = self._response + else: + response = self._get_auth_request(self.location, + params=params) + response.raise_for_status() + return response.json() def __enter__(self): - self._response = self._get_response() + self._response = self._get_auth_request(self.location) return self def __exit__(self, exc_type, exc_val, exc_tb): pass - - -class HyperCatCisco(HyperCat): - def __init__(self, location: str, - api_key: typing.Optional[str] = None, - entity_url: str = None): - super().__init__(location, api_key=api_key) - - self.entity_url = entity_url - - def get_entities(self): - r = self._get_auth_request(self.entity_url) - return r.json() - - def _get_auth_request(self, url, **kwargs): - return requests.get(url, - # Doesn't accept HttpBasicAuth - headers={'Authorization': self.api_key}, - **kwargs) diff --git a/datasources/connectors/iotuk.py b/datasources/connectors/iotuk.py deleted file mode 100644 index 67a8f485edf8233fd044ef70fd615c6bca312619..0000000000000000000000000000000000000000 --- a/datasources/connectors/iotuk.py +++ /dev/null @@ -1,20 +0,0 @@ -import typing - -import requests - -from .base import BaseDataConnector - - -class IoTUK(BaseDataConnector): - def get_data(self, - dataset: typing.Optional[str] = None, - query_params: typing.Optional[typing.Mapping[str, str]] = None): - """ - Get data from a source using the IoTUK Nation API. - - :param dataset: Optional dataset for which to get data - :param query_params: Optional query parameter filters - :return: Requested data - """ - r = requests.get(self.location, params=query_params) - return r.json() diff --git a/datasources/connectors/postcode_lookup.py b/datasources/connectors/postcode_lookup.py new file mode 100644 index 0000000000000000000000000000000000000000..6d4c95a670bab9b224c256d198d0ff618d935467 --- /dev/null +++ b/datasources/connectors/postcode_lookup.py @@ -0,0 +1,106 @@ +""" +This module contains a connector for UK postcode lookup. + +Run as module with --import <csv file> to import a postcode database CSV. +""" + +import csv +import sys +import typing + +from django.http import JsonResponse + +from decouple import config +import sqlalchemy +from sqlalchemy.exc import NoSuchTableError +import sqlalchemy.orm + +from .base import DataSetConnector + + +class OnsPostcodeDirectoryConnector(DataSetConnector): + """ + Connector for UK postcode lookup, backed by an SQL table. + """ + _table_name = 'connector_postcode' + + def __init__(self, location: str, + api_key: typing.Optional[str] = None, + auth: typing.Optional[typing.Callable] = None): + super().__init__(location, api_key=api_key, auth=auth) + + self._engine = sqlalchemy.create_engine(config('DATABASE_URL')) + self._session_maker = sqlalchemy.orm.sessionmaker(bind=self._engine) + + try: + self._table_meta = sqlalchemy.MetaData(self._engine) + self._table = sqlalchemy.Table(self._table_name, self._table_meta, autoload=True) + + except NoSuchTableError as exc: + raise FileNotFoundError('Postcode table is not present') from exc + + def get_response(self, + params: typing.Optional[typing.Mapping[str, str]] = None): + if params is None or 'postcode' not in params: + return JsonResponse({ + 'status': 'fail', + 'data': { + 'postcode': 'Field \'postcode\' is a required field', + }, + }, status=400) + + query = sqlalchemy.select( + [self._table] + ).where(self._table.c.postcode == params['postcode'].replace(' ', '').upper()) + + result = self._session_maker().execute(query).fetchone() + + try: + return JsonResponse(dict(result), json_dumps_params={'default': str}) + + except TypeError: + # Did not return a valid result + return JsonResponse({ + 'status': 'fail', + 'data': { + 'postcode': 'No record matching postcode \'{0}\' found'.format(params['postcode']), + }, + }, status=404) + + @classmethod + def setup(cls, filename): + engine = sqlalchemy.create_engine(config('DATABASE_URL')) + + metadata = sqlalchemy.MetaData(engine) + postcodes = sqlalchemy.Table( + cls._table_name, metadata, + sqlalchemy.Column('postcode', sqlalchemy.String(length=10), index=True, nullable=False, primary_key=True), + sqlalchemy.Column('lat', sqlalchemy.Float, nullable=False), + sqlalchemy.Column('long', sqlalchemy.Float, nullable=False) + ) + + try: + postcodes.create() + + except sqlalchemy.exc.OperationalError: + pass + + conn = engine.connect() + + with open(filename, 'r') as csvfile: + reader = csv.DictReader(csvfile) + + # TODO this fails if any row already exists - but checking each row in turn is slow - find solution + conn.execute(postcodes.insert(), [ + {'postcode': row['pcds'].replace(' ', '').upper(), + 'lat': row['lat'], + 'long': row['long']} for row in reader + ]) + + +if __name__ == '__main__': + if len(sys.argv) == 3 and sys.argv[1] == '--import': + OnsPostcodeDirectoryConnector.setup(sys.argv[2]) + + else: + print(__doc__) diff --git a/datasources/connectors/rest_api.py b/datasources/connectors/rest_api.py new file mode 100644 index 0000000000000000000000000000000000000000..0632752ef09b4592b736821ad2c5d22f73e28b2c --- /dev/null +++ b/datasources/connectors/rest_api.py @@ -0,0 +1,66 @@ +""" +This module contains a data connector class for retrieving data via a REST API. +""" + +import typing +import urllib.parse + +from .base import BaseDataConnector, DataCatalogueConnector + + +class RestApiConnector(DataCatalogueConnector): + """ + Data connector for retrieving data from a REST API. + """ + def __init__(self, location: str, + api_key: typing.Optional[str] = None, + auth: typing.Optional[typing.Callable] = None): + super().__init__(location, api_key=api_key, auth=auth) + + self._response = None + + def __getitem__(self, item: str) -> BaseDataConnector: + url = urllib.parse.urljoin(self.location, item) + + return type(self)(location=url, + api_key=self.api_key, + auth=self.auth) + + def get_metadata(self, + params: typing.Optional[typing.Mapping[str, str]] = None): + """ + Metadata is not supported by this connector. + """ + raise NotImplementedError('This data connector does not provide metadata') + + def get_data(self, + params: typing.Optional[typing.Mapping[str, str]] = None): + """ + Retrieve the data from this source. + + If the data is JSON formatted it will be parsed into a dictionary - otherwise it will + be passed as plain text. + + :param params: Query parameters to be passed through to the data source API + :return: Data source data + """ + response = self.get_response(params) + + if 'json' in response.headers['Content-Type']: + return response.json() + + return response.text + + def get_datasets(self, + params: typing.Optional[typing.Mapping[str, str]] = None) -> typing.List[str]: + """ + Listing datasets is not supported by this connector. + """ + raise NotImplementedError('This data connector does not provide a list of datasets') + + def __enter__(self): + self._response = self._get_auth_request(self.location) + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + pass diff --git a/datasources/forms.py b/datasources/forms.py new file mode 100644 index 0000000000000000000000000000000000000000..a5477b241e523ab4c1b3376129adff893de15278 --- /dev/null +++ b/datasources/forms.py @@ -0,0 +1,108 @@ +from django import forms + +from requests.exceptions import ConnectionError + +from . import connectors, models + + +# TODO arrange such that this doesn't need to be loaded each time +connectors.BaseDataConnector.load_plugins('datasources/connectors') + + +class DataSourceForm(forms.ModelForm): + """ + Form class for creating / updating DataSource. + """ + plugin_name = forms.ChoiceField(choices=connectors.BaseDataConnector.plugin_choices, + initial='DataSetConnector') + + class Meta: + model = models.DataSource + exclude = ['auth_method', 'owner', 'users'] + + def clean(self): + cleaned_data = super().clean() + + try: + cleaned_data['auth_method'] = models.DataSource.determine_auth_method( + cleaned_data['url'], + cleaned_data['api_key'] + ) + + except ConnectionError: + raise forms.ValidationError('Could not authenticate against URL with provided API key.') + + return cleaned_data + + def clean_encrypted_docs_url(self): + """ + Make sure that 'is_encrypted' and 'encrypted_docs_url' are always present as a pair. + """ + if self.cleaned_data['encrypted_docs_url'] and not self.cleaned_data['is_encrypted']: + raise forms.ValidationError('You may not provide an encryption documentation URL if the data is not encrypted') + + if self.cleaned_data['is_encrypted'] and not self.cleaned_data['encrypted_docs_url']: + raise forms.ValidationError('You must provide an encryption documentation URL is the data is encrypted') + + return self.cleaned_data['encrypted_docs_url'] + + +class PermissionRequestForm(forms.ModelForm): + class Meta: + model = models.UserPermissionLink + fields = ['user', 'requested', 'push_requested', 'reason'] + widgets = { + 'reason': forms.Textarea + } + help_texts = { + 'user': 'You may request permission for yourself or on behalf of any applications for which you are responsible.', + 'push_requested': 'Do you also require permission to push data?', + } + + def __init__(self, *args, **kwargs): + user_choices = kwargs.pop('user_choices') + user_initial = kwargs.pop('user_initial') + user_field_hidden = kwargs.pop('user_field_hidden') + + super().__init__(*args, **kwargs) + + self.fields['user'].choices = user_choices + self.fields['user'].initial = user_initial + + if user_field_hidden: + self.fields['user'].widget = forms.HiddenInput() + + def save(self, commit=True): + """ + Save this permission request. + + Because (user, datasource) are unique, if the user is changed we need to get the corresponding record. + """ + record, created = models.UserPermissionLink.objects.get_or_create( + user=self.instance.user, + datasource=self.instance.datasource + ) + + record.requested = self.instance.requested + record.push_requested = self.instance.push_requested + record.reason = self.instance.reason + + record.save() + + +class PermissionGrantForm(forms.ModelForm): + class Meta: + model = models.UserPermissionLink + 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 + exclude = ['owner'] diff --git a/datasources/management/__init__.py b/datasources/management/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/datasources/management/commands/__init__.py b/datasources/management/commands/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/datasources/management/commands/reset_api_count.py b/datasources/management/commands/reset_api_count.py new file mode 100644 index 0000000000000000000000000000000000000000..628ddf959de3c449fd022ab599e3f8706e52863a --- /dev/null +++ b/datasources/management/commands/reset_api_count.py @@ -0,0 +1,14 @@ +from django.core.management.base import BaseCommand + +from datasources.models import DataSource + + +class Command(BaseCommand): + help = 'Resets external API call count on all data sources' + + def handle(self, *args, **options): + for datasource in DataSource.objects.all(): + datasource.external_requests = 0 + datasource.save() + + self.stdout.write(self.style.SUCCESS('Successfully reset count for data source "%s"' % datasource.pk)) diff --git a/datasources/migrations/0006_one_to_one_group.py b/datasources/migrations/0006_one_to_one_group.py new file mode 100644 index 0000000000000000000000000000000000000000..e163ea68d7e76f22384c3726876b09fcfed7bc1d --- /dev/null +++ b/datasources/migrations/0006_one_to_one_group.py @@ -0,0 +1,24 @@ +# Generated by Django 2.0.8 on 2018-09-25 12:35 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('datasources', '0005_datasource_plugin_name'), + ] + + operations = [ + migrations.AlterField( + model_name='datasource', + name='users_group', + field=models.OneToOneField(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='auth.Group'), + ), + migrations.AlterField( + model_name='datasource', + name='users_group_requested', + field=models.OneToOneField(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='auth.Group'), + ), + ] diff --git a/datasources/migrations/0007_datasource_api_key.py b/datasources/migrations/0007_datasource_api_key.py new file mode 100644 index 0000000000000000000000000000000000000000..172c36aa6f0f69d8190eb1a1f9945be053800ee5 --- /dev/null +++ b/datasources/migrations/0007_datasource_api_key.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.8 on 2018-10-10 12:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('datasources', '0006_one_to_one_group'), + ] + + operations = [ + migrations.AddField( + model_name='datasource', + name='api_key', + field=models.CharField(blank=True, max_length=127), + ), + ] diff --git a/datasources/migrations/0008_datasource__connector_string.py b/datasources/migrations/0008_datasource__connector_string.py new file mode 100644 index 0000000000000000000000000000000000000000..4145862e2da597838922cb2576fd2c3304642eb5 --- /dev/null +++ b/datasources/migrations/0008_datasource__connector_string.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.8 on 2018-10-29 13:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('datasources', '0007_datasource_api_key'), + ] + + operations = [ + migrations.AddField( + model_name='datasource', + name='_connector_string', + field=models.CharField(blank=True, max_length=255), + ), + ] diff --git a/datasources/migrations/0009_unset_url_required.py b/datasources/migrations/0009_unset_url_required.py new file mode 100644 index 0000000000000000000000000000000000000000..b55e32aece0463922a8bcfc8b2f0b9eb6d1aad4d --- /dev/null +++ b/datasources/migrations/0009_unset_url_required.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.8 on 2018-10-29 13:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('datasources', '0008_datasource__connector_string'), + ] + + operations = [ + migrations.AlterField( + model_name='datasource', + name='url', + field=models.URLField(blank=True), + ), + ] diff --git a/datasources/migrations/0010_datasource_auth_method.py b/datasources/migrations/0010_datasource_auth_method.py new file mode 100644 index 0000000000000000000000000000000000000000..2d5b88e56c165b9d31359f46001b9f1aadcb2430 --- /dev/null +++ b/datasources/migrations/0010_datasource_auth_method.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.8 on 2018-10-31 14:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('datasources', '0009_unset_url_required'), + ] + + operations = [ + migrations.AddField( + model_name='datasource', + name='auth_method', + field=models.IntegerField(choices=[('NONE', -1), ('UNKNOWN', 0), ('BASIC', 1), ('HEADER', 2)], default=0, editable=False), + ), + ] diff --git a/datasources/migrations/0011_datasource_permissions_table.py b/datasources/migrations/0011_datasource_permissions_table.py new file mode 100644 index 0000000000000000000000000000000000000000..2e74f808122a0bb8129c717824f9dae6338f33fc --- /dev/null +++ b/datasources/migrations/0011_datasource_permissions_table.py @@ -0,0 +1,75 @@ +# Generated by Django 2.0.8 on 2018-11-06 15:29 + +import datasources.models +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +def datasource_permissions_forward(apps, schema_editor): + """ + Copy user permissions from old group-based form to new linking-table form. + """ + DataSource = apps.get_model('datasources', 'DataSource') + UserPermissionLink = apps.get_model('datasources', 'UserPermissionLink') + + for data_source in DataSource.objects.all(): + if data_source.access_control: + for user in data_source.users_group.user_set.all(): + UserPermissionLink.objects.get_or_create( + user=user, + datasource=data_source, + granted=datasources.models.UserPermissionLevels.VIEW + ) + + for user in data_source.users_group_requested.user_set.all(): + link, created = UserPermissionLink.objects.get_or_create( + user=user, + datasource=data_source, + ) + if not link.granted >= datasources.models.UserPermissionLevels.VIEW: + link.requested = datasources.models.UserPermissionLevels.VIEW + link.save() + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('datasources', '0010_datasource_auth_method'), + ] + + operations = [ + migrations.CreateModel( + name='UserPermissionLink', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('granted', models.IntegerField(choices=[('NONE', 0), ('VIEW', 1), ('META', 2), ('DATA', 3), ('PROV', 4)], default=datasources.models.UserPermissionLevels(0))), + ('requested', models.IntegerField(choices=[('NONE', 0), ('VIEW', 1), ('META', 2), ('DATA', 3), ('PROV', 4)], default=datasources.models.UserPermissionLevels(0))), + ], + ), + migrations.AddField( + model_name='userpermissionlink', + name='datasource', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='datasources.DataSource'), + ), + migrations.AddField( + model_name='userpermissionlink', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='datasource', + name='users', + field=models.ManyToManyField(through='datasources.UserPermissionLink', to=settings.AUTH_USER_MODEL), + ), + migrations.RunPython(datasource_permissions_forward), + migrations.RemoveField( + model_name='datasource', + name='users_group', + ), + migrations.RemoveField( + model_name='datasource', + name='users_group_requested', + ), + ] diff --git a/datasources/migrations/0012_add_request_reason.py b/datasources/migrations/0012_add_request_reason.py new file mode 100644 index 0000000000000000000000000000000000000000..b73cf3988311032ec3d31aa6979f8d92e84b05c9 --- /dev/null +++ b/datasources/migrations/0012_add_request_reason.py @@ -0,0 +1,29 @@ +# Generated by Django 2.0.8 on 2018-11-08 09:29 + +import datasources.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('datasources', '0011_datasource_permissions_table'), + ] + + operations = [ + migrations.AddField( + model_name='userpermissionlink', + name='reason', + field=models.CharField(blank=True, max_length=511), + ), + migrations.AlterField( + model_name='userpermissionlink', + name='granted', + field=models.IntegerField(choices=[(0, 'NONE'), (1, 'VIEW'), (2, 'META'), (3, 'DATA'), (4, 'PROV')], default=datasources.models.UserPermissionLevels(0)), + ), + migrations.AlterField( + model_name='userpermissionlink', + name='requested', + field=models.IntegerField(choices=[(0, 'NONE'), (1, 'VIEW'), (2, 'META'), (3, 'DATA'), (4, 'PROV')], default=datasources.models.UserPermissionLevels(0)), + ), + ] diff --git a/datasources/migrations/0013_is_encrypted.py b/datasources/migrations/0013_is_encrypted.py new file mode 100644 index 0000000000000000000000000000000000000000..5a5c6d19a449395e75911a0b246f6f935dee9391 --- /dev/null +++ b/datasources/migrations/0013_is_encrypted.py @@ -0,0 +1,23 @@ +# Generated by Django 2.0.8 on 2018-11-12 08:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('datasources', '0012_add_request_reason'), + ] + + operations = [ + migrations.AddField( + model_name='datasource', + name='is_encrypted', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='datasource', + name='encrypted_docs_url', + field=models.URLField(blank=True, verbose_name='Documentation URL for managing encrypted data'), + ), + ] diff --git a/datasources/migrations/0014_access_control_levels.py b/datasources/migrations/0014_access_control_levels.py new file mode 100644 index 0000000000000000000000000000000000000000..901fd89755d9dec9c7a6251ceedae22dfa0d5450 --- /dev/null +++ b/datasources/migrations/0014_access_control_levels.py @@ -0,0 +1,42 @@ +# Generated by Django 2.0.8 on 2018-11-16 13:43 + +import datasources.models +from django.db import migrations, models + + +def access_control_forward(apps, schema_editor): + DataSource = apps.get_model('datasources', 'DataSource') + + for data_source in DataSource.objects.all(): + if data_source.access_control: + data_source.public_permission_level = datasources.models.UserPermissionLevels.NONE + data_source.save() + + +def access_control_backward(apps, schema_editor): + DataSource = apps.get_model('datasources', 'DataSource') + + for data_source in DataSource.objects.all(): + if data_source.public_permission_level == datasources.models.UserPermissionLevels.NONE: + data_source.access_control = True + data_source.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('datasources', '0013_is_encrypted'), + ] + + operations = [ + migrations.AddField( + model_name='datasource', + name='public_permission_level', + field=models.IntegerField(choices=[(0, 'NONE'), (1, 'VIEW'), (2, 'META'), (3, 'DATA'), (4, 'PROV')], default=datasources.models.UserPermissionLevels(3)), + ), + migrations.RunPython(access_control_forward, access_control_backward), + migrations.RemoveField( + model_name='datasource', + name='access_control', + ), + ] diff --git a/datasources/migrations/0015_add_dynamic_metadata.py b/datasources/migrations/0015_add_dynamic_metadata.py new file mode 100644 index 0000000000000000000000000000000000000000..f82938fc1c4ecff9885b0df7b5cd58250ef8ae2f --- /dev/null +++ b/datasources/migrations/0015_add_dynamic_metadata.py @@ -0,0 +1,34 @@ +# Generated by Django 2.0.8 on 2018-12-07 11:03 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('datasources', '0014_access_control_levels'), + ] + + operations = [ + migrations.CreateModel( + name='MetadataField', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=63, unique=True)), + ], + ), + migrations.CreateModel( + name='MetadataItem', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('value', models.CharField(blank=True, max_length=511)), + ('datasource', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='metadata_items', to='datasources.DataSource')), + ('field', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='values', to='datasources.MetadataField')), + ], + ), + migrations.AlterUniqueTogether( + name='metadataitem', + unique_together={('field', 'datasource')}, + ), + ] diff --git a/datasources/migrations/0015_datasource_prov_exempt.py b/datasources/migrations/0015_datasource_prov_exempt.py new file mode 100644 index 0000000000000000000000000000000000000000..c5202aca1f24dea8e22296b5b65244b261dc1aa8 --- /dev/null +++ b/datasources/migrations/0015_datasource_prov_exempt.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.8 on 2018-12-07 11:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('datasources', '0014_access_control_levels'), + ] + + operations = [ + migrations.AddField( + model_name='datasource', + name='prov_exempt', + field=models.BooleanField(default=False, help_text='Should this data source be exempt from PROV tracking? This is useful for utility data sources which expect a large volume of queries, but are not interested in analysing usage patterns.'), + ), + ] diff --git a/datasources/migrations/0016_metadatafield_short_name.py b/datasources/migrations/0016_metadatafield_short_name.py new file mode 100644 index 0000000000000000000000000000000000000000..78132c2f37f5ac1a006105c9a6cd6c2179a69ce2 --- /dev/null +++ b/datasources/migrations/0016_metadatafield_short_name.py @@ -0,0 +1,20 @@ +# Generated by Django 2.0.8 on 2018-12-07 13:53 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('datasources', '0015_add_dynamic_metadata'), + ] + + operations = [ + migrations.AddField( + model_name='metadatafield', + name='short_name', + field=models.CharField(default='', max_length=63, unique=True, validators=[django.core.validators.RegexValidator('^[a-zA-Z][a-zA-Z0-9_]*\\Z', 'Short name must begin with a letter and consist only of letters, numbers and underscores.', 'invalid')]), + preserve_default=False, + ), + ] diff --git a/datasources/migrations/0016_prov_exempt_help_text.py b/datasources/migrations/0016_prov_exempt_help_text.py new file mode 100644 index 0000000000000000000000000000000000000000..05e114d89720f5c6d5def026365d1d143a4664b7 --- /dev/null +++ b/datasources/migrations/0016_prov_exempt_help_text.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.8 on 2018-12-07 11:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('datasources', '0015_datasource_prov_exempt'), + ] + + operations = [ + migrations.AlterField( + model_name='datasource', + name='prov_exempt', + field=models.BooleanField(default=False, help_text='Should this data source be exempt from PROV tracking? This is useful for utility data sources which expect a large volume of queries, but are not interested in analysing usage patterns. Note that this only disables tracking of data accesses, not of updates to the data source in PEDASI.'), + ), + ] diff --git a/datasources/migrations/0017_count_external_requests.py b/datasources/migrations/0017_count_external_requests.py new file mode 100644 index 0000000000000000000000000000000000000000..4a11b85b2fdb856cad4603c3cbde022c1c5b8c95 --- /dev/null +++ b/datasources/migrations/0017_count_external_requests.py @@ -0,0 +1,23 @@ +# Generated by Django 2.0.8 on 2018-12-13 09:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('datasources', '0016_prov_exempt_help_text'), + ] + + operations = [ + migrations.AddField( + model_name='datasource', + name='external_requests', + field=models.PositiveIntegerField(default=0, editable=False), + ), + migrations.AddField( + model_name='datasource', + name='external_requests_total', + field=models.PositiveIntegerField(default=0, editable=False), + ), + ] diff --git a/datasources/migrations/0018_editable_auth_method.py b/datasources/migrations/0018_editable_auth_method.py new file mode 100644 index 0000000000000000000000000000000000000000..f420d7e9b5ed45c9445198a14ee2e85dfd337afa --- /dev/null +++ b/datasources/migrations/0018_editable_auth_method.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.8 on 2018-12-19 11:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('datasources', '0017_count_external_requests'), + ] + + operations = [ + migrations.AlterField( + model_name='datasource', + name='auth_method', + field=models.IntegerField(choices=[('NONE', -1), ('UNKNOWN', 0), ('BASIC', 1), ('HEADER', 2)], default=0), + ), + ] diff --git a/datasources/migrations/0019_auth_method_swap_key_value.py b/datasources/migrations/0019_auth_method_swap_key_value.py new file mode 100644 index 0000000000000000000000000000000000000000..4d2ec8f15908dc006d2fd170e9218b10bbb425ae --- /dev/null +++ b/datasources/migrations/0019_auth_method_swap_key_value.py @@ -0,0 +1,19 @@ +# Generated by Django 2.0.8 on 2018-12-19 11:58 + +import datasources.connectors.base +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('datasources', '0018_editable_auth_method'), + ] + + operations = [ + migrations.AlterField( + model_name='datasource', + name='auth_method', + field=models.IntegerField(choices=[(-1, 'NONE'), (0, 'UNKNOWN'), (1, 'BASIC'), (2, 'HEADER')], default=datasources.connectors.base.AuthMethod(0)), + ), + ] diff --git a/datasources/migrations/0020_merge_20190107_1508.py b/datasources/migrations/0020_merge_20190107_1508.py new file mode 100644 index 0000000000000000000000000000000000000000..74af6a88e922a090de2188319915fc68f7869fbe --- /dev/null +++ b/datasources/migrations/0020_merge_20190107_1508.py @@ -0,0 +1,14 @@ +# Generated by Django 2.0.8 on 2019-01-07 15:08 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('datasources', '0019_auth_method_swap_key_value'), + ('datasources', '0016_metadatafield_short_name'), + ] + + operations = [ + ] diff --git a/datasources/migrations/0021_loosen_unique_metadata.py b/datasources/migrations/0021_loosen_unique_metadata.py new file mode 100644 index 0000000000000000000000000000000000000000..206b01fde47f9ab4ab9e37863511289f660423bd --- /dev/null +++ b/datasources/migrations/0021_loosen_unique_metadata.py @@ -0,0 +1,17 @@ +# Generated by Django 2.0.8 on 2019-01-10 16:27 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('datasources', '0020_merge_20190107_1508'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='metadataitem', + unique_together={('field', 'datasource', 'value')}, + ), + ] diff --git a/datasources/migrations/0022_datasource_is_deleted.py b/datasources/migrations/0022_datasource_is_deleted.py new file mode 100644 index 0000000000000000000000000000000000000000..7449f37a4ce798485dd1c342a5fc3c241170f88b --- /dev/null +++ b/datasources/migrations/0022_datasource_is_deleted.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.8 on 2019-01-11 14:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('datasources', '0021_loosen_unique_metadata'), + ] + + operations = [ + migrations.AddField( + model_name='datasource', + name='is_deleted', + field=models.BooleanField(default=False, editable=False), + ), + ] diff --git a/datasources/migrations/0023_relax_url_to_char.py b/datasources/migrations/0023_relax_url_to_char.py new file mode 100644 index 0000000000000000000000000000000000000000..c8ee9956bb34e967553192f939f4d00ebde84d90 --- /dev/null +++ b/datasources/migrations/0023_relax_url_to_char.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.8 on 2019-01-16 11:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('datasources', '0022_datasource_is_deleted'), + ] + + operations = [ + migrations.AlterField( + model_name='datasource', + name='url', + field=models.CharField(blank=True, max_length=255), + ), + ] diff --git a/datasources/migrations/0024_metadatafield_operational.py b/datasources/migrations/0024_metadatafield_operational.py new file mode 100644 index 0000000000000000000000000000000000000000..ab4950fc1c248640487adca251425914dbbddc6e --- /dev/null +++ b/datasources/migrations/0024_metadatafield_operational.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.8 on 2019-01-18 08:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('datasources', '0023_relax_url_to_char'), + ] + + operations = [ + migrations.AddField( + model_name='metadatafield', + name='operational', + field=models.BooleanField(default=False), + ), + ] diff --git a/datasources/migrations/0025_unique_together_permission_link.py b/datasources/migrations/0025_unique_together_permission_link.py new file mode 100644 index 0000000000000000000000000000000000000000..29ef5f1d35e5adad23a5deee68632e3f3d9ccacd --- /dev/null +++ b/datasources/migrations/0025_unique_together_permission_link.py @@ -0,0 +1,19 @@ +# Generated by Django 2.0.8 on 2019-01-25 11:27 + +from django.conf import settings +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('datasources', '0024_metadatafield_operational'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='userpermissionlink', + unique_together={('user', 'datasource')}, + ), + ] diff --git a/datasources/migrations/0026_userpermissionlink_push_data.py b/datasources/migrations/0026_userpermissionlink_push_data.py new file mode 100644 index 0000000000000000000000000000000000000000..0f3021bc4e0b5ccb430ab403fb8c26eb21b4bd6d --- /dev/null +++ b/datasources/migrations/0026_userpermissionlink_push_data.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.8 on 2019-01-25 14:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('datasources', '0025_unique_together_permission_link'), + ] + + operations = [ + migrations.AddField( + model_name='userpermissionlink', + name='push_data', + field=models.BooleanField(default=False), + ), + ] diff --git a/datasources/migrations/0027_add_push_granted.py b/datasources/migrations/0027_add_push_granted.py new file mode 100644 index 0000000000000000000000000000000000000000..74411a94290d0d8e1ea0b975f24824534607440c --- /dev/null +++ b/datasources/migrations/0027_add_push_granted.py @@ -0,0 +1,23 @@ +# Generated by Django 2.0.8 on 2019-01-25 14:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('datasources', '0026_userpermissionlink_push_data'), + ] + + operations = [ + migrations.RenameField( + model_name='userpermissionlink', + old_name='push_data', + new_name='push_requested', + ), + migrations.AddField( + model_name='userpermissionlink', + name='push_granted', + field=models.BooleanField(default=False), + ), + ] diff --git a/datasources/migrations/0028_add_licence_model.py b/datasources/migrations/0028_add_licence_model.py new file mode 100644 index 0000000000000000000000000000000000000000..42c40f54479a635834af824bf7f18aa9402fb617 --- /dev/null +++ b/datasources/migrations/0028_add_licence_model.py @@ -0,0 +1,33 @@ +# Generated by Django 2.0.8 on 2019-01-30 12:49 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('datasources', '0027_add_push_granted'), + ] + + operations = [ + migrations.CreateModel( + name='License', + 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)), + ('version', models.CharField(max_length=63)), + ('url', models.CharField(blank=True, max_length=255)), + ], + ), + migrations.AlterUniqueTogether( + name='license', + unique_together={('name', 'version')}, + ), + migrations.AddField( + model_name='datasource', + name='license', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='datasources', to='datasources.License'), + ), + ] diff --git a/datasources/migrations/0029_license_owner.py b/datasources/migrations/0029_license_owner.py new file mode 100644 index 0000000000000000000000000000000000000000..08ad7fe7701936df62b1ce44a17a3551653041f3 --- /dev/null +++ b/datasources/migrations/0029_license_owner.py @@ -0,0 +1,22 @@ +# Generated by Django 2.0.8 on 2019-01-30 13:53 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('datasources', '0028_add_licence_model'), + ] + + operations = [ + migrations.AddField( + model_name='license', + name='owner', + field=models.ForeignKey(default=1, limit_choices_to={'groups__name': 'Data providers'}, on_delete=django.db.models.deletion.PROTECT, related_name='licences', to=settings.AUTH_USER_MODEL), + preserve_default=False, + ), + ] diff --git a/datasources/migrations/0030_rename_licence.py b/datasources/migrations/0030_rename_licence.py new file mode 100644 index 0000000000000000000000000000000000000000..3429e6e62ad12e532a55b530d98275dadf05a499 --- /dev/null +++ b/datasources/migrations/0030_rename_licence.py @@ -0,0 +1,24 @@ +# Generated by Django 2.0.8 on 2019-01-30 14:29 + +from django.conf import settings +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('datasources', '0029_license_owner'), + ] + + operations = [ + migrations.RenameModel( + old_name='License', + new_name='Licence', + ), + migrations.RenameField( + model_name='datasource', + old_name='license', + new_name='licence', + ), + ] diff --git a/datasources/models.py b/datasources/models.py index 7681ccea7c2f52b6d5a7bcbaef56e7db495505b0..c95627b513885a0ce2149e2e135bac3c5d871c4e 100644 --- a/datasources/models.py +++ b/datasources/models.py @@ -1,10 +1,186 @@ +import contextlib +import enum +import json +import typing + from django.conf import settings from django.contrib.auth.models import Group +from django.core import validators from django.db import models from django.urls import reverse +import requests +import requests.exceptions + +from core.models import BaseAppDataModel, MAX_LENGTH_API_KEY, MAX_LENGTH_NAME, MAX_LENGTH_PATH, SoftDeletionManager +from datasources.connectors.base import AuthMethod, BaseDataConnector, REQUEST_AUTH_FUNCTIONS + +#: Length of request reason field - must include brief description of project +MAX_LENGTH_REASON = 511 + + +class Licence(models.Model): + """ + Model representing a licence under which a data source is published e.g. Open Government Licence. + """ + + #: User who has responsibility for this licence + owner = models.ForeignKey(settings.AUTH_USER_MODEL, + limit_choices_to={ + 'groups__name': 'Data providers' + }, + on_delete=models.PROTECT, + related_name='licences', + blank=False, null=False) + + #: Name of the licence - e.g. Open Government License + name = models.CharField(max_length=MAX_LENGTH_NAME, + blank=False, null=False) + + #: Short text identifier - e.g. OGL + short_name = models.CharField(max_length=MAX_LENGTH_NAME, + blank=True, null=False) + + #: Licence version - e.g. v2.0 + version = models.CharField(max_length=MAX_LENGTH_NAME, + blank=False, null=False) -from datasources.connectors.base import BaseDataConnector -from pedasi.common.base_models import BaseAppDataModel, MAX_LENGTH_NAME + #: Address at which the licence text may be accessed + url = models.CharField(max_length=MAX_LENGTH_PATH, + blank=True, null=False) + + class Meta: + unique_together = (('name', 'version'),) + + def __str__(self): + return self.name + + def get_absolute_url(self): + 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( + '^[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) + + # TODO create all operational fields if missing + #: Does the field have an operational effect within PEDASI? + operational = models.BooleanField(default=False, + blank=False, null=False) + + def __str__(self): + return self.name + + +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): + """ + User permission levels on data sources. + """ + #: No permissions + NONE = 0 + + #: Permission to view in PEDASI UI + VIEW = 1 + + #: Permission to query metadata via API / UI + META = 2 + + #: Permission to query data via API / UI + DATA = 3 + + #: Permission to query PROV via API / UI + PROV = 4 + + @classmethod + def choices(cls): + return tuple((i.value, i.name) for i in cls) + + +class UserPermissionLink(models.Model): + """ + Model to act as a many to many joining table to handle user permission levels for access to data sources. + """ + #: User being managed + user = models.ForeignKey(settings.AUTH_USER_MODEL, + on_delete=models.CASCADE) + + #: Data source on which the permissions are being granted + datasource = models.ForeignKey('DataSource', + on_delete=models.CASCADE) + + #: Granted permission level + granted = models.IntegerField(choices=UserPermissionLevels.choices(), + default=UserPermissionLevels.NONE, + blank=False, null=False) + + #: Requested permission level + requested = models.IntegerField(choices=UserPermissionLevels.choices(), + default=UserPermissionLevels.NONE, + blank=False, null=False) + + #: Have permission to push data? + push_granted = models.BooleanField(default=False) + + #: Also require permission to push data? + push_requested = models.BooleanField(default=False) + + #: Reason the permission was requested + reason = models.CharField(max_length=MAX_LENGTH_REASON, + blank=True, null=False) + + class Meta: + unique_together = (('user', 'datasource'),) class DataSource(BaseAppDataModel): @@ -18,7 +194,12 @@ class DataSource(BaseAppDataModel): * Track provenance of the data source itself * Track provenance of data accesses """ - # TODO replace this with an admin group + objects = SoftDeletionManager() + + #: Address at which the API may be accessed + url = models.CharField(max_length=MAX_LENGTH_PATH, + blank=True, null=False) + #: User who has responsibility for this data source owner = models.ForeignKey(settings.AUTH_USER_MODEL, limit_choices_to={ @@ -28,66 +209,238 @@ class DataSource(BaseAppDataModel): related_name='datasources', blank=False, null=False) - #: Group of users who have explicit permission to use (query) this data source - users_group = models.ForeignKey(Group, - on_delete=models.SET_NULL, - related_name='datasource', - editable=False, - blank=True, null=True) - - #: Groups of users who have requested explicit permission to use this data source - users_group_requested = models.ForeignKey(Group, - on_delete=models.SET_NULL, - related_name='datasource_requested', - editable=False, - blank=True, null=True) - - #: Do users require explicit permission to use this data source? - access_control = models.BooleanField(default=False, - blank=False, null=False) + #: Information required to initialise the connector for this data source + _connector_string = models.CharField(max_length=MAX_LENGTH_PATH, + blank=True, null=False) #: Name of plugin which allows interaction with this data source plugin_name = models.CharField(max_length=MAX_LENGTH_NAME, blank=False, null=False) + #: If the data source API requires an API key use this one + api_key = models.CharField(max_length=MAX_LENGTH_API_KEY, + blank=True, null=False) + + #: Contains encrypted data? + is_encrypted = models.BooleanField(default=False, + blank=False, null=False) + + #: Where to find information about how to use this encrypted data + encrypted_docs_url = models.URLField('Documentation URL for managing encrypted data', + blank=True, null=False) + + #: Which authentication method to use - defined in :class:`datasources.connectors.base.AuthMethod` enum + auth_method = models.IntegerField(choices=AuthMethod.choices(), + default=AuthMethod.UNKNOWN, + blank=False, null=False) + + #: Users - linked via a permission table - see :class:`UserPermissionLink` + users = models.ManyToManyField(settings.AUTH_USER_MODEL, + through=UserPermissionLink) + + #: The level of access that users are assumed to have without gaining explicit permission + public_permission_level = models.IntegerField(choices=UserPermissionLevels.choices(), + default=UserPermissionLevels.DATA.value, + blank=False, null=False) + + #: Is this data source exempt from PROV tracking - e.g. utility data sources - postcode lookup + prov_exempt = models.BooleanField(default=False, + help_text=( + 'Should this data source be exempt from PROV tracking? ' + 'This is useful for utility data sources which expect a large volume ' + 'of queries, but are not interested in analysing usage patterns. ' + 'Note that this only disables tracking of data accesses, ' + 'not of updates to the data source in PEDASI.' + ), + blank=False, null=False) + + #: Which licence is this data published under + licence = models.ForeignKey(Licence, + related_name='datasources', + on_delete=models.PROTECT, + blank=True, null=True) + + #: Total number of requests sent to the external API + external_requests_total = models.PositiveIntegerField(default=0, + editable=False, blank=False, null=False) + + #: Number of requests sent to the external API since the last reset - reset at midnight by cron job + external_requests = models.PositiveIntegerField(default=0, + editable=False, blank=False, null=False) + + #: Has this object been soft deleted? + is_deleted = models.BooleanField(default=False, + editable=False, blank=False, null=False) + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._data_connector = None - @property - def data_connector(self): - if self._data_connector is None: - BaseDataConnector.load_plugins('datasources/connectors') - plugin = BaseDataConnector.get_plugin(self.plugin_name) - self._data_connector = plugin(self.url) - - return self._data_connector + def delete(self, using=None, keep_parents=False): + """ + Soft delete this object. + """ + self.is_deleted = True + self.save() def has_view_permission(self, user: settings.AUTH_USER_MODEL) -> bool: """ - Does a user have permission to use this data source? + Does a user have permission to view this data source in the PEDASI UI? :param user: User to check :return: User has permission? """ - if not self.access_control: - return True - if self.owner == user: + return self.has_permission_level(user, UserPermissionLevels.VIEW) + + def has_permission_level(self, user: settings.AUTH_USER_MODEL, level: UserPermissionLevels) -> bool: + """ + Does a user have a particular permission level on this data source? + + :param user: User to check + :param level: Permission level to check for + :return: User has permission? + """ + if self.public_permission_level >= level: + # Everyone has access return True - return self.users_group.user_set.filter(pk=user.pk).exists() + if self.owner == user or user.is_superuser: + return True - def save(self, **kwargs): - if self.access_control: - # Create access control groups if they do not exist - self.users_group, created = Group.objects.get_or_create( - name=self.name + ' Users' - ) - self.users_group_requested, created = Group.objects.get_or_create( - name=self.name + ' Users Requested' + try: + permission = UserPermissionLink.objects.get( + user=user, + datasource=self ) + except (UserPermissionLink.DoesNotExist, TypeError): + # TypeError - user is not logged in + return False + + return permission.granted >= level + + def has_edit_permission(self, user: settings.AUTH_USER_MODEL) -> bool: + """ + Does a given user have permission to edit this data source? + + :param user: User to check + :return: User has permission to edit? + """ + return user.is_superuser or user == self.owner + + @property + def is_catalogue(self) -> bool: + return self.data_connector_class.is_catalogue + + @property + def connector_string(self): + if self._connector_string: + return self._connector_string + return self.url + + @property + def data_connector_class(self) -> typing.Type[BaseDataConnector]: + """ + Get the data connector class for this source. + + :return: Data connector class + """ + BaseDataConnector.load_plugins('datasources/connectors') + + try: + plugin = BaseDataConnector.get_plugin(self.plugin_name) + + except KeyError as e: + if not self.plugin_name: + raise ValueError('Data source plugin is not set') from e + + raise KeyError('Data source plugin not found') from e + + return plugin + + @property + @contextlib.contextmanager + def data_connector(self) -> BaseDataConnector: + """ + Context manager to construct the data connector for this source. + + :return: Data connector instance + """ + if self._data_connector is None: + plugin = self.data_connector_class + + if not self.api_key: + self._data_connector = plugin(self.connector_string) + + else: + # Is the authentication method set? + auth_method = AuthMethod(self.auth_method) + if not auth_method: + auth_method = self.determine_auth_method(self.url, self.api_key) + + # Inject function to get authenticated request + auth_class = REQUEST_AUTH_FUNCTIONS[auth_method] + + self._data_connector = plugin(self.connector_string, self.api_key, + auth=auth_class) + + try: + # Returns as context manager + yield self._data_connector + + finally: + # Executed after the context manager is closed + self.external_requests += self._data_connector.request_count + self.external_requests_total += self._data_connector.request_count + + self.save() + + @property + def search_representation(self) -> str: + lines = [ + self.name, + self.owner.get_full_name(), + self.description, + ] + + try: + lines.append(json.dumps( + self.data_connector.get_metadata(), + indent=4 + )) + + except: + # KeyError: Plugin was not found + # NotImplementedError: Plugin does not support metadata + # ValueError: Plugin was not set + pass + + result = '\n'.join(lines) + return result + + @staticmethod + def determine_auth_method(url: str, api_key: str) -> AuthMethod: + # If not using an API key - can't require auth + if not api_key: + return AuthMethod.NONE + + for auth_method_id, auth_function in REQUEST_AUTH_FUNCTIONS.items(): + try: + # Can we get a response using this auth method? + if auth_function is None: + response = requests.get(url) + + else: + response = requests.get(url, + auth=auth_function(api_key, '')) + + response.raise_for_status() + return auth_method_id + + except requests.exceptions.HTTPError: + pass - super().save(**kwargs) + # None of the attempted authentication methods was successful + raise requests.exceptions.ConnectionError('Could not authenticate against external API') def get_absolute_url(self): return reverse('datasources:datasource.detail', diff --git a/datasources/permissions.py b/datasources/permissions.py new file mode 100644 index 0000000000000000000000000000000000000000..0d5e36966b103f3c703d95d974b8a7707f65927d --- /dev/null +++ b/datasources/permissions.py @@ -0,0 +1,12 @@ +from django.contrib.auth.mixins import UserPassesTestMixin + + +class HasPermissionLevelMixin(UserPassesTestMixin): + """ + Mixin to reject users who do not have permission to view this DataSource. + """ + #: Required permission level from datasources.models.UserPermissionLevels + permission_level = None + + def test_func(self) -> bool: + return self.get_object().has_permission_level(self.request.user, self.permission_level) diff --git a/datasources/search_indexes.py b/datasources/search_indexes.py new file mode 100644 index 0000000000000000000000000000000000000000..bff3740e06661e11ae3006ce3afe160ebf23ebcd --- /dev/null +++ b/datasources/search_indexes.py @@ -0,0 +1,10 @@ +from haystack import indexes + +from . import models + + +class DataSourceIndex(indexes.SearchIndex, indexes.Indexable): + text = indexes.CharField(document=True, use_template=True) + + def get_model(self): + return models.DataSource diff --git a/datasources/serializers.py b/datasources/serializers.py new file mode 100644 index 0000000000000000000000000000000000000000..232ecd4725377d62613a487c429cd879db21d5e3 --- /dev/null +++ b/datasources/serializers.py @@ -0,0 +1,42 @@ +from rest_framework import serializers + +from . import models + + +class LicenceSerializer(serializers.ModelSerializer): + class Meta: + model = models.Licence + fields = ['name', 'short_name', 'version', 'url'] + + +class MetadataFieldSerializer(serializers.ModelSerializer): + class Meta: + model = models.MetadataField + fields = ['name', 'short_name'] + + +class MetadataItemSerializer(serializers.ModelSerializer): + field = MetadataFieldSerializer(read_only=True) + + class Meta: + model = models.MetadataItem + fields = ['field', 'value'] + + +class DataSourceSerializer(serializers.ModelSerializer): + metadata_items = MetadataItemSerializer(many=True, read_only=True) + licence = LicenceSerializer(many=False, read_only=True) + + class Meta: + model = models.DataSource + fields = [ + 'id', + 'name', + 'description', + 'url', + 'plugin_name', + 'licence', + 'is_encrypted', + 'encrypted_docs_url', + 'metadata_items' + ] diff --git a/datasources/static/js/explorer.js b/datasources/static/js/explorer.js new file mode 100644 index 0000000000000000000000000000000000000000..030a102b22a147faaf2670cae9dec4fa9531afc0 --- /dev/null +++ b/datasources/static/js/explorer.js @@ -0,0 +1,312 @@ +"use strict"; + +const params = new Map(); + +let datasourceUrl = null; +let selectedDataset = null; + + +/** + * Set the root URL at which the data source may be accessed in the PEDASI API. + * + * @param url PEDASI API data source URL + */ +function setDatasourceUrl(url) { + "use strict"; + datasourceUrl = url; +} + + +/** + * Append a row of text cells to a table. + * + * @param table Table to which row should be appended + * @param values Iterable of strings to append as new cells + */ +function tableAppendRow(table, values) { + "use strict"; + const row = table.insertRow(-1); + + for (const value of values) { + const cell = row.insertCell(-1); + const text = document.createTextNode(value); + cell.appendChild(text); + } + + return row; +} + + +/** + * Get the base URL to use for requests to PEDASI API. + * + * @returns {string} PEDASI API URL + */ +function getBaseURL() { + "use strict"; + let url = datasourceUrl; + + if (selectedDataset !== null) { + url += "datasets/" + selectedDataset + "/"; + } + + return url; +} + + +/** + * Render request query parameters into table. + */ +function renderParams() { + "use strict"; + const table = document.getElementById("tableParams"); + + /* Clear the table */ + while (table.rows.length > 1) { + table.deleteRow(-1); + } + + params.forEach(function (value, key, map) { + tableAppendRow(table, [key, value]); + }); +} + + +/** + * Get the query params as a URL query string. + * + * @returns {string} Query param string + */ +function getQueryParamString() { + "use strict"; + const encodedParams = new URLSearchParams(); + params.forEach(function (value, key, map) { + encodedParams.set(key.trim(), value.trim()); + }); + + return encodedParams.toString(); +} + + +/** + * Display query param string in #queryParamSpan. + */ +function displayParams() { + "use strict"; + const paramString = getQueryParamString(); + const span = document.getElementById("queryParamSpan"); + + span.textContent = paramString; +} + + +/** + * Add a query parameter to the API query. + * + * @returns {boolean} return false to halt form processing + */ +function addParam() { + "use strict"; + const param = document.getElementById("inputParam").value; + const value = document.getElementById("inputValue").value; + + params.set(param, value); + + renderParams(); + displayParams(); + + return false; +} + + +/** + * Submit the prepared query to the PEDASI API and render the result into the 'results' panel. + */ +function submitQuery() { + "use strict"; + const results = document.getElementById("queryResults"); + const query = getBaseURL() + "data/?" + getQueryParamString(); + + fetch(query).then(function (response) { + const contentType = response.headers.get("content-type"); + if (contentType && contentType.indexOf("application/json") !== -1) { + return response.json(); + } else { + return response.text(); + } + }).then(function (data) { + if (typeof data === "string") { + results.textContent = data; + } else { + results.textContent = JSON.stringify(data, null, 4); + } + }); +} + + +/** + * Query the PEDASI API for data source internal metadata and render it into the 'metadataInternal' panel. + */ +function populateMetadataInternal() { + "use strict"; + + const table = document.getElementById("metadataInternal"); + + const url = getBaseURL(); + + fetch(url).then(function (response) { + if (response.ok) { + return response.json(); + } + throw new Error("Internal metadata request failed."); + }).then(function (data) { + data.metadata_items.forEach(function (item) { + tableAppendRow(table, [item.field.name, item.value]); + }); + }).catch(function (e) { + tableAppendRow(table, e.toString()); + }); +} + + +/** + * Query the PEDASI API for data source metadata and render it into the 'metadata' panel. + */ +function populateMetadata() { + "use strict"; + + const table = document.getElementById("metadata"); + + /* Clear the table */ + while (table.rows.length > 0) { + table.deleteRow(-1); + } + + const url = getBaseURL() + "metadata/"; + + fetch(url).then( + response => response.json() + ).then(function (data) { + if (data.status === "success") { + try{ + data.data.forEach(function (item) { + tableAppendRow(table, [JSON.stringify(item, null, 4)]); + }); + } catch (e) { + for (const key in data.data) { + tableAppendRow(table, [ + key, + JSON.stringify(data.data[key], null, 4) + ]); + } + } + } else if (data.message) { + tableAppendRow(table, [data.message]); + } + }).catch(function (e) { + tableAppendRow(table, [e.toString()]); + }); +} + + +/** + * Query the PEDASI API for the list of data sets within the data source and render into the 'datasets' panel. + */ +function populateDatasets() { + "use strict"; + function rowAppendButton (row, item) { + const buttonCell = row.insertCell(-1); + const button = document.createElement("button"); + + button.id = "btn-" + item; + button.classList.add("btn", "btn-secondary", "btn-dataset"); + button.addEventListener( + "click", + function () { + selectDataset(item); + } + ); + button.textContent = "Select"; + buttonCell.appendChild(button); + row.appendChild(buttonCell); + } + + const url = getBaseURL() + "datasets/"; + const table = document.getElementById("datasets"); + + fetch(url).then( + response => response.json() + ).then(function (data) { + if (data.status === "success") { + const row = tableAppendRow(table, ["Root"]); + rowAppendButton(row, null); + + data.data.forEach(function (item) { + const row = tableAppendRow(table, [item]); + rowAppendButton(row, item) + }); + } else if (data.message) { + tableAppendRow(table, [data.message]); + } + }).catch(function (e) { + tableAppendRow(table, [e.toString()]); + }); +} + + +/** + * Change the active dataset. + * + * @param datasetId Dataset id to select + */ +function selectDataset(datasetId) { + "use strict"; + selectedDataset = datasetId; + document.getElementById("selectedDataset").textContent = selectedDataset === null ? "None" : selectedDataset; + + const urlComponentSpan = document.getElementById("datasetUrlSpan"); + if (selectedDataset == null) { + urlComponentSpan.textContent = ""; + } else { + urlComponentSpan.textContent = "datasets/" + selectedDataset + "/"; + } + + populateMetadata(); + + // Have to use for ... of ... loop since collection is live (updates with changes to DOM) + for (const button of document.getElementsByClassName("btn-dataset")) { + button.removeAttribute("disabled"); + button.textContent = "Select"; + } + + const button = document.getElementById("btn-" + selectedDataset); + button.textContent = "Selected"; + button.setAttribute("disabled", "true"); +} + + +function toggleExpandPanel(e) { + "use strict"; + const button = e.target; + const panel = document.querySelector(button.dataset.target); + + if (panel === null) { + // Happens in MS Edge when a span receives the click event before the button it belongs to + return; + } + + const iconPlus = button.querySelector(".toggle-icon-plus"); + const iconMinus = button.querySelector(".toggle-icon-minus"); + + if ("expanded" in panel.dataset) { + panel.style.height = "30vh"; + iconPlus.style.display = "inline"; + iconMinus.style.display = "none"; + delete panel.dataset.expanded; + } else { + panel.style.height = "100%"; + iconPlus.style.display = "none"; + iconMinus.style.display = "inline"; + panel.dataset.expanded = "true"; + } +} diff --git a/datasources/static/js/metadata.js b/datasources/static/js/metadata.js new file mode 100644 index 0000000000000000000000000000000000000000..e05914501082d1d28d7b232df888db58053968c2 --- /dev/null +++ b/datasources/static/js/metadata.js @@ -0,0 +1,121 @@ +"use strict"; + +let metadataUrl = null; + + +/** + * Set the root URL at which the data source may be accessed in the PEDASI API. + * + * @param url PEDASI API data source URL + */ +function setMetadataUrl(url) { + "use strict"; + metadataUrl = url; +} + + +function getCookie(name) { + const re = new RegExp("(^| )" + name + "=([^;]+)"); + const match = document.cookie.match(re); + if (match) { + return match[2]; + } +} + + +function tableAppendRow(table, values, id=null) { + const row = table.insertRow(-1); + if (id !== null) { + row.id = id; + } + + for (const value of values) { + const cell = row.insertCell(-1); + const text = document.createTextNode(value); + cell.appendChild(text); + } + + return row; +} + + +function postMetadata() { + fetch( + metadataUrl, + { + method: "POST", + body: JSON.stringify({ + field: document.getElementById("id_field").value, + value: document.getElementById("id_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 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); + } + } + ).catch( + function (e) { + console.log(e); + } + ) +} + +function deleteMetadata(field, value) { + fetch( + metadataUrl, + { + 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); + } + + }) +} diff --git a/datasources/templates/datasources/datasource/create.html b/datasources/templates/datasources/datasource/create.html new file mode 100644 index 0000000000000000000000000000000000000000..682d9cb005d954f1d451359871fc6b926626e770 --- /dev/null +++ b/datasources/templates/datasources/datasource/create.html @@ -0,0 +1,68 @@ +{% extends "base.html" %} +{% load bootstrap4 %} + +{% block content %} + <nav aria-label="breadcrumb"> + <ol class="breadcrumb"> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'index' %}">Home</a> + </li> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'datasources:datasource.list' %}">Data Sources</a> + </li> + <li class="breadcrumb-item active" aria-current="page"> + New Data Source + </li> + </ol> + </nav> + + <h2 class="pb-3">New Data Source</h2> + + <form class="form" method="post" action=""> + {% csrf_token %} + + {% bootstrap_field form.name %} + {% bootstrap_field form.description %} + {% bootstrap_field form.url %} + {% bootstrap_field form.api_key %} + {% bootstrap_field form.plugin_name %} + {% bootstrap_field form.licence %} + + <hr> + + <div class="row"> + <div class="col-sm-3"> + {% bootstrap_field form.is_encrypted %} + </div> + + <div class="col-sm-9"> + <div id="encryptedDocsToggle"> + {% bootstrap_field form.encrypted_docs_url %} + </div> + </div> + + <script type="application/javascript"> + const checkboxEncrypted = document.getElementById("id_is_encrypted"); + const divEncryptedToggle = document.getElementById("encryptedDocsToggle"); + + function setupEncryptedToggle() { + if (checkboxEncrypted.checked) { + divEncryptedToggle.style.display = "block"; + } else { + divEncryptedToggle.style.display = "none"; + } + } + + checkboxEncrypted.addEventListener("change", setupEncryptedToggle); + + setupEncryptedToggle(); + </script> + </div> + + {% bootstrap_field form.public_permission_level %} + {% bootstrap_field form.prov_exempt %} + + <input type="submit" class="btn btn-success" value="Create"> + </form> + +{% endblock %} diff --git a/datasources/templates/datasources/datasource/dataset_search.html b/datasources/templates/datasources/datasource/dataset_search.html new file mode 100644 index 0000000000000000000000000000000000000000..d7df90065980b6f69fbc3b4f04080d85d4dacc99 --- /dev/null +++ b/datasources/templates/datasources/datasource/dataset_search.html @@ -0,0 +1,42 @@ +{% for dataset_uri, dataset in datasets %} + <div class="col-md-12 p-2"> + <a href="#" + class="no-hover" role="button"> + + <div class="card rounded-0 h-100"> + <div class="card-body d-flex flex-column"> + <h5 class="card-title">{{ dataset_uri }}</h5> + + <div class="card-text"> + <table class="table"> + <thead> + <th scope="col" class="col-md-3 border-0"></th> + <th scope="col" class="border-0"></th> + </thead> + + <tbody> + {% if metadata_type == 'list' %} + {% for meta in dataset.get_metadata %} + <tr> + <td>{{ meta.rel }}</td> + <td>{{ meta.val }}</td> + </tr> + {% endfor %} + {% else %} + {% for k, v in dataset.get_metadata.items %} + <tr> + <td>{{ k }}</td> + <td>{{ v }}</td> + </tr> + {% endfor %} + {% endif %} + </tbody> + </table> + </div> + </div> + </div> + + </a> + </div> +{% empty %} +{% endfor %} diff --git a/datasources/templates/datasources/datasource/delete.html b/datasources/templates/datasources/datasource/delete.html new file mode 100644 index 0000000000000000000000000000000000000000..42778a993bd9012488ee977de8a03fddf6037f29 --- /dev/null +++ b/datasources/templates/datasources/datasource/delete.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} +{% load bootstrap4 %} + +{% block content %} + <nav aria-label="breadcrumb"> + <ol class="breadcrumb"> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'index' %}">Home</a> + </li> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'datasources:datasource.list' %}">Data Sources</a> + </li> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'datasources:datasource.detail' pk=datasource.pk %}">{{ datasource.name }}</a> + </li> + <li class="breadcrumb-item active" aria-current="page"> + Delete + </li> + </ol> + </nav> + + <div class="row"> + <div class="col-md-10 col-sm-8"> + <h2>{{ datasource.name }}</h2> + + {% if datasource.description %} + {{ datasource.description|linebreaks }} + {% endif %} + </div> + </div> + + <table class="table"> + <thead> + <th scope="col" class="w-25 border-0"></th> + <th scope="col" class="border-0"></th> + </thead> + + <tbody> + <tr> + <td>Owner</td> + <td> + {{ datasource.owner }} + </td> + </tr> + <tr> + <td>URL</td> + <td>{{ datasource.url }}</td> + </tr> + </tbody> + </table> + + <div class="alert alert-danger"> + <p><b>Are you sure you want to delete this data source?</b></p> + + <form class="form" method="post"> + {% csrf_token %} + + <input type="submit" role="button" class="btn btn-danger" value="Delete"> + + <a role="button" class="btn btn-info" + href="{% url 'datasources:datasource.detail' pk=datasource.pk %}">Cancel</a> + </form> + </div> +{% endblock %} \ No newline at end of file diff --git a/datasources/templates/datasources/datasource/detail-no-access.html b/datasources/templates/datasources/datasource/detail-no-access.html index bcd8b6c4737bca90aa161c9401581a4359db878e..cc993c4cc89ebb24939725b6c20e7d0be5298bb9 100644 --- a/datasources/templates/datasources/datasource/detail-no-access.html +++ b/datasources/templates/datasources/datasource/detail-no-access.html @@ -1,6 +1,10 @@ {% extends "base.html" %} {% load bootstrap4 %} +{% block extra_head %} + <script src="https://cdn.jsdelivr.net/npm/js-cookie@2.2.0/src/js.cookie.min.js"></script> +{% endblock %} + {% block content %} <nav aria-label="breadcrumb"> <ol class="breadcrumb"> @@ -16,17 +20,71 @@ </ol> </nav> - <h2>View Data Source - {{ datasource.name }}</h2> + + <div class="row"> + <div class="col-md-10 col-sm-8"> + <h2> + {{ datasource.name }} + {% if datasource.licence %} + <small> + <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> + <span class="badge badge-warning" + data-toggle="tooltip" data-placement="bottom" title="No Licence"> + No Licence + </span> + </small> + {% endif %} + </h2> + + {% if datasource.description %} + {{ datasource.description|linebreaks }} + {% endif %} + </div> + + <div class="col-md-2 col-sm-4"> + {% if request.user != datasource.owner and not request.user.is_superuser %} + <a href="{% url 'datasources:datasource.access.request' pk=datasource.id %}" + class="btn btn-block btn-secondary" role="button">Manage My Access</a> + {% endif %} + </div> + </div> <div class="alert alert-warning"> You do not have permission to access this resource. - <a href="#" - class="btn btn-primary" role="button">Request Access</a> + {% if request.user in datasource.users.all %} + Please wait for your request to be approved. + + {% else %} + You may request access using the 'Manage My Access' button. + {% endif %} </div> - {% if datasource.description %} - <p>{{ datasource.description }}</p> - {% endif %} + <table class="table"> + <thead> + <th scope="col" class="w-25 border-0"></th> + <th scope="col" class="border-0"></th> + </thead> + + <tbody> + <tr> + <td>Owner</td> + <td> + {{ datasource.owner }} + </td> + </tr> + <tr> + <td>URL</td> + <td>{{ datasource.url }}</td> + </tr> + </tbody> + </table> {% endblock %} \ No newline at end of file diff --git a/datasources/templates/datasources/datasource/detail.html b/datasources/templates/datasources/datasource/detail.html index 4f258ba87a2717db0ca0231d725bdbc9f8997f57..67152ed7bf0d97367a3d78e7d2d64636e39c01e5 100644 --- a/datasources/templates/datasources/datasource/detail.html +++ b/datasources/templates/datasources/datasource/detail.html @@ -1,6 +1,18 @@ {% extends "base.html" %} {% load bootstrap4 %} +{% block extra_head %} + {% if metadata_field_form %} + {% load static %} + + <script type="application/javascript" src="{% static 'js/metadata.js' %}"></script> + + <script type="application/javascript"> + setMetadataUrl("/datasources/{{ datasource.pk }}/metadata"); + </script> + {% endif %} +{% endblock %} + {% block content %} <nav aria-label="breadcrumb"> <ol class="breadcrumb"> @@ -16,28 +28,204 @@ </ol> </nav> - <h2>View Data Source - {{ datasource.name }}</h2> + <div class="row"> + <div class="col-md-10 col-sm-8"> + <h2> + {{ datasource.name }} + {# TODO make this a template tag #} + {% if datasource.licence %} + <small> + <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> + <span class="badge badge-warning" + data-toggle="tooltip" data-placement="bottom" title="No Licence"> + No Licence + </span> + </small> + {% endif %} + </h2> + + {% if datasource.description %} + {{ datasource.description|linebreaks }} + {% endif %} + </div> + + <div class="col-md-2 col-sm-4"> + <a href="{% url 'datasources:datasource.explorer' pk=datasource.id %}" + class="btn btn-block btn-info" role="button">API Explorer</a> + + {% if request.user != datasource.owner and not request.user.is_superuser %} + <a href="{% url 'datasources:datasource.access.request' pk=datasource.id %}" + class="btn btn-block btn-secondary" role="button">Manage My Access</a> + {% endif %} - <p> - Owner: <a href="#" role="link">{{ datasource.owner }}</a> - </p> + {% if has_edit_permission %} + <a href="{% url 'datasources:datasource.access.manage' pk=datasource.pk %}" + class="btn btn-block btn-primary" role="button">Manage Access</a> - {% if datasource.description %} - <p>{{ datasource.description }}</p> + <a href="{% url 'datasources:datasource.edit' datasource.id %}" + class="btn btn-block btn-success" role="button">Edit</a> + + <a href="{% url 'datasources:datasource.delete' datasource.id %}" + class="btn btn-block btn-danger" role="button">Delete</a> + {% endif %} + </div> + </div> + + {% if datasource.is_encrypted %} + <div class="alert alert-warning mt-3"> + <p> + This data source contains encrypted data. + </p> + + {% if datasource.encrypted_docs_url %} + For guidance on how to process this data please see <a href="{{ datasource.encrypted_docs_url }}">{{ datasource.encrypted_docs_url }}</a>. + {% endif %} + </div> {% endif %} - <a href="{% url 'datasources:datasource.query' pk=datasource.id %}" - class="btn btn-success" role="link">Query</a> - <a href="{% url 'datasources:datasource.metadata' pk=datasource.id %}" - class="btn btn-success" role="link">Metadata</a> - - <a href="{% url 'admin:datasources_datasource_change' datasource.id %}" - class="btn btn-success" role="button">Edit</a> - <a href="{% url 'admin:datasources_datasource_delete' datasource.id %}" - class="btn btn-danger" role="button">Delete</a> - {% if datasource.access_control %} - <a href="{% url 'datasources:datasource.manage-access' pk=datasource.pk %}" - class="btn btn-primary" role="button">Manage Access</a> + <table class="table"> + <thead> + <th scope="col" class="w-25 border-0"></th> + <th scope="col" class="border-0"></th> + </thead> + + <tbody> + <tr> + <td>Owner</td> + <td> + {{ datasource.owner }} + </td> + </tr> + <tr> + <td>URL</td> + <td>{{ datasource.url }}</td> + </tr> + <tr> + <td>Licence</td> + <td>{{ datasource.licence.name }}</td> + </tr> + </tbody> + </table> + + <div class="card"> + <div class="card-header" data-toggle="collapse" data-target="#collapseMetadata"> + <h6>Metadata</h6> + </div> + + <div id="collapseMetadata" class="card-body collapse show"> + <table id="tableMetadata" class="table"> + <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> + {% for metadata_item in datasource.metadata_items.all %} + <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> + + <hr> + + {% if is_catalogue %} + <h2 class="mt-3">Data Sets</h2> + + <script type="application/javascript"> + function search() { + // Give the user a visual cue that a search is happening + $('#btn-search-rest').hide(); + $('#btn-search-active').show(); + + const endpoint = "search?q=" + $("#id_query").val(); + + // Load HTML response into element + $('#dataset-results').load(endpoint, function(response, status, xhr) { + if (status === "error") { + const msg = "There was an error: "; + console.log(msg + xhr.status + " " + xhr.statusText); + } + + // Reset the button + $('#btn-search-rest').show(); + $('#btn-search-active').hide(); + }); + } + + function clearResults() { + $('#dataset-results').empty(); + } + </script> + + <form class="form-inline" action="javascript:search();"> + <div class="input-group"> + <input type="search" class="form-control" id="id_query" + aria-label="search" placeholder="Search"> + + <div class="input-group-append"> + <button type="submit" class="btn btn-outline-primary"> + <i id="btn-search-rest" class="fas fa-search"></i> + <i id="btn-search-active" class="fas fa-spinner fa-pulse" style="display: none"></i> + </button> + </div> + + <div class="input-group-append"> + <button type="button" class="btn btn-outline-primary" onclick="clearResults();"><i class="fas fa-trash"></i></button> + </div> + </div> + </form> + + <div id="dataset-results" class="row px-2"> + </div> {% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} + +{% block extra_body %} + <script type="application/javascript"> + $(function () { + $("[data-toggle='tooltip']").tooltip() + }) + </script> +{% endblock %} diff --git a/datasources/templates/datasources/datasource/explorer.html b/datasources/templates/datasources/datasource/explorer.html new file mode 100644 index 0000000000000000000000000000000000000000..dff92cde41b694e4db184265fc5f24fffcc0bde9 --- /dev/null +++ b/datasources/templates/datasources/datasource/explorer.html @@ -0,0 +1,244 @@ +{% load bootstrap4 %} + +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> + + <title>PEDASI</title> + + {% load static %} + <link rel="shortcut icon" href="{% static 'img/favicon.ico' %}" type="image/vnd.microsoft.icon"/> + + {% bootstrap_css %} + + <!-- Font Awesome - https://fontawesome.com --> + <link rel="stylesheet" + href="https://use.fontawesome.com/releases/v5.2.0/css/solid.css" + integrity="sha384-wnAC7ln+XN0UKdcPvJvtqIH3jOjs9pnKnq9qX68ImXvOGz2JuFoEiCjT8jyZQX2z" + crossorigin="anonymous"> + + <link rel="stylesheet" + href="https://use.fontawesome.com/releases/v5.2.0/css/fontawesome.css" + integrity="sha384-HbmWTHay9psM8qyzEKPc8odH4DsOuzdejtnr+OFtDmOcIVnhgReQ4GZBH7uwcjf6" + crossorigin="anonymous"> + + <!-- BootSwatch Journal theme --> + <link href="https://stackpath.bootstrapcdn.com/bootswatch/4.1.3/journal/bootstrap.min.css" + rel="stylesheet" + integrity="sha384-5C8TGNupopdjruopVTTrVJacBbWqxHK9eis5DB+DYE6RfqIJapdLBRUdaZBTq7mE" + crossorigin="anonymous"> + + <link rel="stylesheet" href="{% static 'css/pedasi.css' %}"> + + <script type="application/javascript" src="{% static 'js/explorer.js' %}"></script> + <script type="application/javascript"> + setDatasourceUrl("/api/datasources/{{ datasource.pk }}/"); + </script> +</head> + +<body> + +<nav class="navbar navbar-expand-lg navbar-dark bg-primary"> + <div class="container-fluid"> + <a class="navbar-brand" href="/">PEDASI</a> + <button class="navbar-toggler" type="button" + data-toggle="collapse" data-target="#navbarSupportedContent" + aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation"> + <span class="navbar-toggler-icon"></span> + </button> + + <div class="collapse navbar-collapse" id="navbarSupportedContent"> + <ul class="navbar-nav mr-auto"> + <li class="nav-item"> + <a class="nav-link" href="{% url 'applications:application.list' %}">Applications</a> + </li> + <li class="nav-item"> + <a class="nav-link" href="{% url 'datasources:datasource.list' %}">Data Sources</a> + </li> + </ul> + + <ul class="navbar-nav"> + {% if request.user.is_authenticated %} + <li class="nav-item"> + <p class="navbar-text"> + Welcome {{ request.user.username }} + </p> + </li> + + <li class="nav-item"> + <a class="nav-link" href="{% url 'profiles:profile' %}">My Profile</a> + </li> + + <li class="nav-item"> + <a class="nav-link" href="{% url 'profiles:logout' %}">Logout</a> + </li> + + {% else %} + {% if 'google-oauth2' in backends.backends %} + <li class="nav-item"> + <a class="nav-link" href="{% url 'social:begin' 'google-oauth2' %}">Google Login</a> + </li> + {% endif %} + + <li class="nav-item"> + <a class="nav-link" href="{% url 'profiles:login' %}">Login</a> + </li> + {% endif %} + </ul> + </div> + </div> +</nav> + +<div class="container-fluid mt-3"> + {% bootstrap_messages %} + + <nav aria-label="breadcrumb"> + <ol class="breadcrumb"> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'index' %}">Home</a> + </li> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'datasources:datasource.list' %}">Data Sources</a> + </li> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'datasources:datasource.detail' pk=datasource.pk %}">{{ datasource.name }}</a> + </li> + <li class="breadcrumb-item active" aria-current="page"> + API Explorer + </li> + </ol> + </nav> + + <div class="row"> + <div class="col-md"> + <h2>{{ datasource.name }}</h2> + + {% if datasource.description %} + <p>{{ datasource.description|linebreaks }}</p> + {% endif %} + </div> + </div> + + <hr> + + {% if datasource.is_catalogue %} + <div class="row"> + <div class="col-md"> + <div class="alert alert-warning"> + <h5>Active data set</h5> + <span id="selectedDataset">None</span> + </div> + </div> + </div> + {% endif %} + + <div class="row"> + <div class="col-md-6"> + <h5>Query Builder</h5> + + <form class="form-inline" onsubmit="return addParam();"> + {% if data_query_params %} + <select class="form-control mb-2 mr-sm-2" id="inputParam"> + {% for param in data_query_params %} + <option value="{{ param }}">{{ param }}</option> + {% endfor %} + </select> + {% else %} + <input type="text" class="form-control mb-2 mr-sm-2" id="inputParam" placeholder="Parameter"> + {% endif %} + + <input type="text" class="form-control mb-2 mr-sm-2" id="inputValue" placeholder="Parameter value"> + + <button type="submit" class="btn btn-secondary mb-2 mr-sm-2">Add to Query</button> + <button type="button" class="btn btn-success mb-2" onclick="submitQuery();">Submit Query</button> + </form> + + <div class="alert alert-info w-100"> + Query URL: /api/datasources/{{ datasource.pk }}/<span id="datasetUrlSpan"></span>data/?<span id="queryParamSpan"></span> + </div> + + <table class="table" id="tableParams"> + <thead> + <th>Parameter</th> + <th>Value</th> + </thead> + </table> + </div> + + <div class="col-md-6"> + <h5>Query Results</h5> + + <pre id="queryResults" style="overflow-y: auto; height: 30vh;"> + </pre> + </div> + </div> + + <hr> + + <div class="row"> + <div class="col-md"> + <h5 class="mb-3"> + Metadata + <button class="btn btn-sm btn-default float-right" + onclick="toggleExpandPanel(event)" data-target="#metadataPanel"> + <span class="toggle-icon-plus fas fa-plus fa-lg" style="pointer-events: none;"></span> + <span class="toggle-icon-minus fas fa-minus fa-lg" style="display: none; pointer-events: none;"></span> + </button> + </h5> + + <div id="metadataPanel" style="overflow-y: auto; height: 30vh;"> + <h6>Metadata from PEDASI</h6> + + <table id="metadataInternal" class="table"> + <script type="application/javascript"> + document.addEventListener("DOMContentLoaded", populateMetadataInternal); + </script> + </table> + + <h6>Metadata from source</h6> + + <table id="metadata" class="table"> + <script type="application/javascript"> + document.addEventListener("DOMContentLoaded", populateMetadata); + </script> + </table> + </div> + + </div> + + <div class="col-md"> + <h5 class="mb-3"> + Datasets + <button class="btn btn-sm btn-default float-right" + onclick="toggleExpandPanel(event);" data-target="#datasetsPanel"> + <span class="toggle-icon-plus fas fa-plus fa-lg" style="pointer-events: none;"></span> + <span class="toggle-icon-minus fas fa-minus fa-lg" style="display: none; pointer-events: none;"></span> + </button> + </h5> + + <div id="datasetsPanel" style="overflow-y: auto; height: 30vh;"> + <table id="datasets" class="table"> + <script type="application/javascript"> + document.addEventListener("DOMContentLoaded", populateDatasets) + </script> + </table> + </div> + </div> + </div> + + <hr> + +</div> + +<footer class="footer bg-primary"> + <div class="container"> + <p class="m-0 text-center text-white">PEDASI: IoT Observatory Demonstrator</p> + </div> +</footer> + +{% bootstrap_javascript jquery=True %} + +</body> +</html> diff --git a/datasources/templates/datasources/datasource/list.html b/datasources/templates/datasources/datasource/list.html index 863c30b829464da6a431cb4121dc3c43dfa502fe..771bf6d7d6315eef819457cb24251cffd2c0dd95 100644 --- a/datasources/templates/datasources/datasource/list.html +++ b/datasources/templates/datasources/datasource/list.html @@ -15,26 +15,68 @@ <h2>Data Sources</h2> - <a href="{% url 'admin:datasources_datasource_add' %}" - class="btn btn-success" role="button">Create Data Source</a> + {% if perms.datasources.add_datasource %} + <div class="row"> + <div class="col-md-6 mx-auto"> + <a href="{% url 'datasources:datasource.add' %}" + class="btn btn-block btn-success" role="button">New Data Source</a> + </div> + </div> + {% endif %} <div class="mt-3"></div> - <table class="table"> + <table class="table table-hover"> <thead class="thead"> <tr> - <th>Name</th> - <th></th> + <th class="w-75">Name</th> + <th class="w-auto"> + <i class="fas fa-user" aria-hidden="true"></i> + </th> + <th class="w-auto">Access</th> + <th class="w-auto"></th> </tr> </thead> <tbody> {% for datasource in datasources %} <tr> - <td>{{ datasource.name }}</td> <td> + <p> + <b>{{ datasource.name }}</b> + {% 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> + {% else %} + <span class="badge badge-warning" + data-toggle="tooltip" data-placement="bottom" title="No Licence"> + No Licence + </span> + {% endif %} + </p> + <p class="pl-5"> + {{ datasource.description|truncatechars:120 }} + </p> + </td> + <td class="align-middle"> + {% if datasource.owner == request.user %} + <i class="fas fa-user" aria-hidden="true" + data-toggle="tooltip" data-placement="top" title="My data source"></i> + {% endif %} + </td> + <td class="align-middle"> + {# Level 1 is VIEW #} + {% if datasource.public_permission_level < 1 %} + <i class="fas fa-lock fa-lg" + data-toggle="tooltip" data-placement="top" title="Data source has access controls"></i> + {% endif %} + </td> + <td class="align-middle"> <a href="{% url 'datasources:datasource.detail' pk=datasource.pk %}" - class="btn btn-primary" role="button">Detail</a> + class="btn btn-block btn-secondary" role="button">Detail</a> </td> </tr> {% empty %} @@ -44,4 +86,12 @@ {% endfor %} </tbody> </table> -{% endblock %} \ No newline at end of file +{% endblock %} + +{% block extra_body %} + <script type="application/javascript"> + $(function () { + $("[data-toggle='tooltip']").tooltip() + }) + </script> +{% endblock %} diff --git a/datasources/templates/datasources/datasource/manage_access.html b/datasources/templates/datasources/datasource/manage_access.html index f7263b9988d6ba2b526197e177d75818f90f8ce9..11ce9bdce97f4cf53ac318d7aa07e6c5467234ee 100644 --- a/datasources/templates/datasources/datasource/manage_access.html +++ b/datasources/templates/datasources/datasource/manage_access.html @@ -1,6 +1,10 @@ {% extends "base.html" %} {% load bootstrap4 %} +{% block extra_head %} + <script src="https://cdn.jsdelivr.net/npm/js-cookie@2.2.0/src/js.cookie.min.js"></script> +{% endblock %} + {% block content %} <nav aria-label="breadcrumb"> <ol class="breadcrumb"> @@ -19,34 +23,60 @@ </ol> </nav> - <h2>View Data Source - {{ datasource.name }}</h2> - - {% if datasource.description %} - <p>{{ datasource.description }}</p> - {% endif %} + <h2>{{ datasource.name }}</h2> - <hr/> + <hr> <h2>Requests for Access</h2> - <table class="table"> + <table id="requested-user-table" class="table"> <thead class="thead"> <tr> <th>Username</th> + <th>Requested</th> + <th>Current</th> + <th>Push?</th> + <th></th> <th></th> <th></th> </tr> </thead> <tbody> - {% for user in datasource.users_group_requested.users.all %} - <tr> - <td>user.username</td> + {% for permission in permissions_requested.all %} + <tr id="requested-user-{{ permission.user.pk }}"> + <td> + <p> + {{ permission.user.username }} + </p> + {% if permission.reason %} + <div class="alert alert-secondary" role="note"> + {{ permission.reason }} + </div> + {% endif %} + </td> + <td>{{ permission.get_requested_display }}</td> + <td>{{ permission.get_granted_display }}</td> + <td> + {% if permission.push_requested %} + <i class="fas fa-upload fa-lg" + data-toggle="tooltip" data-placement="top" title="Permission to push data"></i> + {% endif %} + </td> + <td> + <button onclick="userGrantAccess({{ permission.user.pk }}, {{ permission.requested }}, {{ permission.push_requested|yesno:'true,false' }})" + class="btn btn-success" + role="button">Approve</button> + </td> <td> - <a href="#" class="btn btn-success" role="button">Approve</a> + <a href="{% url 'datasources:datasource.access.grant' pk=datasource.pk %}?user={{ permission.user_id }}" + class="btn btn-info" + role="button">Edit</a> </td> <td> - <a href="#" class="btn btn-danger" role="button">Reject</a> + <button onclick="userGrantAccess({{ permission.user.pk }}, {{ permission.granted }}, {{ permission.push_granted|yesno:'true,false' }})" + class="btn btn-danger" + role="button">Reject</button> </td> </tr> {% empty %} @@ -59,24 +89,40 @@ <h2>Approved Users</h2> - <table class="table"> + <table id="approved-user-table" class="table"> <thead class="thead"> <tr> <th>Username</th> + <th>Requested</th> + <th>Current</th> + <th>Push?</th> + <th></th> <th></th> <th></th> </tr> </thead> <tbody> - {% for user in datasource.users_group.users.all %} - <tr> - <td>user.username</td> + {% for permission in permissions_granted %} + <tr id="approved-user-{{ permission.user.pk }}"> + <td>{{ permission.user.username }}</td> + <td>{{ permission.get_requested_display }}</td> + <td>{{ permission.get_granted_display }}</td> <td> - <a href="#" class="btn btn-success" role="button">Approve</a> + {% if permission.push_granted %} + <i class="fas fa-upload fa-lg" + data-toggle="tooltip" data-placement="top" title="Permission to push data"></i> + {% endif %} </td> <td> - <a href="#" class="btn btn-danger" role="button">Reject</a> + <a href="{% url 'datasources:datasource.access.grant' pk=datasource.pk %}?user={{ permission.user_id }}" + class="btn btn-info" + role="button">Edit</a> + </td> + <td> + <button onclick="userRemoveAccess({{ permission.user.pk }})" + class="btn btn-danger" + role="button">Remove</button> </td> </tr> {% empty %} @@ -85,4 +131,52 @@ </tbody> </table> + <script type="application/javascript"> + function userGrantAccess(userPk, level, push){ + $.post({ + url: '{% url 'datasources:datasource.access.grant' pk=datasource.pk %}', + data: { + 'user': userPk, + 'granted': level, + 'requested': level, + 'push_granted': push, + 'push_requested': push + }, + headers: { + 'X-CSRFToken': Cookies.get('csrftoken') + }, + success: function(result, status, xhr){ + document.getElementById('requested-user-' + userPk.toString()).remove(); + + // TODO if table is empty add 'table is empty' row + } + }) + } + + function userRemoveAccess(userPk){ + $.post({ + url: '{% url 'datasources:datasource.access.grant' pk=datasource.pk %}', + data: { + 'user': userPk, + 'granted': 0, + 'requested': 0 + }, + headers: { + 'X-CSRFToken': Cookies.get('csrftoken') + }, + success: function(result, status, xhr){ + try { + document.getElementById('approved-user-' + userPk.toString()).remove(); + } catch (err) {} + + try { + document.getElementById('requested-user-' + userPk.toString()).remove(); + } catch (err) {} + + // TODO if table is empty add 'table is empty' row + } + }) + } + </script> + {% endblock %} \ No newline at end of file diff --git a/datasources/templates/datasources/datasource/update.html b/datasources/templates/datasources/datasource/update.html new file mode 100644 index 0000000000000000000000000000000000000000..75db48fba5b66ef8e33156e9e7737b6a202cf436 --- /dev/null +++ b/datasources/templates/datasources/datasource/update.html @@ -0,0 +1,71 @@ +{% extends "base.html" %} +{% load bootstrap4 %} + +{% block content %} + <nav aria-label="breadcrumb"> + <ol class="breadcrumb"> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'index' %}">Home</a> + </li> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'datasources:datasource.list' %}">Data Sources</a> + </li> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'datasources:datasource.detail' pk=datasource.pk %}">{{ datasource.name }}</a> + </li> + <li class="breadcrumb-item active" aria-current="page"> + Edit + </li> + </ol> + </nav> + + <h2 class="pb-3">Edit Data Source</h2> + + <form class="form" method="post" action=""> + {% csrf_token %} + + {% bootstrap_field form.name %} + {% bootstrap_field form.description %} + {% bootstrap_field form.url %} + {% bootstrap_field form.api_key %} + {% bootstrap_field form.plugin_name %} + {% bootstrap_field form.licence %} + + <hr> + + <div class="row"> + <div class="col-sm-3"> + {% bootstrap_field form.is_encrypted %} + </div> + + <div class="col-sm-9"> + <div id="encryptedDocsToggle"> + {% bootstrap_field form.encrypted_docs_url %} + </div> + </div> + + <script type="application/javascript"> + const checkboxEncrypted = document.getElementById("id_is_encrypted"); + const divEncryptedToggle = document.getElementById("encryptedDocsToggle"); + + function setupEncryptedToggle() { + if (checkboxEncrypted.checked) { + divEncryptedToggle.style.display = "block"; + } else { + divEncryptedToggle.style.display = "none"; + } + } + + checkboxEncrypted.addEventListener("change", setupEncryptedToggle); + + setupEncryptedToggle(); + </script> + </div> + + {% bootstrap_field form.public_permission_level %} + {% bootstrap_field form.prov_exempt %} + + <input type="submit" class="btn btn-success" value="Update"> + </form> + +{% endblock %} diff --git a/datasources/templates/datasources/licence/create.html b/datasources/templates/datasources/licence/create.html new file mode 100644 index 0000000000000000000000000000000000000000..9840fe50d99edb8742d0bf7f38e6839d35954f2b --- /dev/null +++ b/datasources/templates/datasources/licence/create.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% load bootstrap4 %} + +{% block content %} + <nav aria-label="breadcrumb"> + <ol class="breadcrumb"> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'index' %}">Home</a> + </li> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'datasources:datasource.list' %}">Data Sources</a> + </li> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'datasources:licence.list' %}">Licences</a> + </li> + <li class="breadcrumb-item active" aria-current="page"> + New Licence + </li> + </ol> + </nav> + + <h2 class="pb-3">New Licence</h2> + + <form class="form" method="post" action=""> + {% csrf_token %} + + {% bootstrap_form form %} + + <input type="submit" class="btn btn-success" value="Create"> + </form> + +{% endblock %} diff --git a/datasources/templates/datasources/licence/delete.html b/datasources/templates/datasources/licence/delete.html new file mode 100644 index 0000000000000000000000000000000000000000..b41fbd481393422c1ac57157f6088e7b74db1a1c --- /dev/null +++ b/datasources/templates/datasources/licence/delete.html @@ -0,0 +1,67 @@ +{% extends "base.html" %} +{% load bootstrap4 %} + +{% block content %} + <nav aria-label="breadcrumb"> + <ol class="breadcrumb"> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'index' %}">Home</a> + </li> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'datasources:datasource.list' %}">Data Sources</a> + </li> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'datasources:licence.list' %}">Licences</a> + </li> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'datasources:licence.detail' pk=licence.pk %}">{{ licence.name }}</a> + </li> + <li class="breadcrumb-item active" aria-current="page"> + Delete + </li> + </ol> + </nav> + + <div class="row"> + <div class="col-md-10 col-sm-8"> + <h2>{{ licence.name }}</h2> + </div> + </div> + + <table class="table"> + <thead> + <th scope="col" class="w-25 border-0"></th> + <th scope="col" class="border-0"></th> + </thead> + + <tbody> + <tr> + <td>Short Name</td> + <td>{{ licence.short_name }}</td> + </tr> + <tr> + <td>Version</td> + <td>{{ licence.version }}</td> + </tr> + <tr> + <td>URL</td> + <td> + <a href="{{ licence.url }}">{{ licence.url }}</a> + </td> + </tr> + </tbody> + </table> + + <div class="alert alert-danger"> + <p><b>Are you sure you want to delete this licence?</b></p> + + <form class="form" method="post"> + {% csrf_token %} + + <input type="submit" role="button" class="btn btn-danger" value="Delete"> + + <a role="button" class="btn btn-info" + href="{% url 'datasources:licence.detail' pk=licence.pk %}">Cancel</a> + </form> + </div> +{% endblock %} \ No newline at end of file diff --git a/datasources/templates/datasources/licence/detail.html b/datasources/templates/datasources/licence/detail.html new file mode 100644 index 0000000000000000000000000000000000000000..ffcade1e038d8ee148a3e9b5cd0f0a0b4ce1e6d0 --- /dev/null +++ b/datasources/templates/datasources/licence/detail.html @@ -0,0 +1,62 @@ +{% extends "base.html" %} +{% load bootstrap4 %} + +{% block content %} + <nav aria-label="breadcrumb"> + <ol class="breadcrumb"> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'index' %}">Home</a> + </li> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'datasources:datasource.list' %}">Data Sources</a> + </li> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'datasources:licence.list' %}">Licences</a> + </li> + <li class="breadcrumb-item active" aria-current="page"> + {{ licence.name }} + </li> + </ol> + </nav> + + <div class="row"> + <div class="col-md-10 col-sm-8"> + <h2>{{ licence.name }}</h2> + </div> + + <div class="col-md-2 col-sm-4"> + {% if has_edit_permission %} + <a href="{% url 'datasources:licence.edit' pk=licence.id %}" + class="btn btn-block btn-success" role="button">Edit</a> + + <a href="{% url 'datasources:licence.delete' pk=licence.id %}" + class="btn btn-block btn-danger" role="button">Delete</a> + {% endif %} + </div> + </div> + + <table class="table"> + <thead> + <th scope="col" class="w-25 border-0"></th> + <th scope="col" class="border-0"></th> + </thead> + + <tbody> + <tr> + <td>Short Name</td> + <td>{{ licence.short_name }}</td> + </tr> + <tr> + <td>Version</td> + <td>{{ licence.version }}</td> + </tr> + <tr> + <td>URL</td> + <td> + <a href="{{ licence.url }}">{{ licence.url }}</a> + </td> + </tr> + </tbody> + </table> + +{% endblock %} diff --git a/datasources/templates/datasources/licence/list.html b/datasources/templates/datasources/licence/list.html new file mode 100644 index 0000000000000000000000000000000000000000..bbd1984107a0fd42dcea98904b8d055dd77a62e7 --- /dev/null +++ b/datasources/templates/datasources/licence/list.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} +{% load bootstrap4 %} + +{% block content %} + <nav aria-label="breadcrumb"> + <ol class="breadcrumb"> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'index' %}">Home</a> + </li> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'datasources:datasource.list' %}">Data Sources</a> + </li> + <li class="breadcrumb-item active" aria-current="page"> + Licences + </li> + </ol> + </nav> + + <h2>Licenses</h2> + + {% if perms.datasources.add_datasource %} + <div class="row"> + <div class="col-md-6 mx-auto"> + <a href="{% url 'datasources:licence.add' %}" + class="btn btn-block btn-success" role="button">New License</a> + </div> + </div> + {% endif %} + + <div class="mt-3"></div> + + <table class="table table-hover"> + <thead class="thead"> + <tr> + <th class="w-75">Name</th> + <th class="w-auto"></th> + </tr> + </thead> + + <tbody> + {% for licence in licences %} + <tr> + <td> + <p> + <b>{{ licence.name }}</b> + </p> + <p class="pl-5"> + {{ datasource.description|truncatechars:120 }} + </p> + </td> + <td class="align-middle"> + <a href="{% url 'datasources:licence.detail' pk=licence.pk %}" + class="btn btn-block btn-secondary" role="button">Detail</a> + </td> + </tr> + {% empty %} + <tr> + <td>There are no licences currently available.</td> + </tr> + {% endfor %} + </tbody> + </table> +{% endblock %} diff --git a/datasources/templates/datasources/licence/update.html b/datasources/templates/datasources/licence/update.html new file mode 100644 index 0000000000000000000000000000000000000000..063b6115d751a0cb040ba93f8e58f54dce2cbe99 --- /dev/null +++ b/datasources/templates/datasources/licence/update.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% load bootstrap4 %} + +{% block content %} + <nav aria-label="breadcrumb"> + <ol class="breadcrumb"> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'index' %}">Home</a> + </li> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'datasources:datasource.list' %}">Data Sources</a> + </li> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'datasources:licence.list' %}">Licences</a> + </li> + <li class="breadcrumb-item active" aria-current="page"> + Update Licence + </li> + </ol> + </nav> + + <h2 class="pb-3">Update Licence</h2> + + <form class="form" method="post" action=""> + {% csrf_token %} + + {% bootstrap_form form %} + + <input type="submit" class="btn btn-success" value="Update"> + </form> + +{% endblock %} diff --git a/datasources/templates/datasources/user_permission_link/permission_grant.html b/datasources/templates/datasources/user_permission_link/permission_grant.html new file mode 100644 index 0000000000000000000000000000000000000000..272984d6446df890dd7133c4b3d39194e4f8501f --- /dev/null +++ b/datasources/templates/datasources/user_permission_link/permission_grant.html @@ -0,0 +1,72 @@ +{% extends "base.html" %} +{% load bootstrap4 %} + +{% block content %} + <nav aria-label="breadcrumb"> + <ol class="breadcrumb"> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'index' %}">Home</a> + </li> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'datasources:datasource.list' %}">Data Sources</a> + </li> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'datasources:datasource.detail' pk=datasource.pk %}">{{ datasource.name }}</a> + </li> + <li class="breadcrumb-item active" aria-current="page"> + Manage Access + </li> + </ol> + </nav> + + <h2>{{ datasource.name }}</h2> + + <hr> + + <table class="table"> + <thead> + <th scope="col" class="col-md-2 border-0"></th> + <th scope="col" class="border-0"></th> + </thead> + + <tbody> + <tr> + <td>Owner</td> + <td> + {{ datasource.owner }} + </td> + </tr> + <tr> + <td>URL</td> + <td>{{ datasource.url }}</td> + </tr> + </tbody> + </table> + + <h2>Access Request</h2> + + {{ permission.user.username }} requests {{ permission.get_requested_display }} permission on this data source + + {% if permission.push_requested %} + and permission to push data + {% endif %} + + {% if permission.reason %} + <div class="alert alert-secondary" role="note"> + <p> + {{ permission.reason|linebreaks }} + </p> + </div> + {% endif %} + + <form method="post"> + {% csrf_token %} + {% bootstrap_form form %} + + {% buttons %} + <input type="submit" class="btn btn-success" value="Grant"> + <button onclick="window.history.back();" class="btn btn-default" type="button">Cancel</button> + {% endbuttons %} + </form> + +{% endblock %} \ No newline at end of file diff --git a/datasources/templates/datasources/user_permission_link/permission_request.html b/datasources/templates/datasources/user_permission_link/permission_request.html new file mode 100644 index 0000000000000000000000000000000000000000..a4393c6ed1bdfd03e72fce3dae47dd5d84ecfe34 --- /dev/null +++ b/datasources/templates/datasources/user_permission_link/permission_request.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} +{% load bootstrap4 %} + +{% block content %} + <nav aria-label="breadcrumb"> + <ol class="breadcrumb"> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'index' %}">Home</a> + </li> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'datasources:datasource.list' %}">Data Sources</a> + </li> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'datasources:datasource.detail' pk=datasource.pk %}">{{ datasource.name }}</a> + </li> + <li class="breadcrumb-item active" aria-current="page"> + Request Access + </li> + </ol> + </nav> + + <h2>{{ datasource.name }}</h2> + + <hr> + + <table class="table"> + <thead> + <th scope="col" class="col-md-2 border-0"></th> + <th scope="col" class="border-0"></th> + </thead> + + <tbody> + <tr> + <td>Owner</td> + <td> + {{ datasource.owner }} + </td> + </tr> + <tr> + <td>URL</td> + <td>{{ datasource.url }}</td> + </tr> + </tbody> + </table> + + <h2>Access Request</h2> + + <form method="post"> + {% csrf_token %} + {% bootstrap_form form %} + + {% buttons %} + <input type="submit" class="btn btn-success" value="Request"> + <button onclick="window.history.back();" class="btn btn-default" type="button">Cancel</button> + {% endbuttons %} + </form> + +{% endblock %} \ No newline at end of file diff --git a/datasources/templates/search/indexes/datasources/datasource_text.txt b/datasources/templates/search/indexes/datasources/datasource_text.txt new file mode 100644 index 0000000000000000000000000000000000000000..16cade4e8d823e5d40337e0992df98839750d095 --- /dev/null +++ b/datasources/templates/search/indexes/datasources/datasource_text.txt @@ -0,0 +1 @@ +{{ object.search_representation }} diff --git a/datasources/tests/test_connectors.py b/datasources/tests/test_connectors.py index b3e09fb8f042368587b2e84d225cd7aa14a2f562..8527559159265b2990a24ed6ad2120a6b88a9110 100644 --- a/datasources/tests/test_connectors.py +++ b/datasources/tests/test_connectors.py @@ -10,12 +10,12 @@ class ConnectorPluginTest(TestCase): """ BaseDataConnector.load_plugins('datasources/connectors') - def test_get_plugin_iotuk(self): + def test_get_plugin_simple(self): """ - Test that we have the IoTUK plugin and can activate it. + Test that we have the plugin for trivial APIs and can activate it. """ BaseDataConnector.load_plugins('datasources/connectors') - plugin = BaseDataConnector.get_plugin('IoTUK') + plugin = BaseDataConnector.get_plugin('DataSetConnector') self.assertIsNotNone(plugin) @@ -41,19 +41,29 @@ class ConnectorPluginTest(TestCase): class ConnectorIoTUKTest(TestCase): url = 'https://api.iotuk.org.uk/iotOrganisation' + def _get_connection(self) -> BaseDataConnector: + return self.plugin(self.url) + def setUp(self): BaseDataConnector.load_plugins('datasources/connectors') - self.plugin = BaseDataConnector.get_plugin('IoTUK') + self.plugin = BaseDataConnector.get_plugin('DataSetConnector') def test_get_plugin(self): self.assertIsNotNone(self.plugin) def test_plugin_init(self): - connection = self.plugin(self.url) + connection = self._get_connection() + self.assertEqual(connection.location, self.url) + def test_plugin_type(self): + connection = self._get_connection() + + self.assertFalse(connection.is_catalogue) + def test_plugin_get_data_fails(self): - connection = self.plugin(self.url) + connection = self._get_connection() + result = connection.get_data() self.assertIn('status', result) @@ -63,8 +73,9 @@ class ConnectorIoTUKTest(TestCase): self.assertEqual(result['results'], -1) def test_plugin_get_data_query(self): - connection = self.plugin(self.url) - result = connection.get_data(query_params={'year': 2018}) + connection = self._get_connection() + + result = connection.get_data(params={'year': 2018}) self.assertIn('status', result) self.assertEqual(result['status'], 200) @@ -76,135 +87,39 @@ class ConnectorIoTUKTest(TestCase): self.assertGreater(len(result['data']), 0) -class ConnectorHyperCatTest(TestCase): - url = 'https://portal.bt-hypercat.com/cat' +class ConnectorRestApiTest(TestCase): + url = 'https://api.iotuk.org.uk/' - # Met Office dataset for weather at Heathrow - dataset = 'http://api.bt-hypercat.com/sensors/feeds/c7f361c6-7cb7-4ef5-aed9-397a0c0c4088' + def _get_connection(self) -> BaseDataConnector: + return self.plugin(self.url) def setUp(self): BaseDataConnector.load_plugins('datasources/connectors') - self.plugin = BaseDataConnector.get_plugin('HyperCat') + self.plugin = BaseDataConnector.get_plugin('RestApiConnector') def test_get_plugin(self): self.assertIsNotNone(self.plugin) def test_plugin_init(self): - connection = self.plugin(self.url) - self.assertEqual(connection.location, self.url) - - def test_plugin_get_dataset_metadata(self): - connection = self.plugin(self.url) - result = connection.get_metadata(dataset=self.dataset) - - for property in [ - 'urn:X-bt:rels:feedTitle', - 'urn:X-hypercat:rels:hasDescription:en', - 'urn:X-bt:rels:feedTag', - 'urn:X-bt:rels:hasSensorStream', - 'urn:X-hypercat:rels:isContentType', - ]: - self.assertIn(property, result) - - self.assertIn('Met Office', - result['urn:X-bt:rels:feedTitle'][0]) - - self.assertIn('Met Office', - result['urn:X-hypercat:rels:hasDescription:en'][0]) - - self.assertEqual(len(result['urn:X-bt:rels:feedTag']), 1) - self.assertEqual(result['urn:X-bt:rels:feedTag'][0], 'weather') - - self.assertGreaterEqual(len(result['urn:X-bt:rels:hasSensorStream']), 1) - - self.assertIn('application/json', - result['urn:X-hypercat:rels:isContentType']) - - def test_plugin_get_dataset_data(self): - """ - Test that we can get data from a single dataset within the catalogue. - """ - from decouple import config - - api_key = config('HYPERCAT_BT_API_KEY') - - dataset = self.dataset + '/datastreams/0' - - connection = self.plugin(self.url, - api_key=api_key) - result = connection.get_data(dataset=dataset) - - self.assertIsInstance(result, str) - self.assertGreaterEqual(len(result), 1) - - -class ConnectorHyperCatCiscoTest(TestCase): - url = 'https://api.cityverve.org.uk/v1/cat' - entity_url = 'https://api.cityverve.org.uk/v1/entity' + connection = self._get_connection() - dataset = 'weather-observations-wind' - - def setUp(self): - from decouple import config - - BaseDataConnector.load_plugins('datasources/connectors') - self.plugin = BaseDataConnector.get_plugin('HyperCatCisco') - - self.api_key = config('HYPERCAT_CISCO_API_KEY') - - def test_get_plugin(self): - self.assertIsNotNone(self.plugin) - - def test_plugin_init(self): - connection = self.plugin(self.url) self.assertEqual(connection.location, self.url) - def test_plugin_get_entities(self): - connection = self.plugin(self.url, - api_key=self.api_key, - entity_url=self.entity_url) - result = connection.get_entities() - - self.assertGreaterEqual(len(result), 1) - - for entity in result: - self.assertIn('id', entity) - self.assertIn('uri', entity) + def test_plugin_type(self): + connection = self._get_connection() - def test_plugin_get_catalogue_metadata(self): - connection = self.plugin(self.url) - result = connection.get_metadata() + self.assertTrue(connection.is_catalogue) - self.assertIn('application/vnd.hypercat.catalogue+json', - result['urn:X-hypercat:rels:isContentType']) + def test_plugin_dataset_get_data_query(self): + connection = self._get_connection() - self.assertIn('CityVerve', - result['urn:X-hypercat:rels:hasDescription:en'][0]) + result = connection['iotOrganisation'].get_data(params={'year': 2018}) - self.assertEqual('https://developer.cityverve.org.uk', - result['urn:X-hypercat:rels:hasHomepage']) - - def test_plugin_get_dataset_metadata(self): - connection = self.plugin(self.url) - result = connection.get_metadata(dataset=self.dataset) - - for property in [ - 'urn:X-bt:rels:feedTitle', - 'urn:X-hypercat:rels:hasDescription:en', - 'urn:X-bt:rels:feedTag', - 'urn:X-bt:rels:hasSensorStream', - 'urn:X-hypercat:rels:isContentType', - ]: - self.assertIn(property, result) - - self.assertIn('Met Office', - result['urn:X-bt:rels:feedTitle'][0]) - - self.assertIn('Met Office', - result['urn:X-hypercat:rels:hasDescription:en'][0]) - - self.assertEqual(len(result['urn:X-bt:rels:feedTag']), 1) - self.assertEqual(result['urn:X-bt:rels:feedTag'][0], 'weather') + self.assertIn('status', result) + self.assertEqual(result['status'], 200) - self.assertGreaterEqual(len(result['urn:X-bt:rels:hasSensorStream']), 1) + self.assertIn('results', result) + self.assertGreater(result['results'], 0) + self.assertIn('data', result) + self.assertGreater(len(result['data']), 0) diff --git a/datasources/tests/test_connectors_hypercat.py b/datasources/tests/test_connectors_hypercat.py new file mode 100644 index 0000000000000000000000000000000000000000..a34bff6a05a8caaa7aeb4464f9f0cea4132332c7 --- /dev/null +++ b/datasources/tests/test_connectors_hypercat.py @@ -0,0 +1,367 @@ +import itertools +import typing + +from django.test import TestCase +from requests.auth import HTTPBasicAuth + +from datasources.connectors.base import BaseDataConnector, HttpHeaderAuth + + +def _get_item_by_key_value(collection: typing.Iterable[typing.Mapping], + key: str, value) -> typing.Mapping: + matches = [item for item in collection if item[key] == value] + + if not matches: + raise KeyError + elif len(matches) > 1: + raise ValueError('Multiple items were found') + + return matches[0] + + +def _count_items_by_key_value(collection: typing.Iterable[typing.Mapping], + key: str, value) -> int: + matches = [item for item in collection if item[key] == value] + + return len(matches) + + +class ConnectorHyperCatTest(TestCase): + url = 'https://portal.bt-hypercat.com/cat' + + # Met Office dataset for weather at Heathrow + dataset = 'http://api.bt-hypercat.com/sensors/feeds/c7f361c6-7cb7-4ef5-aed9-397a0c0c4088' + + def _get_connection(self) -> BaseDataConnector: + return self.plugin(self.url, + api_key=self.api_key, + auth=HTTPBasicAuth) + + def setUp(self): + from decouple import config + + BaseDataConnector.load_plugins('datasources/connectors') + self.plugin = BaseDataConnector.get_plugin('HyperCat') + + self.api_key = config('HYPERCAT_BT_API_KEY') + + def test_get_plugin(self): + self.assertIsNotNone(self.plugin) + + def test_plugin_init(self): + connection = self._get_connection() + + self.assertEqual(connection.location, self.url) + + def test_plugin_type(self): + connection = self._get_connection() + + self.assertTrue(connection.is_catalogue) + + def test_plugin_get_metadata(self): + connection = self._get_connection() + + result = connection.get_metadata() + + relations = [relation['rel'] for relation in result] + for property in [ + 'urn:X-hypercat:rels:hasDescription:en', + 'urn:X-hypercat:rels:isContentType', + ]: + self.assertIn(property, relations) + + self.assertEqual('BT Hypercat DataHub Catalog', + _get_item_by_key_value(result, 'rel', 'urn:X-hypercat:rels:hasDescription:en')['val']) + + self.assertEqual('application/vnd.hypercat.catalogue+json', + _get_item_by_key_value(result, 'rel', 'urn:X-hypercat:rels:isContentType')['val']) + + def test_plugin_get_datasets(self): + connection = self._get_connection() + + datasets = connection.get_datasets() + + self.assertEqual(list, + type(datasets)) + + self.assertLessEqual(1, + len(datasets)) + + self.assertIn(self.dataset, + datasets) + + def test_plugin_iter_datasets(self): + connection = self._get_connection() + + for dataset in connection: + self.assertEqual(str, + type(dataset)) + + def test_plugin_len_datasets(self): + connection = self._get_connection() + + self.assertLessEqual(1, + len(connection)) + + def test_plugin_iter_items(self): + """ + Test naive iteration over key-value pairs of datasets within this catalogue. + + This process is SLOW so we only do a couple of iterations. + """ + connection = self._get_connection() + + for k, v in itertools.islice(connection.items(), 5): + self.assertEqual(str, + type(k)) + + self.assertIsInstance(v, + BaseDataConnector) + + self.assertEqual(k, + v.location) + + def test_plugin_iter_items_context_manager(self): + """ + Test context-managed iteration over key-value pairs of datasets within this catalogue. + + This process is relatively slow so we only do a couple of iterations. + """ + with self.plugin(self.url, api_key=self.api_key, auth=HTTPBasicAuth) as connection: + for k, v in itertools.islice(connection.items(), 5): + self.assertEqual(str, + type(k)) + + self.assertIsInstance(v, + BaseDataConnector) + + self.assertEqual(k, + v.location) + + def test_plugin_get_dataset_metadata(self): + connection = self._get_connection() + + result = connection[self.dataset].get_metadata() + + relations = [relation['rel'] for relation in result] + for property in [ + 'urn:X-bt:rels:feedTitle', + 'urn:X-hypercat:rels:hasDescription:en', + 'urn:X-bt:rels:feedTag', + 'urn:X-bt:rels:hasSensorStream', + 'urn:X-hypercat:rels:isContentType', + ]: + self.assertIn(property, relations) + + self.assertIn('Met Office', + _get_item_by_key_value(result, 'rel', 'urn:X-bt:rels:feedTitle')['val']) + + self.assertIn('Met Office', + _get_item_by_key_value(result, 'rel', 'urn:X-hypercat:rels:hasDescription:en')['val']) + + self.assertEqual(1, + _count_items_by_key_value(result, 'rel', 'urn:X-bt:rels:feedTag')) + + self.assertGreaterEqual(_count_items_by_key_value(result, 'rel', 'urn:X-bt:rels:hasSensorStream'), + 1) + + def test_plugin_get_dataset_data(self): + """ + Test that we can get data from a single dataset within the catalogue. + """ + connection = self._get_connection() + + dataset = connection[self.dataset] + result = dataset.get_data() + + self.assertIsInstance(result, str) + self.assertGreaterEqual(len(result), 1) + self.assertIn('c7f361c6-7cb7-4ef5-aed9-397a0c0c4088', + result) + + +class ConnectorHyperCatCiscoTest(TestCase): + url = 'https://api.cityverve.org.uk/v1/cat' + subcatalogue = 'https://api.cityverve.org.uk/v1/cat/polling-station' + dataset = 'https://api.cityverve.org.uk/v1/entity/polling-station/5' + + def _get_connection(self) -> BaseDataConnector: + return self.plugin(self.url, + api_key=self.api_key, + auth=HttpHeaderAuth) + + def setUp(self): + from decouple import config + + BaseDataConnector.load_plugins('datasources/connectors') + self.plugin = BaseDataConnector.get_plugin('HyperCat') + + self.api_key = config('HYPERCAT_CISCO_API_KEY') + self.auth = None + + def test_get_plugin(self): + self.assertIsNotNone(self.plugin) + + def test_plugin_init(self): + connection = self._get_connection() + + self.assertEqual(connection.location, self.url) + + def test_plugin_type(self): + connection = self._get_connection() + + self.assertTrue(connection.is_catalogue) + + def test_plugin_get_catalogue_metadata(self): + connection = self._get_connection() + + result = connection.get_metadata() + + relations = [relation['rel'] for relation in result] + for property in [ + 'urn:X-hypercat:rels:hasDescription:en', + 'urn:X-hypercat:rels:isContentType', + 'urn:X-hypercat:rels:hasHomepage', + ]: + self.assertIn(property, relations) + + self.assertEqual('CityVerve Public API - master catalogue', + _get_item_by_key_value(result, 'rel', 'urn:X-hypercat:rels:hasDescription:en')['val']) + + self.assertEqual('application/vnd.hypercat.catalogue+json', + _get_item_by_key_value(result, 'rel', 'urn:X-hypercat:rels:isContentType')['val']) + + self.assertEqual('https://developer.cityverve.org.uk', + _get_item_by_key_value(result, 'rel', 'urn:X-hypercat:rels:hasHomepage')['val']) + + def test_plugin_get_datasets(self): + connection = self._get_connection() + + datasets = connection.get_datasets() + + self.assertEqual(list, + type(datasets)) + + self.assertLessEqual(1, + len(datasets)) + + # Only check a couple of expected results are present - there's too many to list here + expected = { + 'https://api.cityverve.org.uk/v1/cat/accident', + 'https://api.cityverve.org.uk/v1/cat/advertising-board', + 'https://api.cityverve.org.uk/v1/cat/advertising-post', + # And because later tests rely on it... + 'https://api.cityverve.org.uk/v1/cat/polling-station', + } + for exp in expected: + self.assertIn(exp, datasets) + + def test_plugin_get_subcatalogue_metadata(self): + connection = self._get_connection() + + result = connection[self.subcatalogue].get_metadata() + + relations = [relation['rel'] for relation in result] + for property in [ + 'urn:X-hypercat:rels:hasDescription:en', + 'urn:X-hypercat:rels:isContentType', + 'urn:X-hypercat:rels:hasHomepage', + ]: + self.assertIn(property, relations) + + self.assertEqual('CityVerve Public API - polling-station catalogue', + _get_item_by_key_value(result, 'rel', 'urn:X-hypercat:rels:hasDescription:en')['val']) + + self.assertEqual('application/vnd.hypercat.catalogue+json', + _get_item_by_key_value(result, 'rel', 'urn:X-hypercat:rels:isContentType')['val']) + + self.assertEqual('https://developer.cityverve.org.uk', + _get_item_by_key_value(result, 'rel', 'urn:X-hypercat:rels:hasHomepage')['val']) + + def test_plugin_get_subcatalogue_datasets(self): + connection = self._get_connection() + + subcatalogue = connection[self.subcatalogue] + datasets = subcatalogue.get_datasets() + + self.assertEqual(list, + type(datasets)) + + self.assertLessEqual(1, + len(datasets)) + + # Only check a couple of expected results are present - there's too many to list here + expected = { + 'https://api.cityverve.org.uk/v1/entity/polling-station/5', + 'https://api.cityverve.org.uk/v1/entity/polling-station/3', + 'https://api.cityverve.org.uk/v1/entity/polling-station/4', + } + for exp in expected: + self.assertIn(exp, datasets) + + def test_plugin_get_subcatalogue_dataset_metadata(self): + connection = self._get_connection() + + subcatalogue = connection[self.subcatalogue] + dataset = subcatalogue[self.dataset] + result = dataset.get_metadata() + + relations = [relation['rel'] for relation in result] + for property in [ + 'urn:X-cityverve:rels:id', + 'urn:X-cityverve:rels:name', + 'urn:X-cityverve:rels:type', + 'http://www.w3.org/2003/01/geo/wgs84_pos#long', + 'http://www.w3.org/2003/01/geo/wgs84_pos#lat', + 'urn:X-cityverve:rels:entity.district', + 'urn:X-cityverve:rels:entity.ward', + ]: + self.assertIn(property, relations) + + self.assertEqual('5', + _get_item_by_key_value(result, 'rel', 'urn:X-cityverve:rels:id')['val']) + + self.assertEqual('Cadishead : 5', + _get_item_by_key_value(result, 'rel', 'urn:X-cityverve:rels:name')['val']) + + self.assertEqual('polling-station', + _get_item_by_key_value(result, 'rel', 'urn:X-cityverve:rels:type')['val']) + + def test_plugin_get_subcatalogue_dataset_data(self): + connection = self._get_connection() + + subcatalogue = connection[self.subcatalogue] + dataset = subcatalogue[self.dataset] + data = dataset.get_data() + + self.assertEqual(list, + type(data)) + + self.assertEqual(1, + len(data)) + + data_entry = data[0] + + self.assertEqual(dict, + type(data_entry)) + + for property in [ + 'id', + 'uri', + 'type', + 'name', + 'loc', + 'entity', + 'instance', + 'legal', + ]: + self.assertIn(property, data_entry) + + self.assertEqual('5', + data_entry['id']) + + self.assertEqual(self.dataset, + data_entry['uri']) + + self.assertEqual('polling-station', + data_entry['type']) diff --git a/datasources/urls.py b/datasources/urls.py index d96f8c95e86741d07d9b006480f1611449da97bc..f8f94d360cdfd75d0baed7a309fe37363be74c2a 100644 --- a/datasources/urls.py +++ b/datasources/urls.py @@ -6,22 +6,72 @@ app_name = 'datasources' urlpatterns = [ path('', - views.DataSourceListView.as_view(), + views.datasource.DataSourceListView.as_view(), name='datasource.list'), + path('add', + views.datasource.DataSourceCreateView.as_view(), + name='datasource.add'), + path('<int:pk>/', - views.DataSourceDetailView.as_view(), + views.datasource.DataSourceDetailView.as_view(), name='datasource.detail'), - path('<int:pk>/manage-access', - views.DataSourceManageAccessView.as_view(), - name='datasource.manage-access'), + path('<int:pk>/edit', + views.datasource.DataSourceUpdateView.as_view(), + name='datasource.edit'), - path('<int:pk>/query', - views.DataSourceQueryView.as_view(), - name='datasource.query'), + path('<int:pk>/delete', + views.datasource.DataSourceDeleteView.as_view(), + name='datasource.delete'), path('<int:pk>/metadata', - views.DataSourceMetadataView.as_view(), + views.datasource.DataSourceMetadataAjaxView.as_view(), name='datasource.metadata'), + + path('<int:pk>/explorer', + views.datasource.DataSourceExplorerView.as_view(), + name='datasource.explorer'), + + path('<int:pk>/search', + views.datasource.DataSourceDataSetSearchView.as_view(), + name='datasource.dataset.search'), + + ########## + # Licences + + path('licences', + views.licence.LicenceListView.as_view(), + name='licence.list'), + + path('licences/add', + views.licence.LicenceCreateView.as_view(), + name='licence.add'), + + path('licences/<int:pk>/', + views.licence.LicenceDetailView.as_view(), + name='licence.detail'), + + path('licences/<int:pk>/edit', + views.licence.LicenceUpdateView.as_view(), + name='licence.edit'), + + path('licences/<int:pk>/delete', + views.licence.LicenceDeleteView.as_view(), + name='licence.delete'), + + ####################### + # Permission management + + path('<int:pk>/access', + views.user_permission_link.DataSourceAccessManageView.as_view(), + name='datasource.access.manage'), + + path('<int:pk>/access/request', + views.user_permission_link.DataSourceAccessRequestView.as_view(), + name='datasource.access.request'), + + path('<int:pk>/access/grant', + views.user_permission_link.DataSourceAccessGrantView.as_view(), + name='datasource.access.grant'), ] diff --git a/datasources/views.py b/datasources/views.py deleted file mode 100644 index 6e49cfc2518a0ab6a4eeecac05b693550bc4b31d..0000000000000000000000000000000000000000 --- a/datasources/views.py +++ /dev/null @@ -1,64 +0,0 @@ -from django.views.generic.detail import DetailView -from django.views.generic.list import ListView - -from profiles.permissions import OwnerPermissionRequiredMixin -from datasources import models - - -class DataSourceListView(ListView): - model = models.DataSource - template_name = 'datasources/datasource/list.html' - context_object_name = 'datasources' - - -class DataSourceDetailView(DetailView): - model = models.DataSource - template_name = 'datasources/datasource/detail.html' - context_object_name = 'datasource' - - def get_template_names(self): - if not self.object.has_view_permission(self.request.user): - return ['datasources/datasource/detail-no-access.html'] - return super().get_template_names() - - -class DataSourceManageAccessView(OwnerPermissionRequiredMixin, DetailView): - model = models.DataSource - template_name = 'datasources/datasource/manage_access.html' - context_object_name = 'datasource' - - permission_required = 'datasources.change_datasource' - - -class DataSourceQueryView(DetailView): - model = models.DataSource - template_name = 'datasources/datasource/query.html' - context_object_name = 'datasource' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - - context['results'] = self.object.data_connector.get_data( - query_params={'year': 2018} - ) - - return context - - -class DataSourceMetadataView(DetailView): - model = models.DataSource - template_name = 'datasources/datasource/metadata.html' - context_object_name = 'datasource' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - - # Using data connector context manager saves API queries - with self.object.data_connector as dc: - context['metadata'] = dc.get_metadata() - context['datasets'] = { - dataset: dc.get_metadata(dataset) - for dataset in dc.get_datasets() - } - - return context diff --git a/datasources/views/__init__.py b/datasources/views/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a9638065b7f3d3f840e2ba8905802847088e2825 --- /dev/null +++ b/datasources/views/__init__.py @@ -0,0 +1 @@ +from . import datasource, licence, user_permission_link \ No newline at end of file diff --git a/datasources/views/datasource.py b/datasources/views/datasource.py new file mode 100644 index 0000000000000000000000000000000000000000..736d4ea966dcb290b236bf71845668872d1cc1bf --- /dev/null +++ b/datasources/views/datasource.py @@ -0,0 +1,201 @@ +from django.contrib import messages +from django.contrib.auth.mixins import PermissionRequiredMixin +from django.http import HttpResponse +from django.urls import reverse_lazy +from django.views.generic.detail import DetailView +from django.views.generic.edit import CreateView, DeleteView, UpdateView +from django.views.generic.list import ListView + +from rest_framework.response import Response +from rest_framework import serializers +from rest_framework.views import APIView +import requests.exceptions + +from core.permissions import OwnerPermissionMixin +from datasources import forms, models +from datasources.permissions import HasPermissionLevelMixin + + +class DataSourceListView(ListView): + model = models.DataSource + template_name = 'datasources/datasource/list.html' + context_object_name = 'datasources' + + +class DataSourceDetailView(DetailView): + model = models.DataSource + template_name = 'datasources/datasource/detail.html' + context_object_name = 'datasource' + + def get_template_names(self): + if not self.object.has_view_permission(self.request.user): + return ['datasources/datasource/detail-no-access.html'] + return super().get_template_names() + + def get_context_data(self, **kwargs): + 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 + except (KeyError, ValueError): + messages.error(self.request, 'This data source is not configured correctly. Please notify the owner.') + + return context + + +class DataSourceCreateView(PermissionRequiredMixin, CreateView): + model = models.DataSource + template_name = 'datasources/datasource/create.html' + context_object_name = 'datasource' + + form_class = forms.DataSourceForm + permission_required = 'datasources.add_datasource' + + def form_valid(self, form): + try: + owner = form.instance.owner + + except models.DataSource.owner.RelatedObjectDoesNotExist: + form.instance.owner = self.request.user + + return super().form_valid(form) + + +class DataSourceUpdateView(OwnerPermissionMixin, UpdateView): + model = models.DataSource + template_name = 'datasources/datasource/update.html' + context_object_name = 'datasource' + + form_class = forms.DataSourceForm + + +class DataSourceDeleteView(OwnerPermissionMixin, DeleteView): + model = models.DataSource + template_name = 'datasources/datasource/delete.html' + context_object_name = 'datasource' + + success_url = reverse_lazy('datasources:datasource.list') + + +class DataSourceDataSetSearchView(HasPermissionLevelMixin, DetailView): + model = models.DataSource + template_name = 'datasources/datasource/dataset_search.html' + context_object_name = 'datasource' + + permission_level = models.UserPermissionLevels.META + + def get(self, request, *args, **kwargs): + try: + return super().get(request, *args, **kwargs) + except requests.exceptions.HTTPError as e: + return HttpResponse( + 'API call failed', + # Pass status code through unless it was 200 OK + status=424 if e.response.status_code == 200 else e.response.status_code + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + connector = self.object.data_connector + try: + datasets = list(connector.items( + params={ + 'prefix-val': self.request.GET.get('q') + } + )) + context['datasets'] = datasets + + # Check the metadata format of the first dataset + # TODO will all metadata formats be the same + if isinstance(datasets[0][1].get_metadata(), list): + context['metadata_type'] = 'list' + else: + context['metadata_type'] = 'dict' + + except AttributeError: + # DataSource is not a catalogue + pass + + 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__short_name=self.request.data['field'], + value=self.request.data['value'] + ) + + metadata_item.delete() + + return Response({ + 'status': 'success' + }) + + +class DataSourceExplorerView(HasPermissionLevelMixin, DetailView): + model = models.DataSource + template_name = 'datasources/datasource/explorer.html' + context_object_name = 'datasource' + + permission_level = models.UserPermissionLevels.META + + def get_context_data(self, **kwargs): + context = super().get_context_data() + + context['data_query_params'] = self.object.metadata_items.filter( + field__short_name='data_query_param' + ).values_list('value', flat=True) + + return context diff --git a/datasources/views/licence.py b/datasources/views/licence.py new file mode 100644 index 0000000000000000000000000000000000000000..4b90a3694fad9e37d22ee0241c19ee8b0e5448f9 --- /dev/null +++ b/datasources/views/licence.py @@ -0,0 +1,68 @@ +from django.contrib.auth.mixins import PermissionRequiredMixin +from django.urls import reverse_lazy +from django.views.generic import CreateView, DeleteView, DetailView, ListView, UpdateView + +from .. import forms, models +from core.permissions import OwnerPermissionMixin + + +class LicenceListView(ListView): + model = models.Licence + template_name = 'datasources/licence/list.html' + context_object_name = 'licences' + + +class LicenceCreateView(PermissionRequiredMixin, CreateView): + model = models.Licence + template_name = 'datasources/licence/create.html' + context_object_name = 'licence' + + form_class = forms.LicenceForm + permission_required = 'datasources.add_licence' + + def form_valid(self, form): + try: + owner = form.instance.owner + + except models.Licence.owner.RelatedObjectDoesNotExist: + form.instance.owner = self.request.user + + return super().form_valid(form) + + +class LicenceDetailView(DetailView): + model = models.Licence + template_name = 'datasources/licence/detail.html' + context_object_name = 'licence' + + def get_context_data(self, **kwargs): + context = super().get_context_data() + + context['has_edit_permission'] = self.request.user.is_superuser or self.request.user == self.object.owner + + return context + + +class LicenceUpdateView(OwnerPermissionMixin, UpdateView): + model = models.Licence + template_name = 'datasources/licence/update.html' + context_object_name = 'licence' + + form_class = forms.LicenceForm + + def form_valid(self, form): + try: + owner = form.instance.owner + + except models.Licence.owner.RelatedObjectDoesNotExist: + form.instance.owner = self.request.user + + return super().form_valid(form) + + +class LicenceDeleteView(OwnerPermissionMixin, DeleteView): + model = models.Licence + template_name = 'datasources/licence/delete.html' + context_object_name = 'licence' + + success_url = reverse_lazy('datasources:licence.list') diff --git a/datasources/views/user_permission_link.py b/datasources/views/user_permission_link.py new file mode 100644 index 0000000000000000000000000000000000000000..f9d019c66f3b36f94f30e8a89f06b957e169828e --- /dev/null +++ b/datasources/views/user_permission_link.py @@ -0,0 +1,214 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.mixins import LoginRequiredMixin +from django.db.models import F, ObjectDoesNotExist +from django.http import HttpResponseRedirect +from django.shortcuts import reverse +from django.views.generic.detail import DetailView +from django.views.generic.edit import UpdateView + +from profiles.permissions import OwnerPermissionRequiredMixin +from datasources import forms, models + + +class DataSourceAccessManageView(OwnerPermissionRequiredMixin, DetailView): + model = models.DataSource + template_name = 'datasources/datasource/manage_access.html' + context_object_name = 'datasource' + + permission_required = 'datasources.change_datasource' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context['all_users'] = get_user_model().objects + + context['permissions_requested'] = models.UserPermissionLink.objects.filter( + datasource=self.object, + requested__gt=F('granted') + ).union( + models.UserPermissionLink.objects.filter( + datasource=self.object, + ).exclude( + push_requested=F('push_granted') + ) + ) + + context['permissions_granted'] = models.UserPermissionLink.objects.filter( + datasource=self.object, + requested__lte=F('granted'), + push_requested=F('push_granted') + ) + + return context + + +# TODO check permissions +class DataSourceAccessGrantView(LoginRequiredMixin, UpdateView): + """ + Manage a user's access to a DataSource. + + Provides a form view to edit permissions, but permissions may also be set using an AJAX POST request. + """ + model = models.UserPermissionLink + form_class = forms.PermissionGrantForm + context_object_name = 'permission' + template_name = 'datasources/user_permission_link/permission_grant.html' + + def get_context_data(self, **kwargs): + """ + Add data source to the context. + """ + context = super().get_context_data() + + context['datasource'] = models.DataSource.objects.get(pk=self.kwargs['pk']) + + return context + + def get_object(self, queryset=None): + """ + Get or create a permission object for the relevant user. + """ + self.datasource = models.DataSource.objects.get(pk=self.kwargs['pk']) + + try: + user = get_user_model().objects.get(id=self.request.POST.get('user')) + + except get_user_model().DoesNotExist: + user = get_user_model().objects.get(id=self.request.GET.get('user')) + + obj, created = self.model.objects.get_or_create( + user=user, + datasource=self.datasource + ) + + # Set default value to approve request - but do not automatically save this + obj.granted = obj.requested + obj.push_granted = obj.push_requested + + return obj + + def form_valid(self, form): + """ + Automatically grant requests which are either: + - Edited by owner / admin + - Requests for a reduction in permission level + """ + form.instance.requested = form.instance.granted + form.instance.push_requested = form.instance.push_granted + + if form.instance.requested == models.UserPermissionLevels.NONE and not form.instance.push_requested: + form.instance.delete() + + else: + form.instance.save() + + return HttpResponseRedirect(self.get_success_url()) + + def get_success_url(self): + """ + Return to access management view. + """ + return reverse('datasources:datasource.access.manage', kwargs={'pk': self.datasource.pk}) + + +class DataSourceAccessRequestView(LoginRequiredMixin, UpdateView): + """ + Request access to a data source, or request changes to an existing permission. + + Provides a form view to edit permission requests, but permissions may also be requested using an AJAX POST request. + """ + model = models.UserPermissionLink + form_class = forms.PermissionRequestForm + template_name = 'datasources/user_permission_link/permission_request.html' + + def get_context_data(self, **kwargs): + """ + Add data source to the context. + """ + context = super().get_context_data() + + context['datasource'] = models.DataSource.objects.get(pk=self.kwargs['pk']) + + return context + + def get_object(self, queryset=None): + """ + Get or create a permission object for the relevant user. + """ + self.datasource = models.DataSource.objects.get(pk=self.kwargs['pk']) + user = self.request.user + + if self.request.user == self.datasource.owner or self.request.user.is_superuser: + try: + # Let owner and admins edit other user's requests + user = get_user_model().objects.get(id=self.request.GET.get('user')) + + except ObjectDoesNotExist: + pass + + try: + # Is there an existing record? + obj = self.model.objects.get( + user=user, + datasource=self.datasource + ) + + except self.model.DoesNotExist: + # Don't save to DB - only temporary + obj = self.model( + user=user, + datasource=self.datasource + ) + + return obj + + def get_form(self, form_class=None): + """ + Get form with choices and default value set for user field. + """ + # Authenticated user and any applications for which they are responsible + user_choices = [(self.request.user.pk, self.request.user.username)] + user_choices += [(app.proxy_user.pk, 'App: ' + app.name) for app in self.request.user.applications.all()] + + user_field_hidden = len(user_choices) <= 1 + + if form_class is None: + form_class = self.get_form_class() + + return form_class( + user_choices=user_choices, + user_initial=self.request.user, + user_field_hidden=user_field_hidden, + **self.get_form_kwargs() + ) + + def form_valid(self, form): + """ + Automatically grant requests which are either: + - Edited by owner / admin + - Requests for a reduction in permission level + """ + if form.instance.requested == models.UserPermissionLevels.NONE: + form.instance.delete() + + else: + if ( + (self.request.user == self.datasource.owner or self.request.user.is_superuser) or + form.instance.granted > form.instance.requested + ): + form.instance.granted = form.instance.requested + + form.save() + + return HttpResponseRedirect(self.get_success_url()) + + def get_success_url(self): + """ + Return to the data source or access management view depending on user class. + """ + if self.request.user == self.datasource.owner or self.request.user.is_superuser: + return reverse('datasources:datasource.access.manage', kwargs={'pk': self.datasource.pk}) + + return reverse('datasources:datasource.detail', kwargs={'pk': self.datasource.pk}) + + diff --git a/deploy/nginx/sites-available/pedasi b/deploy/nginx/sites-available/pedasi index 99122e917b0d8dcced6d6055678b594ea9e17989..6d405e53bf3b4e4866a20db25afd339d73de162f 100644 --- a/deploy/nginx/sites-available/pedasi +++ b/deploy/nginx/sites-available/pedasi @@ -1,14 +1,23 @@ server { listen 80; - server_name localhost; + server_name localhost pedasi.* pedasi-dev.* *.iotobservatory.io; + + merge_slashes off; location /favicon.ico { alias /var/www/pedasi/static/img/favicon.ico; } + location /static/ { alias /var/www/pedasi/static/; } + location = /report.html { + file /var/www/pedasi/report.html; + auth_basic "Restricted Content"; + auth_basic_user_file /etc/nginx/.htpasswd; + } + location / { include uwsgi_params; uwsgi_pass unix:/run/uwsgi/pedasi.sock; diff --git a/deploy/nginx/sites-available/pedasi.dev b/deploy/nginx/sites-available/pedasi.dev new file mode 100644 index 0000000000000000000000000000000000000000..e7a65daa508ee3282fc157c7a3bd830886fecf74 --- /dev/null +++ b/deploy/nginx/sites-available/pedasi.dev @@ -0,0 +1,24 @@ +server { + listen 80; + + merge_slashes off; + + location = /favicon.ico { + alias /var/www/pedasi/static/img/favicon.ico; + } + + location /static/ { + alias /var/www/pedasi/static/; + } + + location = /report.html { + alias /var/www/pedasi/report.html; + auth_basic "Restricted Content"; + auth_basic_user_file /etc/nginx/.htpasswd; + } + + location / { + include uwsgi_params; + uwsgi_pass unix:/run/uwsgi/pedasi.sock; + } +} diff --git a/pedasi/common/base_models.py b/pedasi/common/base_models.py deleted file mode 100644 index 358d06d2299b1edd9ba21a6b8a0589b537c24d9e..0000000000000000000000000000000000000000 --- a/pedasi/common/base_models.py +++ /dev/null @@ -1,23 +0,0 @@ -from django.db import models - - -#: Length of CharFields used to hold the names of objects -MAX_LENGTH_NAME = 63 - - -class BaseAppDataModel(models.Model): - #: Friendly name of this application - name = models.CharField(max_length=MAX_LENGTH_NAME, - blank=False, null=False) - - #: A brief description - description = models.TextField(blank=True, null=False) - - #: Address at which the API may be accessed - url = models.URLField(blank=False, null=False) - - def __str__(self): - return self.name - - class Meta: - abstract = True diff --git a/pedasi/routers.py b/pedasi/routers.py deleted file mode 100644 index 6d935dd92e442684a20c144ffffc577181c6dffa..0000000000000000000000000000000000000000 --- a/pedasi/routers.py +++ /dev/null @@ -1,32 +0,0 @@ -class DefaultRouter: - """ - Django database router to route all models to the default database. - """ - - db_name = 'default' - - def db_for_read(self, model, **hints): - """ - Read from default database. - """ - return self.db_name - - def db_for_write(self, model, **hints): - """ - Write to default database. - """ - return self.db_name - - def allow_relation(self, obj1, obj2, **hints): - """ - Allow relation if both objects are stored in the default database. - """ - if obj1._state.db == self.db_name and obj2._state.db == self.db_name: - return True - return None - - def allow_migrate(self, db, app_label, model_name=None, **hints): - """ - Always allow migrations. - """ - return True diff --git a/pedasi/settings.py b/pedasi/settings.py index 8f4ba12ee20204b5031025ca936792e06843b277..990ea0db8172b2ab1c2ff3b408188fd9b41369cd 100644 --- a/pedasi/settings.py +++ b/pedasi/settings.py @@ -43,6 +43,7 @@ from django.urls import reverse_lazy from decouple import config import dj_database_url +import mongoengine # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -53,7 +54,16 @@ SECRET_KEY = config('SECRET_KEY') # SECURITY WARNING: don't run with debug turned on in production! DEBUG = config('DEBUG', default=False, cast=bool) -ALLOWED_HOSTS = [] +if DEBUG: + ALLOWED_HOSTS = [ + '*', + ] + +else: + ALLOWED_HOSTS = [ + 'localhost', + 'pedasi-dev.eastus.cloudapp.azure.com', + ] # Application definition @@ -68,20 +78,28 @@ DJANGO_APPS = [ ] THIRD_PARTY_APPS = [ + 'corsheaders', 'bootstrap4', + 'haystack', + 'rest_framework', + 'rest_framework.authtoken', + 'social_django', ] CUSTOM_APPS = [ - 'profiles', + 'profiles.apps.ProfilesConfig', # Refer to AppConfig directly since we override the .ready() method 'applications', 'datasources', - 'prov', + 'provenance', + 'core', + 'api', ] # Custom apps have to be listed before Django apps so they override default templates INSTALLED_APPS = CUSTOM_APPS + THIRD_PARTY_APPS + DJANGO_APPS MIDDLEWARE = [ + 'corsheaders.middleware.CorsMiddleware', 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', @@ -104,6 +122,7 @@ TEMPLATES = [ 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', + 'social_django.context_processors.backends', ], }, }, @@ -121,21 +140,35 @@ DATABASES = { default='sqlite:///' + os.path.join(BASE_DIR, 'db.sqlite3'), cast=dj_database_url.parse ), - # Separate PROV models into their own MongoDB database - 'prov': { - 'ENGINE': 'djongo', - 'NAME': config( - 'PROV_DATABASE_NAME', - default='prov' - ) +} + +mongoengine.register_connection( + host=config( + 'PROV_DATABASE_URL', + default='mongodb://localhost/prov', + ), + alias='default' +) + +mongoengine.register_connection( + host=config( + 'INTERNAL_DATABASE_URL', + default='mongodb://localhost/internal_data' + ), + alias='internal_data', +) + + +# Search backend + +HAYSTACK_CONNECTIONS = { + 'default': { + 'ENGINE': 'haystack.backends.whoosh_backend.WhooshEngine', + 'PATH': os.path.join(BASE_DIR, 'whoosh_index'), } } -# Database routers direct models into the correct database -DATABASE_ROUTERS = [ - 'prov.routers.ProvRouter', - 'pedasi.routers.DefaultRouter', -] +HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor' # Password validation @@ -156,6 +189,72 @@ AUTH_PASSWORD_VALIDATORS = [ }, ] + +# Social auth app configuration + +SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = config('SOCIAL_AUTH_GOOGLE_OAUTH2_KEY', default=None) +SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = config('SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET', default=None) + +AUTHENTICATION_BACKENDS = [ + 'django.contrib.auth.backends.ModelBackend', +] + +if SOCIAL_AUTH_GOOGLE_OAUTH2_KEY is not None and SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET is not None: + AUTHENTICATION_BACKENDS += [ + 'social_core.backends.google.GoogleOAuth2', + ] + +SOCIAL_AUTH_PIPELINE = [ + # Get the information we can about the user and return it in a simple + # format to create the user instance later. On some cases the details are + # already part of the auth response from the provider, but sometimes this + # could hit a provider API. + 'social_core.pipeline.social_auth.social_details', + + # Get the social uid from whichever service we're authing thru. The uid is + # the unique identifier of the given user in the provider. + 'social_core.pipeline.social_auth.social_uid', + + # Verifies that the current auth process is valid within the current + # project, this is where emails and domains whitelists are applied (if + # defined). + 'social_core.pipeline.social_auth.auth_allowed', + + # Checks if the current social-account is already associated in the site. + 'social_core.pipeline.social_auth.social_user', + + # Make up a username for this person, appends a random string at the end if + # there's any collision. + 'social_core.pipeline.user.get_username', + + # Send a validation email to the user to verify its email address. + # Disabled by default. + # 'social_core.pipeline.mail.mail_validation', + + # Associates the current social details with another user account with + # a similar email address. Disabled by default. + # 'social_core.pipeline.social_auth.associate_by_email', + + # Create a user account if we haven't found one yet. + 'social_core.pipeline.user.create_user', + # 'profiles.social_auth.create_user_disabled', + + # Create the record that associates the social account with the user. + 'social_core.pipeline.social_auth.associate_user', + + # Populate the extra_data field in the social record with the values + # specified by settings (and the default ones like access_token, etc). + 'social_core.pipeline.social_auth.load_extra_data', + + # Update the user record with any changed info from the auth service. + 'social_core.pipeline.user.user_details', +] + +SOCIAL_AUTH_LOGIN_REDIRECT_URL = reverse_lazy('index') +LOGIN_REDIRECT_URL = reverse_lazy('index') +SOCIAL_AUTH_INACTIVE_USER_URL = reverse_lazy('profiles:inactive') + + # Use Argon2 as hashing algorithm for new passwords PASSWORD_HASHERS = [ @@ -172,6 +271,26 @@ AUTH_USER_MODEL = 'profiles.User' LOGIN_URL = reverse_lazy('profiles:login') +# Application API config +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + # Authenticate using tokens for normal API use + 'rest_framework.authentication.TokenAuthentication', + # Allow logged in users to explore the API through the PEDASI web interface + 'rest_framework.authentication.SessionAuthentication', + ], + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated', + ] +} + +# Add CORS exemption to API endpoints + +CORS_ORIGIN_ALLOW_ALL = True + +CORS_URLS_REGEX = r'^/api/.*$' + + # Internationalization # https://docs.djangoproject.com/en/2.0/topics/i18n/ @@ -193,5 +312,6 @@ STATIC_URL = '/static/' STATIC_ROOT = os.path.join(BASE_DIR, 'static') STATICFILES_DIRS = [ - os.path.join(BASE_DIR, 'docs', 'build') + os.path.join(BASE_DIR, 'pedasi', 'static'), + os.path.join(BASE_DIR, 'docs', 'build'), ] diff --git a/pedasi/static/css/pedasi.css b/pedasi/static/css/pedasi.css new file mode 100644 index 0000000000000000000000000000000000000000..52c97bf22ccef3469cef9d38ac9280f9c212c692 --- /dev/null +++ b/pedasi/static/css/pedasi.css @@ -0,0 +1,21 @@ +a.no-hover { + color: inherit; + text-decoration: none; +} + +html { + position: relative; + min-height: 100%; +} + +body { + margin-bottom: 75px; +} + +.footer { + position: absolute; + bottom: 0; + width: 100%; + height: 60px; + line-height: 60px; +} diff --git a/pedasi/static/img/IoT_observatory.png b/pedasi/static/img/IoT_observatory.png new file mode 100644 index 0000000000000000000000000000000000000000..589ea658fee8b966cb46e032135e2463ce2ff56e Binary files /dev/null and b/pedasi/static/img/IoT_observatory.png differ diff --git a/pedasi/static/img/favicon.ico b/pedasi/static/img/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..0cf908313b08eea29cf38d64c98e0681385214aa Binary files /dev/null and b/pedasi/static/img/favicon.ico differ diff --git a/pedasi/templates/base.html b/pedasi/templates/base.html index 0a6bf29dfef7fb145e6bb214c22280aadbcdede5..6fd529e68ab1a120342c7a0ef29b05142f01d3f4 100644 --- a/pedasi/templates/base.html +++ b/pedasi/templates/base.html @@ -4,73 +4,119 @@ <html lang="en"> <head> <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> + <title>PEDASI</title> + {% load static %} + <link rel="shortcut icon" href="{% static 'img/favicon.ico' %}" type="image/vnd.microsoft.icon"/> + {% bootstrap_css %} - {% bootstrap_javascript jquery=True %} <!-- Font Awesome - https://fontawesome.com --> <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.2.0/css/solid.css" integrity="sha384-wnAC7ln+XN0UKdcPvJvtqIH3jOjs9pnKnq9qX68ImXvOGz2JuFoEiCjT8jyZQX2z" crossorigin="anonymous"> + <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.2.0/css/fontawesome.css" integrity="sha384-HbmWTHay9psM8qyzEKPc8odH4DsOuzdejtnr+OFtDmOcIVnhgReQ4GZBH7uwcjf6" crossorigin="anonymous"> + <!-- BootSwatch Journal theme --> + <link href="https://stackpath.bootstrapcdn.com/bootswatch/4.1.3/journal/bootstrap.min.css" + rel="stylesheet" + integrity="sha384-5C8TGNupopdjruopVTTrVJacBbWqxHK9eis5DB+DYE6RfqIJapdLBRUdaZBTq7mE" + crossorigin="anonymous"> + + <link rel="stylesheet" href="{% static 'css/pedasi.css' %}"> + {% block extra_head %} {% endblock %} </head> <body> - <nav class="navbar navbar-expand-lg navbar-light bg-light"> - <a class="navbar-brand" href="/">PEDASI</a> - <button class="navbar-toggler" type="button" - data-toggle="collapse" data-target="#navbarSupportedContent" - aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation"> - <span class="navbar-toggler-icon"></span> - </button> - - <div class="collapse navbar-collapse" id="navbarSupportedContent"> - <ul class="navbar-nav mr-auto"> - <li class="nav-item"> - <a class="nav-link" href="{% url 'applications:application.list' %}">Applications</a> - </li> - <li class="nav-item"> - <a class="nav-link" href="{% url 'datasources:datasource.list' %}">Data Sources</a> - </li> - </ul> - - <ul class="navbar-nav"> - {% if request.user.is_authenticated %} + <nav class="navbar navbar-expand-lg navbar-dark bg-primary"> + <div class="container"> + <a class="navbar-brand" href="/">PEDASI</a> + <button class="navbar-toggler" type="button" + data-toggle="collapse" data-target="#navbarSupportedContent" + aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation"> + <span class="navbar-toggler-icon"></span> + </button> + + <div class="collapse navbar-collapse" id="navbarSupportedContent"> + <ul class="navbar-nav mr-auto"> <li class="nav-item"> - <a class="nav-link" href="{% url 'profiles:profile' %}">My Profile</a> + <a class="nav-link" href="{% url 'applications:application.list' %}">Applications</a> </li> + <li class="nav-item"> - <a class="nav-link" href="{% url 'profiles:logout' %}">Logout</a> + <a class="nav-link" href="{% url 'datasources:datasource.list' %}">Data Sources</a> </li> - {% else %} + <li class="nav-item"> - <a class="nav-link" href="{% url 'profiles:login' %}">Login</a> + <a class="nav-link" href="{% url 'datasources:licence.list' %}">Licences</a> </li> - {% endif %} - </ul> + + {% if request.user.is_superuser %} + <li class="nav-item"> + <a class="nav-link" href="{% url 'admin:index' %}">Admin Panel</a> + </li> + {% endif %} + </ul> + + <ul class="navbar-nav"> + {% if request.user.is_authenticated %} + <span class="navbar-text"> + Welcome {{ request.user.username }} + </span> + + <li class="nav-item"> + <a class="nav-link" href="{% url 'profiles:profile' %}">My Profile</a> + </li> + + <li class="nav-item"> + <a class="nav-link" href="{% url 'profiles:logout' %}">Logout</a> + </li> + + {% else %} + {% if 'google-oauth2' in backends.backends %} + <li class="nav-item"> + <a class="nav-link" href="{% url 'social:begin' 'google-oauth2' %}">Google Login</a> + </li> + {% endif %} + + <li class="nav-item"> + <a class="nav-link" href="{% url 'profiles:login' %}">Login</a> + </li> + {% endif %} + </ul> + </div> </div> </nav> {% block pre_content %} {% endblock %} - <div class="container"> - <div class="mt-3"></div> - + <div class="container mt-3"> {% bootstrap_messages %} {% block content %} {% endblock %} </div> + <footer class="footer bg-primary"> + <div class="container"> + <p class="m-0 text-center text-white">PEDASI: IoT Observatory Demonstrator</p> + </div> + </footer> + + {% bootstrap_javascript jquery=True %} + + {% block extra_body %} + {% endblock %} </body> </html> \ No newline at end of file diff --git a/pedasi/templates/search/search.html b/pedasi/templates/search/search.html new file mode 100644 index 0000000000000000000000000000000000000000..c86673a9bec3fbdac9369b09bd40bd08952a66a4 --- /dev/null +++ b/pedasi/templates/search/search.html @@ -0,0 +1,45 @@ +{% extends 'base.html' %} + +{% load bootstrap4 %} + +{% block content %} + <h2>Search</h2> + + <form method="get" action="."> + <table> + {% bootstrap_form form %} + <tr> + <td> </td> + <td> + <input type="submit" value="Search"> + </td> + </tr> + </table> + + {% if query %} + <h3>Results</h3> + + {% for result in page.object_list %} + <p> + <a href="{{ result.object.get_absolute_url }}">{{ result.object.name }}</a> + </p> + <p> + {% load highlight %} + {% highlight result.text with query html_tag "mark" %} + </p> + {% empty %} + <p>No results found.</p> + {% endfor %} + + {% if page.has_previous or page.has_next %} + <div> + {% if page.has_previous %}<a href="?q={{ query }}&page={{ page.previous_page_number }}">{% endif %}« Previous{% if page.has_previous %}</a>{% endif %} + | + {% if page.has_next %}<a href="?q={{ query }}&page={{ page.next_page_number }}">{% endif %}Next »{% if page.has_next %}</a>{% endif %} + </div> + {% endif %} + {% else %} + {# Show some example queries to run, maybe query syntax, something else? #} + {% endif %} + </form> +{% endblock %} \ No newline at end of file diff --git a/pedasi/urls.py b/pedasi/urls.py index 94b1fb13936809779fbb4dbfd63db9c947e2409e..014c0b315b4803f6b15e0639fc5d34b2fb64c605 100644 --- a/pedasi/urls.py +++ b/pedasi/urls.py @@ -18,6 +18,7 @@ from django.urls import include, path from profiles.views import IndexView + urlpatterns = [ path('admin/', admin.site.urls), @@ -39,4 +40,15 @@ urlpatterns = [ include('datasources.urls', namespace='datasources') ), + + path('api/', + include('api.urls', + namespace='api')), + + path('search/', + include('haystack.urls')), + + path('social/', + include('social_django.urls', + namespace='social')), ] diff --git a/playbook.yml b/playbook.yml index 9c34cc06c621d148831b8e721070bce443405e2c..0d00be0307246ffc30f277e877f343dadb1d4d66 100644 --- a/playbook.yml +++ b/playbook.yml @@ -26,32 +26,65 @@ repo: deb [ arch=amd64 ] https://repo.mongodb.org/apt/ubuntu bionic/mongodb-org/4.0 multiverse filename: mongodb-org-4.0 + - name: Add Certbot repo + apt_repository: + repo: ppa:certbot/certbot + - name: Update apt apt: update_cache: yes upgrade: yes - name: Install apt prerequisites - apt: name={{ item }} state=latest update_cache=yes - with_items: - - nginx - - python3 - - python3-dev - - python3-pip - - python3-venv - - git - - mysql-server - - libmysqlclient-dev - - mongodb-org - # Required for Ansible to setup DB - - python3-mysqldb + apt: + name: '{{ packages }}' + state: latest + update_cache: yes + vars: + packages: + - nginx + - python3 + - python3-dev + - python3-pip + - python3-venv + - git + - mysql-server + - libmysqlclient-dev + - mongodb-org + - goaccess + - apache2-utils + - python-certbot-nginx + # Required for Ansible to setup DB + - python3-mysqldb - name: Setup dev deployment block: + - name: Check if running under Vagrant + stat: + path: /vagrant + register: vagrant_dir + - name: Clone / update from local repo git: repo: '/vagrant' dest: '{{ project_dir }}' + when: vagrant_dir.stat.exists == True + + - name: Copy deploy key + copy: + src: 'deploy/.deployment-key' + dest: '~/.deployment-key' + mode: 0600 + when: vagrant_dir.stat.exists == False + + - name: Clone / update dev branch from main repo + git: + repo: 'ssh://git@github.com/Southampton-RSG/PEDASI-IoT.git' + dest: '{{ project_dir }}' + accept_hostkey: yes + key_file: '~/.deployment-key' + version: dev + when: vagrant_dir.stat.exists == False - name: Copy dev settings copy: @@ -127,18 +160,6 @@ path: '{{ project_dir }}' mode: 0775 - - name: Compile documentation - make: - chdir: '{{ project_dir }}/docs' - target: '{{ item }}' - params: - SPHINXBUILD: '{{ venv_dir }}/bin/sphinx-build' - SPHINXAPIDOC: '{{ venv_dir }}/bin/sphinx-apidoc' - loop: - - clean - - apidoc - - html - - name: Create static directory file: path: '{{ project_dir }}/static' @@ -147,11 +168,11 @@ group: www-data mode: 0775 - - name: Perform Django setup - django_manage: command={{ item }} app_path={{ project_dir }} virtualenv={{ venv_dir }} - with_items: - - migrate - - collectstatic + - name: Run Django migrations + django_manage: + command: migrate + app_path: "{{ project_dir }}" + virtualenv: "{{ venv_dir }}" - name: Install uWSGI pip: @@ -165,15 +186,32 @@ mode: 0755 - name: Copy web config files - synchronize: src={{ item.src }} dest={{ item.dest }} + file: + src: '{{ item.src }}' + dest: '{{ item.dest }}' + state: link + force: yes with_items: - - { src: deploy/uwsgi/sites/pedasi.ini, dest: /etc/uwsgi/sites/pedasi.ini } - - { src: deploy/systemd/system/uwsgi.service, dest: /etc/systemd/system/uwsgi.service } + - { src: '{{ project_dir }}/deploy/uwsgi/sites/pedasi.ini', dest: /etc/uwsgi/sites/pedasi.ini } + - { src: '{{ project_dir }}/deploy/systemd/system/uwsgi.service', dest: /etc/systemd/system/uwsgi.service } - - name: Copy web config files - synchronize: - src: deploy/nginx/sites-available/pedasi + - name: Copy web config files - dev + copy: + src: '{{ project_dir }}/deploy/nginx/sites-available/pedasi.dev' dest: /etc/nginx/sites-available/pedasi + remote_src: yes + # WARNING: this will not update an existing file + force: no + when: production is not defined + + - name: Copy web config files - production + copy: + src: '{{ project_dir }}/deploy/nginx/sites-available/pedasi' + dest: /etc/nginx/sites-available/pedasi + remote_src: yes + # WARNING: this will not update an existing file + force: no + when: production is defined - name: Activate Nginx site file: @@ -188,7 +226,64 @@ - uwsgi - mongod + - name: Set permissions on report.html + file: + path: "{{ project_dir }}/report.html" + state: touch + owner: www-data + group: www-data + + # Interferes with wildcard server_name in dev deployment + - name: Remove default Nginx server config + file: + path: /etc/nginx/sites-enabled/default + state: absent + + - name: Restart Nginx + systemd: + name: nginx + state: restarted + - name: Open firewall ufw: rule: allow - name: 'Nginx Full' + name: "{{ item }}" + state: enabled + with_items: + - 'Nginx Full' + - 'OpenSSH' + + - name: Setup Goaccess Cron job + cron: + name: "Generate Goaccess report" + user: www-data + state: present + minute: "*/10" + job: "{{ project_dir }}/scripts/goaccess.sh {{ project_dir }}/report.html" + + - name: Setup external API access counters reset Cron job + cron: + name: "Reset external API access counters" + user: www-data + state: present + hour: 0 + minute: 0 + job: "{{ venv_dir }}/bin/python {{ project_dir }}/manage.py reset_api_count" + + - name: Compile documentation + make: + chdir: '{{ project_dir }}/docs' + target: '{{ item }}' + params: + SPHINXBUILD: '{{ venv_dir }}/bin/sphinx-build' + SPHINXAPIDOC: '{{ venv_dir }}/bin/sphinx-apidoc' + loop: + - clean + - apidoc + - html + + - name: Django collect static files + django_manage: + command: collectstatic + app_path: "{{ project_dir }}" + virtualenv: "{{ venv_dir }}" diff --git a/profiles/apps.py b/profiles/apps.py index 5501fdad352cf444c6a7fd60ecd52b5d00729aa9..51cb0a78fe63eacbca8d06e30a61b06a8d1bae35 100644 --- a/profiles/apps.py +++ b/profiles/apps.py @@ -1,5 +1,54 @@ +import logging + from django.apps import AppConfig +from django.db.utils import ProgrammingError + + +logger = logging.getLogger(__name__) class ProfilesConfig(AppConfig): name = 'profiles' + + @staticmethod + def create_groups(): + """ + Create the necessary groups for Application and Data Source managers. + """ + from django.contrib.auth.models import Group, Permission + + app_providers, created = Group.objects.get_or_create( + name='Application Providers', + ) + app_providers.permissions.add( + Permission.objects.get(codename='add_application'), + Permission.objects.get(codename='change_application'), + Permission.objects.get(codename='delete_application') + ) + + data_providers, created = Group.objects.get_or_create( + name='Data Providers', + ) + data_providers.permissions.add( + Permission.objects.get(codename='add_datasource'), + Permission.objects.get(codename='change_datasource'), + Permission.objects.get(codename='delete_datasource'), + ) + + try: + data_providers.permissions.add( + Permission.objects.get(codename='add_licence'), + Permission.objects.get(codename='change_licence'), + Permission.objects.get(codename='delete_licence') + ) + + except Permission.DoesNotExist: + logger.warning('Licence permissions not found - please restart Django server') + + def ready(self): + # Runs after app registry is populated - i.e. all models exist and are importable + try: + self.create_groups() + + except ProgrammingError: + logging.warning('Could not create Group fixtures, database has not been initialized') diff --git a/profiles/fixtures/auth.Group.json b/profiles/fixtures/auth.Group.json deleted file mode 100644 index 7e4028da3db52c129712684ae1aa624126e7287f..0000000000000000000000000000000000000000 --- a/profiles/fixtures/auth.Group.json +++ /dev/null @@ -1,26 +0,0 @@ -[ - { - "model": "auth.group", - "pk": 1, - "fields": { - "name": "Application Providers", - "permissions": [ - 19, - 20, - 21 - ] - } - }, - { - "model": "auth.group", - "pk": 2, - "fields": { - "name": "Data Providers", - "permissions": [ - 22, - 23, - 24 - ] - } - } -] \ No newline at end of file diff --git a/profiles/models.py b/profiles/models.py index ee4f2a2c254dec7622019818076909fe70119938..9e97bb33adacd1128297d29bbe086de53769bce8 100644 --- a/profiles/models.py +++ b/profiles/models.py @@ -1,4 +1,5 @@ from django.contrib.auth.models import AbstractUser +from django.urls import reverse class User(AbstractUser): @@ -6,4 +7,10 @@ class User(AbstractUser): Custom Django user model to allow for additional functionality to be added more easily in the future. """ - pass + def get_uri(self): + """ + Get a URI for this user. + + Used in PROV records. + """ + return reverse('profiles:uri', kwargs={'pk': self.pk}) diff --git a/profiles/permissions.py b/profiles/permissions.py index 3c32a3638b97f9b124119e69b15bf2a5550330f0..dd58254727d8a73532135ff5277ff4ef25ae7224 100644 --- a/profiles/permissions.py +++ b/profiles/permissions.py @@ -1,4 +1,4 @@ -from django.contrib.auth.mixins import PermissionRequiredMixin +from django.contrib.auth.mixins import UserPassesTestMixin, PermissionRequiredMixin class OwnerPermissionRequiredMixin(PermissionRequiredMixin): @@ -15,4 +15,15 @@ class OwnerPermissionRequiredMixin(PermissionRequiredMixin): :return: Does the user have permission to perform this action? """ - return super().has_permission() and self.request.user == getattr(self.get_object(), self.owner_attribute) + return self.request.user.is_superuser or ( + super().has_permission() and + self.request.user == getattr(self.get_object(), self.owner_attribute) + ) + + +class HasViewPermissionMixin(UserPassesTestMixin): + """ + Mixin to reject users who do not have permission to view this DataSource. + """ + def test_func(self) -> bool: + return self.get_object().has_view_permission(self.request.user) diff --git a/profiles/social_auth.py b/profiles/social_auth.py new file mode 100644 index 0000000000000000000000000000000000000000..b4d320fc414d66500d14aa5320443f2a3174c39c --- /dev/null +++ b/profiles/social_auth.py @@ -0,0 +1,13 @@ +from social_core.pipeline.user import create_user + + +def create_user_disabled(strategy, details, backend, user=None, *args, **kwargs): + # Returns dict containing 'is_new' and 'user' + result = create_user(strategy, details, backend, user, *args, **kwargs) + + if result['is_new']: + django_user = result['user'] + django_user.is_active = False + django_user.save() + + return result diff --git a/profiles/static/css/masthead.css b/profiles/static/css/masthead.css index d24332afd4e3ddbf83306c4240f73f26d0464e39..56fb41ee26eaff0c75341a8dd745092b1e83915b 100644 --- a/profiles/static/css/masthead.css +++ b/profiles/static/css/masthead.css @@ -1,12 +1,15 @@ header.masthead { position: relative; - background: #343a40 url("https://via.placeholder.com/350x150") no-repeat top; + background: #343a40 no-repeat center; -webkit-background-size: cover; -moz-background-size: cover; -o-background-size: cover; background-size: cover; padding-top: 8rem; padding-bottom: 8rem; + min-height: 200px; + height: 60vh; + z-index: -2; } header.masthead .overlay { @@ -16,7 +19,8 @@ header.masthead .overlay { width: 100%; top: 0; left: 0; - opacity: 0.1; + opacity: 0.4; + z-index: -1; } header.masthead .textbox-container { diff --git a/profiles/static/img/pedasi-hero.jpg b/profiles/static/img/pedasi-hero.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e8e22c5f4263a24e90fad132534da9f1876dde34 Binary files /dev/null and b/profiles/static/img/pedasi-hero.jpg differ diff --git a/profiles/templates/index.html b/profiles/templates/index.html index c6f7e5bdffa385cc2fc7028622471c2da804f7fe..732e933b68e7d3cb73856451f423433a64d5ed08 100644 --- a/profiles/templates/index.html +++ b/profiles/templates/index.html @@ -7,49 +7,166 @@ {% endblock %} {% block pre_content %} - <header class="masthead text-white text-left"> + <header class="container-fluid masthead text-white text-left" + style="background-image: url('{% static 'img/pedasi-hero.jpg' %}')"> <div class="overlay"></div> + + <div class="row"> + <div class="ml-5 px-4 mt-3 pt-3 textbox-container"> + <h2 class="display-1">PEDASI</h2> + <p class="lead">IoT Observatory Demonstrator</p> + </div> + </div> + </header> + + <div class="bg-secondary py-3"> + <div class="container text-white"> + <h2> + A platform for research in data sharing + </h2> + </div> + </div> + + <div class="bg-light py-2"> <div class="container"> <div class="row"> - <div class="px-4 pt-3 textbox-container"> - <h2 class="display-1">PEDASI</h2> - <p class="lead">IoT Observatory Demonstrator</p> + + <div class="col-md-4"> + <div class="card text-center"> + <div class="card-body"> + <h2>Find Data Sources</h2> + + <span class="fas fa-5x fa-atlas"></span> + </div> + </div> </div> + + <div class="col-md-4"> + <div class="card text-center"> + <div class="card-body"> + <h2>Access Data</h2> + + <span class="fas fa-5x fa-cloud-download-alt"></span> + </div> + </div> + </div> + + <div class="col-md-4"> + <div class="card text-center"> + <div class="card-body"> + <h2>Share Results</h2> + + <span class="fas fa-5x fa-tablet-alt"></span> + </div> + </div> + </div> + </div> </div> - </header> + </div> {% endblock %} {% block content %} - <h2>Recent Data Sources</h2> + <div class="container pb-3"> + <div class="row align-items-center"> + <div class="col-sm-8"> + <h2 class="pb-2">About PEDASI</h2> + + <p> + This project will address issues related to sharing IoT datasets on a large distributed scale to support + innovation in a way that will not compromise privacy and security. The project will identify and address + infrastructural, technological and legal issues to that end, and will initiate the deployment of an + infrastructure that will enable individuals or organisations to share IoT datasets. + </p> - <div class="row"> - {% for datasource in datasources %} - <div class="card col-lg-4 col-md-6 border-0"> - <div class="card-body d-flex flex-column"> - <h5 class="card-title">{{ datasource.name }}</h5> - <p class="card-text">{{ datasource.description }}</p> + <p> + This activity works in synergy with existing initiatives within the Web Science and Internet Science communities. + It will provide for accessing IoT datasets already available on the Web and it will aim to host datasets from the + experiments of the PETRAS project providing a vehicle for community engagement for the development of analytics + and visualisations on those datasets across the PETRAS community and beyond. + </p> + </div> - <a href="{% url 'datasources:datasource.detail' pk=datasource.pk %}" - class="btn btn-primary btn-block mt-auto" role="button">Detail</a> + <div class="col-sm-4"> + <img class="img-fluid py-3" src="{% static 'img/IoT_observatory.png' %}"> </div> </div> - {% endfor %} </div> - <h2 class="mt-3">Recent Applications</h2> + <div class="jumbotron jumbotron-fluid pt-3 pb-2 mb-3"> + <div class="container"> + <h2>Featured Applications</h2> - <div class="row"> - {% for application in applications %} - <div class="card col-lg-4 col-md-6 border-0"> - <div class="card-body d-flex flex-column"> - <h5 class="card-title">{{ application.name }}</h5> - <p class="card-text">{{ application.description }}</p> + <div class="row px-2"> + + {% for application in applications %} + <div class="col-md-6 col-lg-4 p-2"> + <a href="{% url 'applications:application.detail' pk=application.pk %}" + class="no-hover" role="button"> + + <div class="card rounded-0 h-100"> + <img src="{% static 'img/IoT_observatory.png' %}" + class="card-img-top rounded-0 mt-4 mx-auto d-block" + style="width: 150px;"> + + <div class="card-body d-flex flex-column pb-2"> + <h5 class="card-title">{{ application.name }}</h5> + <p class="card-text">{{ application.description|truncatechars:120 }}</p> + + <p class="card-text text-right mt-auto"> + <small class="text-muted"> + Last updated X ago + </small> + </p> + + </div> + </div> + + </a> + </div> + {% endfor %} - <a href="{% url 'applications:application.detail' pk=application.pk %}" - class="btn btn-primary btn-block mt-auto" role="button">Detail</a> - </div> </div> - {% endfor %} + + </div> </div> + + <div class="jumbotron jumbotron-fluid pt-3 pb-2"> + <div class="container"> + <h2>Featured Data Sources</h2> + + <div class="row px-2"> + + {% for datasource in datasources %} + <div class="col-md-6 col-lg-4 p-2"> + <a href="{% url 'datasources:datasource.detail' pk=datasource.pk %}" + class="no-hover" role="button"> + + <div class="card rounded-0 h-100"> + <img src="{% static 'img/IoT_observatory.png' %}" + class="card-img-top rounded-0 mt-4 mx-auto d-block" + style="width: 150px;"> + + <div class="card-body d-flex flex-column pb-2"> + <h5 class="card-title">{{ datasource.name }}</h5> + <p class="card-text">{{ datasource.description|truncatechars:120 }}</p> + + <p class="card-text text-right mt-auto"> + <small class="text-muted"> + {{ datasource.external_requests }} recent connection{{ datasource.external_requests|pluralize }} + </small> + </p> + + </div> + </div> + + </a> + </div> + {% endfor %} + + </div> + + </div> + </div> + {% endblock %} \ No newline at end of file diff --git a/profiles/templates/profiles/user/inactive.html b/profiles/templates/profiles/user/inactive.html new file mode 100644 index 0000000000000000000000000000000000000000..8a4681c915a4d157ac50fd6cce42eb44d844a85f --- /dev/null +++ b/profiles/templates/profiles/user/inactive.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% load bootstrap4 %} + +{% block content %} + <nav aria-label="breadcrumb"> + <ol class="breadcrumb"> + <li class="breadcrumb-item" aria-current="page"> + <a href="{% url 'index' %}">Home</a> + </li> + <li class="breadcrumb-item active" aria-current="page"> + My Profile + </li> + </ol> + </nav> + + <div class="alert alert-warning"> + <b>Inactive Account:</b> + Your account has not yet been activated - please wait for an admin to approve your request. + </div> + +{% endblock %} \ No newline at end of file diff --git a/profiles/templates/profiles/user/profile.html b/profiles/templates/profiles/user/profile.html index 0871fc9a389c85543c3c51e5b96910ac795fbac3..52af26156d30256b035cd272c4b05353c51917e5 100644 --- a/profiles/templates/profiles/user/profile.html +++ b/profiles/templates/profiles/user/profile.html @@ -15,4 +15,40 @@ <h2>My Profile</h2> + <table class="table"> + <thead> + <th scope="col" class="w-25 border-0"></th> + <th scope="col" class="border-0"></th> + </thead> + + <tbody> + <tr> + <td>API Token</td> + <td> + <span id="spanApiToken"> + {% if user == request.user and user.auth_token %} + {{ user.auth_token }} + + {% else %} + <script type="application/javascript"> + function getToken() { + $.ajax({ + dataType: "json", + url: "{% url 'profiles:token' %}", + data: null, + success: function (data) { + $('#spanApiToken').text(data.data.token.key); + } + }); + } + </script> + + <button onclick="getToken();" class="btn btn-default" role="button">Generate an API Token</button> + {% endif %} + </span> + </td> + </tr> + </tbody> + </table> + {% endblock %} \ No newline at end of file diff --git a/profiles/templates/registration/login.html b/profiles/templates/registration/login.html index 92c590526b51ee39fce2a7f9247985ddbf483735..51b8b156359c72bf61ddf3b1dac1b1322c544fa6 100644 --- a/profiles/templates/registration/login.html +++ b/profiles/templates/registration/login.html @@ -4,11 +4,27 @@ {% block content %} <h2>Login</h2> - {% if form.errors %} - <div> </div> - {% else %} - <div class="alert alert-warning">Please log in</div> - {% endif %} + {% if 'google-oauth2' in backends.backends %} + <div class="alert alert-info mt-3"> + <p> + The preferred method to log in to PEDASI is using your Google account. + </p> + <p> + If you do not have a Google account and wish to use PEDASI you should contact a + system administrator to create a PEDASI account for you. + + Once your account has been created you may log in using the form below. + </p> + </div> + + <div class="row justify-content-center"> + <a class="btn btn-lg btn-success col-sm-4" + role="button" href="{% url 'social:begin' 'google-oauth2' %}">Google Login</a> + + </div> + {% endif %} + + <hr> <div> <form action="" method="post"> diff --git a/profiles/tests.py b/profiles/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..75e3d2c60ce4d5b15446147a96409ce94083fc6a --- /dev/null +++ b/profiles/tests.py @@ -0,0 +1,64 @@ +from django.test import Client, TestCase +from django.urls import reverse + +from rest_framework.authtoken.models import Token + +from . import models + + +class UserTest(TestCase): + def test_user_uri(self): + """ + Test that we can get a URI for a user. + """ + user = models.User.objects.create_user('Test User') + uri = user.get_uri() + + self.assertEqual(str, type(uri)) + self.assertLessEqual(1, len(uri)) + + client = Client() + response = client.get(uri) + + self.assertEqual(200, response.status_code) + + content = response.json() + self.assertIn('status', content) + self.assertIn('data', content) + + data = content['data'] + self.assertIn('user', data) + self.assertIn('pk', data['user']) + self.assertEqual(user.pk, data['user']['pk']) + + def test_user_get_token(self): + """ + Check that we can get an API Token for a given user. + """ + user = models.User.objects.create_user('Test User') + + client = Client() + client.force_login(user) + + with self.assertRaises(Token.DoesNotExist): + # User should not already have token + token = Token.objects.get(user=user) + + response = client.get(reverse('profiles:token')) + + self.assertEqual(200, response.status_code) + + content = response.json() + self.assertIn('status', content) + self.assertIn('data', content) + self.assertEqual('success', content['status']) + + data = content['data'] + self.assertIn('token', data) + self.assertIn('key', data['token']) + + key = data['token']['key'] + + token = Token.objects.get(user=user) + + self.assertEqual(key, token.key) diff --git a/profiles/urls.py b/profiles/urls.py index ba5a39b9b30c189dea5eb5c5d382bb844db42eb0..1c71dc1268c0de04e2ea8bc075da22834d6a701f 100644 --- a/profiles/urls.py +++ b/profiles/urls.py @@ -1,6 +1,6 @@ from django.urls import include, path -from .views.views import UserProfileView +from . import views app_name = 'profiles' @@ -8,6 +8,18 @@ urlpatterns = [ path('', include('django.contrib.auth.urls')), path('profile', - UserProfileView.as_view(), + views.UserProfileView.as_view(), name='profile'), + + path('inactive', + views.UserInactiveView.as_view(), + name='inactive'), + + path('uri/<int:pk>', + views.UserUriView.as_view(), + name='uri'), + + path('token', + views.UserGetTokenView.as_view(), + name='token'), ] diff --git a/profiles/views.py b/profiles/views.py new file mode 100644 index 0000000000000000000000000000000000000000..d6cca5d14ec6f69bedf3d8bdf925e267c71e425f --- /dev/null +++ b/profiles/views.py @@ -0,0 +1,86 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import JsonResponse +from django.views.generic import TemplateView +from django.views.generic.detail import DetailView + +from rest_framework.authtoken.models import Token + +from applications.models import Application +from datasources.models import DataSource + + +class IndexView(TemplateView): + template_name = 'index.html' + + def get_context_data(self, **kwargs): + """ + Add recent Applications and DataSources to index page. + + :return: Django context dictionary + """ + context = super().get_context_data(**kwargs) + + context['datasources'] = DataSource.objects.order_by('-external_requests')[:6] + context['applications'] = Application.objects.order_by('-id')[:3] + + return context + + +class UserProfileView(DetailView): + template_name = 'profiles/user/profile.html' + context_object_name = 'user' + + def get_object(self, queryset=None): + return self.request.user + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + return context + + +class UserUriView(DetailView): + """ + View providing verification that a PEDASI User URI exists. + """ + model = get_user_model() + + def render_to_response(self, context, **response_kwargs): + return JsonResponse({ + 'status': 'success', + 'data': { + 'user': { + 'pk': self.object.pk, + } + } + }) + + +class UserInactiveView(TemplateView): + template_name = 'profiles/user/inactive.html' + + +class UserGetTokenView(LoginRequiredMixin, DetailView): + """ + Get an API Token for the currently authenticated user. + """ + def get_object(self, queryset=None): + return self.request.user + + def render_to_response(self, context, **response_kwargs): + """ + Get an existing API Token or create a new one for the currently authenticated user. + + :return: JSON containing Token key + """ + api_token, created = Token.objects.get_or_create(user=self.object) + + return JsonResponse({ + 'status': 'success', + 'data': { + 'token': { + 'key': api_token.key + } + } + }) diff --git a/profiles/views/__init__.py b/profiles/views/__init__.py deleted file mode 100644 index 6330c39c81ceef4998d8f53c8e92305046882675..0000000000000000000000000000000000000000 --- a/profiles/views/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .views import IndexView diff --git a/profiles/views/views.py b/profiles/views/views.py deleted file mode 100644 index 9d1afdcf38cf0f39e7b6631bb5041181cf9b5be8..0000000000000000000000000000000000000000 --- a/profiles/views/views.py +++ /dev/null @@ -1,30 +0,0 @@ -from django.views.generic import TemplateView -from django.views.generic.detail import DetailView - -from applications.models import Application -from datasources.models import DataSource - - -class IndexView(TemplateView): - template_name = 'index.html' - - def get_context_data(self, **kwargs): - """ - Add recent Applications and DataSources to index page. - - :return: Django context dictionary - """ - context = super().get_context_data(**kwargs) - - context['datasources'] = DataSource.objects.order_by('-id')[:3] - context['applications'] = Application.objects.order_by('-id')[:3] - - return context - - -class UserProfileView(DetailView): - template_name = 'profiles/user/profile.html' - context_object_name = 'user' - - def get_object(self, queryset=None): - return self.request.user diff --git a/prov/migrations/0001_initial.py b/prov/migrations/0001_initial.py deleted file mode 100644 index e3412fe56312e3a66bb915f2de92254fd92a1852..0000000000000000000000000000000000000000 --- a/prov/migrations/0001_initial.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 2.0.8 on 2018-08-29 12:26 - -from django.db import migrations, models -import djongo.models.fields -import prov.models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='ProvCollection', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('app_label', models.CharField(max_length=100)), - ('model_name', models.CharField(max_length=100)), - ('related_pk', models.PositiveIntegerField()), - ('entries', djongo.models.fields.ArrayModelField(blank=True, default=[], model_container=prov.models.ProvEntry, null=False)), - ], - ), - ] diff --git a/prov/migrations/0002_editable_false.py b/prov/migrations/0002_editable_false.py deleted file mode 100644 index f6fbf163386b6c07040d43250d640ccfcb19acf3..0000000000000000000000000000000000000000 --- a/prov/migrations/0002_editable_false.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 2.0.8 on 2018-08-30 09:40 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('prov', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='provcollection', - name='app_label', - field=models.CharField(editable=False, max_length=100), - ), - migrations.AlterField( - model_name='provcollection', - name='model_name', - field=models.CharField(editable=False, max_length=100), - ), - ] diff --git a/prov/models.py b/prov/models.py deleted file mode 100644 index d43c6a29531ec91253f45c289b4932b5f82bde1b..0000000000000000000000000000000000000000 --- a/prov/models.py +++ /dev/null @@ -1,82 +0,0 @@ -from django.db.models import signals -from django.dispatch import receiver - -from djongo import models - -from applications.models import Application -from datasources.models import DataSource - -MAX_LENGTH_NAME_FIELD = 100 - - -class ProvEntry(models.Model): - """ - Stored PROV record for a single action. - - e.g. Update a model's metadata, use a model. - """ - #: Time at which the action occurred - time = models.DateTimeField(auto_now_add=True, - editable=False, - blank=False, null=False) - - class Meta: - # Make this model abstract to avoid creating a table - # since it will only be used inside a ProvCollection model - abstract = True - - -class ProvCollection(models.Model): - """ - The complete set of PROV records storing all actions performed on a single model instance. - """ - #: App from which the model comes - app_label = models.CharField(max_length=MAX_LENGTH_NAME_FIELD, - editable=False, - blank=False, null=False) - - #: Name of the model - model_name = models.CharField(max_length=MAX_LENGTH_NAME_FIELD, - editable=False, - blank=False, null=False) - - # TODO should this be editable=False? Can a model PK change? - #: Primary key of the model instance - related_pk = models.PositiveIntegerField(blank=False, null=False) - - #: List of ProvEntry actions - entries = models.ArrayModelField( - model_container=ProvEntry, - default=[], - blank=True, null=False - ) - - @classmethod - def for_model_instance(cls, instance: models.Model) -> 'ProvCollection': - """ - Get the :class:`ProvCollection` instance related to a particular model instance. - - Create a :class:`ProvCollection` instance if there is not one already. - - :param instance: Model instance for which to get :class:`ProvCollection` - :return: :class:`ProvCollection` instance - """ - obj, created = cls.objects.get_or_create( - app_label=instance._meta.app_label, - model_name=instance._meta.model_name, - related_pk=instance.pk - ) - - return obj - - -@receiver(signals.post_save, sender=Application) -@receiver(signals.post_save, sender=DataSource) -def save_prov(sender, instance, **kwargs): - """ - Signal receiver to update a ProvCollection when a PROV tracked model is saved. - """ - obj = ProvCollection.for_model_instance(instance) - - obj.entries.append(ProvEntry()) - obj.save() diff --git a/prov/routers.py b/prov/routers.py deleted file mode 100644 index 3fa878a2aaa3d59719ac6856ea58a64359236cd7..0000000000000000000000000000000000000000 --- a/prov/routers.py +++ /dev/null @@ -1,42 +0,0 @@ -class ProvRouter: - """ - Django database router to direct models within the PROV app to the correct database. - - This is required to separate the PROV models into MongoDB. - - This router should be listed before the default router in the Django settings file. - """ - - db_name = 'prov' - app_label = 'prov' - - def db_for_read(self, model, **hints): - """ - Read from default database. - """ - if model._meta.app_label == self.app_label: - return self.db_name - return None - - def db_for_write(self, model, **hints): - """ - Write to default database. - """ - if model._meta.app_label == self.app_label: - return self.db_name - return None - - def allow_relation(self, obj1, obj2, **hints): - """ - Allow relation if a model in this app is involved. - """ - if obj1._meta.app_label == self.app_label or obj2._meta.app_label == self.app_label: - return True - return None - - def allow_migrate(self, db, app_label, model_name=None, **hints): - """ - Always allow migrations. - """ - if app_label == self.app_label: - return db == self.db_name diff --git a/prov/tests.py b/prov/tests.py deleted file mode 100644 index 59f1ccc17fb75091105a0b863e8a6891e54bc795..0000000000000000000000000000000000000000 --- a/prov/tests.py +++ /dev/null @@ -1,77 +0,0 @@ -import unittest - -from django.contrib.auth import get_user_model -from django.test import TestCase - -from datasources.models import DataSource -from prov.models import ProvCollection - - -class ProvCollectionTest(TestCase): - @classmethod - def setUpTestData(cls): - cls.user_model = get_user_model() - cls.user = cls.user_model.objects.create_user('test') - - def setUp(self): - self.datasource = DataSource.objects.create( - name='Test Data Source', - url='http://www.example.com', - owner=self.user, - plugin_name='TEST' - ) - - def test_prov_datasource_create(self): - """ - Test that a PROV collection / entry is created when a model is created. - """ - # PROV record should be created when model is created - prov_collection = ProvCollection.for_model_instance(self.datasource) - self.assertEqual(len(prov_collection.entries), 1) - - def test_prov_datasource_update(self): - """ - Test that a new PROV entry is created when a model is updated. - """ - prov_collection = ProvCollection.for_model_instance(self.datasource) - n_provs = len(prov_collection.entries) - - self.datasource.plugin_name = 'CHANGED' - self.datasource.save() - - # Another PROV record should be created when model is changed and saved - prov_collection = ProvCollection.for_model_instance(self.datasource) - self.assertEqual(len(prov_collection.entries), n_provs + 1) - - @unittest.expectedFailure - def test_prov_datasource_null_update(self): - """ - Test that no new PROV entry is created when a model is saved without changes. - """ - prov_collection = ProvCollection.for_model_instance(self.datasource) - n_provs = len(prov_collection.entries) - - self.datasource.save() - - # No PROV record should be created when saving a model that has not changed - prov_collection = ProvCollection.for_model_instance(self.datasource) - self.assertEqual(len(prov_collection.entries), n_provs) - - def test_prov_records_distinct(self): - """ - Test that a distinct PROV collection is created for each model instance. - """ - prov_collection = ProvCollection.for_model_instance(self.datasource) - - new_datasource = DataSource.objects.create( - name='Another Test Data Source', - url='http://www.example.com', - owner=self.user, - plugin_name='TEST' - ) - new_prov_collection = ProvCollection.for_model_instance(new_datasource) - - self.assertIsNot(prov_collection, new_prov_collection) - - self.assertEqual(prov_collection.related_pk, self.datasource.pk) - self.assertEqual(new_prov_collection.related_pk, new_datasource.pk) diff --git a/provenance/__init__.py b/provenance/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/prov/apps.py b/provenance/apps.py similarity index 100% rename from prov/apps.py rename to provenance/apps.py diff --git a/provenance/data/prov-json.schema.json b/provenance/data/prov-json.schema.json new file mode 100644 index 0000000000000000000000000000000000000000..056a62d2974212e7706d7fe35b7c147b574e1673 --- /dev/null +++ b/provenance/data/prov-json.schema.json @@ -0,0 +1,347 @@ +{ + "id": "http://provenance.ecs.soton.ac.uk/prov-json/schema#", + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "Schema for a PROV-JSON document", + "type": "object", + "additionalProperties": false, + "properties": { + "prefix": { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9_\\-]+$": { "type" : "string", "format": "uri" } + } + }, + "entity": { + "type": "object", + "additionalProperties": { "$ref":"#/definitions/entity" } + }, + "activity": { + "type": "object", + "additionalProperties": { "$ref":"#/definitions/activity" } + }, + "agent": { + "type": "object", + "additionalProperties": { "$ref":"#/definitions/agent" } + }, + "wasGeneratedBy": { + "type": "object", + "additionalProperties": { "$ref":"#/definitions/generation" } + }, + "used": { + "type": "object", + "additionalProperties": { "$ref":"#/definitions/usage" } + }, + "wasInformedBy": { + "type": "object", + "additionalProperties": { "$ref":"#/definitions/communication" } + }, + "wasStartedBy": { + "type": "object", + "additionalProperties": { "$ref":"#/definitions/start" } + }, + "wasEndedby": { + "type": "object", + "additionalProperties": { "$ref":"#/definitions/end" } + }, + "wasInvalidatedBy": { + "type": "object", + "additionalProperties": { "$ref":"#/definitions/invalidation" } + }, + "wasDerivedFrom": { + "type": "object", + "additionalProperties": { "$ref":"#/definitions/derivation" } + }, + "wasAttributedTo": { + "type": "object", + "additionalProperties": { "$ref":"#/definitions/attribution" } + }, + "wasAssociatedWith": { + "type": "object", + "additionalProperties": { "$ref":"#/definitions/association" } + }, + "actedOnBehalfOf": { + "type": "object", + "additionalProperties": { "$ref":"#/definitions/delegation" } + }, + "wasInfluencedBy": { + "type": "object", + "additionalProperties": { "$ref":"#/definitions/influence" } + }, + "specializationOf": { + "type": "object", + "additionalProperties": { "$ref":"#/definitions/specialization" } + }, + "alternateOf": { + "type": "object", + "additionalProperties": { "$ref":"#/definitions/alternate" } + }, + "hadMember": { + "type": "object", + "additionalProperties": { "$ref":"#/definitions/membership" } + }, + "bundle": { + "type": "object", + "additionalProperties": { "$ref":"#/definitions/bundle" } + } + }, + "definitions": { + "typedLiteral": { + "title": "PROV-JSON Typed Literal", + "type": "object", + "properties": { + "$": { "type": "string" }, + "type": { "type": "string", "format": "uri" }, + "lang": { "type": "string" } + }, + "required": ["$"], + "additionalProperties": false + }, + "stringLiteral": {"type": "string"}, + "numberLiteral": {"type": "number"}, + "booleanLiteral": {"type": "boolean"}, + "literalArray": { + "type": "array", + "minItems": 1, + "items": { + "anyOf": [ + { "$ref": "#/definitions/stringLiteral" }, + { "$ref": "#/definitions/numberLiteral" }, + { "$ref": "#/definitions/booleanLiteral" }, + { "$ref": "#/definitions/typedLiteral" } + ] + } + }, + "attributeValues": { + "anyOf": [ + { "$ref": "#/definitions/stringLiteral" }, + { "$ref": "#/definitions/numberLiteral" }, + { "$ref": "#/definitions/booleanLiteral" }, + { "$ref": "#/definitions/typedLiteral" }, + { "$ref": "#/definitions/literalArray" } + ] + }, + "entity": { + "type": "object", + "title": "entity", + "additionalProperties": { "$ref": "#/definitions/attributeValues" } + }, + "agent": { "$ref": "#/definitions/entity" }, + "activity": { + "type": "object", + "title": "activity", + "prov:startTime": { "type": "string", "format": "date-time" }, + "prov:endTime": { "type": "string", "format": "date-time" }, + "additionalProperties": { "$ref": "#/definitions/attributeValues" } + }, + "generation": { + "type": "object", + "title": "generation/usage", + "properties": { + "prov:entity": { "type": "string", "format": "uri" }, + "prov:activity": { "type": "string", "format": "uri" }, + "prov:time": { "type": "string", "format": "date-time" } + }, + "required": ["prov:entity"], + "additionalProperties": { "$ref": "#/definitions/attributeValues" } + }, + "usage": {"$ref":"#/definitions/generation"}, + "communication":{ + "type": "object", + "title": "communication", + "properties": { + "prov:informant": {"type": "string", "format": "uri"}, + "prov:informed": {"type": "string", "format": "uri"} + }, + "required": ["prov:informant", "prov:informed"], + "additionalProperties": { "$ref": "#/definitions/attributeValues" } + }, + "start":{ + "type": "object", + "title": "start/end", + "properties": { + "prov:activity": {"type": "string", "format": "uri"}, + "prov:time": {"type": "string", "format": "date-time"}, + "prov:trigger": {"type": "string", "format": "uri"} + }, + "required": ["prov:activity"], + "additionalProperties": { "$ref": "#/definitions/attributeValues" } + }, + "end": {"$ref":"#/definitions/start"}, + "invalidation":{ + "type": "object", + "title": "invalidation", + "properties": { + "prov:entity": { "type": "string", "format": "uri" }, + "prov:time": { "type": "string", "format": "date-time" }, + "prov:activity": { "type": "string", "format": "uri" } + }, + "required": ["prov:entity"], + "additionalProperties": { "$ref": "#/definitions/attributeValues" } + }, + "derivation":{ + "type": "object", + "title": "derivation", + "properties": { + "prov:generatedEntity": { "type": "string", "format": "uri" }, + "prov:usedEntity": { "type": "string", "format": "uri" }, + "prov:activity": { "type": "string", "format": "uri" }, + "prov:generation": { "type": "string", "format": "uri" }, + "prov:usage": { "type": "string", "format": "uri" } + }, + "required": ["prov:generatedEntity", "prov:usedEntity"], + "additionalProperties": { "$ref": "#/definitions/attributeValues" } + }, + "attribution":{ + "type": "object", + "title": "attribution", + "properties": { + "prov:entity": { "type": "string", "format": "uri" }, + "prov:agent": { "type": "string", "format": "uri" } + }, + "required": ["prov:entity", "prov:agent"], + "additionalProperties": { "$ref": "#/definitions/attributeValues" } + }, + "association": { + "type": "object", + "title": "association", + "properties": { + "prov:activity": { "type": "string", "format": "uri" }, + "prov:agent": { "type": "string", "format": "uri" }, + "prov:plan": { "type": "string", "format": "uri" } + }, + "required": ["prov:activity"], + "additionalProperties": { "$ref": "#/definitions/attributeValues" } + }, + "delegation": { + "type": "object", + "title": "delegation", + "properties": { + "prov:delegate": { "type": "string", "format": "uri" }, + "prov:responsible": { "type": "string", "format": "uri" }, + "prov:activity": { "type": "string", "format": "uri" } + }, + "required": ["prov:delegate", "prov:responsible"], + "additionalProperties": { "$ref": "#/definitions/attributeValues" } + }, + "influence": { + "type": "object", + "title": "", + "properties": { + "prov:influencer": { "type": "string", "format": "uri" }, + "prov:influencee": { "type": "string", "format": "uri" } + }, + "required": ["prov:influencer", "prov:influencee"], + "additionalProperties": { "$ref": "#/definitions/attributeValues" } + }, + "specialization": { + "type": "object", + "title": "specialization", + "properties": { + "prov:generalEntity": { "type": "string", "format": "uri" }, + "prov:specificEntity": { "type": "string", "format": "uri" } + }, + "required": ["prov:generalEntity", "prov:specificEntity"], + "additionalProperties": { "$ref": "#/definitions/attributeValues" } + }, + "alternate": { + "type": "object", + "title": "alternate", + "properties": { + "prov:alternate1": { "type": "string", "format": "uri" }, + "prov:alternate2": { "type": "string", "format": "uri" } + }, + "required": ["prov:alternate1", "prov:alternate2"], + "additionalProperties": { "$ref": "#/definitions/attributeValues" } + }, + "membership": { + "type": "object", + "title": "membership", + "properties": { + "prov:collection": { "type": "string", "format": "uri" }, + "prov:entity": { "type": "string", "format": "uri" } + }, + "required": ["prov:collection", "prov:entity"], + "additionalProperties": { "$ref": "#/definitions/attributeValues" } + }, + "bundle": { + "type": "object", + "title": "bundle", + "properties":{ + "prefix": { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9_\\-]+$": { "type" : "string", "format": "uri" } + } + }, + "entity": { + "type": "object", + "additionalProperties": { "$ref":"#/definitions/entity" } + }, + "activity": { + "type": "object", + "additionalProperties": { "$ref":"#/definitions/activity" } + }, + "agent": { + "type": "object", + "additionalProperties": { "$ref":"#/definitions/agent" } + }, + "wasGeneratedBy": { + "type": "object", + "additionalProperties": { "$ref":"#/definitions/generation" } + }, + "used": { + "type": "object", + "additionalProperties": { "$ref":"#/definitions/usage" } + }, + "wasInformedBy": { + "type": "object", + "additionalProperties": { "$ref":"#/definitions/communication" } + }, + "wasStartedBy": { + "type": "object", + "additionalProperties": { "$ref":"#/definitions/start" } + }, + "wasEndedby": { + "type": "object", + "additionalProperties": { "$ref":"#/definitions/end" } + }, + "wasInvalidatedBy": { + "type": "object", + "additionalProperties": { "$ref":"#/definitions/invalidation" } + }, + "wasDerivedFrom": { + "type": "object", + "additionalProperties": { "$ref":"#/definitions/derivation" } + }, + "wasAttributedTo": { + "type": "object", + "additionalProperties": { "$ref":"#/definitions/attribution" } + }, + "wasAssociatedWith": { + "type": "object", + "additionalProperties": { "$ref":"#/definitions/association" } + }, + "actedOnBehalfOf": { + "type": "object", + "additionalProperties": { "$ref":"#/definitions/delegation" } + }, + "wasInfluencedBy": { + "type": "object", + "additionalProperties": { "$ref":"#/definitions/influence" } + }, + "specializationOf": { + "type": "object", + "additionalProperties": { "$ref":"#/definitions/specialization" } + }, + "alternateOf": { + "type": "object", + "additionalProperties": { "$ref":"#/definitions/alternate" } + }, + "hadMember": { + "type": "object", + "additionalProperties": { "$ref":"#/definitions/membership" } + } + } + } + } +} \ No newline at end of file diff --git a/provenance/migrations/__init__.py b/provenance/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/provenance/models.py b/provenance/models.py new file mode 100644 index 0000000000000000000000000000000000000000..0716eac6023c74260a0aa2dbf288f9a98bb37722 --- /dev/null +++ b/provenance/models.py @@ -0,0 +1,264 @@ +""" +This module provides models required for the creation, storage and manipulation of PROV documents. + +These PROV documents describe actions made by users and applications, allowing usage patterns to be tracked. + +For details on PROV see https://www.w3.org/TR/2013/NOTE-prov-overview-20130430/ +""" + +import enum +import json +import typing +import uuid + +from django import apps +from django.contrib.contenttypes.models import ContentType +from django.db.models import QuerySet, signals +from django.dispatch import receiver +from django.utils import timezone +from django.utils.text import slugify + +import mongoengine +from mongoengine.queryset.visitor import Q +import prov.model + +from applications.models import Application +from core.models import BaseAppDataModel +from datasources.models import DataSource + +MAX_LENGTH_NAME_FIELD = 100 + + +class PedasiDummyApplication: + """ + Dummy application model to fall back to when an action was performed via the PEDASI web interface. + """ + name = 'PEDASI' + pk = 'pedasi' # We convert pk to string anyway - so this is fine + + @staticmethod + def get_absolute_url(): + """ + Return the URL at which PEDASI is hosted. + """ + # TODO don't hardcode URL + return 'http://www.pedasi-iot.org/' + + +@enum.unique +class ProvActivity(enum.Enum): + """ + Enum representing the types of activity to be tracked by PROV. + """ + UPDATE = 'piot:update' + ACCESS = 'piot:access' + + +class ProvEntry(mongoengine.DynamicDocument): + """ + Stored PROV record for a single action. + + e.g. Update a model's metadata, use a model. + + These will be referred to by a :class:`ProvWrapper` document. + """ + @classmethod + def create_prov(cls, + instance: BaseAppDataModel, + user_uri: str, + application: typing.Optional[Application] = None, + activity_type: typing.Optional[ProvActivity] = ProvActivity.UPDATE) -> 'ProvEntry': + """ + Build a PROV document representing a particular activity within PEDASI. + + :param instance: Application or DataSource which is the object of the activity + :param user_uri: URI of user who performed the activity + :param application: Application which the user used to perform the activity + :param activity_type: Type of the activity - from :class:`ProvActivity` + :return: PROV document in PROV-JSON form + """ + instance_type = ContentType.objects.get_for_model(instance) + + document = prov.model.ProvDocument(namespaces={ + 'piot': 'http://www.pedasi-iot.org/', + 'foaf': 'http://xmlns.com/foaf/0.1/', + 'xsd': 'http://www.w3.org/2001/XMLSchema#', + }) + + entity = document.entity( + # TODO unique identifier for instance + 'piot:e-' + slugify(instance_type.model) + str(instance.pk), + other_attributes={ + prov.model.PROV_TYPE: 'piot:' + slugify(instance_type.model), + 'xsd:anyURI': instance.get_absolute_url(), + } + ) + + activity = document.activity( + 'piot:a-' + str(uuid.uuid4()), + timezone.now(), + None, + other_attributes={ + prov.model.PROV_TYPE: activity_type.value + } + ) + + agent_user = document.agent( + # Generate a UUID so we can lookup records belonging to a user + # But not identify the user from a given record + # TODO how strongly do we want to prevent user identification? + # See https://github.com/Southampton-RSG/PEDASI-IoT/issues/10 + 'piot:u-' + str(uuid.uuid5(uuid.NAMESPACE_URL, user_uri)), + other_attributes={ + prov.model.PROV_TYPE: 'prov:Person', + } + ) + + if application is None: + application = PedasiDummyApplication + + agent_application = document.agent( + 'piot:app-' + str(application.pk), + other_attributes={ + prov.model.PROV_TYPE: 'prov:SoftwareAgent', + 'xsd:anyURI': application.get_absolute_url(), + } + ) + + document.actedOnBehalfOf( + agent_application, # User who performs the action, on behalf of... + agent_user, # User who is responsible + activity, # NB: The prov library documentation suggests these are the other way round + other_attributes={ + prov.model.PROV_TYPE: 'piot:ApplicationAction', + } + ) + + # PROV library does not appear to be able to give a Python dictionary directly - have to go via string + return cls.deserialize(content=document.serialize()) + + @classmethod + def deserialize(cls, source=None, content: str = None, format: str = 'json', **kwargs): + """ + Create an instance of :class:`ProvEntry` from another instance or a string document. + + Used to create a :class:`ProvEntry` from a serialized PROV document. + + Provide one of 'source' or 'content'. + + :param source: Source from which to copy object + :param content: Text from which to create object + :param format: Format of text - e.g. JSON + :return: New instance of :class:`ProvEntry` + """ + if source is None and content is not None and format == 'json': + json_string = content + + else: + document = prov.model.ProvDocument.deserialize(source, content, format, **kwargs) + json_string = document.serialize(format='json') + + # PROV library does not appear to be able to give a Python dictionary directly - have to go via string + prov_json = json.loads(json_string) + return cls(**prov_json) + + +class ProvWrapper(mongoengine.Document): + """ + Wrapper around a single PROV record (:class:`ProvEntry`) which allows it to be easily linked to an instance + of a Django model. + + This is managed using MongoEngine rather than as a Django model. + """ + #: App from which the model comes + app_label = mongoengine.fields.StringField(max_length=MAX_LENGTH_NAME_FIELD, + required=True, null=False) + + #: Name of the model + model_name = mongoengine.fields.StringField(max_length=MAX_LENGTH_NAME_FIELD, + required=True, null=False) + + # TODO should this be editable=False? Can a model PK change? + # TODO can we make this behave like a primary key? + #: Primary key of the model instance + related_pk = mongoengine.fields.IntField(required=True, null=False) + + #: The actual PROV entry + entry = mongoengine.fields.ReferenceField( + document_type=ProvEntry + ) + + @property + def instance(self): + """ + Return the Django model instance to which this :class:`ProvWrapper` refers. + """ + model = apps.apps.get_model(self.app_label, self.model_name) + return model.objects.get(pk=self.related_pk) + + @classmethod + def filter_model_instance(cls, instance: BaseAppDataModel) -> QuerySet: + """ + Get all :class:`ProvEntry` documents related to a particular Django model instance. + + :param instance: Model instance for which to get all :class:`ProvEntry`s + :return: List of :class:`ProvEntry`s + """ + instance_type = ContentType.objects.get_for_model(instance) + + return ProvWrapper.objects( + Q(app_label=instance_type.app_label) & + Q(model_name=instance_type.model) & + Q(related_pk=instance.pk) + ).values_list('entry') + + @classmethod + def create_prov(cls, + instance: BaseAppDataModel, + user_uri: str, + application: typing.Optional[Application] = None, + activity_type: typing.Optional[ProvActivity] = ProvActivity.UPDATE) -> ProvEntry: + """ + Create a PROV record for a single action. + + e.g. Update a model's metadata, use a model. + + These will create and return a :class:`ProvEntry` document. + """ + prov_entry = ProvEntry.create_prov(instance, user_uri, + application=application, + activity_type=activity_type) + prov_entry.save() + + instance_type = ContentType.objects.get_for_model(instance) + + wrapper = cls( + app_label=instance_type.app_label, + model_name=instance_type.model, + related_pk=instance.pk, + entry=prov_entry + ) + wrapper.save() + + return prov_entry + + def delete(self, signal_kwargs=None, **write_concern): + """ + Delete this document and the :class:`ProvEntry` to which it refers. + """ + self.entry.delete(signal_kwargs, **write_concern) + super().delete(signal_kwargs, **write_concern) + + +@receiver(signals.post_save, sender=Application) +@receiver(signals.post_save, sender=DataSource) +def save_prov(sender, instance, **kwargs): + """ + Signal receiver to create a :class:`ProvEntry` when a PROV tracked model is saved. + """ + ProvWrapper.create_prov( + instance, + # TODO what if an admin edits a model? + instance.owner.get_uri(), + activity_type=ProvActivity.UPDATE + ) diff --git a/provenance/tests.py b/provenance/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..21029c68d10595a4d0e53e999cfdc41aa6629a75 --- /dev/null +++ b/provenance/tests.py @@ -0,0 +1,166 @@ +""" +Tests for PROV tracking functionality and the models required to support it. +""" + +import json +import pathlib +import unittest + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase + +import jsonschema +import mongoengine +from mongoengine.queryset.visitor import Q + +from datasources.models import DataSource +from provenance import models + + +# Create connection to test DB +TEST_DB = mongoengine.connect('test_prov') +TEST_DB.drop_database('test_prov') + + +class ProvEntryTest(TestCase): + """ + Test the :class:`ProvEntry` model in isolation. + """ + + @classmethod + def setUpTestData(cls): + cls.user_model = get_user_model() + cls.user = cls.user_model.objects.create_user('Test Prov User') + + def setUp(self): + self.datasource = DataSource.objects.create( + name='Test Data Source', + url='http://www.example.com', + owner=self.user, + plugin_name='TEST' + ) + + def tearDown(self): + # Have to delete instance manually since we're not using Django's database manager + datasource_type = ContentType.objects.get_for_model(DataSource) + + models.ProvWrapper.objects( + Q(app_label=datasource_type.app_label) & + Q(model_name=datasource_type.model) & + Q(related_pk=self.datasource.pk) + ).delete() + + def test_prov_created(self): + """ + Test the creation of a :class:`ProvEntry` in isolation. + """ + entry = models.ProvEntry.create_prov(self.datasource, + self.user.get_uri()) + + self.assertIsNotNone(entry) + + # TODO test content of PROV document - not just compliance to spec + def test_prov_schema(self): + """ + Validate :class:`ProvEntry` against PROV-JSON schema. + """ + entry = models.ProvEntry.create_prov(self.datasource, + self.user.get_uri()) + entry_json = json.loads(entry.to_json()) + + schema_path = pathlib.Path(settings.BASE_DIR).joinpath('provenance', 'data', 'prov-json.schema.json') + with open(schema_path) as schema_file: + schema = json.load(schema_file) + + validator = jsonschema.Draft4Validator(schema) + + try: + validator.validate(entry_json) + except jsonschema.exceptions.ValidationError: + for error in validator.iter_errors(entry_json): + print(error.message) + raise + + +class ProvWrapperTest(TestCase): + """ + Test the wrapper that allows us to look up :class:`ProvEntry`s for a given object. + """ + + @classmethod + def setUpTestData(cls): + cls.user_model = get_user_model() + cls.user = cls.user_model.objects.create_user('Test Prov User') + + def setUp(self): + self.datasource = DataSource.objects.create( + name='Test Data Source', + url='http://www.example.com', + owner=self.user, + plugin_name='TEST' + ) + + def tearDown(self): + # Have to delete instance manually since we're not using Django's database manager + datasource_type = ContentType.objects.get_for_model(DataSource) + + models.ProvWrapper.objects( + Q(app_label=datasource_type.app_label) & + Q(model_name=datasource_type.model) & + Q(related_pk=self.datasource.pk) + ).delete() + + @staticmethod + def _count_prov(datasource: DataSource) -> int: + prov_entries = models.ProvWrapper.filter_model_instance(datasource) + return prov_entries.count() + + def test_prov_datasource_create(self): + """ + Test that a :class:`ProvEntry` is created when a model is created. + """ + # PROV record should be created when model is created + self.assertEqual(self._count_prov(self.datasource), 1) + + def test_prov_datasource_update(self): + """ + Test that a new :class:`ProvEntry` is created when a model is updated. + """ + n_provs = self._count_prov(self.datasource) + + self.datasource.plugin_name = 'CHANGED' + self.datasource.save() + + # Another PROV record should be created when model is changed and saved + self.assertEqual(self._count_prov(self.datasource), n_provs + 1) + + @unittest.expectedFailure + def test_prov_datasource_null_update(self): + """ + Test that no new :class:`ProvEntry` is created when a model is saved without changes. + """ + n_provs = self._count_prov(self.datasource) + + self.datasource.save() + + # No PROV record should be created when saving a model that has not changed + self.assertEqual(self._count_prov(self.datasource), n_provs) + + def test_prov_records_distinct(self): + """ + Test that :class:`ProvEntry`s are not reused. + """ + prov_entries = models.ProvWrapper.filter_model_instance(self.datasource) + + new_datasource = DataSource.objects.create( + name='Another Test Data Source', + url='http://www.example.com', + owner=self.user, + plugin_name='TEST' + ) + new_prov_entries = models.ProvWrapper.filter_model_instance(new_datasource) + + intersection = set(prov_entries).intersection(new_prov_entries) + self.assertFalse(intersection) diff --git a/requirements.txt b/requirements.txt index 49e5f7f8fa1e5fcae6e7fae5502ad40d33c2eda1..5ec9ca35cae6d7137f67d446713fd0a74e470810 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,37 +5,54 @@ Babel==2.6.0 certifi==2018.8.13 cffi==1.11.5 chardet==3.0.4 -dataclasses==0.6 +decorator==4.3.0 +defusedxml==0.5.0 dj-database-url==0.5.0 Django==2.0.8 django-bootstrap4==0.0.6 -djongo==1.2.29 +django-cors-headers==2.4.0 +django-haystack==2.8.1 +djangorestframework==3.9.1 docutils==0.14 idna==2.7 imagesize==1.0.0 +isodate==0.6.0 isort==4.3.4 Jinja2==2.10 +jsonschema==2.6.0 lazy-object-proxy==1.3.1 +lxml==4.2.5 MarkupSafe==1.0 mccabe==0.6.1 mongoengine==0.15.3 mysqlclient==1.3.13 +networkx==2.2 +oauthlib==2.1.0 packaging==17.1 +prov==1.5.2 pycparser==2.18 Pygments==2.2.0 +PyJWT==1.6.4 pylint==2.1.1 pylint-django==2.0 pylint-plugin-utils==0.4 pymongo==3.7.1 pyparsing==2.2.0 +python-dateutil==2.7.3 python-decouple==3.1 +python3-openid==3.1.0 pytz==2018.5 +rdflib==4.2.2 requests==2.19.1 +requests-oauthlib==1.0.0 six==1.11.0 snowballstemmer==1.2.1 +social-auth-app-django==3.0.0 +social-auth-core==2.0.0 Sphinx==1.7.6 sphinxcontrib-websupport==1.1.0 -sqlparse==0.2.4 +SQLAlchemy==1.2.14 typed-ast==1.1.0 urllib3==1.23 +Whoosh==2.7.4 wrapt==1.10.11 diff --git a/scripts/goaccess.sh b/scripts/goaccess.sh new file mode 100755 index 0000000000000000000000000000000000000000..bf0eb4494dc12f4bd3e9d7375aa0d273cdb3f90f --- /dev/null +++ b/scripts/goaccess.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash + +if [[ "$#" -ne 1 ]]; then + echo "Requires output file name" + exit 1 +fi + +if [[ -f /var/log/nginx/access.log.2.gz ]]; then + zcat /var/log/nginx/access.log.*.gz | goaccess -q --log-format=COMBINED -o $1 /var/log/nginx/access.log /var/log/nginx/access.log.1 - +elif [[ -f /var/log/nginx/access.log.1 ]]; then + goaccess -q --log-format=COMBINED -o $1 /var/log/nginx/access.log /var/log/nginx/access.log.1 - +else + goaccess -q --log-format=COMBINED -o $1 /var/log/nginx/access.log +fi