Compare commits

..

No commits in common. "f0911b5eb64dc81b6e555d7063c81499a51f498c" and "440a4b04d5bf85e521d775a5cc35dd4e67d60d6d" have entirely different histories.

10 changed files with 76 additions and 313 deletions

View File

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

View File

@ -287,7 +287,6 @@ 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!

View File

@ -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, Parcel, District
from konova.models import Deadline, Geometry
from user.models import UserActionLogEntry
@ -23,7 +23,6 @@ class Command(BaseKonovaCommand):
self.sanitize_actions()
self.sanitize_deadlines()
self.sanitize_geometries()
self.sanitize_parcels_and_districts()
except KeyboardInterrupt:
self._break_line()
exit(-1)
@ -267,34 +266,3 @@ 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()

View File

@ -1,41 +0,0 @@
"""
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()

View File

@ -7,10 +7,8 @@ Created on: 15.11.21
"""
from django.contrib.gis.db.models import MultiPolygonField
from django.db import models
from django.utils import timezone
from konova.models import BaseResource, UuidModel
from konova.utils.wfs.spatial import ParcelWFSFetcher
class Geometry(BaseResource):
@ -23,6 +21,7 @@ class Geometry(BaseResource):
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
self.check_for_conflicts()
self.update_parcels()
def check_for_conflicts(self):
""" Checks for new geometry overlaps
@ -94,38 +93,8 @@ class Geometry(BaseResource):
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(
flr=fetched_parcel["ave:flur"],
flrstck_nnr=fetched_parcel['ave:flstnrnen'],
flrstck_zhlr=fetched_parcel['ave:flstnrzae'],
)[0]
district = District.objects.get_or_create(
gmrkng=fetched_parcel["ave:gemarkung"],
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)
# ToDo
pass
class GeometryConflict(UuidModel):

View File

@ -22,30 +22,28 @@ class Parcel(UuidModel):
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)
geometries = models.ManyToManyField("konova.Geometry", related_name="parcels", null=True, blank=True)
district = models.ForeignKey("konova.District", on_delete=models.SET_NULL, null=True, blank=True, related_name="parcels")
flrstck_nnr = models.CharField(
max_length=1000,
help_text="Flurstücksnenner",
null=True,
blank=True,
help_text="Flurstücksnenner"
)
flrstck_zhlr = models.CharField(
max_length=1000,
help_text="Flurstückszähler",
null=True,
blank=True,
help_text="Flurstückszähler"
)
flr = models.CharField(
max_length=1000,
help_text="Flur",
null=True,
blank=True,
help_text="Flur"
)
gmrkng = models.CharField(
max_length=1000,
help_text="Gemarkung"
)
updated_on = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.flr} | {self.flrstck_nnr} | {self.flrstck_zhlr}"
return f"{self.gmrkng} | {self.flr} | {self.flrstck_nnr} | {self.flrstck_zhlr}"
class District(UuidModel):
@ -56,24 +54,18 @@ class District(UuidModel):
District.
"""
gmrkng = models.CharField(
max_length=1000,
help_text="Gemarkung",
null=True,
blank=True,
)
gmnd = models.CharField(
max_length=1000,
help_text="Gemeinde",
null=True,
blank=True,
help_text="Gemeinde"
)
vg = models.CharField(
max_length=1000,
help_text="Verbandsgemeinde",
)
krs = models.CharField(
max_length=1000,
help_text="Kreis",
null=True,
blank=True,
help_text="Kreis"
)
def __str__(self):
return f"{self.gmrkng} | {self.gmnd} | {self.krs}"
return f"{self.krs} | {self.vg} | {self.gmnd}"

View File

@ -80,8 +80,3 @@ 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"

View File

@ -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 konova.utils.wfs.spatial import ParcelWFSFetcher
from konova.utils.wfs.spatial import SpatialWFSFetcher
class GeometryTestCase(BaseTestCase):
@ -24,22 +24,6 @@ class GeometryTestCase(BaseTestCase):
geom=geom,
)
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.
@ -63,17 +47,19 @@ class GeometryTestCase(BaseTestCase):
self.assertEqual(0, num_conflict)
def test_wfs_fetch(self):
""" Tests the fetching functionality of ParcelWFSFetcher
""" Tests the fetching functionality of SpatialWFSFetcher
+++ Test relies on the availability of the RLP Flurstück WFS +++
+++ Test relies on the availability of the RLP Gemarkung WFS +++
Returns:
"""
fetcher = ParcelWFSFetcher(
geometry_id=self.geom_1.id,
fetcher = SpatialWFSFetcher(
base_url="http://geo5.service24.rlp.de/wfs/verwaltungsgrenzen_rp.fcgi",
version="1.1.0",
geometry=self.geom_1
)
features = fetcher.get_features(
"ave:Flurstueck",
"vermkv:fluren_rlp"
)
self.assertNotEqual(0, len(features), msg="Spatial wfs get feature did not work!")

View File

@ -214,7 +214,7 @@ class BaseTestCase(TestCase):
Returns:
"""
polygon = Polygon.from_bbox((7.592449, 50.359385, 7.593382, 50.359874))
polygon = Polygon.from_bbox((7.157593, 49.882247, 7.816772, 50.266521))
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

View File

@ -10,167 +10,83 @@ from abc import abstractmethod
import requests
import xmltodict
from django.contrib.gis.db.models.functions import AsGML, Transform
from requests.auth import HTTPDigestAuth
from django.contrib.gis.geos import MultiPolygon
from owslib.wfs import WebFeatureService
from konova.settings import DEFAULT_SRID_RLP, PARCEL_WFS_USER, PARCEL_WFS_PW
from konova.models import Geometry
from konova.settings import DEFAULT_SRID_RLP
class AbstractWFSFetcher:
""" Base class for fetching WFS data
"""
class BaseWFSFetcher:
# base_url represents not the capabilities url but the parameter-free base url
base_url = None
version = None
base_url = ""
version = ""
wfs = 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):
def __init__(self, base_url: str, version: str = "1.1.0", *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
)
self.wfs = WebFeatureService(
url=base_url,
version=version,
)
@abstractmethod
def get_features(self, feature_identifier: str, filter_str: str):
def get_features(self, feature_identifier: str, filter: str):
raise NotImplementedError
class ParcelWFSFetcher(AbstractWFSFetcher):
""" Fetches features from a special parcel WFS
class SpatialWFSFetcher(BaseWFSFetcher):
""" Fetches features from a parcel WFS
"""
geometry_id = None
geometry_property_name = None
count = 100
geometry = None
geometry_property_name = ""
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
def __init__(self, geometry: MultiPolygon, geometry_property_name: str = "msGeometry", *args, **kwargs):
super().__init__(*args, **kwargs)
self.geometry = geometry
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
def _create_geometry_filter(self, geometry_operation: str, filter_srid: str = None):
""" Creates an
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
geometry_operation ():
filter_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
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>'
_filter = f"<Filter><{geometry_operation}><PropertyName>{self.geometry_property_name}</PropertyName>{geom_gml}</{geometry_operation}></Filter>"
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
def get_features(self, typenames: str, geometry_operation: str = "Intersects", filter_srid: str = None, filter: str = None):
if filter is None:
filter = self._create_geometry_filter(geometry_operation, filter_srid)
response = requests.post(
url=f"{self.base_url}?request=GetFeature&service=WFS&version={self.version}&typenames={typenames}&count=10",
data={
"filter": filter,
}
)
content = response.content.decode("utf-8")
content = xmltodict.parse(content)
features = content.get(
"wfs:FeatureCollection",
{},
).get(
"gml:featureMember",
[],
)
return features