Compare commits

..

18 Commits

Author SHA1 Message Date
mpeltriaux f5aaac2acf Merge pull request '# Basemap' (#557) from 546_Remove_WebAtlasRP_service into master
Reviewed-on: #557
2026-07-10 14:01:38 +02:00
mpeltriaux 775e40e9ae # Basemap
* removes discontinued webatlas wms layers from config.json
* enables basemap_grau as default wms layer
2026-07-10 14:00:36 +02:00
mpeltriaux e794f3fff2 Merge pull request '# Improvement PUT API' (#556) from 554_API_PUT_external_identifier into master
Reviewed-on: #556
2026-07-10 13:59:22 +02:00
mpeltriaux b4f2e3232a # Improvement PUT API
* improves error processing and response on PUT endpoint
* consolidates improved error processing into central method for all API endpoints
2026-07-10 13:58:23 +02:00
mpeltriaux b9c453fdd2 Merge pull request '549 rework geometry conflict fetching' (#552) from 549_Rework_GeometryConflict_fetching into master
Reviewed-on: #552
2026-06-27 09:31:08 +02:00
mpeltriaux 625c591122 # Tests
* updates tests
* enhances workflow of geometry conflict fetching
2026-06-25 08:55:50 +02:00
mpeltriaux f551763798 # Performance boost
* boosts performance of geometry conflict fetching 50-75%
2026-06-25 08:27:09 +02:00
mpeltriaux 08884cb370 Merge pull request '# HOTFIX' (#550) from hotfix_indexErrorOnAPIGet into master
Reviewed-on: #550
2026-06-20 12:10:51 +02:00
mpeltriaux 9b5defec6d # HOTFIX
* fixes bug where unfetchable data entries of a GeometryConflict would result in an error via API
2026-06-20 12:10:13 +02:00
mpeltriaux 0425430e65 Merge pull request '# HOTFIX' (#547) from hotfix_indexErrorOnAPIGet into master
Reviewed-on: #547
2026-06-20 09:27:55 +02:00
mpeltriaux fec313445d # HOTFIX
* fixes a bug where detected GeometryConflicts with deleted entries would case an IndexError
2026-06-20 09:27:02 +02:00
mpeltriaux 28db96b081 Merge pull request '# Geometry conflicts on API' (#544) from 534_Return_geometry_conflicts_on_API into master
Reviewed-on: #544
2026-06-17 11:58:55 +02:00
mpeltriaux cf53e69d74 # Geometry conflicts on API
* refactors internal fetching of GeometryConflict data
* adds serializing of GeometryConflict entry data (identifier, id) to GET API calls
2026-06-17 11:57:19 +02:00
mpeltriaux 6f2b6c44d9 Merge pull request '541 security improvements' (#542) from 541_Security_improvements into master
Reviewed-on: #542
2026-06-13 13:42:46 +02:00
mpeltriaux 494e80a4ac # Intervention remove compensation
* adds default role check for intervention's compensation removing endpoint
2026-06-13 13:41:45 +02:00
mpeltriaux 1f6c81874b # User propagation
* adds exception catching if gibberish data is sent to POST endpoint of user data propagation
* commits 'changed' manage.py and jspdf.debug.js despite being identical with repo files (git wants it, git gets it)
2026-06-13 13:33:17 +02:00
mpeltriaux 9ee016a8bb # Token generator
* improves reliability of generated token randomness
2026-06-13 12:57:19 +02:00
mpeltriaux 93d29982a6 Merge pull request '538_API_share_with_ids' (#539) from 538_API_share_with_ids into master
Reviewed-on: #539
2026-05-14 13:04:59 +00:00
13 changed files with 427 additions and 312 deletions
+40 -2
View File
@@ -6,6 +6,7 @@ Created on: 24.01.22
"""
import json
import uuid
from abc import abstractmethod
from django.contrib.gis import geos
@@ -171,13 +172,20 @@ class AbstractModelAPISerializer:
Returns:
"""
if not id:
raise ValueError("No id provided. The id is expected to live in the URL!")
# First if there is an external identifier linked to an internal one, so we can continue with the internal
try:
ext_id = ExternalIdentifier.objects.get(external_id=id)
id = ext_id.internal_id
except ObjectDoesNotExist:
# No external id found - let's hope the given id exists internally
pass
# Not found as external id - let's check whether this is a valid uuid and therefore
# potentially an internal id
try:
uuid.UUID(id)
except ValueError:
raise AssertionError(f"'{id}' is neither a known external identifier nor a valid uuid.")
obj = self.model.objects.get(
id=id,
@@ -202,3 +210,33 @@ class AbstractModelAPISerializer:
obj (Intervention)
"""
raise NotImplementedError("Must be implemented in subclasses")
def _geometry_conflicts_to_list(self, geometry) -> list:
""" Serializes geometry conflict ids into dict
Args:
geometry (Geometry): The geometry to fetch geometry conflicts from
Returns:
ids (list): Serialized geometry conflicts as dict objects inside a list
"""
ids = []
conflict_geometries = geometry.get_conflict_geometries()
for geom in conflict_geometries:
try:
data = geom.get_data_objects(["identifier", "id"])
if len(data) == 0:
# expected behaviour in case of deleted data object
continue
data = data[0]
except KeyError:
raise AssertionError(f"Geometry {geom.id} is not attached to any entries. Contact an admin!")
ids.append(
{
"identifier": data["identifier"],
"id": data["id"],
}
)
return ids
+1
View File
@@ -54,6 +54,7 @@ class AbstractModelAPISerializerV1(AbstractModelAPISerializer):
"created_on": self._created_on_to_json(entry),
"modified_on": self._modified_on_to_json(entry),
"external_identifiers": ext_ids,
"geometry_conflicts": self._geometry_conflicts_to_list(entry.geometry)
}
self._extend_properties_data(entry)
geo_json["properties"] = self.properties_data
+15 -18
View File
@@ -10,6 +10,7 @@ from json import JSONDecodeError
from django.core.exceptions import ObjectDoesNotExist
from django.http import JsonResponse, HttpRequest
from django.views.decorators.csrf import csrf_exempt
from api.utils.serializer.v1.compensation import CompensationAPISerializerV1
from api.utils.serializer.v1.deduction import DeductionAPISerializerV1
@@ -28,6 +29,20 @@ class AbstractAPIViewV1(AbstractAPIView):
super().__init__(*args, **kwargs)
self.serializer = self.serializer()
@csrf_exempt
def dispatch(self, request, *args, **kwargs):
try:
return super().dispatch(request, *args, **kwargs)
except (JSONDecodeError,
AssertionError,
ValueError,
PermissionError) as e:
return self._return_error_response(e, 400)
except ObjectDoesNotExist as e:
return self._return_error_response(e, 404)
except Exception as e:
return self._return_error_response(e, 500)
def get(self, request: HttpRequest, id=None):
""" Handles the GET request
@@ -40,17 +55,12 @@ class AbstractAPIViewV1(AbstractAPIView):
Returns:
response (JsonResponse)
"""
try:
self.rpp = int(request.GET.get("rpp", self.rpp))
self.page_number = int(request.GET.get("p", self.page_number))
self.serializer.rpp = self.rpp
self.serializer.page_number = self.page_number
self.serializer.prepare_lookup(id, self.user)
data = self.serializer.fetch_and_serialize()
except Exception as e:
return self._return_error_response(e, 500)
return self._return_response(request, data)
def post(self, request: HttpRequest):
@@ -64,16 +74,9 @@ class AbstractAPIViewV1(AbstractAPIView):
Returns:
response (JsonResponse)
"""
try:
body = request.body.decode("utf-8")
body = json.loads(body)
created_id = self.serializer.create_model_from_json(body, self.user)
except (JSONDecodeError,
AssertionError,
ValueError,
PermissionError,
ObjectDoesNotExist) as e:
return self._return_error_response(e, 400)
return JsonResponse({"id": created_id})
def put(self, request: HttpRequest, id=None):
@@ -88,12 +91,9 @@ class AbstractAPIViewV1(AbstractAPIView):
Returns:
response (JsonResponse)
"""
try:
body = request.body.decode("utf-8")
body = json.loads(body)
updated_id = self.serializer.update_model_from_json(id, body, self.user)
except Exception as e:
return self._return_error_response(e, 500)
return JsonResponse({"id": updated_id})
def delete(self, request: HttpRequest, id=None):
@@ -107,10 +107,7 @@ class AbstractAPIViewV1(AbstractAPIView):
response (JsonResponse)
"""
try:
success = self.serializer.delete_entry(id, self.user)
except Exception as e:
return self._return_error_response(e, 500)
return JsonResponse(
{
"success": success,
+9
View File
@@ -36,6 +36,7 @@ class InterventionViewTestCase(BaseViewTestCase):
self.run_check_url = reverse("intervention:check", args=(self.intervention.id,))
self.record_url = reverse("intervention:record", args=(self.intervention.id,))
self.report_url = reverse("intervention:report", args=(self.intervention.id,))
self.compensation_remove_url = reverse("intervention:remove-compensation", args=(self.compensation.intervention.id, self.compensation.id))
self.deduction.intervention = self.intervention
self.deduction.save()
@@ -83,6 +84,7 @@ class InterventionViewTestCase(BaseViewTestCase):
self.revocation_new_url: f"{login_redirect_base}{self.revocation_new_url}",
self.revocation_edit_url: f"{login_redirect_base}{self.revocation_edit_url}",
self.revocation_remove_url: f"{login_redirect_base}{self.revocation_remove_url}",
self.compensation_remove_url: f"{login_redirect_base}{self.compensation_remove_url}",
}
self.assert_url_success(client, success_urls)
@@ -124,6 +126,7 @@ class InterventionViewTestCase(BaseViewTestCase):
self.deduction_new_url,
self.deduction_edit_url,
self.deduction_remove_url,
self.compensation_remove_url,
]
self.assert_url_success(client, success_urls)
@@ -162,6 +165,7 @@ class InterventionViewTestCase(BaseViewTestCase):
self.deduction_new_url,
self.deduction_edit_url,
self.deduction_remove_url,
self.compensation_remove_url,
]
fail_urls = [
self.run_check_url,
@@ -212,6 +216,7 @@ class InterventionViewTestCase(BaseViewTestCase):
self.deduction_new_url,
self.deduction_edit_url,
self.deduction_remove_url,
self.compensation_remove_url,
]
success_urls_redirect = {
self.share_url: self.detail_url
@@ -258,6 +263,7 @@ class InterventionViewTestCase(BaseViewTestCase):
self.deduction_new_url,
self.deduction_edit_url,
self.deduction_remove_url,
self.compensation_remove_url,
]
success_urls_redirect = {
self.share_url: self.detail_url
@@ -304,6 +310,7 @@ class InterventionViewTestCase(BaseViewTestCase):
self.deduction_new_url,
self.deduction_edit_url,
self.deduction_remove_url,
self.compensation_remove_url,
]
success_urls_redirect = {
self.share_url: self.detail_url
@@ -350,6 +357,7 @@ class InterventionViewTestCase(BaseViewTestCase):
self.deduction_new_url,
self.deduction_edit_url,
self.deduction_remove_url,
self.compensation_remove_url,
]
success_urls_redirect = {
self.share_url: self.detail_url
@@ -396,6 +404,7 @@ class InterventionViewTestCase(BaseViewTestCase):
self.deduction_new_url,
self.deduction_edit_url,
self.deduction_remove_url,
self.compensation_remove_url,
]
# Define urls where a redirect to a specific location is the proper response
success_urls_redirect = {
+3 -1
View File
@@ -14,7 +14,7 @@ from django.utils.decorators import method_decorator
from django.views import View
from intervention.models import Intervention
from konova.decorators import shared_access_required
from konova.decorators import shared_access_required, default_group_required
from konova.forms.modals import RemoveModalForm
from konova.utils.message_templates import COMPENSATION_REMOVED_TEMPLATE
@@ -45,10 +45,12 @@ class RemoveCompensationFromInterventionView(LoginRequiredMixin, View):
redirect_url=reverse("intervention:detail", args=(id,)) + "#related_data",
)
@method_decorator(default_group_required)
@method_decorator(shared_access_required(Intervention, "id"))
def get(self, request, id: str, comp_id: str, *args, **kwargs) -> HttpResponse:
return self.__process_request(request, id, comp_id, *args, **kwargs)
@method_decorator(default_group_required)
@method_decorator(shared_access_required(Intervention, "id"))
def post(self, request, id: str, comp_id: str, *args, **kwargs) -> HttpResponse:
return self.__process_request(request, id, comp_id, *args, **kwargs)
+53 -7
View File
@@ -113,7 +113,7 @@ class Geometry(BaseResource):
objs (list): The list of objects
"""
objs = []
stop_searching = False
# Some related data sets can be processed rather easily
regular_sets = [
self.intervention_set,
@@ -125,28 +125,44 @@ class Geometry(BaseResource):
deleted=None
)
if limit_to_attrs:
objs += set_objs.values_list(*limit_to_attrs, flat=True)
objs += set_objs.values(*limit_to_attrs)
else:
objs += set_objs
stop_searching = len(objs) > 0
if stop_searching:
break
if not stop_searching:
# ... but we need a special treatment for compensations, since they can be deleted directly OR inherit their
# de-facto-deleted status from their deleted parent intervention
comp_objs = self.compensation_set.filter(
Q(deleted=None) & Q(intervention__deleted=None)
Q(deleted=None) | Q(intervention__deleted=None)
)
if limit_to_attrs:
objs += comp_objs.values_list(*limit_to_attrs, flat=True)
objs += comp_objs.values(*limit_to_attrs)
else:
objs += comp_objs
return objs
def get_data_object(self):
def get_data_object(self, limit_to_attrs: list = None):
"""
Getter for the specific data object which is related to this geometry
Getter for the specific data object which is related to this geometry.
!!! Only returns undeleted entries !!!
Returns:
result (str|None): Returns the desired attributes or None if the data object is marked as deleted
"""
objs = self.get_data_objects()
objs = self.get_data_objects(limit_to_attrs)
assert (len(objs) <= 1)
try:
result = objs.pop()
except IndexError:
# If this happens, we just processed a GeometryConflict with an entry which is marked as deleted.
# Therefore we return None
result = None
return result
def update_parcels(self):
@@ -436,6 +452,16 @@ class Geometry(BaseResource):
output_geom.transform(DEFAULT_SRID_RLP)
return output_geom
def get_conflict_geometries(self):
""" Getter for geometry ids which conflict with this geometry or are conflicted by this one
Returns:
geom_ids (list): List of geometry ids
"""
conflict_geoms_id = GeometryConflict.get_conflict_geometries(self)
conflict_geoms = Geometry.objects.filter(id__in=conflict_geoms_id)
return conflict_geoms
class GeometryConflict(UuidModel):
"""
@@ -459,3 +485,23 @@ class GeometryConflict(UuidModel):
def __str__(self):
return f"{self.conflicting_geometry.id} conflicts with {self.affected_geometry.id}"
@staticmethod
def get_conflict_geometries(geometry: Geometry):
""" Getter for geometries which conflict in one or another way with the given one
Args:
geometry (Geometry): The geometry which shall be checked
Returns:
conflict_geometries (QuerySet): QuerySet of geometries which have conflicts with the given geometry
"""
conflict_geometries = GeometryConflict.objects.filter(
affected_geometry=geometry.id,
).values_list("conflicting_geometry__id", flat=True)
conflict_geometries = conflict_geometries.union(
GeometryConflict.objects.filter(
conflicting_geometry=geometry.id,
).values_list("affected_geometry__id", flat=True)
)
return conflict_geometries
+9 -16
View File
@@ -676,24 +676,17 @@ class GeoReferencedMixin(models.Model):
if self.geometry is None:
return request
instance_objs = []
needed_data_object_attrs = [
"identifier"
]
conflicts = self.geometry.conflicts_geometries.iterator()
conflicting_geometries = self.geometry.get_conflict_geometries()
data_object_identifiers = []
for conflicting_geom in conflicting_geometries:
data_obj_id = conflicting_geom.get_data_object(["identifier"])
if data_obj_id:
data_object_identifiers.append(data_obj_id)
for conflict in conflicts:
# Only check the affected geometry of this conflict, since we know the conflicting geometry is self.geometry
instance_objs += conflict.affected_geometry.get_data_objects(needed_data_object_attrs)
conflicts = self.geometry.conflicted_by_geometries.iterator()
for conflict in conflicts:
# Only check the conflicting geometry of this conflict, since we know the affected geometry is self.geometry
instance_objs += conflict.conflicting_geometry.get_data_objects(needed_data_object_attrs)
add_message = len(instance_objs) > 0
add_message = len(data_object_identifiers) > 0
if add_message:
instance_identifiers = ", ".join(instance_objs)
data_object_identifiers = [x["identifier"] for x in data_object_identifiers]
instance_identifiers = ", ".join(data_object_identifiers)
message_str = GEOMETRY_CONFLICT_WITH_TEMPLATE.format(instance_identifiers)
messages.info(request, message_str)
return request
+5 -14
View File
@@ -85,12 +85,6 @@ class GeometryTestCase(BaseTestCase):
)
def test_get_data_objects(self):
num_objs_with_geom = 0
self.assertEqual(
len(self.geom_1.get_data_objects()),
num_objs_with_geom
)
objs = [
self.intervention,
self.compensation,
@@ -98,16 +92,13 @@ class GeometryTestCase(BaseTestCase):
self.ema,
]
for obj in objs:
obj.geometry = self.geom_1
if not obj.geometry:
obj.geometry = Geometry.objects.create(geom=self.create_dummy_geometry())
obj.save()
num_objs_with_geom += 1
geom_objs = self.geom_1.get_data_objects()
self.assertEqual(
len(geom_objs),
num_objs_with_geom
)
self.assertIn(obj, geom_objs)
data_objs = obj.geometry.get_data_objects()
self.assertEqual(len(data_objs), 1)
self.assertIn(obj, data_objs)
def test_as_feature_collection(self):
geometry = self.geom_1.geom
+4 -8
View File
@@ -5,22 +5,18 @@ Contact: michel.peltriaux@sgdnord.rlp.de
Created on: 09.11.20
"""
import random
import secrets
import string
import qrcode
import qrcode.image.svg
from io import BytesIO
def generate_token() -> str:
def generate_token(length: int = 64) -> str:
""" Shortcut for default generating of e.g. API token
Returns:
token (str)
"""
return generate_random_string(
length=64,
length=length,
use_numbers=True,
use_letters_lc=True
)
@@ -39,7 +35,7 @@ def generate_random_string(length: int, use_numbers: bool = False, use_letters_l
elements.append(string.ascii_uppercase)
elements = "".join(elements)
ret_val = "".join(random.choice(elements) for i in range(length))
ret_val = "".join(secrets.choice(elements) for i in range(length))
return ret_val
class IdentifierGenerator:
Executable → Regular
View File
+1 -3
View File
@@ -56,11 +56,9 @@
],
"layers":
[
{ "id": "webatlas_farbe", "folder": "bg", "type": "WMS", "order": -1, "title": "WebatlasRP farbig", "attribution": "LVermGeo", "url": "https://maps.service24.rlp.de/gisserver/services/RP/RP_WebAtlasRP/MapServer/WmsServer?", "name": "RP_WebAtlasRP", "active": true},
{ "id": "webatlas_grau", "folder": "bg", "type": "WMS", "order": -1, "title": "WebatlasRP grau", "attribution": "LVermGeo", "url": "https://maps.service24.rlp.de/gisserver/services/RP/RP_ETRS_Gt/MapServer/WmsServer?", "name": "0", "active": false },
{ "id": "luftbilder", "folder": "bg", "type": "WMS", "order": -1, "title": "Luftbilder", "attribution": "LVermGeo", "url": "https://geo4.service24.rlp.de/wms/rp_dop20.fcgi?", "name": "rp_dop20", "active": false },
{ "id": "basemap_farbe", "folder": "bg", "type": "WMS", "order": -1, "title": "BasemapDE farbig", "attribution": "BKG", "url": "https://sgx.geodatenzentrum.de/wms_basemapde?", "name": "de_basemapde_web_raster_farbe", "active": false },
{ "id": "basemap_grau", "folder": "bg", "type": "WMS", "order": -1, "title": "BasemapDE grau", "attribution": "BKG", "url": "https://sgx.geodatenzentrum.de/wms_basemapde?", "name": "de_basemapde_web_raster_grau", "active": false },
{ "id": "basemap_grau", "folder": "bg", "type": "WMS", "order": -1, "title": "BasemapDE grau", "attribution": "BKG", "url": "https://sgx.geodatenzentrum.de/wms_basemapde?", "name": "de_basemapde_web_raster_grau", "active": true },
{ "id": "dtk_farbe", "folder": "bg", "type": "WMS", "order": -1, "title": "DTK5 farbig", "attribution": "LVermGeo", "url": "https://geo4.service24.rlp.de/wms/dtk5_rp.fcgi?", "name": "rp_dtk5", "active": false },
{ "id": "dtk_grau", "folder": "bg", "type": "WMS", "order": -1, "title": "DTK5 grau", "attribution": "LVermGeo", "url": "https://geo4.service24.rlp.de/wms/dtk5_rp.fcgi?", "name": "rp_dtk5_grau", "active": false },
+57 -13
View File
@@ -7,9 +7,10 @@ Created on: 10.05.24
"""
import base64
import hashlib
import http
import json
from cryptography.fernet import Fernet
from cryptography.fernet import Fernet, InvalidToken
from django.core.exceptions import ObjectDoesNotExist
from django.http import HttpRequest, JsonResponse
from django.utils.decorators import method_decorator
@@ -27,34 +28,77 @@ class PropagateUserView(View):
proper rights management)
"""
class PropagateStatus:
UNPROCESSED = "unprocessed"
UPDATED = "updated"
CREATED = "created"
@method_decorator(csrf_exempt)
def dispatch(self, request, *args, **kwargs):
return super().dispatch(request, *args, **kwargs)
def post(self, request: HttpRequest, *args, **kwargs):
response_data = {
"success": None,
"status": None
}
# Decrypt
encrypted_body = request.body
_hash = hashlib.md5()
_hash.update(PROPAGATION_SECRET.encode("utf-8"))
key = base64.urlsafe_b64encode(_hash.hexdigest().encode("utf-8"))
fernet = Fernet(key)
try:
body = fernet.decrypt(encrypted_body).decode("utf-8")
body = json.loads(body)
except InvalidToken:
response_data["error"] = "Invalid Token"
response_data["success"] = False
response_data["status"] = self.PropagateStatus.UNPROCESSED
return JsonResponse(
status=http.HTTPStatus.UNPROCESSABLE_CONTENT,
data=response_data
)
except (json.JSONDecodeError) as e:
response_data["error"] = str(e)
response_data["success"] = False
response_data["status"] = self.PropagateStatus.UNPROCESSED
return JsonResponse(
status=http.HTTPStatus.UNPROCESSABLE_CONTENT,
data=response_data
)
# Process decrypted user data
processing_ret_vals = self.__process_user_data(body)
response_data["success"] = processing_ret_vals[0]
response_data["status"] = processing_ret_vals[1]
user = processing_ret_vals[2]
try:
status = "updated"
user = User.resolve_user_using_propagation_data(body)
user = user.update_user_using_propagation_data(body)
except ObjectDoesNotExist:
user = User(**body)
status = "created"
user.set_unusable_password()
user.save()
data = {
"success": True,
"status": status
}
return JsonResponse(
status=http.HTTPStatus.OK,
data=response_data
)
return JsonResponse(data)
def __process_user_data(self, body: dict) -> (bool, str, User):
""" Process decrypted user data
Args:
body:
Returns:
success (bool): Whether the processing was successful
status (str): In which way the data was used ('created' | 'updated')
user (User): Processed user object
"""
try:
user = User.resolve_user_using_propagation_data(body)
user = user.update_user_using_propagation_data(body)
status = self.PropagateStatus.UPDATED
except ObjectDoesNotExist:
user = User(**body)
status = self.PropagateStatus.CREATED
return True, status, user