Compare commits

...

10 Commits

Author SHA1 Message Date
f146aa983a Merge pull request '# Boost geometry conflict message fetch' (#523) from 503_Improve_performance_on_geometry_conflict_message into master
Reviewed-on: #523
2026-01-14 08:03:01 +00:00
60e9430542 # Boost geometry conflict message fetch
* reduces runtime of geometry conflict info message generating to ~45% to prior runtime
2026-01-14 09:02:41 +01:00
970d0e79fa Merge pull request '490_View_refactoring_II' (#520) from 490_View_refactoring_II into master
Reviewed-on: #520
2026-01-13 09:36:11 +00:00
3f33de3626 # Analysis, API and Payment views
* refactors payment creation, editing and removing into class based views
* refactors analysis report methods into class based views
* drops unused method view on api app (token generating has been de facto moved into users app long time ago)
2026-01-13 10:35:09 +01:00
9e5bb84ab4 Merge pull request '# Compensation sum fix' (#518) from 517_Compensation_sum_wrong into master
Reviewed-on: #518
2026-01-10 10:03:27 +00:00
4c372c1a04 # Compensation sum fix
* fixes sum of compensations on landing page
2026-01-10 11:00:26 +01:00
ee2c859a9e Merge pull request '# Improve exception reporting for API' (#515) from improve_exception_reporting into master
Reviewed-on: #515
2025-12-19 14:17:37 +01:00
328f672ec0 # Improve exception reporting for API
* fixes typo in exception_reporter.py
* properly catches error on geometry cast into multipolygon if input are no valid polygons
* extends error response on malicious api calls
* specifies different exceptions on try-catch while initializing api data
2025-12-19 14:17:15 +01:00
047c9489fe Merge pull request '# ExceptionReporter adjustment' (#513) from improve_exception_reporting into master
Reviewed-on: #513
2025-12-17 14:03:23 +01:00
38b81996ed # ExceptionReporter adjustment
* extends the KonovaExceptionReporter to hold POST body content (practical for debugging broken content on API)
2025-12-17 14:02:08 +01:00
13 changed files with 246 additions and 152 deletions

View File

@@ -10,6 +10,6 @@ from analysis.views import *
app_name = "analysis" app_name = "analysis"
urlpatterns = [ urlpatterns = [
path("reports/", index_reports_view, name="reports"), path("reports/", ReportIndexView.as_view(), name="reports"),
path("reports/<id>", detail_report_view, name="report-detail"), path("reports/<id>", ReportDetailView.as_view(), name="report-detail"),
] ]

View File

@@ -1,8 +1,12 @@
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.shortcuts import render, redirect, get_object_or_404 from django.shortcuts import render, redirect, get_object_or_404
from django.utils import timezone from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views import View
from django.views.generic import DetailView
from analysis.forms import TimespanReportForm from analysis.forms import TimespanReportForm
from analysis.utils.excel.excel import TempExcelFile from analysis.utils.excel.excel import TempExcelFile
@@ -42,57 +46,112 @@ def index_reports_view(request: HttpRequest):
context = BaseContext(request, context).context context = BaseContext(request, context).context
return render(request, template, context) return render(request, template, context)
class ReportIndexView(LoginRequiredMixin, View):
@method_decorator(conservation_office_group_required)
def get(self, request: HttpRequest) -> HttpResponse:
@login_required """
@conservation_office_group_required
def detail_report_view(request: HttpRequest, id: str):
""" Renders the detailed report for a conservation office
Args: Args:
request (HttpRequest): The incoming request request (HttpRequest): The incoming request
id (str): The conservation_office KonovaCode id
Returns: Returns:
""" """
# Try to resolve the requested office id template = "analysis/reports/index.html"
cons_office = get_object_or_404( form = TimespanReportForm(None)
KonovaCode, context = {
id=id "form": form
) }
# Try to resolve the date parameters into Date objects -> redirect if this fails context = BaseContext(request, context).context
try: return render(request, template, context)
df = request.GET.get("df", None)
dt = request.GET.get("dt", None) @method_decorator(conservation_office_group_required)
date_from = timezone.make_aware(timezone.datetime.fromisoformat(df)) def post(self, request: HttpRequest) -> HttpResponse:
date_to = timezone.make_aware(timezone.datetime.fromisoformat(dt))
except ValueError: """
messages.error(
request, Args:
PARAMS_INVALID, request (HttpRequest): The incoming request
extra_tags="danger",
Returns:
"""
template = "analysis/reports/index.html"
form = TimespanReportForm(request.POST or None)
if form.is_valid():
redirect_url = form.save()
return redirect(redirect_url)
else:
messages.error(
request,
FORM_INVALID,
extra_tags="danger",
)
context = {
"form": form
}
context = BaseContext(request, context).context
return render(request, template, context)
class ReportDetailView(LoginRequiredMixin, DetailView):
@method_decorator(conservation_office_group_required)
def get(self, request: HttpRequest, id: str):
""" Renders the detailed report for a conservation office
Args:
request (HttpRequest): The incoming request
id (str): The conservation_office KonovaCode id
Returns:
"""
# Try to resolve the requested office id
cons_office = get_object_or_404(
KonovaCode,
id=id
) )
return redirect("analysis:reports") # Try to resolve the date parameters into Date objects -> redirect if this fails
try:
df = request.GET.get("df", None)
dt = request.GET.get("dt", None)
date_from = timezone.make_aware(timezone.datetime.fromisoformat(df))
date_to = timezone.make_aware(timezone.datetime.fromisoformat(dt))
except ValueError:
messages.error(
request,
PARAMS_INVALID,
extra_tags="danger",
)
return redirect("analysis:reports")
# Check whether the html default rendering is requested or an alternative # Check whether the html default rendering is requested or an alternative
format_param = request.GET.get("format", "html") format_param = request.GET.get("format", "html")
report = TimespanReport(id, date_from, date_to) report = TimespanReport(id, date_from, date_to)
if format_param == "html": if format_param == "html":
return self.__handle_html_format(request, report, cons_office)
elif format_param == "excel":
return self.__handle_excel_format(report, cons_office, df, dt)
else:
raise NotImplementedError
def __handle_html_format(self, request, report: TimespanReport, office: KonovaCode):
template = "analysis/reports/detail.html" template = "analysis/reports/detail.html"
context = { context = {
"office": cons_office, "office": office,
"report": report, "report": report,
} }
context = BaseContext(request, context).context context = BaseContext(request, context).context
return render(request, template, context) return render(request, template, context)
elif format_param == "excel":
def __handle_excel_format(self, report: TimespanReport, office: KonovaCode, df: str, dt: str):
file = TempExcelFile(report.excel_template_path, report.excel_map) file = TempExcelFile(report.excel_template_path, report.excel_map)
response = HttpResponse( response = HttpResponse(
content=file.stream, content=file.stream,
content_type="application/ms-excel", content_type="application/ms-excel",
) )
response['Content-Disposition'] = f'attachment; filename={cons_office.long_name}_{df}_{dt}.xlsx' response['Content-Disposition'] = f'attachment; filename={office.long_name}_{df}_{dt}.xlsx'
return response return response
else:
raise NotImplementedError

View File

@@ -71,7 +71,7 @@ class APIV1CreateTestCase(BaseAPIV1TestCase):
# Expect this first request to fail, since user has no shared access on the intervention, we want to create # Expect this first request to fail, since user has no shared access on the intervention, we want to create
# a compensation for # a compensation for
response = self._run_create_request(url, post_body) response = self._run_create_request(url, post_body)
self.assertEqual(response.status_code, 500, msg=response.content) self.assertEqual(response.status_code, 400, msg=response.content)
content = json.loads(response.content) content = json.loads(response.content)
self.assertGreater(len(content.get("errors", [])), 0, msg=response.content) self.assertGreater(len(content.get("errors", [])), 0, msg=response.content)

View File

@@ -7,11 +7,8 @@ Created on: 21.01.22
""" """
from django.urls import path, include from django.urls import path, include
from api.views.method_views import generate_new_token_view
app_name = "api" app_name = "api"
urlpatterns = [ urlpatterns = [
path("v1/", include("api.urls.v1.urls", namespace="v1")), path("v1/", include("api.urls.v1.urls", namespace="v1")),
path("token/generate", generate_new_token_view, name="generate-new-token"),
] ]

View File

@@ -1,35 +0,0 @@
"""
Author: Michel Peltriaux
Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany
Contact: michel.peltriaux@sgdnord.rlp.de
Created on: 27.01.22
"""
from django.contrib.auth.decorators import login_required
from django.http import HttpRequest, JsonResponse
from api.models import APIUserToken
@login_required
def generate_new_token_view(request: HttpRequest):
""" Handles request for fetching
Args:
request (HttpRequest): The incoming request
Returns:
"""
if request.method == "GET":
token = APIUserToken()
while APIUserToken.objects.filter(token=token.token).exists():
token = APIUserToken()
return JsonResponse(
data={
"gen_data": token.token
}
)
else:
raise NotImplementedError

View File

@@ -6,7 +6,9 @@ Created on: 21.01.22
""" """
import json import json
from json import JSONDecodeError
from django.core.exceptions import ObjectDoesNotExist
from django.http import JsonResponse, HttpRequest from django.http import JsonResponse, HttpRequest
from api.utils.serializer.v1.compensation import CompensationAPISerializerV1 from api.utils.serializer.v1.compensation import CompensationAPISerializerV1
@@ -66,8 +68,12 @@ class AbstractAPIViewV1(AbstractAPIView):
body = request.body.decode("utf-8") body = request.body.decode("utf-8")
body = json.loads(body) body = json.loads(body)
created_id = self.serializer.create_model_from_json(body, self.user) created_id = self.serializer.create_model_from_json(body, self.user)
except Exception as e: except (JSONDecodeError,
return self._return_error_response(e, 500) AssertionError,
ValueError,
PermissionError,
ObjectDoesNotExist) as e:
return self._return_error_response(e, 400)
return JsonResponse({"id": created_id}) return JsonResponse({"id": created_id})
def put(self, request: HttpRequest, id=None): def put(self, request: HttpRequest, id=None):

View File

@@ -81,9 +81,7 @@ class AbstractAPIView(View):
Returns: Returns:
""" """
content = [error.__str__()] content = [f"{error.__class__.__name__}: {str(error)}"]
if hasattr(error, "messages"):
content = error.messages
return JsonResponse( return JsonResponse(
{ {
"errors": content "errors": content

View File

@@ -10,7 +10,7 @@ from compensation.views.payment import *
app_name = "pay" app_name = "pay"
urlpatterns = [ urlpatterns = [
path('<id>/new', new_payment_view, name='new'), path('<id>/new', NewPaymentView.as_view(), name='new'),
path('<id>/remove/<payment_id>', payment_remove_view, name='remove'), path('<id>/remove/<payment_id>', RemovePaymentView.as_view(), name='remove'),
path('<id>/edit/<payment_id>', payment_edit_view, name='edit'), path('<id>/edit/<payment_id>', EditPaymentView.as_view(), name='edit'),
] ]

View File

@@ -5,10 +5,12 @@ Contact: michel.peltriaux@sgdnord.rlp.de
Created on: 09.08.21 Created on: 09.08.21
""" """
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse from django.urls import reverse
from django.contrib.auth.decorators import login_required
from django.http import HttpRequest from django.http import HttpRequest
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.decorators import method_decorator
from django.views import View
from compensation.forms.modals.payment import NewPaymentForm, RemovePaymentModalForm, EditPaymentModalForm from compensation.forms.modals.payment import NewPaymentForm, RemovePaymentModalForm, EditPaymentModalForm
from compensation.models import Payment from compensation.models import Payment
@@ -17,72 +19,97 @@ from konova.decorators import default_group_required, shared_access_required
from konova.utils.message_templates import PAYMENT_ADDED, PAYMENT_REMOVED, PAYMENT_EDITED from konova.utils.message_templates import PAYMENT_ADDED, PAYMENT_REMOVED, PAYMENT_EDITED
@login_required class NewPaymentView(LoginRequiredMixin, View):
@default_group_required
@shared_access_required(Intervention, "id")
def new_payment_view(request: HttpRequest, id: str):
""" Renders a modal view for adding new payments
Args: def __process_request(self, request: HttpRequest, id: str):
request (HttpRequest): The incoming request """ Renders a modal view for adding new payments
id (str): The intervention's id for which a new payment shall be added
Returns: Args:
request (HttpRequest): The incoming request
id (str): The intervention's id for which a new payment shall be added
""" Returns:
intervention = get_object_or_404(Intervention, id=id)
form = NewPaymentForm(request.POST or None, instance=intervention, request=request) """
return form.process_request( intervention = get_object_or_404(Intervention, id=id)
request, form = NewPaymentForm(request.POST or None, instance=intervention, request=request)
msg_success=PAYMENT_ADDED, return form.process_request(
redirect_url=reverse("intervention:detail", args=(id,)) + "#related_data" request,
) msg_success=PAYMENT_ADDED,
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: HttpRequest, id: str):
return self.__process_request(request, id=id)
@method_decorator(default_group_required)
@method_decorator(shared_access_required(Intervention, "id"))
def post(self, request: HttpRequest, id: str):
return self.__process_request(request, id=id)
@login_required class RemovePaymentView(LoginRequiredMixin, View):
@default_group_required
@shared_access_required(Intervention, "id")
def payment_remove_view(request: HttpRequest, id: str, payment_id: str):
""" Renders a modal view for removing payments
Args: def __process_request(self, request: HttpRequest, id: str, payment_id: str):
request (HttpRequest): The incoming request """ Renders a modal view for removing payments
id (str): The intervention's id
payment_id (str): The payment's id
Returns: Args:
request (HttpRequest): The incoming request
id (str): The intervention's id
payment_id (str): The payment's id
""" Returns:
intervention = get_object_or_404(Intervention, id=id)
payment = get_object_or_404(Payment, id=payment_id) """
form = RemovePaymentModalForm(request.POST or None, instance=intervention, payment=payment, request=request) intervention = get_object_or_404(Intervention, id=id)
return form.process_request( payment = get_object_or_404(Payment, id=payment_id)
request=request, form = RemovePaymentModalForm(request.POST or None, instance=intervention, payment=payment, request=request)
msg_success=PAYMENT_REMOVED, return form.process_request(
redirect_url=reverse("intervention:detail", args=(payment.intervention_id,)) + "#related_data" request=request,
) msg_success=PAYMENT_REMOVED,
redirect_url=reverse("intervention:detail", args=(payment.intervention_id,)) + "#related_data"
)
@method_decorator(default_group_required)
@method_decorator(shared_access_required(Intervention, "id"))
def get(self, request: HttpRequest, id: str, payment_id: str):
return self.__process_request(request, id=id, payment_id=payment_id)
@method_decorator(default_group_required)
@method_decorator(shared_access_required(Intervention, "id"))
def post(self, request: HttpRequest, id: str, payment_id: str):
return self.__process_request(request, id=id, payment_id=payment_id)
@login_required class EditPaymentView(LoginRequiredMixin, View):
@default_group_required def __process_request(self, request: HttpRequest, id: str, payment_id: str):
@shared_access_required(Intervention, "id") """ Renders a modal view for editing payments
def payment_edit_view(request: HttpRequest, id: str, payment_id: str):
""" Renders a modal view for editing payments
Args: Args:
request (HttpRequest): The incoming request request (HttpRequest): The incoming request
id (str): The intervention's id id (str): The intervention's id
payment_id (str): The payment's id payment_id (str): The payment's id
Returns: Returns:
""" """
intervention = get_object_or_404(Intervention, id=id) intervention = get_object_or_404(Intervention, id=id)
payment = get_object_or_404(Payment, id=payment_id) payment = get_object_or_404(Payment, id=payment_id)
form = EditPaymentModalForm(request.POST or None, instance=intervention, payment=payment, request=request) form = EditPaymentModalForm(request.POST or None, instance=intervention, payment=payment, request=request)
return form.process_request( return form.process_request(
request=request, request=request,
msg_success=PAYMENT_EDITED, msg_success=PAYMENT_EDITED,
redirect_url=reverse("intervention:detail", args=(payment.intervention_id,)) + "#related_data" redirect_url=reverse("intervention:detail", args=(payment.intervention_id,)) + "#related_data"
) )
@method_decorator(default_group_required)
@method_decorator(shared_access_required(Intervention, "id"))
def get(self, request: HttpRequest, id: str, payment_id: str):
return self.__process_request(request, id=id, payment_id=payment_id)
@method_decorator(default_group_required)
@method_decorator(shared_access_required(Intervention, "id"))
def post(self, request: HttpRequest, id: str, payment_id: str):
return self.__process_request(request, id=id, payment_id=payment_id)

View File

@@ -103,9 +103,12 @@ class Geometry(BaseResource):
resolved_conflicts = all_conflicted_by_conflicts.exclude(id__in=still_conflicting_conflicts) resolved_conflicts = all_conflicted_by_conflicts.exclude(id__in=still_conflicting_conflicts)
resolved_conflicts.delete() resolved_conflicts.delete()
def get_data_objects(self): def get_data_objects(self, limit_to_attrs: list = None):
""" Getter for all objects which are related to this geometry """ Getter for all objects which are related to this geometry
Using the limit_to_attrs we can limit the amount of returned data directly onto the data object attributes
we want to have. Reduces memory consumption and runtime.
Returns: Returns:
objs (list): The list of objects objs (list): The list of objects
""" """
@@ -121,15 +124,20 @@ class Geometry(BaseResource):
set_objs = _set.filter( set_objs = _set.filter(
deleted=None deleted=None
) )
objs += set_objs if limit_to_attrs:
objs += set_objs.values_list(*limit_to_attrs, flat=True)
else:
objs += set_objs
# ... but we need a special treatment for compensations, since they can be deleted directly OR inherit their # ... 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 # de-facto-deleted status from their deleted parent intervention
comp_objs = self.compensation_set.filter( comp_objs = self.compensation_set.filter(
Q(deleted=None) & Q(intervention__deleted=None) Q(deleted=None) & Q(intervention__deleted=None)
) )
objs += comp_objs if limit_to_attrs:
objs += comp_objs.values_list(*limit_to_attrs, flat=True)
else:
objs += comp_objs
return objs return objs
def get_data_object(self): def get_data_object(self):
@@ -407,7 +415,10 @@ class Geometry(BaseResource):
""" """
output_geom = input_geom output_geom = input_geom
if not isinstance(input_geom, MultiPolygon): if not isinstance(input_geom, MultiPolygon):
output_geom = MultiPolygon(input_geom, srid=DEFAULT_SRID_RLP) try:
output_geom = MultiPolygon(input_geom, srid=DEFAULT_SRID_RLP)
except TypeError as e:
raise AssertionError(f"Only (Multi)Polygon allowed! Could not convert {input_geom.geom_type} to MultiPolygon")
return output_geom return output_geom
@staticmethod @staticmethod

View File

@@ -677,19 +677,23 @@ class GeoReferencedMixin(models.Model):
return request return request
instance_objs = [] instance_objs = []
needed_data_object_attrs = [
"identifier"
]
conflicts = self.geometry.conflicts_geometries.iterator() conflicts = self.geometry.conflicts_geometries.iterator()
for conflict in conflicts: for conflict in conflicts:
instance_objs += conflict.affected_geometry.get_data_objects() # 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() conflicts = self.geometry.conflicted_by_geometries.iterator()
for conflict in conflicts: for conflict in conflicts:
instance_objs += conflict.conflicting_geometry.get_data_objects() # 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(instance_objs) > 0
if add_message: if add_message:
instance_identifiers = [x.identifier for x in instance_objs] instance_identifiers = ", ".join(instance_objs)
instance_identifiers = ", ".join(instance_identifiers)
message_str = GEOMETRY_CONFLICT_WITH_TEMPLATE.format(instance_identifiers) message_str = GEOMETRY_CONFLICT_WITH_TEMPLATE.format(instance_identifiers)
messages.info(request, message_str) messages.info(request, message_str)
return request return request

View File

@@ -5,6 +5,9 @@ Contact: ksp-servicestelle@sgdnord.rlp.de
Created on: 11.12.23 Created on: 11.12.23
""" """
import json
from json import JSONDecodeError
from django.views.debug import ExceptionReporter from django.views.debug import ExceptionReporter
@@ -30,7 +33,7 @@ class KonovaExceptionReporter(ExceptionReporter):
""" """
whitelist = [ whitelist = [
"is_email", "is_email",
"unicdoe_hint", "unicode_hint",
"frames", "frames",
"request", "request",
"user_str", "user_str",
@@ -39,6 +42,8 @@ class KonovaExceptionReporter(ExceptionReporter):
"raising_view_name", "raising_view_name",
"exception_type", "exception_type",
"exception_value", "exception_value",
"filtered_GET_items",
"filtered_POST_items",
] ]
clean_data = dict() clean_data = dict()
for entry in whitelist: for entry in whitelist:
@@ -56,7 +61,28 @@ class KonovaExceptionReporter(ExceptionReporter):
""" """
tb_data = super().get_traceback_data() tb_data = super().get_traceback_data()
return_data = tb_data
if self.is_email: if self.is_email:
tb_data = self._filter_traceback_data(tb_data) filtered_data = dict()
filtered_data.update(self._filter_traceback_data(tb_data))
filtered_data.update(self._filter_POST_body(tb_data))
return_data = filtered_data
return return_data
return tb_data def _filter_POST_body(self, tb_data: dict):
""" Filters POST body from traceback data
"""
post_data = tb_data.get("request", None)
if post_data:
post_data = post_data.body
try:
post_data = json.loads(post_data)
except JSONDecodeError:
pass
post_data = {
"filtered_POST_items": [
("body", post_data),
]
}
return post_data

View File

@@ -53,6 +53,7 @@ class HomeView(LoginRequiredMixin, View):
# Repeat for other objects # Repeat for other objects
comps = Compensation.objects.filter( comps = Compensation.objects.filter(
deleted=None, deleted=None,
intervention__deleted=None,
) )
user_comps = comps.filter( user_comps = comps.filter(
Q(intervention__users__in=[user]) | Q(intervention__teams__in=user_teams) Q(intervention__users__in=[user]) | Q(intervention__teams__in=user_teams)