Merge pull request '49_Parcel_calculation' (#56) from 49_Parcel_calculation into master

Reviewed-on: SGD-Nord/konova#56
pull/58/head
Michel Peltriaux 3 years ago
commit e618d454e2

@ -64,8 +64,8 @@ class TimespanReport:
responsible__conservation_office__id=id,
legal__registration_date__gt=LKOMPVZVO_PUBLISH_DATE,
deleted=None,
created__timestamp__gte=date_from,
created__timestamp__lte=date_to,
created__timestamp__date__gte=date_from,
created__timestamp__date__lte=date_to,
)
self.queryset_checked = self.queryset.filter(
checked__isnull=False
@ -231,8 +231,8 @@ class TimespanReport:
intervention__responsible__conservation_office__id=id,
intervention__legal__registration_date__gt=LKOMPVZVO_PUBLISH_DATE,
deleted=None,
intervention__created__timestamp__gte=date_from,
intervention__created__timestamp__lte=date_to,
intervention__created__date__timestamp__gte=date_from,
intervention__created__date__timestamp__lte=date_to,
)
self.queryset_checked = self.queryset.filter(
intervention__checked__isnull=False
@ -400,8 +400,8 @@ class TimespanReport:
self.queryset = EcoAccount.objects.filter(
responsible__conservation_office__id=id,
deleted=None,
created__timestamp__gte=date_from,
created__timestamp__lte=date_to,
created__timestamp__date__gte=date_from,
created__timestamp__date__lte=date_to,
)
self.queryset_recorded = self.queryset.filter(
recorded__isnull=False
@ -479,8 +479,8 @@ class TimespanReport:
legal__registration_date__lte=LKOMPVZVO_PUBLISH_DATE,
responsible__conservation_office__id=id,
deleted=None,
created__timestamp__gte=date_from,
created__timestamp__lte=date_to,
created__timestamp__date__gte=date_from,
created__timestamp__date__lte=date_to,
)
self.queryset_intervention_recorded = self.queryset_intervention.filter(
recorded__isnull=False

@ -170,7 +170,7 @@ class AbstractCompensation(BaseObject, GeoReferencedMixin):
"""
if not self.is_shared_with(request.user):
messages.info(request, DATA_UNSHARED_EXPLANATION)
request = self._set_geometry_conflict_message(request)
request = self.set_geometry_conflict_message(request)
return request

@ -110,7 +110,10 @@
{% include 'map/geom_form.html' %}
</div>
<div class="row">
{% include 'konova/comment_card.html' %}
{% include 'konova/includes/parcels.html' %}
</div>
<div class="row">
{% include 'konova/includes/comment_card.html' %}
</div>
</div>
</div>

@ -93,7 +93,10 @@
{% include 'map/geom_form.html' %}
</div>
<div class="row">
{% include 'konova/comment_card.html' %}
{% include 'konova/includes/parcels.html' %}
</div>
<div class="row">
{% include 'konova/includes/comment_card.html' %}
</div>
</div>
</div>

@ -37,6 +37,9 @@
<div class="row">
{% include 'map/geom_form.html' %}
</div>
<div class="row">
{% include 'konova/includes/parcels.html' %}
</div>
<div class="row">
<div class="col-sm-6 col-md-6 col-lg-6">
<h4>{% trans 'Open in browser' %}</h4>

@ -54,6 +54,9 @@
<div class="row">
{% include 'map/geom_form.html' %}
</div>
<div class="row">
{% include 'konova/includes/parcels.html' %}
</div>
<div class="row">
<div class="col-sm-6 col-md-6 col-lg-6">
<h4>{% trans 'Open in browser' %}</h4>

@ -170,6 +170,7 @@ def detail_view(request: HttpRequest, id: str):
template = "compensation/detail/compensation/view.html"
comp = get_object_or_404(Compensation, id=id)
geom_form = SimpleGeomForm(instance=comp)
parcels = comp.get_underlying_parcels()
_user = request.user
is_data_shared = comp.intervention.is_shared_with(_user)
@ -189,6 +190,7 @@ def detail_view(request: HttpRequest, id: str):
context = {
"obj": comp,
"geom_form": geom_form,
"parcels": parcels,
"has_access": is_data_shared,
"actions": actions,
"before_states": before_states,
@ -451,6 +453,7 @@ def report_view(request: HttpRequest, id: str):
geom_form = SimpleGeomForm(
instance=comp
)
parcels = comp.get_underlying_parcels()
qrcode_img = generate_qr_code(
request.build_absolute_uri(reverse("compensation:report", args=(id,))),
10
@ -472,6 +475,7 @@ def report_view(request: HttpRequest, id: str):
"before_states": before_states,
"after_states": after_states,
"geom_form": geom_form,
"parcels": parcels,
"actions": actions,
}
context = BaseContext(request, context).context

@ -181,6 +181,7 @@ def detail_view(request: HttpRequest, id: str):
id=id
)
geom_form = SimpleGeomForm(instance=acc)
parcels = acc.get_underlying_parcels()
_user = request.user
is_data_shared = acc.is_shared_with(_user)
@ -207,6 +208,7 @@ def detail_view(request: HttpRequest, id: str):
context = {
"obj": acc,
"geom_form": geom_form,
"parcels": parcels,
"has_access": is_data_shared,
"before_states": before_states,
"after_states": after_states,
@ -553,6 +555,7 @@ def report_view(request:HttpRequest, id: str):
geom_form = SimpleGeomForm(
instance=acc
)
parcels = acc.get_underlying_parcels()
qrcode_img = generate_qr_code(
request.build_absolute_uri(reverse("ema:report", args=(id,))),
10
@ -580,6 +583,7 @@ def report_view(request:HttpRequest, id: str):
"before_states": before_states,
"after_states": after_states,
"geom_form": geom_form,
"parcels": parcels,
"actions": actions,
"deductions": deductions,
}

@ -106,7 +106,7 @@ class Ema(AbstractCompensation, ShareableObjectMixin, RecordableObjectMixin):
"""
if not self.is_shared_with(request.user):
messages.info(request, DATA_UNSHARED_EXPLANATION)
self._set_geometry_conflict_message(request)
self.set_geometry_conflict_message(request)
return request

@ -2,7 +2,13 @@
{% load i18n l10n static fontawesome_5 humanize %}
{% block head %}
{% comment %}
dal documentation (django-autocomplete-light) states using form.media for adding needed scripts.
This does not work properly with modal forms, as the scripts are not loaded properly inside the modal.
Therefore the script linkages from form.media have been extracted and put inside dal/scripts.html to ensure
these scripts are loaded when needed.
{% endcomment %}
{% include 'dal/scripts.html' %}
{% endblock %}
{% block body %}
@ -77,8 +83,16 @@
</div>
</div>
<div class="col-sm-12 col-md-12 col-lg-6">
<div class="row">
{% include 'map/geom_form.html' %}
</div>
<div class="row">
{% include 'konova/includes/parcels.html' %}
</div>
<div class="row">
{% include 'konova/includes/comment_card.html' %}
</div>
</div>
</div>
<hr>

@ -41,6 +41,9 @@
<div class="row">
{% include 'map/geom_form.html' %}
</div>
<div class="row">
{% include 'konova/includes/parcels.html' %}
</div>
<div class="row">
<div class="col-sm-6 col-md-6 col-lg-6">
<h4>{% trans 'Open in browser' %}</h4>

@ -125,6 +125,7 @@ def detail_view(request: HttpRequest, id: str):
ema = get_object_or_404(Ema, id=id, deleted=None)
geom_form = SimpleGeomForm(instance=ema)
parcels = ema.get_underlying_parcels()
_user = request.user
is_data_shared = ema.is_shared_with(_user)
@ -143,6 +144,7 @@ def detail_view(request: HttpRequest, id: str):
context = {
"obj": ema,
"geom_form": geom_form,
"parcels": parcels,
"has_access": is_data_shared,
"before_states": before_states,
"after_states": after_states,
@ -464,6 +466,7 @@ def report_view(request:HttpRequest, id: str):
geom_form = SimpleGeomForm(
instance=ema,
)
parcels = ema.get_underlying_parcels()
qrcode_img = generate_qr_code(
request.build_absolute_uri(reverse("ema:report", args=(id,))),
10
@ -485,6 +488,7 @@ def report_view(request:HttpRequest, id: str):
"before_states": before_states,
"after_states": after_states,
"geom_form": geom_form,
"parcels": parcels,
"actions": actions,
}
context = BaseContext(request, context).context

@ -278,7 +278,7 @@ class Intervention(BaseObject, ShareableObjectMixin, RecordableObjectMixin, Chec
"""
if not self.is_shared_with(request.user):
messages.info(request, DATA_UNSHARED_EXPLANATION)
request = self._set_geometry_conflict_message(request)
request = self.set_geometry_conflict_message(request)
return request

@ -127,7 +127,10 @@
{% include 'map/geom_form.html' %}
</div>
<div class="row">
{% include 'konova/comment_card.html' %}
{% include 'konova/includes/parcels.html' %}
</div>
<div class="row">
{% include 'konova/includes/comment_card.html' %}
</div>
</div>
</div>

@ -100,6 +100,9 @@
<div class="row">
{% include 'map/geom_form.html' %}
</div>
<div class="row">
{% include 'konova/includes/parcels.html' %}
</div>
<div class="row">
<div class="col-sm-6 col-md-6 col-lg-6">
<h4>{% trans 'Open in browser' %}</h4>

@ -236,6 +236,8 @@ def detail_view(request: HttpRequest, id: str):
instance=intervention,
)
parcels = intervention.get_underlying_parcels()
# Inform user about revocation
if intervention.legal.revocations.exists():
messages.error(
@ -249,6 +251,7 @@ def detail_view(request: HttpRequest, id: str):
"compensations": compensations,
"has_access": is_data_shared,
"geom_form": geom_form,
"parcels": parcels,
"is_default_member": in_group(_user, DEFAULT_GROUP),
"is_zb_member": in_group(_user, ZB_GROUP),
"is_ets_member": in_group(_user, ETS_GROUP),
@ -544,6 +547,7 @@ def report_view(request:HttpRequest, id: str):
geom_form = SimpleGeomForm(
instance=intervention
)
parcels = intervention.get_underlying_parcels()
distinct_deductions = intervention.deductions.all().distinct(
"account"
@ -562,6 +566,7 @@ def report_view(request:HttpRequest, id: str):
"qrcode": qrcode_img,
"qrcode_lanis": qrcode_img_lanis,
"geom_form": geom_form,
"parcels": parcels,
}
context = BaseContext(request, context).context
return render(request, template, context)

@ -7,7 +7,7 @@ Created on: 22.07.21
"""
from django.contrib import admin
from konova.models import Geometry, Deadline, GeometryConflict
from konova.models import Geometry, Deadline, GeometryConflict, Parcel, District
class GeometryAdmin(admin.ModelAdmin):
@ -17,6 +17,25 @@ class GeometryAdmin(admin.ModelAdmin):
]
class ParcelAdmin(admin.ModelAdmin):
list_display = [
"id",
"gmrkng",
"flr",
"flrstck_nnr",
"flrstck_zhlr",
"updated_on",
]
class DistrictAdmin(admin.ModelAdmin):
list_display = [
"id",
"gmnd",
"krs",
]
class GeometryConflictAdmin(admin.ModelAdmin):
list_display = [
"conflicting_geometry",
@ -52,5 +71,7 @@ class BaseObjectAdmin(admin.ModelAdmin):
admin.site.register(Geometry, GeometryAdmin)
admin.site.register(Parcel, ParcelAdmin)
admin.site.register(District, DistrictAdmin)
admin.site.register(GeometryConflict, GeometryConflictAdmin)
admin.site.register(Deadline, DeadlineAdmin)

@ -15,18 +15,17 @@ class BaseContext:
"""
Holds all base data which is needed for every context rendering
"""
context = {
context = None
def __init__(self, request: HttpRequest, additional_context: dict = {}):
self.context = {
"base_title": BASE_TITLE,
"base_frontend_title": BASE_FRONTEND_TITLE,
"language": LANGUAGE_CODE,
"user": None,
"language": request.LANGUAGE_CODE,
"user": request.user,
"current_role": None,
"help_link": HELP_LINK,
"help_link": HELP_LINK
}
def __init__(self, request: HttpRequest, additional_context: dict = {}):
self.context["language"] = request.LANGUAGE_CODE
self.context["user"] = request.user
# Add additional context, derived from given parameters
self.context.update(additional_context)

@ -287,6 +287,7 @@ class SimpleGeomForm(BaseForm):
geometry = self.instance.geometry
geometry.geom = self.cleaned_data.get("geom", MultiPolygon(srid=DEFAULT_SRID))
geometry.modified = action
geometry.update_parcels()
geometry.save()
except LookupError:
# No geometry or linked instance holding a geometry exist --> create a new one!

@ -9,7 +9,7 @@ from compensation.models import CompensationState, Compensation, EcoAccount, Com
from ema.models import Ema
from intervention.models import Intervention
from konova.management.commands.setup import BaseKonovaCommand
from konova.models import Deadline, Geometry
from konova.models import Deadline, Geometry, Parcel, District
from user.models import UserActionLogEntry
@ -23,6 +23,7 @@ class Command(BaseKonovaCommand):
self.sanitize_actions()
self.sanitize_deadlines()
self.sanitize_geometries()
self.sanitize_parcels_and_districts()
except KeyboardInterrupt:
self._break_line()
exit(-1)
@ -266,3 +267,34 @@ class Command(BaseKonovaCommand):
self._write_success("No unused states found.")
self._break_line()
def sanitize_parcels_and_districts(self):
""" Removes unattached parcels and districts
Returns:
"""
self._write_warning("=== Sanitize parcels and districts ===")
unrelated_parcels = Parcel.objects.filter(
geometries=None,
)
num_unrelated_parcels = unrelated_parcels.count()
if num_unrelated_parcels > 0:
self._write_error(f"Found {num_unrelated_parcels} unrelated parcel entries. Delete now...")
unrelated_parcels.delete()
self._write_success("Unrelated parcels deleted.")
else:
self._write_success("No unrelated parcels found.")
unrelated_districts = District.objects.filter(
parcels=None,
)
num_unrelated_districts = unrelated_districts.count()
if num_unrelated_districts > 0:
self._write_error(f"Found {num_unrelated_districts} unrelated district entries. Delete now...")
unrelated_districts.delete()
self._write_success("Unrelated districts deleted.")
else:
self._write_success("No unrelated districts found.")
self._break_line()

@ -0,0 +1,41 @@
"""
Author: Michel Peltriaux
Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany
Contact: michel.peltriaux@sgdnord.rlp.de
Created on: 04.01.22
"""
from konova.management.commands.setup import BaseKonovaCommand
from konova.models import Geometry, Parcel, District
class Command(BaseKonovaCommand):
help = "Checks the database' sanity and removes unused entries"
def handle(self, *args, **options):
try:
self.update_all_parcels()
except KeyboardInterrupt:
self._break_line()
exit(-1)
def update_all_parcels(self):
num_parcels_before = Parcel.objects.count()
num_districts_before = District.objects.count()
self._write_warning("=== Update parcels and districts ===")
geometries = Geometry.objects.all().exclude(
geom=None
)
self._write_warning(f"Process parcels for {geometries.count()} geometry entries now ...")
for geometry in geometries:
geometry.update_parcels()
num_parcels_after = Parcel.objects.count()
num_districts_after = District.objects.count()
if num_parcels_after != num_parcels_before:
self._write_error(f"Parcels have changed: {num_parcels_before} to {num_parcels_after} entries. You should run the sanitize command.")
if num_districts_after != num_districts_before:
self._write_error(f"Districts have changed: {num_districts_before} to {num_districts_after} entries. You should run the sanitize command.")
self._write_success("Updating parcels done!")
self._break_line()

@ -9,3 +9,4 @@ from .object import *
from .deadline import *
from .document import *
from .geometry import *
from .parcel import *

@ -7,9 +7,10 @@ Created on: 15.11.21
"""
from django.contrib.gis.db.models import MultiPolygonField
from django.db import models
from django.db.models import Q
from django.utils import timezone
from konova.models import BaseResource, UuidModel
from konova.utils.wfs.spatial import ParcelWFSFetcher
class Geometry(BaseResource):
@ -32,7 +33,7 @@ class Geometry(BaseResource):
"""
# If no geometry is given or important data is missing, we can not perform any checks
if self.geom is None or (self.created is None and self.modified is None):
if self.geom is None:
return None
self.recheck_existing_conflicts()
@ -44,6 +45,9 @@ class Geometry(BaseResource):
).distinct()
for match in overlapping_geoms:
# Make sure this conflict is not already known but in a swapped constellation
conflict_exists_swapped = GeometryConflict.objects.filter(conflicting_geometry=match, affected_geometry=self).exists()
if not conflict_exists_swapped:
GeometryConflict.objects.get_or_create(conflicting_geometry=self, affected_geometry=match)
def recheck_existing_conflicts(self):
@ -69,7 +73,6 @@ class Geometry(BaseResource):
resolved_conflicts = all_conflicted_by_conflicts.exclude(id__in=still_conflicting_conflicts)
resolved_conflicts.delete()
def get_data_objects(self):
""" Getter for all objects which are related to this geometry
@ -90,6 +93,54 @@ class Geometry(BaseResource):
objs += set_objs
return objs
def update_parcels(self):
""" Updates underlying parcel information
Returns:
"""
from konova.models import Parcel, District
parcel_fetcher = ParcelWFSFetcher(
geometry_id=self.id,
)
typename = "ave:Flurstueck"
fetched_parcels = parcel_fetcher.get_features(
typename
)
underlying_parcels = []
for result in fetched_parcels:
fetched_parcel = result[typename]
parcel_obj = Parcel.objects.get_or_create(
gmrkng=fetched_parcel["ave:gemarkung"],
flr=fetched_parcel["ave:flur"],
flrstck_nnr=fetched_parcel['ave:flstnrnen'],
flrstck_zhlr=fetched_parcel['ave:flstnrzae'],
)[0]
district = District.objects.get_or_create(
gmnd=fetched_parcel["ave:gemeinde"],
krs=fetched_parcel["ave:kreis"],
)[0]
parcel_obj.district = district
parcel_obj.updated_on = timezone.now()
parcel_obj.save()
underlying_parcels.append(parcel_obj)
self.parcels.set(underlying_parcels)
def get_underlying_parcels(self):
""" Getter for related parcels and their districts
Returns:
parcels (QuerySet): The related parcels as queryset
"""
parcels = self.parcels.all().prefetch_related(
"district"
).order_by(
"gmrkng",
)
return parcels
class GeometryConflict(UuidModel):
"""

@ -420,13 +420,29 @@ class GeoReferencedMixin(models.Model):
class Meta:
abstract = True
def _set_geometry_conflict_message(self, request: HttpRequest):
def get_underlying_parcels(self):
""" Getter for related parcels
Returns:
parcels (Iterable): An empty list or a Queryset
"""
if self.geometry is not None:
return self.geometry.get_underlying_parcels()
else:
return []
def set_geometry_conflict_message(self, request: HttpRequest):
if self.geometry is None:
return request
instance_objs = []
add_message = False
conflicts = self.geometry.conflicts_geometries.all()
for conflict in conflicts:
instance_objs += conflict.affected_geometry.get_data_objects()
add_message = True
conflicts = self.geometry.conflicted_by_geometries.all()
for conflict in conflicts:
instance_objs += conflict.conflicting_geometry.get_data_objects()

@ -0,0 +1,79 @@
"""
Author: Michel Peltriaux
Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany
Contact: michel.peltriaux@sgdnord.rlp.de
Created on: 16.12.21
"""
from django.db import models
from konova.models import UuidModel
class Parcel(UuidModel):
""" The Parcel model holds administrative data on the covered properties.
Due to the unique but relevant naming of the administrative data, we have to use these namings as field
names in german. Any try to translate them to English result in strange or insufficient translations.
All fields have to be CharFields as well, since there are e.g. Flurstücksnummer holding e.g. '123____' which
can not be realized using numerical fields.
To avoid conflicts due to german Umlaute, the field names are shortened and vocals are dropped.
"""
geometries = models.ManyToManyField("konova.Geometry", related_name="parcels", blank=True)
district = models.ForeignKey("konova.District", on_delete=models.SET_NULL, null=True, blank=True, related_name="parcels")
gmrkng = models.CharField(
max_length=1000,
help_text="Gemarkung",
null=True,
blank=True,
)
flrstck_nnr = models.CharField(
max_length=1000,
help_text="Flurstücksnenner",
null=True,
blank=True,
)
flrstck_zhlr = models.CharField(
max_length=1000,
help_text="Flurstückszähler",
null=True,
blank=True,
)
flr = models.CharField(
max_length=1000,
help_text="Flur",
null=True,
blank=True,
)
updated_on = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.gmrkng} | {self.flr} | {self.flrstck_zhlr} | {self.flrstck_nnr}"
class District(UuidModel):
""" The model District holds more coarse information, such as Kreis, Verbandsgemeinde and Gemeinde.
There might be the case that a geometry lies on a hundred Parcel entries but only on one District entry.
Therefore a geometry can have a lot of relations to Parcel entries but only a few or only a single one to one
District.
"""
gmnd = models.CharField(
max_length=1000,
help_text="Gemeinde",
null=True,
blank=True,
)
krs = models.CharField(
max_length=1000,
help_text="Kreis",
null=True,
blank=True,
)
def __str__(self):
return f"{self.gmnd} | {self.krs}"

@ -80,3 +80,8 @@ LANIS_ZOOM_LUT = {
1000: 30,
500: 31,
}
# Parcel WFS settings
PARCEL_WFS_BASE_URL = "https://www.geoportal.rlp.de/registry/wfs/519"
PARCEL_WFS_USER = "ksp"
PARCEL_WFS_PW = "CHANGE_ME"

@ -0,0 +1,29 @@
{% load i18n %}
<div>
<h3>{% trans 'Spatial reference' %}</h3>
</div>
<div class="table-container w-100 scroll-300">
<table class="table table-hover">
<thead>
<tr>
<th scope="col">{% trans 'Kreis' %}</th>
<th scope="col">{% trans 'Gemarkung' %}</th>
<th scope="col">{% trans 'Parcel' %}</th>
<th scope="col">{% trans 'Parcel counter' %}</th>
<th scope="col">{% trans 'Parcel number' %}</th>
</tr>
</thead>
<tbody>
{% for parcel in parcels %}
<tr>
<td>{{parcel.district.krs|default_if_none:"-"}}</td>
<td>{{parcel.gmrkng|default_if_none:"-"}}</td>
<td>{{parcel.flr|default_if_none:"-"}}</td>
<td>{{parcel.flrstck_zhlr|default_if_none:"-"}}</td>
<td>{{parcel.flrstck_nnr|default_if_none:"-"}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>

@ -9,7 +9,7 @@ from django.contrib.gis.db.models.functions import Translate
from konova.models import Geometry, GeometryConflict
from konova.tests.test_views import BaseTestCase
from user.models import UserActionLogEntry
from konova.utils.wfs.spatial import ParcelWFSFetcher
class GeometryTestCase(BaseTestCase):
@ -17,16 +17,29 @@ class GeometryTestCase(BaseTestCase):
def setUpTestData(cls):
super().setUpTestData()
geom = cls.create_dummy_geometry()
action = UserActionLogEntry.get_created_action(cls.superuser)
cls.geom_1 = Geometry.objects.create(
geom=geom,
created=action,
)
cls.geom_2 = Geometry.objects.create(
geom=geom,
created=action,
)
def test_geometry_parcel_caluclation(self):
""" Tests whether newly created geometries already have parcels calculated during save
Returns:
"""
has_parcels = self.geom_1.parcels.all().exists()
self.assertFalse(has_parcels, msg=f"{self.geom_1.id} has parcels but should not!")
self.geom_1.update_parcels()
self.geom_1.refresh_from_db()
parcels = self.geom_1.parcels.all()
has_parcels = parcels.exists()
parcel_districts = parcels.values_list("district", flat=True)
self.assertTrue(has_parcels, msg=f"{self.geom_1.id} has no parcels but should!")
self.assertEqual(parcels.count(), len(parcel_districts), msg=f"Not every parcel has exactly one district!")
def test_geometry_conflict(self):
""" Tests whether a geometry conflict will be present in case of identical/overlaying geometries and
if the conflict will be resolved if one geometry is edited.
@ -34,8 +47,9 @@ class GeometryTestCase(BaseTestCase):
Returns:
"""
self.geom_1.check_for_conflicts()
conflict = GeometryConflict.objects.all().first()
conflict = GeometryConflict.objects.all()
self.assertEqual(1, conflict.count())
conflict = conflict.first()
self.assertEqual(conflict.conflicting_geometry, self.geom_2)
self.assertEqual(conflict.affected_geometry, self.geom_1)
@ -47,3 +61,19 @@ class GeometryTestCase(BaseTestCase):
self.geom_1.check_for_conflicts()
num_conflict = GeometryConflict.objects.all().count()
self.assertEqual(0, num_conflict)
def test_wfs_fetch(self):
""" Tests the fetching functionality of ParcelWFSFetcher
+++ Test relies on the availability of the RLP Flurstück WFS +++
Returns:
"""
fetcher = ParcelWFSFetcher(
geometry_id=self.geom_1.id,
)
features = fetcher.get_features(
"ave:Flurstueck",
)
self.assertNotEqual(0, len(features), msg="Spatial wfs get feature did not work!")

@ -214,7 +214,7 @@ class BaseTestCase(TestCase):
Returns:
"""
polygon = Polygon.from_bbox((7.157593, 49.882247, 7.816772, 50.266521))
polygon = Polygon.from_bbox((7.592449, 50.359385, 7.593382, 50.359874))
polygon.srid = 4326
polygon = polygon.transform(3857, clone=True)
return MultiPolygon(polygon, srid=3857) # 3857 is the default srid used for MultiPolygonField in the form

@ -0,0 +1,175 @@
"""
Author: Michel Peltriaux
Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany
Contact: michel.peltriaux@sgdnord.rlp.de
Created on: 17.12.21
"""
from abc import abstractmethod
import requests
import xmltodict
from django.contrib.gis.db.models.functions import AsGML, Transform
from requests.auth import HTTPDigestAuth
from konova.settings import DEFAULT_SRID_RLP, PARCEL_WFS_USER, PARCEL_WFS_PW
class AbstractWFSFetcher:
""" Base class for fetching WFS data
"""
# base_url represents not the capabilities url but the parameter-free base url
base_url = None
version = None
auth_user = None
auth_pw = None
auth_digest_obj = None
class Meta:
abstract = True
def __init__(self, base_url: str, version: str = "1.1.0", auth_user: str = None, auth_pw: str = None, *args, **kwargs):
self.base_url = base_url
self.version = version
self.auth_pw = auth_pw
self.auth_user = auth_user
self._create_auth_obj()
def _create_auth_obj(self):
if self.auth_pw is not None and self.auth_user is not None:
self.auth_digest_obj = HTTPDigestAuth(
self.auth_user,
self.auth_pw
)
@abstractmethod
def get_features(self, feature_identifier: str, filter_str: str):
raise NotImplementedError
class ParcelWFSFetcher(AbstractWFSFetcher):
""" Fetches features from a special parcel WFS
"""
geometry_id = None
geometry_property_name = None
count = 100
def __init__(self, geometry_id: str, geometry_property_name: str = "msGeometry", *args, **kwargs):
super().__init__(
version="2.0.0",
base_url="https://www.geoportal.rlp.de/registry/wfs/519",
auth_user=PARCEL_WFS_USER,
auth_pw=PARCEL_WFS_PW,
*args,
**kwargs
)
self.geometry_id = geometry_id
self.geometry_property_name = geometry_property_name
def _create_spatial_filter(self,
geometry_operation: str,
filter_srid: str = None):
""" Creates a xml spatial filter according to the WFS filter specification
Args:
geometry_operation (str): One of the WFS supported spatial filter operations (according to capabilities)
filter_srid (str): Used to transform the geometry into the spatial reference system identified by this srid
Returns:
spatial_filter (str): The spatial filter element
"""
from konova.models import Geometry
if filter_srid is None:
filter_srid = DEFAULT_SRID_RLP
geom_gml = Geometry.objects.filter(
id=self.geometry_id
).annotate(
transformed=Transform(srid=filter_srid, expression="geom")
).annotate(
gml=AsGML('transformed')
).first().gml
spatial_filter = f"<Filter><{geometry_operation}><PropertyName>{self.geometry_property_name}</PropertyName>{geom_gml}</{geometry_operation}></Filter>"
return spatial_filter
def _create_post_data(self,
geometry_operation: str,
filter_srid: str = None,
typenames: str = None,
start_index: int = 0,
):
""" Creates a POST body content for fetching features
Args:
geometry_operation (str): One of the WFS supported spatial filter operations (according to capabilities)
filter_srid (str): Used to transform the geometry into the spatial reference system identified by this srid
Returns:
_filter (str): A proper xml WFS filter
"""
start_index = str(start_index)
spatial_filter = self._create_spatial_filter(
geometry_operation,
filter_srid
)
_filter = f'<wfs:GetFeature service="WFS" version="{self.version}" xmlns:wfs="http://www.opengis.net/wfs/2.0" xmlns:fes="http://www.opengis.net/fes/2.0" xmlns:myns="http://www.someserver.com/myns" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.opengis.net/wfs/2.0 http://schemas.opengis.net/wfs/2.0.0/wfs.xsd" count="{self.count}" startindex="{start_index}"><wfs:Query typeNames="{typenames}">{spatial_filter}</wfs:Query></wfs:GetFeature>'
return _filter
def get_features(self,
typenames: str,
spatial_operator: str = "Intersects",
filter_srid: str = None,
start_index: int = 0,
):
""" Fetches features from the WFS using POST
POST is required since GET has a character limit around 4000. Having a larger filter would result in errors,
which do not occur in case of POST.
Args:
typenames (str): References to parameter 'typenames' in a WFS GetFeature request
spatial_operator (str): Defines the spatial operation for filtering
filter_srid (str): Defines the spatial reference system, the geometry shall be transformed into for filtering
start_index (str): References to parameter 'startindex' in a
Returns:
features (list): A list of returned features
"""
features = []
while start_index is not None:
post_body = self._create_post_data(
spatial_operator,
filter_srid,
typenames,
start_index
)
response = requests.post(
url=self.base_url,
data=post_body,
auth=self.auth_digest_obj
)
content = response.content.decode("utf-8")
content = xmltodict.parse(content)
collection = content.get(
"wfs:FeatureCollection",
{},
)
members = collection.get(
"wfs:member",
[],
)
if len(members) > 1:
features += members
else:
features += [members]
if collection.get("@next", None) is not None:
start_index += self.count
else:
start_index = None
return features

Binary file not shown.

@ -11,15 +11,15 @@
#: intervention/forms/forms.py:52 intervention/forms/forms.py:154
#: intervention/forms/forms.py:166 intervention/forms/modalForms.py:125
#: intervention/forms/modalForms.py:138 intervention/forms/modalForms.py:151
#: konova/forms.py:139 konova/forms.py:240 konova/forms.py:308
#: konova/forms.py:335 konova/forms.py:345 konova/forms.py:358
#: konova/forms.py:370 konova/forms.py:388 user/forms.py:38
#: konova/forms.py:139 konova/forms.py:240 konova/forms.py:309
#: konova/forms.py:336 konova/forms.py:346 konova/forms.py:359
#: konova/forms.py:371 konova/forms.py:389 user/forms.py:38
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-12-16 09:17+0100\n"
"POT-Creation-Date: 2022-01-05 14:04+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -329,7 +329,7 @@ msgstr "Automatisch generiert"
#: intervention/templates/intervention/detail/includes/documents.html:28
#: intervention/templates/intervention/detail/view.html:31
#: intervention/templates/intervention/report/report.html:12
#: konova/forms.py:334
#: konova/forms.py:335
msgid "Title"
msgstr "Bezeichnung"
@ -356,7 +356,7 @@ msgstr "Kompensation XY; Flur ABC"
#: intervention/templates/intervention/detail/includes/documents.html:31
#: intervention/templates/intervention/detail/includes/payments.html:34
#: intervention/templates/intervention/detail/includes/revocation.html:38
#: konova/forms.py:369 konova/templates/konova/comment_card.html:16
#: konova/forms.py:370 konova/templates/konova/includes/comment_card.html:16
msgid "Comment"
msgstr "Kommentar"
@ -472,7 +472,7 @@ msgstr "Zahlung wird an diesem Datum erwartet"
#: compensation/forms/modalForms.py:62 compensation/forms/modalForms.py:239
#: compensation/forms/modalForms.py:317 intervention/forms/modalForms.py:152
#: konova/forms.py:371
#: konova/forms.py:372
msgid "Additional comment, maximum {} letters"
msgstr "Zusätzlicher Kommentar, maximal {} Zeichen"
@ -793,7 +793,7 @@ msgstr "Dokumente"
#: compensation/templates/compensation/detail/eco_account/includes/documents.html:14
#: ema/templates/ema/detail/includes/documents.html:14
#: intervention/templates/intervention/detail/includes/documents.html:14
#: konova/forms.py:387
#: konova/forms.py:388
msgid "Add new document"
msgstr "Neues Dokument hinzufügen"
@ -1056,41 +1056,41 @@ msgstr "Kompensation {} hinzugefügt"
msgid "Compensation {} edited"
msgstr "Kompensation {} bearbeitet"
#: compensation/views/compensation.py:228 compensation/views/eco_account.py:307
#: ema/views.py:181 intervention/views.py:474
#: compensation/views/compensation.py:230 compensation/views/eco_account.py:309
#: ema/views.py:181 intervention/views.py:477
msgid "Log"
msgstr "Log"
#: compensation/views/compensation.py:251
#: compensation/views/compensation.py:253
msgid "Compensation removed"
msgstr "Kompensation entfernt"
#: compensation/views/compensation.py:272 compensation/views/eco_account.py:459
#: compensation/views/compensation.py:274 compensation/views/eco_account.py:461
#: ema/views.py:348 intervention/views.py:129
msgid "Document added"
msgstr "Dokument hinzugefügt"
#: compensation/views/compensation.py:341 compensation/views/eco_account.py:353
#: compensation/views/compensation.py:343 compensation/views/eco_account.py:355
#: ema/views.py:286
msgid "State added"
msgstr "Zustand hinzugefügt"
#: compensation/views/compensation.py:362 compensation/views/eco_account.py:374
#: compensation/views/compensation.py:364 compensation/views/eco_account.py:376
#: ema/views.py:307
msgid "Action added"
msgstr "Maßnahme hinzugefügt"
#: compensation/views/compensation.py:383 compensation/views/eco_account.py:439
#: compensation/views/compensation.py:385 compensation/views/eco_account.py:441
#: ema/views.py:328
msgid "Deadline added"
msgstr "Frist/Termin hinzugefügt"
#: compensation/views/compensation.py:405 compensation/views/eco_account.py:396
#: compensation/views/compensation.py:407 compensation/views/eco_account.py:398
#: ema/views.py:418
msgid "State removed"
msgstr "Zustand gelöscht"
#: compensation/views/compensation.py:427 compensation/views/eco_account.py:418
#: compensation/views/compensation.py:429 compensation/views/eco_account.py:420
#: ema/views.py:440
msgid "Action removed"
msgstr "Maßnahme entfernt"
@ -1103,45 +1103,45 @@ msgstr "Ökokonto {} hinzugefügt"
msgid "Eco-Account {} edited"
msgstr "Ökokonto {} bearbeitet"
#: compensation/views/eco_account.py:255
#: compensation/views/eco_account.py:257
msgid "Eco-account removed"
msgstr "Ökokonto entfernt"
#: compensation/views/eco_account.py:283
#: compensation/views/eco_account.py:285
msgid "Deduction removed"
msgstr "Abbuchung entfernt"
#: compensation/views/eco_account.py:328 ema/views.py:261
#: intervention/views.py:516
#: compensation/views/eco_account.py:330 ema/views.py:261
#: intervention/views.py:519
msgid "{} unrecorded"
msgstr "{} entzeichnet"
#: compensation/views/eco_account.py:328 ema/views.py:261
#: intervention/views.py:516
#: compensation/views/eco_account.py:330 ema/views.py:261
#: intervention/views.py:519
msgid "{} recorded"
msgstr "{} verzeichnet"
#: compensation/views/eco_account.py:529 intervention/views.py:497
#: compensation/views/eco_account.py:531 intervention/views.py:500
msgid "Deduction added"
msgstr "Abbuchung hinzugefügt"
#: compensation/views/eco_account.py:612 ema/views.py:516
#: intervention/views.py:372
#: compensation/views/eco_account.py:614 ema/views.py:516
#: intervention/views.py:375
msgid "{} has already been shared with you"
msgstr "{} wurde bereits für Sie freigegeben"
#: compensation/views/eco_account.py:617 ema/views.py:521
#: intervention/views.py:377
#: compensation/views/eco_account.py:619 ema/views.py:521
#: intervention/views.py:380
msgid "{} has been shared with you"
msgstr "{} ist nun für Sie freigegeben"
#: compensation/views/eco_account.py:624 ema/views.py:528
#: intervention/views.py:384
#: compensation/views/eco_account.py:626 ema/views.py:528
#: intervention/views.py:387
msgid "Share link invalid"
msgstr "Freigabelink ungültig"
#: compensation/views/eco_account.py:647 ema/views.py:551
#: intervention/views.py:407
#: compensation/views/eco_account.py:649 ema/views.py:551
#: intervention/views.py:410
msgid "Share settings updated"
msgstr "Freigabe Einstellungen aktualisiert"
@ -1333,7 +1333,7 @@ msgstr "Kompensationen und Zahlungen geprüft"
msgid "Run check"
msgstr "Prüfung vornehmen"
#: intervention/forms/modalForms.py:196 konova/forms.py:453
#: intervention/forms/modalForms.py:196 konova/forms.py:454
msgid ""
"I, {} {}, confirm that all necessary control steps have been performed by "
"myself."
@ -1472,31 +1472,31 @@ msgstr ""
msgid "Intervention {} added"
msgstr "Eingriff {} hinzugefügt"
#: intervention/views.py:243
#: intervention/views.py:245
msgid "This intervention has {} revocations"
msgstr "Dem Eingriff liegen {} Widersprüche vor"
#: intervention/views.py:290
#: intervention/views.py:293
msgid "Intervention {} edited"
msgstr "Eingriff {} bearbeitet"
#: intervention/views.py:325
#: intervention/views.py:328
msgid "{} removed"
msgstr "{} entfernt"
#: intervention/views.py:346
#: intervention/views.py:349
msgid "Revocation removed"
msgstr "Widerspruch entfernt"
#: intervention/views.py:428
#: intervention/views.py:431
msgid "Check performed"
msgstr "Prüfung durchgeführt"
#: intervention/views.py:450
#: intervention/views.py:453
msgid "Revocation added"
msgstr "Widerspruch hinzugefügt"
#: intervention/views.py:521
#: intervention/views.py:524
msgid "There are errors on this intervention:"
msgstr "Es liegen Fehler in diesem Eingriff vor:"
@ -1525,11 +1525,11 @@ msgstr "Speichern"
msgid "Not editable"
msgstr "Nicht editierbar"
#: konova/forms.py:138 konova/forms.py:307
#: konova/forms.py:138 konova/forms.py:308
msgid "Confirm"
msgstr "Bestätige"
#: konova/forms.py:150 konova/forms.py:316
#: konova/forms.py:150 konova/forms.py:317
msgid "Remove"
msgstr "Löschen"
@ -1542,56 +1542,56 @@ msgstr "Sie sind dabei {} {} zu löschen"
msgid "Geometry"
msgstr "Geometrie"
#: konova/forms.py:317
#: konova/forms.py:318
msgid "Are you sure?"
msgstr "Sind Sie sicher?"
#: konova/forms.py:344
#: konova/forms.py:345
msgid "Created on"
msgstr "Erstellt"
#: konova/forms.py:346
#: konova/forms.py:347
msgid "When has this file been created? Important for photos."
msgstr "Wann wurde diese Datei erstellt oder das Foto aufgenommen?"
#: konova/forms.py:357
#: konova/forms.py:358
#: venv/lib/python3.7/site-packages/django/db/models/fields/files.py:231
msgid "File"
msgstr "Datei"
#: konova/forms.py:359
#: konova/forms.py:360
msgid "Allowed formats: pdf, jpg, png. Max size 15 MB."
msgstr "Formate: pdf, jpg, png. Maximal 15 MB."
#: konova/forms.py:405
#: konova/forms.py:406
msgid "Unsupported file type"
msgstr "Dateiformat nicht unterstützt"
#: konova/forms.py:412
#: konova/forms.py:413
msgid "File too large"
msgstr "Datei zu groß"
#: konova/forms.py:421
#: konova/forms.py:422
msgid "Added document"
msgstr "Dokument hinzugefügt"
#: konova/forms.py:444
#: konova/forms.py:445
msgid "Confirm record"
msgstr "Verzeichnen bestätigen"
#: konova/forms.py:452
#: konova/forms.py:453
msgid "Record data"
msgstr "Daten verzeichnen"
#: konova/forms.py:459
#: konova/forms.py:460
msgid "Confirm unrecord"
msgstr "Entzeichnen bestätigen"
#: konova/forms.py:460
#: konova/forms.py:461
msgid "Unrecord data"
msgstr "Daten entzeichnen"
#: konova/forms.py:461
#: konova/forms.py:462
msgid "I, {} {}, confirm that this data must be unrecorded."
msgstr ""
"Ich, {} {}, bestätige, dass diese Daten wieder entzeichnet werden müssen."
@ -1663,6 +1663,30 @@ msgstr "Anzeigen"
msgid "Deduct"
msgstr "Abbuchen"
#: konova/templates/konova/includes/parcels.html:3
msgid "Spatial reference"
msgstr "Raumreferenz"
#: konova/templates/konova/includes/parcels.html:9
msgid "Kreis"
msgstr "Kreis"
#: konova/templates/konova/includes/parcels.html:10
msgid "Gemarkung"
msgstr "Gemarkung"
#: konova/templates/konova/includes/parcels.html:11
msgid "Parcel"
msgstr "Flur"
#: konova/templates/konova/includes/parcels.html:12
msgid "Parcel counter"
msgstr "Flurstückzähler"
#: konova/templates/konova/includes/parcels.html:13
msgid "Parcel number"
msgstr "Flurstücknenner"
#: konova/templates/konova/widgets/generate-content-input.html:6
msgid "Generate new"
msgstr "Neu generieren"
@ -1726,8 +1750,8 @@ msgid ""
"Action canceled. Eco account is recorded or deductions exist. Only "
"conservation office member can perform this action."
msgstr ""
"Aktion abgebrochen. Ökokonto ist bereits verzeichnet oder Abbuchungen liegen vor. Nur "
"Eintragungsstellennutzer können diese Aktion jetzt durchführen."
"Aktion abgebrochen. Ökokonto ist bereits verzeichnet oder Abbuchungen liegen "
"vor. Nur Eintragungsstellennutzer können diese Aktion jetzt durchführen."
#: konova/utils/message_templates.py:25
msgid "Edited general data"

@ -14,10 +14,14 @@ django-tables2==2.3.4
et-xmlfile==1.1.0
idna==2.10
importlib-metadata==2.1.1
itsdangerous<1.0.0
itsdangerous==0.24
openpyxl==3.0.9
OWSLib==0.25.0
psycopg2-binary==2.9.1
pyproj==3.2.1
python-dateutil==2.8.2
pytz==2020.4
PyYAML==6.0
qrcode==7.3.1
requests==2.25.0
six==1.15.0
@ -25,4 +29,5 @@ soupsieve==2.2.1
sqlparse==0.4.1
urllib3==1.26.2
webservices==0.7
xmltodict==0.12.0
zipp==3.4.1

Loading…
Cancel
Save