diff options
author | Étienne Loks <etienne.loks@iggdrasil.net> | 2025-05-09 10:41:44 +0200 |
---|---|---|
committer | Étienne Loks <etienne.loks@iggdrasil.net> | 2025-07-21 15:07:42 +0200 |
commit | a847560476e1f19c7b6be782b44842283b4d3517 (patch) | |
tree | f2a5717c4344a50eaa535b68366027309b188c10 | |
parent | 4eed75f567f60c635a092f5db69076fa91268529 (diff) | |
download | Ishtar-a847560476e1f19c7b6be782b44842283b4d3517.tar.bz2 Ishtar-a847560476e1f19c7b6be782b44842283b4d3517.zip |
✨ GIS API: manage GIS connections (list, request token, create token, delete)
-rw-r--r-- | example_project/settings.py | 2 | ||||
-rw-r--r-- | ishtar_common/forms_common.py | 14 | ||||
-rw-r--r-- | ishtar_common/models_rest.py | 11 | ||||
-rw-r--r-- | ishtar_common/templates/ishtar/gis_token_list.html | 90 | ||||
-rw-r--r-- | ishtar_common/templates/navbar.html | 3 | ||||
-rw-r--r-- | ishtar_common/urls.py | 29 | ||||
-rw-r--r-- | ishtar_common/views.py | 74 |
7 files changed, 221 insertions, 2 deletions
diff --git a/example_project/settings.py b/example_project/settings.py index cc6cafebd..709fce8ea 100644 --- a/example_project/settings.py +++ b/example_project/settings.py @@ -287,7 +287,7 @@ ISHTAR_MUSEUM_GAM = False # France - AlimGAM export of exhibitions # exclude business days from deadline calculation ISHTAR_FILE_EXCLUDE_BUSSINESS_DAYS = True # timeout for request token (GIS connector) -ISHTAR_REQUEST_TOKEN_TIMEOUT = 60*10 # 10 minutes +ISHTAR_REQUEST_TOKEN_TIMEOUT = 60 * 5 # 5 minutes ISHTAR_SLUGS = { "document-publisher": ["publisher"], } diff --git a/ishtar_common/forms_common.py b/ishtar_common/forms_common.py index 4c482a0a1..5789f1e8d 100644 --- a/ishtar_common/forms_common.py +++ b/ishtar_common/forms_common.py @@ -3469,3 +3469,17 @@ class PreGISForm(IshtarForm): self.fields["back_url"] = forms.CharField( label="", required=False, widget=forms.HiddenInput, initial=back_url ) + + +class GisRequestForm(IshtarForm): + access_type = forms.ChoiceField( + label=_("New access type"), choices=models_rest.API_ACCESS_TYPES + ) + name = forms.CharField(label=_("Name"), max_length=150) + limit_date = DateField(label=_("Limit date"), required=False) + + def clean(self): + data = self.cleaned_data + if data.get("limit_date", None) and data["limit_date"] < datetime.date.today(): + raise forms.ValidationError(_("Limit date cannot be in the past.")) + return data diff --git a/ishtar_common/models_rest.py b/ishtar_common/models_rest.py index e26968daf..f6b21a14a 100644 --- a/ishtar_common/models_rest.py +++ b/ishtar_common/models_rest.py @@ -58,6 +58,17 @@ class UserRequestToken(models.Model): verbose_name_plural = _("API - GIS - Request tokens") ADMIN_SECTION = _("API") + @property + def access_type_label(self): + if self.access_type in API_ACCESS_TYPES_DICT: + return API_ACCESS_TYPES_DICT[self.access_type] + return "" + + @property + def expiry(self): + timeout = timezone.now() - self.created + return settings.ISHTAR_REQUEST_TOKEN_TIMEOUT - timeout.seconds + @classmethod def clean_keys(cls): profile = get_current_profile() diff --git a/ishtar_common/templates/ishtar/gis_token_list.html b/ishtar_common/templates/ishtar/gis_token_list.html new file mode 100644 index 000000000..ca4389076 --- /dev/null +++ b/ishtar_common/templates/ishtar/gis_token_list.html @@ -0,0 +1,90 @@ +{% extends "base.html" %} +{% load i18n l10n %} + +{% block content %} +{% localize off %} +{% if user_request %} +<script type="text/javascript"> + $(document).ready(function(){ + $("#qgis-code-button").click(function(){ + navigator.clipboard.writeText($("#qgis-code").html()); + display_info("{% trans 'Key copied to clipboard.' %}"); + }); + $("#qgis-instance-button").click(function(){ + navigator.clipboard.writeText($("#qgis-instance").html()); + display_info("{% trans 'Instance address copied to clipboard.' %}"); + }); + var timer = $("#expire-seconds").text(); + var intervalId = window.setInterval(function(){ + timer -= 1; + if (timer >= 0){ + $("#expire-seconds").html(timer); + } else { + location.reload(); + } + }, 1000); + }); +</script> +{% endif %} +<h2>{{page_name}}</h2> +<div class='form'> + <div class="row justify-content-center"> + {% if user_request %} + <div class="alert alert-info show" role="alert"> + <i class="fa fa-info-circle" aria-hidden="true"></i> + <span>{% trans "Put instance name and code in the QGIS plugin." %} {{expiry}}</span> + <div class='m-1'><big><strong>{% trans "Ishtar instance" %}</strong> + <button id="qgis-instance-button" class="btn btn-secondary text-monospace" data-toggle="tooltip" data-placement="bottom" title="{% trans 'Copy instance name to clipboard' %}"><i class="fa fa-clipboard" aria-hidden="true"></i> <span id='qgis-instance'>{{instance}}</span></button></big></div> + <div class='m-1'><big><strong>{% trans "Code" %} ({{user_request.access_type_label}})</strong> + <button id="qgis-code-button" class="btn btn-secondary text-monospace" data-toggle="tooltip" data-placement="bottom" title="{% trans 'Copy code to clipboard' %}"><i class="fa fa-clipboard" aria-hidden="true"></i> <span id='qgis-code'>{{user_request.key}}</span></button></big></div> + </div> + {% else %} + <form method="post" action="{% url 'gis-request-key' %}"> + {% csrf_token %} + <div class="form-row"> + {% with field=request_form.access_type %} + {% include "blocks/bs_field_snippet.html" %} + {% endwith %} + {% with field=request_form.comment %} + {% include "blocks/bs_field_snippet.html" %} + {% endwith %} + {% with field=request_form.limit_date %} + {% include "blocks/bs_field_snippet.html" %} + {% endwith %} + <div class="form-group col-12 text-center"> + <button type='submit' class="btn btn-success mt-2"> + <i class="fa fa-plus"></i> {% trans 'GIS connection' %} + </button> + </div> + </div> + </form> + {% endif %} + </div> + {% if object_list %} + <hr> + <div class="row justify-content-center"> + <table class="table table-striped w-50"> + <tr> + <th>{% trans "Access type (limit date)" %}</th> + <th>{% trans "Comment" %}</th> + <th>{% trans "Last access (IP)" %}</th> + <th>{% trans "Delete" %}</th> + </tr> + {% for access in object_list %} + <tr> + <td>{{access.access_type_label}}{% if access.limit_date %} ({{access.limit_date|date:"DATE_FORMAT"|default:"-"}}){% endif %}</td> + <td>{{access.comment|default:"-"}}</td> + <td>{{access.last_access|date:"DATE_FORMAT"}} ({{access.last_ip|default:"-"}})</td> + <td> + <a class="btn btn-danger btn-sm" title="{% trans 'Delete' %}" href="{% url 'gis-token-delete' access.key %}" + onclick='return confirm("{% trans 'Are you sure?' %}")'> + <i class="fa fa-trash"></i></a> + </td> + </tr> + {% endfor %} + </table> + </div> + {% endif %} +{% endlocalize %} +{% endblock %} + diff --git a/ishtar_common/templates/navbar.html b/ishtar_common/templates/navbar.html index 3e7190645..79023100a 100644 --- a/ishtar_common/templates/navbar.html +++ b/ishtar_common/templates/navbar.html @@ -33,6 +33,9 @@ <a class="dropdown-item" href="{% url 'password_change' %}"> {% trans "Change password" %} </a> + {% if SITE_PROFILE.gis_connector %}<a class="dropdown-item" href="{% url 'gis-token-list' %}"> + {% trans "GIS connections" %} + </a>{% endif %} <a class="dropdown-item" href="{% url 'changelog' %}"> {% trans "Changelog" %} </a> diff --git a/ishtar_common/urls.py b/ishtar_common/urls.py index a66947183..16ae48908 100644 --- a/ishtar_common/urls.py +++ b/ishtar_common/urls.py @@ -342,6 +342,35 @@ urlpatterns = [ )(views.line_error), name="import_ignore_line", ), + path( + "gis/tokens/list", + check_permissions( + ["ishtar_common.change_geovectordata", + "ishtar_common.change_own_geovectordata"] + )(views.GISTokenListView.as_view()), + name="gis-token-list", + ), + path( + "gis/tokens/delete/<slug:key>/", + check_permissions( + ["ishtar_common.change_geovectordata", + "ishtar_common.change_own_geovectordata"] + )(views.gis_token_delete), + name="gis-token-delete", + ), + path( + "gis/tokens/request-key/", + check_permissions( + ["ishtar_common.change_geovectordata", + "ishtar_common.change_own_geovectordata"] + )(views.gis_generate_request_key), + name="gis-request-key", + ), + path( + "gis/tokens/create/<slug:request_key>/<slug:app_key>/", + views.gis_create_token, + name="gis-token-create", + ), re_path(r"^profile(?:/(?P<pk>[0-9]+))?/$", views.ProfileEdit.as_view(), name="profile"), re_path( r"^save-search/(?P<app_label>[a-z-]+)/(?P<model>[a-z-]+)/$", diff --git a/ishtar_common/views.py b/ishtar_common/views.py index d6296e02e..e998bf078 100644 --- a/ishtar_common/views.py +++ b/ishtar_common/views.py @@ -20,6 +20,7 @@ import csv import datetime import importlib +from ipware import get_client_ip from jinja2 import TemplateSyntaxError import json import logging @@ -57,13 +58,14 @@ from django.shortcuts import redirect, render, get_object_or_404 from django.urls import reverse, NoReverseMatch from django.utils import timezone, translation from django.utils.decorators import method_decorator +from django.utils.safestring import mark_safe from django.utils.translation import gettext, gettext_lazy as _ from django.views.generic import ListView, TemplateView, View from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView from extra_views import ModelFormSetView from markdown import markdown -from . import models +from . import models, models_rest from archaeological_context_records.models import ContextRecord from archaeological_files.models import File from archaeological_finds.models import Find, Treatment, TreatmentFile @@ -1529,6 +1531,76 @@ class ProfileEdit(LoginRequiredMixin, FormView): return HttpResponseRedirect(self.get_success_url()) +class GISTokenListView(IshtarMixin, LoginRequiredMixin, ListView): + template_name = "ishtar/gis_token_list.html" + model = models_rest.UserToken + page_name = _("GIS connections") + + def get_queryset(self): + user = self.request.user + if not user.pk or not user.ishtaruser: + raise Http404() + return self.model.objects.all() + + def get_context_data(self, *args, **kwargs): + data = super().get_context_data(*args, **kwargs) + data["instance"] = self.request.build_absolute_uri().split("//")[1].split("/")[0] + models_rest.UserRequestToken.clean_keys() + q = models_rest.UserRequestToken.objects.filter(user=self.request.user) + if q.count(): + user_request = q.all()[0] + data["user_request"] = user_request + data["expiry"] = mark_safe( + str( + _("This key expires in <span id='expire-seconds'>{}</span> seconds.") + ).format(user_request.expiry) + ) + else: + data["request_form"] = forms.GisRequestForm() + return data + + +def gis_token_delete(request, key, current_right=None): + if not current_right or not request.user.ishtaruser: + raise Http404() + q = models_rest.UserToken.objects.filter(user=request.user, key=key) + # if token not found silently redirect to token list + if q.count(): + q.all()[0].delete() + return redirect("gis-token-list") + + +def gis_generate_request_key(request, current_right=None): + if not current_right or not request.user.ishtaruser: + raise Http404() + form = forms.GisRequestForm(request.POST) + if not form.is_valid(): + errors = form.non_field_errors() + put_session_message( + request.session.session_key, + errors, + "warning", + ) + return redirect("gis-token-list") + if not models_rest.UserRequestToken.objects.filter(user=request.user).count(): + models_rest.UserRequestToken.objects.create( + user=request.user, access_type=form.cleaned_data['access_type'], + name=form.cleaned_data.get("name", ""), + limit_date=form.cleaned_data.get('limit_date', None) + ) + return redirect("gis-token-list") + + +def gis_create_token(request, request_key, app_key): + # prevent brut force of bots? + q = models_rest.UserRequestToken.objects.filter(key=request_key) + if not q.count(): + return HttpResponse(content_type="text/plain") + client_ip, __ = get_client_ip(request) + token = q.all()[0].generate_token(app_key, from_ip=client_ip) + return HttpResponse((token and token.key) or "", content_type="text/plain") + + class DynamicModelView: def get_model(self, kwargs): app = kwargs.get("app").replace("-", "_") |