From 16146deb2e76bb77f9566e0c3d5f21fd98d0f65c Mon Sep 17 00:00:00 2001 From: Étienne Loks Date: Fri, 9 May 2025 10:41:44 +0200 Subject: ✨ GIS API: manage GIS connections (list, request token, create token, delete) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ishtar_common/forms_common.py | 14 ++++ ishtar_common/models_rest.py | 11 +++ ishtar_common/templates/ishtar/gis_token_list.html | 90 ++++++++++++++++++++++ ishtar_common/templates/navbar.html | 3 + ishtar_common/urls.py | 29 +++++++ ishtar_common/views.py | 74 +++++++++++++++++- 6 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 ishtar_common/templates/ishtar/gis_token_list.html (limited to 'ishtar_common') diff --git a/ishtar_common/forms_common.py b/ishtar_common/forms_common.py index 757e3dc85..007be5fe0 100644 --- a/ishtar_common/forms_common.py +++ b/ishtar_common/forms_common.py @@ -3481,3 +3481,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 %} + +{% endif %} +

{{page_name}}

+
+
+ {% if user_request %} + + {% else %} +
+ {% csrf_token %} +
+ {% 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 %} +
+ +
+
+
+ {% endif %} +
+ {% if object_list %} +
+
+ + + + + + + + {% for access in object_list %} + + + + + + + {% endfor %} +
{% trans "Access type (limit date)" %}{% trans "Comment" %}{% trans "Last access (IP)" %}{% trans "Delete" %}
{{access.access_type_label}}{% if access.limit_date %} ({{access.limit_date|date:"DATE_FORMAT"|default:"-"}}){% endif %}{{access.comment|default:"-"}}{{access.last_access|date:"DATE_FORMAT"}} ({{access.last_ip|default:"-"}}) + + +
+
+ {% 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 @@ {% trans "Change password" %} + {% if SITE_PROFILE.gis_connector %} + {% trans "GIS connections" %} + {% endif %} {% trans "Changelog" %} 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//", + 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///", + views.gis_create_token, + name="gis-token-create", + ), re_path(r"^profile(?:/(?P[0-9]+))?/$", views.ProfileEdit.as_view(), name="profile"), re_path( r"^save-search/(?P[a-z-]+)/(?P[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 {} 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("-", "_") -- cgit v1.2.3