diff --git a/compensation/account_urls.py b/compensation/account_urls.py index 5daa6c87..511ee601 100644 --- a/compensation/account_urls.py +++ b/compensation/account_urls.py @@ -21,8 +21,9 @@ urlpatterns = [ path('<id>/deadline/new', deadline_new_view, name="acc-new-deadline"), # Documents - # Document remove route can be found in konova/urls.py path('<id>/document/new/', new_document_view, name='acc-new-doc'), + path('document/<doc_id>', get_document_view, name='acc-get-doc'), + path('document/<doc_id>/remove/', remove_document_view, name='acc-remove-doc'), # Eco-account deductions path('<id>/remove/<deduction_id>', deduction_remove_view, name='deduction-remove'), diff --git a/compensation/comp_urls.py b/compensation/comp_urls.py index 1e79319b..84979d89 100644 --- a/compensation/comp_urls.py +++ b/compensation/comp_urls.py @@ -21,8 +21,9 @@ urlpatterns = [ path('<id>/deadline/new', deadline_new_view, name="new-deadline"), # Documents - # Document remove route can be found in konova/urls.py path('<id>/document/new/', new_document_view, name='new-doc'), + path('document/<doc_id>', get_document_view, name='get-doc'), + path('document/<doc_id>/remove/', remove_document_view, name='remove-doc'), # Generic state routes path('state/<id>/remove', state_remove_view, name='state-remove'), diff --git a/compensation/models.py b/compensation/models.py index 556f1000..2732f9c7 100644 --- a/compensation/models.py +++ b/compensation/models.py @@ -5,16 +5,19 @@ 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.validators import MinValueValidator -from django.db.models import Sum +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 intervention.models import Intervention, ResponsibilityData -from konova.models import BaseObject, BaseResource, Geometry, UuidModel +from konova.models import BaseObject, BaseResource, Geometry, UuidModel, AbstractDocument, \ + generate_document_file_upload_path from konova.settings import DEFAULT_SRID_RLP, LANIS_LINK_TEMPLATE from user.models import UserActionLogEntry @@ -137,7 +140,6 @@ class AbstractCompensation(BaseObject): deadlines = models.ManyToManyField("konova.Deadline", blank=True, related_name="+") geometry = models.ForeignKey(Geometry, null=True, blank=True, on_delete=models.SET_NULL) - documents = models.ManyToManyField("konova.Document", blank=True) class Meta: abstract = True @@ -197,6 +199,65 @@ class Compensation(AbstractCompensation): 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): """ @@ -298,6 +359,65 @@ class EcoAccount(AbstractCompensation): return ret_msgs + 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): """ diff --git a/compensation/templates/compensation/detail/compensation/includes/documents.html b/compensation/templates/compensation/detail/compensation/includes/documents.html index 3801f7c8..3548e940 100644 --- a/compensation/templates/compensation/detail/compensation/includes/documents.html +++ b/compensation/templates/compensation/detail/compensation/includes/documents.html @@ -39,14 +39,14 @@ {% for doc in obj.documents.all %} <tr> <td class="align-middle"> - <a href="{% url 'doc-open' doc.id %}"> + <a href="{% url 'compensation:get-doc' doc.id %}"> {{ doc.title }} </a> </td> <td class="align-middle">{{ doc.comment }}</td> <td> {% if is_default_member and has_access %} - <button data-form-url="{% url 'doc-remove' doc.id %}" class="btn btn-default btn-modal" title="{% trans 'Remove document' %}"> + <button data-form-url="{% url 'compensation:remove-doc' doc.id %}" class="btn btn-default btn-modal" title="{% trans 'Remove document' %}"> {% fa5_icon 'trash' %} </button> {% endif %} diff --git a/compensation/templates/compensation/detail/eco_account/includes/documents.html b/compensation/templates/compensation/detail/eco_account/includes/documents.html index 0c4b9ddd..bd371df2 100644 --- a/compensation/templates/compensation/detail/eco_account/includes/documents.html +++ b/compensation/templates/compensation/detail/eco_account/includes/documents.html @@ -39,14 +39,14 @@ {% for doc in obj.documents.all %} <tr> <td class="align-middle"> - <a href="{% url 'doc-open' doc.id %}"> + <a href="{% url 'compensation:acc-get-doc' doc.id %}"> {{ doc.title }} </a> </td> <td class="align-middle">{{ doc.comment }}</td> <td> {% if is_default_member and has_access %} - <button data-form-url="{% url 'doc-remove' doc.id %}" class="btn btn-default btn-modal" title="{% trans 'Remove document' %}"> + <button data-form-url="{% url 'compensation:acc-remove-doc' doc.id %}" class="btn btn-default btn-modal" title="{% trans 'Remove document' %}"> {% fa5_icon 'trash' %} </button> {% endif %} diff --git a/compensation/views/compensation_views.py b/compensation/views/compensation_views.py index 0403af29..08091075 100644 --- a/compensation/views/compensation_views.py +++ b/compensation/views/compensation_views.py @@ -5,11 +5,12 @@ from django.shortcuts import render, get_object_or_404 from django.utils.translation import gettext_lazy as _ from compensation.forms import NewStateModalForm, NewDeadlineModalForm, NewActionModalForm -from compensation.models import Compensation, CompensationState, CompensationAction +from compensation.models import Compensation, CompensationState, CompensationAction, CompensationDocument from compensation.tables import CompensationTable from konova.contexts import BaseContext from konova.decorators import * from konova.forms import RemoveModalForm, SimpleGeomForm, NewDocumentForm +from konova.utils.documents import get_document, remove_document from konova.utils.user_checks import in_group @@ -163,6 +164,43 @@ def new_document_view(request: HttpRequest, id: str): ) +@login_required +def get_document_view(request: HttpRequest, doc_id: str): + """ Returns the document as downloadable file + + Wraps the generic document fetcher function from konova.utils. + + Args: + request (HttpRequest): The incoming request + doc_id (str): The document id + + Returns: + + """ + doc = get_object_or_404(CompensationDocument, id=doc_id) + return get_document(doc) + + +@login_required +def remove_document_view(request: HttpRequest, doc_id: str): + """ Removes the document from the database and file system + + Wraps the generic functionality from konova.utils. + + Args: + request (HttpRequest): The incoming request + doc_id (str): The document id + + Returns: + + """ + doc = get_object_or_404(CompensationDocument, id=doc_id) + return remove_document( + request, + doc + ) + + @login_required def state_new_view(request: HttpRequest, id: str): """ Renders a form for adding new states for a compensation diff --git a/compensation/views/eco_account_views.py b/compensation/views/eco_account_views.py index 86677ff5..c53f5349 100644 --- a/compensation/views/eco_account_views.py +++ b/compensation/views/eco_account_views.py @@ -14,13 +14,14 @@ from django.http import HttpRequest, Http404 from django.shortcuts import render, get_object_or_404 from compensation.forms import NewStateModalForm, NewActionModalForm, NewDeadlineModalForm -from compensation.models import EcoAccount +from compensation.models import EcoAccount, EcoAccountDocument from compensation.tables import EcoAccountTable from intervention.forms import NewDeductionForm from konova.contexts import BaseContext from konova.decorators import any_group_check, default_group_required, conservation_office_group_required from konova.forms import RemoveModalForm, SimpleGeomForm, NewDocumentForm, RecordModalForm from konova.settings import DEFAULT_GROUP, ZB_GROUP, ETS_GROUP +from konova.utils.documents import get_document, remove_document from konova.utils.user_checks import in_group @@ -289,6 +290,43 @@ def new_document_view(request: HttpRequest, id: str): ) +@login_required +def get_document_view(request: HttpRequest, doc_id: str): + """ Returns the document as downloadable file + + Wraps the generic document fetcher function from konova.utils. + + Args: + request (HttpRequest): The incoming request + doc_id (str): The document id + + Returns: + + """ + doc = get_object_or_404(EcoAccountDocument, id=doc_id) + return get_document(doc) + + +@login_required +def remove_document_view(request: HttpRequest, doc_id: str): + """ Removes the document from the database and file system + + Wraps the generic functionality from konova.utils. + + Args: + request (HttpRequest): The incoming request + doc_id (str): The document id + + Returns: + + """ + doc = get_object_or_404(EcoAccountDocument, id=doc_id) + return remove_document( + request, + doc + ) + + @login_required @default_group_required def new_deduction_view(request: HttpRequest, id: str): diff --git a/ema/models.py b/ema/models.py index aab3a86f..f777a813 100644 --- a/ema/models.py +++ b/ema/models.py @@ -1,8 +1,12 @@ +import shutil + from django.contrib.auth.models import User from django.db import models +from django.db.models import QuerySet from compensation.models import AbstractCompensation -from konova.settings import DEFAULT_SRID_RLP, LANIS_LINK_TEMPLATE +from konova.models import AbstractDocument, generate_document_file_upload_path +from konova.settings import DEFAULT_SRID_RLP, LANIS_LINK_TEMPLATE, EMA_DOC_PATH from user.models import UserActionLogEntry @@ -83,4 +87,63 @@ class Ema(AbstractCompensation): # ToDo: Add check methods! - return ret_msgs \ No newline at end of file + return ret_msgs + + def get_documents(self) -> QuerySet: + """ Getter for all documents of an EMA + + Returns: + docs (QuerySet): The queryset of all documents + """ + docs = EmaDocument.objects.filter( + instance=self + ) + return docs + + +class EmaDocument(AbstractDocument): + """ + Specializes document upload for ema with certain path + """ + instance = models.ForeignKey( + Ema, + 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: + + """ + ema_docs = self.instance.get_documents() + + folder_path = None + if ema_docs.count() == 1: + # The only file left for this EMA 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/ema/templates/ema/detail/includes/documents.html b/ema/templates/ema/detail/includes/documents.html index 0670bbe7..96a24a67 100644 --- a/ema/templates/ema/detail/includes/documents.html +++ b/ema/templates/ema/detail/includes/documents.html @@ -39,14 +39,14 @@ {% for doc in obj.documents.all %} <tr> <td class="align-middle"> - <a href="{% url 'doc-open' doc.id %}"> + <a href="{% url 'ema:get-doc' doc.id %}"> {{ doc.title }} </a> </td> <td class="align-middle">{{ doc.comment }}</td> <td> {% if is_default_member and has_access %} - <button data-form-url="{% url 'doc-remove' doc.id %}" class="btn btn-default btn-modal" title="{% trans 'Remove document' %}"> + <button data-form-url="{% url 'ema:remove-doc' doc.id %}" class="btn btn-default btn-modal" title="{% trans 'Remove document' %}"> {% fa5_icon 'trash' %} </button> {% endif %} diff --git a/ema/urls.py b/ema/urls.py index c3f5bd31..860d863b 100644 --- a/ema/urls.py +++ b/ema/urls.py @@ -24,6 +24,8 @@ urlpatterns = [ # Documents # Document remove route can be found in konova/urls.py path('<id>/document/new/', document_new_view, name='new-doc'), + path('document/<doc_id>', get_document_view, name='get-doc'), + path('document/<doc_id>/remove/', remove_document_view, name='remove-doc'), # Generic state routes path('state/<id>/remove', state_remove_view, name='state-remove'), diff --git a/ema/views.py b/ema/views.py index df347deb..30520ca8 100644 --- a/ema/views.py +++ b/ema/views.py @@ -10,9 +10,10 @@ from compensation.forms import NewStateModalForm, NewActionModalForm, NewDeadlin from ema.tables import EmaTable from konova.contexts import BaseContext from konova.decorators import conservation_office_group_required -from ema.models import Ema +from ema.models import Ema, EmaDocument from konova.forms import RemoveModalForm, NewDocumentForm, SimpleGeomForm, RecordModalForm from konova.settings import DEFAULT_GROUP, ZB_GROUP, ETS_GROUP +from konova.utils.documents import get_document, remove_document from konova.utils.user_checks import in_group @@ -250,6 +251,43 @@ def document_new_view(request: HttpRequest, id: str): ) +@login_required +def get_document_view(request: HttpRequest, doc_id: str): + """ Returns the document as downloadable file + + Wraps the generic document fetcher function from konova.utils. + + Args: + request (HttpRequest): The incoming request + doc_id (str): The document id + + Returns: + + """ + doc = get_object_or_404(EmaDocument, id=doc_id) + return get_document(doc) + + +@login_required +def remove_document_view(request: HttpRequest, doc_id: str): + """ Removes the document from the database and file system + + Wraps the generic functionality from konova.utils. + + Args: + request (HttpRequest): The incoming request + doc_id (str): The document id + + Returns: + + """ + doc = get_object_or_404(EmaDocument, id=doc_id) + return remove_document( + request, + doc + ) + + @login_required def state_remove_view(request: HttpRequest, id: str): """ Renders a form for removing an EMA state diff --git a/intervention/admin.py b/intervention/admin.py index 2046935e..cf49bc39 100644 --- a/intervention/admin.py +++ b/intervention/admin.py @@ -1,6 +1,7 @@ from django.contrib import admin -from intervention.models import Intervention, ResponsibilityData, LegalData, Revocation +from intervention.models import Intervention, ResponsibilityData, LegalData, Revocation, InterventionDocument +from konova.admin import AbstractDocumentAdmin class InterventionAdmin(admin.ModelAdmin): @@ -12,6 +13,8 @@ class InterventionAdmin(admin.ModelAdmin): "deleted", ] +class InterventionDocumentAdmin(AbstractDocumentAdmin): + pass class ResponsibilityAdmin(admin.ModelAdmin): list_display = [ @@ -47,3 +50,4 @@ admin.site.register(Intervention, InterventionAdmin) admin.site.register(ResponsibilityData, ResponsibilityAdmin) admin.site.register(LegalData, LegalAdmin) admin.site.register(Revocation, RevocationAdmin) +admin.site.register(InterventionDocument, InterventionDocumentAdmin) diff --git a/intervention/forms.py b/intervention/forms.py index 25ff7d36..508dbacb 100644 --- a/intervention/forms.py +++ b/intervention/forms.py @@ -15,9 +15,8 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ from compensation.models import EcoAccountDeduction, EcoAccount -from intervention.models import Intervention, Revocation +from intervention.models import Intervention, Revocation, RevocationDocument from konova.forms import BaseForm, BaseModalForm -from konova.models import Document from konova.settings import DEFAULT_LAT, DEFAULT_LON, DEFAULT_ZOOM, ZB_GROUP, ETS_GROUP from konova.utils.messenger import Messenger from konova.utils.user_checks import in_group @@ -372,19 +371,9 @@ class NewRevocationForm(BaseModalForm): user=self.user, action=UserAction.EDITED ) - if self.cleaned_data["file"]: - document = Document.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"], - ) - else: - document = None revocation = Revocation.objects.create( date=self.cleaned_data["date"], comment=self.cleaned_data["comment"], - document=document, created=created_action, ) self.instance.modified = edited_action @@ -392,6 +381,15 @@ class NewRevocationForm(BaseModalForm): self.instance.log.add(edited_action) self.instance.legal.revocation = revocation self.instance.legal.save() + + 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 + ) return revocation diff --git a/intervention/models.py b/intervention/models.py index a62da795..f4a7e739 100644 --- a/intervention/models.py +++ b/intervention/models.py @@ -5,19 +5,22 @@ 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.db.models import QuerySet from django.utils.timezone import localtime 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 konova.models import BaseObject, Geometry, UuidModel, BaseResource +from konova.models import BaseObject, Geometry, UuidModel, BaseResource, AbstractDocument, \ + generate_document_file_upload_path from konova.settings import DEFAULT_SRID_RLP, LANIS_LINK_TEMPLATE from konova.sub_settings.django_settings import DEFAULT_DATE_TIME_FORMAT from konova.utils import generators -from organisation.models import Organisation from user.models import UserActionLogEntry @@ -68,15 +71,71 @@ class Revocation(BaseResource): """ date = models.DateField(null=True, blank=True, help_text="Revocation from") comment = models.TextField(null=True, blank=True) - document = models.ForeignKey("konova.Document", blank=True, null=True, on_delete=models.SET_NULL) - def delete(self): + def delete(self, *args, **kwargs): # Make sure related objects are being removed as well if self.document: - self.document.delete() + 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.legaldata.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 + folder_path = self.file.path.split("/") + 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 @@ -112,7 +171,7 @@ class LegalData(UuidModel): } ) - revocation = models.ForeignKey(Revocation, null=True, blank=True, help_text="Refers to 'Widerspruch am'", on_delete=models.SET_NULL) + revocation = models.OneToOneField(Revocation, null=True, blank=True, help_text="Refers to 'Widerspruch am'", on_delete=models.SET_NULL) def __str__(self): return "{} | {} | {}".format( @@ -131,7 +190,6 @@ class Intervention(BaseObject): on_delete=models.SET_NULL, null=True, blank=True, - related_name='+', help_text="Holds data on responsible organizations ('Zulassungsbehörde', 'Eintragungsstelle')" ) legal = models.OneToOneField( @@ -139,11 +197,9 @@ class Intervention(BaseObject): on_delete=models.SET_NULL, null=True, blank=True, - related_name='+', help_text="Holds data on legal dates or law" ) geometry = models.ForeignKey(Geometry, null=True, blank=True, on_delete=models.SET_NULL) - documents = models.ManyToManyField("konova.Document", blank=True) # Checks - Refers to "Genehmigen" but optional checked = models.OneToOneField( @@ -307,4 +363,67 @@ class Intervention(BaseObject): value = localtime(value) on = value.strftime(DEFAULT_DATE_TIME_FORMAT) tooltip = _("Recorded on {} by {}").format(on, self.recorded.user) - return tooltip \ No newline at end of file + return tooltip + + def get_documents(self) -> (QuerySet, QuerySet): + """ Getter for all documents of an intervention + + Returns: + revoc_docs (QuerySet): The queryset of a revocation document + regular_docs (QuerySet): The queryset of regular other documents + """ + revoc_docs = RevocationDocument.objects.filter( + instance=self.legal.revocation + ) + regular_docs = InterventionDocument.objects.filter( + instance=self + ) + return revoc_docs, regular_docs + + +class InterventionDocument(AbstractDocument): + """ + Specializes document upload for an intervention with certain path + """ + instance = models.ForeignKey( + Intervention, + 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 InterventionDocuments. + 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.instance.get_documents() + + folder_path = None + if revoc_docs.count() == 0 and other_intervention_docs.count() == 1: + # The only file left for this intervention is the one which is currently processed and will be deleted + # Make sure that the intervention 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/intervention/templates/intervention/detail/includes/documents.html b/intervention/templates/intervention/detail/includes/documents.html index c0bea2b0..54972bf5 100644 --- a/intervention/templates/intervention/detail/includes/documents.html +++ b/intervention/templates/intervention/detail/includes/documents.html @@ -39,14 +39,14 @@ {% for doc in intervention.documents.all %} <tr> <td class="align-middle"> - <a href="{% url 'doc-open' doc.id %}"> + <a href="{% url 'intervention:get-doc' doc.id %}"> {{ doc.title }} </a> </td> <td class="align-middle">{{ doc.comment }}</td> <td> {% if is_default_member and has_access %} - <button data-form-url="{% url 'doc-remove' doc.id %}" class="btn btn-default btn-modal" title="{% trans 'Remove document' %}"> + <button data-form-url="{% url 'intervention:remove-doc' doc.id %}" class="btn btn-default btn-modal" title="{% trans 'Remove document' %}"> {% fa5_icon 'trash' %} </button> {% endif %} diff --git a/intervention/templates/intervention/detail/includes/revocation.html b/intervention/templates/intervention/detail/includes/revocation.html index 332b0d76..10a4c44e 100644 --- a/intervention/templates/intervention/detail/includes/revocation.html +++ b/intervention/templates/intervention/detail/includes/revocation.html @@ -31,10 +31,10 @@ {% trans 'From' context 'Revocation' %} </th> <th scope="col"> - {% trans 'Comment' %} + {% trans 'Document' %} </th> <th scope="col"> - {% trans 'Document' %} + {% trans 'Comment' %} </th> <th scope="col"> {% trans 'Action' %} @@ -48,14 +48,14 @@ <td class="align-middle"> {{ rev.date }} </td> - <td class="align-middle">{{ rev.comment }}</td> <td class="align-middle"> {% if rev.document %} - <a href="{% url 'doc-open' rev.document.id %}"> - {{ rev.document.file }} + <a href="{% url 'intervention:get-doc-revocation' rev.document.id %}"> + {% trans 'Revocation' %} </a> {% endif %} </td> + <td class="align-middle">{{ rev.comment }}</td> <td> {% if is_default_member and has_access %} <button data-form-url="{% url 'intervention:remove-revocation' rev.id %}" class="btn btn-default btn-modal" title="{% trans 'Remove revocation' %}"> diff --git a/intervention/urls.py b/intervention/urls.py index 2172e16b..51d1162f 100644 --- a/intervention/urls.py +++ b/intervention/urls.py @@ -9,13 +9,12 @@ from django.urls import path from intervention.views import index_view, new_view, open_view, edit_view, remove_view, new_document_view, share_view, \ create_share_view, remove_revocation_view, new_revocation_view, run_check_view, log_view, new_deduction_view, \ - record_view + record_view, remove_document_view, get_document_view, get_revocation_view app_name = "intervention" urlpatterns = [ path("", index_view, name="index"), path('new/', new_view, name='new'), - path('<id>/document/new/', new_document_view, name='new-doc'), path('<id>', open_view, name='open'), path('<id>/log', log_view, name='log'), path('<id>/edit', edit_view, name='edit'), @@ -25,10 +24,16 @@ urlpatterns = [ path('<id>/check', run_check_view, name='run-check'), path('<id>/record', record_view, name='record'), + # Documents + path('<id>/document/new/', new_document_view, name='new-doc'), + path('document/<doc_id>', get_document_view, name='get-doc'), + path('document/<doc_id>/remove/', remove_document_view, name='remove-doc'), + # Deductions path('<id>/deduction/new', new_deduction_view, name='acc-new-deduction'), # Revocation routes path('<id>/revocation/new', new_revocation_view, name='new-revocation'), path('revocation/<id>/remove', remove_revocation_view, name='remove-revocation'), + path('revocation/<doc_id>', get_revocation_view, name='get-doc-revocation'), ] \ No newline at end of file diff --git a/intervention/views.py b/intervention/views.py index e8893cef..eb8cab4f 100644 --- a/intervention/views.py +++ b/intervention/views.py @@ -6,12 +6,13 @@ from django.shortcuts import render, get_object_or_404 from intervention.forms import NewInterventionForm, EditInterventionForm, ShareInterventionForm, NewRevocationForm, \ RunCheckForm, NewDeductionForm -from intervention.models import Intervention, Revocation +from intervention.models import Intervention, Revocation, InterventionDocument, RevocationDocument from intervention.tables import InterventionTable from konova.contexts import BaseContext from konova.decorators import * from konova.forms import SimpleGeomForm, NewDocumentForm, RemoveModalForm, RecordModalForm from konova.sub_settings.django_settings import DEFAULT_DATE_FORMAT +from konova.utils.documents import remove_document, get_document from konova.utils.message_templates import FORM_INVALID, INTERVENTION_INVALID from konova.utils.user_checks import in_group @@ -94,6 +95,60 @@ def new_document_view(request: HttpRequest, id: str): ) +@login_required +def get_revocation_view(request: HttpRequest, doc_id: str): + """ Returns the revocation document as downloadable file + + Wraps the generic document fetcher function from konova.utils. + + Args: + request (HttpRequest): The incoming request + doc_id (str): The document id + + Returns: + + """ + doc = get_object_or_404(RevocationDocument, id=doc_id) + return get_document(doc) + + +@login_required +def get_document_view(request: HttpRequest, doc_id: str): + """ Returns the document as downloadable file + + Wraps the generic document fetcher function from konova.utils. + + Args: + request (HttpRequest): The incoming request + doc_id (str): The document id + + Returns: + + """ + doc = get_object_or_404(InterventionDocument, id=doc_id) + return get_document(doc) + + +@login_required +def remove_document_view(request: HttpRequest, doc_id: str): + """ Removes the document from the database and file system + + Wraps the generic functionality from konova.utils. + + Args: + request (HttpRequest): The incoming request + doc_id (str): The document id + + Returns: + + """ + doc = get_object_or_404(InterventionDocument, id=doc_id) + return remove_document( + request, + doc + ) + + @login_required @any_group_check def open_view(request: HttpRequest, id: str): diff --git a/konova/admin.py b/konova/admin.py index dc2c0583..c57117af 100644 --- a/konova/admin.py +++ b/konova/admin.py @@ -7,7 +7,7 @@ Created on: 22.07.21 """ from django.contrib import admin -from konova.models import Geometry, Document, Deadline +from konova.models import Geometry, Deadline class GeometryAdmin(admin.ModelAdmin): @@ -17,7 +17,7 @@ class GeometryAdmin(admin.ModelAdmin): ] -class DocumentAdmin(admin.ModelAdmin): +class AbstractDocumentAdmin(admin.ModelAdmin): list_display = [ "id", "title", @@ -36,5 +36,4 @@ class DeadlineAdmin(admin.ModelAdmin): admin.site.register(Geometry, GeometryAdmin) -admin.site.register(Document, DocumentAdmin) admin.site.register(Deadline, DeadlineAdmin) diff --git a/konova/forms.py b/konova/forms.py index 2ea9f57e..84f2a724 100644 --- a/konova/forms.py +++ b/konova/forms.py @@ -21,11 +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 -from ema.models import Ema -from intervention.models import Intervention +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 Document, BaseObject +from konova.models import BaseObject from konova.utils.message_templates import FORM_INVALID from user.models import UserActionLogEntry, UserAction @@ -307,7 +307,7 @@ class NewDocumentForm(BaseModalForm): attrs={ "class": "w-75" } - ) + ), ) comment = forms.CharField( required=False, @@ -322,6 +322,13 @@ class NewDocumentForm(BaseModalForm): } ) ) + document_instance_map = { + Intervention: InterventionDocument, + Compensation: CompensationDocument, + EcoAccount: EcoAccountDocument, + Revocation: RevocationDocument, + Ema: EmaDocument, + } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -331,6 +338,12 @@ 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: + raise NotImplementedError("Unsupported document type for {}".format(self.instance.__class__)) def save(self): with transaction.atomic(): @@ -338,14 +351,14 @@ class NewDocumentForm(BaseModalForm): user=self.user, action=UserAction.CREATED, ) - doc = Document.objects.create( + doc = self.document_type.objects.create( created=action, title=self.cleaned_data["title"], comment=self.cleaned_data["comment"], file=self.cleaned_data["file"], date_of_creation=self.cleaned_data["creation_date"], + instance=self.instance, ) - self.instance.documents.add(doc) edited_action = UserActionLogEntry.objects.create( user=self.user, diff --git a/konova/models.py b/konova/models.py index 2c554f64..492dcef7 100644 --- a/konova/models.py +++ b/konova/models.py @@ -18,6 +18,7 @@ from compensation.settings import COMPENSATION_IDENTIFIER_TEMPLATE, COMPENSATION 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.generators import generate_random_string from user.models import UserActionLogEntry, UserAction @@ -220,7 +221,47 @@ class Deadline(BaseResource): return None -class Document(BaseResource): +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. """ @@ -229,6 +270,9 @@ class Document(BaseResource): 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 @@ -239,7 +283,11 @@ class Document(BaseResource): Returns: """ - os.remove(self.file.file.name) + 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/settings.py b/konova/settings.py index e6c37d6f..aa028000 100644 --- a/konova/settings.py +++ b/konova/settings.py @@ -55,7 +55,20 @@ DEFAULT_GROUP = "Default" ZB_GROUP = "Registration office" ETS_GROUP = "Conservation office" - # Needed to redirect to LANIS ## Values to be inserted are [zoom_level, x_coord, y_coord] LANIS_LINK_TEMPLATE = "https://geodaten.naturschutz.rlp.de/kartendienste_naturschutz/index.php?lang=de&zl={}&x={}&y={}&bl=tk_rlp_tms_grau&bo=1&lo=0.8,0.8,0.8,0.6,0.8,0.8,0.8,0.8,0.8&layers=eiv_f,eiv_l,eiv_p,kom_f,kom_l,kom_p,oek_f,ema_f,mae&service=kartendienste_naturschutz" + +# ALLOWED FILE UPLOAD DEFINITIONS +# Default: Upload into upper project folder +MEDIA_ROOT = BASE_DIR + "/.." + +# DOCUMENT UPLOAD PATHS +# Extends MEDIA_ROOT +## {} is a placeholder for the object's uuid --> each object will have it's own folder +BASE_DOC_PATH = "konova_uploaded_files/" +INTERVENTION_DOC_PATH = BASE_DOC_PATH + "interventions/{}/" +INTERVENTION_REVOCATION_DOC_PATH = BASE_DOC_PATH + "interventions/{}/revocation/" +COMPENSATION_DOC_PATH = BASE_DOC_PATH + "compensations/{}/" +ECO_ACCOUNT_DOC_PATH = BASE_DOC_PATH + "eco_account/{}/" +EMA_DOC_PATH = BASE_DOC_PATH + "ema/{}/" \ No newline at end of file diff --git a/konova/urls.py b/konova/urls.py index 32173a85..37909256 100644 --- a/konova/urls.py +++ b/konova/urls.py @@ -22,7 +22,7 @@ from konova.autocompletes import OrganisationAutocomplete, NonOfficialOrganisati RegistrationOfficeCodeAutocomplete, ConservationOfficeCodeAutocomplete from konova.settings import SSO_SERVER, SSO_PUBLIC_KEY, SSO_PRIVATE_KEY, DEBUG from konova.sso.sso import KonovaSSOClient -from konova.views import logout_view, home_view, get_document_view, remove_document_view, remove_deadline_view +from konova.views import logout_view, home_view, remove_deadline_view sso_client = KonovaSSOClient(SSO_SERVER, SSO_PUBLIC_KEY, SSO_PRIVATE_KEY) urlpatterns = [ @@ -38,10 +38,6 @@ urlpatterns = [ path('news/', include("news.urls")), path('news/', include("codelist.urls")), - # Generic documents routes - path('document/<id>', get_document_view, name="doc-open"), - path('document/<id>/remove', remove_document_view, name="doc-remove"), - # Generic deadline routes path('deadline/<id>/remove', remove_deadline_view, name="deadline-remove"), diff --git a/konova/utils/documents.py b/konova/utils/documents.py new file mode 100644 index 00000000..695347c3 --- /dev/null +++ b/konova/utils/documents.py @@ -0,0 +1,53 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 01.09.21 + +""" +from django.http import FileResponse, HttpRequest, HttpResponse, Http404 +from django.utils.translation import gettext_lazy as _ + +from konova.forms import RemoveModalForm +from konova.models import AbstractDocument + + +def get_document(doc: AbstractDocument): + """ Returns a document as downloadable attachment + + Args: + request (HttpRequest): The incoming request + id (str): The document id + + Returns: + + """ + try: + return FileResponse(doc.file, as_attachment=True) + except FileNotFoundError: + raise Http404() + + +def remove_document(request: HttpRequest, doc: AbstractDocument): + """ Renders a form for uploading new documents + + This function works using a modal. We are not using the regular way, the django bootstrap modal forms are + intended to be used. Instead of View classes we work using the classic way of dealing with forms (see below). + It is important to mention, that modal forms, which should reload the page afterwards, must provide a + 'reload_page' bool in the context. This way, the modal may reload the page or not. + + For further details see the comments in templates/modal or + https://github.com/trco/django-bootstrap-modal-forms + + Args: + request (HttpRequest): The incoming request + + Returns: + + """ + title = doc.title + form = RemoveModalForm(request.POST or None, instance=doc, user=request.user) + return form.process_request( + request=request, + msg_success=_("Document '{}' deleted").format(title) + ) \ No newline at end of file diff --git a/konova/views.py b/konova/views.py index cfe1b3c8..e1ca2db6 100644 --- a/konova/views.py +++ b/konova/views.py @@ -17,7 +17,7 @@ from intervention.models import Intervention from konova.contexts import BaseContext from konova.decorators import any_group_check from konova.forms import RemoveModalForm -from konova.models import Document, Deadline +from konova.models import Deadline from news.models import ServerMessage from konova.settings import SSO_SERVER_BASE @@ -97,48 +97,6 @@ def home_view(request: HttpRequest): return render(request, template, context) -@login_required -def get_document_view(request: HttpRequest, id: str): - """ Returns a document as downloadable attachment - - Args: - request (HttpRequest): The incoming request - id (str): The document id - - Returns: - - """ - doc = get_object_or_404(Document, id=id) - return FileResponse(doc.file, as_attachment=True) - - -@login_required -def remove_document_view(request: HttpRequest, id: str): - """ Renders a form for uploading new documents - - This function works using a modal. We are not using the regular way, the django bootstrap modal forms are - intended to be used. Instead of View classes we work using the classic way of dealing with forms (see below). - It is important to mention, that modal forms, which should reload the page afterwards, must provide a - 'reload_page' bool in the context. This way, the modal may reload the page or not. - - For further details see the comments in templates/modal or - https://github.com/trco/django-bootstrap-modal-forms - - Args: - request (HttpRequest): The incoming request - - Returns: - - """ - doc = get_object_or_404(Document, id=id) - title = doc.title - form = RemoveModalForm(request.POST or None, instance=doc, user=request.user) - return form.process_request( - request=request, - msg_success=_("Document '{}' deleted").format(title) - ) - - @login_required def remove_deadline_view(request: HttpRequest, id:str): """ Renders a modal form for removing a deadline object