master #123

Merged
mpeltriaux merged 33 commits from master into Docker 3 years ago

@ -109,8 +109,8 @@ class APIV1CreateTestCase(BaseAPIV1TestCase):
Returns:
"""
self.intervention.share_with(self.superuser)
self.eco_account.share_with(self.superuser)
self.intervention.share_with_user(self.superuser)
self.eco_account.share_with_user(self.superuser)
url = reverse("api:v1:deduction")
json_file_path = "api/tests/v1/create/deduction_create_post_body.json"

@ -57,7 +57,7 @@ class APIV1DeleteTestCase(BaseAPIV1TestCase):
"""
test_intervention = self.create_dummy_intervention()
test_intervention.share_with(self.superuser)
test_intervention.share_with_user(self.superuser)
url = reverse("api:v1:intervention", args=(str(test_intervention.id),))
self._test_delete_object(test_intervention, url)
@ -68,7 +68,7 @@ class APIV1DeleteTestCase(BaseAPIV1TestCase):
"""
test_comp = self.create_dummy_compensation()
test_comp.share_with(self.superuser)
test_comp.share_with_user(self.superuser)
url = reverse("api:v1:compensation", args=(str(test_comp.id),))
self._test_delete_object(test_comp, url)
@ -79,7 +79,7 @@ class APIV1DeleteTestCase(BaseAPIV1TestCase):
"""
test_acc = self.create_dummy_eco_account()
test_acc.share_with(self.superuser)
test_acc.share_with_user(self.superuser)
url = reverse("api:v1:ecoaccount", args=(str(test_acc.id),))
self._test_delete_object(test_acc, url)
@ -90,7 +90,7 @@ class APIV1DeleteTestCase(BaseAPIV1TestCase):
"""
test_ema = self.create_dummy_ema()
test_ema.share_with(self.superuser)
test_ema.share_with_user(self.superuser)
url = reverse("api:v1:ema", args=(str(test_ema.id),))
self._test_delete_object(test_ema, url)
@ -101,7 +101,7 @@ class APIV1DeleteTestCase(BaseAPIV1TestCase):
"""
test_deduction = self.create_dummy_deduction()
test_deduction.intervention.share_with(self.superuser)
test_deduction.intervention.share_with_user(self.superuser)
url = reverse("api:v1:deduction", args=(str(test_deduction.id),))
response = self._run_delete_request(url)

@ -36,7 +36,12 @@ class APIV1GetTestCase(BaseAPIV1TestCase):
"""
response = self._run_get_request(url)
content = json.loads(response.content)
geojson = content[str(obj.id)]
self.assertIn("rpp", content)
self.assertIn("p", content)
self.assertIn("next", content)
self.assertIn("results", content)
paginated_content = content["results"]
geojson = paginated_content[str(obj.id)]
self.assertEqual(response.status_code, 200, msg=response.content)
return geojson
@ -59,7 +64,7 @@ class APIV1GetTestCase(BaseAPIV1TestCase):
Returns:
"""
self.intervention.share_with(self.superuser)
self.intervention.share_with_user(self.superuser)
url = reverse("api:v1:intervention", args=(str(self.intervention.id),))
geojson = self._test_get_object(self.intervention, url)
self._assert_geojson_format(geojson)
@ -80,13 +85,33 @@ class APIV1GetTestCase(BaseAPIV1TestCase):
except KeyError as e:
self.fail(e)
def test_get_shared(self):
""" Tests api GET on shared info of the intervention
Returns:
"""
self.intervention.share_with_user(self.superuser)
self.intervention.share_with_team(self.team)
url = reverse("api:v1:intervention-share", args=(str(self.intervention.id),))
response = self._run_get_request(url)
content = json.loads(response.content)
self.assertIn("users", content)
self.assertIn(self.superuser.username, content["users"])
self.assertEqual(1, len(content["users"]))
self.assertIn("teams", content)
self.assertEqual(1, len(content["teams"]))
for team in content["teams"]:
self.assertEqual(team["id"], str(self.team.id))
self.assertEqual(team["name"], self.team.name)
def test_get_compensation(self):
""" Tests api GET
Returns:
"""
self.intervention.share_with(self.superuser)
self.intervention.share_with_user(self.superuser)
self.compensation.intervention = self.intervention
self.compensation.save()
@ -114,7 +139,7 @@ class APIV1GetTestCase(BaseAPIV1TestCase):
Returns:
"""
self.eco_account.share_with(self.superuser)
self.eco_account.share_with_user(self.superuser)
url = reverse("api:v1:ecoaccount", args=(str(self.eco_account.id),))
geojson = self._test_get_object(self.eco_account, url)
@ -143,7 +168,7 @@ class APIV1GetTestCase(BaseAPIV1TestCase):
Returns:
"""
self.ema.share_with(self.superuser)
self.ema.share_with_user(self.superuser)
url = reverse("api:v1:ema", args=(str(self.ema.id),))
geojson = self._test_get_object(self.ema, url)
@ -167,7 +192,7 @@ class APIV1GetTestCase(BaseAPIV1TestCase):
Returns:
"""
self.deduction.intervention.share_with(self.superuser)
self.deduction.intervention.share_with_user(self.superuser)
url = reverse("api:v1:deduction", args=(str(self.deduction.id),))
_json = self._test_get_object(self.deduction, url)

@ -0,0 +1,8 @@
{
"users": [
"CHANGE_ME"
],
"teams": [
"CHANGE_ME"
]
}

@ -52,7 +52,7 @@ class APIV1UpdateTestCase(BaseAPIV1TestCase):
Returns:
"""
self.intervention.share_with(self.superuser)
self.intervention.share_with_user(self.superuser)
modified_on = self.intervention.modified
url = reverse("api:v1:intervention", args=(str(self.intervention.id),))
json_file_path = "api/tests/v1/update/intervention_update_put_body.json"
@ -79,7 +79,7 @@ class APIV1UpdateTestCase(BaseAPIV1TestCase):
"""
self.compensation.intervention = self.intervention
self.compensation.save()
self.intervention.share_with(self.superuser)
self.intervention.share_with_user(self.superuser)
modified_on = self.compensation.modified
url = reverse("api:v1:compensation", args=(str(self.compensation.id),))
@ -108,7 +108,7 @@ class APIV1UpdateTestCase(BaseAPIV1TestCase):
Returns:
"""
self.eco_account.share_with(self.superuser)
self.eco_account.share_with_user(self.superuser)
modified_on = self.eco_account.modified
url = reverse("api:v1:ecoaccount", args=(str(self.eco_account.id),))
@ -139,7 +139,7 @@ class APIV1UpdateTestCase(BaseAPIV1TestCase):
Returns:
"""
self.ema.share_with(self.superuser)
self.ema.share_with_user(self.superuser)
modified_on = self.ema.modified
url = reverse("api:v1:ema", args=(str(self.ema.id),))
@ -168,8 +168,8 @@ class APIV1UpdateTestCase(BaseAPIV1TestCase):
Returns:
"""
self.deduction.intervention.share_with(self.superuser)
self.deduction.account.share_with(self.superuser)
self.deduction.intervention.share_with_user(self.superuser)
self.deduction.account.share_with_user(self.superuser)
url = reverse("api:v1:deduction", args=(str(self.deduction.id),))
json_file_path = "api/tests/v1/update/deduction_update_put_body.json"
@ -184,3 +184,24 @@ class APIV1UpdateTestCase(BaseAPIV1TestCase):
self.assertEqual(put_body["intervention"], str(self.deduction.intervention.id))
self.assertEqual(put_body["eco_account"], str(self.deduction.account.id))
self.assertEqual(put_body["surface"], self.deduction.surface)
def test_update_share_intervention(self):
self.intervention.share_with_user(self.superuser)
url = reverse("api:v1:intervention-share", args=(str(self.intervention.id),))
json_file_path = "api/tests/v1/update/intervention_share_update_put_body.json"
with open(json_file_path) as json_file:
put_body = json.load(fp=json_file)
put_body["users"] = [self.user.username]
put_body["teams"] = [self.team.name]
self.assertFalse(self.intervention.is_shared_with(self.user))
self.assertEqual(0, self.intervention.shared_teams.count())
response = self._run_update_request(url, put_body)
self.assertEqual(response.status_code, 200, msg=response.content)
self.intervention.refresh_from_db()
self.assertEqual(1, self.intervention.shared_teams.count())
self.assertEqual(2, self.intervention.shared_users.count())
self.assertEqual(self.team.name, self.intervention.shared_teams.first().name)
self.assertTrue(self.intervention.is_shared_with(self.user))

