600 lines
20 KiB
Python
600 lines
20 KiB
Python
"""
|
|
Author: Michel Peltriaux
|
|
Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany
|
|
Contact: michel.peltriaux@sgdnord.rlp.de
|
|
Created on: 16.11.20
|
|
|
|
"""
|
|
|
|
from abc import abstractmethod
|
|
|
|
from bootstrap_modal_forms.forms import BSModalForm
|
|
from bootstrap_modal_forms.utils import is_ajax
|
|
from django import forms
|
|
from django.contrib import messages
|
|
from django.db.models.fields.files import FieldFile
|
|
|
|
from user.models import User
|
|
from django.contrib.gis.forms import OSMWidget, MultiPolygonField
|
|
from django.contrib.gis.geos import MultiPolygon
|
|
from django.db import transaction
|
|
from django.http import HttpRequest, HttpResponseRedirect
|
|
from django.shortcuts import render
|
|
from django.utils.translation import gettext_lazy as _
|
|
|
|
from konova.contexts import BaseContext
|
|
from konova.models import BaseObject, Geometry, RecordableObjectMixin, AbstractDocument
|
|
from konova.settings import DEFAULT_SRID
|
|
from konova.tasks import celery_update_parcels
|
|
from konova.utils.message_templates import FORM_INVALID, FILE_TYPE_UNSUPPORTED, FILE_SIZE_TOO_LARGE, DOCUMENT_EDITED
|
|
from user.models import UserActionLogEntry
|
|
|
|
|
|
class BaseForm(forms.Form):
|
|
"""
|
|
Basic form for that holds attributes needed in all other forms
|
|
"""
|
|
template = None
|
|
action_url = None
|
|
action_btn_label = _("Save")
|
|
form_title = None
|
|
cancel_redirect = None
|
|
form_caption = None
|
|
instance = None # The data holding model object
|
|
request = None
|
|
form_attrs = {} # Holds additional attributes, that can be used in the template
|
|
has_required_fields = False # Automatically set. Triggers hint rendering in templates
|
|
show_cancel_btn = True
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
self.instance = kwargs.pop("instance", None)
|
|
super().__init__(*args, **kwargs)
|
|
if self.request is not None:
|
|
self.user = self.request.user
|
|
# Check for required fields
|
|
for _field_name, _field_val in self.fields.items():
|
|
if _field_val.required:
|
|
self.has_required_fields = True
|
|
break
|
|
|
|
@abstractmethod
|
|
def save(self):
|
|
# To be implemented in subclasses!
|
|
pass
|
|
|
|
def disable_form_field(self, field: str):
|
|
"""
|
|
Disables a form field for user editing
|
|
"""
|
|
self.fields[field].widget.attrs["readonly"] = True
|
|
self.fields[field].disabled = True
|
|
self.fields[field].widget.attrs["title"] = _("Not editable")
|
|
|
|
def initialize_form_field(self, field: str, val):
|
|
"""
|
|
Initializes a form field with a value
|
|
"""
|
|
self.fields[field].initial = val
|
|
|
|
def add_placeholder_for_field(self, field: str, val):
|
|
"""
|
|
Adds a placeholder to a field after initialization without the need to redefine the form widget
|
|
|
|
Args:
|
|
field (str): Field name
|
|
val (str): Placeholder
|
|
|
|
Returns:
|
|
|
|
"""
|
|
self.fields[field].widget.attrs["placeholder"] = val
|
|
|
|
def load_initial_data(self, form_data: dict, disabled_fields: list = None):
|
|
""" Initializes form data from instance
|
|
|
|
Inserts instance data into form and disables form fields
|
|
|
|
Returns:
|
|
|
|
"""
|
|
if self.instance is None:
|
|
return
|
|
for k, v in form_data.items():
|
|
self.initialize_form_field(k, v)
|
|
if disabled_fields:
|
|
for field in disabled_fields:
|
|
self.disable_form_field(field)
|
|
|
|
def add_widget_html_class(self, field: str, cls: str):
|
|
""" Adds a HTML class string to the widget of a field
|
|
|
|
Args:
|
|
field (str): The field's name
|
|
cls (str): The new class string
|
|
|
|
Returns:
|
|
|
|
"""
|
|
set_class = self.fields[field].widget.attrs.get("class", "")
|
|
if cls in set_class:
|
|
return
|
|
else:
|
|
set_class += " " + cls
|
|
self.fields[field].widget.attrs["class"] = set_class
|
|
|
|
def remove_widget_html_class(self, field: str, cls: str):
|
|
""" Removes a HTML class string from the widget of a field
|
|
|
|
Args:
|
|
field (str): The field's name
|
|
cls (str): The new class string
|
|
|
|
Returns:
|
|
|
|
"""
|
|
set_class = self.fields[field].widget.attrs.get("class", "")
|
|
set_class = set_class.replace(cls, "")
|
|
self.fields[field].widget.attrs["class"] = set_class
|
|
|
|
|
|
class RemoveForm(BaseForm):
|
|
check = forms.BooleanField(
|
|
label=_("Confirm"),
|
|
label_suffix=_(""),
|
|
required=True,
|
|
)
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
self.object_to_remove = kwargs.pop("object_to_remove", None)
|
|
self.remove_post_url = kwargs.pop("remove_post_url", "")
|
|
self.cancel_url = kwargs.pop("cancel_url", "")
|
|
|
|
super().__init__(*args, **kwargs)
|
|
|
|
self.form_title = _("Remove")
|
|
if self.object_to_remove is not None:
|
|
self.form_caption = _("You are about to remove {} {}").format(self.object_to_remove.__class__.__name__, self.object_to_remove)
|
|
self.action_url = self.remove_post_url
|
|
self.cancel_redirect = self.cancel_url
|
|
|
|
def is_checked(self) -> bool:
|
|
return self.cleaned_data.get("check", False)
|
|
|
|
def save(self, user: User):
|
|
""" Perform generic removing by running the form typical 'save()' method
|
|
|
|
Args:
|
|
user (User): The performing user
|
|
|
|
Returns:
|
|
|
|
"""
|
|
if self.object_to_remove is not None and self.is_checked():
|
|
with transaction.atomic():
|
|
self.object_to_remove.is_active = False
|
|
action = UserActionLogEntry.get_deleted_action(user)
|
|
self.object_to_remove.deleted = action
|
|
self.object_to_remove.save()
|
|
return self.object_to_remove
|
|
|
|
|
|
class BaseModalForm(BaseForm, BSModalForm):
|
|
""" A specialzed form class for modal form handling
|
|
|
|
"""
|
|
is_modal_form = True
|
|
render_submit = True
|
|
template = "modal/modal_form.html"
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self.action_btn_label = _("Continue")
|
|
|
|
def process_request(self, request: HttpRequest, msg_success: str = _("Object removed"), msg_error: str = FORM_INVALID, redirect_url: str = None):
|
|
""" Generic processing of request
|
|
|
|
Wraps the request processing logic, so we don't need the same code everywhere a RemoveModalForm is being used
|
|
|
|
Args:
|
|
request (HttpRequest): The incoming request
|
|
msg_success (str): The message in case of successful removing
|
|
msg_error (str): The message in case of an error
|
|
|
|
Returns:
|
|
|
|
"""
|
|
redirect_url = redirect_url if redirect_url is not None else request.META.get("HTTP_REFERER", "home")
|
|
template = self.template
|
|
if request.method == "POST":
|
|
if self.is_valid():
|
|
if not is_ajax(request.META):
|
|
# Modal forms send one POST for checking on data validity. This can be used to return possible errors
|
|
# on the form. A second POST (if no errors occured) is sent afterwards and needs to process the
|
|
# saving/commiting of the data to the database. is_ajax() performs this check. The first request is
|
|
# an ajax call, the second is a regular form POST.
|
|
self.save()
|
|
messages.success(
|
|
request,
|
|
msg_success
|
|
)
|
|
return HttpResponseRedirect(redirect_url)
|
|
else:
|
|
context = {
|
|
"form": self,
|
|
}
|
|
context = BaseContext(request, context).context
|
|
return render(request, template, context)
|
|
elif request.method == "GET":
|
|
context = {
|
|
"form": self,
|
|
}
|
|
context = BaseContext(request, context).context
|
|
return render(request, template, context)
|
|
else:
|
|
raise NotImplementedError
|
|
|
|
|
|
class SimpleGeomForm(BaseForm):
|
|
""" A geometry form for rendering geometry read-only using a widget
|
|
|
|
"""
|
|
geom = MultiPolygonField(
|
|
srid=DEFAULT_SRID,
|
|
label=_("Geometry"),
|
|
help_text=_(""),
|
|
label_suffix="",
|
|
required=False,
|
|
disabled=False,
|
|
widget=OSMWidget(
|
|
attrs={
|
|
"map_width": 600,
|
|
"map_height": 400,
|
|
# default_zoom defines the nearest possible zoom level from which the JS automatically
|
|
# zooms out if geometry requires a larger view port. So define a larger range for smaller geometries
|
|
"default_zoom": 25,
|
|
}
|
|
)
|
|
)
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
read_only = kwargs.pop("read_only", True)
|
|
super().__init__(*args, **kwargs)
|
|
|
|
# Initialize geometry
|
|
try:
|
|
geom = self.instance.geometry.geom
|
|
self.empty = geom.empty
|
|
except AttributeError:
|
|
# If no geometry exists for this form, we simply set the value to None and zoom to the maximum level
|
|
geom = None
|
|
self.empty = True
|
|
self.fields["geom"].widget.attrs["default_zoom"] = 1
|
|
|
|
self.initialize_form_field("geom", geom)
|
|
if read_only:
|
|
self.fields["geom"].disabled = True
|
|
|
|
def save(self, action: UserActionLogEntry):
|
|
""" Saves the form's geometry
|
|
|
|
Creates a new geometry entry if none is set, yet
|
|
|
|
Args:
|
|
action ():
|
|
|
|
Returns:
|
|
|
|
"""
|
|
try:
|
|
if self.instance is None or self.instance.geometry is None:
|
|
raise LookupError
|
|
geometry = self.instance.geometry
|
|
geometry.geom = self.cleaned_data.get("geom", MultiPolygon(srid=DEFAULT_SRID))
|
|
geometry.modified = action
|
|
|
|
geometry.save()
|
|
except LookupError:
|
|
# No geometry or linked instance holding a geometry exist --> create a new one!
|
|
geometry = Geometry.objects.create(
|
|
geom=self.cleaned_data.get("geom", MultiPolygon(srid=DEFAULT_SRID)),
|
|
created=action,
|
|
)
|
|
# Start the parcel update procedure in a background process
|
|
celery_update_parcels.delay(geometry.id)
|
|
return geometry
|
|
|
|
|
|
class RemoveModalForm(BaseModalForm):
|
|
""" Generic removing modal form
|
|
|
|
Can be used for anything, where removing shall be confirmed by the user a second time.
|
|
|
|
"""
|
|
confirm = forms.BooleanField(
|
|
label=_("Confirm"),
|
|
label_suffix=_(""),
|
|
widget=forms.CheckboxInput(),
|
|
required=True,
|
|
)
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
self.template = "modal/modal_form.html"
|
|
super().__init__(*args, **kwargs)
|
|
self.form_title = _("Remove")
|
|
self.form_caption = _("Are you sure?")
|
|
# Disable automatic w-100 setting for this type of modal form. Looks kinda strange
|
|
self.fields["confirm"].widget.attrs["class"] = ""
|
|
|
|
def save(self):
|
|
if isinstance(self.instance, BaseObject):
|
|
self.instance.mark_as_deleted(self.user)
|
|
else:
|
|
# If the class does not provide restorable delete functionality, we must delete the entry finally
|
|
self.instance.delete()
|
|
|
|
|
|
class RemoveDeadlineModalForm(RemoveModalForm):
|
|
""" Removing modal form for deadlines
|
|
|
|
Can be used for anything, where removing shall be confirmed by the user a second time.
|
|
|
|
"""
|
|
deadline = None
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
deadline = kwargs.pop("deadline", None)
|
|
self.deadline = deadline
|
|
super().__init__(*args, **kwargs)
|
|
|
|
def save(self):
|
|
self.instance.remove_deadline(self)
|
|
|
|
|
|
class NewDocumentModalForm(BaseModalForm):
|
|
""" Modal form for new documents
|
|
|
|
"""
|
|
title = forms.CharField(
|
|
label=_("Title"),
|
|
label_suffix=_(""),
|
|
max_length=500,
|
|
widget=forms.TextInput(
|
|
attrs={
|
|
"class": "form-control",
|
|
}
|
|
)
|
|
)
|
|
creation_date = forms.DateField(
|
|
label=_("Created on"),
|
|
label_suffix=_(""),
|
|
help_text=_("When has this file been created? Important for photos."),
|
|
widget=forms.DateInput(
|
|
attrs={
|
|
"type": "date",
|
|
"data-provide": "datepicker",
|
|
"class": "form-control",
|
|
},
|
|
format="%d.%m.%Y"
|
|
)
|
|
)
|
|
file = forms.FileField(
|
|
label=_("File"),
|
|
label_suffix=_(""),
|
|
help_text=_("Allowed formats: pdf, jpg, png. Max size 15 MB."),
|
|
widget=forms.FileInput(
|
|
attrs={
|
|
"class": "form-control-file",
|
|
}
|
|
),
|
|
)
|
|
comment = forms.CharField(
|
|
required=False,
|
|
max_length=200,
|
|
label=_("Comment"),
|
|
label_suffix=_(""),
|
|
help_text=_("Additional comment, maximum {} letters").format(200),
|
|
widget=forms.Textarea(
|
|
attrs={
|
|
"cols": 30,
|
|
"rows": 5,
|
|
"class": "form-control",
|
|
}
|
|
)
|
|
)
|
|
document_model = None
|
|
|
|
class Meta:
|
|
abstract = True
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self.form_title = _("Add new document")
|
|
self.form_caption = _("")
|
|
self.template = "modal/modal_form.html"
|
|
self.form_attrs = {
|
|
"enctype": "multipart/form-data", # important for file upload
|
|
}
|
|
if not self.document_model:
|
|
raise NotImplementedError("Unsupported document type for {}".format(self.instance.__class__))
|
|
|
|
def is_valid(self):
|
|
super_valid = super().is_valid()
|
|
|
|
_file = self.cleaned_data.get("file", None)
|
|
|
|
if _file is None or isinstance(_file, FieldFile):
|
|
# FieldFile declares that no new file has been uploaded and we do not need to check on the file again
|
|
return super_valid
|
|
|
|
mime_type_valid = self.document_model.is_mime_type_valid(_file)
|
|
if not mime_type_valid:
|
|
self.add_error(
|
|
"file",
|
|
FILE_TYPE_UNSUPPORTED
|
|
)
|
|
|
|
file_size_valid = self.document_model.is_file_size_valid(_file)
|
|
if not file_size_valid:
|
|
self.add_error(
|
|
"file",
|
|
FILE_SIZE_TOO_LARGE
|
|
)
|
|
|
|
file_valid = mime_type_valid and file_size_valid
|
|
return super_valid and file_valid
|
|
|
|
def save(self):
|
|
with transaction.atomic():
|
|
action = UserActionLogEntry.get_created_action(self.user)
|
|
edited_action = UserActionLogEntry.get_edited_action(self.user, _("Added document"))
|
|
|
|
doc = self.document_model.objects.create(
|
|
created=action,
|
|
title=self.cleaned_data["title"],
|
|
comment=self.cleaned_data["comment"],
|
|
file=self.cleaned_data["file"],
|
|
date_of_creation=self.cleaned_data["creation_date"],
|
|
instance=self.instance,
|
|
)
|
|
|
|
self.instance.log.add(edited_action)
|
|
self.instance.modified = edited_action
|
|
self.instance.save()
|
|
|
|
return doc
|
|
|
|
|
|
class EditDocumentModalForm(NewDocumentModalForm):
|
|
document = None
|
|
document_model = AbstractDocument
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
self.document = kwargs.pop("document", None)
|
|
super().__init__(*args, **kwargs)
|
|
self.form_title = _("Edit document")
|
|
form_data = {
|
|
"title": self.document.title,
|
|
"comment": self.document.comment,
|
|
"creation_date": str(self.document.date_of_creation),
|
|
"file": self.document.file,
|
|
}
|
|
self.load_initial_data(form_data)
|
|
|
|
|
|
def save(self):
|
|
with transaction.atomic():
|
|
document = self.document
|
|
file = self.cleaned_data.get("file", None)
|
|
|
|
document.title = self.cleaned_data.get("title", None)
|
|
document.comment = self.cleaned_data.get("comment", None)
|
|
document.date_of_creation = self.cleaned_data.get("creation_date", None)
|
|
if not isinstance(file, FieldFile):
|
|
document.replace_file(file)
|
|
document.save()
|
|
|
|
self.instance.mark_as_edited(self.user, self.request, edit_comment=DOCUMENT_EDITED)
|
|
|
|
return document
|
|
|
|
|
|
class RecordModalForm(BaseModalForm):
|
|
""" Modal form for recording data
|
|
|
|
"""
|
|
confirm = forms.BooleanField(
|
|
label=_("Confirm record"),
|
|
label_suffix="",
|
|
widget=forms.CheckboxInput(),
|
|
required=True,
|
|
)
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self.form_title = _("Record data")
|
|
self.form_caption = _("I, {} {}, confirm that all necessary control steps have been performed by myself.").format(self.user.first_name, self.user.last_name)
|
|
# Disable automatic w-100 setting for this type of modal form. Looks kinda strange
|
|
self.fields["confirm"].widget.attrs["class"] = ""
|
|
|
|
if self.instance.recorded:
|
|
# unrecord!
|
|
self.fields["confirm"].label = _("Confirm unrecord")
|
|
self.form_title = _("Unrecord data")
|
|
self.form_caption = _("I, {} {}, confirm that this data must be unrecorded.").format(self.user.first_name, self.user.last_name)
|
|
|
|
if not isinstance(self.instance, RecordableObjectMixin):
|
|
raise NotImplementedError
|
|
|
|
def is_valid(self):
|
|
""" Checks for instance's validity and data quality
|
|
|
|
Returns:
|
|
|
|
"""
|
|
from intervention.models import Intervention
|
|
super_val = super().is_valid()
|
|
if self.instance.recorded:
|
|
# If user wants to unrecord an already recorded dataset, we do not need to perform custom checks
|
|
return super_val
|
|
checker = self.instance.quality_check()
|
|
for msg in checker.messages:
|
|
self.add_error(
|
|
"confirm",
|
|
msg
|
|
)
|
|
valid = checker.valid
|
|
# Special case: Intervention
|
|
# Add direct checks for related compensations
|
|
if isinstance(self.instance, Intervention):
|
|
comps_valid = self._are_compensations_valid()
|
|
valid = valid and comps_valid
|
|
return super_val and valid
|
|
|
|
def _are_deductions_valid(self):
|
|
""" Performs validity checks on deductions and their eco-account
|
|
|
|
Returns:
|
|
|
|
"""
|
|
deductions = self.instance.deductions.all()
|
|
for deduction in deductions:
|
|
checker = deduction.account.quality_check()
|
|
for msg in checker.messages:
|
|
self.add_error(
|
|
"confirm",
|
|
f"{deduction.account.identifier}: {msg}"
|
|
)
|
|
return checker.valid
|
|
return True
|
|
|
|
def _are_compensations_valid(self):
|
|
""" Runs a special case for intervention-compensations validity
|
|
|
|
Returns:
|
|
|
|
"""
|
|
comps = self.instance.compensations.filter(
|
|
deleted=None,
|
|
)
|
|
comps_valid = True
|
|
for comp in comps:
|
|
checker = comp.quality_check()
|
|
comps_valid = comps_valid and checker.valid
|
|
for msg in checker.messages:
|
|
self.add_error(
|
|
"confirm",
|
|
f"{comp.identifier}: {msg}"
|
|
)
|
|
|
|
deductions_valid = self._are_deductions_valid()
|
|
|
|
return comps_valid and deductions_valid
|
|
|
|
def save(self):
|
|
with transaction.atomic():
|
|
if self.cleaned_data["confirm"]:
|
|
if self.instance.recorded:
|
|
self.instance.set_unrecorded(self.user)
|
|
else:
|
|
self.instance.set_recorded(self.user)
|
|
return self.instance |