#50 Overlaying geometries
* refactors geometry field into GeoReferencedMixin, holding more handy methods and used in all models, formerly holding the geometry field * refactors backend admin configuration, so modified, deleted and created are not editable in the backend which also skips loading of all possible choices * fixes typo in sanitize_db command * introduces GeometryConflict model, holding a link between two geometries, where one overlaps the other * adds first (WIP) messages into detail views of ema and intervention for test purposes
This commit is contained in:
		
							parent
							
								
									262f85e0e0
								
							
						
					
					
						commit
						f4541abf20
					
				@ -2,6 +2,7 @@ from django.contrib import admin
 | 
			
		||||
 | 
			
		||||
from compensation.models import Compensation, CompensationAction, CompensationState, Payment, \
 | 
			
		||||
    EcoAccountDeduction, EcoAccount
 | 
			
		||||
from konova.admin import BaseObjectAdmin
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CompensationStateAdmin(admin.ModelAdmin):
 | 
			
		||||
@ -22,7 +23,7 @@ class CompensationActionAdmin(admin.ModelAdmin):
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CompensationAdmin(admin.ModelAdmin):
 | 
			
		||||
class CompensationAdmin(BaseObjectAdmin):
 | 
			
		||||
    list_display = [
 | 
			
		||||
        "id",
 | 
			
		||||
        "identifier",
 | 
			
		||||
 | 
			
		||||
@ -16,12 +16,13 @@ from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from compensation.managers import CompensationManager
 | 
			
		||||
from compensation.models import CompensationState, CompensationAction
 | 
			
		||||
from compensation.utils.quality import CompensationQualityChecker
 | 
			
		||||
from konova.models import BaseObject, AbstractDocument, Geometry, Deadline, generate_document_file_upload_path
 | 
			
		||||
from konova.models import BaseObject, AbstractDocument, Deadline, generate_document_file_upload_path, \
 | 
			
		||||
    GeoReferencedMixin
 | 
			
		||||
from konova.settings import DEFAULT_SRID_RLP, LANIS_LINK_TEMPLATE
 | 
			
		||||
from user.models import UserActionLogEntry
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AbstractCompensation(BaseObject):
 | 
			
		||||
class AbstractCompensation(BaseObject, GeoReferencedMixin):
 | 
			
		||||
    """
 | 
			
		||||
    Abstract compensation model which holds basic attributes, shared by subclasses like the regular Compensation,
 | 
			
		||||
    EMA or EcoAccount.
 | 
			
		||||
@ -41,8 +42,6 @@ class AbstractCompensation(BaseObject):
 | 
			
		||||
 | 
			
		||||
    deadlines = models.ManyToManyField("konova.Deadline", blank=True, related_name="+")
 | 
			
		||||
 | 
			
		||||
    geometry = models.ForeignKey(Geometry, null=True, blank=True, on_delete=models.SET_NULL)
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        abstract = True
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -7,15 +7,17 @@ Created on: 15.11.21
 | 
			
		||||
"""
 | 
			
		||||
import shutil
 | 
			
		||||
 | 
			
		||||
from django.contrib.auth.models import User
 | 
			
		||||
from django.contrib import messages
 | 
			
		||||
from django.db import models
 | 
			
		||||
from django.db.models import QuerySet
 | 
			
		||||
from django.http import HttpRequest
 | 
			
		||||
 | 
			
		||||
from compensation.models import AbstractCompensation
 | 
			
		||||
from ema.managers import EmaManager
 | 
			
		||||
from ema.utils.quality import EmaQualityChecker
 | 
			
		||||
from konova.models import AbstractDocument, generate_document_file_upload_path, RecordableObjectMixin, ShareableObjectMixin
 | 
			
		||||
from konova.settings import DEFAULT_SRID_RLP, LANIS_LINK_TEMPLATE
 | 
			
		||||
from konova.utils.message_templates import DATA_UNSHARED_EXPLANATION
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Ema(AbstractCompensation, ShareableObjectMixin, RecordableObjectMixin):
 | 
			
		||||
@ -91,6 +93,13 @@ class Ema(AbstractCompensation, ShareableObjectMixin, RecordableObjectMixin):
 | 
			
		||||
        )
 | 
			
		||||
        return docs
 | 
			
		||||
 | 
			
		||||
    def set_status_messages(self, request: HttpRequest):
 | 
			
		||||
        if not self.is_shared_with(request.user):
 | 
			
		||||
            messages.info(request, DATA_UNSHARED_EXPLANATION)
 | 
			
		||||
        self._set_overlapped_by_message(request)
 | 
			
		||||
        self._set_overlapping_message(request)
 | 
			
		||||
        return request
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class EmaDocument(AbstractDocument):
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
@ -138,8 +138,7 @@ def detail_view(request: HttpRequest, id: str):
 | 
			
		||||
    sum_after_states = after_states.aggregate(Sum("surface"))["surface__sum"] or 0
 | 
			
		||||
    diff_states = abs(sum_before_states - sum_after_states)
 | 
			
		||||
 | 
			
		||||
    if not is_data_shared:
 | 
			
		||||
        messages.info(request, DATA_UNSHARED_EXPLANATION)
 | 
			
		||||
    ema.set_status_messages(request)
 | 
			
		||||
 | 
			
		||||
    context = {
 | 
			
		||||
        "obj": ema,
 | 
			
		||||
 | 
			
		||||
@ -1,10 +1,10 @@
 | 
			
		||||
from django.contrib import admin
 | 
			
		||||
 | 
			
		||||
from intervention.models import Intervention, Responsibility, Legal, Revocation, InterventionDocument
 | 
			
		||||
from konova.admin import AbstractDocumentAdmin
 | 
			
		||||
from konova.admin import AbstractDocumentAdmin, BaseObjectAdmin
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class InterventionAdmin(admin.ModelAdmin):
 | 
			
		||||
class InterventionAdmin(BaseObjectAdmin):
 | 
			
		||||
    list_display = [
 | 
			
		||||
        "id",
 | 
			
		||||
        "identifier",
 | 
			
		||||
@ -13,9 +13,11 @@ class InterventionAdmin(admin.ModelAdmin):
 | 
			
		||||
        "deleted",
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class InterventionDocumentAdmin(AbstractDocumentAdmin):
 | 
			
		||||
    pass
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ResponsibilityAdmin(admin.ModelAdmin):
 | 
			
		||||
    list_display = [
 | 
			
		||||
        "id",
 | 
			
		||||
 | 
			
		||||
@ -7,6 +7,7 @@ Created on: 15.11.21
 | 
			
		||||
"""
 | 
			
		||||
import shutil
 | 
			
		||||
 | 
			
		||||
from django.contrib import messages
 | 
			
		||||
from django.contrib.auth.models import User
 | 
			
		||||
from django.db import models, transaction
 | 
			
		||||
from django.db.models import QuerySet
 | 
			
		||||
@ -18,13 +19,15 @@ from intervention.models.legal import Legal
 | 
			
		||||
from intervention.models.responsibility import Responsibility
 | 
			
		||||
from intervention.models.revocation import RevocationDocument, Revocation
 | 
			
		||||
from intervention.utils.quality import InterventionQualityChecker
 | 
			
		||||
from konova.models import generate_document_file_upload_path, AbstractDocument, Geometry, BaseObject, ShareableObjectMixin, \
 | 
			
		||||
    RecordableObjectMixin, CheckableObjectMixin
 | 
			
		||||
from konova.models import generate_document_file_upload_path, AbstractDocument, BaseObject, \
 | 
			
		||||
    ShareableObjectMixin, \
 | 
			
		||||
    RecordableObjectMixin, CheckableObjectMixin, GeoReferencedMixin
 | 
			
		||||
from konova.settings import LANIS_LINK_TEMPLATE, LANIS_ZOOM_LUT, DEFAULT_SRID_RLP
 | 
			
		||||
from konova.utils.message_templates import DATA_UNSHARED_EXPLANATION
 | 
			
		||||
from user.models import UserActionLogEntry
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Intervention(BaseObject, ShareableObjectMixin, RecordableObjectMixin, CheckableObjectMixin):
 | 
			
		||||
class Intervention(BaseObject, ShareableObjectMixin, RecordableObjectMixin, CheckableObjectMixin, GeoReferencedMixin):
 | 
			
		||||
    """
 | 
			
		||||
    Interventions are e.g. construction sites where nature used to be.
 | 
			
		||||
    """
 | 
			
		||||
@ -42,7 +45,6 @@ class Intervention(BaseObject, ShareableObjectMixin, RecordableObjectMixin, Chec
 | 
			
		||||
        blank=True,
 | 
			
		||||
        help_text="Holds data on legal dates or law"
 | 
			
		||||
    )
 | 
			
		||||
    geometry = models.ForeignKey(Geometry, null=True, blank=True, on_delete=models.SET_NULL)
 | 
			
		||||
 | 
			
		||||
    objects = InterventionManager()
 | 
			
		||||
 | 
			
		||||
@ -263,6 +265,13 @@ class Intervention(BaseObject, ShareableObjectMixin, RecordableObjectMixin, Chec
 | 
			
		||||
        if self.checked:
 | 
			
		||||
            self.set_unchecked()
 | 
			
		||||
 | 
			
		||||
    def set_status_messages(self, request: HttpRequest):
 | 
			
		||||
        if not self.is_shared_with(request.user):
 | 
			
		||||
            messages.info(request, DATA_UNSHARED_EXPLANATION)
 | 
			
		||||
        request = self._set_overlapping_message(request)
 | 
			
		||||
        request = self._set_overlapped_by_message(request)
 | 
			
		||||
        return request
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class InterventionDocument(AbstractDocument):
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
@ -255,8 +255,7 @@ def detail_view(request: HttpRequest, id: str):
 | 
			
		||||
        "LANIS_LINK": intervention.get_LANIS_link()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if not is_data_shared:
 | 
			
		||||
        messages.info(request, DATA_UNSHARED_EXPLANATION)
 | 
			
		||||
    request = intervention.set_status_messages(request)
 | 
			
		||||
 | 
			
		||||
    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
 | 
			
		||||
from konova.models import Geometry, Deadline, GeometryConflict
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GeometryAdmin(admin.ModelAdmin):
 | 
			
		||||
@ -17,6 +17,14 @@ class GeometryAdmin(admin.ModelAdmin):
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GeometryConflictAdmin(admin.ModelAdmin):
 | 
			
		||||
    list_display = [
 | 
			
		||||
        "conflicting_geometry",
 | 
			
		||||
        "existing_geometry",
 | 
			
		||||
        "detected_on",
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AbstractDocumentAdmin(admin.ModelAdmin):
 | 
			
		||||
    list_display = [
 | 
			
		||||
        "id",
 | 
			
		||||
@ -35,5 +43,14 @@ class DeadlineAdmin(admin.ModelAdmin):
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class BaseObjectAdmin(admin.ModelAdmin):
 | 
			
		||||
    readonly_fields = [
 | 
			
		||||
        "modified",
 | 
			
		||||
        "deleted",
 | 
			
		||||
        "created",
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
admin.site.register(Geometry, GeometryAdmin)
 | 
			
		||||
admin.site.register(GeometryConflict, GeometryConflictAdmin)
 | 
			
		||||
admin.site.register(Deadline, DeadlineAdmin)
 | 
			
		||||
 | 
			
		||||
@ -208,7 +208,7 @@ class Command(BaseKonovaCommand):
 | 
			
		||||
        if num_entries > 0:
 | 
			
		||||
            self._write_error(f"Found {num_entries} geometries not attached to anything. Delete now...")
 | 
			
		||||
            unattached_geometries.delete()
 | 
			
		||||
            self._write_success("Deadlines deleted.")
 | 
			
		||||
            self._write_success("Geometries deleted.")
 | 
			
		||||
        else:
 | 
			
		||||
            self._write_success("No unattached geometries found.")
 | 
			
		||||
        self._break_line()
 | 
			
		||||
 | 
			
		||||
@ -6,13 +6,93 @@ Created on: 15.11.21
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from django.contrib.gis.db.models import MultiPolygonField
 | 
			
		||||
from django.db import models
 | 
			
		||||
 | 
			
		||||
from konova.models import BaseResource
 | 
			
		||||
from konova.models import BaseResource, UuidModel
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Geometry(BaseResource):
 | 
			
		||||
    """
 | 
			
		||||
    Outsourced geometry model so multiple versions of the same object can refer to the same geometry if it is not changed
 | 
			
		||||
    Geometry model
 | 
			
		||||
    """
 | 
			
		||||
    from konova.settings import DEFAULT_SRID
 | 
			
		||||
    geom = MultiPolygonField(null=True, blank=True, srid=DEFAULT_SRID)
 | 
			
		||||
    geom = MultiPolygonField(null=True, blank=True, srid=DEFAULT_SRID)
 | 
			
		||||
 | 
			
		||||
    def save(self, *args, **kwargs):
 | 
			
		||||
        super().save(*args, **kwargs)
 | 
			
		||||
        self.check_for_conflicts()
 | 
			
		||||
 | 
			
		||||
    def check_for_conflicts(self):
 | 
			
		||||
        """ Checks for geometry overlaps
 | 
			
		||||
 | 
			
		||||
        Creates a new GeometryConflict entry for each overlap to another geometry, which has already been there before
 | 
			
		||||
 | 
			
		||||
        Returns:
 | 
			
		||||
 | 
			
		||||
        """
 | 
			
		||||
        # 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):
 | 
			
		||||
            return None
 | 
			
		||||
 | 
			
		||||
        check_timestamp_obj = self.modified or self.created
 | 
			
		||||
        ts = check_timestamp_obj.timestamp
 | 
			
		||||
        overlapping_geoms = Geometry.objects.filter(
 | 
			
		||||
            modified__timestamp__lte=ts,
 | 
			
		||||
            geom__overlaps=self.geom,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # Drop known conflicts for this object to replace with new ones
 | 
			
		||||
        self.conflicts_geometries.all().delete()
 | 
			
		||||
        for match in overlapping_geoms:
 | 
			
		||||
            GeometryConflict.objects.get_or_create(conflicting_geometry=self, existing_geometry=match)
 | 
			
		||||
 | 
			
		||||
        # Rerun the conflict check for all conflicts where this object is not the cause but the one that already existed.
 | 
			
		||||
        # It may be possible that this object has been edited, so the conflicts would be resolved in the newer entries.
 | 
			
		||||
        existing_conflicts = self.conflicted_by_geometries.all()
 | 
			
		||||
        for conflict in existing_conflicts:
 | 
			
		||||
            conflicting_geom = conflict.conflicting_geometry
 | 
			
		||||
            conflicting_geom.check_for_conflicts()
 | 
			
		||||
 | 
			
		||||
    def get_data_objects(self):
 | 
			
		||||
        """ Getter for all objects which are related to this geometry
 | 
			
		||||
 | 
			
		||||
        Returns:
 | 
			
		||||
            objs (list): The list of objects
 | 
			
		||||
        """
 | 
			
		||||
        objs = []
 | 
			
		||||
        sets = [
 | 
			
		||||
            self.intervention_set,
 | 
			
		||||
            self.compensation_set,
 | 
			
		||||
            self.ema_set,
 | 
			
		||||
            self.ecoaccount_set,
 | 
			
		||||
        ]
 | 
			
		||||
        for _set in sets:
 | 
			
		||||
            set_objs = _set.filter(
 | 
			
		||||
                deleted=None
 | 
			
		||||
            )
 | 
			
		||||
            objs += set_objs
 | 
			
		||||
        return objs
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GeometryConflict(UuidModel):
 | 
			
		||||
    """
 | 
			
		||||
    Geometry conflicts model
 | 
			
		||||
 | 
			
		||||
    If a new/edited geometry overlays an existing geometry, there will be a new GeometryConflict on the db
 | 
			
		||||
    """
 | 
			
		||||
    conflicting_geometry = models.ForeignKey(
 | 
			
		||||
        Geometry,
 | 
			
		||||
        on_delete=models.CASCADE,
 | 
			
		||||
        help_text="The geometry which came second",
 | 
			
		||||
        related_name="conflicts_geometries"
 | 
			
		||||
    )
 | 
			
		||||
    existing_geometry = models.ForeignKey(
 | 
			
		||||
        Geometry,
 | 
			
		||||
        on_delete=models.CASCADE,
 | 
			
		||||
        help_text="The geometry which came first",
 | 
			
		||||
        related_name="conflicted_by_geometries"
 | 
			
		||||
    )
 | 
			
		||||
    detected_on = models.DateTimeField(auto_now_add=True, null=True)
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return f"{self.conflicting_geometry.id} conflicts with {self.existing_geometry.id}"
 | 
			
		||||
 | 
			
		||||
@ -7,6 +7,7 @@ Created on: 15.11.21
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
import uuid
 | 
			
		||||
from abc import abstractmethod
 | 
			
		||||
 | 
			
		||||
from django.contrib import messages
 | 
			
		||||
from django.contrib.auth.models import User
 | 
			
		||||
@ -20,7 +21,8 @@ from ema.settings import EMA_ACCOUNT_IDENTIFIER_LENGTH, EMA_ACCOUNT_IDENTIFIER_T
 | 
			
		||||
from intervention.settings import INTERVENTION_IDENTIFIER_LENGTH, INTERVENTION_IDENTIFIER_TEMPLATE
 | 
			
		||||
from konova.utils import generators
 | 
			
		||||
from konova.utils.generators import generate_random_string
 | 
			
		||||
from konova.utils.message_templates import CHECKED_RECORDED_RESET
 | 
			
		||||
from konova.utils.message_templates import CHECKED_RECORDED_RESET, GEOMETRY_OVERLAPS_WITH_TEMPLATE, \
 | 
			
		||||
    GEOMETRY_OVERLAPPED_BY_TEMPLATE
 | 
			
		||||
from user.models import UserActionLogEntry, UserAction
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -94,6 +96,10 @@ class BaseObject(BaseResource):
 | 
			
		||||
    class Meta:
 | 
			
		||||
        abstract = True
 | 
			
		||||
 | 
			
		||||
    @abstractmethod
 | 
			
		||||
    def set_status_messages(self, request: HttpRequest):
 | 
			
		||||
        raise NotImplementedError
 | 
			
		||||
 | 
			
		||||
    def mark_as_deleted(self, user: User):
 | 
			
		||||
        """ Mark an entry as deleted
 | 
			
		||||
 | 
			
		||||
@ -407,3 +413,34 @@ class ShareableObjectMixin(models.Model):
 | 
			
		||||
            id__in=accessing_users
 | 
			
		||||
        )
 | 
			
		||||
        self.share_with_list(users)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GeoReferencedMixin(models.Model):
 | 
			
		||||
    geometry = models.ForeignKey("konova.Geometry", null=True, blank=True, on_delete=models.SET_NULL)
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        abstract = True
 | 
			
		||||
 | 
			
		||||
    def _set_overlapping_message(self, request: HttpRequest):
 | 
			
		||||
        geom_conflicts = self.geometry.conflicts_geometries.all()
 | 
			
		||||
        if geom_conflicts:
 | 
			
		||||
            data_objs = []
 | 
			
		||||
            for conflict in geom_conflicts:
 | 
			
		||||
                data_objs += conflict.existing_geometry.get_data_objects()
 | 
			
		||||
            data_identifiers = [x.identifier for x in data_objs]
 | 
			
		||||
            data_identifiers = ", ".join(data_identifiers)
 | 
			
		||||
            message_str = GEOMETRY_OVERLAPS_WITH_TEMPLATE.format(data_identifiers)
 | 
			
		||||
            messages.info(request, message_str)
 | 
			
		||||
        return request
 | 
			
		||||
 | 
			
		||||
    def _set_overlapped_by_message(self, request: HttpRequest):
 | 
			
		||||
        geom_conflicts = self.geometry.conflicted_by_geometries.all()
 | 
			
		||||
        if geom_conflicts:
 | 
			
		||||
            data_objs = []
 | 
			
		||||
            for conflict in geom_conflicts:
 | 
			
		||||
                data_objs += conflict.conflicting_geometry.get_data_objects()
 | 
			
		||||
            data_identifiers = [x.identifier for x in data_objs]
 | 
			
		||||
            data_identifiers = ", ".join(data_identifiers)
 | 
			
		||||
            message_str = GEOMETRY_OVERLAPPED_BY_TEMPLATE.format(data_identifiers)
 | 
			
		||||
            messages.info(request, message_str)
 | 
			
		||||
        return request
 | 
			
		||||
@ -25,4 +25,8 @@ CANCEL_ACC_RECORDED_OR_DEDUCTED = _("Action canceled. Eco account is recorded or
 | 
			
		||||
EDITED_GENERAL_DATA = _("Edited general data")
 | 
			
		||||
ADDED_COMPENSATION_STATE = _("Added compensation state")
 | 
			
		||||
ADDED_DEADLINE = _("Added deadline")
 | 
			
		||||
ADDED_COMPENSATION_ACTION = _("Added compensation action")
 | 
			
		||||
ADDED_COMPENSATION_ACTION = _("Added compensation action")
 | 
			
		||||
 | 
			
		||||
# Geometry conflicts
 | 
			
		||||
GEOMETRY_OVERLAPS_WITH_TEMPLATE = _("Geometry overlaps {}")
 | 
			
		||||
GEOMETRY_OVERLAPPED_BY_TEMPLATE = _("Geometry overlapped by {}")
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user