@ -10,6 +10,7 @@ from abc import abstractmethod
from django.contrib.gis import geos
from django.contrib.gis.geos import GEOSGeometry
from django.core.paginator import Paginator
from konova.utils.message_templates import DATA_UNSHARED
@ -19,6 +20,10 @@ class AbstractModelAPISerializer:
lookup = None
properties_data = None
rpp = None
page_number = None
paginator = None
class Meta:
abstract = True
@ -80,9 +85,12 @@ class AbstractModelAPISerializer:
Returns:
serialized_data (dict)
"""
entries = self.model.objects.filter(**self.lookup)
entries = self.model.objects.filter(**self.lookup).order_by("id")
self.paginator = Paginator(entries, self.rpp)
requested_entries = self.paginator.page(self.page_number)
serialized_data = {}
for entry in entries:
for entry in requested_entries.object_list:
serialized_data[str(entry.id)] = self._model_to_geo_json(entry)
return serialized_data

@ -367,7 +367,9 @@ class AbstractCompensationAPISerializerV1Mixin:
"""
actions = []
for entry in actions_data:
action = entry["action"]
action_types = [
self._konova_code_from_json(e, CODELIST_COMPENSATION_ACTION_ID) for e in entry["action_types"]
]
action_details = [
self._konova_code_from_json(e, CODELIST_COMPENSATION_ACTION_DETAIL_ID) for e in entry["action_details"]
]
@ -384,7 +386,7 @@ class AbstractCompensationAPISerializerV1Mixin:
# If this exact data is already existing, we do not create it new. Instead put it's id in the list of
# entries, we will use to set the new actions
action_entry = obj.actions.filter(
action_type__atom_id=action,
action_type__in=action_types,
amount=amount,
unit=unit,
comment=comment,
@ -396,13 +398,13 @@ class AbstractCompensationAPISerializerV1Mixin:
else:
# Create and add id to list
action_entry = CompensationAction.objects.create(
action_type=self._konova_code_from_json(action, CODELIST_COMPENSATION_ACTION_ID),
amount=amount,
unit=unit,
comment=comment,
)
actions.append(action_entry.id)
action_entry.action_type.set(action_types)
action_entry.action_type_details.set(action_details)
obj.actions.set(actions)
return obj
@ -438,7 +440,9 @@ class AbstractCompensationAPISerializerV1Mixin:
"""
return [
{
"action": self._konova_code_to_json(entry.action_type),
"action_types": [
self._konova_code_to_json(action) for action in entry.action_type.all()
],
"action_details": [
self._konova_code_to_json(detail) for detail in entry.action_type_details.all()
],

@ -21,7 +21,6 @@ class AbstractAPIViewV1(AbstractAPIView):
""" Holds general serialization functions for API v1
"""
serializer = None
def __init__(self, *args, **kwargs):
self.lookup = {
@ -45,11 +44,17 @@ class AbstractAPIViewV1(AbstractAPIView):
response (JsonResponse)
"""
try:
self.rpp = int(request.GET.get("rpp", self.rpp))
self.page_number = int(request.GET.get("p", self.page_number))
self.serializer.rpp = self.rpp
self.serializer.page_number = self.page_number
self.serializer.prepare_lookup(id, self.user)
data = self.serializer.fetch_and_serialize()
except Exception as e:
return self.return_error_response(e, 500)
return JsonResponse(data)
return self._return_error_response(e, 500)
return self._return_response(request, data)
def post(self, request: HttpRequest):
""" Handles the POST request
@ -67,7 +72,7 @@ class AbstractAPIViewV1(AbstractAPIView):
body = json.loads(body)
created_id = self.serializer.create_model_from_json(body, self.user)
except Exception as e:
return self.return_error_response(e, 500)
return self._return_error_response(e, 500)
return JsonResponse({"id": created_id})
def put(self, request: HttpRequest, id=None):
@ -87,7 +92,7 @@ class AbstractAPIViewV1(AbstractAPIView):
body = json.loads(body)
updated_id = self.serializer.update_model_from_json(id, body, self.user)
except Exception as e:
return self.return_error_response(e, 500)
return self._return_error_response(e, 500)
return JsonResponse({"id": updated_id})
def delete(self, request: HttpRequest, id=None):
@ -104,7 +109,7 @@ class AbstractAPIViewV1(AbstractAPIView):
try:
success = self.serializer.delete_entry(id, self.user)
except Exception as e:
return self.return_error_response(e, 500)
return self._return_error_response(e, 500)
return JsonResponse(
{
"success": success,

@ -19,7 +19,7 @@ from ema.models import Ema
from intervention.models import Intervention
from konova.utils.message_templates import DATA_UNSHARED
from konova.utils.user_checks import is_default_group_only
from user.models import User
from user.models import User, Team
class AbstractAPIView(View):
@ -31,10 +31,22 @@ class AbstractAPIView(View):
"""
user = None
serializer = None
rpp = 5 # Results per page default
page_number = 1 # Page number default
class Meta:
abstract = True
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.response_body_base = {
"rpp": None,
"p": None,
"next": None,
"results": None
}
@csrf_exempt
def dispatch(self, request, *args, **kwargs):
try:
@ -42,13 +54,14 @@ class AbstractAPIView(View):
ksp_token = request.headers.get(KSP_TOKEN_HEADER_IDENTIFIER, None)
ksp_user = request.headers.get(KSP_USER_HEADER_IDENTIFIER, None)
self.user = APIUserToken.get_user_from_token(ksp_token, ksp_user)
request.user = self.user
if not self.user.is_default_user():
raise PermissionError("Default permissions required")
except PermissionError as e:
return self.return_error_response(e, 403)
return self._return_error_response(e, 403)
return super().dispatch(request, *args, **kwargs)
def return_error_response(self, error, status_code=500):
def _return_error_response(self, error, status_code=500):
""" Returns an error as JsonReponse
Args:
@ -68,6 +81,31 @@ class AbstractAPIView(View):
status=status_code
)
def _return_response(self, request: HttpRequest, data):
""" Returns all important data into a response object
Args:
request (HttpRequest): The incoming request
data (dict): The serialized data
Returns:
response (JsonResponse): The response to be returned
"""
response = self.response_body_base
next_page = self.page_number + 1
next_page = next_page if next_page in self.serializer.paginator.page_range else None
if next_page is not None:
next_url = request.build_absolute_uri(
request.path + f"?rpp={self.rpp}&p={next_page}"
)
else:
next_url = None
response["rpp"] = self.rpp
response["p"] = self.page_number
response["next"] = next_url
response["results"] = data
return JsonResponse(response)
class InterventionCheckAPIView(AbstractAPIView):
@ -82,14 +120,14 @@ class InterventionCheckAPIView(AbstractAPIView):
response (JsonResponse)
"""
if not self.user.is_zb_user():
return self.return_error_response("Permission not granted", 403)
return self._return_error_response("Permission not granted", 403)
try:
obj = Intervention.objects.get(
id=id,
users__in=[self.user]
)
except Exception as e:
return self.return_error_response(e)
return self._return_error_response(e)
all_valid, check_details = self.run_quality_checks(obj)
@ -160,13 +198,21 @@ class AbstractModelShareAPIView(AbstractAPIView):
"""
try:
users = self._get_shared_users_of_object(id)
teams = self._get_shared_teams_of_object(id)
except Exception as e:
return self.return_error_response(e)
return self._return_error_response(e)
data = {
"users": [
user.username for user in users
]
],
"teams": [
{
"id": team.id,
"name": team.name,
}
for team in teams
],
}
return JsonResponse(data)
@ -185,7 +231,7 @@ class AbstractModelShareAPIView(AbstractAPIView):
try:
success = self._process_put_body(request.body, id)
except Exception as e:
return self.return_error_response(e)
return self._return_error_response(e)
data = {
"success": success,
}
@ -220,6 +266,22 @@ class AbstractModelShareAPIView(AbstractAPIView):
users = obj.shared_users
return users
def _get_shared_teams_of_object(self, id) -> QuerySet:
""" Check permissions and get the teams
Args:
id (str): The object's id
Returns:
users (QuerySet)
"""
obj = self.model.objects.get(
id=id
)
self._check_user_has_shared_access(obj)
teams = obj.shared_teams
return teams
def _process_put_body(self, body: bytes, id: str):
""" Reads the body data, performs validity checks and sets the new users
@ -233,19 +295,26 @@ class AbstractModelShareAPIView(AbstractAPIView):
obj = self.model.objects.get(id=id)
self._check_user_has_shared_access(obj)
new_users = json.loads(body.decode("utf-8"))
new_users = new_users.get("users", [])
content = json.loads(body.decode("utf-8"))
new_users = content.get("users", [])
if len(new_users) == 0:
raise ValueError("Shared user list must not be empty!")
new_teams = content.get("teams", [])
# Eliminate duplicates
new_users = list(dict.fromkeys(new_users))
new_teams = list(dict.fromkeys(new_teams))
# Make sure each of these names exist as a user
new_users_objs = []
for user in new_users:
new_users_objs.append(User.objects.get(username=user))
# Make sure each of these names exist as a user
new_teams_objs = []
for team_name in new_teams:
new_teams_objs.append(Team.objects.get(name=team_name))
if is_default_group_only(self.user):
# Default only users are not allowed to remove other users from having access. They can only add new ones!
new_users_to_be_added = User.objects.filter(
@ -254,7 +323,16 @@ class AbstractModelShareAPIView(AbstractAPIView):
id__in=obj.shared_users
)
new_users_objs = obj.shared_users.union(new_users_to_be_added)
obj.share_with_list(new_users_objs)
new_teams_to_be_added = Team.objects.filter(
name__in=new_teams
).exclude(
id__in=obj.shared_teams
)
new_teams_objs = obj.shared_teams.union(new_teams_to_be_added)
obj.share_with_user_list(new_users_objs)
obj.share_with_team_list(new_teams_objs)
return True

@ -65,6 +65,26 @@ class KonovaCode(models.Model):
ret_val += ", " + self.parent.long_name
return ret_val
def add_children(self):
""" Adds all children (resurcively until leaf) as .children to the KonovaCode
Returns:
code (KonovaCode): The manipulated KonovaCode instance
"""
if self.is_leaf:
return None
children = KonovaCode.objects.filter(
code_lists__in=self.code_lists.all(),
parent=self
).order_by(
"long_name"
)
self.children = children
for child in children:
child.add_children()
return self
class KonovaCodeList(models.Model):
"""

@ -13,7 +13,8 @@ CODELIST_BASE_URL = "https://codelisten.naturschutz.rlp.de/repository/referenzli
CODELIST_INTERVENTION_HANDLER_ID = 903 # CLMassnahmeträger
CODELIST_CONSERVATION_OFFICE_ID = 907 # CLNaturschutzbehörden
CODELIST_REGISTRATION_OFFICE_ID = 1053 # CLZulassungsbehörden
CODELIST_BIOTOPES_ID = 974 # CL_EIV_Biotoptypen
CODELIST_BIOTOPES_ID = 654 # CL_Biotoptypen
CODELIST_AFTER_STATE_BIOTOPES__ID = 974 # CL-KSP_ZielBiotoptypen - USAGE HAS BEEN DROPPED IN 2022 IN FAVOR OF 654
CODELIST_BIOTOPES_EXTRA_CODES_ID = 975 # CLZusatzbezeichnung
CODELIST_LAW_ID = 1048 # CLVerfahrensrecht
CODELIST_PROCESS_TYPE_ID = 44382 # CLVerfahrenstyp

@ -59,8 +59,9 @@ class CheckboxCompensationTableFilter(CheckboxTableFilter):
"""
if not value:
return queryset.filter(
intervention__users__in=[self.user], # requesting user has access
)
Q(intervention__users__in=[self.user]) | # requesting user has access
Q(intervention__teams__users__in=[self.user])
).distinct()
else:
return queryset
@ -127,24 +128,6 @@ class CheckboxEcoAccountTableFilter(CheckboxTableFilter):
)
)
def filter_show_all(self, queryset, name, value) -> QuerySet:
""" Filters queryset depending on value of 'show_all' setting
Args:
queryset ():
name ():
value ():
Returns:
"""
if not value:
return queryset.filter(
users__in=[self.user], # requesting user has access
)
else:
return queryset
def filter_only_show_unrecorded(self, queryset, name, value) -> QuerySet:
""" Filters queryset depending on value of 'show_recorded' setting

@ -400,7 +400,7 @@ class NewEcoAccountForm(AbstractCompensationForm, CompensationResponsibleFormMix
comment=comment,
legal=legal
)
acc.share_with(user)
acc.share_with_user(user)
# Add the log entry to the main objects log list
acc.log.add(action)

@ -17,10 +17,11 @@ from codelist.models import KonovaCode
from codelist.settings import CODELIST_BIOTOPES_ID, CODELIST_COMPENSATION_ACTION_ID, CODELIST_BIOTOPES_EXTRA_CODES_ID, \
CODELIST_COMPENSATION_ACTION_DETAIL_ID
from compensation.models import CompensationDocument, EcoAccountDocument
from intervention.inputs import CompensationActionTreeCheckboxSelectMultiple
from konova.contexts import BaseContext
from konova.forms import BaseModalForm, NewDocumentModalForm, RemoveModalForm
from konova.models import DeadlineType
from konova.utils.message_templates import FORM_INVALID, ADDED_COMPENSATION_STATE, ADDED_DEADLINE, \
from konova.utils.message_templates import FORM_INVALID, ADDED_COMPENSATION_STATE, \
ADDED_COMPENSATION_ACTION, PAYMENT_EDITED, COMPENSATION_STATE_EDITED, COMPENSATION_ACTION_EDITED, DEADLINE_EDITED
@ -405,22 +406,13 @@ class NewActionModalForm(BaseModalForm):
"""
from compensation.models import UnitChoices
action_type = forms.ModelChoiceField(
action_type = forms.MultipleChoiceField(
label=_("Action Type"),
label_suffix="",
required=True,
help_text=_("Select the action type"),
queryset=KonovaCode.objects.filter(
is_archived=False,
is_leaf=True,
code_lists__in=[CODELIST_COMPENSATION_ACTION_ID],
),
widget=autocomplete.ModelSelect2(
url="codes-compensation-action-autocomplete",
attrs={
"data-placeholder": _("Action"),
}
),
help_text=_("An action can consist of multiple different action types. All the selected action types are expected to be performed according to the amount and unit below on this form."),
choices=[],
widget=CompensationActionTreeCheckboxSelectMultiple(),
)
action_type_details = forms.ModelMultipleChoiceField(
label=_("Action Type detail"),
@ -482,6 +474,16 @@ class NewActionModalForm(BaseModalForm):
super().__init__(*args, **kwargs)
self.form_title = _("New action")
self.form_caption = _("Insert data for the new action")
choices =KonovaCode.objects.filter(
code_lists__in=[CODELIST_COMPENSATION_ACTION_ID],
is_archived=False,
is_leaf=True,
).values_list("id", flat=True)
choices = [
(choice, choice)
for choice in choices
]
self.fields["action_type"].choices = choices
def save(self):
action = self.instance.add_action(self)
@ -496,7 +498,7 @@ class EditCompensationActionModalForm(NewActionModalForm):
self.action = kwargs.pop("action", None)
super().__init__(*args, **kwargs)
form_data = {
"action_type": self.action.action_type,
"action_type": list(self.action.action_type.values_list("id", flat=True)),
"action_type_details": self.action.action_type_details.all(),
"amount": self.action.amount,
"unit": self.action.unit,
@ -506,7 +508,7 @@ class EditCompensationActionModalForm(NewActionModalForm):
def save(self):
action = self.action
action.action_type = self.cleaned_data.get("action_type", None)
action.action_type.set(self.cleaned_data.get("action_type", []))
action.action_type_details.set(self.cleaned_data.get("action_type_details", []))
action.amount = self.cleaned_data.get("amount", None)
action.unit = self.cleaned_data.get("unit", None)

@ -8,17 +8,6 @@ Created on: 14.10.21
from django.db import models
class CompensationActionManager(models.Manager):
""" Holds default db fetch setting for this model type
"""
def get_queryset(self):
return super().get_queryset().select_related(
"action_type",
"action_type__parent"
)
class CompensationStateManager(models.Manager):
""" Holds default db fetch setting for this model type

@ -0,0 +1,42 @@
# Generated by Django 3.1.3 on 2022-02-10 13:02
from django.db import migrations, models
def migrate_actions(apps, schema_editor):
CompensationAction = apps.get_model('compensation', 'CompensationAction')
actions = CompensationAction.objects.all()
for action in actions:
action_type = action.action_type or []
action.action_type_tmp.set([action_type])
action.save()
if not action.action_type_tmp.count() > 0:
raise ValueError("Migration of actions did not work! Stoped before data loss!")
class Migration(migrations.Migration):
dependencies = [
('codelist', '0001_initial'),
('compensation', '0003_auto_20220202_0846'),
]
operations = [
migrations.AddField(
model_name='compensationaction',
name='action_type_tmp',
field=models.ManyToManyField(blank=True, limit_choices_to={'code_lists__in': [1026], 'is_archived': False, 'is_selectable': True}, related_name='_compensationaction_action_type_+', to='codelist.KonovaCode'),
),
migrations.RunPython(migrate_actions),
migrations.RemoveField(
model_name='compensationaction',
name='action_type',
),
migrations.RenameField(
model_name='compensationaction',
old_name='action_type_tmp',
new_name='action_type',
)
]

@ -0,0 +1,46 @@
# Generated by Django 3.1.3 on 2022-02-18 08:17
from django.db import migrations, models, transaction
import django.db.models.deletion
from codelist.settings import CODELIST_BIOTOPES_ID, CODELIST_AFTER_STATE_BIOTOPES__ID
def migrate_entries_974_to_654(apps, schema_editor):
CompensationState = apps.get_model("compensation", "CompensationState")
KonovaCode = apps.get_model("codelist", "KonovaCode")
all_states = CompensationState.objects.all()
with transaction.atomic():
for state in all_states:
code_from_654 = KonovaCode.objects.get(
short_name=state.biotope_type.short_name,
code_lists__in=[CODELIST_BIOTOPES_ID],
is_archived=False,
is_leaf=True,
)
state.biotope_type = code_from_654
state.save()
old_list_states = CompensationState.objects.filter(
biotope_type__code_lists__in=[CODELIST_AFTER_STATE_BIOTOPES__ID]
)
if old_list_states.count() > 0:
raise Exception("Still unmigrated values!")
class Migration(migrations.Migration):
dependencies = [
('codelist', '0001_initial'),
('compensation', '0004_auto_20220210_1402'),
]
operations = [
migrations.RunPython(migrate_entries_974_to_654),
migrations.AlterField(
model_name='compensationstate',
name='biotope_type',
field=models.ForeignKey(blank=True, limit_choices_to={'code_lists__in': [654], 'is_archived': False, 'is_selectable': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='codelist.konovacode'),
),
]

@ -0,0 +1,19 @@
# Generated by Django 3.1.3 on 2022-02-18 09:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('user', '0003_team'),
('compensation', '0005_auto_20220218_0917'),
]
operations = [
migrations.AddField(
model_name='ecoaccount',
name='teams',
field=models.ManyToManyField(help_text='Teams having access (data shared with)', to='user.Team'),
),
]

@ -10,9 +10,7 @@ from django.utils.translation import gettext_lazy as _
from codelist.models import KonovaCode
from codelist.settings import CODELIST_COMPENSATION_ACTION_ID, CODELIST_COMPENSATION_ACTION_DETAIL_ID
from compensation.managers import CompensationActionManager
from konova.models import BaseResource
from konova.utils.message_templates import COMPENSATION_ACTION_REMOVED
class UnitChoices(models.TextChoices):
@ -31,10 +29,8 @@ class CompensationAction(BaseResource):
"""
Compensations include actions like planting trees, refreshing rivers and so on.
"""
action_type = models.ForeignKey(
action_type = models.ManyToManyField(
KonovaCode,
on_delete=models.SET_NULL,
null=True,
blank=True,
limit_choices_to={
"code_lists__in": [CODELIST_COMPENSATION_ACTION_ID],
@ -57,10 +53,8 @@ class CompensationAction(BaseResource):
unit = models.CharField(max_length=100, null=True, blank=True, choices=UnitChoices.choices)
comment = models.TextField(blank=True, null=True, help_text="Additional comment")
objects = CompensationActionManager()
def __str__(self):
return f"{self.action_type} | {self.amount} {self.unit}"
return f"{self.action_type.all()} | {self.amount} {self.unit}"
@property
def unit_humanize(self):

@ -8,7 +8,7 @@ Created on: 16.11.21
import shutil
from django.contrib import messages
from user.models import User
from user.models import User, Team
from django.db import models, transaction
from django.db.models import QuerySet, Sum
from django.http import HttpRequest
@ -104,12 +104,12 @@ class AbstractCompensation(BaseObject, GeoReferencedMixin):
with transaction.atomic():
user_action = UserActionLogEntry.get_created_action(user)
comp_action = CompensationAction.objects.create(
action_type=form_data["action_type"],
amount=form_data["amount"],
unit=form_data["unit"],
comment=form_data["comment"],
created=user_action,
)
comp_action.action_type.set(form_data.get("action_type", []))
comp_action_details = form_data["action_type_details"]
comp_action.action_type_details.set(comp_action_details)
self.actions.add(comp_action)
@ -299,7 +299,7 @@ class Compensation(AbstractCompensation, CEFMixin, CoherenceMixin):
# Compensations inherit their shared state from the interventions
return self.intervention.is_shared_with(user)
def share_with(self, user: User):
def share_with_user(self, user: User):
""" Adds user to list of shared access users
Args:
@ -308,10 +308,9 @@ class Compensation(AbstractCompensation, CEFMixin, CoherenceMixin):
Returns:
"""
if not self.intervention.is_shared_with(user):
self.intervention.users.add(user)
self.intervention.users.add(user)
def share_with_list(self, user_list: list):
def share_with_user_list(self, user_list: list):
""" Sets the list of shared access users
Args:
@ -322,6 +321,28 @@ class Compensation(AbstractCompensation, CEFMixin, CoherenceMixin):
"""
self.intervention.users.set(user_list)
def share_with_team(self, team: Team):
""" Adds team to list of shared access teams
Args:
team (Team): The team to be added to the object
Returns:
"""
self.intervention.teams.add(team)
def share_with_team_list(self, team_list: list):
""" Sets the list of shared access teams
Args:
team_list (list): The teams to be added to the object
Returns:
"""
self.intervention.teams.set(team_list)
@property
def shared_users(self) -> QuerySet:
""" Shortcut for fetching the users which have shared access on this object
@ -331,6 +352,15 @@ class Compensation(AbstractCompensation, CEFMixin, CoherenceMixin):
"""
return self.intervention.users.all()
@property
def shared_teams(self) -> QuerySet:
""" Shortcut for fetching the teams which have shared access on this object
Returns:
users (QuerySet)
"""
return self.intervention.teams.all()
def get_documents(self) -> QuerySet:
""" Getter for all documents of a compensation

@ -47,13 +47,15 @@
{% for action in actions %}
<tr>
<td class="">
<span>{{ action.action_type }}</span>
{% if action.action_type_details.count > 0 %}
<br>
{% for detail in action.action_type_details.all %}
<span class="badge badge-pill rlp-r" title="{{detail}}">{{detail.long_name}}</span>
{% endfor %}
{% endif %}
{% for type in action.action_type.all %}
<div> {{type.parent.parent.long_name}} {% fa5_icon 'angle-right' %} {{type.parent.long_name}} {% fa5_icon 'angle-right' %} {{type.long_name}} </div>
<hr>
{% endfor %}
{% for detail in action.action_type_details.all %}
<span class="badge badge-pill rlp-r" title="{{detail}}">{{detail.long_name}}</span>
{% empty %}
<span class="badge badge-pill rlp-r-outline" title="{% trans 'No action type details' %}">{% trans 'No action type details' %}</span>
{% endfor %}
</td>
<td class="">{{ action.amount|floatformat:2|intcomma }} {{ action.unit_humanize }}</td>
<td class="">

@ -48,13 +48,13 @@
{% for state in after_states %}
<tr>
<td>
<span>{{ state.biotope_type }}</span>
{% if state.biotope_type_details.count > 0 %}
<br>
{% for detail in state.biotope_type_details.all %}
<span class="badge badge-pill rlp-r" title="{{detail}}">{{detail.long_name}}</span>
{% endfor %}
{% endif %}
<span>{{ state.biotope_type.parent.long_name }} {% fa5_icon 'angle-right' %} {{ state.biotope_type.long_name }} ({{state.biotope_type.short_name}})</span>
<br>
{% for detail in state.biotope_type_details.all %}
<span class="badge badge-pill rlp-r" title="{{detail}}">{{detail.long_name}}</span>
{% empty %}
<span class="badge badge-pill rlp-r-outline" title="{% trans 'No biotope type details' %}">{% trans 'No biotope type details' %}</span>
{% endfor %}
</td>
<td>{{ state.surface|floatformat:2 }} m²</td>
<td class="align-middle float-right">

@ -48,13 +48,13 @@
{% for state in before_states %}
<tr>
<td>
<span>{{ state.biotope_type }}</span>
{% if state.biotope_type_details.count > 0 %}
<br>
{% for detail in state.biotope_type_details.all %}
<span class="badge badge-pill rlp-r" title="{{detail}}">{{detail.long_name}}</span>
{% endfor %}
{% endif %}
<span>{{ state.biotope_type.parent.long_name }} {% fa5_icon 'angle-right' %} {{ state.biotope_type.long_name }} ({{state.biotope_type.short_name}})</span>
<br>
{% for detail in state.biotope_type_details.all %}
<span class="badge badge-pill rlp-r" title="{{detail}}">{{detail.long_name}}</span>
{% empty %}
<span class="badge badge-pill rlp-r-outline" title="{% trans 'No biotope type details' %}">{% trans 'No biotope type details' %}</span>
{% endfor %}
</td>
<td>{{ state.surface|floatformat:2 }} m²</td>
<td class="align-middle float-right">

@ -2,6 +2,7 @@
{% load i18n l10n static fontawesome_5 humanize ksp_filters %}
{% block head %}
{% comment %}
dal documentation (django-autocomplete-light) states using form.media for adding needed scripts.
This does not work properly with modal forms, as the scripts are not loaded properly inside the modal.
@ -97,6 +98,10 @@
<tr>
<th scope="row">{% trans 'Shared with' %}</th>
<td class="align-middle">
{% for team in obj.intervention.teams.all %}
{% include 'user/includes/team_data_modal_button.html' %}
{% endfor %}
<hr>
{% for user in obj.intervention.users.all %}
{% include 'user/includes/contact_modal_button.html' %}
{% endfor %}

@ -46,13 +46,15 @@
{% for action in actions %}
<tr>
<td class="">
<span>{{ action.action_type }}</span>
{% if action.action_type_details.count > 0 %}
<br>
{% for detail in action.action_type_details.all %}
<span class="badge badge-pill rlp-r" title="{{detail}}">{{detail.long_name}}</span>
{% endfor %}
{% endif %}
{% for type in action.action_type.all %}
<div> {{type.parent.parent.long_name}} {% fa5_icon 'angle-right' %} {{type.parent.long_name}} {% fa5_icon 'angle-right' %} {{type.long_name}} </div>
<hr>
{% endfor %}
{% for detail in action.action_type_details.all %}
<span class="badge badge-pill rlp-r" title="{{detail}}">{{detail.long_name}}</span>
{% empty %}
<span class="badge badge-pill rlp-r-outline" title="{% trans 'No action type details' %}">{% trans 'No action type details' %}</span>
{% endfor %}
</td>
<td class="">{{ action.amount|floatformat:2|intcomma }} {{ action.unit_humanize }}</td>
<td class="">

@ -10,7 +10,7 @@
</div>
<div class="col-sm-6">
<div class="d-flex justify-content-end">
{% if is_default_member and has_access %}
{% if is_default_member and obj.recorded %}
<button class="btn btn-outline-default btn-modal" data-form-url="{% url 'compensation:acc:new-deduction' obj.id %}" title="{% trans 'Add new deduction' %}">
{% fa5_icon 'plus' %}
{% fa5_icon 'tree' %}
@ -61,7 +61,7 @@
<td class="align-middle">{{ deduction.surface|floatformat:2|intcomma }} m²</td>
<td class="align-middle">{{ deduction.created.timestamp|default_if_none:""|naturalday}}</td>
<td class="align-middle float-right">
{% if is_default_member and has_access %}
{% if is_default_member and has_access or is_default_member and user in deduction.intervention.shared_users %}
<button data-form-url="{% url 'compensation:acc:edit-deduction' deduction.account.id deduction.id %}" class="btn btn-default btn-modal" title="{% trans 'Edit Deduction' %}">
{% fa5_icon 'edit' %}
</button>

@ -48,13 +48,13 @@
{% for state in after_states %}
<tr>
<td>
<span>{{ state.biotope_type }}</span>
{% if state.biotope_type_details.count > 0 %}
<br>
{% for detail in state.biotope_type_details.all %}
<span class="badge badge-pill rlp-r" title="{{detail}}">{{detail.long_name}}</span>
{% endfor %}
{% endif %}
<span>{{ state.biotope_type.parent.long_name }} {% fa5_icon 'angle-right' %} {{ state.biotope_type.long_name }} ({{state.biotope_type.short_name}})</span>
<br>
{% for detail in state.biotope_type_details.all %}
<span class="badge badge-pill rlp-r" title="{{detail}}">{{detail.long_name}}</span>
{% empty %}
<span class="badge badge-pill rlp-r-outline" title="{% trans 'No biotope type details' %}">{% trans 'No biotope type details' %}</span>
{% endfor %}
</td>
<td>{{ state.surface|floatformat:2 }} m²</td>
<td class="align-middle float-right">

@ -48,13 +48,13 @@
{% for state in before_states %}
<tr>
<td>
<span>{{ state.biotope_type }}</span>
{% if state.biotope_type_details.count > 0 %}
<br>
{% for detail in state.biotope_type_details.all %}
<span class="badge badge-pill rlp-r" title="{{detail}}">{{detail.long_name}}</span>
{% endfor %}
{% endif %}
<span>{{ state.biotope_type.parent.long_name }} {% fa5_icon 'angle-right' %} {{ state.biotope_type.long_name }} ({{state.biotope_type.short_name}})</span>
<br>
{% for detail in state.biotope_type_details.all %}
<span class="badge badge-pill rlp-r" title="{{detail}}">{{detail.long_name}}</span>
{% empty %}
<span class="badge badge-pill rlp-r-outline" title="{% trans 'No biotope type details' %}">{% trans 'No biotope type details' %}</span>
{% endfor %}
</td>
<td>{{ state.surface|floatformat:2 }} m²</td>
<td class="align-middle float-right">

@ -2,6 +2,7 @@
{% load i18n l10n static fontawesome_5 humanize %}
{% block head %}
{% comment %}
dal documentation (django-autocomplete-light) states using form.media for adding needed scripts.
This does not work properly with modal forms, as the scripts are not loaded properly inside the modal.
@ -80,6 +81,10 @@
<tr>
<th scope="row">{% trans 'Shared with' %}</th>
<td class="align-middle">
{% for team in obj.teams.all %}
{% include 'user/includes/team_data_modal_button.html' %}
{% endfor %}
<hr>
{% for user in obj.users.all %}
{% include 'user/includes/contact_modal_button.html' %}
{% endfor %}

@ -103,7 +103,7 @@ class CompensationViewTestCase(BaseViewTestCase):
client = Client()
client.login(username=self.superuser.username, password=self.superuser_pw)
self.superuser.groups.set([])
self.intervention.share_with_list([self.superuser])
self.intervention.share_with_user_list([self.superuser])
# Since the user has no groups, it does not matter that data has been shared. There SHOULD not be any difference
# to a user without access, since the important permissions are missing
@ -143,7 +143,7 @@ class CompensationViewTestCase(BaseViewTestCase):
client.login(username=self.superuser.username, password=self.superuser_pw)
self.superuser.groups.set([])
# Sharing is inherited by base intervention for compensation. Therefore configure the interventions share state
self.intervention.share_with_list([])
self.intervention.share_with_user_list([])
# Since the user has no groups, it does not matter that data is unshared. There SHOULD not be any difference
# to a user having shared access, since all important permissions are missing
@ -185,7 +185,7 @@ class CompensationViewTestCase(BaseViewTestCase):
group = self.groups.get(name=DEFAULT_GROUP)
self.superuser.groups.set([group])
# Sharing is inherited by base intervention for compensation. Therefore configure the interventions share state
self.intervention.share_with_list([self.superuser])
self.intervention.share_with_user_list([self.superuser])
success_urls = [
self.index_url,
@ -221,7 +221,7 @@ class CompensationViewTestCase(BaseViewTestCase):
group = self.groups.get(name=DEFAULT_GROUP)
self.superuser.groups.set([group])
# Sharing is inherited by base intervention for compensation. Therefore configure the interventions share state
self.intervention.share_with_list([])
self.intervention.share_with_user_list([])
success_urls = [
self.index_url,

@ -25,7 +25,7 @@ class CompensationWorkflowTestCase(BaseWorkflowTestCase):
super().setUp()
# Give the user shared access to the dummy intervention -> inherits the access to the compensation
self.intervention.share_with(self.superuser)
self.intervention.share_with_user(self.superuser)
# Make sure the intervention itself would be fine with valid data
self.intervention = self.fill_out_intervention(self.intervention)

@ -78,7 +78,7 @@ class EcoAccountViewTestCase(CompensationViewTestCase):
client = Client()
client.login(username=self.superuser.username, password=self.superuser_pw)
self.superuser.groups.set([])
self.eco_account.share_with_list([self.superuser])
self.eco_account.share_with_user_list([self.superuser])
# Since the user has no groups, it does not matter that data has been shared. There SHOULD not be any difference
# to a user without access, since the important permissions are missing
@ -119,7 +119,7 @@ class EcoAccountViewTestCase(CompensationViewTestCase):
client = Client()
client.login(username=self.superuser.username, password=self.superuser_pw)
self.superuser.groups.set([])
self.eco_account.share_with_list([])
self.eco_account.share_with_user_list([])
# Since the user has no groups, it does not matter that data is unshared. There SHOULD not be any difference
# to a user having shared access, since all important permissions are missing
@ -163,7 +163,7 @@ class EcoAccountViewTestCase(CompensationViewTestCase):
group = self.groups.get(name=DEFAULT_GROUP)
self.superuser.groups.set([group])
# Sharing is inherited by base intervention for compensation. Therefore configure the interventions share state
self.eco_account.share_with_list([self.superuser])
self.eco_account.share_with_user_list([self.superuser])
success_urls = [
self.index_url,
@ -200,7 +200,7 @@ class EcoAccountViewTestCase(CompensationViewTestCase):
client.login(username=self.superuser.username, password=self.superuser_pw)
group = self.groups.get(name=DEFAULT_GROUP)
self.superuser.groups.set([group])
self.eco_account.share_with_list([])
self.eco_account.share_with_user_list([])
success_urls = [
self.index_url,

@ -27,7 +27,7 @@ class EcoAccountWorkflowTestCase(BaseWorkflowTestCase):
# Add user to conservation office group and give shared access to the account
self.superuser.groups.add(self.groups.get(name=DEFAULT_GROUP))
self.superuser.groups.add(self.groups.get(name=ETS_GROUP))
self.eco_account.share_with_list([self.superuser])
self.eco_account.share_with_user_list([self.superuser])
def test_new(self):
""" Test the creation of an EcoAccount
@ -73,7 +73,7 @@ class EcoAccountWorkflowTestCase(BaseWorkflowTestCase):
Returns:
"""
self.eco_account.share_with(self.superuser)
self.eco_account.share_with_user(self.superuser)
url = reverse("compensation:acc:edit", args=(self.eco_account.id,))
pre_edit_log_count = self.eco_account.log.count()
@ -129,7 +129,7 @@ class EcoAccountWorkflowTestCase(BaseWorkflowTestCase):
"""
# Add proper privilege for the user
self.eco_account.share_with(self.superuser)
self.eco_account.share_with_user(self.superuser)
pre_record_log_count = self.eco_account.log.count()
# Prepare url and form data
@ -178,7 +178,7 @@ class EcoAccountWorkflowTestCase(BaseWorkflowTestCase):
"""
# Give user shared access to the dummy intervention, which will be needed here
self.intervention.share_with(self.superuser)
self.intervention.share_with_user(self.superuser)
pre_deduction_acc_log_count = self.eco_account.log.count()
pre_deduction_int_log_count = self.intervention.log.count()
@ -231,7 +231,9 @@ class EcoAccountWorkflowTestCase(BaseWorkflowTestCase):
def test_edit_deduction(self):
test_surface = self.eco_account.get_available_rest()[0]
self.eco_account.set_recorded(self.superuser)
self.intervention.share_with_user(self.superuser)
self.eco_account.refresh_from_db()
self.assertTrue(self.superuser, self.intervention.is_shared_with(self.superuser))
deduction = EcoAccountDeduction.objects.create(
intervention=self.intervention,
@ -279,8 +281,8 @@ class EcoAccountWorkflowTestCase(BaseWorkflowTestCase):
"confirm": True,
}
intervention.share_with(self.superuser)
account.share_with(self.superuser)
intervention.share_with_user(self.superuser)
account.share_with_user(self.superuser)
pre_edit_intervention_log_count = intervention.log.count()
pre_edit_account_log_count = account.log.count()

@ -64,7 +64,7 @@ class PaymentViewTestCase(BaseViewTestCase):
client = Client()
client.login(username=self.superuser.username, password=self.superuser_pw)
self.superuser.groups.set([])
self.intervention.share_with_list([self.superuser])
self.intervention.share_with_user_list([self.superuser])
# Since the user has no groups, it does not matter that data has been shared. There SHOULD not be any difference
# to a user without access, since the important permissions are missing
@ -91,7 +91,7 @@ class PaymentViewTestCase(BaseViewTestCase):
client.login(username=self.superuser.username, password=self.superuser_pw)
self.superuser.groups.set([])
# Sharing is inherited by base intervention for compensation. Therefore configure the interventions share state
self.intervention.share_with_list([])
self.intervention.share_with_user_list([])
# Since the user has no groups, it does not matter that data is unshared. There SHOULD not be any difference
# to a user having shared access, since all important permissions are missing
@ -120,7 +120,7 @@ class PaymentViewTestCase(BaseViewTestCase):
group = self.groups.get(name=DEFAULT_GROUP)
self.superuser.groups.set([group])
# Sharing is inherited by base intervention for compensation. Therefore configure the interventions share state
self.intervention.share_with_list([self.superuser])
self.intervention.share_with_user_list([self.superuser])
success_urls = [
self.new_url,
@ -143,7 +143,7 @@ class PaymentViewTestCase(BaseViewTestCase):
group = self.groups.get(name=DEFAULT_GROUP)
self.superuser.groups.set([group])
# Sharing is inherited by base intervention for compensation. Therefore configure the interventions share state
self.intervention.share_with_list([])
self.intervention.share_with_user_list([])
success_urls = [
]

@ -21,7 +21,7 @@ class PaymentWorkflowTestCase(BaseWorkflowTestCase):
def setUp(self) -> None:
super().setUp()
# Give the user shared access to the dummy intervention
self.intervention.share_with(self.superuser)
self.intervention.share_with_user(self.superuser)
self.payment = Payment.objects.get_or_create(
intervention=self.intervention,

@ -272,7 +272,6 @@ def remove_view(request: HttpRequest, id: str):
@login_required
@default_group_required
@shared_access_required(EcoAccount, "id")
def deduction_remove_view(request: HttpRequest, id: str, deduction_id: str):
""" Renders a modal view for removing deductions
@ -287,6 +286,8 @@ def deduction_remove_view(request: HttpRequest, id: str, deduction_id: str):
acc = get_object_or_404(EcoAccount, id=id)
try:
eco_deduction = acc.deductions.get(id=deduction_id)
if not eco_deduction.intervention.is_shared_with(request.user):
raise ObjectDoesNotExist()
except ObjectDoesNotExist:
raise Http404("Unknown deduction")
@ -300,7 +301,6 @@ def deduction_remove_view(request: HttpRequest, id: str, deduction_id: str):
@login_required
@default_group_required
@shared_access_required(EcoAccount, "id")
def deduction_edit_view(request: HttpRequest, id: str, deduction_id: str):
""" Renders a modal view for editing deductions
@ -315,6 +315,8 @@ def deduction_edit_view(request: HttpRequest, id: str, deduction_id: str):
acc = get_object_or_404(EcoAccount, id=id)
try:
eco_deduction = acc.deductions.get(id=deduction_id)
if not eco_deduction.intervention.is_shared_with(request.user):
raise ObjectDoesNotExist
except ObjectDoesNotExist:
raise Http404("Unknown deduction")
@ -679,7 +681,6 @@ def remove_document_view(request: HttpRequest, id: str, doc_id: str):
@login_required
@default_group_required
@shared_access_required(EcoAccount, "id")
def new_deduction_view(request: HttpRequest, id: str):
""" Renders a modal form view for creating deductions
@ -691,6 +692,8 @@ def new_deduction_view(request: HttpRequest, id: str):
"""
acc = get_object_or_404(EcoAccount, id=id)
if not acc.recorded:
raise Http404()
form = NewDeductionModalForm(request.POST or None, instance=acc, request=request)
return form.process_request(
request,
@ -793,7 +796,7 @@ def share_view(request: HttpRequest, id: str, token: str):
request,
_("{} has been shared with you").format(obj.identifier)
)
obj.share_with(user)
obj.share_with_user(user)
return redirect("compensation:acc:detail", id=id)
else:
messages.error(

@ -80,7 +80,7 @@ class NewEmaForm(AbstractCompensationForm, CompensationResponsibleFormMixin):
)
# Add the creating user to the list of shared users
acc.share_with(user)
acc.share_with_user(user)
# Add the log entry to the main objects log list
acc.log.add(action)

@ -0,0 +1,19 @@
# Generated by Django 3.1.3 on 2022-02-18 09:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('user', '0003_team'),
('ema', '0002_auto_20220114_0936'),
]
operations = [
migrations.AddField(
model_name='ema',
name='teams',
field=models.ManyToManyField(help_text='Teams having access (data shared with)', to='user.Team'),
),
]

@ -44,13 +44,15 @@
{% for action in obj.actions.all %}
<tr>
<td class="">
<span>{{ action.action_type }}</span>
{% if action.action_type_details.count > 0 %}
<br>
{% for detail in action.action_type_details.all %}
<span class="badge badge-pill rlp-r" title="{{detail}}">{{detail.long_name}}</span>
{% endfor %}
{% endif %}
{% for type in action.action_type.all %}
<div> {{type.parent.parent.long_name}} {% fa5_icon 'angle-right' %} {{type.parent.long_name}} {% fa5_icon 'angle-right' %} {{type.long_name}} </div>
<hr>
{% endfor %}
{% for detail in action.action_type_details.all %}
<span class="badge badge-pill rlp-r" title="{{detail}}">{{detail.long_name}}</span>
{% empty %}
<span class="badge badge-pill rlp-r-outline" title="{% trans 'No action type details' %}">{% trans 'No action type details' %}</span>
{% endfor %}
</td>
<td class="">{{ action.amount|floatformat:2|intcomma }} {{ action.unit_humanize }}</td>
<td class="">

@ -46,13 +46,13 @@
{% for state in after_states %}
<tr>
<td>
<span>{{ state.biotope_type }}</span>
{% if state.biotope_type_details.count > 0 %}
<br>
{% for detail in state.biotope_type_details.all %}
<span class="badge badge-pill rlp-r" title="{{detail}}">{{detail.long_name}}</span>
{% endfor %}
{% endif %}
<span>{{ state.biotope_type.parent.long_name }} {% fa5_icon 'angle-right' %} {{ state.biotope_type.long_name }} ({{state.biotope_type.short_name}})</span>
<br>
{% for detail in state.biotope_type_details.all %}
<span class="badge badge-pill rlp-r" title="{{detail}}">{{detail.long_name}}</span>
{% empty %}
<span class="badge badge-pill rlp-r-outline" title="{% trans 'No biotope type details' %}">{% trans 'No biotope type details' %}</span>
{% endfor %}
</td>
<td>{{ state.surface|floatformat:2 }} m²</td>
<td class="align-middle float-right">

@ -46,13 +46,13 @@
{% for state in before_states %}
<tr>
<td>
<span>{{ state.biotope_type }}</span>
{% if state.biotope_type_details.count > 0 %}
<br>
{% for detail in state.biotope_type_details.all %}
<span class="badge badge-pill rlp-r" title="{{detail}}">{{detail.long_name}}</span>
{% endfor %}
{% endif %}
<span>{{ state.biotope_type.parent.long_name }} {% fa5_icon 'angle-right' %} {{ state.biotope_type.long_name }} ({{state.biotope_type.short_name}})</span>
<br>
{% for detail in state.biotope_type_details.all %}
<span class="badge badge-pill rlp-r" title="{{detail}}">{{detail.long_name}}</span>
{% empty %}
<span class="badge badge-pill rlp-r-outline" title="{% trans 'No biotope type details' %}">{% trans 'No biotope type details' %}</span>
{% endfor %}
</td>
<td>{{ state.surface|floatformat:2 }} m²</td>
<td class="align-middle float-right">

@ -74,6 +74,10 @@
<tr>
<th scope="row">{% trans 'Shared with' %}</th>
<td class="align-middle">
{% for team in obj.teams.all %}
{% include 'user/includes/team_data_modal_button.html' %}
{% endfor %}
<hr>
{% for user in obj.users.all %}
{% include 'user/includes/contact_modal_button.html' %}
{% endfor %}

@ -110,7 +110,7 @@ class EmaViewTestCase(CompensationViewTestCase):
# Sharing does not have any effect in here, since the default group will prohibit further functionality access
# to this user
self.ema.share_with_list([self.superuser])
self.ema.share_with_user_list([self.superuser])
success_urls = [
self.index_url,
@ -160,7 +160,7 @@ class EmaViewTestCase(CompensationViewTestCase):
# Sharing does not have any effect in here, since the default group will prohibit further functionality access
# to this user
self.ema.share_with_list([])
self.ema.share_with_user_list([])
success_urls = [
self.index_url,
@ -203,7 +203,7 @@ class EmaViewTestCase(CompensationViewTestCase):
groups = self.groups.filter(Q(name=ETS_GROUP)|Q(name=DEFAULT_GROUP))
self.superuser.groups.set(groups)
# Sharing is inherited by base intervention for compensation. Therefore configure the interventions share state
self.ema.share_with_list([self.superuser])
self.ema.share_with_user_list([self.superuser])
success_urls = [
self.index_url,
@ -243,7 +243,7 @@ class EmaViewTestCase(CompensationViewTestCase):
groups = self.groups.filter(Q(name=ETS_GROUP)|Q(name=DEFAULT_GROUP))
self.superuser.groups.set(groups)
# Sharing is inherited by base intervention for compensation. Therefore configure the interventions share state
self.ema.share_with_list([])
self.ema.share_with_user_list([])
success_urls = [
self.index_url,

@ -621,7 +621,7 @@ def share_view(request: HttpRequest, id: str, token: str):
request,
_("{} has been shared with you").format(obj.identifier)
)
obj.share_with(user)
obj.share_with_user(user)
return redirect("ema:detail", id=id)
else:
messages.error(

@ -253,7 +253,7 @@ class NewInterventionForm(BaseForm):
intervention.log.add(action)
# Add the performing user as the first user having access to the data
intervention.share_with(user)
intervention.share_with_user(user)
return intervention

@ -7,11 +7,11 @@ Created on: 27.09.21
"""
from dal import autocomplete
from django.core.exceptions import ObjectDoesNotExist
from django.db.models.fields.files import FieldFile
from konova.utils.message_templates import DEDUCTION_ADDED, REVOCATION_ADDED, DEDUCTION_REMOVED, DEDUCTION_EDITED, \
REVOCATION_EDITED, FILE_TYPE_UNSUPPORTED, FILE_SIZE_TOO_LARGE
from user.models import User, UserActionLogEntry
REVOCATION_EDITED, ENTRY_REMOVE_MISSING_PERMISSION
from user.models import User, Team
from user.models import UserActionLogEntry
from django.db import transaction
from django import forms
from django.utils.translation import gettext_lazy as _
@ -37,32 +37,33 @@ class ShareModalForm(BaseModalForm):
}
)
)
user_select = forms.ModelMultipleChoiceField(
label=_("Add user to share with"),
teams = forms.ModelMultipleChoiceField(
label=_("Add team to share with"),
label_suffix="",
help_text=_("Multiple selection possible - You can only select users which do not already have access. Enter the full username."),
help_text=_("Multiple selection possible - You can only select teams which do not already have access."),
required=False,
queryset=User.objects.all(),
queryset=Team.objects.all(),
widget=autocomplete.ModelSelect2Multiple(
url="share-user-autocomplete",
url="share-team-autocomplete",
attrs={
"data-placeholder": _("Click for selection"),
"data-minimum-input-length": 3,
},
forward=["users"]
),
)
users = forms.MultipleChoiceField(
label=_("Shared with"),
users = forms.ModelMultipleChoiceField(
label=_("Add user to share with"),
label_suffix="",
required=True,
help_text=_("Remove check to remove access for this user"),
widget=forms.CheckboxSelectMultiple(
help_text=_("Multiple selection possible - You can only select users which do not already have access. Enter the full username."),
required=False,
queryset=User.objects.all(),
widget=autocomplete.ModelSelect2Multiple(
url="share-user-autocomplete",
attrs={
"class": "list-unstyled",
}
"data-placeholder": _("Click for selection"),
"data-minimum-input-length": 3,
},
),
choices=[]
)
def __init__(self, *args, **kwargs):
@ -77,6 +78,48 @@ class ShareModalForm(BaseModalForm):
self._init_fields()
def _user_team_valid(self):
""" Checks whether users and teams have been removed by the user and if the user is allowed to do so or not
Returns:
"""
users = self.cleaned_data.get("users", User.objects.none())
teams = self.cleaned_data.get("teams", Team.objects.none())
_is_valid = True
if is_default_group_only(self.user):
shared_users = self.instance.shared_users
shared_teams = self.instance.shared_teams
shared_users_are_removed = not set(shared_users).issubset(users)
shared_teams_are_removed = not set(shared_teams).issubset(teams)
if shared_users_are_removed:
self.add_error(
"users",
ENTRY_REMOVE_MISSING_PERMISSION
)
_is_valid = False
if shared_teams_are_removed:
self.add_error(
"teams",
ENTRY_REMOVE_MISSING_PERMISSION
)
_is_valid = False
return _is_valid
def is_valid(self):
""" Extended validity check
Returns:
"""
super_valid = super().is_valid()
user_team_valid = self._user_team_valid()
_is_valid = super_valid and user_team_valid
return _is_valid
def _init_fields(self):
""" Wraps initializing of fields
@ -91,34 +134,14 @@ class ShareModalForm(BaseModalForm):
self.share_link
)
# Initialize users field
# Disable field if user is not in registration or conservation group
if is_default_group_only(self.request.user):
self.disable_form_field("users")
self._add_user_choices_to_field()
def _add_user_choices_to_field(self):
""" Transforms the instance's sharing users into a list for the form field
Returns:
"""
users = self.instance.users.all()
choices = []
for n in users:
choices.append(
(n.id, n.username)
)
self.fields["users"].choices = choices
u_ids = list(users.values_list("id", flat=True))
self.initialize_form_field(
"users",
u_ids
)
form_data = {
"teams": self.instance.teams.all(),
"users": self.instance.users.all(),
}
self.load_initial_data(form_data)
def save(self):
self.instance.update_sharing_user(self)
self.instance.update_shared_access(self)
class NewRevocationModalForm(BaseModalForm):
@ -267,7 +290,9 @@ class CheckModalForm(BaseModalForm):
Returns:
"""
comps = self.instance.compensations.all()
comps = self.instance.compensations.filter(
deleted=None,
)
comps_valid = True
for comp in comps:
checker = comp.quality_check()

@ -1,4 +1,6 @@
from django import forms
from codelist.models import KonovaCode
from codelist.settings import CODELIST_COMPENSATION_ACTION_ID
class DummyFilterInput(forms.HiddenInput):
@ -30,3 +32,51 @@ class GenerateInput(forms.TextInput):
"""
template_name = "konova/widgets/generate-content-input.html"
class TreeCheckboxSelectMultiple(forms.CheckboxSelectMultiple):
""" Provides multiple selection of parent-child data
"""
template_name = "konova/widgets/checkbox-tree-select.html"
class meta:
abstract = True
class KonovaCodeTreeCheckboxSelectMultiple(TreeCheckboxSelectMultiple):
""" Provides multiple selection of KonovaCodes
"""
filter = None
def __init__(self, *args, **kwargs):
self.code_list = kwargs.pop("code_list", None)
self.filter = kwargs.pop("filter", {})
super().__init__(*args, **kwargs)
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
codes = KonovaCode.objects.filter(
**self.filter,
)
codes = [
parent_code.add_children()
for parent_code in codes
]
context["codes"] = codes
return context
class CompensationActionTreeCheckboxSelectMultiple(KonovaCodeTreeCheckboxSelectMultiple):
""" Provides multiple selection of CompensationActions
"""
filter = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.filter = {
"code_lists__in": [CODELIST_COMPENSATION_ACTION_ID],
"parent": None,
}

@ -0,0 +1,19 @@
# Generated by Django 3.1.3 on 2022-02-18 09:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('user', '0003_team'),
('intervention', '0002_auto_20220114_0936'),
]
operations = [
migrations.AddField(
model_name='intervention',
name='teams',
field=models.ManyToManyField(help_text='Teams having access (data shared with)', to='user.Team'),
),
]

@ -114,6 +114,10 @@
<tr>
<th scope="row">{% trans 'Shared with' %}</th>
<td class="align-middle">
{% for team in obj.teams.all %}
{% include 'user/includes/team_data_modal_button.html' %}
{% endfor %}
<hr>
{% for user in obj.users.all %}
{% include 'user/includes/contact_modal_button.html' %}
{% endfor %}

@ -144,7 +144,7 @@ class InterventionViewTestCase(BaseViewTestCase):
# Add user to default group
default_group = Group.objects.get(name=DEFAULT_GROUP)
self.superuser.groups.set([default_group])
self.intervention.share_with_list([self.superuser])
self.intervention.share_with_user_list([self.superuser])
success_urls = [
self.index_url,
@ -190,7 +190,7 @@ class InterventionViewTestCase(BaseViewTestCase):
# Add user to default group
default_group = Group.objects.get(name=DEFAULT_GROUP)
self.superuser.groups.set([default_group])
self.intervention.share_with_list([])
self.intervention.share_with_user_list([])
success_urls = [
self.index_url,
@ -236,7 +236,7 @@ class InterventionViewTestCase(BaseViewTestCase):
# Add user to zb group
zb_group = self.groups.get(name=ZB_GROUP)
self.superuser.groups.set([zb_group])
self.intervention.share_with_list([self.superuser])
self.intervention.share_with_user_list([self.superuser])
success_urls = [
self.index_url,
@ -282,7 +282,7 @@ class InterventionViewTestCase(BaseViewTestCase):
# Add user to zb group
zb_group = self.groups.get(name=ZB_GROUP)
self.superuser.groups.set([zb_group])
self.intervention.share_with_list([])
self.intervention.share_with_user_list([])
success_urls = [
self.index_url,
@ -328,7 +328,7 @@ class InterventionViewTestCase(BaseViewTestCase):
# Add user to ets group
ets_group = Group.objects.get(name=ETS_GROUP)
self.superuser.groups.set([ets_group])
self.intervention.share_with_list([self.superuser])
self.intervention.share_with_user_list([self.superuser])
success_urls = [
self.index_url,
@ -374,7 +374,7 @@ class InterventionViewTestCase(BaseViewTestCase):
# Add user to default group
ets_group = Group.objects.get(name=ETS_GROUP)
self.superuser.groups.set([ets_group])
self.intervention.share_with_list([])
self.intervention.share_with_user_list([])
success_urls = [
self.index_url,

@ -30,7 +30,7 @@ class InterventionWorkflowTestCase(BaseWorkflowTestCase):
super().setUp()
# Recreate a new (bare minimum) intervention before each test
self.intervention = self.create_dummy_intervention()
self.intervention.share_with(self.superuser)
self.intervention.share_with_user(self.superuser)
def test_new(self):
"""
@ -303,7 +303,6 @@ class InterventionWorkflowTestCase(BaseWorkflowTestCase):
Reasons for failing are:
* EcoAccount does not provide enough 'deductable_surface'
* EcoAccount is not recorded (not "approved"), yet
* EcoAccount is not shared with performing user
Args:
new_url (str): The url to send the post data to
@ -315,7 +314,6 @@ class InterventionWorkflowTestCase(BaseWorkflowTestCase):
# Before running fail positive tests, we need to have an account in a (normally) fine working state
self.assertIsNotNone(self.eco_account.recorded) # -> is recorded
self.assertGreater(self.eco_account.deductable_surface, test_surface) # -> has more deductable surface than we need
self.assertIn(self.superuser, self.eco_account.users.all()) # -> is shared with the performing user
# Count the number of already existing deductions in total and for the account for later comparison
num_deductions = self.eco_account.deductions.count()
@ -333,20 +331,11 @@ class InterventionWorkflowTestCase(BaseWorkflowTestCase):
self.assertEqual(num_deductions, self.eco_account.deductions.count())
self.assertEqual(num_deductions_total, EcoAccountDeduction.objects.count())
# Now restore the deductable surface to a valid size back again but remove the user from the shared list
# Now restore the deductable surface to a valid size back again
self.eco_account.deductable_surface = test_surface + 100.00
self.eco_account.share_with_list([])
self.eco_account.save()
# Now perform the (expected) failing request (again)
self.client_user.post(new_url, post_data)
# Expect no changes at all, since the account is not shared
self.assertEqual(num_deductions, self.eco_account.deductions.count())
self.assertEqual(num_deductions_total, EcoAccountDeduction.objects.count())
# Restore the sharing but remove the recording state
self.eco_account.share_with_list([self.superuser])
# Remove the recording state
self.eco_account.recorded.delete()
self.eco_account.refresh_from_db()
self.eco_account.save()
@ -376,7 +365,7 @@ class InterventionWorkflowTestCase(BaseWorkflowTestCase):
if self.eco_account.recorded is None:
rec_action = UserActionLogEntry.get_recorded_action(self.superuser)
self.eco_account.recorded = rec_action
self.eco_account.share_with_list([self.superuser])
self.eco_account.share_with_user_list([self.superuser])
self.eco_account.save()
num_all_deducs = EcoAccountDeduction.objects.count()

@ -432,7 +432,7 @@ def share_view(request: HttpRequest, id: str, token: str):
request,
_("{} has been shared with you").format(intervention.identifier)
)
intervention.share_with(user)
intervention.share_with_user(user)
return redirect("intervention:detail", id=id)
else:
messages.error(

@ -5,8 +5,13 @@ Contact: michel.peltriaux@sgdnord.rlp.de
Created on: 07.12.20
"""
import collections
from dal_select2.views import Select2QuerySetView, Select2GroupQuerySetView
from user.models import User
from django.core.exceptions import ImproperlyConfigured
from konova.utils.message_templates import UNGROUPED
from user.models import User, Team
from django.db.models import Q
from codelist.models import KonovaCode
@ -20,7 +25,7 @@ from intervention.models import Intervention
class EcoAccountAutocomplete(Select2QuerySetView):
""" Autocomplete for ecoAccount entries
Only returns entries that are accessible for the requesting user and already are recorded
Only returns entries that are already recorded and not deleted
"""
def get_queryset(self):
@ -29,7 +34,6 @@ class EcoAccountAutocomplete(Select2QuerySetView):
qs = EcoAccount.objects.filter(
deleted=None,
recorded__isnull=False,
users__in=[self.request.user],
).order_by(
"identifier"
)
@ -65,27 +69,40 @@ class InterventionAutocomplete(Select2QuerySetView):
class ShareUserAutocomplete(Select2QuerySetView):
""" Autocomplete for intervention entries
""" Autocomplete for share with single users
Only returns entries that are accessible for the requesting user
"""
def get_queryset(self):
if self.request.user.is_anonymous:
return User.objects.none()
exclude_user_ids = self.forwarded.get("users", [])
_exclude = {"id__in": exclude_user_ids}
qs = User.objects.all().exclude(
**_exclude
).order_by(
"username"
)
qs = User.objects.all()
if self.q:
# Due to privacy concerns only a full username match will return the proper user entry
qs = qs.filter(
Q(username=self.q) |
Q(email=self.q)
).distinct()
qs = qs.order_by("username")
return qs
class ShareTeamAutocomplete(Select2QuerySetView):
""" Autocomplete for share with teams
"""
def get_queryset(self):
if self.request.user.is_anonymous:
return Team.objects.none()
qs = Team.objects.all()
if self.q:
# Due to privacy concerns only a full username match will return the proper user entry
qs = qs.filter(
name__icontains=self.q
)
qs = qs.order_by(
"name"
)
return qs
@ -139,6 +156,8 @@ class KonovaCodeAutocomplete(Select2GroupQuerySetView):
q_or |= Q(short_name__icontains=keyword)
q_or |= Q(parent__long_name__icontains=keyword)
q_or |= Q(parent__short_name__icontains=keyword)
q_or |= Q(parent__parent__long_name__icontains=keyword)
q_or |= Q(parent__parent__short_name__icontains=keyword)
_filter.add(q_or, Q.AND)
qs = qs.filter(_filter).distinct()
return qs
@ -181,7 +200,7 @@ class CompensationActionDetailCodeAutocomplete(KonovaCodeAutocomplete):
def order_by(self, qs):
return qs.order_by(
"parent__long_name"
"long_name"
)
@ -214,6 +233,41 @@ class BiotopeCodeAutocomplete(KonovaCodeAutocomplete):
def get_result_label(self, result):
return f"{result.long_name} ({result.short_name})"
def get_results(self, context):
"""Return the options grouped by a common related model.
Raises ImproperlyConfigured if self.group_by_name is not configured
"""
if not self.group_by_related:
raise ImproperlyConfigured("Missing group_by_related.")
super_groups = collections.OrderedDict()
object_list = context['object_list']
for result in object_list:
group = result.parent if result.parent else None
group_name = f"{group.long_name} ({group.short_name})" if group else UNGROUPED
super_group = result.parent.parent if result.parent else None
super_group_name = f"{super_group.long_name} ({super_group.short_name})" if super_group else UNGROUPED
super_groups.setdefault(super_group_name, {})
super_groups[super_group_name].setdefault(group_name, [])
super_groups[super_group_name][group_name].append(result)
return [{
'id': None,
'text': super_group,
'children': [{
"id": None,
"text": group,
"children": [{
'id': self.get_result_value(result),
'text': self.get_result_label(result),
'selected_text': self.get_selected_result_label(result),
} for result in results]
} for group, results in groups.items()]
} for super_group, groups in super_groups.items()]
class BiotopeExtraCodeAutocomplete(KonovaCodeAutocomplete):
"""
@ -239,7 +293,7 @@ class BiotopeExtraCodeAutocomplete(KonovaCodeAutocomplete):
qs (QuerySet): The ordered queryset
"""
return qs.order_by(
"parent__long_name",
"long_name",
)
def get_result_label(self, result):
@ -284,6 +338,11 @@ class RegistrationOfficeCodeAutocomplete(KonovaCodeAutocomplete):
self.c = CODELIST_REGISTRATION_OFFICE_ID
super().__init__(*args, **kwargs)
def order_by(self, qs):
return qs.order_by(
"parent__long_name"
)
class ConservationOfficeCodeAutocomplete(KonovaCodeAutocomplete):
"""
@ -297,4 +356,4 @@ class ConservationOfficeCodeAutocomplete(KonovaCodeAutocomplete):
super().__init__(*args, **kwargs)
def get_result_label(self, result):
return f"{result.long_name} ({result.short_name})"
return f"{result.long_name} ({result.short_name})"

@ -297,8 +297,9 @@ class ShareableTableFilterMixin(django_filters.FilterSet):
"""
if not value:
return queryset.filter(
users__in=[self.user], # requesting user has access
)
Q(users__in=[self.user]) | # requesting user has access
Q(teams__users__in=[self.user])
).distinct()
else:
return queryset

@ -0,0 +1,40 @@
# Generated by Django 3.1.3 on 2022-02-16 07:56
from django.db import migrations, transaction
from codelist.settings import CODELIST_BIOTOPES_ID, CODELIST_AFTER_STATE_BIOTOPES__ID
def migrate_biotopes_from_974_to_654(apps, schema_editor):
KonovaCode = apps.get_model("codelist", "KonovaCode")
CompensationState = apps.get_model("compensation", "CompensationState")
all_states = CompensationState.objects.all()
with transaction.atomic():
for state in all_states:
new_biotope_code = KonovaCode.objects.get(
code_lists__in=[CODELIST_BIOTOPES_ID],
is_selectable=True,
is_archived=False,
short_name=state.biotope_type.short_name,
)
state.biotope_type = new_biotope_code
state.save()
all_states = CompensationState.objects.all()
after_state_list_elements = all_states.filter(
biotope_type__code_lists__in=[CODELIST_AFTER_STATE_BIOTOPES__ID]
)
if after_state_list_elements.count() > 0:
raise Exception("Still states with wrong codelist entries!")
class Migration(migrations.Migration):
dependencies = [
('konova', '0004_auto_20220209_0839'),
]
operations = [
migrations.RunPython(migrate_biotopes_from_974_to_654),
]

@ -15,8 +15,10 @@ from django.db.models import QuerySet
from konova.sub_settings.lanis_settings import DEFAULT_SRID_RLP, LANIS_ZOOM_LUT, LANIS_LINK_TEMPLATE
from konova.tasks import celery_send_mail_shared_access_removed, celery_send_mail_shared_access_given, \
celery_send_mail_shared_data_recorded, celery_send_mail_shared_data_unrecorded, \
celery_send_mail_shared_data_deleted, celery_send_mail_shared_data_checked
from user.models import User
celery_send_mail_shared_data_deleted, celery_send_mail_shared_data_checked, \
celery_send_mail_shared_access_given_team, celery_send_mail_shared_access_removed_team, \
celery_send_mail_shared_data_checked_team, celery_send_mail_shared_data_deleted_team, \
celery_send_mail_shared_data_unrecorded_team, celery_send_mail_shared_data_recorded_team
from django.core.exceptions import ObjectDoesNotExist
from django.http import HttpRequest
from django.utils.timezone import now
@ -28,7 +30,6 @@ from intervention.settings import INTERVENTION_IDENTIFIER_LENGTH, INTERVENTION_I
from konova.utils import generators
from konova.utils.generators import generate_random_string
from konova.utils.message_templates import CHECKED_RECORDED_RESET, GEOMETRY_CONFLICT_WITH_TEMPLATE
from user.models import UserActionLogEntry, UserAction
class UuidModel(models.Model):
@ -50,14 +51,14 @@ class BaseResource(UuidModel):
A basic resource model, which defines attributes for every derived model
"""
created = models.ForeignKey(
UserActionLogEntry,
"user.UserActionLogEntry",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='+'
)
modified = models.ForeignKey(
UserActionLogEntry,
"user.UserActionLogEntry",
on_delete=models.SET_NULL,
null=True,
blank=True,
@ -94,9 +95,9 @@ class BaseObject(BaseResource):
"""
identifier = models.CharField(max_length=1000, null=True, blank=True)
title = models.CharField(max_length=1000, null=True, blank=True)
deleted = models.ForeignKey(UserActionLogEntry, on_delete=models.SET_NULL, null=True, blank=True, related_name='+')
deleted = models.ForeignKey("user.UserActionLogEntry", on_delete=models.SET_NULL, null=True, blank=True, related_name='+')
comment = models.TextField(null=True, blank=True)
log = models.ManyToManyField(UserActionLogEntry, blank=True, help_text="Keeps all user actions of an object", editable=False)
log = models.ManyToManyField("user.UserActionLogEntry", blank=True, help_text="Keeps all user actions of an object", editable=False)
class Meta:
abstract = True
@ -105,7 +106,7 @@ class BaseObject(BaseResource):
def set_status_messages(self, request: HttpRequest):
raise NotImplementedError
def mark_as_deleted(self, user: User, send_mail: bool = True):
def mark_as_deleted(self, user, send_mail: bool = True):
""" Mark an entry as deleted
Does not delete from database but sets a timestamp for being deleted on and which user deleted the object
@ -116,6 +117,7 @@ class BaseObject(BaseResource):
Returns:
"""
from user.models import UserActionLogEntry
if self.deleted:
# Nothing to do here
return
@ -131,9 +133,14 @@ class BaseObject(BaseResource):
for user_id in shared_users:
celery_send_mail_shared_data_deleted.delay(self.identifier, self.title, user_id)
# Send mail
shared_teams = self.shared_teams.values_list("id", flat=True)
for team_id in shared_teams:
celery_send_mail_shared_data_deleted_team.delay(self.identifier, self.title, team_id)
self.save()
def mark_as_edited(self, performing_user: User, request: HttpRequest = None, edit_comment: str = None):
def mark_as_edited(self, performing_user, request: HttpRequest = None, edit_comment: str = None):
""" In case the object or a related object changed the log history needs to be updated
Args:
@ -144,13 +151,14 @@ class BaseObject(BaseResource):
Returns:
"""
from user.models import UserActionLogEntry
edit_action = UserActionLogEntry.get_edited_action(performing_user, edit_comment)
self.modified = edit_action
self.log.add(edit_action)
self.save()
return edit_action
def add_log_entry(self, action: UserAction, user: User, comment: str):
def add_log_entry(self, action, user, comment: str):
""" Wraps adding of UserActionLogEntry to log
Args:
@ -161,6 +169,7 @@ class BaseObject(BaseResource):
Returns:
"""
from user.models import UserActionLogEntry
user_action = UserActionLogEntry.objects.create(
user=user,
action=action,
@ -229,7 +238,7 @@ class RecordableObjectMixin(models.Model):
"""
# Refers to "verzeichnen"
recorded = models.OneToOneField(
UserActionLogEntry,
"user.UserActionLogEntry",
on_delete=models.SET_NULL,
null=True,
blank=True,
@ -240,7 +249,7 @@ class RecordableObjectMixin(models.Model):
class Meta:
abstract = True
def set_unrecorded(self, user: User):
def set_unrecorded(self, user):
""" Perform unrecording
Args:
@ -249,6 +258,7 @@ class RecordableObjectMixin(models.Model):
Returns:
"""
from user.models import UserActionLogEntry
if not self.recorded:
return None
action = UserActionLogEntry.get_unrecorded_action(user)
@ -256,13 +266,18 @@ class RecordableObjectMixin(models.Model):
self.save()
self.log.add(action)
shared_users = self.users.all().values_list("id", flat=True)
shared_users = self.shared_users.values_list("id", flat=True)
shared_teams = self.shared_teams.values_list("id", flat=True)
for user_id in shared_users:
celery_send_mail_shared_data_unrecorded.delay(self.identifier, self.title, user_id)
for team_id in shared_teams:
celery_send_mail_shared_data_unrecorded_team.delay(self.identifier, self.title, team_id)
return action
def set_recorded(self, user: User):
def set_recorded(self, user):
""" Perform recording
Args:
@ -271,6 +286,7 @@ class RecordableObjectMixin(models.Model):
Returns:
"""
from user.models import UserActionLogEntry
if self.recorded:
return None
action = UserActionLogEntry.get_recorded_action(user)
@ -278,13 +294,18 @@ class RecordableObjectMixin(models.Model):
self.save()
self.log.add(action)
shared_users = self.users.all().values_list("id", flat=True)
shared_users = self.shared_users.values_list("id", flat=True)
shared_teams = self.shared_teams.values_list("id", flat=True)
for user_id in shared_users:
celery_send_mail_shared_data_recorded.delay(self.identifier, self.title, user_id)
for team_id in shared_teams:
celery_send_mail_shared_data_recorded_team.delay(self.identifier, self.title, team_id)
return action
def unrecord(self, performing_user: User, request: HttpRequest = None):
def unrecord(self, performing_user, request: HttpRequest = None):
""" Unrecords a dataset
Args:
@ -318,7 +339,7 @@ class RecordableObjectMixin(models.Model):
class CheckableObjectMixin(models.Model):
# Checks - Refers to "Genehmigen" but optional
checked = models.OneToOneField(
UserActionLogEntry,
"user.UserActionLogEntry",
on_delete=models.SET_NULL,
null=True,
blank=True,
@ -346,7 +367,7 @@ class CheckableObjectMixin(models.Model):
self.save()
return None
def set_checked(self, user: User) -> UserActionLogEntry:
def set_checked(self, user):
""" Perform checking
Args:
@ -355,6 +376,7 @@ class CheckableObjectMixin(models.Model):
Returns:
"""
from user.models import UserActionLogEntry
if self.checked:
# Nothing to do
return
@ -363,17 +385,23 @@ class CheckableObjectMixin(models.Model):
self.save()
# Send mail
shared_users = self.users.all().values_list("id", flat=True)
shared_users = self.shared_users.values_list("id", flat=True)
for user_id in shared_users:
celery_send_mail_shared_data_checked.delay(self.identifier, self.title, user_id)
# Send mail
shared_teams = self.shared_teams.values_list("id", flat=True)
for team_id in shared_teams:
celery_send_mail_shared_data_checked_team.delay(self.identifier, self.title, team_id)
self.log.add(action)
return action
class ShareableObjectMixin(models.Model):
# Users having access on this object
users = models.ManyToManyField(User, help_text="Users having access (data shared with)")
users = models.ManyToManyField("user.User", help_text="Users having access (data shared with)")
teams = models.ManyToManyField("user.Team", help_text="Teams having access (data shared with)")
access_token = models.CharField(
max_length=255,
null=True,
@ -420,7 +448,7 @@ class ShareableObjectMixin(models.Model):
self.access_token = token
self.save()
def is_shared_with(self, user: User):
def is_shared_with(self, user):
""" Access check
Checks whether a given user has access to this object
@ -431,9 +459,36 @@ class ShareableObjectMixin(models.Model):
Returns:
"""
return self.users.filter(id=user.id)
directly_shared = self.users.filter(id=user.id).exists()
team_shared = self.teams.filter(
users__in=[user]
).exists()
is_shared = directly_shared or team_shared
return is_shared
def share_with_team(self, team):
""" Adds team to list of shared access teans
Args:
team (Team): The team to be added to the object
Returns:
def share_with(self, user: User):
"""
self.teams.add(team)
def share_with_team_list(self, team_list: list):
""" Sets the list of shared access teams
Args:
team_list (list): The teams to be added to the object
Returns:
"""
self.teams.set(team_list)
def share_with_user(self, user):
""" Adds user to list of shared access users
Args:
@ -445,7 +500,7 @@ class ShareableObjectMixin(models.Model):
if not self.is_shared_with(user):
self.users.add(user)
def share_with_list(self, user_list: list):
def share_with_user_list(self, user_list: list):
""" Sets the list of shared access users
Args:
@ -456,8 +511,8 @@ class ShareableObjectMixin(models.Model):
"""
self.users.set(user_list)
def update_sharing_user(self, form):
""" Adds a new user with shared access to the object
def _update_shared_teams(self, form):
""" Updates shared access on the object for teams
Args:
form (ShareModalForm): The form holding the data
@ -466,25 +521,65 @@ class ShareableObjectMixin(models.Model):
"""
form_data = form.cleaned_data
shared_teams = self.shared_teams
keep_accessing_users = form_data["users"]
new_accessing_users = list(form_data["user_select"].values_list("id", flat=True))
accessing_users = keep_accessing_users + new_accessing_users
users = User.objects.filter(
id__in=accessing_users
)
removed_users = self.users.all().exclude(
# Fetch selected teams and find out which user IDs are in removed teams -> mails need to be sent
accessing_teams = form_data["teams"]
removed_teams = shared_teams.exclude(
id__in=accessing_teams
).values_list("id", flat=True)
new_teams = accessing_teams.exclude(
id__in=shared_teams
).values_list("id", flat=True)
for team_id in new_teams:
celery_send_mail_shared_access_given_team.delay(self.identifier, self.title, team_id)
for team_id in removed_teams:
celery_send_mail_shared_access_removed_team.delay(self.identifier, self.title, team_id)
self.share_with_team_list(accessing_teams)
def _update_shared_users(self, form):
""" Updates shared access on the object for single users
Args:
form (ShareModalForm): The form holding the data
Returns:
"""
form_data = form.cleaned_data
shared_users = self.shared_users
# Fetch selected users
accessing_users = form_data["users"]
removed_users = shared_users.exclude(
id__in=accessing_users
).values("id")
).values_list("id", flat=True)
new_users = accessing_users.exclude(
id__in=shared_users
).values_list("id", flat=True)
# Send mails
for user in removed_users:
celery_send_mail_shared_access_removed.delay(self.identifier, self.title, user["id"])
for user in new_accessing_users:
celery_send_mail_shared_access_given.delay(self.identifier, self.title, user)
for user_id in removed_users:
celery_send_mail_shared_access_removed.delay(self.identifier, self.title, user_id)
for user_id in new_users:
celery_send_mail_shared_access_given.delay(self.identifier, self.title, user_id)
# Set new shared users
self.share_with_list(users)
self.share_with_user_list(accessing_users)
def update_shared_access(self, form):
""" Updates shared access on the object
Args:
form (ShareModalForm): The form holding the data
Returns:
"""
self._update_shared_teams(form)
self._update_shared_users(form)
@property
def shared_users(self) -> QuerySet:
@ -495,6 +590,15 @@ class ShareableObjectMixin(models.Model):
"""
return self.users.all()
@property
def shared_teams(self) -> QuerySet:
""" Shortcut for fetching the teams which have shared access on this object
Returns:
teams (QuerySet)
"""
return self.teams.all()
@abstractmethod
def get_share_url(self):
""" Returns the share url for the object

@ -242,15 +242,24 @@ Similar to bootstraps 'shadow-lg'
.select2-results__option--highlighted{
background-color: var(--rlp-red) !important;
}
/*
.select2-container--default .select2-results__group{
background-color: var(--rlp-gray-light);
}
.select2-container--default .select2-results__option .select2-results__option{
padding-left: 2em !important;
padding-left: 1em !important;
}
*/
.select2-results__options--nested{
padding-left: 1em !important;
}
.select2-container--default .select2-results > .select2-results__options{
max-height: 500px !important;
}
/*
.select2-container--default .select2-results__option .select2-results__option{
padding-left: 2em;
}
}
*/

@ -38,6 +38,20 @@ def celery_send_mail_shared_access_given(obj_identifier, obj_title=None, user_id
user.send_mail_shared_access_given(obj_identifier, obj_title)
@shared_task
def celery_send_mail_shared_access_removed_team(obj_identifier, obj_title=None, team_id=None):
from user.models import Team
team = Team.objects.get(id=team_id)
team.send_mail_shared_access_removed(obj_identifier, obj_title)
@shared_task
def celery_send_mail_shared_access_given_team(obj_identifier, obj_title=None, team_id=None):
from user.models import Team
team = Team.objects.get(id=team_id)
team.send_mail_shared_access_given_team(obj_identifier, obj_title)
@shared_task
def celery_send_mail_shared_data_recorded(obj_identifier, obj_title=None, user_id=None):
from user.models import User
@ -52,6 +66,20 @@ def celery_send_mail_shared_data_unrecorded(obj_identifier, obj_title=None, user
user.send_mail_shared_data_unrecorded(obj_identifier, obj_title)
@shared_task
def celery_send_mail_shared_data_recorded_team(obj_identifier, obj_title=None, team_id=None):
from user.models import Team
team = Team.objects.get(id=team_id)
team.send_mail_shared_data_recorded(obj_identifier, obj_title)
@shared_task
def celery_send_mail_shared_data_unrecorded_team(obj_identifier, obj_title=None, team_id=None):
from user.models import Team
team = Team.objects.get(id=team_id)
team.send_mail_shared_data_unrecorded(obj_identifier, obj_title)
@shared_task
def celery_send_mail_shared_data_deleted(obj_identifier, obj_title=None, user_id=None):
from user.models import User
@ -64,3 +92,17 @@ def celery_send_mail_shared_data_checked(obj_identifier, obj_title=None, user_id
from user.models import User
user = User.objects.get(id=user_id)
user.send_mail_shared_data_checked(obj_identifier, obj_title)
@shared_task
def celery_send_mail_shared_data_deleted_team(obj_identifier, obj_title=None, team_id=None):
from user.models import Team
team = Team.objects.get(id=team_id)
team.send_mail_shared_data_deleted(obj_identifier, obj_title)
@shared_task
def celery_send_mail_shared_data_checked_team(obj_identifier, obj_title=None, team_id=None):
from user.models import Team
team = Team.objects.get(id=team_id)
team.send_mail_shared_data_checked(obj_identifier, obj_title)

@ -0,0 +1,21 @@
{% load l10n fontawesome_5 %}
{% for code in codes %}
<div class="ml-4 tree-element">
<label class="tree-label" role="{% if not code.is_leaf%}button{% endif %}" for="input_{{code.pk|unlocalize}}" id="{{code.pk|unlocalize}}" data-toggle="collapse" data-target="#children_{{code.pk|unlocalize}}" aria-expanded="true" aria-controls="children_{{code.pk|unlocalize}}">
{% if code.is_leaf%}
<input class="tree-input" id="input_{{code.pk|unlocalize}}" name="{{ widget.name }}" type="checkbox" value="{{code.pk|unlocalize}}" {% if code.pk|unlocalize in widget.value %}checked{% endif %}/>
{% else %}
{% fa5_icon 'angle-right' %}
{% endif %}
{{code.long_name}}
</label>
{% if not code.is_leaf %}
<div id="children_{{code.pk|unlocalize}}" data-toggle="collapse" class="collapse tree-element-children">
{% with code.children as codes %}
{% include 'konova/widgets/checkbox-tree-select-content.html' %}
{% endwith %}
</div>
{% endif %}
</div>
{% endfor %}

@ -0,0 +1,68 @@
{% load i18n %}
<div class="ml-4 mb-4">
<input id="tree-search-input" class="form-control" type="text" placeholder="{% trans 'Search' %}"/>
</div>
<div id="tree-root">
{% include 'konova/widgets/checkbox-tree-select-content.html' %}
</div>
<script>
function toggleSelectedCssClass(element){
element = $(element);
var cssClass = "badge rlp-r"
var directParent = element.closest(".tree-element-children")
var root = element.parents(".tree-element-children")
var otherCheckedInputsOfParent = directParent.find('.tree-input:checked');
var otherCheckedInputsOfRoot = root.find('.tree-input:checked');
if(otherCheckedInputsOfParent.length == 0){
var parentLabel = directParent.siblings(".tree-label");
parentLabel.removeClass(cssClass)
if(otherCheckedInputsOfRoot.length == 0){
var rootLabel = root.siblings(".tree-label")
rootLabel.removeClass(cssClass)
}
}else{
var rootAndParentLabel = root.siblings(".tree-label");
rootAndParentLabel.addClass(cssClass);
}
}
function changeHandler(event){
toggleSelectedCssClass(this);
}
function searchInputHandler(event){
var elem = $(this);
var val = elem.val()
var allTreeElements = $(".tree-element")
var allTreeElementsContain = $(".tree-element").filter(function(){
var reg = new RegExp(val, "i");
return reg.test($(this).text());
}
);
if(val.length > 0){
allTreeElements.hide()
allTreeElementsContain.show()
}else{
allTreeElements.show()
}
}
// Add event listener on search input
$("#tree-search-input").keyup(searchInputHandler)
// Add event listener on changed checkboxes
$(".tree-input").change(changeHandler);
// initialize all pre-checked checkboxes (e.g. on an edit form)
var preCheckedElements = $(".tree-input:checked");
preCheckedElements.each(function (index, element){
toggleSelectedCssClass(element);
})
</script>

@ -71,6 +71,7 @@ class AutocompleteTestCase(BaseTestCase):
"codes-registration-office-autocomplete",
"codes-conservation-office-autocomplete",
"share-user-autocomplete",
"share-team-autocomplete",
]
for test in tests:
self.client.login(username=self.superuser.username, password=self.superuser_pw)

@ -9,7 +9,7 @@ import datetime
from codelist.settings import CODELIST_CONSERVATION_OFFICE_ID
from ema.models import Ema
from user.models import User
from user.models import User, Team
from django.contrib.auth.models import Group
from django.contrib.gis.geos import MultiPolygon, Polygon
from django.core.exceptions import ObjectDoesNotExist
@ -65,6 +65,7 @@ class BaseTestCase(TestCase):
self.create_dummy_states()
self.create_dummy_action()
self.codes = self.create_dummy_codes()
self.team = self.create_dummy_team()
# Set the default group as only group for the user
default_group = self.groups.get(name=DEFAULT_GROUP)
@ -251,6 +252,24 @@ class BaseTestCase(TestCase):
])
return codes
def create_dummy_team(self):
""" Creates a dummy team
Returns:
"""
if self.superuser is None:
self.create_users()
team = Team.objects.get_or_create(
name="Testteam",
description="Testdescription",
admin=self.superuser,
)[0]
team.users.add(self.superuser)
return team
@staticmethod
def create_dummy_geometry() -> MultiPolygon:
""" Creates some geometry

@ -20,7 +20,7 @@ from django.urls import path, include
from konova.autocompletes import EcoAccountAutocomplete, \
InterventionAutocomplete, CompensationActionCodeAutocomplete, BiotopeCodeAutocomplete, LawCodeAutocomplete, \
RegistrationOfficeCodeAutocomplete, ConservationOfficeCodeAutocomplete, ProcessTypeCodeAutocomplete, \
ShareUserAutocomplete, BiotopeExtraCodeAutocomplete, CompensationActionDetailCodeAutocomplete
ShareUserAutocomplete, BiotopeExtraCodeAutocomplete, CompensationActionDetailCodeAutocomplete, ShareTeamAutocomplete
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
@ -52,6 +52,7 @@ urlpatterns = [
path("atcmplt/codes/reg-off", RegistrationOfficeCodeAutocomplete.as_view(), name="codes-registration-office-autocomplete"),
path("atcmplt/codes/cons-off", ConservationOfficeCodeAutocomplete.as_view(), name="codes-conservation-office-autocomplete"),
path("atcmplt/share/u", ShareUserAutocomplete.as_view(), name="share-user-autocomplete"),
path("atcmplt/share/t", ShareTeamAutocomplete.as_view(), name="share-team-autocomplete"),
]
if DEBUG:

@ -92,6 +92,144 @@ class Mailer:
msg
)
def send_mail_shared_access_given_team(self, obj_identifier, obj_title, team):
""" Send a mail if a team just got access to the object
Args:
obj_identifier (str): The object identifier
Returns:
"""
context = {
"team": team,
"obj_identifier": obj_identifier,
"obj_title": obj_title,
"EMAIL_REPLY_TO": EMAIL_REPLY_TO,
}
msg = render_to_string("email/sharing/shared_access_given_team.html", context)
user_mail_address = team.users.values_list("email", flat=True)
self.send(
user_mail_address,
_("{} - Shared access given").format(obj_identifier),
msg
)
def send_mail_shared_access_removed_team(self, obj_identifier, obj_title, team):
""" Send a mail if a team just lost access to the object
Args:
obj_identifier (str): The object identifier
Returns:
"""
context = {
"team": team,
"obj_identifier": obj_identifier,
"obj_title": obj_title,
"EMAIL_REPLY_TO": EMAIL_REPLY_TO,
}
msg = render_to_string("email/sharing/shared_access_removed_team.html", context)
user_mail_address = team.users.values_list("email", flat=True)
self.send(
user_mail_address,
_("{} - Shared access removed").format(obj_identifier),
msg
)
def send_mail_shared_data_unrecorded_team(self, obj_identifier, obj_title, team):
""" Send a mail if data has just been unrecorded
Args:
obj_identifier (str): The object identifier
Returns:
"""
context = {
"team": team,
"obj_identifier": obj_identifier,
"obj_title": obj_title,
"EMAIL_REPLY_TO": EMAIL_REPLY_TO,
}
msg = render_to_string("email/recording/shared_data_unrecorded_team.html", context)
user_mail_address = team.users.values_list("email", flat=True)
self.send(
user_mail_address,
_("{} - Shared data unrecorded").format(obj_identifier),
msg
)
def send_mail_shared_data_recorded_team(self, obj_identifier, obj_title, team):
""" Send a mail if data has just been recorded
Args:
obj_identifier (str): The object identifier
Returns:
"""
context = {
"team": team,
"obj_identifier": obj_identifier,
"obj_title": obj_title,
"EMAIL_REPLY_TO": EMAIL_REPLY_TO,
}
msg = render_to_string("email/recording/shared_data_recorded_team.html", context)
user_mail_address = team.users.values_list("email", flat=True)
self.send(
user_mail_address,
_("{} - Shared data recorded").format(obj_identifier),
msg
)
def send_mail_shared_data_checked_team(self, obj_identifier, obj_title, team):
""" Send a mail if data has just been checked
Args:
obj_identifier (str): The object identifier
Returns:
"""
context = {
"team": team,
"obj_identifier": obj_identifier,
"obj_title": obj_title,
"EMAIL_REPLY_TO": EMAIL_REPLY_TO,
}
msg = render_to_string("email/checking/shared_data_checked_team.html", context)
user_mail_address = team.users.values_list("email", flat=True)
self.send(
user_mail_address,
_("{} - Shared data checked").format(obj_identifier),
msg
)
def send_mail_shared_data_deleted_team(self, obj_identifier, obj_title, team):
""" Send a mail if data has just been deleted
Args:
obj_identifier (str): The object identifier
Returns:
"""
context = {
"team": team,
"obj_identifier": obj_identifier,
"obj_title": obj_title,
"EMAIL_REPLY_TO": EMAIL_REPLY_TO,
}
msg = render_to_string("email/deleting/shared_data_deleted_team.html", context)
user_mail_address = team.users.values_list("email", flat=True)
self.send(
user_mail_address,
_("{} - Shared data deleted").format(obj_identifier),
msg
)
def send_mail_shared_data_recorded(self, obj_identifier, obj_title, user):
""" Send a mail if the user's shared data has just been unrecorded

@ -7,17 +7,19 @@ Created on: 02.08.21
"""
from django.utils.translation import gettext_lazy as _
UNGROUPED = _("Ungrouped")
FORM_INVALID = _("There was an error on this form.")
PARAMS_INVALID = _("Invalid parameters")
INTERVENTION_INVALID = _("There are errors in this intervention.")
IDENTIFIER_REPLACED = _("The identifier '{}' had to be changed to '{}' since another entry has been added in the meanwhile, which uses this identifier")
DATA_UNSHARED = _("This data is not shared with you")
DATA_UNSHARED_EXPLANATION = _("Remember: This data has not been shared with you, yet. This means you can only read but can not edit or perform any actions like running a check or recording.")
ENTRY_REMOVE_MISSING_PERMISSION = _("Only conservation or registration office users are allowed to remove entries.")
MISSING_GROUP_PERMISSION = _("You need to be part of another user group.")
CHECKED_RECORDED_RESET = _("Status of Checked and Recorded reseted")
# SHARE
DATA_UNSHARED = _("This data is not shared with you")
DATA_UNSHARED_EXPLANATION = _("Remember: This data has not been shared with you, yet. This means you can only read but can not edit or perform any actions like running a check or recording.")
# FILES
FILE_TYPE_UNSUPPORTED = _("Unsupported file type")
FILE_SIZE_TOO_LARGE = _("File too large")

@ -9,9 +9,12 @@ from django.contrib.auth import logout
from django.contrib.auth.decorators import login_required
from django.http import HttpRequest, FileResponse
from django.shortcuts import redirect, render, get_object_or_404
from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from codelist.models import KonovaCode
from codelist.settings import CODELIST_COMPENSATION_ACTION_ID
from compensation.models import Compensation, EcoAccount
from intervention.models import Intervention
from konova.contexts import BaseContext

Binary file not shown.

File diff suppressed because it is too large Load Diff

@ -0,0 +1,28 @@
{% load i18n %}
<div>
<h2>{% trans 'Shared data checked' %}</h2>
<h4>{{obj_identifier}}</h4>
<hr>
<article>
{% trans 'Hello team' %} {{team.name}},
<br>
{% trans 'the following dataset has just been checked' %}
<br>
<strong>{{obj_identifier}}</strong>
<br>
<strong>{{obj_title}}</strong>
<br>
{% trans 'This means, the responsible registration office just confirmed the correctness of this dataset.' %}
<br>
<br>
{% trans 'Best regards' %}
<br>
KSP
<br>
<br>
<br>
{% include 'email/signature.html' %}
</article>
</div>

@ -0,0 +1,28 @@
{% load i18n %}
<div>
<h2>{% trans 'Shared data deleted' %}</h2>
<h4>{{obj_identifier}}</h4>
<hr>
<article>
{% trans 'Hello team' %} {{team.name}},
<br>
{% trans 'the following dataset has just been deleted' %}
<br>
<strong>{{obj_identifier}}</strong>
<br>
<strong>"{{obj_title}}"</strong>
<br>
{% trans 'If this should not have been happened, please contact us. See the signature for details.' %}
<br>
<br>
{% trans 'Best regards' %}
<br>
KSP
<br>
<br>
<br>
{% include 'email/signature.html' %}
</article>
</div>

@ -0,0 +1,33 @@
{% load i18n %}
<div>
<h2>{% trans 'Shared data recorded' %}</h2>
<h4>{{obj_identifier}}</h4>
<hr>
<article>
{% trans 'Hello team' %} {{team.name}},
<br>
{% trans 'the following dataset has just been recorded' %}
<br>
<strong>{{obj_identifier}}</strong>
<br>
<strong>"{{obj_title}}"</strong>
<br>
{% trans 'This means the data is now publicly available, e.g. in LANIS' %}
<br>
<br>
{% trans 'Best regards' %}
<br>
KSP
<br>
<br>
<br>
<small>
{% trans 'Please note: Recorded intervention means the compensations are recorded as well.' %}
</small>
<br>
<br>
{% include 'email/signature.html' %}
</article>
</div>

@ -0,0 +1,33 @@
{% load i18n %}
<div>
<h2>{% trans 'Shared data unrecorded' %}</h2>
<h4>{{obj_identifier}}</h4>
<hr>
<article>
{% trans 'Hello team' %} {{team.name}},
<br>
{% trans 'the following dataset has just been unrecorded' %}
<br>
<strong>{{obj_identifier}}</strong>
<br>
<strong>"{{obj_title}}"</strong>
<br>
{% trans 'This means the data is no longer publicly available.' %}
<br>
<br>
{% trans 'Best regards' %}
<br>
KSP
<br>
<br>
<br>
<small>
{% trans 'Please note: Unrecorded intervention means the compensations are unrecorded as well.' %}
</small>
<br>
<br>
{% include 'email/signature.html' %}
</article>
</div>

@ -0,0 +1,34 @@
{% load i18n %}
<div>
<h2>{% trans 'Access shared' %}</h2>
<h4>{{obj_identifier}}</h4>
<hr>
<article>
{% trans 'Hello team' %} {{team.name}},
<br>
{% trans 'the following dataset has just been shared with your team' %}
<br>
<strong>{{obj_identifier}}</strong>
<br>
<strong>"{{obj_title}}"</strong>
<br>
{% trans 'This means you can now edit this dataset.' %}
{% trans 'The shared dataset appears now by default on your overview for this dataset type.' %}
<br>
<br>
{% trans 'Best regards' %}
<br>
KSP
<br>
<br>
<br>
<small>
{% trans 'Please note: Shared access on an intervention means you automatically have editing access to related compensations.' %}
</small>
<br>
<br>
{% include 'email/signature.html' %}
</article>
</div>

@ -0,0 +1,29 @@
{% load i18n %}
<div>
<h2>{% trans 'Shared access removed' %}</h2>
<h4>{{obj_identifier}}</h4>
<hr>
<article>
{% trans 'Hello team' %} {{team.name}},
<br>
{% trans 'your teams shared access, including editing, has been revoked for the dataset ' %}
<br>
<strong>{{obj_identifier}}</strong>
<br>
<strong>"{{obj_title}}"</strong>
<br>
{% trans 'However, you are still able to view the dataset content.' %}
{% trans 'Please use the provided search filter on the dataset`s overview pages to find them.' %}
<br>
<br>
{% trans 'Best regards' %}
<br>
KSP
<br>
<br>
<br>
{% include 'email/signature.html' %}
</article>
</div>

@ -1,6 +1,6 @@
from django.contrib import admin
from user.models import UserNotification, UserActionLogEntry, User
from user.models import UserNotification, UserActionLogEntry, User, Team
class UserNotificationAdmin(admin.ModelAdmin):
@ -64,7 +64,20 @@ class UserActionLogEntryAdmin(admin.ModelAdmin):
]
class TeamAdmin(admin.ModelAdmin):
list_display = [
"name",
"description",
"admin",
]
search_fields = [
"name",
"description",
]
admin.site.register(User, UserAdmin)
admin.site.register(Team, TeamAdmin)
# Outcommented for a cleaner admin backend on production
#admin.site.register(UserNotification, UserNotificationAdmin)

@ -5,17 +5,17 @@ Contact: michel.peltriaux@sgdnord.rlp.de
Created on: 08.07.21
"""
from dal import autocomplete
from django import forms
from django.db import IntegrityError
from django.db import IntegrityError, transaction
from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext_lazy as _
from api.models import APIUserToken
from intervention.inputs import GenerateInput
from user.models import User
from user.models import User, UserNotification, Team
from konova.forms import BaseForm, BaseModalForm
from user.models import UserNotification
from konova.forms import BaseForm, BaseModalForm, RemoveModalForm
class UserNotificationForm(BaseForm):
@ -160,3 +160,201 @@ class UserAPITokenForm(BaseForm):
user.api_token = new_token
user.save()
return new_token
class NewTeamModalForm(BaseModalForm):
name = forms.CharField(
label_suffix="",
label=_("Team name"),
max_length=500,
widget=forms.TextInput(
attrs={
"placeholder": _("Team name"),
"class": "form-control",
}
)
)
description = forms.CharField(
label_suffix="",
label=_("Description"),
widget=forms.Textarea(
attrs={
"rows": 5,
"class": "form-control"
}
)
)
members = forms.ModelMultipleChoiceField(
label=_("Manage team members"),
label_suffix="",
help_text=_("Multiple selection possible - You can only select users which are not already a team member. Enter the full username or e-mail."),
required=True,
queryset=User.objects.all(),
widget=autocomplete.ModelSelect2Multiple(
url="share-user-autocomplete",
attrs={
"data-placeholder": _("Click for selection"),
"data-minimum-input-length": 3,
},
),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.form_title = _("Create new team")
self.form_caption = _("You will become the administrator for this group by default. You do not need to add yourself to the list of members.")
self.action_url = reverse("user:team-new")
self.cancel_redirect = reverse("user:team-index")
def _is_name_valid(self):
name = self.cleaned_data.get("name", None)
teams_with_same_name = Team.objects.filter(
name=name
)
name_valid = not teams_with_same_name.exists()
if not name_valid:
self.add_error(
"name",
_("Name already taken. Try another.")
)
return name_valid
def is_valid(self):
super_valid = super().is_valid()
name_valid = self._is_name_valid()
return super_valid and name_valid
def save(self):
with transaction.atomic():
team = Team.objects.create(
name=self.cleaned_data.get("name", None),
description=self.cleaned_data.get("description", None),
admin=self.user,
)
members = self.cleaned_data.get("members", User.objects.none())
if self.user.id not in members:
members = members.union(
User.objects.filter(
id=self.user.id
)
)
team.users.set(members)
return team
class EditTeamModalForm(NewTeamModalForm):
admin = forms.ModelChoiceField(
label_suffix="",
label=_("Admin"),
help_text=_("Administrators manage team details and members"),
queryset=User.objects.none(),
empty_label=None,
)
def __is_admin_valid(self):
admin = self.cleaned_data.get("admin", None)
members = self.cleaned_data.get("members", None)
_is_valid = admin in members
if not _is_valid:
self.add_error(
"members",
_("Selected admin ({}) needs to be a member of this team.").format(admin.username)
)
return _is_valid
def _is_name_valid(self):
name = self.cleaned_data.get("name", None)
teams_with_same_name = Team.objects.filter(
name=name
).exclude(
id=self.instance.id
)
name_valid = not teams_with_same_name.exists()
if not name_valid:
self.add_error(
"name",
_("Name already taken. Try another.")
)
return name_valid
def is_valid(self):
super_valid = super().is_valid()
admin_valid = self.__is_admin_valid()
return super_valid and admin_valid
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.form_title = _("Edit team")
self.action_url = reverse("user:team-edit", args=(self.instance.id,))
self.cancel_redirect = reverse("user:team-index")
members = self.instance.users.all()
self.fields["admin"].queryset = members
form_data = {
"members": members,
"name": self.instance.name,
"description": self.instance.description,
"admin": self.instance.admin,
}
self.load_initial_data(form_data)
def save(self):
with transaction.atomic():
self.instance.name = self.cleaned_data.get("name", None)
self.instance.description = self.cleaned_data.get("description", None)
self.instance.admin = self.cleaned_data.get("admin", None)
self.instance.save()
self.instance.users.set(self.cleaned_data.get("members", []))
return self.instance
class RemoveTeamModalForm(RemoveModalForm):
pass
class TeamDataForm(BaseModalForm):
name = forms.CharField(
label_suffix="",
label=_("Team name"),
max_length=500,
required=False,
widget=forms.TextInput(
attrs={
"placeholder": _("Team name"),
"class": "form-control",
}
)
)
description = forms.CharField(
label_suffix="",
required=False,
label=_("Description"),
widget=forms.Textarea(
attrs={
"rows": 5,
"class": "form-control"
}
)
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.form_title = _("Team")
self.form_caption = ""
self.render_submit = False
form_data = {
"name": self.instance.name,
"description": self.instance.description,
}
self.load_initial_data(
form_data,
[
"name",
"description"
]
)

@ -0,0 +1,29 @@
# Generated by Django 3.1.3 on 2022-02-17 10:22
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
('user', '0002_user_api_token'),
]
operations = [
migrations.CreateModel(
name='Team',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(blank=True, max_length=500, null=True)),
('description', models.TextField(blank=True, null=True)),
('admin', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('users', models.ManyToManyField(blank=True, related_name='teams', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
]

@ -5,6 +5,7 @@ Contact: michel.peltriaux@sgdnord.rlp.de
Created on: 15.11.21
"""
from .user_action import *
from .user import *
from .notification import *
from .user_action import UserActionLogEntry, UserAction
from .user import User
from .notification import UserNotification, UserNotificationEnum
from .team import Team

@ -0,0 +1,95 @@
from django.db import models
from konova.models import UuidModel
from konova.utils.mailer import Mailer
class Team(UuidModel):
""" Groups users in self managed teams. Can be used for multi-sharing of data
"""
name = models.CharField(max_length=500, null=True, blank=True)
description = models.TextField(null=True, blank=True)
users = models.ManyToManyField("user.User", blank=True, related_name="teams")
admin = models.ForeignKey("user.User", blank=True, null=True, related_name="+", on_delete=models.SET_NULL)
def __str__(self):
return self.name
def send_mail_shared_access_given_team(self, obj_identifier, obj_title):
""" Sends a mail to the team members in case of given shared access
Args:
obj_identifier ():
obj_title ():
Returns:
"""
mailer = Mailer()
mailer.send_mail_shared_access_given_team(obj_identifier, obj_title, self)
def send_mail_shared_access_removed(self, obj_identifier, obj_title):
""" Sends a mail to the team members in case of removed shared access
Args:
obj_identifier ():
obj_title ():
Returns:
"""
mailer = Mailer()
mailer.send_mail_shared_access_removed_team(obj_identifier, obj_title, self)
def send_mail_shared_data_unrecorded(self, obj_identifier, obj_title):
""" Sends a mail to the team members in case of unrecorded data
Args:
obj_identifier ():
obj_title ():
Returns:
"""
mailer = Mailer()
mailer.send_mail_shared_data_unrecorded_team(obj_identifier, obj_title, self)
def send_mail_shared_data_recorded(self, obj_identifier, obj_title):
""" Sends a mail to the team members in case of unrecorded data
Args:
obj_identifier ():
obj_title ():
Returns:
"""
mailer = Mailer()
mailer.send_mail_shared_data_recorded_team(obj_identifier, obj_title, self)
def send_mail_shared_data_checked(self, obj_identifier, obj_title):
""" Sends a mail to the team members in case of checked data
Args:
obj_identifier ():
obj_title ():
Returns:
"""
mailer = Mailer()
mailer.send_mail_shared_data_checked_team(obj_identifier, obj_title, self)
def send_mail_shared_data_deleted(self, obj_identifier, obj_title):
""" Sends a mail to the team members in case of deleted data
Args:
obj_identifier ():
obj_title ():
Returns:
"""
mailer = Mailer()
mailer.send_mail_shared_data_deleted_team(obj_identifier, obj_title, self)

@ -1,6 +1,6 @@
{% load fontawesome_5 i18n %}
<button class="btn btn-default btn-modal" data-form-url="{% url 'user:contact' user.id %}" title="{% trans 'Show contact data' %}">
{% fa5_icon 'id-card' %}
{% fa5_icon 'user' %}
<span>{{user.username}}</span>
</button>

@ -0,0 +1,6 @@
{% load fontawesome_5 i18n %}
<button class="btn btn-default btn-modal" data-form-url="{% url 'user:team-data' team.id %}" title="{% trans 'Show team data' %}">
{% fa5_icon 'users' %}
<span>{{team.name}}</span>
</button>

@ -62,6 +62,14 @@
</button>
</a>
</div>
<div class="row mb-2">
<a href="{% url 'user:team-index' %}" title="{% trans 'Manage teams' %}">
<button class="btn btn-default">
{% fa5_icon 'users' %}
<span>{% trans 'Teams' %}</span>
</button>
</a>
</div>
</div>
</div>
</div>

@ -0,0 +1,69 @@
{% extends 'base.html' %}
{% load i18n fontawesome_5 %}
{% block head %}
{% comment %}
dal documentation (django-autocomplete-light) states using form.media for adding needed scripts.
This does not work properly with modal forms, as the scripts are not loaded properly inside the modal.
Therefore the script linkages from form.media have been extracted and put inside dal/scripts.html to ensure
these scripts are loaded when needed.
{% endcomment %}
{% include 'dal/scripts.html' %}
{% endblock %}
{% block body %}
<div class="container">
<h4>{% trans 'Teams' %}</h4>
<div class="col-md">
<button class="btn rlp-r btn-modal" data-form-url="{% url 'user:team-new' %}" title="{% trans 'Add new team' %}">
{% fa5_icon 'plus' %}
{% trans 'New' %}
</button>
</div>
<div class="table-container">
<table class="table table-hover">
<thead>
<tr>
<th scope="col" class="align-middle">{% trans 'Name' %}</th>
<th scope="col" class="align-middle w-20">{% trans 'Description' %}</th>
<th scope="col" class="align-middle">{% trans 'Members' %}</th>
<th scope="col" class="align-middle">{% trans 'Action' %}</th>
</tr>
</thead>
<tbody>
{% for team in teams %}
<tr>
<td>{{team.name}}</td>
<td>
<div class="scroll-150">
{{team.description}}
</div>
</td>
<td>
{% for member in team.users.all %}
<span class="badge badge-pill rlp-r">{{member.username}}</span>
{% endfor %}
</td>
<td>
{% if team.admin == user %}
<button class="btn rlp-r btn-modal" data-form-url="{% url 'user:team-edit' team.id %}" title="{% trans 'Edit team' %}">
{% fa5_icon 'edit' %}
</button>
<button class="btn rlp-r btn-modal" data-form-url="{% url 'user:team-remove' team.id %}" title="{% trans 'Remove team' %}">
{% fa5_icon 'trash' %}
</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% with 'btn-modal' as btn_class %}
{% include 'modal/modal_form_script.html' %}
{% endwith %}
{% endblock %}

@ -15,5 +15,10 @@ urlpatterns = [
path("notifications/", notifications_view, name="notifications"),
path("token/api", api_token_view, name="api-token"),
path("contact/<id>", contact_view, name="contact"),
path("team/", index_team_view, name="team-index"),
path("team/<id>", data_team_view, name="team-data"),
path("team/new", new_team_view, name="team-new"),
path("team/<id>/edit", edit_team_view, name="team-edit"),
path("team/<id>/remove", remove_team_view, name="team-remove"),
]

@ -1,17 +1,19 @@
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.urls import reverse
from konova.sub_settings.context_settings import TAB_TITLE_IDENTIFIER
from konova.utils.mailer import Mailer
from konova.utils.message_templates import FORM_INVALID
from user.models import User
from django.http import HttpRequest
from user.models import User, Team
from django.http import HttpRequest, Http404
from django.shortcuts import render, redirect, get_object_or_404
from django.utils.translation import gettext_lazy as _
from konova.contexts import BaseContext
from konova.decorators import any_group_check, default_group_required
from user.forms import UserNotificationForm, UserContactForm, UserAPITokenForm
from user.forms import UserNotificationForm, UserContactForm, UserAPITokenForm, NewTeamModalForm, EditTeamModalForm, \
RemoveTeamModalForm, TeamDataForm
@login_required
@ -128,4 +130,77 @@ def contact_view(request: HttpRequest, id: str):
request,
template,
context
)
)
@login_required
def data_team_view(request: HttpRequest, id: str):
""" Renders team data
Args:
request (HttpRequest): The incoming request
id (str): The team's id
Returns:
"""
team = get_object_or_404(Team, id=id)
form = TeamDataForm(request.POST or None, instance=team, request=request)
template = "modal/modal_form.html"
context = {
"form": form,
}
context = BaseContext(request, context).context
return render(
request,
template,
context
)
@login_required
def index_team_view(request: HttpRequest):
template = "user/team/index.html"
user = request.user
context = {
"teams": user.teams.all(),
"tab_title": _("Teams"),
}
context = BaseContext(request, context).context
return render(request, template, context)
@login_required
def new_team_view(request: HttpRequest):
form = NewTeamModalForm(request.POST or None, request=request)
return form.process_request(
request,
_("New team added"),
redirect_url=reverse("user:team-index")
)
@login_required
def edit_team_view(request: HttpRequest, id: str):
team = get_object_or_404(Team, id=id)
if request.user != team.admin:
raise Http404()
form = EditTeamModalForm(request.POST or None, instance=team, request=request)
return form.process_request(
request,
_("Team edited"),
redirect_url=reverse("user:team-index")
)
@login_required
def remove_team_view(request: HttpRequest, id: str):
team = get_object_or_404(Team, id=id)
if request.user != team.admin:
raise Http404()
form = RemoveTeamModalForm(request.POST or None, instance=team, request=request)
return form.process_request(
request,
_("Team removed"),
redirect_url=reverse("user:team-index")
)

Loading…
Cancel
Save