""" 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 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 from konova.settings import DEFAULT_SRID from konova.tasks import celery_update_parcels from konova.utils.message_templates import FORM_INVALID 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): """ 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) 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 NewDocumentForm(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) mime_type_valid = self.document_model.is_mime_type_valid(_file) if not mime_type_valid: self.add_error( "file", _("Unsupported file type") ) file_size_valid = self.document_model.is_file_size_valid(_file) if not file_size_valid: self.add_error( "file", _("File 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 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.all() 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