summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
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
commita847560476e1f19c7b6be782b44842283b4d3517 (patch)
treef2a5717c4344a50eaa535b68366027309b188c10
parent4eed75f567f60c635a092f5db69076fa91268529 (diff)
downloadIshtar-a847560476e1f19c7b6be782b44842283b4d3517.tar.bz2
Ishtar-a847560476e1f19c7b6be782b44842283b4d3517.zip
✨ GIS API: manage GIS connections (list, request token, create token, delete)
-rw-r--r--example_project/settings.py2
-rw-r--r--ishtar_common/forms_common.py14
-rw-r--r--ishtar_common/models_rest.py11
-rw-r--r--ishtar_common/templates/ishtar/gis_token_list.html90
-rw-r--r--ishtar_common/templates/navbar.html3
-rw-r--r--ishtar_common/urls.py29
-rw-r--r--ishtar_common/views.py74
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>&nbsp;&nbsp;
+ <span>{% trans "Put instance name and code in the QGIS plugin." %} {{expiry}}</span>
+ <div class='m-1'><big><strong>{% trans "Ishtar instance" %}</strong> &nbsp;&nbsp;
+ <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> &nbsp;&nbsp;
+ <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> &nbsp;{% 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("-", "_")