From c4af63cea23d242f949567368659f17210216c70 Mon Sep 17 00:00:00 2001 From: mpeltriaux <michel.peltriaux@sgdnord.rlp.de> Date: Tue, 26 Oct 2021 15:09:30 +0200 Subject: [PATCH] #19 Tests * refactors CheckableMixin and RecordableMixin into CheckableObject and RecordableObject * adds ShareableObject for wrapping share related fields and functionality * adds share functionality to EcoAccount and EMA, just like Intervention --- compensation/account_urls.py | 2 + compensation/models.py | 21 +---- .../detail/eco_account/includes/controls.html | 3 + compensation/views/eco_account_views.py | 62 ++++++++++++- ema/models.py | 22 +---- .../ema/detail/includes/controls.html | 3 + ema/urls.py | 2 + ema/views.py | 65 +++++++++++++- intervention/forms/modalForms.py | 3 +- intervention/models.py | 67 +------------- konova/models.py | 87 +++++++++++++++++-- 11 files changed, 220 insertions(+), 117 deletions(-) diff --git a/compensation/account_urls.py b/compensation/account_urls.py index d29c827b..5d9d635f 100644 --- a/compensation/account_urls.py +++ b/compensation/account_urls.py @@ -21,6 +21,8 @@ urlpatterns = [ path('<id>/state/new', state_new_view, name='acc-new-state'), path('<id>/action/new', action_new_view, name='acc-new-action'), path('<id>/deadline/new', deadline_new_view, name="acc-new-deadline"), + path('<id>/share/<token>', share_view, name='share'), + path('<id>/share', create_share_view, name='share-create'), # Documents path('<id>/document/new/', new_document_view, name='acc-new-doc'), diff --git a/compensation/models.py b/compensation/models.py index 37550125..b355a428 100644 --- a/compensation/models.py +++ b/compensation/models.py @@ -22,7 +22,7 @@ from compensation.managers import CompensationStateManager, EcoAccountDeductionM 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, RecordableMixin + generate_document_file_upload_path, RecordableObject, ShareableObject from konova.settings import DEFAULT_SRID_RLP, LANIS_LINK_TEMPLATE from user.models import UserActionLogEntry @@ -311,28 +311,11 @@ class CompensationDocument(AbstractDocument): pass -class EcoAccount(AbstractCompensation, RecordableMixin): +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. """ - # Users having access on this object - # Not needed in regular Compensation since their access is defined by the linked intervention's access - users = models.ManyToManyField( - User, - help_text="Users having access (shared with)" - ) - - # Refers to "verzeichnen" - recorded = models.OneToOneField( - UserActionLogEntry, - on_delete=models.SET_NULL, - null=True, - blank=True, - help_text="Holds data on user and timestamp of this action", - related_name="+" - ) - deductable_surface = models.FloatField( blank=True, null=True, diff --git a/compensation/templates/compensation/detail/eco_account/includes/controls.html b/compensation/templates/compensation/detail/eco_account/includes/controls.html index 5aa9620e..f43ddebe 100644 --- a/compensation/templates/compensation/detail/eco_account/includes/controls.html +++ b/compensation/templates/compensation/detail/eco_account/includes/controls.html @@ -12,6 +12,9 @@ </button> </a> {% if has_access %} + <button class="btn btn-default btn-modal mr-2" title="{% trans 'Share' %}" data-form-url="{% url 'compensation:share-create' obj.id %}"> + {% fa5_icon 'share-alt' %} + </button> {% if is_ets_member %} {% if obj.recorded %} <button class="btn btn-default btn-modal mr-2" title="{% trans 'Unrecord' %}" data-form-url="{% url 'compensation:acc-record' obj.id %}"> diff --git a/compensation/views/eco_account_views.py b/compensation/views/eco_account_views.py index 1ac355fe..63c85294 100644 --- a/compensation/views/eco_account_views.py +++ b/compensation/views/eco_account_views.py @@ -18,7 +18,7 @@ from compensation.forms.forms import NewEcoAccountForm, EditEcoAccountForm from compensation.forms.modalForms import NewStateModalForm, NewActionModalForm, NewDeadlineModalForm from compensation.models import EcoAccount, EcoAccountDocument from compensation.tables import EcoAccountTable -from intervention.forms.modalForms import NewDeductionModalForm +from intervention.forms.modalForms import NewDeductionModalForm, ShareInterventionModalForm 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 @@ -511,3 +511,63 @@ def report_view(request:HttpRequest, id: str): } context = BaseContext(request, context).context return render(request, template, context) + + +@login_required +def share_view(request: HttpRequest, id: str, token: str): + """ Performs sharing of an eco account + + If token given in url is not valid, the user will be redirected to the dashboard + + Args: + request (HttpRequest): The incoming request + id (str): EcoAccount's id + token (str): Access token for EcoAccount + + Returns: + + """ + user = request.user + obj = get_object_or_404(EcoAccount, id=id) + # Check tokens + if obj.access_token == token: + # Send different messages in case user has already been added to list of sharing users + if obj.is_shared_with(user): + messages.info( + request, + _("{} has already been shared with you").format(obj.identifier) + ) + else: + messages.success( + request, + _("{} has been shared with you").format(obj.identifier) + ) + obj.users.add(user) + return redirect("compensation:acc-detail", id=id) + else: + messages.error( + request, + _("Share link invalid"), + extra_tags="danger", + ) + return redirect("home") + + +@login_required +@default_group_required +def create_share_view(request: HttpRequest, id: str): + """ Renders sharing form for an eco account + + Args: + request (HttpRequest): The incoming request + id (str): EcoAccount's id + + Returns: + + """ + obj = get_object_or_404(EcoAccount, id=id) + form = ShareInterventionModalForm(request.POST or None, instance=obj, request=request) + return form.process_request( + request, + msg_success=_("Share settings updated") + ) \ No newline at end of file diff --git a/ema/models.py b/ema/models.py index 1fa85019..d37eca5d 100644 --- a/ema/models.py +++ b/ema/models.py @@ -7,12 +7,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, RecordableMixin +from konova.models import AbstractDocument, generate_document_file_upload_path, RecordableObject, ShareableObject from konova.settings import DEFAULT_SRID_RLP, LANIS_LINK_TEMPLATE -from user.models import UserActionLogEntry -class Ema(AbstractCompensation, RecordableMixin): +class Ema(AbstractCompensation, ShareableObject, RecordableObject): """ EMA = Ersatzzahlungsmaßnahme (compensation actions from payments) @@ -28,23 +27,6 @@ class Ema(AbstractCompensation, RecordableMixin): EMA therefore holds data like a compensation: actions, before-/after-states, deadlines, ... """ - # Users having access on this object - # Not needed in regular Compensation since their access is defined by the linked intervention's access - users = models.ManyToManyField( - User, - help_text="Users having access (shared with)" - ) - - # Refers to "verzeichnen" - recorded = models.OneToOneField( - UserActionLogEntry, - on_delete=models.SET_NULL, - null=True, - blank=True, - help_text="Holds data on user and timestamp of this action", - related_name="+" - ) - objects = EmaManager() def __str__(self): diff --git a/ema/templates/ema/detail/includes/controls.html b/ema/templates/ema/detail/includes/controls.html index 1d5e5467..6a4f7062 100644 --- a/ema/templates/ema/detail/includes/controls.html +++ b/ema/templates/ema/detail/includes/controls.html @@ -12,6 +12,9 @@ </button> </a> {% if has_access %} + <button class="btn btn-default btn-modal mr-2" title="{% trans 'Share' %}" data-form-url="{% url 'ema:share-create' obj.id %}"> + {% fa5_icon 'share-alt' %} + </button> {% if is_ets_member %} {% if obj.recorded %} <button class="btn btn-default btn-modal mr-2" title="{% trans 'Unrecord' %}" data-form-url="{% url 'ema:record' obj.id %}"> diff --git a/ema/urls.py b/ema/urls.py index ad926d05..ee3d30f0 100644 --- a/ema/urls.py +++ b/ema/urls.py @@ -22,6 +22,8 @@ urlpatterns = [ path('<id>/state/new', state_new_view, name='new-state'), path('<id>/action/new', action_new_view, name='new-action'), path('<id>/deadline/new', deadline_new_view, name="new-deadline"), + path('<id>/share/<token>', share_view, name='share'), + path('<id>/share', create_share_view, name='share-create'), # Documents # Document remove route can be found in konova/urls.py diff --git a/ema/views.py b/ema/views.py index 1e72e096..b180433f 100644 --- a/ema/views.py +++ b/ema/views.py @@ -10,8 +10,9 @@ import compensation from compensation.forms.modalForms import NewStateModalForm, NewActionModalForm, NewDeadlineModalForm from ema.forms import NewEmaForm, EditEmaForm from ema.tables import EmaTable +from intervention.forms.modalForms import ShareInterventionModalForm from konova.contexts import BaseContext -from konova.decorators import conservation_office_group_required +from konova.decorators import conservation_office_group_required, default_group_required from ema.models import Ema, EmaDocument from konova.forms import RemoveModalForm, NewDocumentForm, SimpleGeomForm, RecordModalForm from konova.settings import DEFAULT_GROUP, ZB_GROUP, ETS_GROUP @@ -460,4 +461,64 @@ def report_view(request:HttpRequest, id: str): "actions": actions, } context = BaseContext(request, context).context - return render(request, template, context) \ No newline at end of file + return render(request, template, context) + + +@login_required +def share_view(request: HttpRequest, id: str, token: str): + """ Performs sharing of an ema + + If token given in url is not valid, the user will be redirected to the dashboard + + Args: + request (HttpRequest): The incoming request + id (str): EMA's id + token (str): Access token for EMA + + Returns: + + """ + user = request.user + obj = get_object_or_404(Ema, id=id) + # Check tokens + if obj.access_token == token: + # Send different messages in case user has already been added to list of sharing users + if obj.is_shared_with(user): + messages.info( + request, + _("{} has already been shared with you").format(obj.identifier) + ) + else: + messages.success( + request, + _("{} has been shared with you").format(obj.identifier) + ) + obj.users.add(user) + return redirect("ema:detail", id=id) + else: + messages.error( + request, + _("Share link invalid"), + extra_tags="danger", + ) + return redirect("home") + + +@login_required +@default_group_required +def create_share_view(request: HttpRequest, id: str): + """ Renders sharing form for an Ema + + Args: + request (HttpRequest): The incoming request + id (str): Ema's id + + Returns: + + """ + obj = get_object_or_404(Ema, id=id) + form = ShareInterventionModalForm(request.POST or None, instance=obj, request=request) + return form.process_request( + request, + msg_success=_("Share settings updated") + ) \ No newline at end of file diff --git a/intervention/forms/modalForms.py b/intervention/forms/modalForms.py index 69425969..84e96126 100644 --- a/intervention/forms/modalForms.py +++ b/intervention/forms/modalForms.py @@ -68,8 +68,9 @@ class ShareInterventionModalForm(BaseModalForm): """ # Initialize share_link field + url_name = f"{self.instance._meta.app_label}:share" self.share_link = self.request.build_absolute_uri( - reverse("intervention:share", args=(self.instance.id, self.instance.access_token,)) + reverse(url_name, args=(self.instance.id, self.instance.access_token,)) ) self.initialize_form_field( "url", diff --git a/intervention/models.py b/intervention/models.py index ec01e15f..39d141f2 100644 --- a/intervention/models.py +++ b/intervention/models.py @@ -17,7 +17,7 @@ from codelist.settings import CODELIST_REGISTRATION_OFFICE_ID, CODELIST_CONSERVA from intervention.managers import InterventionManager from intervention.utils.quality import InterventionQualityChecker from konova.models import BaseObject, Geometry, UuidModel, BaseResource, AbstractDocument, \ - generate_document_file_upload_path, RecordableMixin, CheckableMixin + generate_document_file_upload_path, RecordableObject, CheckableObject, ShareableObject from konova.settings import DEFAULT_SRID_RLP, LANIS_LINK_TEMPLATE, LANIS_ZOOM_LUT from konova.utils import generators from user.models import UserActionLogEntry @@ -171,7 +171,7 @@ class LegalData(UuidModel): revocation = models.OneToOneField(Revocation, null=True, blank=True, help_text="Refers to 'Widerspruch am'", on_delete=models.SET_NULL) -class Intervention(BaseObject, RecordableMixin, CheckableMixin): +class Intervention(BaseObject, ShareableObject, RecordableObject, CheckableObject): """ Interventions are e.g. construction sites where nature used to be. """ @@ -191,74 +191,11 @@ class Intervention(BaseObject, RecordableMixin, CheckableMixin): ) geometry = models.ForeignKey(Geometry, null=True, blank=True, on_delete=models.SET_NULL) - # Checks - Refers to "Genehmigen" but optional - checked = models.OneToOneField( - UserActionLogEntry, - on_delete=models.SET_NULL, - null=True, - blank=True, - help_text="Holds data on user and timestamp of this action", - related_name="+" - ) - - # Refers to "verzeichnen" - recorded = models.OneToOneField( - UserActionLogEntry, - on_delete=models.SET_NULL, - null=True, - blank=True, - help_text="Holds data on user and timestamp of this action", - related_name="+" - ) - - # Users having access on this object - users = models.ManyToManyField(User, help_text="Users having access (data shared with)") - access_token = models.CharField( - max_length=255, - null=True, - blank=True, - help_text="Used for sharing access", - ) - objects = InterventionManager() def __str__(self): return "{} ({})".format(self.identifier, self.title) - def generate_access_token(self, make_unique: bool = False, rec_depth: int = 5): - """ Creates a new access token for the intervention - - Tokens are not used for identification of a table row. The share logic checks the intervention id as well - as the given token. Therefore two different interventions can hold the same access_token without problems. - For (possible) future changes to the share logic, the make_unique parameter may be used for checking whether - the access_token is already used in any intervention. If so, tokens will be generated as long as a free token - can be found. - - Args: - make_unique (bool): Perform check on uniqueness over all intervention entries - rec_depth (int): How many tries for generating a free random token (only if make_unique) - - Returns: - - """ - # Make sure we won't end up in an infinite loop of trying to generate access_tokens - rec_depth = rec_depth - 1 - if rec_depth < 0 and make_unique: - raise RuntimeError( - "Access token generating for {} does not seem to find a free random token! Aborted!".format(self.id) - ) - - # Create random token - token = generators.generate_random_string(15, True, True, False) - token_used_in = Intervention.objects.filter(access_token=token) - # Make sure the token is not used anywhere as access_token, yet. - # Make use of QuerySet lazy method for checking if it exists or not. - if token_used_in and make_unique: - self.generate_access_token(make_unique, rec_depth) - else: - self.access_token = token - self.save() - def save(self, *args, **kwargs): """ Custom save functionality diff --git a/konova/models.py b/konova/models.py index 830916fd..91ae0358 100644 --- a/konova/models.py +++ b/konova/models.py @@ -21,6 +21,7 @@ from compensation.settings import COMPENSATION_IDENTIFIER_TEMPLATE, COMPENSATION 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 @@ -315,12 +316,23 @@ class Geometry(BaseResource): geom = MultiPolygonField(null=True, blank=True, srid=DEFAULT_SRID) -class RecordableMixin: - """ Mixin to be combined with BaseObject class - - Provides functionality related to un/recording of data +class RecordableObject(models.Model): + """ Wraps record related fields and functionality """ + # Refers to "verzeichnen" + recorded = models.OneToOneField( + UserActionLogEntry, + on_delete=models.SET_NULL, + null=True, + blank=True, + help_text="Holds data on user and timestamp of this action", + related_name="+" + ) + + class Meta: + abstract = True + def set_unrecorded(self, user: User): """ Perform unrecording @@ -370,12 +382,19 @@ class RecordableMixin: self.set_unrecorded(user) -class CheckableMixin: - """ Mixin to be combined with BaseObject class +class CheckableObject(models.Model): + # Checks - Refers to "Genehmigen" but optional + checked = models.OneToOneField( + UserActionLogEntry, + on_delete=models.SET_NULL, + null=True, + blank=True, + help_text="Holds data on user and timestamp of this action", + related_name="+" + ) + class Meta: + abstract = True - Provides functionality related to un/checking of data - - """ def set_unchecked(self, user: User): """ Perform unrecording @@ -417,3 +436,53 @@ class CheckableMixin: self.set_checked(user) else: self.set_unchecked(user) + + +class ShareableObject(models.Model): + # Users having access on this object + users = models.ManyToManyField(User, help_text="Users having access (data shared with)") + access_token = models.CharField( + max_length=255, + null=True, + blank=True, + help_text="Used for sharing access", + ) + + class Meta: + abstract = True + + def generate_access_token(self, make_unique: bool = False, rec_depth: int = 5): + """ Creates a new access token for the data + + Tokens are not used for identification of a table row. The share logic checks the intervention id as well + as the given token. Therefore two different interventions can hold the same access_token without problems. + For (possible) future changes to the share logic, the make_unique parameter may be used for checking whether + the access_token is already used in any intervention. If so, tokens will be generated as long as a free token + can be found. + + Args: + make_unique (bool): Perform check on uniqueness over all intervention entries + rec_depth (int): How many tries for generating a free random token (only if make_unique) + + Returns: + + """ + # Make sure we won't end up in an infinite loop of trying to generate access_tokens + rec_depth = rec_depth - 1 + if rec_depth < 0 and make_unique: + raise RuntimeError( + "Access token generating for {} does not seem to find a free random token! Aborted!".format(self.id) + ) + + # Create random token + token = generators.generate_random_string(15, True, True, False) + # Check dynamically wheter there is another instance of that model, which holds this random access token + _model = self._meta.concrete_model + token_used_in = _model.objects.filter(access_token=token) + # Make sure the token is not used anywhere as access_token, yet. + # Make use of QuerySet lazy method for checking if it exists or not. + if token_used_in and make_unique: + self.generate_access_token(make_unique, rec_depth) + else: + self.access_token = token + self.save()