diff --git a/api/permissions.py b/api/permissions.py new file mode 100644 index 0000000000000000000000000000000000000000..fdb89abcbb3d84f9878534bfd435c25df965cf9b --- /dev/null +++ b/api/permissions.py @@ -0,0 +1,43 @@ +from rest_framework import permissions + +from datasources import models + + +class BaseUserPermission(permissions.BasePermission): + message = 'You do not have permission to access this resource.' + permission_level = models.UserPermissionLevels.NONE + + def has_object_permission(self, request, view, obj): + if not obj.access_control: + return True + + try: + permission = models.UserPermissionLink.objects.get( + user=request.user, + datasource=obj + ) + + return permission.granted >= self.permission_level + + except models.UserPermissionLink.DoesNotExist: + return False + + +class ViewPermission(BaseUserPermission): + message = 'You do not have permission to access this resource.' + permission_level = models.UserPermissionLevels.VIEW + + +class MetadataPermission(BaseUserPermission): + message = 'You do not have permission to access the metadata of this resource.' + permission_level = models.UserPermissionLevels.META + + +class DataPermission(BaseUserPermission): + message = 'You do not have permission to access the data of this resource.' + permission_level = models.UserPermissionLevels.DATA + + +class ProvPermission(BaseUserPermission): + message = 'You do not have permission to access the prov data of this resource.' + permission_level = models.UserPermissionLevels.PROV diff --git a/api/tests.py b/api/tests.py index 19521951d376e8bc62885e143e07407fb0d666f2..e7cb105768785b577fc4603c493a4adf5b9d67e2 100644 --- a/api/tests.py +++ b/api/tests.py @@ -149,6 +149,191 @@ class DataSourceApiTest(TestCase): self._assert_datasource_correct(datasource) +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 = APIClient() + self.owner_client.force_authenticate(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) + + 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', + access_control=True + ) + + 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', + access_control=True + ) + + 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', + access_control=True + ) + + 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', + access_control=True + ) + + 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): diff --git a/api/views/datasources.py b/api/views/datasources.py index 6040353bf4d8368581e9ce029ec3811ebd4b5ce3..1c866d4878fd333ea71f2c5b6d20e55744bee4be 100644 --- a/api/views/datasources.py +++ b/api/views/datasources.py @@ -5,6 +5,7 @@ from django.db.models import ObjectDoesNotExist from django.http import HttpResponse from rest_framework import decorators, response, viewsets +from .. import permissions from datasources import models, serializers from provenance import models as prov_models @@ -39,6 +40,7 @@ class DataSourceApiViewset(viewsets.ReadOnlyModelViewSet): """ queryset = models.DataSource.objects.all() serializer_class = serializers.DataSourceSerializer + permission_classes = [permissions.ViewPermission] def try_passthrough_response(self, map_response: typing.Callable[..., HttpResponse], @@ -85,7 +87,7 @@ class DataSourceApiViewset(viewsets.ReadOnlyModelViewSet): } return response.Response(data, status=400) - @decorators.action(detail=True) + @decorators.action(detail=True, permission_classes=[permissions.ProvPermission]) def prov(self, request, pk=None): """ View for /api/datasources/<int>/prov/ @@ -100,7 +102,7 @@ class DataSourceApiViewset(viewsets.ReadOnlyModelViewSet): } return response.Response(data, status=200) - @decorators.action(detail=True) + @decorators.action(detail=True, permission_classes=[permissions.MetadataPermission]) def metadata(self, request, pk=None): """ View for /api/datasources/<int>/metadata/ @@ -117,7 +119,7 @@ class DataSourceApiViewset(viewsets.ReadOnlyModelViewSet): return self.try_passthrough_response(map_response, 'Data source does not provide metadata') - @decorators.action(detail=True) + @decorators.action(detail=True, permission_classes=[permissions.DataPermission]) def data(self, request, pk=None): """ View for /api/datasources/<int>/data/ @@ -132,7 +134,7 @@ class DataSourceApiViewset(viewsets.ReadOnlyModelViewSet): return self.try_passthrough_response(map_response, 'Data source does not provide data') - @decorators.action(detail=True) + @decorators.action(detail=True, permission_classes=[permissions.MetadataPermission]) def datasets(self, request, pk=None): """ View for /api/datasources/<int>/datasets/ @@ -151,7 +153,8 @@ class DataSourceApiViewset(viewsets.ReadOnlyModelViewSet): # TODO URL pattern here uses pre Django 2 format @decorators.action(detail=True, - url_path='datasets/(?P<href>.*)/metadata') + 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/ @@ -171,7 +174,8 @@ class DataSourceApiViewset(viewsets.ReadOnlyModelViewSet): # TODO URL pattern here uses pre Django 2 format @decorators.action(detail=True, - url_path='datasets/(?P<href>.*)/data') + 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>/metadata/ diff --git a/applications/models.py b/applications/models.py index c5496c34ae5da3969021d573da8c6eb293a34943..985ba161908e237bc4eda70c03a69a00a38ecdd6 100644 --- a/applications/models.py +++ b/applications/models.py @@ -1,4 +1,5 @@ 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 @@ -35,6 +36,67 @@ class Application(BaseAppDataModel): 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) + + @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. @@ -52,12 +114,6 @@ class Application(BaseAppDataModel): return proxy_user - def save(self, **kwargs): - if not self.proxy_user: - self.proxy_user = self._get_proxy_user() - - return super().save(**kwargs) - def get_absolute_url(self): return reverse('applications:application.detail', kwargs={'pk': self.pk}) diff --git a/core/models.py b/core/models.py index f45646c4e5d52c71b72a6bf87a218c4412eee6d0..c968f1fce3bc4b776f4ea30db4347f7c3e3a045a 100644 --- a/core/models.py +++ b/core/models.py @@ -4,8 +4,6 @@ This module contains models for functionality common to both Application and Dat import abc -from django.conf import settings -from django.contrib.auth.models import Group from django.db import models @@ -46,64 +44,6 @@ class BaseAppDataModel(models.Model): """ raise NotImplementedError - #: 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) - - @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' - ) - - super().save(**kwargs) - - def has_view_permission(self, user: settings.AUTH_USER_MODEL) -> bool: - """ - Does a user have permission to use this data source? - - :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() - @abc.abstractmethod def get_absolute_url(self): """ diff --git a/datasources/forms.py b/datasources/forms.py new file mode 100644 index 0000000000000000000000000000000000000000..6731712b6ea454dd24a8d12c3c33d655aa74958c --- /dev/null +++ b/datasources/forms.py @@ -0,0 +1,15 @@ +from django import forms + +from . import models + + +class PermissionRequestForm(forms.ModelForm): + class Meta: + model = models.UserPermissionLink + fields = ['requested', 'reason'] + + +class PermissionGrantForm(forms.ModelForm): + class Meta: + model = models.UserPermissionLink + fields = ['granted'] 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/models.py b/datasources/models.py index a98fbf9952cf2eccf7c5a006f21a16322097969a..0386db69b9b65d3541cc809c66616f56154c5eaf 100644 --- a/datasources/models.py +++ b/datasources/models.py @@ -1,3 +1,4 @@ +import enum import json import typing @@ -11,6 +12,61 @@ import requests.exceptions from core.models import BaseAppDataModel, MAX_LENGTH_API_KEY, MAX_LENGTH_NAME, MAX_LENGTH_PATH 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 + + +@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) + + #: Reason the permission was requested + reason = models.CharField(max_length=MAX_LENGTH_REASON, + blank=True, null=False) + class DataSource(BaseAppDataModel): """ @@ -44,11 +100,15 @@ class DataSource(BaseAppDataModel): api_key = models.CharField(max_length=MAX_LENGTH_API_KEY, blank=True, null=False) - #: Which authentication method to use - defined in datasources.connectors.base.AuthMethod enum + #: Which authentication method to use - defined in :class:`datasources.connectors.base.AuthMethod` enum auth_method = models.IntegerField(choices=AuthMethod.choices(), default=AuthMethod.UNKNOWN.value, editable=False, blank=False, null=False) + #: Users - linked via a permission table - see :class:`UserPermissionLink` + users = models.ManyToManyField(settings.AUTH_USER_MODEL, + through=UserPermissionLink) + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._data_connector = None @@ -60,6 +120,29 @@ class DataSource(BaseAppDataModel): return super().save(**kwargs) + def has_view_permission(self, user: settings.AUTH_USER_MODEL) -> bool: + """ + 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 True + + try: + permission = UserPermissionLink.objects.get( + user=user, + datasource=self + ) + except UserPermissionLink.DoesNotExist: + return False + + return permission.granted >= UserPermissionLevels.VIEW + @property def is_catalogue(self) -> bool: return self.data_connector_class.is_catalogue diff --git a/datasources/templates/datasources/datasource/detail-no-access.html b/datasources/templates/datasources/datasource/detail-no-access.html index fcdddd1a39e15d541976c7e30d07169c603c5e5c..a1bce56974966e161d087c160773f527b42e55ed 100644 --- a/datasources/templates/datasources/datasource/detail-no-access.html +++ b/datasources/templates/datasources/datasource/detail-no-access.html @@ -20,43 +20,53 @@ </ol> </nav> - <h2>View Data Source - {{ datasource.name }}</h2> + + <div class="row"> + <div class="col-md-10 col-sm-8"> + <h2>{{ datasource.name }}</h2> + + {% if datasource.description %} + {{ datasource.description|linebreaks }} + {% endif %} + </div> + + <div class="col-md-2 col-sm-4"> + {% if datasource.access_control and 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. - {% if datasource.users_group_requested in request.user.groups.all %} - <button id="btn-request-access" disabled - class="btn btn-primary" role="button">Access Requested</button> + {% if request.user in datasource.users.all %} + Please wait for your request to be approved. {% 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-primary" role="button">Request Access</button> - - <script type="application/javascript"> - function requestAccess(){ - $.ajax({ - url: '{% url 'datasources:datasource.manage-access.user' pk=datasource.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> - {% if datasource.description %} - <p>{{ datasource.description }}</p> - {% endif %} + <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> {% endblock %} \ No newline at end of file diff --git a/datasources/templates/datasources/datasource/detail.html b/datasources/templates/datasources/datasource/detail.html index eaeecbec0231bf0e9b2e290ab6cf03bf859c91ab..8fdba0376e061411d06dd5f32d0dcec9b61f1bdb 100644 --- a/datasources/templates/datasources/datasource/detail.html +++ b/datasources/templates/datasources/datasource/detail.html @@ -20,18 +20,23 @@ <div class="row"> <div class="col-md-10 col-sm-8"> <h2>{{ datasource.name }}</h2> + + {% if datasource.description %} + {{ datasource.description|linebreaks }} + {% endif %} </div> <div class="col-md-2 col-sm-4"> <a href="{% url 'api:datasource-detail' pk=datasource.id %}" class="btn btn-block btn-info" role="button">API Explorer</a> + + {% if datasource.access_control and 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> - {% if datasource.description %} - {{ datasource.description|linebreaks }} - {% endif %} - <table class="table"> <thead> <th scope="col" class="col-md-2 border-0"></th> @@ -53,14 +58,14 @@ </table> <div class="row justify-content-sm-center"> - {% if datasource.access_control %} - <div class="col-md-2 col-sm-4"> - <a href="{% url 'datasources:datasource.manage-access' pk=datasource.pk %}" - class="btn btn-block col btn-primary" role="button">Manage Access</a> - </div> - {% endif %} - {% if has_edit_permission %} + {% if datasource.access_control %} + <div class="col-md-2 col-sm-4"> + <a href="{% url 'datasources:datasource.access.manage' pk=datasource.pk %}" + class="btn btn-block col btn-primary" role="button">Manage Access</a> + </div> + {% endif %} + <div class="col-md-2 col-sm-4"> <a href="{% url 'admin:datasources_datasource_change' datasource.id %}" class="btn btn-block btn-success" role="button">Edit</a> diff --git a/datasources/templates/datasources/datasource/list.html b/datasources/templates/datasources/datasource/list.html index dafe837d2e9fe10a357ace2f065f24839710afda..6ac0f461fd82a2f5c3dde396e3f6c8e289571345 100644 --- a/datasources/templates/datasources/datasource/list.html +++ b/datasources/templates/datasources/datasource/list.html @@ -59,7 +59,7 @@ </td> <td> <a href="{% url 'datasources:datasource.detail' pk=datasource.pk %}" - class="btn btn-block btn-primary" role="button">Detail</a> + class="btn btn-block btn-secondary" role="button">Detail</a> </td> </tr> {% empty %} diff --git a/datasources/templates/datasources/datasource/manage_access.html b/datasources/templates/datasources/datasource/manage_access.html index e788956b7b0ed4e59d7fe190cc98bf79e6b1ceec..bd1459dfe2f10361ea9dc263e957c68702369d83 100644 --- a/datasources/templates/datasources/datasource/manage_access.html +++ b/datasources/templates/datasources/datasource/manage_access.html @@ -37,28 +37,39 @@ <thead class="thead"> <tr> <th>Username</th> + <th>Requested</th> + <th>Current</th> + <th></th> <th></th> <th></th> </tr> </thead> <tbody> - {% for user in datasource.users_group_requested.user_set.all %} - <tr id="requested-user-{{ user.pk }}"> - <td>{{ user.username }}</td> + {% for permission in permissions_requested.all %} + <tr id="requested-user-{{ permission.user.pk }}"> + <td> + <p> + {{ permission.user.username }} + </p> + <div class="alert alert-secondary" role="note"> + {{ permission.reason }} + </div> + </td> + <td>{{ permission.get_requested_display }}</td> + <td>{{ permission.get_granted_display }}</td> <td> - <button onclick="userGrantAccess( - '{% url 'datasources:datasource.manage-access.user' pk=datasource.pk user_pk=user.pk %}', - {{ user.pk }} - )" + <button onclick="userGrantAccess({{ permission.user.pk }}, {{ permission.requested }})" class="btn btn-success" role="button">Approve</button> </td> <td> - <button onclick="userRemoveAccess( - '{% url 'datasources:datasource.manage-access.user' pk=datasource.pk user_pk=user.pk %}', - {{ user.pk }} - )" + <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="userGrantAccess({{ permission.user.pk }}, {{ permission.granted }})" class="btn btn-danger" role="button">Reject</button> </td> @@ -77,21 +88,27 @@ <thead class="thead"> <tr> <th>Username</th> + <th>Requested</th> + <th>Current</th> + <th></th> <th></th> <th></th> </tr> </thead> <tbody> - {% for user in datasource.users_group.user_set.all %} - <tr id="approved-user-{{ user.pk }}"> - <td>{{ user.username }}</td> - <td></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> - <button onclick="userRemoveAccess( - '{% url 'datasources:datasource.manage-access.user' pk=datasource.pk user_pk=user.pk %}', - {{ user.pk }} - )" + <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> @@ -103,14 +120,17 @@ </table> <script type="application/javascript"> - function userGrantAccess(url, userPk){ - $.ajax({ - {#url: '{% url 'datasources:datasource.manage-access.user' pk=datasource.pk user_pk=request.user.pk %}',#} - url: url, + function userGrantAccess(userPk, level){ + $.post({ + url: '{% url 'datasources:datasource.access.grant' pk=datasource.pk %}', + data: { + 'user': userPk, + 'granted': level, + 'requested': level + }, headers: { 'X-CSRFToken': Cookies.get('csrftoken') }, - method: 'PUT', success: function(result, status, xhr){ document.getElementById('requested-user-' + userPk.toString()).remove(); @@ -119,14 +139,17 @@ }) } - function userRemoveAccess(url, userPk){ - $.ajax({ - {#url: '{% url 'datasources:datasource.manage-access.user' pk=datasource.pk user_pk=request.user.pk %}',#} - url: url, + 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') }, - method: 'DELETE', success: function(result, status, xhr){ try { document.getElementById('approved-user-' + userPk.toString()).remove(); @@ -135,9 +158,9 @@ try { document.getElementById('requested-user-' + userPk.toString()).remove(); } catch (err) {} - } - // TODO if table is empty add 'table is empty' row + // TODO if table is empty add 'table is empty' row + } }) } </script> 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..916c912068bc58a922771bfe9fa23cafc5f75848 --- /dev/null +++ b/datasources/templates/datasources/user_permission_link/permission_grant.html @@ -0,0 +1,69 @@ +{% 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> + + {% if datasource.description %} + {{ datasource.description|linebreaks }} + {% endif %} + + <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 + + <div class="alert alert-secondary" role="note"> + <p> + {{ permission.reason|linebreaks }} + </p> + </div> + + <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/datasources/user_permission_link/permission_request.html b/datasources/templates/datasources/user_permission_link/permission_request.html new file mode 100644 index 0000000000000000000000000000000000000000..6ce6c7f33112a8f06f982dff7dcf8653ebe4c61f --- /dev/null +++ b/datasources/templates/datasources/user_permission_link/permission_request.html @@ -0,0 +1,61 @@ +{% 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> + + {% if datasource.description %} + {{ datasource.description|linebreaks }} + {% endif %} + + <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/urls.py b/datasources/urls.py index 1747efc4f223438792095ec0efe3fd9d402700bf..cdba90c399196596ad1e8384d54529d2484585a2 100644 --- a/datasources/urls.py +++ b/datasources/urls.py @@ -6,34 +6,41 @@ app_name = 'datasources' urlpatterns = [ path('', - views.DataSourceListView.as_view(), + views.datasource.DataSourceListView.as_view(), name='datasource.list'), 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>/users/<int:user_pk>', - views.DataSourceRequestAccessView.as_view(), - name='datasource.manage-access.user'), - path('<int:pk>/query', - views.DataSourceQueryView.as_view(), + views.datasource.DataSourceQueryView.as_view(), name='datasource.query'), path('<int:pk>/metadata', - views.DataSourceMetadataView.as_view(), + views.datasource.DataSourceMetadataView.as_view(), name='datasource.metadata'), path('<int:pk>/explore', - views.DataSourceExploreView.as_view(), + views.datasource.DataSourceExploreView.as_view(), name='datasource.explore'), path('<int:pk>/search', - views.DataSourceDataSetSearchView.as_view(), + views.datasource.DataSourceDataSetSearchView.as_view(), name='datasource.dataset.search'), + + ####################### + # 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 589facbde5c9b0c72d5a5bd6d28e4e0af467c7ab..0000000000000000000000000000000000000000 --- a/datasources/views.py +++ /dev/null @@ -1,224 +0,0 @@ -from django.contrib.auth import get_user_model -from django.http import JsonResponse, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden -from django.views.generic import View -from django.views.generic.detail import DetailView, SingleObjectMixin -from django.views.generic.list import ListView - -import requests.exceptions - -from profiles.permissions import HasViewPermissionMixin, 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() - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - - context['has_edit_permission'] = self.request.user.is_staff or self.request.user == self.object.owner - - return context - - -class DataSourceDataSetSearchView(DetailView): - model = models.DataSource - template_name = 'datasources/datasource/dataset_search.html' - context_object_name = 'datasource' - - 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 DataSourceManageAccessView(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 - - return context - - -class DataSourceRequestAccessView(SingleObjectMixin, View): - """ - Manage a user's access to a DataSource. - - 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.DataSource - - def put(self, request, *args, **kwargs): - """ - Add a user to the access group for a DataSource. - - If the request is performed by the DataSource 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_staff: - # If request is from DataSource owner or staff, 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 DataSource 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 - }, - }, - }) - - -class DataSourceQueryView(HasViewPermissionMixin, 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( - params={'year': 2018} - ) - - return context - - -class DataSourceMetadataView(HasViewPermissionMixin, 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 - - -class DataSourceExploreView(HasViewPermissionMixin, DetailView): - model = models.DataSource - template_name = 'datasources/datasource/explore.html' - context_object_name = 'datasource' diff --git a/datasources/views/__init__.py b/datasources/views/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..825d77f60d80dc456a8e2cfeec611f73f246e674 --- /dev/null +++ b/datasources/views/__init__.py @@ -0,0 +1 @@ +from . import datasource, 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..fc2f6ccf802075eb05b6f947a4f2c95a6c10586c --- /dev/null +++ b/datasources/views/datasource.py @@ -0,0 +1,113 @@ +from django.http import HttpResponse +from django.views.generic.detail import DetailView +from django.views.generic.list import ListView + +import requests.exceptions + +from datasources import models +from profiles.permissions import HasViewPermissionMixin + + +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_staff or self.request.user == self.object.owner + + return context + + +class DataSourceDataSetSearchView(DetailView): + model = models.DataSource + template_name = 'datasources/datasource/dataset_search.html' + context_object_name = 'datasource' + + 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 DataSourceQueryView(HasViewPermissionMixin, 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( + params={'year': 2018} + ) + + return context + + +class DataSourceMetadataView(HasViewPermissionMixin, 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 + + +class DataSourceExploreView(HasViewPermissionMixin, DetailView): + model = models.DataSource + template_name = 'datasources/datasource/explore.html' + context_object_name = 'datasource' diff --git a/datasources/views/user_permission_link.py b/datasources/views/user_permission_link.py new file mode 100644 index 0000000000000000000000000000000000000000..41ebc64ed0be616d46069af561bf14d487710787 --- /dev/null +++ b/datasources/views/user_permission_link.py @@ -0,0 +1,175 @@ +from django.contrib.auth import get_user_model +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') + ) + + context['permissions_granted'] = models.UserPermissionLink.objects.filter( + datasource=self.object, + requested__lte=F('granted'), + # granted__gt=models.UserPermissionLevels.NONE + ) + + return context + + +class DataSourceAccessGrantView(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 + + 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 + + if form.instance.requested == models.UserPermissionLevels.NONE: + 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(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 + + obj, created = self.model.objects.get_or_create( + user=user, + datasource=self.datasource + ) + + return obj + + 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.instance.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}) + +