diff --git a/api/tests.py b/api/tests.py index 483cf28300763493a739c594f28f991616e4f38a..9803a6cd92dd434e449a9bb736ba0c2a0067a11c 100644 --- a/api/tests.py +++ b/api/tests.py @@ -1,5 +1,7 @@ import typing +import unittest + from django.contrib.auth import get_user_model from django.test import Client, TestCase @@ -554,6 +556,8 @@ class DataSourceApiHyperCatTest(TestCase): self.assertLessEqual(1, len(data['data'])) # TODO test contents of 'data' list + # CityVerve API is discontinued + @unittest.expectedFailure def test_api_datasource_get_dataset_metadata(self): """ Test the :class:`DataSource` API functionality to retrieve dataset metadata. @@ -569,6 +573,8 @@ class DataSourceApiHyperCatTest(TestCase): self.assertLessEqual(1, len(data['data'])) # TODO test contents of 'data' list + # CityVerve API is discontinued + @unittest.expectedFailure def test_api_datasource_get_dataset_data(self): """ Test the :class:`DataSource` API functionality to retrieve dataset data. diff --git a/api/views/datasources.py b/api/views/datasources.py index 0061d50c7344ccdc9d266e68bb47023ebc58bf7f..1804a6da47d915b5b9184f678b49a45b961f300a 100644 --- a/api/views/datasources.py +++ b/api/views/datasources.py @@ -8,7 +8,9 @@ import typing from django.db.models import ObjectDoesNotExist from django.http import HttpResponse, JsonResponse + from rest_framework import decorators, request, response, viewsets +from requests.exceptions import HTTPError from .. import permissions from datasources import models, serializers @@ -20,28 +22,28 @@ class DataSourceApiViewset(viewsets.ReadOnlyModelViewSet): Provides views for: /api/datasources/ - List all :class:`DataSource`s + List all :class:`datasources.models.DataSource`\ s. /api/datasources/<int>/ - Retrieve a single :class:`DataSource` + Retrieve a single :class:`datasources.models.DataSource`. /api/datasources/<int>/prov/ - Retrieve PROV records related to a :class:`DataSource` + Retrieve PROV records related to a :class:`datasources.models.DataSource`. /api/datasources/<int>/metadata/ - Retrieve :class:`DataSource` metadata via API call to data source URL + Retrieve :class:`datasources.models.DataSource` metadata via API call to data source URL. /api/datasources/<int>/data/ - Retrieve :class:`DataSource` data via API call to data source URL + Retrieve :class:`datasources.models.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 + Retrieve :class:`datasources.models.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 + Retrieve :class:`datasources.models.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 + Retrieve :class:`datasources.models.DataSource` data for a single dataset via API call to data source URL. """ queryset = models.DataSource.objects.all() serializer_class = serializers.DataSourceSerializer @@ -134,6 +136,10 @@ class DataSourceApiViewset(viewsets.ReadOnlyModelViewSet): } return response.Response(data, status=400) + except HTTPError as e: + # Pass upstream errors through + return response.Response(e.response.text, status=e.response.status_code) + def list(self, request, *args, **kwargs): """ List the queryset after filtering by request query parameters for data source metadata. diff --git a/applications/admin.py b/applications/admin.py index 9f0ea3ec04cf84fbc0d8355c69300cada23996a3..107e3f2c4e8ff4af5d2d66f4a1b7e0e4b620366b 100644 --- a/applications/admin.py +++ b/applications/admin.py @@ -14,7 +14,7 @@ class ApplicationAdmin(admin.ModelAdmin): permission = super().has_change_permission(request, obj) if obj is not None: - permission &= obj.owner == request.user + permission &= (obj.owner == request.user) or request.user.is_superuser return permission @@ -25,7 +25,7 @@ class ApplicationAdmin(admin.ModelAdmin): permission = super().has_delete_permission(request, obj) if obj is not None: - permission &= obj.owner == request.user + permission &= (obj.owner == request.user) or request.user.is_superuser return permission diff --git a/applications/models.py b/applications/models.py index 33fc2eca58c5c78c0dce1483ae5f9d81a5526f34..fa32ec28188fb954d46b70325d62bfbb3d8c6d37 100644 --- a/applications/models.py +++ b/applications/models.py @@ -1,3 +1,5 @@ +from uuid import uuid4 + from django.contrib.auth import get_user_model from django.contrib.auth.models import Group from django.conf import settings @@ -127,7 +129,8 @@ class Application(ProvAbleModel, ProvApplicationModel, BaseAppDataModel): if self.proxy_user: return self.proxy_user - proxy_username = 'application-proxy-' + slugify(self.name) + # Add random UUID to username to allow multiple applications with the same name + proxy_username = 'application-proxy-' + slugify(self.name) + str(uuid4()) proxy_user = get_user_model().objects.create_user(proxy_username) # Create an API access token for the proxy user diff --git a/applications/templates/applications/application/detail.html b/applications/templates/applications/application/detail.html index cdeb12b3c087f3c5f279c11293ddb22e935c3093..37a95c90d09d0ad111a93e0909f5fa4c8beb7651 100644 --- a/applications/templates/applications/application/detail.html +++ b/applications/templates/applications/application/detail.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"> @@ -62,28 +66,51 @@ <tr> <td>API Key</td> <td> - <span id="spanApiToken"> - {% if application.proxy_user.auth_token %} - {{ application.proxy_user.auth_token }} + <script type="application/javascript"> + function getToken() { + $.ajax({ + dataType: "json", + url: "{% url 'applications:token' pk=application.pk %}", + data: null, + success: function (data) { + $('#spanApiToken').text(data.data.token.key); + + document.getElementById("spanApiTokenPresent").style.display = "inline"; + document.getElementById("spanApiTokenAbsent").style.display = "none"; + } + }); + } - {% else %} - <script type="application/javascript"> - function getToken() { - $.ajax({ - dataType: "json", - url: "{% url 'applications:token' pk=application.pk %}", - data: null, - success: function (data) { - $('#spanApiToken').text(data.data.token.key); - } - }); + function revokeToken() { + $.ajax({ + dataType: "json", + url: "{% url 'applications:token' pk=application.pk %}", + method: "DELETE", + headers: { + "X-CSRFToken": Cookies.get("csrftoken") + }, + data: null, + success: function (data) { + $('#spanApiToken').text(""); + + document.getElementById("spanApiTokenPresent").style.display = "none"; + document.getElementById("spanApiTokenAbsent").style.display = "inline"; } - </script> + }); + } + </script> + + <span id="spanApiTokenPresent" {% if not application.proxy_user.auth_token %}style="display: none;"{% endif %}> + <span id="spanApiToken"> + {{ application.proxy_user.auth_token }} + </span> - <button onclick="getToken();" class="btn btn-default" role="button">Generate an API Token</button> - {% endif %} + <button onclick="revokeToken();" class="btn btn-danger" role="button">Revoke API Token</button> </span> + <span id="spanApiTokenAbsent" {% if application.proxy_user.auth_token %}style="display: none;"{% endif %}> + <button onclick="getToken();" class="btn btn-default" role="button">Generate API Token</button> + </span> </td> </tr> {% endif %} diff --git a/applications/urls.py b/applications/urls.py index ae74341444ed0fe0599bced8c8622946e25c3bb7..731701cb8c9d654fd7bbc4b44d75644b800e095a 100644 --- a/applications/urls.py +++ b/applications/urls.py @@ -26,7 +26,7 @@ urlpatterns = [ name='application.delete'), path('<int:pk>/token', - views.ApplicationGetTokenView.as_view(), + views.ApplicationManageTokenView.as_view(), name='token'), path('<int:pk>/manage-access', diff --git a/applications/views.py b/applications/views.py index 82565962e6a28573605c547ee5bdbbcaf3515d44..046a64136ede982dc98da345540d286688af30ee 100644 --- a/applications/views.py +++ b/applications/views.py @@ -1,3 +1,7 @@ +""" +Views to manage and access :class:`Application`s. +""" + from django.contrib.auth.mixins import PermissionRequiredMixin from django.http import JsonResponse from django.urls import reverse_lazy @@ -42,6 +46,7 @@ class ApplicationUpdateView(OwnerPermissionMixin, UpdateView): context_object_name = 'application' fields = '__all__' + permission_required = 'applications.change_application' class ApplicationDeleteView(OwnerPermissionMixin, DeleteView): @@ -49,6 +54,7 @@ class ApplicationDeleteView(OwnerPermissionMixin, DeleteView): template_name = 'applications/application/delete.html' context_object_name = 'application' + permission_required = 'applications.delete_application' success_url = reverse_lazy('applications:application.list') @@ -90,15 +96,15 @@ class ApplicationManageAccessView(OwnerPermissionMixin, ManageAccessView): context_object_name = 'application' -class ApplicationGetTokenView(OwnerPermissionMixin, DetailView): +class ApplicationManageTokenView(OwnerPermissionMixin, DetailView): """ - Get an API Token for an application. + Manage an API Token for an application. """ model = models.Application def render_to_response(self, context, **response_kwargs): """ - Get an existing API Token or create a new one for the currently authenticated user. + Get an existing API Token or create a new one for the requested :class:`Application`. :return: JSON containing Token key """ @@ -112,3 +118,17 @@ class ApplicationGetTokenView(OwnerPermissionMixin, DetailView): } } }) + + def delete(self, request, *args, **kwargs): + """ + Revoke an API Token for the requested :class:`Application`. + """ + self.object = self.get_object() + self.object.proxy_user.revoke_auth_token() + + return JsonResponse({ + 'status': 'success', + 'data': { + 'token': None, + } + }) diff --git a/datasources/admin.py b/datasources/admin.py index f98162f754fafea55bc3d7fccae6ec20095a71e6..c2419c0629b2ba98784c3ecfee0fcd8309da7548 100644 --- a/datasources/admin.py +++ b/datasources/admin.py @@ -20,7 +20,7 @@ class DataSourceAdmin(admin.ModelAdmin): permission = super().has_change_permission(request, obj) if obj is not None: - permission &= obj.owner == request.user + permission &= (obj.owner == request.user) or request.user.is_superuser return permission @@ -31,7 +31,7 @@ class DataSourceAdmin(admin.ModelAdmin): permission = super().has_delete_permission(request, obj) if obj is not None: - permission &= obj.owner == request.user + permission &= (obj.owner == request.user) or request.user.is_superuser return permission diff --git a/datasources/templates/datasources/datasource/detail.html b/datasources/templates/datasources/datasource/detail.html index 67152ed7bf0d97367a3d78e7d2d64636e39c01e5..d2a4c823b24511ab37f10a27af890603d72f5564 100644 --- a/datasources/templates/datasources/datasource/detail.html +++ b/datasources/templates/datasources/datasource/detail.html @@ -104,12 +104,16 @@ </td> </tr> <tr> - <td>URL</td> + <td>Licence</td> + <td>{{ datasource.licence.name }}</td> + </tr> + <tr> + <td>External API URL</td> <td>{{ datasource.url }}</td> </tr> <tr> - <td>Licence</td> - <td>{{ datasource.licence.name }}</td> + <td>Pedasi API URL</td> + <td>{{ api_url }}</td> </tr> </tbody> </table> diff --git a/datasources/templates/datasources/datasource/explorer.html b/datasources/templates/datasources/datasource/explorer.html index dff92cde41b694e4db184265fc5f24fffcc0bde9..1f215b0da83fefd809a818e7f179afba92cc1bf2 100644 --- a/datasources/templates/datasources/datasource/explorer.html +++ b/datasources/templates/datasources/datasource/explorer.html @@ -156,7 +156,7 @@ </form> <div class="alert alert-info w-100"> - Query URL: /api/datasources/{{ datasource.pk }}/<span id="datasetUrlSpan"></span>data/?<span id="queryParamSpan"></span> + Query URL: {{ api_url }}<span id="datasetUrlSpan"></span>data/?<span id="queryParamSpan"></span> </div> <table class="table" id="tableParams"> diff --git a/datasources/templates/datasources/datasource/manage_access.html b/datasources/templates/datasources/datasource/manage_access.html index 11ce9bdce97f4cf53ac318d7aa07e6c5467234ee..014e49572c01d74888b46ab1054a46c84e5ae9cf 100644 --- a/datasources/templates/datasources/datasource/manage_access.html +++ b/datasources/templates/datasources/datasource/manage_access.html @@ -47,7 +47,13 @@ <tr id="requested-user-{{ permission.user.pk }}"> <td> <p> - {{ permission.user.username }} + {% if permission.user.application_proxy %} + {{ permission.user.application_proxy.name }} + <a href="{% url 'applications:application.detail' pk=permission.user.application_proxy.pk %}" + role="button" class="badge badge-info">App</a> + {% else %} + {{ permission.user.username }} + {% endif %} </p> {% if permission.reason %} <div class="alert alert-secondary" role="note"> @@ -105,7 +111,15 @@ <tbody> {% for permission in permissions_granted %} <tr id="approved-user-{{ permission.user.pk }}"> - <td>{{ permission.user.username }}</td> + <td> + {% if permission.user.application_proxy %} + {{ permission.user.application_proxy.name }} + <a href="{% url 'applications:application.detail' pk=permission.user.application_proxy.pk %}" + role="button" class="badge badge-info">App</a> + {% else %} + {{ permission.user.username }} + {% endif %} + </td> <td>{{ permission.get_requested_display }}</td> <td>{{ permission.get_granted_display }}</td> <td> diff --git a/datasources/tests/test_connectors_hypercat.py b/datasources/tests/test_connectors_hypercat.py index 8c25b63c3c20f540a2cb1075f30c5d0d81113d7d..67825675da044d6449648cfaf89e3578e3bb5939 100644 --- a/datasources/tests/test_connectors_hypercat.py +++ b/datasources/tests/test_connectors_hypercat.py @@ -1,5 +1,6 @@ import itertools import typing +import unittest from django.test import TestCase @@ -157,6 +158,8 @@ class ConnectorHyperCatTest(TestCase): self.assertEqual(k, v.location) + # CityVerve API is discontinued + @unittest.expectedFailure def test_plugin_get_subcatalogue_metadata(self): connection = self._get_connection() @@ -179,6 +182,8 @@ class ConnectorHyperCatTest(TestCase): self.assertEqual('https://developer.cityverve.org.uk', _get_item_by_key_value(result, 'rel', 'urn:X-hypercat:rels:hasHomepage')['val']) + # CityVerve API is discontinued + @unittest.expectedFailure def test_plugin_get_subcatalogue_datasets(self): connection = self._get_connection() @@ -200,6 +205,8 @@ class ConnectorHyperCatTest(TestCase): for exp in expected: self.assertIn(exp, datasets) + # CityVerve API is discontinued + @unittest.expectedFailure def test_plugin_get_subcatalogue_dataset_metadata(self): connection = self._get_connection() @@ -228,6 +235,8 @@ class ConnectorHyperCatTest(TestCase): self.assertEqual('polling-station', _get_item_by_key_value(result, 'rel', 'urn:X-cityverve:rels:type')['val']) + # CityVerve API is discontinued + @unittest.expectedFailure def test_plugin_get_subcatalogue_dataset_data(self): connection = self._get_connection() diff --git a/datasources/views/datasource.py b/datasources/views/datasource.py index dbc90b468aa9ef7953e76ac2fd5dd1ecd261dc01..1ea6417e764043f439af276d276805c6458c6426 100644 --- a/datasources/views/datasource.py +++ b/datasources/views/datasource.py @@ -44,6 +44,12 @@ class DataSourceDetailView(DetailView): except (KeyError, ValueError): messages.error(self.request, 'This data source is not configured correctly. Please notify the owner.') + context['api_url'] = ( + 'https://' if self.request.is_secure() else 'http://' + + self.request.get_host() + + '/api/datasources/{0}/'.format(self.object.pk) + ) + return context @@ -71,6 +77,7 @@ class DataSourceUpdateView(OwnerPermissionMixin, UpdateView): context_object_name = 'datasource' form_class = forms.DataSourceForm + permission_required = 'datasources.change_datasource' class DataSourceDeleteView(OwnerPermissionMixin, DeleteView): @@ -78,6 +85,7 @@ class DataSourceDeleteView(OwnerPermissionMixin, DeleteView): template_name = 'datasources/datasource/delete.html' context_object_name = 'datasource' + permission_required = 'datasources.delete_datasource' success_url = reverse_lazy('datasources:datasource.list') @@ -198,4 +206,10 @@ class DataSourceExplorerView(HasPermissionLevelMixin, DetailView): field__short_name='data_query_param' ).values_list('value', flat=True) + context['api_url'] = ( + 'https://' if self.request.is_secure() else 'http://' + + self.request.get_host() + + '/api/datasources/{0}/'.format(self.object.pk) + ) + return context diff --git a/deploy/nginx/sites-available/pedasi b/deploy/nginx/sites-available/pedasi index 6d405e53bf3b4e4866a20db25afd339d73de162f..399490107c8da3a36966a93c8b0439bab0f91bf1 100644 --- a/deploy/nginx/sites-available/pedasi +++ b/deploy/nginx/sites-available/pedasi @@ -1,8 +1,9 @@ server { - listen 80; - server_name localhost pedasi.* pedasi-dev.* *.iotobservatory.io; + listen 80 default_server; + server_name _; merge_slashes off; + client_max_body_size 100m; location /favicon.ico { alias /var/www/pedasi/static/img/favicon.ico; @@ -13,11 +14,15 @@ server { } location = /report.html { - file /var/www/pedasi/report.html; + alias /var/www/pedasi/report.html; auth_basic "Restricted Content"; auth_basic_user_file /etc/nginx/.htpasswd; } + location = /robots.txt { + alias /var/www/pedasi/deploy/robots.txt; + } + 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 deleted file mode 100644 index e7a65daa508ee3282fc157c7a3bd830886fecf74..0000000000000000000000000000000000000000 --- a/deploy/nginx/sites-available/pedasi.dev +++ /dev/null @@ -1,24 +0,0 @@ -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/deploy/robots.txt b/deploy/robots.txt new file mode 100644 index 0000000000000000000000000000000000000000..adc3595e0ed36ab274e1ea13c3a835751103faad --- /dev/null +++ b/deploy/robots.txt @@ -0,0 +1,3 @@ +User-agent: * +Disallow: /admin/ +Disallow: /api/ diff --git a/pedasi/common/__init__.py b/pedasi/common/__init__.py deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/pedasi/settings.py b/pedasi/settings.py index abac017ef80c43ed47d11f89bc128dbf3f38add2..c72f67d4d77dda75ca8191f42f61704e8cdb9d15 100644 --- a/pedasi/settings.py +++ b/pedasi/settings.py @@ -27,6 +27,9 @@ DEBUG Run the server in debug mode? Default is 'false'. +ALLOWED_HOSTS - required if not in debug mode + List of hostnames on which the server is permitted to run + DATABASE_URL URL to default SQL database - in `dj-database-url <https://github.com/kennethreitz/dj-database-url>`_ format. Default is SQLite3 'db.sqlite3' in project root directory. @@ -42,7 +45,7 @@ import os from django.urls import reverse_lazy -from decouple import config +from decouple import config, Csv import dj_database_url import mongoengine @@ -55,17 +58,9 @@ SECRET_KEY = config('SECRET_KEY') # SECURITY WARNING: don't run with debug turned on in production! DEBUG = config('DEBUG', default=False, cast=bool) -if DEBUG: - ALLOWED_HOSTS = [ - '*', - ] - -else: - ALLOWED_HOSTS = [ - 'localhost', - 'pedasi-dev.eastus.cloudapp.azure.com', - ] - +ALLOWED_HOSTS = config('ALLOWED_HOSTS', + cast=Csv(), + default='*' if DEBUG else '127.0.0.1,localhost.localdomain,localhost') # Application definition @@ -237,8 +232,8 @@ SOCIAL_AUTH_PIPELINE = [ # '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', + # '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', @@ -249,6 +244,9 @@ SOCIAL_AUTH_PIPELINE = [ # Update the user record with any changed info from the auth service. 'social_core.pipeline.user.user_details', + + # Email admins to activate the account + 'profiles.social_auth.email_admins', ] SOCIAL_AUTH_LOGIN_REDIRECT_URL = reverse_lazy('index') @@ -316,3 +314,23 @@ STATICFILES_DIRS = [ os.path.join(BASE_DIR, 'pedasi', 'static'), os.path.join(BASE_DIR, 'docs', 'build'), ] + + +# Email provider for notification emails +EMAIL_HOST = config('EMAIL_HOST', default=None) +EMAIL_PORT = config('EMAIL_PORT', cast=int, default=25) + +EMAIL_HOST_USER = config('EMAIL_HOST_USER', default=None) +EMAIL_HOST_PASSWORD = config('EMAIL_HOST_PASSWORD', default=None) +DEFAULT_FROM_EMAIL = config('DEFAULT_FROM_EMAIL', default=EMAIL_HOST_USER) + +EMAIL_USE_TLS = config('EMAIL_USE_TLS', cast=bool, default=False) +EMAIL_USE_SSL = config('EMAIL_USE_SSL', cast=bool, default=False) + +EMAIL_SUBJECT_PREFIX = '[PEDASI]' + +if DEBUG and not EMAIL_HOST: + EMAIL_BACKEND = 'django.core.mail.backends.filebased.EmailBackend' + EMAIL_FILE_PATH = os.path.join(BASE_DIR, 'mail') + +ADMINS = [val.split('|') for val in config('ADMINS', cast=Csv(), default='')] diff --git a/playbook.yml b/playbook.yml index 74a342fcdc54335d494c5526a6c1e7196fea0f9f..b3345535366f688603832879bc15d23a88d4becd 100644 --- a/playbook.yml +++ b/playbook.yml @@ -46,7 +46,7 @@ - python3 - python3-dev - python3-pip - - python3-venv + - python-virtualenv - git - mysql-server - libmysqlclient-dev @@ -57,83 +57,43 @@ # 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/PEDASI/PEDASI.git' - dest: '{{ project_dir }}' - accept_hostkey: yes - key_file: '~/.deployment-key' - version: dev - when: vagrant_dir.stat.exists == False - - - name: Copy dev settings - copy: - src: 'deploy/.env.dev' - dest: '{{ project_dir }}/.env' - owner: www-data - group: www-data - mode: 0600 - - when: production is not defined - - - name: Setup production deployment - block: - - name: Copy deploy key - copy: - src: 'deploy/.deployment-key' - dest: '~/.deployment-key' - mode: 0600 - - - name: Clone / update master branch - git: - repo: 'ssh://git@github.com/PEDASI/PEDASI.git' - dest: '{{ project_dir }}' - accept_hostkey: yes - key_file: '~/.deployment-key' - version: master - - - name: Copy production settings - copy: - src: 'deploy/.env.prod' - dest: '{{ project_dir }}/.env' - owner: www-data - group: www-data - mode: 0600 - - when: production is defined + - 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: Clone / update branch from main repo + git: + repo: 'https://github.com/PEDASI/PEDASI.git' + dest: '{{ project_dir }}' + accept_hostkey: yes + version: '{{ branch | default("dev") }}' + when: vagrant_dir.stat.exists == False + + - name: Copy settings + copy: + src: '{{ env_file | default("deploy/.env") }}' + dest: '{{ project_dir }}/.env' + owner: www-data + group: www-data + mode: 0600 - name: Set permissions on manage.py file: path: '{{ project_dir }}/manage.py' mode: 0755 - - name: Create virtualenv - command: python3 -m venv '{{ venv_dir }}' creates='{{ venv_dir }}' - - name: Install pip requirements pip: requirements: '{{ project_dir}}/requirements.txt' virtualenv: '{{ venv_dir }}' + virtualenv_python: python3 - name: Restart and enable MariaDB systemd: @@ -195,23 +155,18 @@ - { 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 - 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: Deactivate default Nginx site + file: + path: /etc/nginx/sites-enabled/default + state: absent - - name: Copy web config files - production + - name: Copy web config files 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: @@ -279,7 +234,6 @@ SPHINXAPIDOC: '{{ venv_dir }}/bin/sphinx-apidoc' loop: - clean - - apidoc - html - name: Django collect static files diff --git a/profiles/models.py b/profiles/models.py index 1ef55900baa1e8ce943b830059f4d71c76841dc7..738fa71aaca9294d9327cb4476253935c4d89972 100644 --- a/profiles/models.py +++ b/profiles/models.py @@ -28,3 +28,10 @@ class User(AbstractUser): """ token, created = Token.objects.get_or_create(user=self) return token + + def revoke_auth_token(self): + """ + Revoke and API auth token for this user. + """ + self.auth_token.delete() + diff --git a/profiles/permissions.py b/profiles/permissions.py index 5c5d5a4e82df6dd8131f0751a2338f4929be9b4e..ae136d39f06e519565322a5ebad7734ceb0b49fe 100644 --- a/profiles/permissions.py +++ b/profiles/permissions.py @@ -1,6 +1,21 @@ +""" +Mixins for views to require certain permissions or ownership. +""" + from django.contrib.auth.mixins import UserPassesTestMixin, PermissionRequiredMixin +class SelfOrAdminPermissionMixin(UserPassesTestMixin): + """ + Mixin to require that a user is the linked object or an admin. + + To be used e.g. for edit permission on user profiles + """ + def test_func(self) -> bool: + user = self.get_object() + return user == self.request.user or self.request.user.is_superuser + + class OwnerPermissionMixin(PermissionRequiredMixin): """ Mixin to require that a user has the relevant global permission and is the owner of the relevant object. diff --git a/profiles/social_auth.py b/profiles/social_auth.py index b4d320fc414d66500d14aa5320443f2a3174c39c..453f75f90a4d408059b6f123550622842d68cb99 100644 --- a/profiles/social_auth.py +++ b/profiles/social_auth.py @@ -1,7 +1,21 @@ +""" +Module containing customisations to the Python Social Auth pipeline. + +See https://python-social-auth.readthedocs.io/en/latest/ +""" + +from django.core.mail import mail_admins + + from social_core.pipeline.user import create_user def create_user_disabled(strategy, details, backend, user=None, *args, **kwargs): + """ + Create a user account for the user being authenticated - but mark it as disabled. + + A new user must have their account enabled by an admin before they are able to log in. + """ # Returns dict containing 'is_new' and 'user' result = create_user(strategy, details, backend, user, *args, **kwargs) @@ -11,3 +25,21 @@ def create_user_disabled(strategy, details, backend, user=None, *args, **kwargs) django_user.save() return result + + +def email_admins(strategy, details, backend, user=None, *args, **kwargs): + """ + Email the PEDASI admins if a new account has been created and requires approval + """ + if kwargs['is_new']: + mail_admins( + subject='PEDASI Account Created', + message=( + 'New PEDASI user account: {0}\n\n' + 'A new user account has been created by {1} - {2} and requires admin approval' + ).format( + user.username, + user.get_full_name(), + user.email + ) + ) diff --git a/profiles/templates/profiles/user/profile.html b/profiles/templates/profiles/user/profile.html index 52af26156d30256b035cd272c4b05353c51917e5..76999de7163e1ae0c17c3b120d8a43a44274cd5a 100644 --- a/profiles/templates/profiles/user/profile.html +++ b/profiles/templates/profiles/user/profile.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"> @@ -22,32 +26,58 @@ </thead> <tbody> - <tr> - <td>API Token</td> - <td> - <span id="spanApiToken"> - {% if user == request.user and user.auth_token %} - {{ user.auth_token }} - - {% else %} + {% if user == request.user or request.user.is_superuser %} + <tr> + <td>API Token</td> + <td> <script type="application/javascript"> function getToken() { $.ajax({ dataType: "json", - url: "{% url 'profiles:token' %}", + url: "{% url 'profiles:token' pk=user.pk %}", data: null, success: function (data) { $('#spanApiToken').text(data.data.token.key); + + document.getElementById("spanApiTokenPresent").style.display = "inline"; + document.getElementById("spanApiTokenAbsent").style.display = "none"; + } + }); + } + + function revokeToken() { + $.ajax({ + dataType: "json", + url: "{% url 'profiles:token' pk=user.pk %}", + method: "DELETE", + headers: { + "X-CSRFToken": Cookies.get("csrftoken") + }, + data: null, + success: function (data) { + $('#spanApiToken').text(""); + + document.getElementById("spanApiTokenPresent").style.display = "none"; + document.getElementById("spanApiTokenAbsent").style.display = "inline"; } }); } </script> - <button onclick="getToken();" class="btn btn-default" role="button">Generate an API Token</button> - {% endif %} - </span> - </td> - </tr> + <span id="spanApiTokenPresent" {% if not user.auth_token %}style="display: none;"{% endif %}> + <span id="spanApiToken"> + {{ user.auth_token }} + </span> + + <button onclick="revokeToken();" class="btn btn-danger" role="button">Revoke API Token</button> + </span> + + <span id="spanApiTokenAbsent" {% if user.auth_token %}style="display: none;"{% endif %}> + <button onclick="getToken();" class="btn btn-default" role="button">Generate API Token</button> + </span> + </td> + </tr> + {% endif %} </tbody> </table> diff --git a/profiles/tests.py b/profiles/tests.py index 75e3d2c60ce4d5b15446147a96409ce94083fc6a..1cc396ed84364e154c2c7126cfda4068db4545d7 100644 --- a/profiles/tests.py +++ b/profiles/tests.py @@ -44,7 +44,7 @@ class UserTest(TestCase): # User should not already have token token = Token.objects.get(user=user) - response = client.get(reverse('profiles:token')) + response = client.get(reverse('profiles:token', kwargs={'pk': user.pk})) self.assertEqual(200, response.status_code) diff --git a/profiles/urls.py b/profiles/urls.py index 1c71dc1268c0de04e2ea8bc075da22834d6a701f..d57bf5b141a9ef2eace08b2dffb7591b12e52b20 100644 --- a/profiles/urls.py +++ b/profiles/urls.py @@ -19,7 +19,7 @@ urlpatterns = [ views.UserUriView.as_view(), name='uri'), - path('token', - views.UserGetTokenView.as_view(), + path('<int:pk>/token', + views.UserManageTokenView.as_view(), name='token'), ] diff --git a/profiles/views.py b/profiles/views.py index d6cca5d14ec6f69bedf3d8bdf925e267c71e425f..7546d381dbc9b421cd438eb5e9b693cde5668833 100644 --- a/profiles/views.py +++ b/profiles/views.py @@ -1,5 +1,8 @@ +""" +Views to manage user profiles. +""" + 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 @@ -8,6 +11,7 @@ from rest_framework.authtoken.models import Token from applications.models import Application from datasources.models import DataSource +from profiles.permissions import SelfOrAdminPermissionMixin class IndexView(TemplateView): @@ -61,16 +65,15 @@ class UserInactiveView(TemplateView): template_name = 'profiles/user/inactive.html' -class UserGetTokenView(LoginRequiredMixin, DetailView): +class UserManageTokenView(SelfOrAdminPermissionMixin, DetailView): """ - Get an API Token for the currently authenticated user. + Manage an API Token for the requested user. """ - def get_object(self, queryset=None): - return self.request.user + model = get_user_model() def render_to_response(self, context, **response_kwargs): """ - Get an existing API Token or create a new one for the currently authenticated user. + Get an existing API Token or create a new one for the requested user. :return: JSON containing Token key """ @@ -84,3 +87,17 @@ class UserGetTokenView(LoginRequiredMixin, DetailView): } } }) + + def delete(self, request, *args, **kwargs): + """ + Revoke an API Token for the requested user. + """ + self.object = self.get_object() + self.object.revoke_auth_token() + + return JsonResponse({ + 'status': 'success', + 'data': { + 'token': None, + } + }) diff --git a/provenance/models.py b/provenance/models.py index e94d04867341e9af939cb6b1ac773bccb24be8ec..df9bd295f11f361b817c8da83f5502f4b19bae7a 100644 --- a/provenance/models.py +++ b/provenance/models.py @@ -203,8 +203,8 @@ class ProvWrapper(mongoengine.Document): """ 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 + :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) diff --git a/requirements.txt b/requirements.txt index baab955031f0d1fb0acb9a90179b7db8a63dfccd..4edb81020e50bcc916a03754f8089bb582e121e1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ chardet==3.0.4 decorator==4.3.0 defusedxml==0.5.0 dj-database-url==0.5.0 -Django==2.0.8 +Django==2.0.13 django-bootstrap4==0.0.6 django-cors-headers==2.4.0 django-haystack==2.8.1 @@ -44,12 +44,12 @@ python-decouple==3.1 python3-openid==3.1.0 pytz==2018.5 rdflib==4.2.2 -requests==2.19.1 +requests==2.21.0 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 +social-auth-app-django==3.1.0 +social-auth-core==3.1.0 Sphinx==1.7.6 sphinx-rtd-theme==0.4.2 sphinxcontrib-apidoc==0.3.0