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('/deadline/new', deadline_new_view, name="acc-new-deadline"), # Documents - # Document remove route can be found in konova/urls.py path('/document/new/', new_document_view, name='acc-new-doc'), + path('document/', get_document_view, name='acc-get-doc'), + path('document//remove/', remove_document_view, name='acc-remove-doc'), # Eco-account deductions path('/remove/', 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('/deadline/new', deadline_new_view, name="new-deadline"), # Documents - # Document remove route can be found in konova/urls.py path('/document/new/', new_document_view, name='new-doc'), + path('document/', get_document_view, name='get-doc'), + path('document//remove/', remove_document_view, name='remove-doc'), # Generic state routes path('state//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 %} - + {{ doc.title }} {{ doc.comment }} {% if is_default_member and has_access %} - {% 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 %} - + {{ doc.title }} {{ doc.comment }} {% if is_default_member and has_access %} - {% 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 %} - + {{ doc.title }} {{ doc.comment }} {% if is_default_member and has_access %} - {% 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('/document/new/', document_new_view, name='new-doc'), + path('document/', get_document_view, name='get-doc'), + path('document//remove/', remove_document_view, name='remove-doc'), # Generic state routes path('state//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 %} - + {{ doc.title }} {{ doc.comment }} {% if is_default_member and has_access %} - {% 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' %} - {% trans 'Comment' %} + {% trans 'Document' %} - {% trans 'Document' %} + {% trans 'Comment' %} {% trans 'Action' %} @@ -48,14 +48,14 @@ {{ rev.date }} - {{ rev.comment }} {% if rev.document %} - - {{ rev.document.file }} + + {% trans 'Revocation' %} {% endif %} + {{ rev.comment }} {% if is_default_member and has_access %}