diff --git a/compensation/forms/forms.py b/compensation/forms/forms.py index 212efa63..85e8fd67 100644 --- a/compensation/forms/forms.py +++ b/compensation/forms/forms.py @@ -16,9 +16,9 @@ from codelist.models import KonovaCode from codelist.settings import CODELIST_CONSERVATION_OFFICE_ID from compensation.models import Compensation, EcoAccount from intervention.inputs import GenerateInput -from intervention.models import Intervention, ResponsibilityData, LegalData +from intervention.models import Intervention, Responsibility, Legal from konova.forms import BaseForm, SimpleGeomForm -from user.models import UserActionLogEntry, UserAction +from user.models import UserActionLogEntry class AbstractCompensationForm(BaseForm): @@ -210,10 +210,7 @@ class NewCompensationForm(AbstractCompensationForm, CEFCompensationFormMixin, Co comment = self.cleaned_data.get("comment", None) # Create log entry - action = UserActionLogEntry.objects.create( - user=user, - action=UserAction.CREATED, - ) + action = UserActionLogEntry.get_created_action(user) # Process the geometry form geometry = geom_form.save(action) @@ -270,10 +267,7 @@ class EditCompensationForm(NewCompensationForm): comment = self.cleaned_data.get("comment", None) # Create log entry - action = UserActionLogEntry.objects.create( - user=user, - action=UserAction.EDITED, - ) + action = UserActionLogEntry.get_edited_action(user) # Process the geometry form geometry = geom_form.save(action) @@ -364,20 +358,17 @@ class NewEcoAccountForm(AbstractCompensationForm, CompensationResponsibleFormMix comment = self.cleaned_data.get("comment", None) # Create log entry - action = UserActionLogEntry.objects.create( - user=user, - action=UserAction.CREATED, - ) + action = UserActionLogEntry.get_created_action(user) # Process the geometry form geometry = geom_form.save(action) - responsible = ResponsibilityData.objects.create( + responsible = Responsibility.objects.create( handler=handler, conservation_file_number=conservation_file_number, conservation_office=conservation_office, ) - legal = LegalData.objects.create( + legal = Legal.objects.create( registration_date=registration_date ) @@ -444,10 +435,8 @@ class EditEcoAccountForm(NewEcoAccountForm): comment = self.cleaned_data.get("comment", None) # Create log entry - action = UserActionLogEntry.objects.create( - user=user, - action=UserAction.EDITED, - ) + action = UserActionLogEntry.get_edited_action(user) + # Process the geometry form geometry = geom_form.save(action) diff --git a/compensation/forms/modalForms.py b/compensation/forms/modalForms.py index 2cc10643..999fc7ab 100644 --- a/compensation/forms/modalForms.py +++ b/compensation/forms/modalForms.py @@ -9,19 +9,17 @@ from bootstrap_modal_forms.utils import is_ajax from dal import autocomplete from django import forms from django.contrib import messages -from django.db import transaction from django.http import HttpRequest, HttpResponseRedirect from django.shortcuts import render from django.utils.translation import pgettext_lazy as _con, gettext_lazy as _ from codelist.models import KonovaCode from codelist.settings import CODELIST_BIOTOPES_ID, CODELIST_COMPENSATION_ACTION_ID -from compensation.models import Payment, CompensationState, UnitChoices, CompensationAction +from compensation.models import CompensationDocument, EcoAccountDocument from konova.contexts import BaseContext -from konova.forms import BaseModalForm -from konova.models import DeadlineType, Deadline +from konova.forms import BaseModalForm, NewDocumentForm +from konova.models import DeadlineType from konova.utils.message_templates import FORM_INVALID -from user.models import UserActionLogEntry, UserAction class NewPaymentForm(BaseModalForm): @@ -99,27 +97,8 @@ class NewPaymentForm(BaseModalForm): return super_valid def save(self): - with transaction.atomic(): - created_action = UserActionLogEntry.objects.create( - user=self.user, - action=UserAction.CREATED, - ) - edited_action = UserActionLogEntry.objects.create( - user=self.user, - action=UserAction.EDITED, - comment=_("Added payment"), - ) - pay = Payment.objects.create( - created=created_action, - amount=self.cleaned_data.get("amount", -1), - due_on=self.cleaned_data.get("due", None), - comment=self.cleaned_data.get("comment", None), - intervention=self.intervention, - ) - self.intervention.log.add(edited_action) - self.intervention.modified = edited_action - self.intervention.save() - return pay + pay = self.instance.add_payment(self) + return pay class NewStateModalForm(BaseModalForm): @@ -167,24 +146,7 @@ class NewStateModalForm(BaseModalForm): self.form_caption = _("Insert data for the new state") def save(self, is_before_state: bool = False): - with transaction.atomic(): - user_action = UserActionLogEntry.objects.create( - user=self.user, - action=UserAction.EDITED, - comment=_("Added state") - ) - self.instance.log.add(user_action) - self.instance.modified = user_action - self.instance.save() - - state = CompensationState.objects.create( - biotope_type=self.cleaned_data["biotope_type"], - surface=self.cleaned_data["surface"], - ) - if is_before_state: - self.instance.before_states.add(state) - else: - self.instance.after_states.add(state) + state = self.instance.add_state(self, is_before_state) return state def process_request(self, request: HttpRequest, msg_success: str = _("Object removed"), msg_error: str = FORM_INVALID, redirect_url: str = None): @@ -287,26 +249,7 @@ class NewDeadlineModalForm(BaseModalForm): self.form_caption = _("Insert data for the new deadline") def save(self): - with transaction.atomic(): - created_action = UserActionLogEntry.objects.create( - user=self.user, - action=UserAction.CREATED - ) - deadline = Deadline.objects.create( - type=self.cleaned_data["type"], - date=self.cleaned_data["date"], - comment=self.cleaned_data["comment"], - created=created_action, - ) - edited_action = UserActionLogEntry.objects.create( - user=self.user, - action=UserAction.EDITED, - comment=_("Added deadline") - ) - self.instance.modified = edited_action - self.instance.save() - self.instance.log.add(edited_action) - self.instance.deadlines.add(deadline) + deadline = self.instance.add_new_deadline(self) return deadline @@ -318,6 +261,7 @@ class NewActionModalForm(BaseModalForm): (not in the process logic in Konova, but in the real world). """ + from compensation.models import UnitChoices action_type = forms.ModelChoiceField( label=_("Action Type"), label_suffix="", @@ -381,25 +325,13 @@ class NewActionModalForm(BaseModalForm): self.form_caption = _("Insert data for the new action") def save(self): - with transaction.atomic(): - user_action = UserActionLogEntry.objects.create( - user=self.user, - action=UserAction.CREATED, - ) - comp_action = CompensationAction.objects.create( - action_type=self.cleaned_data["action_type"], - amount=self.cleaned_data["amount"], - unit=self.cleaned_data["unit"], - comment=self.cleaned_data["comment"], - created=user_action, - ) - edited_action = UserActionLogEntry.objects.create( - user=self.user, - action=UserAction.EDITED, - comment=_("Added action"), - ) - self.instance.modified = edited_action - self.instance.save() - self.instance.log.add(edited_action) - self.instance.actions.add(comp_action) - return comp_action \ No newline at end of file + action = self.instance.add_new_action(self) + return action + + +class NewCompensationDocumentForm(NewDocumentForm): + document_model = CompensationDocument + + +class NewEcoAccountDocumentForm(NewDocumentForm): + document_model = EcoAccountDocument \ No newline at end of file diff --git a/compensation/models.py b/compensation/models.py deleted file mode 100644 index a0cf80de..00000000 --- a/compensation/models.py +++ /dev/null @@ -1,568 +0,0 @@ -""" -Author: Michel Peltriaux -Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany -Contact: michel.peltriaux@sgdnord.rlp.de -Created on: 17.11.20 - -""" -import shutil - -from django.contrib.auth.models import User -from django.contrib.gis.db import models -from django.core.exceptions import ValidationError -from django.core.validators import MinValueValidator -from django.db.models import Sum, QuerySet -from django.utils.translation import gettext_lazy as _ - -from codelist.models import KonovaCode -from codelist.settings import CODELIST_COMPENSATION_ACTION_ID, CODELIST_BIOTOPES_ID -from compensation.managers import CompensationStateManager, EcoAccountDeductionManager, CompensationActionManager, \ - EcoAccountManager, CompensationManager -from compensation.utils.quality import CompensationQualityChecker, EcoAccountQualityChecker -from intervention.models import Intervention, ResponsibilityData, LegalData -from konova.models import BaseObject, BaseResource, Geometry, UuidModel, AbstractDocument, \ - generate_document_file_upload_path, RecordableObject, ShareableObject -from konova.settings import DEFAULT_SRID_RLP, LANIS_LINK_TEMPLATE -from user.models import UserActionLogEntry - - -class Payment(BaseResource): - """ - Holds data on a payment for an intervention (alternative to a classic compensation) - """ - amount = models.FloatField(validators=[MinValueValidator(limit_value=0.00)]) - due_on = models.DateField(null=True, blank=True) - comment = models.TextField( - null=True, - blank=True, - help_text="Refers to german money transfer 'Verwendungszweck'", - ) - intervention = models.ForeignKey( - Intervention, - null=True, - blank=True, - on_delete=models.CASCADE, - related_name='payments' - ) - - class Meta: - ordering = [ - "-amount", - ] - - -class CompensationState(UuidModel): - """ - Compensations must define the state of an area before and after the compensation. - """ - biotope_type = models.ForeignKey( - KonovaCode, - on_delete=models.SET_NULL, - null=True, - blank=True, - limit_choices_to={ - "code_lists__in": [CODELIST_BIOTOPES_ID], - "is_selectable": True, - "is_archived": False, - } - ) - surface = models.FloatField() - - objects = CompensationStateManager() - - def __str__(self): - return "{} | {} m²".format(self.biotope_type, self.surface) - - -class UnitChoices(models.TextChoices): - """ - Predefines units for selection - """ - cm = "cm", _("cm") - m = "m", _("m") - km = "km", _("km") - qm = "qm", _("m²") - ha = "ha", _("ha") - st = "pcs", _("Pieces") # pieces - - -class CompensationAction(BaseResource): - """ - Compensations include actions like planting trees, refreshing rivers and so on. - """ - action_type = models.ForeignKey( - KonovaCode, - on_delete=models.SET_NULL, - null=True, - blank=True, - limit_choices_to={ - "code_lists__in": [CODELIST_COMPENSATION_ACTION_ID], - "is_selectable": True, - "is_archived": False, - } - ) - amount = models.FloatField() - unit = models.CharField(max_length=100, null=True, blank=True, choices=UnitChoices.choices) - comment = models.TextField(blank=True, null=True, help_text="Additional comment") - - objects = CompensationActionManager() - - def __str__(self): - return "{} | {} {}".format(self.action_type, self.amount, self.unit) - - @property - def unit_humanize(self): - """ Returns humanized version of enum - - Used for template rendering - - Returns: - - """ - choices = UnitChoices.choices - for choice in choices: - if choice[0] == self.unit: - return choice[1] - return None - - -class AbstractCompensation(BaseObject): - """ - Abstract compensation model which holds basic attributes, shared by subclasses like the regular Compensation, - EMA or EcoAccount. - - """ - responsible = models.OneToOneField( - ResponsibilityData, - on_delete=models.SET_NULL, - null=True, - blank=True, - help_text="Holds data on responsible organizations ('Zulassungsbehörde', 'Eintragungsstelle') and handler", - ) - - before_states = models.ManyToManyField(CompensationState, blank=True, related_name='+', help_text="Refers to 'Ausgangszustand Biotop'") - after_states = models.ManyToManyField(CompensationState, blank=True, related_name='+', help_text="Refers to 'Zielzustand Biotop'") - actions = models.ManyToManyField(CompensationAction, blank=True, help_text="Refers to 'Maßnahmen'") - - 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 - - def get_surface_after_states(self) -> float: - """ Calculates the compensation's/account's surface - - Returns: - sum_surface (float) - """ - return self._calc_surface(self.after_states.all()) - - def get_surface_before_states(self) -> float: - """ Calculates the compensation's/account's surface - - Returns: - sum_surface (float) - """ - return self._calc_surface(self.before_states.all()) - - def _calc_surface(self, qs: QuerySet): - """ Calculates the surface sum of a given queryset - - Args: - qs (QuerySet): The queryset containing CompensationState entries - - Returns: - - """ - return qs.aggregate(Sum("surface"))["surface__sum"] or 0 - - def quality_check(self) -> CompensationQualityChecker: - """ Performs data quality check - - Returns: - checker (CompensationQualityChecker): Holds validity data and error messages - """ - checker = CompensationQualityChecker(self) - checker.run_check() - return checker - - -class CEFMixin(models.Model): - """ Provides CEF flag as Mixin - - """ - is_cef = models.BooleanField( - blank=True, - null=True, - default=False, - help_text="Flag if compensation is a 'CEF-Maßnahme'" - ) - - class Meta: - abstract = True - - -class CoherenceMixin(models.Model): - """ Provides coherence keeping flag as Mixin - - """ - is_coherence_keeping = models.BooleanField( - blank=True, - null=True, - default=False, - help_text="Flag if compensation is a 'Kohärenzsicherung'" - ) - - class Meta: - abstract = True - - -class Compensation(AbstractCompensation, CEFMixin, CoherenceMixin): - """ - Regular compensation, linked to an intervention - """ - intervention = models.ForeignKey( - Intervention, - on_delete=models.CASCADE, - null=True, - blank=True, - related_name='compensations' - ) - - objects = CompensationManager() - - def __str__(self): - return "{}".format(self.identifier) - - def save(self, *args, **kwargs): - if self.identifier is None or len(self.identifier) == 0: - # Create new identifier is none was given - self.identifier = self.generate_new_identifier() - - # Before saving, make sure a given identifier has not been taken already in the meanwhile - while Compensation.objects.filter(identifier=self.identifier).exclude(id=self.id).exists(): - self.identifier = self.generate_new_identifier() - super().save(*args, **kwargs) - - def is_shared_with(self, user: User): - """ Access check - - Checks whether a given user has access to this object - - Args: - user (User): The user to be checked - - Returns: - - """ - # Compensations inherit their shared state from the interventions - return self.intervention.is_shared_with(user) - - def get_LANIS_link(self) -> str: - """ Generates a link for LANIS depending on the geometry - - Returns: - - """ - try: - geom = self.geometry.geom.transform(DEFAULT_SRID_RLP, clone=True) - x = geom.centroid.x - y = geom.centroid.y - zoom_lvl = 16 - except AttributeError: - # If no geometry has been added, yet. - x = 1 - y = 1 - zoom_lvl = 6 - return LANIS_LINK_TEMPLATE.format( - zoom_lvl, - x, - y, - ) - - def get_documents(self) -> QuerySet: - """ Getter for all documents of a compensation - - Returns: - docs (QuerySet): The queryset of all documents - """ - docs = CompensationDocument.objects.filter( - instance=self - ) - return docs - - -class CompensationDocument(AbstractDocument): - """ - Specializes document upload for revocations with certain path - """ - instance = models.ForeignKey( - Compensation, - on_delete=models.CASCADE, - related_name="documents", - ) - file = models.FileField( - upload_to=generate_document_file_upload_path, - max_length=1000, - ) - - def delete(self, *args, **kwargs): - """ - Custom delete functionality for CompensationDocuments. - Removes the folder from the file system if there are no further documents for this entry. - - Args: - *args (): - **kwargs (): - - Returns: - - """ - comp_docs = self.instance.get_documents() - - folder_path = None - if comp_docs.count() == 1: - # The only file left for this compensation is the one which is currently processed and will be deleted - # Make sure that the compensation folder itself is deleted as well, not only the file - # Therefore take the folder path from the file path - folder_path = self.file.path.split("/")[:-1] - folder_path = "/".join(folder_path) - - # Remove the file itself - super().delete(*args, **kwargs) - - # If a folder path has been set, we need to delete the whole folder! - if folder_path is not None: - try: - shutil.rmtree(folder_path) - except FileNotFoundError: - # Folder seems to be missing already... - pass - - -class EcoAccount(AbstractCompensation, ShareableObject, RecordableObject): - """ - An eco account is a kind of 'prepaid' compensation. It can be compared to an account that already has been filled - with some kind of currency. From this account one is able to deduct currency for current projects. - """ - deductable_surface = models.FloatField( - blank=True, - null=True, - help_text="Amount of deductable surface - can be lower than the total surface due to deduction limitations", - default=0, - ) - - legal = models.OneToOneField( - LegalData, - on_delete=models.SET_NULL, - null=True, - blank=True, - help_text="Holds data on legal dates or law" - ) - - objects = EcoAccountManager() - - def __str__(self): - return "{}".format(self.identifier) - - def clean(self): - # Deductable surface can not be larger than added states after surface - after_state_sum = self.get_state_after_surface_sum() - if self.deductable_surface > after_state_sum: - raise ValidationError(_("Deductable surface can not be larger than existing surfaces in after states")) - - # Deductable surface can not be lower than amount of already deducted surfaces - # User needs to contact deducting user in case of further problems - deducted_sum = self.get_deductions_surface() - if self.deductable_surface < deducted_sum: - raise ValidationError( - _("Deductable surface can not be smaller than the sum of already existing deductions. Please contact the responsible users for the deductions!") - ) - - def save(self, *args, **kwargs): - if self.identifier is None or len(self.identifier) == 0: - # Create new identifier if none was given - self.identifier = self.generate_new_identifier() - - # Before saving, make sure the given identifier is not used, yet - while EcoAccount.objects.filter(identifier=self.identifier).exclude(id=self.id).exists(): - self.identifier = self.generate_new_identifier() - super().save(*args, **kwargs) - - @property - def deductions_surface_sum(self) -> float: - """ Shortcut for get_deductions_surface. - - Can be used in templates - - Returns: - sum_surface (float) - """ - return self.get_deductions_surface() - - def get_deductions_surface(self) -> float: - """ Calculates the account's deductions surface sum - - Returns: - sum_surface (float) - """ - return self.deductions.all().aggregate(Sum("surface"))["surface__sum"] or 0 - - def get_state_after_surface_sum(self) -> float: - """ Calculates the account's after state surface sum - - Returns: - sum_surface (float) - """ - return self.after_states.all().aggregate(Sum("surface"))["surface__sum"] or 0 - - def get_available_rest(self) -> (float, float): - """ Calculates available rest surface of the eco account - - Args: - - Returns: - ret_val_total (float): Total amount - ret_val_relative (float): Amount as percentage (0-100) - """ - deductions = self.deductions.filter( - intervention__deleted=None, - ) - deductions_surfaces = deductions.aggregate(Sum("surface"))["surface__sum"] or 0 - available_surfaces = self.deductable_surface or deductions_surfaces ## no division by zero - ret_val_total = available_surfaces - deductions_surfaces - - if available_surfaces > 0: - ret_val_relative = int((ret_val_total / available_surfaces) * 100) - else: - ret_val_relative = 0 - - return ret_val_total, ret_val_relative - - def get_LANIS_link(self) -> str: - """ Generates a link for LANIS depending on the geometry - - Returns: - - """ - try: - geom = self.geometry.geom.transform(DEFAULT_SRID_RLP, clone=True) - x = geom.centroid.x - y = geom.centroid.y - zoom_lvl = 16 - except AttributeError: - # If no geometry has been added, yet. - x = 1 - y = 1 - zoom_lvl = 6 - return LANIS_LINK_TEMPLATE.format( - zoom_lvl, - x, - y, - ) - - def quality_check(self) -> EcoAccountQualityChecker: - """ Quality check - - Returns: - ret_msgs (EcoAccountQualityChecker): Holds validity and error messages - """ - checker = EcoAccountQualityChecker(self) - checker.run_check() - return checker - - def get_documents(self) -> QuerySet: - """ Getter for all documents of an EcoAccount - - Returns: - docs (QuerySet): The queryset of all documents - """ - docs = EcoAccountDocument.objects.filter( - instance=self - ) - return docs - - -class EcoAccountDocument(AbstractDocument): - """ - Specializes document upload for revocations with certain path - """ - instance = models.ForeignKey( - EcoAccount, - on_delete=models.CASCADE, - related_name="documents", - ) - file = models.FileField( - upload_to=generate_document_file_upload_path, - max_length=1000, - ) - - def delete(self, *args, **kwargs): - """ - Custom delete functionality for EcoAccountDocuments. - Removes the folder from the file system if there are no further documents for this entry. - - Args: - *args (): - **kwargs (): - - Returns: - - """ - acc_docs = self.instance.get_documents() - - folder_path = None - if acc_docs.count() == 1: - # The only file left for this eco account is the one which is currently processed and will be deleted - # Make sure that the compensation folder itself is deleted as well, not only the file - # Therefore take the folder path from the file path - folder_path = self.file.path.split("/")[:-1] - folder_path = "/".join(folder_path) - - # Remove the file itself - super().delete(*args, **kwargs) - - # If a folder path has been set, we need to delete the whole folder! - if folder_path is not None: - try: - shutil.rmtree(folder_path) - except FileNotFoundError: - # Folder seems to be missing already... - pass - - -class EcoAccountDeduction(BaseResource): - """ - A deduction object for eco accounts - """ - account = models.ForeignKey( - EcoAccount, - on_delete=models.SET_NULL, - null=True, - blank=True, - help_text="Deducted from", - related_name="deductions", - ) - surface = models.FloatField( - null=True, - blank=True, - help_text="Amount deducted (m²)", - validators=[ - MinValueValidator(limit_value=0.00), - ] - ) - intervention = models.ForeignKey( - Intervention, - on_delete=models.CASCADE, - null=True, - blank=True, - help_text="Deducted for", - related_name="deductions", - ) - - objects = EcoAccountDeductionManager() - - def __str__(self): - return "{} of {}".format(self.surface, self.account) diff --git a/compensation/models/__init__.py b/compensation/models/__init__.py new file mode 100644 index 00000000..9f289bd9 --- /dev/null +++ b/compensation/models/__init__.py @@ -0,0 +1,12 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 16.11.21 + +""" +from .action import * +from .state import * +from .compensation import * +from .eco_account import * +from .payment import * diff --git a/compensation/models/action.py b/compensation/models/action.py new file mode 100644 index 00000000..35d4c3f7 --- /dev/null +++ b/compensation/models/action.py @@ -0,0 +1,66 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 16.11.21 + +""" +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from codelist.models import KonovaCode +from codelist.settings import CODELIST_COMPENSATION_ACTION_ID +from compensation.managers import CompensationActionManager +from konova.models import BaseResource + + +class UnitChoices(models.TextChoices): + """ + Predefines units for selection + """ + cm = "cm", _("cm") + m = "m", _("m") + km = "km", _("km") + qm = "qm", _("m²") + ha = "ha", _("ha") + st = "pcs", _("Pieces") # pieces + + +class CompensationAction(BaseResource): + """ + Compensations include actions like planting trees, refreshing rivers and so on. + """ + action_type = models.ForeignKey( + KonovaCode, + on_delete=models.SET_NULL, + null=True, + blank=True, + limit_choices_to={ + "code_lists__in": [CODELIST_COMPENSATION_ACTION_ID], + "is_selectable": True, + "is_archived": False, + } + ) + amount = models.FloatField() + unit = models.CharField(max_length=100, null=True, blank=True, choices=UnitChoices.choices) + comment = models.TextField(blank=True, null=True, help_text="Additional comment") + + objects = CompensationActionManager() + + def __str__(self): + return "{} | {} {}".format(self.action_type, self.amount, self.unit) + + @property + def unit_humanize(self): + """ Returns humanized version of enum + + Used for template rendering + + Returns: + + """ + choices = UnitChoices.choices + for choice in choices: + if choice[0] == self.unit: + return choice[1] + return None diff --git a/compensation/models/compensation.py b/compensation/models/compensation.py new file mode 100644 index 00000000..7808187d --- /dev/null +++ b/compensation/models/compensation.py @@ -0,0 +1,319 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 16.11.21 + +""" +import shutil + +from django.contrib.auth.models import User +from django.db import models, transaction +from django.db.models import QuerySet, Sum +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.settings import DEFAULT_SRID_RLP, LANIS_LINK_TEMPLATE +from user.models import UserActionLogEntry + + +class AbstractCompensation(BaseObject): + """ + Abstract compensation model which holds basic attributes, shared by subclasses like the regular Compensation, + EMA or EcoAccount. + + """ + responsible = models.OneToOneField( + "intervention.Responsibility", + on_delete=models.SET_NULL, + null=True, + blank=True, + help_text="Holds data on responsible organizations ('Zulassungsbehörde', 'Eintragungsstelle') and handler", + ) + + before_states = models.ManyToManyField(CompensationState, blank=True, related_name='+', help_text="Refers to 'Ausgangszustand Biotop'") + after_states = models.ManyToManyField(CompensationState, blank=True, related_name='+', help_text="Refers to 'Zielzustand Biotop'") + actions = models.ManyToManyField(CompensationAction, blank=True, help_text="Refers to 'Maßnahmen'") + + 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 + + def add_new_deadline(self, form) -> Deadline: + """ Adds a new deadline to the abstract compensation + + Args: + form (NewDeadlineModalForm): The form holding all relevant data + + Returns: + + """ + form_data = form.cleaned_data + user = form.user + with transaction.atomic(): + created_action = UserActionLogEntry.get_created_action(user) + edited_action = UserActionLogEntry.get_edited_action(user, _("Added deadline")) + + deadline = Deadline.objects.create( + type=form_data["type"], + date=form_data["date"], + comment=form_data["comment"], + created=created_action, + ) + + self.modified = edited_action + self.save() + self.log.add(edited_action) + self.deadlines.add(deadline) + return deadline + + def add_new_action(self, form) -> CompensationAction: + """ Adds a new action to the compensation + + Args: + form (NewActionModalForm): The form holding all relevant data + + Returns: + + """ + form_data = form.cleaned_data + user = form.user + with transaction.atomic(): + user_action = UserActionLogEntry.get_created_action(user) + edited_action = UserActionLogEntry.get_edited_action(user, _("Added action")) + + comp_action = CompensationAction.objects.create( + action_type=form_data["action_type"], + amount=form_data["amount"], + unit=form_data["unit"], + comment=form_data["comment"], + created=user_action, + ) + self.modified = edited_action + self.save() + self.log.add(edited_action) + self.actions.add(comp_action) + return comp_action + + def add_state(self, form, is_before_state: bool) -> CompensationState: + """ Adds a new compensation state to the compensation + + Args: + form (NewStateModalForm): The form, holding all relevant data + is_before_state (bool): Whether this is a new before_state or after_state + + Returns: + + """ + form_data = form.cleaned_data + user = form.user + with transaction.atomic(): + user_action = UserActionLogEntry.get_edited_action(user, _("Added state")) + self.log.add(user_action) + self.modified = user_action + self.save() + + state = CompensationState.objects.create( + biotope_type=form_data["biotope_type"], + surface=form_data["surface"], + ) + if is_before_state: + self.before_states.add(state) + else: + self.after_states.add(state) + return state + + def get_surface_after_states(self) -> float: + """ Calculates the compensation's/account's surface + + Returns: + sum_surface (float) + """ + return self._calc_surface(self.after_states.all()) + + def get_surface_before_states(self) -> float: + """ Calculates the compensation's/account's surface + + Returns: + sum_surface (float) + """ + return self._calc_surface(self.before_states.all()) + + def _calc_surface(self, qs: QuerySet): + """ Calculates the surface sum of a given queryset + + Args: + qs (QuerySet): The queryset containing CompensationState entries + + Returns: + + """ + return qs.aggregate(Sum("surface"))["surface__sum"] or 0 + + def quality_check(self) -> CompensationQualityChecker: + """ Performs data quality check + + Returns: + checker (CompensationQualityChecker): Holds validity data and error messages + """ + checker = CompensationQualityChecker(self) + checker.run_check() + return checker + + +class CEFMixin(models.Model): + """ Provides CEF flag as Mixin + + """ + is_cef = models.BooleanField( + blank=True, + null=True, + default=False, + help_text="Flag if compensation is a 'CEF-Maßnahme'" + ) + + class Meta: + abstract = True + + +class CoherenceMixin(models.Model): + """ Provides coherence keeping flag as Mixin + + """ + is_coherence_keeping = models.BooleanField( + blank=True, + null=True, + default=False, + help_text="Flag if compensation is a 'Kohärenzsicherung'" + ) + + class Meta: + abstract = True + + +class Compensation(AbstractCompensation, CEFMixin, CoherenceMixin): + """ + Regular compensation, linked to an intervention + """ + intervention = models.ForeignKey( + "intervention.Intervention", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name='compensations' + ) + + objects = CompensationManager() + + def __str__(self): + return "{}".format(self.identifier) + + def save(self, *args, **kwargs): + if self.identifier is None or len(self.identifier) == 0: + # Create new identifier is none was given + self.identifier = self.generate_new_identifier() + + # Before saving, make sure a given identifier has not been taken already in the meanwhile + while Compensation.objects.filter(identifier=self.identifier).exclude(id=self.id).exists(): + self.identifier = self.generate_new_identifier() + super().save(*args, **kwargs) + + def is_shared_with(self, user: User): + """ Access check + + Checks whether a given user has access to this object + + Args: + user (User): The user to be checked + + Returns: + + """ + # Compensations inherit their shared state from the interventions + return self.intervention.is_shared_with(user) + + def get_LANIS_link(self) -> str: + """ Generates a link for LANIS depending on the geometry + + Returns: + + """ + try: + geom = self.geometry.geom.transform(DEFAULT_SRID_RLP, clone=True) + x = geom.centroid.x + y = geom.centroid.y + zoom_lvl = 16 + except AttributeError: + # If no geometry has been added, yet. + x = 1 + y = 1 + zoom_lvl = 6 + return LANIS_LINK_TEMPLATE.format( + zoom_lvl, + x, + y, + ) + + def get_documents(self) -> QuerySet: + """ Getter for all documents of a compensation + + Returns: + docs (QuerySet): The queryset of all documents + """ + docs = CompensationDocument.objects.filter( + instance=self + ) + return docs + +class CompensationDocument(AbstractDocument): + """ + Specializes document upload for revocations with certain path + """ + instance = models.ForeignKey( + Compensation, + on_delete=models.CASCADE, + related_name="documents", + ) + file = models.FileField( + upload_to=generate_document_file_upload_path, + max_length=1000, + ) + + def delete(self, *args, **kwargs): + """ + Custom delete functionality for CompensationDocuments. + Removes the folder from the file system if there are no further documents for this entry. + + Args: + *args (): + **kwargs (): + + Returns: + + """ + comp_docs = self.instance.get_documents() + + folder_path = None + if comp_docs.count() == 1: + # The only file left for this compensation is the one which is currently processed and will be deleted + # Make sure that the compensation folder itself is deleted as well, not only the file + # Therefore take the folder path from the file path + folder_path = self.file.path.split("/")[:-1] + folder_path = "/".join(folder_path) + + # Remove the file itself + super().delete(*args, **kwargs) + + # If a folder path has been set, we need to delete the whole folder! + if folder_path is not None: + try: + shutil.rmtree(folder_path) + except FileNotFoundError: + # Folder seems to be missing already... + pass diff --git a/compensation/models/eco_account.py b/compensation/models/eco_account.py new file mode 100644 index 00000000..ec612bc3 --- /dev/null +++ b/compensation/models/eco_account.py @@ -0,0 +1,276 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 16.11.21 + +""" +import shutil + +from django.core.exceptions import ValidationError +from django.core.validators import MinValueValidator +from django.db import models, transaction +from django.db.models import Sum, QuerySet +from django.utils.translation import gettext_lazy as _ + +from compensation.managers import EcoAccountManager, EcoAccountDeductionManager +from compensation.models.compensation import AbstractCompensation +from compensation.utils.quality import EcoAccountQualityChecker +from konova.models import ShareableObjectMixin, RecordableObjectMixin, AbstractDocument, BaseResource, \ + generate_document_file_upload_path +from konova.settings import DEFAULT_SRID_RLP, LANIS_LINK_TEMPLATE +from user.models import UserActionLogEntry + + +class EcoAccount(AbstractCompensation, ShareableObjectMixin, RecordableObjectMixin): + """ + An eco account is a kind of 'prepaid' compensation. It can be compared to an account that already has been filled + with some kind of currency. From this account one is able to deduct currency for current projects. + """ + deductable_surface = models.FloatField( + blank=True, + null=True, + help_text="Amount of deductable surface - can be lower than the total surface due to deduction limitations", + default=0, + ) + + legal = models.OneToOneField( + "intervention.Legal", + on_delete=models.SET_NULL, + null=True, + blank=True, + help_text="Holds data on legal dates or law" + ) + + objects = EcoAccountManager() + + def __str__(self): + return "{}".format(self.identifier) + + def clean(self): + # Deductable surface can not be larger than added states after surface + after_state_sum = self.get_state_after_surface_sum() + if self.deductable_surface > after_state_sum: + raise ValidationError(_("Deductable surface can not be larger than existing surfaces in after states")) + + # Deductable surface can not be lower than amount of already deducted surfaces + # User needs to contact deducting user in case of further problems + deducted_sum = self.get_deductions_surface() + if self.deductable_surface < deducted_sum: + raise ValidationError( + _("Deductable surface can not be smaller than the sum of already existing deductions. Please contact the responsible users for the deductions!") + ) + + def save(self, *args, **kwargs): + if self.identifier is None or len(self.identifier) == 0: + # Create new identifier if none was given + self.identifier = self.generate_new_identifier() + + # Before saving, make sure the given identifier is not used, yet + while EcoAccount.objects.filter(identifier=self.identifier).exclude(id=self.id).exists(): + self.identifier = self.generate_new_identifier() + super().save(*args, **kwargs) + + @property + def deductions_surface_sum(self) -> float: + """ Shortcut for get_deductions_surface. + + Can be used in templates + + Returns: + sum_surface (float) + """ + return self.get_deductions_surface() + + def get_deductions_surface(self) -> float: + """ Calculates the account's deductions surface sum + + Returns: + sum_surface (float) + """ + return self.deductions.all().aggregate(Sum("surface"))["surface__sum"] or 0 + + def get_state_after_surface_sum(self) -> float: + """ Calculates the account's after state surface sum + + Returns: + sum_surface (float) + """ + return self.after_states.all().aggregate(Sum("surface"))["surface__sum"] or 0 + + def get_available_rest(self) -> (float, float): + """ Calculates available rest surface of the eco account + + Args: + + Returns: + ret_val_total (float): Total amount + ret_val_relative (float): Amount as percentage (0-100) + """ + deductions = self.deductions.filter( + intervention__deleted=None, + ) + deductions_surfaces = deductions.aggregate(Sum("surface"))["surface__sum"] or 0 + available_surfaces = self.deductable_surface or deductions_surfaces ## no division by zero + ret_val_total = available_surfaces - deductions_surfaces + + if available_surfaces > 0: + ret_val_relative = int((ret_val_total / available_surfaces) * 100) + else: + ret_val_relative = 0 + + return ret_val_total, ret_val_relative + + def get_LANIS_link(self) -> str: + """ Generates a link for LANIS depending on the geometry + + Returns: + + """ + try: + geom = self.geometry.geom.transform(DEFAULT_SRID_RLP, clone=True) + x = geom.centroid.x + y = geom.centroid.y + zoom_lvl = 16 + except AttributeError: + # If no geometry has been added, yet. + x = 1 + y = 1 + zoom_lvl = 6 + return LANIS_LINK_TEMPLATE.format( + zoom_lvl, + x, + y, + ) + + def quality_check(self) -> EcoAccountQualityChecker: + """ Quality check + + Returns: + ret_msgs (EcoAccountQualityChecker): Holds validity and error messages + """ + checker = EcoAccountQualityChecker(self) + checker.run_check() + return checker + + def get_documents(self) -> QuerySet: + """ Getter for all documents of an EcoAccount + + Returns: + docs (QuerySet): The queryset of all documents + """ + docs = EcoAccountDocument.objects.filter( + instance=self + ) + return docs + + def add_deduction(self, form): + """ Adds a new deduction to the intervention + + Args: + form (NewDeductionModalForm): The form holding the data + + Returns: + + """ + form_data = form.cleaned_data + user = form.user + + with transaction.atomic(): + # Create log entry + user_action_create = UserActionLogEntry.get_created_action(user) + user_action_edit = UserActionLogEntry.get_edited_action(user) + self.log.add(user_action_edit) + self.modified = user_action_edit + self.save() + + deduction = EcoAccountDeduction.objects.create( + intervention=form_data["intervention"], + account=self, + surface=form_data["surface"], + created=user_action_create, + ) + return deduction + + +class EcoAccountDocument(AbstractDocument): + """ + Specializes document upload for revocations with certain path + """ + instance = models.ForeignKey( + EcoAccount, + on_delete=models.CASCADE, + related_name="documents", + ) + file = models.FileField( + upload_to=generate_document_file_upload_path, + max_length=1000, + ) + + def delete(self, *args, **kwargs): + """ + Custom delete functionality for EcoAccountDocuments. + Removes the folder from the file system if there are no further documents for this entry. + + Args: + *args (): + **kwargs (): + + Returns: + + """ + acc_docs = self.instance.get_documents() + + folder_path = None + if acc_docs.count() == 1: + # The only file left for this eco account is the one which is currently processed and will be deleted + # Make sure that the compensation folder itself is deleted as well, not only the file + # Therefore take the folder path from the file path + folder_path = self.file.path.split("/")[:-1] + folder_path = "/".join(folder_path) + + # Remove the file itself + super().delete(*args, **kwargs) + + # If a folder path has been set, we need to delete the whole folder! + if folder_path is not None: + try: + shutil.rmtree(folder_path) + except FileNotFoundError: + # Folder seems to be missing already... + pass + + +class EcoAccountDeduction(BaseResource): + """ + A deduction object for eco accounts + """ + account = models.ForeignKey( + EcoAccount, + on_delete=models.SET_NULL, + null=True, + blank=True, + help_text="Deducted from", + related_name="deductions", + ) + surface = models.FloatField( + null=True, + blank=True, + help_text="Amount deducted (m²)", + validators=[ + MinValueValidator(limit_value=0.00), + ] + ) + intervention = models.ForeignKey( + "intervention.Intervention", + on_delete=models.CASCADE, + null=True, + blank=True, + help_text="Deducted for", + related_name="deductions", + ) + + objects = EcoAccountDeductionManager() + + def __str__(self): + return "{} of {}".format(self.surface, self.account) diff --git a/compensation/models/payment.py b/compensation/models/payment.py new file mode 100644 index 00000000..ec56910d --- /dev/null +++ b/compensation/models/payment.py @@ -0,0 +1,37 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 16.11.21 + +""" +from django.core.validators import MinValueValidator +from django.db import models + +from intervention.models import Intervention +from konova.models import BaseResource + + +class Payment(BaseResource): + """ + Holds data on a payment for an intervention (alternative to a classic compensation) + """ + amount = models.FloatField(validators=[MinValueValidator(limit_value=0.00)]) + due_on = models.DateField(null=True, blank=True) + comment = models.TextField( + null=True, + blank=True, + help_text="Refers to german money transfer 'Verwendungszweck'", + ) + intervention = models.ForeignKey( + Intervention, + null=True, + blank=True, + on_delete=models.CASCADE, + related_name='payments' + ) + + class Meta: + ordering = [ + "-amount", + ] diff --git a/compensation/models/state.py b/compensation/models/state.py new file mode 100644 index 00000000..01aad147 --- /dev/null +++ b/compensation/models/state.py @@ -0,0 +1,36 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 16.11.21 + +""" +from django.db import models + +from codelist.models import KonovaCode +from codelist.settings import CODELIST_BIOTOPES_ID +from compensation.managers import CompensationStateManager +from konova.models import UuidModel + + +class CompensationState(UuidModel): + """ + Compensations must define the state of an area before and after the compensation. + """ + biotope_type = models.ForeignKey( + KonovaCode, + on_delete=models.SET_NULL, + null=True, + blank=True, + limit_choices_to={ + "code_lists__in": [CODELIST_BIOTOPES_ID], + "is_selectable": True, + "is_archived": False, + } + ) + surface = models.FloatField() + + objects = CompensationStateManager() + + def __str__(self): + return "{} | {} m²".format(self.biotope_type, self.surface) diff --git a/compensation/urls/__init__.py b/compensation/urls/__init__.py new file mode 100644 index 00000000..247de721 --- /dev/null +++ b/compensation/urls/__init__.py @@ -0,0 +1,8 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 16.11.21 + +""" +from .urls import * diff --git a/compensation/comp_urls.py b/compensation/urls/compensation.py similarity index 96% rename from compensation/comp_urls.py rename to compensation/urls/compensation.py index 8eb7c48d..f169bb9e 100644 --- a/compensation/comp_urls.py +++ b/compensation/urls/compensation.py @@ -6,7 +6,7 @@ Created on: 24.08.21 """ from django.urls import path -from compensation.views.compensation_views import * +from compensation.views.compensation import * urlpatterns = [ # Main compensation diff --git a/compensation/account_urls.py b/compensation/urls/eco_account.py similarity index 96% rename from compensation/account_urls.py rename to compensation/urls/eco_account.py index a2bfd6f8..9bdecf2c 100644 --- a/compensation/account_urls.py +++ b/compensation/urls/eco_account.py @@ -6,7 +6,7 @@ Created on: 24.08.21 """ from django.urls import path -from compensation.views.eco_account_views import * +from compensation.views.eco_account import * urlpatterns = [ path("", index_view, name="acc-index"), diff --git a/compensation/payment_urls.py b/compensation/urls/payment.py similarity index 88% rename from compensation/payment_urls.py rename to compensation/urls/payment.py index d208b07d..0bb80938 100644 --- a/compensation/payment_urls.py +++ b/compensation/urls/payment.py @@ -6,7 +6,7 @@ Created on: 24.08.21 """ from django.urls import path -from compensation.views.payment_views import * +from compensation.views.payment import * urlpatterns = [ path('/new', new_payment_view, name='pay-new'), diff --git a/compensation/urls.py b/compensation/urls/urls.py similarity index 60% rename from compensation/urls.py rename to compensation/urls/urls.py index 368a037b..58765bcb 100644 --- a/compensation/urls.py +++ b/compensation/urls/urls.py @@ -9,7 +9,7 @@ from django.urls import path, include app_name = "compensation" urlpatterns = [ - path("", include("compensation.comp_urls")), - path("acc/", include("compensation.account_urls")), - path("pay/", include("compensation.payment_urls")), + path("", include("compensation.urls.compensation")), + path("acc/", include("compensation.urls.eco_account")), + path("pay/", include("compensation.urls.payment")), ] \ No newline at end of file diff --git a/compensation/utils/quality.py b/compensation/utils/quality.py index 28002db4..b622fcdd 100644 --- a/compensation/utils/quality.py +++ b/compensation/utils/quality.py @@ -61,7 +61,7 @@ class EcoAccountQualityChecker(CompensationQualityChecker): super().run_check() def _check_legal_data(self): - """ Checks the data quality for LegalData + """ Checks the data quality for Legal Returns: diff --git a/compensation/views/__init__.py b/compensation/views/__init__.py new file mode 100644 index 00000000..db03b5a1 --- /dev/null +++ b/compensation/views/__init__.py @@ -0,0 +1,10 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 16.11.21 + +""" +from .compensation import * +from .eco_account import * +from .payment import * diff --git a/compensation/views/compensation_views.py b/compensation/views/compensation.py similarity index 98% rename from compensation/views/compensation_views.py rename to compensation/views/compensation.py index 02bdde01..5bef5040 100644 --- a/compensation/views/compensation_views.py +++ b/compensation/views/compensation.py @@ -5,13 +5,14 @@ from django.shortcuts import render from django.utils.translation import gettext_lazy as _ from compensation.forms.forms import NewCompensationForm, EditCompensationForm -from compensation.forms.modalForms import NewStateModalForm, NewDeadlineModalForm, NewActionModalForm +from compensation.forms.modalForms import NewStateModalForm, NewDeadlineModalForm, NewActionModalForm, \ + NewCompensationDocumentForm from compensation.models import Compensation, CompensationState, CompensationAction, CompensationDocument from compensation.tables import CompensationTable from intervention.models import Intervention from konova.contexts import BaseContext from konova.decorators import * -from konova.forms import RemoveModalForm, SimpleGeomForm, NewDocumentForm +from konova.forms import RemoveModalForm, SimpleGeomForm from konova.utils.documents import get_document, remove_document from konova.utils.generators import generate_qr_code from konova.utils.message_templates import FORM_INVALID, IDENTIFIER_REPLACED, DATA_UNSHARED_EXPLANATION @@ -258,7 +259,7 @@ def new_document_view(request: HttpRequest, id: str): """ comp = get_object_or_404(Compensation, id=id) - form = NewDocumentForm(request.POST or None, request.FILES or None, instance=comp, user=request.user) + form = NewCompensationDocumentForm(request.POST or None, request.FILES or None, instance=comp, user=request.user) return form.process_request( request, msg_success=_("Document added") diff --git a/compensation/views/eco_account_views.py b/compensation/views/eco_account.py similarity index 98% rename from compensation/views/eco_account_views.py rename to compensation/views/eco_account.py index 521963fc..ea058bf5 100644 --- a/compensation/views/eco_account_views.py +++ b/compensation/views/eco_account.py @@ -15,10 +15,11 @@ from django.http import HttpRequest, Http404, JsonResponse from django.shortcuts import render, get_object_or_404, redirect from compensation.forms.forms import NewEcoAccountForm, EditEcoAccountForm -from compensation.forms.modalForms import NewStateModalForm, NewActionModalForm, NewDeadlineModalForm +from compensation.forms.modalForms import NewStateModalForm, NewActionModalForm, NewDeadlineModalForm, \ + NewEcoAccountDocumentForm from compensation.models import EcoAccount, EcoAccountDocument, CompensationState, CompensationAction from compensation.tables import EcoAccountTable -from intervention.forms.modalForms import NewDeductionModalForm, ShareInterventionModalForm +from intervention.forms.modalForms import NewDeductionModalForm, ShareModalForm from konova.contexts import BaseContext from konova.decorators import any_group_check, default_group_required, conservation_office_group_required, \ shared_access_required @@ -453,7 +454,7 @@ def new_document_view(request: HttpRequest, id: str): """ acc = get_object_or_404(EcoAccount, id=id) - form = NewDocumentForm(request.POST or None, request.FILES or None, instance=acc, user=request.user) + form = NewEcoAccountDocumentForm(request.POST or None, request.FILES or None, instance=acc, user=request.user) return form.process_request( request, msg_success=_("Document added") @@ -641,7 +642,7 @@ def create_share_view(request: HttpRequest, id: str): """ obj = get_object_or_404(EcoAccount, id=id) - form = ShareInterventionModalForm(request.POST or None, instance=obj, request=request) + form = ShareModalForm(request.POST or None, instance=obj, request=request) return form.process_request( request, msg_success=_("Share settings updated") diff --git a/compensation/views/payment_views.py b/compensation/views/payment.py similarity index 100% rename from compensation/views/payment_views.py rename to compensation/views/payment.py diff --git a/ema/forms.py b/ema/forms.py index 85a9094b..cb7f0cc8 100644 --- a/ema/forms.py +++ b/ema/forms.py @@ -13,10 +13,10 @@ from django.urls import reverse, reverse_lazy from django.utils.translation import gettext_lazy as _ from compensation.forms.forms import AbstractCompensationForm, CompensationResponsibleFormMixin -from ema.models import Ema -from intervention.models import ResponsibilityData -from konova.forms import SimpleGeomForm -from user.models import UserActionLogEntry, UserAction +from ema.models import Ema, EmaDocument +from intervention.models import Responsibility +from konova.forms import SimpleGeomForm, NewDocumentForm +from user.models import UserActionLogEntry class NewEmaForm(AbstractCompensationForm, CompensationResponsibleFormMixin): @@ -59,14 +59,11 @@ class NewEmaForm(AbstractCompensationForm, CompensationResponsibleFormMixin): comment = self.cleaned_data.get("comment", None) # Create log entry - action = UserActionLogEntry.objects.create( - user=user, - action=UserAction.CREATED, - ) + action = UserActionLogEntry.get_created_action(user) # Process the geometry form geometry = geom_form.save(action) - responsible = ResponsibilityData.objects.create( + responsible = Responsibility.objects.create( handler=handler, conservation_file_number=conservation_file_number, conservation_office=conservation_office, @@ -130,10 +127,7 @@ class EditEmaForm(NewEmaForm): comment = self.cleaned_data.get("comment", None) # Create log entry - action = UserActionLogEntry.objects.create( - user=user, - action=UserAction.EDITED, - ) + action = UserActionLogEntry.get_edited_action(user) # Process the geometry form geometry = geom_form.save(action) @@ -154,3 +148,7 @@ class EditEmaForm(NewEmaForm): # Add the log entry to the main objects log list self.instance.log.add(action) return self.instance + + +class NewEmaDocumentForm(NewDocumentForm): + document_model = EmaDocument \ No newline at end of file diff --git a/ema/models/__init__.py b/ema/models/__init__.py new file mode 100644 index 00000000..77fe9d7b --- /dev/null +++ b/ema/models/__init__.py @@ -0,0 +1,9 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 15.11.21 + +""" + +from .ema import * diff --git a/ema/models.py b/ema/models/ema.py similarity index 93% rename from ema/models.py rename to ema/models/ema.py index b35cda32..bc7589d0 100644 --- a/ema/models.py +++ b/ema/models/ema.py @@ -1,3 +1,10 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 15.11.21 + +""" import shutil from django.db import models @@ -6,11 +13,11 @@ from django.db.models import QuerySet 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, RecordableObject, ShareableObject +from konova.models import AbstractDocument, generate_document_file_upload_path, RecordableObjectMixin, ShareableObjectMixin from konova.settings import DEFAULT_SRID_RLP, LANIS_LINK_TEMPLATE -class Ema(AbstractCompensation, ShareableObject, RecordableObject): +class Ema(AbstractCompensation, ShareableObjectMixin, RecordableObjectMixin): """ EMA = Ersatzzahlungsmaßnahme (compensation actions from payments) diff --git a/ema/tests/test_views.py b/ema/tests/test_views.py index 07d761c8..3d853e7b 100644 --- a/ema/tests/test_views.py +++ b/ema/tests/test_views.py @@ -11,10 +11,10 @@ from django.test.client import Client from compensation.tests.test_views import CompensationViewTestCase from ema.models import Ema -from intervention.models import ResponsibilityData +from intervention.models import Responsibility from konova.models import Geometry from konova.settings import DEFAULT_GROUP, ETS_GROUP -from user.models import UserActionLogEntry, UserAction +from user.models import UserActionLogEntry class EmaViewTestCase(CompensationViewTestCase): @@ -61,12 +61,9 @@ class EmaViewTestCase(CompensationViewTestCase): def create_dummy_data(cls): # Create dummy data # Create log entry - action = UserActionLogEntry.objects.create( - user=cls.superuser, - action=UserAction.CREATED, - ) + action = UserActionLogEntry.get_created_action(cls.superuser) # Create responsible data object - responsibility_data = ResponsibilityData.objects.create() + responsibility_data = Responsibility.objects.create() geometry = Geometry.objects.create() cls.ema = Ema.objects.create( identifier="TEST", diff --git a/ema/views.py b/ema/views.py index 496c55b1..22f6e651 100644 --- a/ema/views.py +++ b/ema/views.py @@ -8,13 +8,13 @@ from django.utils.translation import gettext_lazy as _ from compensation.forms.modalForms import NewStateModalForm, NewActionModalForm, NewDeadlineModalForm from compensation.models import CompensationAction, CompensationState -from ema.forms import NewEmaForm, EditEmaForm +from ema.forms import NewEmaForm, EditEmaForm, NewEmaDocumentForm from ema.tables import EmaTable -from intervention.forms.modalForms import ShareInterventionModalForm +from intervention.forms.modalForms import ShareModalForm from konova.contexts import BaseContext from konova.decorators import conservation_office_group_required, shared_access_required from ema.models import Ema, EmaDocument -from konova.forms import RemoveModalForm, NewDocumentForm, SimpleGeomForm, RecordModalForm +from konova.forms import RemoveModalForm, SimpleGeomForm, RecordModalForm from konova.settings import DEFAULT_GROUP, ZB_GROUP, ETS_GROUP from konova.utils.documents import get_document, remove_document from konova.utils.generators import generate_qr_code @@ -343,7 +343,7 @@ def document_new_view(request: HttpRequest, id: str): """ ema = get_object_or_404(Ema, id=id) - form = NewDocumentForm(request.POST or None, request.FILES or None, instance=ema, user=request.user) + form = NewEmaDocumentForm(request.POST or None, request.FILES or None, instance=ema, user=request.user) return form.process_request( request, msg_success=_("Document added") @@ -546,7 +546,7 @@ def create_share_view(request: HttpRequest, id: str): """ obj = get_object_or_404(Ema, id=id) - form = ShareInterventionModalForm(request.POST or None, instance=obj, request=request) + form = ShareModalForm(request.POST or None, instance=obj, request=request) return form.process_request( request, msg_success=_("Share settings updated") diff --git a/intervention/admin.py b/intervention/admin.py index de11ee27..f65cb332 100644 --- a/intervention/admin.py +++ b/intervention/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from intervention.models import Intervention, ResponsibilityData, LegalData, Revocation, InterventionDocument +from intervention.models import Intervention, Responsibility, Legal, Revocation, InterventionDocument from konova.admin import AbstractDocumentAdmin @@ -46,7 +46,7 @@ class RevocationAdmin(admin.ModelAdmin): admin.site.register(Intervention, InterventionAdmin) -admin.site.register(ResponsibilityData, ResponsibilityAdmin) -admin.site.register(LegalData, LegalAdmin) +admin.site.register(Responsibility, ResponsibilityAdmin) +admin.site.register(Legal, LegalAdmin) admin.site.register(Revocation, RevocationAdmin) admin.site.register(InterventionDocument, InterventionDocumentAdmin) diff --git a/intervention/forms/forms.py b/intervention/forms/forms.py index f7453ccd..fdf88403 100644 --- a/intervention/forms/forms.py +++ b/intervention/forms/forms.py @@ -10,16 +10,15 @@ from django import forms from django.contrib.auth.models import User from django.db import transaction from django.urls import reverse, reverse_lazy -from django.utils import timezone from django.utils.translation import gettext_lazy as _ from codelist.models import KonovaCode from codelist.settings import CODELIST_PROCESS_TYPE_ID, CODELIST_LAW_ID, \ CODELIST_REGISTRATION_OFFICE_ID, CODELIST_CONSERVATION_OFFICE_ID from intervention.inputs import GenerateInput -from intervention.models import Intervention, LegalData, ResponsibilityData +from intervention.models import Intervention, Legal, Responsibility from konova.forms import BaseForm, SimpleGeomForm -from user.models import UserActionLogEntry, UserAction +from user.models import UserActionLogEntry class NewInterventionForm(BaseForm): @@ -214,13 +213,10 @@ class NewInterventionForm(BaseForm): comment = self.cleaned_data.get("comment", None) # Create log entry - action = UserActionLogEntry.objects.create( - user=user, - action=UserAction.CREATED, - ) + action = UserActionLogEntry.get_created_action(user) # Create legal data object (without M2M laws first) - legal_data = LegalData.objects.create( + legal_data = Legal.objects.create( registration_date=registration_date, binding_date=binding_date, process_type=_type, @@ -229,7 +225,7 @@ class NewInterventionForm(BaseForm): legal_data.laws.set(laws) # Create responsible data object - responsibility_data = ResponsibilityData.objects.create( + responsibility_data = Responsibility.objects.create( registration_office=registration_office, conservation_office=conservation_office, registration_file_number=registration_file_number, @@ -337,11 +333,7 @@ class EditInterventionForm(NewInterventionForm): self.instance.responsible.conservation_file_number = conservation_file_number self.instance.responsible.save() - user_action = UserActionLogEntry.objects.create( - user=user, - timestamp=timezone.now(), - action=UserAction.EDITED, - ) + user_action = UserActionLogEntry.get_edited_action(user) geometry = geom_form.save(user_action) self.instance.geometry = geometry @@ -356,8 +348,10 @@ class EditInterventionForm(NewInterventionForm): self.instance.save() # Uncheck and unrecord intervention due to changed data - self.instance.set_unchecked() - self.instance.set_unrecorded(user) + if self.instance.checked: + self.instance.set_unchecked() + if self.instance.recorded: + self.instance.set_unrecorded(user) return self.instance diff --git a/intervention/forms/modalForms.py b/intervention/forms/modalForms.py index fb6ee083..26aaa6b0 100644 --- a/intervention/forms/modalForms.py +++ b/intervention/forms/modalForms.py @@ -10,19 +10,17 @@ from django.contrib.auth.models import User from django.db import transaction from django import forms from django.urls import reverse -from django.utils.translation import gettext_lazy as _ +from django.utils.translation import gettext_lazy as _ -from compensation.models import EcoAccount, EcoAccountDeduction +from compensation.models import EcoAccount from intervention.inputs import TextToClipboardInput -from intervention.models import Revocation, RevocationDocument, Intervention -from konova.forms import BaseModalForm +from intervention.models import Intervention, InterventionDocument +from konova.forms import BaseModalForm, NewDocumentForm from konova.utils.general import format_german_float -from konova.utils.messenger import Messenger from konova.utils.user_checks import is_default_group_only -from user.models import UserActionLogEntry, UserAction -class ShareInterventionModalForm(BaseModalForm): +class ShareModalForm(BaseModalForm): url = forms.CharField( label=_("Share link"), label_suffix="", @@ -118,13 +116,7 @@ class ShareInterventionModalForm(BaseModalForm): ) def save(self): - still_accessing_users = self.cleaned_data["users"] - new_accessing_users = list(self.cleaned_data["user_select"].values_list("id", flat=True)) - accessing_users = still_accessing_users + new_accessing_users - users = User.objects.filter( - id__in=accessing_users - ) - self.instance.share_with_list(users) + self.instance.update_sharing_user(self) class NewRevocationModalForm(BaseModalForm): @@ -176,33 +168,7 @@ class NewRevocationModalForm(BaseModalForm): } def save(self): - with transaction.atomic(): - created_action = UserActionLogEntry.objects.create( - user=self.user, - action=UserAction.CREATED - ) - edited_action = UserActionLogEntry.objects.create( - user=self.user, - action=UserAction.EDITED - ) - revocation = Revocation.objects.create( - date=self.cleaned_data["date"], - legal=self.instance.legal, - comment=self.cleaned_data["comment"], - created=created_action, - ) - self.instance.modified = edited_action - self.instance.save() - self.instance.log.add(edited_action) - - if self.cleaned_data["file"]: - RevocationDocument.objects.create( - title="revocation_of_{}".format(self.instance.identifier), - date_of_creation=self.cleaned_data["date"], - comment=self.cleaned_data["comment"], - file=self.cleaned_data["file"], - instance=revocation - ) + revocation = self.instance.add_revocation(self) return revocation @@ -261,16 +227,6 @@ class CheckModalForm(BaseModalForm): with transaction.atomic(): self.instance.toggle_checked(self.user) - # Send message to the SSO server - messenger = Messenger( - self.instance.users.all(), - type="INFO", - ) - messenger.send_object_checked( - self.instance.identifier, - self.user, - ) - class NewDeductionModalForm(BaseModalForm): """ Form for creating new deduction @@ -326,14 +282,12 @@ class NewDeductionModalForm(BaseModalForm): super().__init__(*args, **kwargs) self.form_title = _("New Deduction") self.form_caption = _("Enter the information for a new deduction from a chosen eco-account") - self.is_intervention_initially = False # Check for Intervention or EcoAccount if isinstance(self.instance, Intervention): # Form has been called with a given intervention self.initialize_form_field("intervention", self.instance) self.disable_form_field("intervention") - self.is_intervention_initially = True elif isinstance(self.instance, EcoAccount): # Form has been called with a given account --> make it initial in the form and read-only self.initialize_form_field("account", self.instance) @@ -350,10 +304,7 @@ class NewDeductionModalForm(BaseModalForm): is_valid (bool) """ super_result = super().is_valid() - if self.is_intervention_initially: - acc = self.cleaned_data["account"] - else: - acc = self.instance + acc = self.cleaned_data["account"] if not acc.recorded: self.add_error( @@ -367,7 +318,7 @@ class NewDeductionModalForm(BaseModalForm): sum_surface_deductions = acc.get_deductions_surface() rest_surface = deductable_surface - sum_surface_deductions form_surface = float(self.cleaned_data["surface"]) - is_valid_surface = form_surface < rest_surface + is_valid_surface = form_surface <= rest_surface if not is_valid_surface: self.add_error( "surface", @@ -380,33 +331,9 @@ class NewDeductionModalForm(BaseModalForm): return is_valid_surface and super_result def save(self): - with transaction.atomic(): - # Create log entry - user_action_edit = UserActionLogEntry.objects.create( - user=self.user, - action=UserAction.EDITED - ) - user_action_create = UserActionLogEntry.objects.create( - user=self.user, - action=UserAction.CREATED - ) - self.instance.log.add(user_action_edit) - self.instance.modified = user_action_edit - self.instance.save() + deduction = self.instance.add_deduction(self) + return deduction - # Create deductions depending on Intervention or EcoAccount as the initial instance - if self.is_intervention_initially: - deduction = EcoAccountDeduction.objects.create( - intervention=self.instance, - account=self.cleaned_data["account"], - surface=self.cleaned_data["surface"], - created=user_action_create, - ) - else: - deduction = EcoAccountDeduction.objects.create( - intervention=self.cleaned_data["intervention"], - account=self.instance, - surface=self.cleaned_data["surface"], - created=user_action_create, - ) - return deduction \ No newline at end of file + +class NewInterventionDocumentForm(NewDocumentForm): + document_model = InterventionDocument diff --git a/intervention/models/__init__.py b/intervention/models/__init__.py new file mode 100644 index 00000000..97b96910 --- /dev/null +++ b/intervention/models/__init__.py @@ -0,0 +1,12 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 15.11.21 + +""" + +from .intervention import * +from .legal import * +from .revocation import * +from .responsibility import * diff --git a/intervention/models.py b/intervention/models/intervention.py similarity index 55% rename from intervention/models.py rename to intervention/models/intervention.py index 6043e422..225c6bb7 100644 --- a/intervention/models.py +++ b/intervention/models/intervention.py @@ -2,187 +2,41 @@ Author: Michel Peltriaux Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany Contact: michel.peltriaux@sgdnord.rlp.de -Created on: 17.11.20 +Created on: 15.11.21 """ import shutil from django.contrib.auth.models import User -from django.contrib.gis.db import models +from django.db import models, transaction from django.db.models import QuerySet +from django.utils.translation import gettext_lazy as _ -from codelist.models import KonovaCode -from codelist.settings import CODELIST_REGISTRATION_OFFICE_ID, CODELIST_CONSERVATION_OFFICE_ID, CODELIST_LAW_ID, \ - CODELIST_PROCESS_TYPE_ID +from compensation.models import EcoAccountDeduction from intervention.managers import InterventionManager +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 BaseObject, Geometry, UuidModel, BaseResource, AbstractDocument, \ - generate_document_file_upload_path, RecordableObject, CheckableObject, ShareableObject -from konova.settings import DEFAULT_SRID_RLP, LANIS_LINK_TEMPLATE, LANIS_ZOOM_LUT +from konova.models import generate_document_file_upload_path, AbstractDocument, Geometry, BaseObject, ShareableObjectMixin, \ + RecordableObjectMixin, CheckableObjectMixin +from konova.settings import LANIS_LINK_TEMPLATE, LANIS_ZOOM_LUT, DEFAULT_SRID_RLP from user.models import UserActionLogEntry -class ResponsibilityData(UuidModel): - """ - Holds intervention data about responsible organizations and their file numbers for this case - - """ - registration_office = models.ForeignKey( - KonovaCode, - on_delete=models.SET_NULL, - null=True, - related_name="+", - blank=True, - limit_choices_to={ - "code_lists__in": [CODELIST_REGISTRATION_OFFICE_ID], - "is_selectable": True, - "is_archived": False, - } - ) - registration_file_number = models.CharField(max_length=1000, blank=True, null=True) - conservation_office = models.ForeignKey( - KonovaCode, - on_delete=models.SET_NULL, - null=True, - related_name="+", - blank=True, - limit_choices_to={ - "code_lists__in": [CODELIST_CONSERVATION_OFFICE_ID], - "is_selectable": True, - "is_archived": False, - } - ) - conservation_file_number = models.CharField(max_length=1000, blank=True, null=True) - handler = models.CharField(max_length=500, null=True, blank=True, help_text="Refers to 'Eingriffsverursacher' or 'Maßnahmenträger'") - - def __str__(self): - return "ZB: {} | ETS: {} | Handler: {}".format( - self.registration_office, - self.conservation_office, - self.handler - ) - - -class Revocation(BaseResource): - """ - Holds revocation data e.g. for intervention objects - """ - date = models.DateField(null=True, blank=True, help_text="Revocation from") - legal = models.ForeignKey("LegalData", null=False, blank=False, on_delete=models.CASCADE, help_text="Refers to 'Widerspruch am'", related_name="revocations") - comment = models.TextField(null=True, blank=True) - - def delete(self, *args, **kwargs): - # Make sure related objects are being removed as well - if self.document: - self.document.delete(*args, **kwargs) - super().delete() - - -class RevocationDocument(AbstractDocument): - """ - Specializes document upload for revocations with certain path - """ - instance = models.OneToOneField( - Revocation, - on_delete=models.CASCADE, - related_name="document", - ) - file = models.FileField( - upload_to=generate_document_file_upload_path, - max_length=1000, - ) - - @property - def intervention(self): - """ - Shortcut for opening the related intervention - - Returns: - intervention (Intervention) - """ - return self.instance.legal.intervention - - def delete(self, *args, **kwargs): - """ - Custom delete functionality for RevocationDocuments. - Removes the folder from the file system if there are no further documents for this entry. - - Args: - *args (): - **kwargs (): - - Returns: - - """ - revoc_docs, other_intervention_docs = self.intervention.get_documents() - - # Remove the file itself - super().delete(*args, **kwargs) - - # Always remove 'revocation' folder if the one revocation we just processed is the only one left - folder_path = self.file.path.split("/") - if revoc_docs.count() == 0: - try: - shutil.rmtree("/".join(folder_path[:-1])) - except FileNotFoundError: - # Revocation subfolder seems to be missing already - pass - - if other_intervention_docs.count() == 0: - # If there are no further documents for the intervention, we can simply remove the whole folder as well! - try: - shutil.rmtree("/".join(folder_path[:-2])) - except FileNotFoundError: - # Folder seems to be missing already - pass - - -class LegalData(UuidModel): - """ - Holds intervention legal data such as important dates, laws or responsible handler - """ - # Refers to "zugelassen am" - registration_date = models.DateField(null=True, blank=True, help_text="Refers to 'Zugelassen am'") - - # Refers to "Bestandskraft am" - binding_date = models.DateField(null=True, blank=True, help_text="Refers to 'Bestandskraft am'") - - process_type = models.ForeignKey( - KonovaCode, - on_delete=models.SET_NULL, - null=True, - related_name="+", - blank=True, - limit_choices_to={ - "code_lists__in": [CODELIST_PROCESS_TYPE_ID], - "is_selectable": True, - "is_archived": False, - } - ) - laws = models.ManyToManyField( - KonovaCode, - blank=True, - limit_choices_to={ - "code_lists__in": [CODELIST_LAW_ID], - "is_selectable": True, - "is_archived": False, - } - ) - - -class Intervention(BaseObject, ShareableObject, RecordableObject, CheckableObject): +class Intervention(BaseObject, ShareableObjectMixin, RecordableObjectMixin, CheckableObjectMixin): """ Interventions are e.g. construction sites where nature used to be. """ responsible = models.OneToOneField( - ResponsibilityData, + Responsibility, on_delete=models.SET_NULL, null=True, blank=True, help_text="Holds data on responsible organizations ('Zulassungsbehörde', 'Eintragungsstelle')" ) legal = models.OneToOneField( - LegalData, + Legal, on_delete=models.SET_NULL, null=True, blank=True, @@ -322,6 +176,98 @@ class Intervention(BaseObject, ShareableObject, RecordableObject, CheckableObjec for comp in comps: comp.log.add(log_entry) + def add_payment(self, form): + """ Adds a new payment to the intervention + + Args: + form (NewPaymentForm): The form holding the data + + Returns: + + """ + from compensation.models import Payment + form_data = form.cleaned_data + user = form.user + with transaction.atomic(): + created_action = UserActionLogEntry.get_created_action(user) + edited_action = UserActionLogEntry.get_edited_action(user, _("Added payment")) + + pay = Payment.objects.create( + created=created_action, + amount=form_data.get("amount", -1), + due_on=form_data.get("due", None), + comment=form_data.get("comment", None), + intervention=self, + ) + self.log.add(edited_action) + self.modified = edited_action + self.save() + return pay + + def add_revocation(self, form): + """ Adds a new revocation to the intervention + + Args: + form (NewRevocationModalForm): The form holding the data + + Returns: + + """ + form_data = form.cleaned_data + user = form.user + with transaction.atomic(): + created_action = UserActionLogEntry.get_created_action(user) + edited_action = UserActionLogEntry.get_edited_action(user) + + revocation = Revocation.objects.create( + date=form_data["date"], + legal=self.legal, + comment=form_data["comment"], + created=created_action, + ) + self.modified = edited_action + self.save() + self.log.add(edited_action) + + if form_data["file"]: + RevocationDocument.objects.create( + title="revocation_of_{}".format(self.identifier), + date_of_creation=form_data["date"], + comment=form_data["comment"], + file=form_data["file"], + instance=revocation + ) + return revocation + + def add_deduction(self, form): + """ Adds a new deduction to the intervention + + Args: + form (NewDeductionModalForm): The form holding the data + + Returns: + + """ + form_data = form.cleaned_data + user = form.user + + with transaction.atomic(): + # Create log entry + user_action_edit = UserActionLogEntry.get_edited_action(user) + user_action_create = UserActionLogEntry.get_created_action(user) + + self.log.add(user_action_edit) + self.modified = user_action_edit + self.save() + + deduction = EcoAccountDeduction.objects.create( + intervention=self, + account=form_data["account"], + surface=form_data["surface"], + created=user_action_create, + ) + return deduction + class InterventionDocument(AbstractDocument): """ @@ -366,7 +312,6 @@ class InterventionDocument(AbstractDocument): if folder_path is not None: try: shutil.rmtree(folder_path) - pass except FileNotFoundError: # Folder seems to be missing already... pass diff --git a/intervention/models/legal.py b/intervention/models/legal.py new file mode 100644 index 00000000..032bb015 --- /dev/null +++ b/intervention/models/legal.py @@ -0,0 +1,46 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 15.11.21 + +""" +from django.db import models + +from codelist.models import KonovaCode +from codelist.settings import CODELIST_LAW_ID, CODELIST_PROCESS_TYPE_ID +from konova.models import UuidModel + + +class Legal(UuidModel): + """ + Holds intervention legal data such as important dates, laws or responsible handler + """ + # Refers to "zugelassen am" + registration_date = models.DateField(null=True, blank=True, help_text="Refers to 'Zugelassen am'") + + # Refers to "Bestandskraft am" + binding_date = models.DateField(null=True, blank=True, help_text="Refers to 'Bestandskraft am'") + + process_type = models.ForeignKey( + KonovaCode, + on_delete=models.SET_NULL, + null=True, + related_name="+", + blank=True, + limit_choices_to={ + "code_lists__in": [CODELIST_PROCESS_TYPE_ID], + "is_selectable": True, + "is_archived": False, + } + ) + laws = models.ManyToManyField( + KonovaCode, + blank=True, + limit_choices_to={ + "code_lists__in": [CODELIST_LAW_ID], + "is_selectable": True, + "is_archived": False, + } + ) + diff --git a/intervention/models/responsibility.py b/intervention/models/responsibility.py new file mode 100644 index 00000000..19234acd --- /dev/null +++ b/intervention/models/responsibility.py @@ -0,0 +1,53 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 15.11.21 + +""" +from django.db import models + +from codelist.models import KonovaCode +from codelist.settings import CODELIST_CONSERVATION_OFFICE_ID, CODELIST_REGISTRATION_OFFICE_ID +from konova.models import UuidModel + + +class Responsibility(UuidModel): + """ + Holds intervention data about responsible organizations and their file numbers for this case + + """ + registration_office = models.ForeignKey( + KonovaCode, + on_delete=models.SET_NULL, + null=True, + related_name="+", + blank=True, + limit_choices_to={ + "code_lists__in": [CODELIST_REGISTRATION_OFFICE_ID], + "is_selectable": True, + "is_archived": False, + } + ) + registration_file_number = models.CharField(max_length=1000, blank=True, null=True) + conservation_office = models.ForeignKey( + KonovaCode, + on_delete=models.SET_NULL, + null=True, + related_name="+", + blank=True, + limit_choices_to={ + "code_lists__in": [CODELIST_CONSERVATION_OFFICE_ID], + "is_selectable": True, + "is_archived": False, + } + ) + conservation_file_number = models.CharField(max_length=1000, blank=True, null=True) + handler = models.CharField(max_length=500, null=True, blank=True, help_text="Refers to 'Eingriffsverursacher' or 'Maßnahmenträger'") + + def __str__(self): + return "ZB: {} | ETS: {} | Handler: {}".format( + self.registration_office, + self.conservation_office, + self.handler + ) diff --git a/intervention/models/revocation.py b/intervention/models/revocation.py new file mode 100644 index 00000000..5eb70e5e --- /dev/null +++ b/intervention/models/revocation.py @@ -0,0 +1,87 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 15.11.21 + +""" + +import shutil + +from django.contrib.gis.db import models +from konova.models import BaseResource, AbstractDocument, generate_document_file_upload_path + + +class Revocation(BaseResource): + """ + Holds revocation data e.g. for intervention objects + """ + date = models.DateField(null=True, blank=True, help_text="Revocation from") + legal = models.ForeignKey("Legal", null=False, blank=False, on_delete=models.CASCADE, help_text="Refers to 'Widerspruch am'", related_name="revocations") + comment = models.TextField(null=True, blank=True) + + def delete(self, *args, **kwargs): + # Make sure related objects are being removed as well + if self.document: + self.document.delete(*args, **kwargs) + super().delete() + + +class RevocationDocument(AbstractDocument): + """ + Specializes document upload for revocations with certain path + """ + instance = models.OneToOneField( + Revocation, + on_delete=models.CASCADE, + related_name="document", + ) + file = models.FileField( + upload_to=generate_document_file_upload_path, + max_length=1000, + ) + + @property + def intervention(self): + """ + Shortcut for opening the related intervention + + Returns: + intervention (Intervention) + """ + return self.instance.legal.intervention + + def delete(self, *args, **kwargs): + """ + Custom delete functionality for RevocationDocuments. + Removes the folder from the file system if there are no further documents for this entry. + + Args: + *args (): + **kwargs (): + + Returns: + + """ + revoc_docs, other_intervention_docs = self.intervention.get_documents() + + # Remove the file itself + super().delete(*args, **kwargs) + + # Always remove 'revocation' folder if the one revocation we just processed is the only one left + folder_path = self.file.path.split("/") + if revoc_docs.count() == 0: + try: + shutil.rmtree("/".join(folder_path[:-1])) + except FileNotFoundError: + # Revocation subfolder seems to be missing already + pass + + if other_intervention_docs.count() == 0: + # If there are no further documents for the intervention, we can simply remove the whole folder as well! + try: + shutil.rmtree("/".join(folder_path[:-2])) + except FileNotFoundError: + # Folder seems to be missing already + pass + diff --git a/intervention/tests/test_workflow.py b/intervention/tests/test_workflow.py index f4484c6d..23a3c116 100644 --- a/intervention/tests/test_workflow.py +++ b/intervention/tests/test_workflow.py @@ -356,10 +356,7 @@ class InterventionWorkflowTestCase(BaseWorkflowTestCase): # Prepare the account for a working situation (enough deductable surface, recorded and shared) self.eco_account.deductable_surface = 10000.00 if self.eco_account.recorded is None: - rec_action = UserActionLogEntry.objects.create( - user=self.superuser, - action=UserAction.RECORDED - ) + rec_action = UserActionLogEntry.get_recorded_action(self.superuser) self.eco_account.recorded = rec_action self.eco_account.share_with_list([self.superuser]) self.eco_account.save() diff --git a/intervention/utils/quality.py b/intervention/utils/quality.py index b4717b18..e15b4ca4 100644 --- a/intervention/utils/quality.py +++ b/intervention/utils/quality.py @@ -24,7 +24,7 @@ class InterventionQualityChecker(AbstractQualityChecker): self.valid = len(self.messages) == 0 def _check_responsible_data(self): - """ Checks data quality of related ResponsibilityData + """ Checks data quality of related Responsibility Args: self.messages (dict): Holds error messages @@ -55,7 +55,7 @@ class InterventionQualityChecker(AbstractQualityChecker): self._add_missing_attr_name(_("Responsible data")) def _check_legal_data(self): - """ Checks data quality of related LegalData + """ Checks data quality of related Legal Args: self.messages (dict): Holds error messages diff --git a/intervention/views.py b/intervention/views.py index c2a87d6e..512285be 100644 --- a/intervention/views.py +++ b/intervention/views.py @@ -4,8 +4,8 @@ from django.http import HttpRequest, JsonResponse from django.shortcuts import render from intervention.forms.forms import NewInterventionForm, EditInterventionForm -from intervention.forms.modalForms import ShareInterventionModalForm, NewRevocationModalForm, \ - CheckModalForm, NewDeductionModalForm +from intervention.forms.modalForms import ShareModalForm, NewRevocationModalForm, \ + CheckModalForm, NewDeductionModalForm, NewInterventionDocumentForm from intervention.models import Intervention, Revocation, InterventionDocument, RevocationDocument from intervention.tables import InterventionTable from konova.contexts import BaseContext @@ -123,7 +123,7 @@ def new_document_view(request: HttpRequest, id: str): """ intervention = get_object_or_404(Intervention, id=id) - form = NewDocumentForm(request.POST or None, request.FILES or None, instance=intervention, user=request.user) + form = NewInterventionDocumentForm(request.POST or None, request.FILES or None, instance=intervention, user=request.user) return form.process_request( request, msg_success=_("Document added") @@ -402,7 +402,7 @@ def create_share_view(request: HttpRequest, id: str): """ intervention = get_object_or_404(Intervention, id=id) - form = ShareInterventionModalForm(request.POST or None, instance=intervention, request=request, user=request.user) + form = ShareModalForm(request.POST or None, instance=intervention, request=request, user=request.user) return form.process_request( request, msg_success=_("Share settings updated") diff --git a/konova/forms.py b/konova/forms.py index 82d46c8a..5e644e5c 100644 --- a/konova/forms.py +++ b/konova/forms.py @@ -21,14 +21,11 @@ from django.shortcuts import render from django.utils import timezone from django.utils.translation import gettext_lazy as _ -from compensation.models import EcoAccount, Compensation, EcoAccountDocument, CompensationDocument -from ema.models import Ema, EmaDocument -from intervention.models import Intervention, Revocation, RevocationDocument, InterventionDocument from konova.contexts import BaseContext -from konova.models import BaseObject, Geometry +from konova.models import BaseObject, Geometry, RecordableObjectMixin from konova.settings import DEFAULT_SRID from konova.utils.message_templates import FORM_INVALID -from user.models import UserActionLogEntry, UserAction +from user.models import UserActionLogEntry class BaseForm(forms.Form): @@ -171,11 +168,7 @@ class RemoveForm(BaseForm): if self.object_to_remove is not None and self.is_checked(): with transaction.atomic(): self.object_to_remove.is_active = False - action = UserActionLogEntry.objects.create( - user=user, - timestamp=timezone.now(), - action=UserAction.DELETED - ) + action = UserActionLogEntry.get_deleted_action(user) self.object_to_remove.deleted = action self.object_to_remove.save() return self.object_to_remove @@ -293,7 +286,7 @@ class SimpleGeomForm(BaseForm): geometry.geom = self.cleaned_data.get("geom", MultiPolygon(srid=DEFAULT_SRID)) geometry.modified = action geometry.save() - except (AttributeError) as e: + except AttributeError: # 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)), @@ -382,13 +375,10 @@ class NewDocumentForm(BaseModalForm): } ) ) - document_instance_map = { - Intervention: InterventionDocument, - Compensation: CompensationDocument, - EcoAccount: EcoAccountDocument, - Revocation: RevocationDocument, - Ema: EmaDocument, - } + document_model = None + + class Meta: + abstract = True def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -398,20 +388,15 @@ class NewDocumentForm(BaseModalForm): self.form_attrs = { "enctype": "multipart/form-data", # important for file upload } - self.document_type = self.document_instance_map.get( - self.instance.__class__, - None - ) - if not self.document_type: + if not self.document_model: raise NotImplementedError("Unsupported document type for {}".format(self.instance.__class__)) def save(self): with transaction.atomic(): - action = UserActionLogEntry.objects.create( - user=self.user, - action=UserAction.CREATED, - ) - doc = self.document_type.objects.create( + 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"], @@ -420,11 +405,6 @@ class NewDocumentForm(BaseModalForm): instance=self.instance, ) - edited_action = UserActionLogEntry.objects.create( - user=self.user, - action=UserAction.EDITED, - comment=_("Added document"), - ) self.instance.log.add(edited_action) self.instance.modified = edited_action self.instance.save() @@ -456,13 +436,7 @@ class RecordModalForm(BaseModalForm): 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) - implemented_cls_logic = { - Intervention, - EcoAccount, - Ema, - } - instance_name = self.instance.__class__ - if instance_name not in implemented_cls_logic: + if not isinstance(self.instance, RecordableObjectMixin): raise NotImplementedError def is_valid(self): @@ -471,6 +445,7 @@ class RecordModalForm(BaseModalForm): 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 diff --git a/konova/models/__init__.py b/konova/models/__init__.py new file mode 100644 index 00000000..c60ecaa9 --- /dev/null +++ b/konova/models/__init__.py @@ -0,0 +1,11 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 15.11.21 + +""" +from .object import * +from .deadline import * +from .document import * +from .geometry import * diff --git a/konova/models/deadline.py b/konova/models/deadline.py new file mode 100644 index 00000000..7cad8a65 --- /dev/null +++ b/konova/models/deadline.py @@ -0,0 +1,49 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 15.11.21 + +""" +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from konova.models import BaseResource + + +class DeadlineType(models.TextChoices): + """ + Django 3.x way of handling enums for models + """ + FINISHED = "finished", _("Finished") + MAINTAIN = "maintain", _("Maintain") + CONTROL = "control", _("Control") + OTHER = "other", _("Other") + + +class Deadline(BaseResource): + """ + Defines a deadline, which can be used to define dates with a semantic meaning + """ + + type = models.CharField(max_length=255, null=True, blank=True, choices=DeadlineType.choices) + date = models.DateField(null=True, blank=True) + comment = models.TextField(null=True, blank=True) + + def __str__(self): + return self.type + + @property + def type_humanized(self): + """ Returns humanized version of enum + + Used for template rendering + + Returns: + + """ + choices = DeadlineType.choices + for choice in choices: + if choice[0] == self.type: + return choice[1] + return None diff --git a/konova/models/document.py b/konova/models/document.py new file mode 100644 index 00000000..11b6c06d --- /dev/null +++ b/konova/models/document.py @@ -0,0 +1,84 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 15.11.21 + +""" +import os + +from django.db import models + +from konova.models import BaseResource +from konova.settings import INTERVENTION_REVOCATION_DOC_PATH + + +def generate_document_file_upload_path(instance, filename): + """ Generates the file upload path for certain document instances + + Documents derived from AbstractDocument need specific upload paths for their related models. + + Args: + instance (): The document instance + filename (): The filename + + Returns: + + """ + from compensation.models import CompensationDocument, EcoAccountDocument + from ema.models import EmaDocument + from intervention.models import InterventionDocument, RevocationDocument + from konova.settings import ECO_ACCOUNT_DOC_PATH, EMA_DOC_PATH, \ + COMPENSATION_DOC_PATH, \ + INTERVENTION_DOC_PATH + + # Map document types to paths on the hard drive + path_map = { + InterventionDocument: INTERVENTION_DOC_PATH, + CompensationDocument: COMPENSATION_DOC_PATH, + EmaDocument: EMA_DOC_PATH, + RevocationDocument: INTERVENTION_REVOCATION_DOC_PATH, + EcoAccountDocument: ECO_ACCOUNT_DOC_PATH, + } + path = path_map.get(instance.__class__, None) + if path is None: + raise NotImplementedError("Unidentified document type: {}".format(instance.__class__)) + + # RevocationDocument needs special treatment, since these files need to be stored in a subfolder of the related + # instance's (Revocation) legaldata interventions folder + if instance.__class__ is RevocationDocument: + path = path.format(instance.intervention.id) + else: + path = path.format(instance.instance.id) + return path + filename + + +class AbstractDocument(BaseResource): + """ + Documents can be attached to compensation or intervention for uploading legal documents or pictures. + """ + title = models.CharField(max_length=500, null=True, blank=True) + date_of_creation = models.DateField() + file = models.FileField() + comment = models.TextField() + + class Meta: + abstract = True + + def delete(self, using=None, keep_parents=False): + """ Custom delete function to remove the real file from the hard drive + + Args: + using (): + keep_parents (): + + Returns: + + """ + try: + os.remove(self.file.file.name) + except FileNotFoundError: + # File seems to missing anyway - continue! + pass + super().delete(using=using, keep_parents=keep_parents) + diff --git a/konova/models/geometry.py b/konova/models/geometry.py new file mode 100644 index 00000000..736e1b3b --- /dev/null +++ b/konova/models/geometry.py @@ -0,0 +1,18 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 15.11.21 + +""" +from django.contrib.gis.db.models import MultiPolygonField + +from konova.models import BaseResource + + +class Geometry(BaseResource): + """ + Outsourced geometry model so multiple versions of the same object can refer to the same geometry if it is not changed + """ + from konova.settings import DEFAULT_SRID + geom = MultiPolygonField(null=True, blank=True, srid=DEFAULT_SRID) \ No newline at end of file diff --git a/konova/models.py b/konova/models/object.py similarity index 69% rename from konova/models.py rename to konova/models/object.py index 7dbe4f73..9eeb3fbc 100644 --- a/konova/models.py +++ b/konova/models/object.py @@ -2,25 +2,21 @@ Author: Michel Peltriaux Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany Contact: michel.peltriaux@sgdnord.rlp.de -Created on: 17.11.20 +Created on: 15.11.21 """ -import os + import uuid from django.contrib.auth.models import User from django.core.exceptions import ObjectDoesNotExist from django.utils import timezone from django.utils.timezone import now -from django.utils.translation import gettext_lazy as _ -from django.contrib.gis.db.models import MultiPolygonField from django.db import models, transaction - from compensation.settings import COMPENSATION_IDENTIFIER_TEMPLATE, COMPENSATION_IDENTIFIER_LENGTH, \ ECO_ACCOUNT_IDENTIFIER_TEMPLATE, ECO_ACCOUNT_IDENTIFIER_LENGTH from ema.settings import EMA_ACCOUNT_IDENTIFIER_LENGTH, EMA_ACCOUNT_IDENTIFIER_TEMPLATE from intervention.settings import INTERVENTION_IDENTIFIER_LENGTH, INTERVENTION_IDENTIFIER_TEMPLATE -from konova.settings import INTERVENTION_REVOCATION_DOC_PATH from konova.utils import generators from konova.utils.generators import generate_random_string from user.models import UserActionLogEntry, UserAction @@ -75,7 +71,7 @@ class BaseResource(UuidModel): """ try: self.created.delete() - except (ObjectDoesNotExist, AttributeError) as e: + except (ObjectDoesNotExist, AttributeError): # Object does not exist anymore - we can skip this pass super().delete() @@ -112,11 +108,7 @@ class BaseObject(BaseResource): return with transaction.atomic(): - action = UserActionLogEntry.objects.create( - user=user, - action=UserAction.DELETED, - timestamp=timezone.now() - ) + action = UserActionLogEntry.get_deleted_action(user) self.deleted = action self.log.add(action) self.save() @@ -139,45 +131,6 @@ class BaseObject(BaseResource): ) self.log.add(user_action) - def is_shared_with(self, user: User): - """ Access check - - Checks whether a given user has access to this object - - Args: - user (): - - Returns: - - """ - if isinstance(self, ShareableObject): - return self.users.filter(id=user.id) - else: - return User.objects.none() - - def share_with(self, user: User): - """ Adds user to list of shared access users - - Args: - user (User): The user to be added to the object - - Returns: - - """ - if not self.is_shared_with(user): - self.users.add(user) - - def share_with_list(self, user_list: list): - """ Sets the list of shared access users - - Args: - user_list (list): The users to be added to the object - - Returns: - - """ - self.users.set(user_list) - def generate_new_identifier(self) -> str: """ Generates a new identifier for the intervention object @@ -224,123 +177,7 @@ class BaseObject(BaseResource): return definitions[self.__class__]["template"].format(_str) -class DeadlineType(models.TextChoices): - """ - Django 3.x way of handling enums for models - """ - FINISHED = "finished", _("Finished") - MAINTAIN = "maintain", _("Maintain") - CONTROL = "control", _("Control") - OTHER = "other", _("Other") - - -class Deadline(BaseResource): - """ - Defines a deadline, which can be used to define dates with a semantic meaning - """ - - type = models.CharField(max_length=255, null=True, blank=True, choices=DeadlineType.choices) - date = models.DateField(null=True, blank=True) - comment = models.TextField(null=True, blank=True) - - def __str__(self): - return self.type - - @property - def type_humanized(self): - """ Returns humanized version of enum - - Used for template rendering - - Returns: - - """ - choices = DeadlineType.choices - for choice in choices: - if choice[0] == self.type: - return choice[1] - return None - - -def generate_document_file_upload_path(instance, filename): - """ Generates the file upload path for certain document instances - - Documents derived from AbstractDocument need specific upload paths for their related models. - - Args: - instance (): The document instance - filename (): The filename - - Returns: - - """ - from compensation.models import CompensationDocument, EcoAccountDocument - from ema.models import EmaDocument - from intervention.models import InterventionDocument, RevocationDocument - from konova.settings import ECO_ACCOUNT_DOC_PATH, EMA_DOC_PATH, \ - COMPENSATION_DOC_PATH, \ - INTERVENTION_DOC_PATH - - # Map document types to paths on the hard drive - path_map = { - InterventionDocument: INTERVENTION_DOC_PATH, - CompensationDocument: COMPENSATION_DOC_PATH, - EmaDocument: EMA_DOC_PATH, - RevocationDocument: INTERVENTION_REVOCATION_DOC_PATH, - EcoAccountDocument: ECO_ACCOUNT_DOC_PATH, - } - path = path_map.get(instance.__class__, None) - if path is None: - raise NotImplementedError("Unidentified document type: {}".format(instance.__class__)) - - # RevocationDocument needs special treatment, since these files need to be stored in a subfolder of the related - # instance's (Revocation) legaldata interventions folder - if instance.__class__ is RevocationDocument: - path = path.format(instance.intervention.id) - else: - path = path.format(instance.instance.id) - return path + filename - - -class AbstractDocument(BaseResource): - """ - Documents can be attached to compensation or intervention for uploading legal documents or pictures. - """ - title = models.CharField(max_length=500, null=True, blank=True) - date_of_creation = models.DateField() - file = models.FileField() - comment = models.TextField() - - class Meta: - abstract = True - - def delete(self, using=None, keep_parents=False): - """ Custom delete function to remove the real file from the hard drive - - Args: - using (): - keep_parents (): - - Returns: - - """ - try: - os.remove(self.file.file.name) - except FileNotFoundError: - # File seems to missing anyway - continue! - pass - super().delete(using=using, keep_parents=keep_parents) - - -class Geometry(BaseResource): - """ - Outsourced geometry model so multiple versions of the same object can refer to the same geometry if it is not changed - """ - from konova.settings import DEFAULT_SRID - geom = MultiPolygonField(null=True, blank=True, srid=DEFAULT_SRID) - - -class RecordableObject(models.Model): +class RecordableObjectMixin(models.Model): """ Wraps record related fields and functionality """ @@ -366,10 +203,7 @@ class RecordableObject(models.Model): Returns: """ - action = UserActionLogEntry.objects.create( - user=user, - action=UserAction.UNRECORDED - ) + action = UserActionLogEntry.get_unrecorded_action(user) self.recorded = None self.save() self.log.add(action) @@ -384,10 +218,7 @@ class RecordableObject(models.Model): Returns: """ - action = UserActionLogEntry.objects.create( - user=user, - action=UserAction.RECORDED - ) + action = UserActionLogEntry.get_recorded_action(user) self.recorded = action self.save() self.log.add(action) @@ -409,7 +240,7 @@ class RecordableObject(models.Model): return ret_log_entry -class CheckableObject(models.Model): +class CheckableObjectMixin(models.Model): # Checks - Refers to "Genehmigen" but optional checked = models.OneToOneField( UserActionLogEntry, @@ -446,10 +277,7 @@ class CheckableObject(models.Model): Returns: """ - action = UserActionLogEntry.objects.create( - user=user, - action=UserAction.CHECKED - ) + action = UserActionLogEntry.get_checked_action(user) self.checked = action self.save() self.log.add(action) @@ -471,7 +299,7 @@ class CheckableObject(models.Model): return ret_log_entry -class ShareableObject(models.Model): +class ShareableObjectMixin(models.Model): # Users having access on this object users = models.ManyToManyField(User, help_text="Users having access (data shared with)") access_token = models.CharField( @@ -519,3 +347,58 @@ class ShareableObject(models.Model): else: self.access_token = token self.save() + + def is_shared_with(self, user: User): + """ Access check + + Checks whether a given user has access to this object + + Args: + user (): + + Returns: + + """ + return self.users.filter(id=user.id) + + def share_with(self, user: User): + """ Adds user to list of shared access users + + Args: + user (User): The user to be added to the object + + Returns: + + """ + if not self.is_shared_with(user): + self.users.add(user) + + def share_with_list(self, user_list: list): + """ Sets the list of shared access users + + Args: + user_list (list): The users to be added to the object + + Returns: + + """ + self.users.set(user_list) + + def update_sharing_user(self, form): + """ Adds a new user with shared access to the object + + Args: + form (ShareModalForm): The form holding the data + + Returns: + + """ + form_data = form.cleaned_data + + keep_accessing_users = form_data["users"] + new_accessing_users = list(form_data["user_select"].values_list("id", flat=True)) + accessing_users = keep_accessing_users + new_accessing_users + users = User.objects.filter( + id__in=accessing_users + ) + self.share_with_list(users) diff --git a/konova/tests/test_views.py b/konova/tests/test_views.py index 682a43f1..ae5d4524 100644 --- a/konova/tests/test_views.py +++ b/konova/tests/test_views.py @@ -15,12 +15,12 @@ from django.urls import reverse from codelist.models import KonovaCode from compensation.models import Compensation, CompensationState, CompensationAction, EcoAccount -from intervention.models import LegalData, ResponsibilityData, Intervention +from intervention.models import Legal, Responsibility, Intervention from konova.management.commands.setup_data import GROUPS_DATA from konova.models import Geometry from konova.settings import DEFAULT_GROUP from konova.utils.generators import generate_random_string -from user.models import UserActionLogEntry, UserAction +from user.models import UserActionLogEntry class BaseTestCase(TestCase): @@ -98,14 +98,11 @@ class BaseTestCase(TestCase): """ # Create dummy data # Create log entry - action = UserActionLogEntry.objects.create( - user=cls.superuser, - action=UserAction.CREATED, - ) + action = UserActionLogEntry.get_created_action(cls.superuser) # Create legal data object (without M2M laws first) - legal_data = LegalData.objects.create() + legal_data = Legal.objects.create() # Create responsible data object - responsibility_data = ResponsibilityData.objects.create() + responsibility_data = Responsibility.objects.create() geometry = Geometry.objects.create() # Finally create main object, holding the other objects intervention = Intervention.objects.create( @@ -131,10 +128,7 @@ class BaseTestCase(TestCase): cls.intervention = cls.create_dummy_intervention() # Create dummy data # Create log entry - action = UserActionLogEntry.objects.create( - user=cls.superuser, - action=UserAction.CREATED, - ) + action = UserActionLogEntry.get_created_action(cls.superuser) geometry = Geometry.objects.create() # Finally create main object, holding the other objects compensation = Compensation.objects.create( @@ -156,14 +150,11 @@ class BaseTestCase(TestCase): """ # Create dummy data # Create log entry - action = UserActionLogEntry.objects.create( - user=cls.superuser, - action=UserAction.CREATED, - ) + action = UserActionLogEntry.get_created_action(cls.superuser) geometry = Geometry.objects.create() # Create responsible data object - lega_data = LegalData.objects.create() - responsible_data = ResponsibilityData.objects.create() + lega_data = Legal.objects.create() + responsible_data = Responsibility.objects.create() # Finally create main object, holding the other objects eco_account = EcoAccount.objects.create( identifier="TEST", diff --git a/user/models/__init__.py b/user/models/__init__.py new file mode 100644 index 00000000..85a98b67 --- /dev/null +++ b/user/models/__init__.py @@ -0,0 +1,10 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 15.11.21 + +""" +from .user_action import * +from .konova_user import * +from .notification import * diff --git a/user/models/konova_user.py b/user/models/konova_user.py new file mode 100644 index 00000000..b100e02f --- /dev/null +++ b/user/models/konova_user.py @@ -0,0 +1,19 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 15.11.21 + +""" +from django.contrib.auth.models import User +from django.db import models + + +class KonovaUserExtension(models.Model): + """ Extension model for additional ksp features + + Extends the default user model for some extras + + """ + user = models.OneToOneField(User, on_delete=models.CASCADE) + notifications = models.ManyToManyField("user.UserNotification", related_name="+") diff --git a/user/models/notification.py b/user/models/notification.py new file mode 100644 index 00000000..c86b84b7 --- /dev/null +++ b/user/models/notification.py @@ -0,0 +1,34 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 15.11.21 + +""" +from django.db import models + +from user.enums import UserNotificationEnum + + +class UserNotification(models.Model): + """ Notifications for users + + """ + id = models.CharField( + max_length=500, + null=False, + blank=False, + choices=UserNotificationEnum.as_choices(drop_empty_choice=True), + primary_key=True, + ) + name = models.CharField( + max_length=500, + null=False, + blank=False, + unique=True, + help_text="Human readable name" + ) + is_active = models.BooleanField(default=True, help_text="Can be toggle to enable/disable this notification for all users") + + def __str__(self): + return self.name \ No newline at end of file diff --git a/user/models.py b/user/models/user_action.py similarity index 50% rename from user/models.py rename to user/models/user_action.py index a85159f5..c1894f7d 100644 --- a/user/models.py +++ b/user/models/user_action.py @@ -1,44 +1,15 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 15.11.21 + +""" import uuid -from django.utils.translation import gettext_lazy as _ from django.contrib.auth.models import User from django.db import models - -from user.enums import UserNotificationEnum - - -class UserNotification(models.Model): - """ Notifications for users - - """ - id = models.CharField( - max_length=500, - null=False, - blank=False, - choices=UserNotificationEnum.as_choices(drop_empty_choice=True), - primary_key=True, - ) - name = models.CharField( - max_length=500, - null=False, - blank=False, - unique=True, - help_text="Human readable name" - ) - is_active = models.BooleanField(default=True, help_text="Can be toggle to enable/disable this notification for all users") - - def __str__(self): - return self.name - - -class KonovaUserExtension(models.Model): - """ Extension model for additional ksp features - - Extends the default user model for some extras - - """ - user = models.OneToOneField(User, on_delete=models.CASCADE) - notifications = models.ManyToManyField(UserNotification, related_name="+") +from django.utils.translation import gettext_lazy as _ class UserAction(models.TextChoices): @@ -96,3 +67,57 @@ class UserActionLogEntry(models.Model): if choice[0] == self.action: return choice[1] return None + + @classmethod + def get_created_action(cls, user: User, comment: str = None): + action = UserActionLogEntry.objects.create( + user=user, + action=UserAction.CREATED, + comment=comment, + ) + return action + + @classmethod + def get_edited_action(cls, user: User, comment: str = None): + action = UserActionLogEntry.objects.create( + user=user, + action=UserAction.EDITED, + comment=comment, + ) + return action + + @classmethod + def get_deleted_action(cls, user: User, comment: str = None): + action = UserActionLogEntry.objects.create( + user=user, + action=UserAction.DELETED, + comment=comment, + ) + return action + + @classmethod + def get_checked_action(cls, user: User, comment: str = None): + action = UserActionLogEntry.objects.create( + user=user, + action=UserAction.CHECKED, + comment=comment, + ) + return action + + @classmethod + def get_recorded_action(cls, user: User, comment: str = None): + action = UserActionLogEntry.objects.create( + user=user, + action=UserAction.RECORDED, + comment=comment, + ) + return action + + @classmethod + def get_unrecorded_action(cls, user: User, comment: str = None): + action = UserActionLogEntry.objects.create( + user=user, + action=UserAction.UNRECORDED, + comment=comment, + ) + return action