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()