master #123

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

@ -109,8 +109,8 @@ class APIV1CreateTestCase(BaseAPIV1TestCase):
Returns: Returns:
""" """
self.intervention.share_with(self.superuser) self.intervention.share_with_user(self.superuser)
self.eco_account.share_with(self.superuser) self.eco_account.share_with_user(self.superuser)
url = reverse("api:v1:deduction") url = reverse("api:v1:deduction")
json_file_path = "api/tests/v1/create/deduction_create_post_body.json" 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 = 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),)) url = reverse("api:v1:intervention", args=(str(test_intervention.id),))
self._test_delete_object(test_intervention, url) self._test_delete_object(test_intervention, url)
@ -68,7 +68,7 @@ class APIV1DeleteTestCase(BaseAPIV1TestCase):
""" """
test_comp = self.create_dummy_compensation() 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),)) url = reverse("api:v1:compensation", args=(str(test_comp.id),))
self._test_delete_object(test_comp, url) self._test_delete_object(test_comp, url)
@ -79,7 +79,7 @@ class APIV1DeleteTestCase(BaseAPIV1TestCase):
""" """
test_acc = self.create_dummy_eco_account() 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),)) url = reverse("api:v1:ecoaccount", args=(str(test_acc.id),))
self._test_delete_object(test_acc, url) self._test_delete_object(test_acc, url)
@ -90,7 +90,7 @@ class APIV1DeleteTestCase(BaseAPIV1TestCase):
""" """
test_ema = self.create_dummy_ema() 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),)) url = reverse("api:v1:ema", args=(str(test_ema.id),))
self._test_delete_object(test_ema, url) self._test_delete_object(test_ema, url)
@ -101,7 +101,7 @@ class APIV1DeleteTestCase(BaseAPIV1TestCase):
""" """
test_deduction = self.create_dummy_deduction() 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),)) url = reverse("api:v1:deduction", args=(str(test_deduction.id),))
response = self._run_delete_request(url) response = self._run_delete_request(url)

@ -36,7 +36,12 @@ class APIV1GetTestCase(BaseAPIV1TestCase):
""" """
response = self._run_get_request(url) response = self._run_get_request(url)
content = json.loads(response.content) 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) self.assertEqual(response.status_code, 200, msg=response.content)
return geojson return geojson
@ -59,7 +64,7 @@ class APIV1GetTestCase(BaseAPIV1TestCase):
Returns: Returns:
""" """
self.intervention.share_with(self.superuser) self.intervention.share_with_user(self.superuser)
url = reverse("api:v1:intervention", args=(str(self.intervention.id),)) url = reverse("api:v1:intervention", args=(str(self.intervention.id),))
geojson = self._test_get_object(self.intervention, url) geojson = self._test_get_object(self.intervention, url)
self._assert_geojson_format(geojson) self._assert_geojson_format(geojson)
@ -80,13 +85,33 @@ class APIV1GetTestCase(BaseAPIV1TestCase):
except KeyError as e: except KeyError as e:
self.fail(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): def test_get_compensation(self):
""" Tests api GET """ Tests api GET
Returns: Returns:
""" """
self.intervention.share_with(self.superuser) self.intervention.share_with_user(self.superuser)
self.compensation.intervention = self.intervention self.compensation.intervention = self.intervention
self.compensation.save() self.compensation.save()
@ -114,7 +139,7 @@ class APIV1GetTestCase(BaseAPIV1TestCase):
Returns: 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),)) url = reverse("api:v1:ecoaccount", args=(str(self.eco_account.id),))
geojson = self._test_get_object(self.eco_account, url) geojson = self._test_get_object(self.eco_account, url)
@ -143,7 +168,7 @@ class APIV1GetTestCase(BaseAPIV1TestCase):
Returns: Returns:
""" """
self.ema.share_with(self.superuser) self.ema.share_with_user(self.superuser)
url = reverse("api:v1:ema", args=(str(self.ema.id),)) url = reverse("api:v1:ema", args=(str(self.ema.id),))
geojson = self._test_get_object(self.ema, url) geojson = self._test_get_object(self.ema, url)
@ -167,7 +192,7 @@ class APIV1GetTestCase(BaseAPIV1TestCase):
Returns: 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),)) url = reverse("api:v1:deduction", args=(str(self.deduction.id),))
_json = self._test_get_object(self.deduction, url) _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: Returns:
""" """
self.intervention.share_with(self.superuser) self.intervention.share_with_user(self.superuser)
modified_on = self.intervention.modified modified_on = self.intervention.modified
url = reverse("api:v1:intervention", args=(str(self.intervention.id),)) url = reverse("api:v1:intervention", args=(str(self.intervention.id),))
json_file_path = "api/tests/v1/update/intervention_update_put_body.json" 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.intervention = self.intervention
self.compensation.save() self.compensation.save()
self.intervention.share_with(self.superuser) self.intervention.share_with_user(self.superuser)
modified_on = self.compensation.modified modified_on = self.compensation.modified
url = reverse("api:v1:compensation", args=(str(self.compensation.id),)) url = reverse("api:v1:compensation", args=(str(self.compensation.id),))
@ -108,7 +108,7 @@ class APIV1UpdateTestCase(BaseAPIV1TestCase):
Returns: Returns:
""" """
self.eco_account.share_with(self.superuser) self.eco_account.share_with_user(self.superuser)
modified_on = self.eco_account.modified modified_on = self.eco_account.modified
url = reverse("api:v1:ecoaccount", args=(str(self.eco_account.id),)) url = reverse("api:v1:ecoaccount", args=(str(self.eco_account.id),))
@ -139,7 +139,7 @@ class APIV1UpdateTestCase(BaseAPIV1TestCase):
Returns: Returns:
""" """
self.ema.share_with(self.superuser) self.ema.share_with_user(self.superuser)
modified_on = self.ema.modified modified_on = self.ema.modified
url = reverse("api:v1:ema", args=(str(self.ema.id),)) url = reverse("api:v1:ema", args=(str(self.ema.id),))
@ -168,8 +168,8 @@ class APIV1UpdateTestCase(BaseAPIV1TestCase):
Returns: Returns:
""" """
self.deduction.intervention.share_with(self.superuser) self.deduction.intervention.share_with_user(self.superuser)
self.deduction.account.share_with(self.superuser) self.deduction.account.share_with_user(self.superuser)
url = reverse("api:v1:deduction", args=(str(self.deduction.id),)) url = reverse("api:v1:deduction", args=(str(self.deduction.id),))
json_file_path = "api/tests/v1/update/deduction_update_put_body.json" 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["intervention"], str(self.deduction.intervention.id))
self.assertEqual(put_body["eco_account"], str(self.deduction.account.id)) self.assertEqual(put_body["eco_account"], str(self.deduction.account.id))
self.assertEqual(put_body["surface"], self.deduction.surface) 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 import geos
from django.contrib.gis.geos import GEOSGeometry from django.contrib.gis.geos import GEOSGeometry
from django.core.paginator import Paginator
from konova.utils.message_templates import DATA_UNSHARED from konova.utils.message_templates import DATA_UNSHARED
@ -19,6 +20,10 @@ class AbstractModelAPISerializer:
lookup = None lookup = None
properties_data = None properties_data = None
rpp = None
page_number = None
paginator = None
class Meta: class Meta:
abstract = True abstract = True
@ -80,9 +85,12 @@ class AbstractModelAPISerializer:
Returns: Returns:
serialized_data (dict) 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 = {} serialized_data = {}
for entry in entries: for entry in requested_entries.object_list:
serialized_data[str(entry.id)] = self._model_to_geo_json(entry) serialized_data[str(entry.id)] = self._model_to_geo_json(entry)
return serialized_data return serialized_data

@ -367,7 +367,9 @@ class AbstractCompensationAPISerializerV1Mixin:
""" """
actions = [] actions = []
for entry in actions_data: 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 = [ action_details = [
self._konova_code_from_json(e, CODELIST_COMPENSATION_ACTION_DETAIL_ID) for e in entry["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 # 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 # entries, we will use to set the new actions
action_entry = obj.actions.filter( action_entry = obj.actions.filter(
action_type__atom_id=action, action_type__in=action_types,
amount=amount, amount=amount,
unit=unit, unit=unit,
comment=comment, comment=comment,
@ -396,13 +398,13 @@ class AbstractCompensationAPISerializerV1Mixin:
else: else:
# Create and add id to list # Create and add id to list
action_entry = CompensationAction.objects.create( action_entry = CompensationAction.objects.create(
action_type=self._konova_code_from_json(action, CODELIST_COMPENSATION_ACTION_ID),
amount=amount, amount=amount,
unit=unit, unit=unit,
comment=comment, comment=comment,
) )
actions.append(action_entry.id) actions.append(action_entry.id)
action_entry.action_type.set(action_types)
action_entry.action_type_details.set(action_details) action_entry.action_type_details.set(action_details)
obj.actions.set(actions) obj.actions.set(actions)
return obj return obj
@ -438,7 +440,9 @@ class AbstractCompensationAPISerializerV1Mixin:
""" """
return [ 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": [ "action_details": [
self._konova_code_to_json(detail) for detail in entry.action_type_details.all() 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 """ Holds general serialization functions for API v1
""" """
serializer = None
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.lookup = { self.lookup = {
@ -45,11 +44,17 @@ class AbstractAPIViewV1(AbstractAPIView):
response (JsonResponse) response (JsonResponse)
""" """
try: 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) self.serializer.prepare_lookup(id, self.user)
data = self.serializer.fetch_and_serialize() data = self.serializer.fetch_and_serialize()
except Exception as e: except Exception as e:
return self.return_error_response(e, 500) return self._return_error_response(e, 500)
return JsonResponse(data) return self._return_response(request, data)
def post(self, request: HttpRequest): def post(self, request: HttpRequest):
""" Handles the POST request """ Handles the POST request
@ -67,7 +72,7 @@ class AbstractAPIViewV1(AbstractAPIView):
body = json.loads(body) body = json.loads(body)
created_id = self.serializer.create_model_from_json(body, self.user) created_id = self.serializer.create_model_from_json(body, self.user)
except Exception as e: except Exception as e:
return self.return_error_response(e, 500) return self._return_error_response(e, 500)
return JsonResponse({"id": created_id}) return JsonResponse({"id": created_id})
def put(self, request: HttpRequest, id=None): def put(self, request: HttpRequest, id=None):
@ -87,7 +92,7 @@ class AbstractAPIViewV1(AbstractAPIView):
body = json.loads(body) body = json.loads(body)
updated_id = self.serializer.update_model_from_json(id, body, self.user) updated_id = self.serializer.update_model_from_json(id, body, self.user)
except Exception as e: except Exception as e:
return self.return_error_response(e, 500) return self._return_error_response(e, 500)
return JsonResponse({"id": updated_id}) return JsonResponse({"id": updated_id})
def delete(self, request: HttpRequest, id=None): def delete(self, request: HttpRequest, id=None):
@ -104,7 +109,7 @@ class AbstractAPIViewV1(AbstractAPIView):
try: try:
success = self.serializer.delete_entry(id, self.user) success = self.serializer.delete_entry(id, self.user)
except Exception as e: except Exception as e:
return self.return_error_response(e, 500) return self._return_error_response(e, 500)
return JsonResponse( return JsonResponse(
{ {
"success": success, "success": success,

@ -19,7 +19,7 @@ from ema.models import Ema
from intervention.models import Intervention from intervention.models import Intervention
from konova.utils.message_templates import DATA_UNSHARED from konova.utils.message_templates import DATA_UNSHARED
from konova.utils.user_checks import is_default_group_only from konova.utils.user_checks import is_default_group_only
from user.models import User from user.models import User, Team
class AbstractAPIView(View): class AbstractAPIView(View):
@ -31,10 +31,22 @@ class AbstractAPIView(View):
""" """
user = None user = None
serializer = None
rpp = 5 # Results per page default
page_number = 1 # Page number default
class Meta: class Meta:
abstract = True 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 @csrf_exempt
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
try: try:
@ -42,13 +54,14 @@ class AbstractAPIView(View):
ksp_token = request.headers.get(KSP_TOKEN_HEADER_IDENTIFIER, None) ksp_token = request.headers.get(KSP_TOKEN_HEADER_IDENTIFIER, None)
ksp_user = request.headers.get(KSP_USER_HEADER_IDENTIFIER, None) ksp_user = request.headers.get(KSP_USER_HEADER_IDENTIFIER, None)
self.user = APIUserToken.get_user_from_token(ksp_token, ksp_user) self.user = APIUserToken.get_user_from_token(ksp_token, ksp_user)
request.user = self.user
if not self.user.is_default_user(): if not self.user.is_default_user():
raise PermissionError("Default permissions required") raise PermissionError("Default permissions required")
except PermissionError as e: except PermissionError as e:
return self.return_error_response(e, 403) return self._return_error_response(e, 403)
return super().dispatch(request, *args, **kwargs) 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 """ Returns an error as JsonReponse
Args: Args:
@ -68,6 +81,31 @@ class AbstractAPIView(View):
status=status_code 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): class InterventionCheckAPIView(AbstractAPIView):
@ -82,14 +120,14 @@ class InterventionCheckAPIView(AbstractAPIView):
response (JsonResponse) response (JsonResponse)
""" """
if not self.user.is_zb_user(): 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: try:
obj = Intervention.objects.get( obj = Intervention.objects.get(
id=id, id=id,
users__in=[self.user] users__in=[self.user]
) )
except Exception as e: 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) all_valid, check_details = self.run_quality_checks(obj)
@ -160,13 +198,21 @@ class AbstractModelShareAPIView(AbstractAPIView):
""" """
try: try:
users = self._get_shared_users_of_object(id) users = self._get_shared_users_of_object(id)
teams = self._get_shared_teams_of_object(id)
except Exception as e: except Exception as e:
return self.return_error_response(e) return self._return_error_response(e)
data = { data = {
"users": [ "users": [
user.username for user in users user.username for user in users
] ],
"teams": [
{
"id": team.id,
"name": team.name,
}
for team in teams
],
} }
return JsonResponse(data) return JsonResponse(data)
@ -185,7 +231,7 @@ class AbstractModelShareAPIView(AbstractAPIView):
try: try:
success = self._process_put_body(request.body, id) success = self._process_put_body(request.body, id)
except Exception as e: except Exception as e:
return self.return_error_response(e) return self._return_error_response(e)
data = { data = {
"success": success, "success": success,
} }
@ -220,6 +266,22 @@ class AbstractModelShareAPIView(AbstractAPIView):
users = obj.shared_users users = obj.shared_users
return 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): def _process_put_body(self, body: bytes, id: str):
""" Reads the body data, performs validity checks and sets the new users """ 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) obj = self.model.objects.get(id=id)
self._check_user_has_shared_access(obj) self._check_user_has_shared_access(obj)
new_users = json.loads(body.decode("utf-8")) content = json.loads(body.decode("utf-8"))
new_users = new_users.get("users", []) new_users = content.get("users", [])
if len(new_users) == 0: if len(new_users) == 0:
raise ValueError("Shared user list must not be empty!") raise ValueError("Shared user list must not be empty!")
new_teams = content.get("teams", [])
# Eliminate duplicates # Eliminate duplicates
new_users = list(dict.fromkeys(new_users)) new_users = list(dict.fromkeys(new_users))
new_teams = list(dict.fromkeys(new_teams))
# Make sure each of these names exist as a user # Make sure each of these names exist as a user
new_users_objs = [] new_users_objs = []
for user in new_users: for user in new_users:
new_users_objs.append(User.objects.get(username=user)) 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): 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! # 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( new_users_to_be_added = User.objects.filter(
@ -254,7 +323,16 @@ class AbstractModelShareAPIView(AbstractAPIView):
id__in=obj.shared_users id__in=obj.shared_users
) )
new_users_objs = obj.shared_users.union(new_users_to_be_added) 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 return True

@ -65,6 +65,26 @@ class KonovaCode(models.Model):
ret_val += ", " + self.parent.long_name ret_val += ", " + self.parent.long_name
return ret_val 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): 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_INTERVENTION_HANDLER_ID = 903 # CLMassnahmeträger
CODELIST_CONSERVATION_OFFICE_ID = 907 # CLNaturschutzbehörden CODELIST_CONSERVATION_OFFICE_ID = 907 # CLNaturschutzbehörden
CODELIST_REGISTRATION_OFFICE_ID = 1053 # CLZulassungsbehö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_BIOTOPES_EXTRA_CODES_ID = 975 # CLZusatzbezeichnung
CODELIST_LAW_ID = 1048 # CLVerfahrensrecht CODELIST_LAW_ID = 1048 # CLVerfahrensrecht
CODELIST_PROCESS_TYPE_ID = 44382 # CLVerfahrenstyp CODELIST_PROCESS_TYPE_ID = 44382 # CLVerfahrenstyp

@ -59,8 +59,9 @@ class CheckboxCompensationTableFilter(CheckboxTableFilter):
""" """
if not value: if not value:
return queryset.filter( 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: else:
return queryset 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: def filter_only_show_unrecorded(self, queryset, name, value) -> QuerySet:
""" Filters queryset depending on value of 'show_recorded' setting """ Filters queryset depending on value of 'show_recorded' setting

@ -400,7 +400,7 @@ class NewEcoAccountForm(AbstractCompensationForm, CompensationResponsibleFormMix
comment=comment, comment=comment,
legal=legal legal=legal
) )
acc.share_with(user) acc.share_with_user(user)
# Add the log entry to the main objects log list # Add the log entry to the main objects log list
acc.log.add(action) 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, \ from codelist.settings import CODELIST_BIOTOPES_ID, CODELIST_COMPENSATION_ACTION_ID, CODELIST_BIOTOPES_EXTRA_CODES_ID, \
CODELIST_COMPENSATION_ACTION_DETAIL_ID CODELIST_COMPENSATION_ACTION_DETAIL_ID
from compensation.models import CompensationDocument, EcoAccountDocument from compensation.models import CompensationDocument, EcoAccountDocument
from intervention.inputs import CompensationActionTreeCheckboxSelectMultiple
from konova.contexts import BaseContext from konova.contexts import BaseContext
from konova.forms import BaseModalForm, NewDocumentModalForm, RemoveModalForm from konova.forms import BaseModalForm, NewDocumentModalForm, RemoveModalForm
from konova.models import DeadlineType 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 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 from compensation.models import UnitChoices
action_type = forms.ModelChoiceField( action_type = forms.MultipleChoiceField(
label=_("Action Type"), label=_("Action Type"),
label_suffix="", label_suffix="",
required=True, required=True,
help_text=_("Select the action type"), 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."),
queryset=KonovaCode.objects.filter( choices=[],
is_archived=False, widget=CompensationActionTreeCheckboxSelectMultiple(),
is_leaf=True,
code_lists__in=[CODELIST_COMPENSATION_ACTION_ID],
),
widget=autocomplete.ModelSelect2(
url="codes-compensation-action-autocomplete",
attrs={
"data-placeholder": _("Action"),
}
),
) )
action_type_details = forms.ModelMultipleChoiceField( action_type_details = forms.ModelMultipleChoiceField(
label=_("Action Type detail"), label=_("Action Type detail"),
@ -482,6 +474,16 @@ class NewActionModalForm(BaseModalForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.form_title = _("New action") self.form_title = _("New action")
self.form_caption = _("Insert data for the 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): def save(self):
action = self.instance.add_action(self) action = self.instance.add_action(self)
@ -496,7 +498,7 @@ class EditCompensationActionModalForm(NewActionModalForm):
self.action = kwargs.pop("action", None) self.action = kwargs.pop("action", None)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
form_data = { 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(), "action_type_details": self.action.action_type_details.all(),
"amount": self.action.amount, "amount": self.action.amount,
"unit": self.action.unit, "unit": self.action.unit,
@ -506,7 +508,7 @@ class EditCompensationActionModalForm(NewActionModalForm):
def save(self): def save(self):
action = self.action 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.action_type_details.set(self.cleaned_data.get("action_type_details", []))
action.amount = self.cleaned_data.get("amount", None) action.amount = self.cleaned_data.get("amount", None)
action.unit = self.cleaned_data.get("unit", None) action.unit = self.cleaned_data.get("unit", None)

@ -8,17 +8,6 @@ Created on: 14.10.21
from django.db import models 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): class CompensationStateManager(models.Manager):
""" Holds default db fetch setting for this model type """ 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.models import KonovaCode
from codelist.settings import CODELIST_COMPENSATION_ACTION_ID, CODELIST_COMPENSATION_ACTION_DETAIL_ID 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.models import BaseResource
from konova.utils.message_templates import COMPENSATION_ACTION_REMOVED
class UnitChoices(models.TextChoices): class UnitChoices(models.TextChoices):
@ -31,10 +29,8 @@ class CompensationAction(BaseResource):
""" """
Compensations include actions like planting trees, refreshing rivers and so on. Compensations include actions like planting trees, refreshing rivers and so on.
""" """
action_type = models.ForeignKey( action_type = models.ManyToManyField(
KonovaCode, KonovaCode,
on_delete=models.SET_NULL,
null=True,
blank=True, blank=True,
limit_choices_to={ limit_choices_to={
"code_lists__in": [CODELIST_COMPENSATION_ACTION_ID], "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) unit = models.CharField(max_length=100, null=True, blank=True, choices=UnitChoices.choices)
comment = models.TextField(blank=True, null=True, help_text="Additional comment") comment = models.TextField(blank=True, null=True, help_text="Additional comment")
objects = CompensationActionManager()
def __str__(self): def __str__(self):
return f"{self.action_type} | {self.amount} {self.unit}" return f"{self.action_type.all()} | {self.amount} {self.unit}"
@property @property
def unit_humanize(self): def unit_humanize(self):

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

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

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

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

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

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

@ -10,7 +10,7 @@
</div> </div>
<div class="col-sm-6"> <div class="col-sm-6">
<div class="d-flex justify-content-end"> <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' %}"> <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 'plus' %}
{% fa5_icon 'tree' %} {% fa5_icon 'tree' %}
@ -61,7 +61,7 @@
<td class="align-middle">{{ deduction.surface|floatformat:2|intcomma }} m²</td> <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">{{ deduction.created.timestamp|default_if_none:""|naturalday}}</td>
<td class="align-middle float-right"> <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' %}"> <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' %} {% fa5_icon 'edit' %}
</button> </button>

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

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

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

@ -103,7 +103,7 @@ class CompensationViewTestCase(BaseViewTestCase):
client = Client() client = Client()
client.login(username=self.superuser.username, password=self.superuser_pw) client.login(username=self.superuser.username, password=self.superuser_pw)
self.superuser.groups.set([]) 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 # 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 # 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) client.login(username=self.superuser.username, password=self.superuser_pw)
self.superuser.groups.set([]) self.superuser.groups.set([])
# Sharing is inherited by base intervention for compensation. Therefore configure the interventions share state # 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 # 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 # 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) group = self.groups.get(name=DEFAULT_GROUP)
self.superuser.groups.set([group]) self.superuser.groups.set([group])
# Sharing is inherited by base intervention for compensation. Therefore configure the interventions share state # 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 = [ success_urls = [
self.index_url, self.index_url,
@ -221,7 +221,7 @@ class CompensationViewTestCase(BaseViewTestCase):
group = self.groups.get(name=DEFAULT_GROUP) group = self.groups.get(name=DEFAULT_GROUP)
self.superuser.groups.set([group]) self.superuser.groups.set([group])
# Sharing is inherited by base intervention for compensation. Therefore configure the interventions share state # 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 = [ success_urls = [
self.index_url, self.index_url,

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

@ -78,7 +78,7 @@ class EcoAccountViewTestCase(CompensationViewTestCase):
client = Client() client = Client()
client.login(username=self.superuser.username, password=self.superuser_pw) client.login(username=self.superuser.username, password=self.superuser_pw)
self.superuser.groups.set([]) 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 # 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 # to a user without access, since the important permissions are missing
@ -119,7 +119,7 @@ class EcoAccountViewTestCase(CompensationViewTestCase):
client = Client() client = Client()
client.login(username=self.superuser.username, password=self.superuser_pw) client.login(username=self.superuser.username, password=self.superuser_pw)
self.superuser.groups.set([]) 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 # 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 # 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) group = self.groups.get(name=DEFAULT_GROUP)
self.superuser.groups.set([group]) self.superuser.groups.set([group])
# Sharing is inherited by base intervention for compensation. Therefore configure the interventions share state # 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 = [ success_urls = [
self.index_url, self.index_url,
@ -200,7 +200,7 @@ class EcoAccountViewTestCase(CompensationViewTestCase):
client.login(username=self.superuser.username, password=self.superuser_pw) client.login(username=self.superuser.username, password=self.superuser_pw)
group = self.groups.get(name=DEFAULT_GROUP) group = self.groups.get(name=DEFAULT_GROUP)
self.superuser.groups.set([group]) self.superuser.groups.set([group])
self.eco_account.share_with_list([]) self.eco_account.share_with_user_list([])
success_urls = [ success_urls = [
self.index_url, self.index_url,

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

@ -64,7 +64,7 @@ class PaymentViewTestCase(BaseViewTestCase):
client = Client() client = Client()
client.login(username=self.superuser.username, password=self.superuser_pw) client.login(username=self.superuser.username, password=self.superuser_pw)
self.superuser.groups.set([]) 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 # 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 # 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) client.login(username=self.superuser.username, password=self.superuser_pw)
self.superuser.groups.set([]) self.superuser.groups.set([])
# Sharing is inherited by base intervention for compensation. Therefore configure the interventions share state # 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 # 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 # 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) group = self.groups.get(name=DEFAULT_GROUP)
self.superuser.groups.set([group]) self.superuser.groups.set([group])
# Sharing is inherited by base intervention for compensation. Therefore configure the interventions share state # 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 = [ success_urls = [
self.new_url, self.new_url,
@ -143,7 +143,7 @@ class PaymentViewTestCase(BaseViewTestCase):
group = self.groups.get(name=DEFAULT_GROUP) group = self.groups.get(name=DEFAULT_GROUP)
self.superuser.groups.set([group]) self.superuser.groups.set([group])
# Sharing is inherited by base intervention for compensation. Therefore configure the interventions share state # 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 = [ success_urls = [
] ]

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

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

@ -80,7 +80,7 @@ class NewEmaForm(AbstractCompensationForm, CompensationResponsibleFormMixin):
) )
# Add the creating user to the list of shared users # 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 # Add the log entry to the main objects log list
acc.log.add(action) 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 %} {% for action in obj.actions.all %}
<tr> <tr>
<td class=""> <td class="">
<span>{{ action.action_type }}</span> {% for type in action.action_type.all %}
{% if action.action_type_details.count > 0 %} <div> {{type.parent.parent.long_name}} {% fa5_icon 'angle-right' %} {{type.parent.long_name}} {% fa5_icon 'angle-right' %} {{type.long_name}} </div>
<br> <hr>
{% for detail in action.action_type_details.all %} {% endfor %}
<span class="badge badge-pill rlp-r" title="{{detail}}">{{detail.long_name}}</span> {% for detail in action.action_type_details.all %}
{% endfor %} <span class="badge badge-pill rlp-r" title="{{detail}}">{{detail.long_name}}</span>
{% endif %} {% empty %}
<span class="badge badge-pill rlp-r-outline" title="{% trans 'No action type details' %}">{% trans 'No action type details' %}</span>
{% endfor %}
</td> </td>
<td class="">{{ action.amount|floatformat:2|intcomma }} {{ action.unit_humanize }}</td> <td class="">{{ action.amount|floatformat:2|intcomma }} {{ action.unit_humanize }}</td>
<td class=""> <td class="">

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

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

@ -74,6 +74,10 @@
<tr> <tr>
<th scope="row">{% trans 'Shared with' %}</th> <th scope="row">{% trans 'Shared with' %}</th>
<td class="align-middle"> <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 %} {% for user in obj.users.all %}
{% include 'user/includes/contact_modal_button.html' %} {% include 'user/includes/contact_modal_button.html' %}
{% endfor %} {% 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 # Sharing does not have any effect in here, since the default group will prohibit further functionality access
# to this user # to this user
self.ema.share_with_list([self.superuser]) self.ema.share_with_user_list([self.superuser])
success_urls = [ success_urls = [
self.index_url, 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 # Sharing does not have any effect in here, since the default group will prohibit further functionality access
# to this user # to this user
self.ema.share_with_list([]) self.ema.share_with_user_list([])
success_urls = [ success_urls = [
self.index_url, self.index_url,
@ -203,7 +203,7 @@ class EmaViewTestCase(CompensationViewTestCase):
groups = self.groups.filter(Q(name=ETS_GROUP)|Q(name=DEFAULT_GROUP)) groups = self.groups.filter(Q(name=ETS_GROUP)|Q(name=DEFAULT_GROUP))
self.superuser.groups.set(groups) self.superuser.groups.set(groups)
# Sharing is inherited by base intervention for compensation. Therefore configure the interventions share state # 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 = [ success_urls = [
self.index_url, self.index_url,
@ -243,7 +243,7 @@ class EmaViewTestCase(CompensationViewTestCase):
groups = self.groups.filter(Q(name=ETS_GROUP)|Q(name=DEFAULT_GROUP)) groups = self.groups.filter(Q(name=ETS_GROUP)|Q(name=DEFAULT_GROUP))
self.superuser.groups.set(groups) self.superuser.groups.set(groups)
# Sharing is inherited by base intervention for compensation. Therefore configure the interventions share state # 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 = [ success_urls = [
self.index_url, self.index_url,

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

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

@ -7,11 +7,11 @@ Created on: 27.09.21
""" """
from dal import autocomplete from dal import autocomplete
from django.core.exceptions import ObjectDoesNotExist 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, \ from konova.utils.message_templates import DEDUCTION_ADDED, REVOCATION_ADDED, DEDUCTION_REMOVED, DEDUCTION_EDITED, \
REVOCATION_EDITED, FILE_TYPE_UNSUPPORTED, FILE_SIZE_TOO_LARGE REVOCATION_EDITED, ENTRY_REMOVE_MISSING_PERMISSION
from user.models import User, UserActionLogEntry from user.models import User, Team
from user.models import UserActionLogEntry
from django.db import transaction from django.db import transaction
from django import forms from django import forms
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -37,32 +37,33 @@ class ShareModalForm(BaseModalForm):
} }
) )
) )
user_select = forms.ModelMultipleChoiceField( teams = forms.ModelMultipleChoiceField(
label=_("Add user to share with"), label=_("Add team to share with"),
label_suffix="", 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, required=False,
queryset=User.objects.all(), queryset=Team.objects.all(),
widget=autocomplete.ModelSelect2Multiple( widget=autocomplete.ModelSelect2Multiple(
url="share-user-autocomplete", url="share-team-autocomplete",
attrs={ attrs={
"data-placeholder": _("Click for selection"), "data-placeholder": _("Click for selection"),
"data-minimum-input-length": 3, "data-minimum-input-length": 3,
}, },
forward=["users"]
), ),
) )
users = forms.MultipleChoiceField( users = forms.ModelMultipleChoiceField(
label=_("Shared with"), label=_("Add user to share with"),
label_suffix="", label_suffix="",
required=True, help_text=_("Multiple selection possible - You can only select users which do not already have access. Enter the full username."),
help_text=_("Remove check to remove access for this user"), required=False,
widget=forms.CheckboxSelectMultiple( queryset=User.objects.all(),
widget=autocomplete.ModelSelect2Multiple(
url="share-user-autocomplete",
attrs={ attrs={
"class": "list-unstyled", "data-placeholder": _("Click for selection"),
} "data-minimum-input-length": 3,
},
), ),
choices=[]
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -77,6 +78,48 @@ class ShareModalForm(BaseModalForm):
self._init_fields() 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): def _init_fields(self):
""" Wraps initializing of fields """ Wraps initializing of fields
@ -91,34 +134,14 @@ class ShareModalForm(BaseModalForm):
self.share_link self.share_link
) )
# Initialize users field form_data = {
# Disable field if user is not in registration or conservation group "teams": self.instance.teams.all(),
if is_default_group_only(self.request.user): "users": self.instance.users.all(),
self.disable_form_field("users") }
self.load_initial_data(form_data)
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
)
def save(self): def save(self):
self.instance.update_sharing_user(self) self.instance.update_shared_access(self)
class NewRevocationModalForm(BaseModalForm): class NewRevocationModalForm(BaseModalForm):
@ -267,7 +290,9 @@ class CheckModalForm(BaseModalForm):
Returns: Returns:
""" """
comps = self.instance.compensations.all() comps = self.instance.compensations.filter(
deleted=None,
)
comps_valid = True comps_valid = True
for comp in comps: for comp in comps:
checker = comp.quality_check() checker = comp.quality_check()

@ -1,4 +1,6 @@
from django import forms from django import forms
from codelist.models import KonovaCode
from codelist.settings import CODELIST_COMPENSATION_ACTION_ID
class DummyFilterInput(forms.HiddenInput): class DummyFilterInput(forms.HiddenInput):
@ -30,3 +32,51 @@ class GenerateInput(forms.TextInput):
""" """
template_name = "konova/widgets/generate-content-input.html" 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> <tr>
<th scope="row">{% trans 'Shared with' %}</th> <th scope="row">{% trans 'Shared with' %}</th>
<td class="align-middle"> <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 %} {% for user in obj.users.all %}
{% include 'user/includes/contact_modal_button.html' %} {% include 'user/includes/contact_modal_button.html' %}
{% endfor %} {% endfor %}

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

@ -30,7 +30,7 @@ class InterventionWorkflowTestCase(BaseWorkflowTestCase):
super().setUp() super().setUp()
# Recreate a new (bare minimum) intervention before each test # Recreate a new (bare minimum) intervention before each test
self.intervention = self.create_dummy_intervention() self.intervention = self.create_dummy_intervention()
self.intervention.share_with(self.superuser) self.intervention.share_with_user(self.superuser)
def test_new(self): def test_new(self):
""" """
@ -303,7 +303,6 @@ class InterventionWorkflowTestCase(BaseWorkflowTestCase):
Reasons for failing are: Reasons for failing are:
* EcoAccount does not provide enough 'deductable_surface' * EcoAccount does not provide enough 'deductable_surface'
* EcoAccount is not recorded (not "approved"), yet * EcoAccount is not recorded (not "approved"), yet
* EcoAccount is not shared with performing user
Args: Args:
new_url (str): The url to send the post data to 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 # 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.assertIsNotNone(self.eco_account.recorded) # -> is recorded
self.assertGreater(self.eco_account.deductable_surface, test_surface) # -> has more deductable surface than we need 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 # Count the number of already existing deductions in total and for the account for later comparison
num_deductions = self.eco_account.deductions.count() 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, self.eco_account.deductions.count())
self.assertEqual(num_deductions_total, EcoAccountDeduction.objects.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.deductable_surface = test_surface + 100.00
self.eco_account.share_with_list([])
self.eco_account.save() self.eco_account.save()
# Now perform the (expected) failing request (again) # Remove the recording state
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])
self.eco_account.recorded.delete() self.eco_account.recorded.delete()
self.eco_account.refresh_from_db() self.eco_account.refresh_from_db()
self.eco_account.save() self.eco_account.save()
@ -376,7 +365,7 @@ class InterventionWorkflowTestCase(BaseWorkflowTestCase):
if self.eco_account.recorded is None: if self.eco_account.recorded is None:
rec_action = UserActionLogEntry.get_recorded_action(self.superuser) rec_action = UserActionLogEntry.get_recorded_action(self.superuser)
self.eco_account.recorded = rec_action 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() self.eco_account.save()
num_all_deducs = EcoAccountDeduction.objects.count() num_all_deducs = EcoAccountDeduction.objects.count()

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

@ -5,8 +5,13 @@ Contact: michel.peltriaux@sgdnord.rlp.de
Created on: 07.12.20 Created on: 07.12.20
""" """
import collections
from dal_select2.views import Select2QuerySetView, Select2GroupQuerySetView 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 django.db.models import Q
from codelist.models import KonovaCode from codelist.models import KonovaCode
@ -20,7 +25,7 @@ from intervention.models import Intervention
class EcoAccountAutocomplete(Select2QuerySetView): class EcoAccountAutocomplete(Select2QuerySetView):
""" Autocomplete for ecoAccount entries """ 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): def get_queryset(self):
@ -29,7 +34,6 @@ class EcoAccountAutocomplete(Select2QuerySetView):
qs = EcoAccount.objects.filter( qs = EcoAccount.objects.filter(
deleted=None, deleted=None,
recorded__isnull=False, recorded__isnull=False,
users__in=[self.request.user],
).order_by( ).order_by(
"identifier" "identifier"
) )
@ -65,27 +69,40 @@ class InterventionAutocomplete(Select2QuerySetView):
class ShareUserAutocomplete(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): def get_queryset(self):
if self.request.user.is_anonymous: if self.request.user.is_anonymous:
return User.objects.none() return User.objects.none()
exclude_user_ids = self.forwarded.get("users", []) qs = User.objects.all()
_exclude = {"id__in": exclude_user_ids}
qs = User.objects.all().exclude(
**_exclude
).order_by(
"username"
)
if self.q: if self.q:
# Due to privacy concerns only a full username match will return the proper user entry # Due to privacy concerns only a full username match will return the proper user entry
qs = qs.filter( qs = qs.filter(
Q(username=self.q) | Q(username=self.q) |
Q(email=self.q) Q(email=self.q)
).distinct() ).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 return qs
@ -139,6 +156,8 @@ class KonovaCodeAutocomplete(Select2GroupQuerySetView):
q_or |= Q(short_name__icontains=keyword) q_or |= Q(short_name__icontains=keyword)
q_or |= Q(parent__long_name__icontains=keyword) q_or |= Q(parent__long_name__icontains=keyword)
q_or |= Q(parent__short_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) _filter.add(q_or, Q.AND)
qs = qs.filter(_filter).distinct() qs = qs.filter(_filter).distinct()
return qs return qs
@ -181,7 +200,7 @@ class CompensationActionDetailCodeAutocomplete(KonovaCodeAutocomplete):
def order_by(self, qs): def order_by(self, qs):
return qs.order_by( return qs.order_by(
"parent__long_name" "long_name"
) )
@ -214,6 +233,41 @@ class BiotopeCodeAutocomplete(KonovaCodeAutocomplete):
def get_result_label(self, result): def get_result_label(self, result):
return f"{result.long_name} ({result.short_name})" 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): class BiotopeExtraCodeAutocomplete(KonovaCodeAutocomplete):
""" """
@ -239,7 +293,7 @@ class BiotopeExtraCodeAutocomplete(KonovaCodeAutocomplete):
qs (QuerySet): The ordered queryset qs (QuerySet): The ordered queryset
""" """
return qs.order_by( return qs.order_by(
"parent__long_name", "long_name",
) )
def get_result_label(self, result): def get_result_label(self, result):
@ -284,6 +338,11 @@ class RegistrationOfficeCodeAutocomplete(KonovaCodeAutocomplete):
self.c = CODELIST_REGISTRATION_OFFICE_ID self.c = CODELIST_REGISTRATION_OFFICE_ID
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def order_by(self, qs):
return qs.order_by(
"parent__long_name"
)
class ConservationOfficeCodeAutocomplete(KonovaCodeAutocomplete): class ConservationOfficeCodeAutocomplete(KonovaCodeAutocomplete):
""" """
@ -297,4 +356,4 @@ class ConservationOfficeCodeAutocomplete(KonovaCodeAutocomplete):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def get_result_label(self, result): 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: if not value:
return queryset.filter( 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: else:
return queryset 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.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, \ 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_recorded, celery_send_mail_shared_data_unrecorded, \
celery_send_mail_shared_data_deleted, celery_send_mail_shared_data_checked celery_send_mail_shared_data_deleted, celery_send_mail_shared_data_checked, \
from user.models import User 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.core.exceptions import ObjectDoesNotExist
from django.http import HttpRequest from django.http import HttpRequest
from django.utils.timezone import now 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 import generators
from konova.utils.generators import generate_random_string from konova.utils.generators import generate_random_string
from konova.utils.message_templates import CHECKED_RECORDED_RESET, GEOMETRY_CONFLICT_WITH_TEMPLATE from konova.utils.message_templates import CHECKED_RECORDED_RESET, GEOMETRY_CONFLICT_WITH_TEMPLATE
from user.models import UserActionLogEntry, UserAction
class UuidModel(models.Model): class UuidModel(models.Model):
@ -50,14 +51,14 @@ class BaseResource(UuidModel):
A basic resource model, which defines attributes for every derived model A basic resource model, which defines attributes for every derived model
""" """
created = models.ForeignKey( created = models.ForeignKey(
UserActionLogEntry, "user.UserActionLogEntry",
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
null=True, null=True,
blank=True, blank=True,
related_name='+' related_name='+'
) )
modified = models.ForeignKey( modified = models.ForeignKey(
UserActionLogEntry, "user.UserActionLogEntry",
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
null=True, null=True,
blank=True, blank=True,
@ -94,9 +95,9 @@ class BaseObject(BaseResource):
""" """
identifier = models.CharField(max_length=1000, null=True, blank=True) identifier = models.CharField(max_length=1000, null=True, blank=True)
title = 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) 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: class Meta:
abstract = True abstract = True
@ -105,7 +106,7 @@ class BaseObject(BaseResource):
def set_status_messages(self, request: HttpRequest): def set_status_messages(self, request: HttpRequest):
raise NotImplementedError 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 """ Mark an entry as deleted
Does not delete from database but sets a timestamp for being deleted on and which user deleted the object 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: Returns:
""" """
from user.models import UserActionLogEntry
if self.deleted: if self.deleted:
# Nothing to do here # Nothing to do here
return return
@ -131,9 +133,14 @@ class BaseObject(BaseResource):
for user_id in shared_users: for user_id in shared_users:
celery_send_mail_shared_data_deleted.delay(self.identifier, self.title, user_id) 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() 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 """ In case the object or a related object changed the log history needs to be updated
Args: Args:
@ -144,13 +151,14 @@ class BaseObject(BaseResource):
Returns: Returns:
""" """
from user.models import UserActionLogEntry
edit_action = UserActionLogEntry.get_edited_action(performing_user, edit_comment) edit_action = UserActionLogEntry.get_edited_action(performing_user, edit_comment)
self.modified = edit_action self.modified = edit_action
self.log.add(edit_action) self.log.add(edit_action)
self.save() self.save()
return edit_action 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 """ Wraps adding of UserActionLogEntry to log
Args: Args:
@ -161,6 +169,7 @@ class BaseObject(BaseResource):
Returns: Returns:
""" """
from user.models import UserActionLogEntry
user_action = UserActionLogEntry.objects.create( user_action = UserActionLogEntry.objects.create(
user=user, user=user,
action=action, action=action,
@ -229,7 +238,7 @@ class RecordableObjectMixin(models.Model):
""" """
# Refers to "verzeichnen" # Refers to "verzeichnen"
recorded = models.OneToOneField( recorded = models.OneToOneField(
UserActionLogEntry, "user.UserActionLogEntry",
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
null=True, null=True,
blank=True, blank=True,
@ -240,7 +249,7 @@ class RecordableObjectMixin(models.Model):
class Meta: class Meta:
abstract = True abstract = True
def set_unrecorded(self, user: User): def set_unrecorded(self, user):
""" Perform unrecording """ Perform unrecording
Args: Args:
@ -249,6 +258,7 @@ class RecordableObjectMixin(models.Model):
Returns: Returns:
""" """
from user.models import UserActionLogEntry
if not self.recorded: if not self.recorded:
return None return None
action = UserActionLogEntry.get_unrecorded_action(user) action = UserActionLogEntry.get_unrecorded_action(user)
@ -256,13 +266,18 @@ class RecordableObjectMixin(models.Model):
self.save() self.save()
self.log.add(action) 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: for user_id in shared_users:
celery_send_mail_shared_data_unrecorded.delay(self.identifier, self.title, user_id) 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 return action
def set_recorded(self, user: User): def set_recorded(self, user):
""" Perform recording """ Perform recording
Args: Args:
@ -271,6 +286,7 @@ class RecordableObjectMixin(models.Model):
Returns: Returns:
""" """
from user.models import UserActionLogEntry
if self.recorded: if self.recorded:
return None return None
action = UserActionLogEntry.get_recorded_action(user) action = UserActionLogEntry.get_recorded_action(user)
@ -278,13 +294,18 @@ class RecordableObjectMixin(models.Model):
self.save() self.save()
self.log.add(action) 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: for user_id in shared_users:
celery_send_mail_shared_data_recorded.delay(self.identifier, self.title, user_id) 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 return action
def unrecord(self, performing_user: User, request: HttpRequest = None): def unrecord(self, performing_user, request: HttpRequest = None):
""" Unrecords a dataset """ Unrecords a dataset
Args: Args:
@ -318,7 +339,7 @@ class RecordableObjectMixin(models.Model):
class CheckableObjectMixin(models.Model): class CheckableObjectMixin(models.Model):
# Checks - Refers to "Genehmigen" but optional # Checks - Refers to "Genehmigen" but optional
checked = models.OneToOneField( checked = models.OneToOneField(
UserActionLogEntry, "user.UserActionLogEntry",
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
null=True, null=True,
blank=True, blank=True,
@ -346,7 +367,7 @@ class CheckableObjectMixin(models.Model):
self.save() self.save()
return None return None
def set_checked(self, user: User) -> UserActionLogEntry: def set_checked(self, user):
""" Perform checking """ Perform checking
Args: Args:
@ -355,6 +376,7 @@ class CheckableObjectMixin(models.Model):
Returns: Returns:
""" """
from user.models import UserActionLogEntry
if self.checked: if self.checked:
# Nothing to do # Nothing to do
return return
@ -363,17 +385,23 @@ class CheckableObjectMixin(models.Model):
self.save() self.save()
# Send mail # 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: for user_id in shared_users:
celery_send_mail_shared_data_checked.delay(self.identifier, self.title, user_id) 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) self.log.add(action)
return action return action
class ShareableObjectMixin(models.Model): class ShareableObjectMixin(models.Model):
# Users having access on this object # 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( access_token = models.CharField(
max_length=255, max_length=255,
null=True, null=True,
@ -420,7 +448,7 @@ class ShareableObjectMixin(models.Model):
self.access_token = token self.access_token = token
self.save() self.save()
def is_shared_with(self, user: User): def is_shared_with(self, user):
""" Access check """ Access check
Checks whether a given user has access to this object Checks whether a given user has access to this object
@ -431,9 +459,36 @@ class ShareableObjectMixin(models.Model):
Returns: 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 """ Adds user to list of shared access users
Args: Args:
@ -445,7 +500,7 @@ class ShareableObjectMixin(models.Model):
if not self.is_shared_with(user): if not self.is_shared_with(user):
self.users.add(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 """ Sets the list of shared access users
Args: Args:
@ -456,8 +511,8 @@ class ShareableObjectMixin(models.Model):
""" """
self.users.set(user_list) self.users.set(user_list)
def update_sharing_user(self, form): def _update_shared_teams(self, form):
""" Adds a new user with shared access to the object """ Updates shared access on the object for teams
Args: Args:
form (ShareModalForm): The form holding the data form (ShareModalForm): The form holding the data
@ -466,25 +521,65 @@ class ShareableObjectMixin(models.Model):
""" """
form_data = form.cleaned_data form_data = form.cleaned_data
shared_teams = self.shared_teams
keep_accessing_users = form_data["users"] # Fetch selected teams and find out which user IDs are in removed teams -> mails need to be sent
new_accessing_users = list(form_data["user_select"].values_list("id", flat=True)) accessing_teams = form_data["teams"]
accessing_users = keep_accessing_users + new_accessing_users removed_teams = shared_teams.exclude(
users = User.objects.filter( id__in=accessing_teams
id__in=accessing_users ).values_list("id", flat=True)
) new_teams = accessing_teams.exclude(
removed_users = self.users.all().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 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 # Send mails
for user in removed_users: for user_id in removed_users:
celery_send_mail_shared_access_removed.delay(self.identifier, self.title, user["id"]) celery_send_mail_shared_access_removed.delay(self.identifier, self.title, user_id)
for user in new_accessing_users: for user_id in new_users:
celery_send_mail_shared_access_given.delay(self.identifier, self.title, user) celery_send_mail_shared_access_given.delay(self.identifier, self.title, user_id)
# Set new shared users # 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 @property
def shared_users(self) -> QuerySet: def shared_users(self) -> QuerySet:
@ -495,6 +590,15 @@ class ShareableObjectMixin(models.Model):
""" """
return self.users.all() 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 @abstractmethod
def get_share_url(self): def get_share_url(self):
""" Returns the share url for the object """ Returns the share url for the object

@ -242,15 +242,24 @@ Similar to bootstraps 'shadow-lg'
.select2-results__option--highlighted{ .select2-results__option--highlighted{
background-color: var(--rlp-red) !important; background-color: var(--rlp-red) !important;
} }
/*
.select2-container--default .select2-results__group{ .select2-container--default .select2-results__group{
background-color: var(--rlp-gray-light); background-color: var(--rlp-gray-light);
} }
.select2-container--default .select2-results__option .select2-results__option{ .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{ .select2-container--default .select2-results > .select2-results__options{
max-height: 500px !important; max-height: 500px !important;
} }
/*
.select2-container--default .select2-results__option .select2-results__option{ .select2-container--default .select2-results__option .select2-results__option{
padding-left: 2em; 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) 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 @shared_task
def celery_send_mail_shared_data_recorded(obj_identifier, obj_title=None, user_id=None): def celery_send_mail_shared_data_recorded(obj_identifier, obj_title=None, user_id=None):
from user.models import User 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) 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 @shared_task
def celery_send_mail_shared_data_deleted(obj_identifier, obj_title=None, user_id=None): def celery_send_mail_shared_data_deleted(obj_identifier, obj_title=None, user_id=None):
from user.models import User 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 from user.models import User
user = User.objects.get(id=user_id) user = User.objects.get(id=user_id)
user.send_mail_shared_data_checked(obj_identifier, obj_title) 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-registration-office-autocomplete",
"codes-conservation-office-autocomplete", "codes-conservation-office-autocomplete",
"share-user-autocomplete", "share-user-autocomplete",
"share-team-autocomplete",
] ]
for test in tests: for test in tests:
self.client.login(username=self.superuser.username, password=self.superuser_pw) 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 codelist.settings import CODELIST_CONSERVATION_OFFICE_ID
from ema.models import Ema 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.auth.models import Group
from django.contrib.gis.geos import MultiPolygon, Polygon from django.contrib.gis.geos import MultiPolygon, Polygon
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
@ -65,6 +65,7 @@ class BaseTestCase(TestCase):
self.create_dummy_states() self.create_dummy_states()
self.create_dummy_action() self.create_dummy_action()
self.codes = self.create_dummy_codes() self.codes = self.create_dummy_codes()
self.team = self.create_dummy_team()
# Set the default group as only group for the user # Set the default group as only group for the user
default_group = self.groups.get(name=DEFAULT_GROUP) default_group = self.groups.get(name=DEFAULT_GROUP)
@ -251,6 +252,24 @@ class BaseTestCase(TestCase):
]) ])
return codes 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 @staticmethod
def create_dummy_geometry() -> MultiPolygon: def create_dummy_geometry() -> MultiPolygon:
""" Creates some geometry """ Creates some geometry

@ -20,7 +20,7 @@ from django.urls import path, include
from konova.autocompletes import EcoAccountAutocomplete, \ from konova.autocompletes import EcoAccountAutocomplete, \
InterventionAutocomplete, CompensationActionCodeAutocomplete, BiotopeCodeAutocomplete, LawCodeAutocomplete, \ InterventionAutocomplete, CompensationActionCodeAutocomplete, BiotopeCodeAutocomplete, LawCodeAutocomplete, \
RegistrationOfficeCodeAutocomplete, ConservationOfficeCodeAutocomplete, ProcessTypeCodeAutocomplete, \ 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.settings import SSO_SERVER, SSO_PUBLIC_KEY, SSO_PRIVATE_KEY, DEBUG
from konova.sso.sso import KonovaSSOClient from konova.sso.sso import KonovaSSOClient
from konova.views import logout_view, home_view 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/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/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/u", ShareUserAutocomplete.as_view(), name="share-user-autocomplete"),
path("atcmplt/share/t", ShareTeamAutocomplete.as_view(), name="share-team-autocomplete"),
] ]
if DEBUG: if DEBUG:

@ -92,6 +92,144 @@ class Mailer:
msg 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): 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 """ 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 _ from django.utils.translation import gettext_lazy as _
UNGROUPED = _("Ungrouped")
FORM_INVALID = _("There was an error on this form.") FORM_INVALID = _("There was an error on this form.")
PARAMS_INVALID = _("Invalid parameters") PARAMS_INVALID = _("Invalid parameters")
INTERVENTION_INVALID = _("There are errors in this intervention.") 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") 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") ENTRY_REMOVE_MISSING_PERMISSION = _("Only conservation or registration office users are allowed to remove entries.")
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.")
MISSING_GROUP_PERMISSION = _("You need to be part of another user group.") MISSING_GROUP_PERMISSION = _("You need to be part of another user group.")
CHECKED_RECORDED_RESET = _("Status of Checked and Recorded reseted") 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 # FILES
FILE_TYPE_UNSUPPORTED = _("Unsupported file type") FILE_TYPE_UNSUPPORTED = _("Unsupported file type")
FILE_SIZE_TOO_LARGE = _("File too large") 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.contrib.auth.decorators import login_required
from django.http import HttpRequest, FileResponse from django.http import HttpRequest, FileResponse
from django.shortcuts import redirect, render, get_object_or_404 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 import timezone
from django.utils.translation import gettext_lazy as _ 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 compensation.models import Compensation, EcoAccount
from intervention.models import Intervention from intervention.models import Intervention
from konova.contexts import BaseContext 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 django.contrib import admin
from user.models import UserNotification, UserActionLogEntry, User from user.models import UserNotification, UserActionLogEntry, User, Team
class UserNotificationAdmin(admin.ModelAdmin): 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(User, UserAdmin)
admin.site.register(Team, TeamAdmin)
# Outcommented for a cleaner admin backend on production # Outcommented for a cleaner admin backend on production
#admin.site.register(UserNotification, UserNotificationAdmin) #admin.site.register(UserNotification, UserNotificationAdmin)

@ -5,17 +5,17 @@ Contact: michel.peltriaux@sgdnord.rlp.de
Created on: 08.07.21 Created on: 08.07.21
""" """
from dal import autocomplete
from django import forms from django import forms
from django.db import IntegrityError from django.db import IntegrityError, transaction
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from api.models import APIUserToken from api.models import APIUserToken
from intervention.inputs import GenerateInput from intervention.inputs import GenerateInput
from user.models import User from user.models import User, UserNotification, Team
from konova.forms import BaseForm, BaseModalForm from konova.forms import BaseForm, BaseModalForm, RemoveModalForm
from user.models import UserNotification
class UserNotificationForm(BaseForm): class UserNotificationForm(BaseForm):
@ -160,3 +160,201 @@ class UserAPITokenForm(BaseForm):
user.api_token = new_token user.api_token = new_token
user.save() user.save()
return new_token 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 Created on: 15.11.21
""" """
from .user_action import * from .user_action import UserActionLogEntry, UserAction
from .user import * from .user import User
from .notification import * 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 %} {% load fontawesome_5 i18n %}
<button class="btn btn-default btn-modal" data-form-url="{% url 'user:contact' user.id %}" title="{% trans 'Show contact data' %}"> <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> <span>{{user.username}}</span>
</button> </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> </button>
</a> </a>
</div> </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> </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("notifications/", notifications_view, name="notifications"),
path("token/api", api_token_view, name="api-token"), path("token/api", api_token_view, name="api-token"),
path("contact/<id>", contact_view, name="contact"), 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 import messages
from django.contrib.auth.decorators import login_required 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.sub_settings.context_settings import TAB_TITLE_IDENTIFIER
from konova.utils.mailer import Mailer from konova.utils.mailer import Mailer
from konova.utils.message_templates import FORM_INVALID from konova.utils.message_templates import FORM_INVALID
from user.models import User from user.models import User, Team
from django.http import HttpRequest from django.http import HttpRequest, Http404
from django.shortcuts import render, redirect, get_object_or_404 from django.shortcuts import render, redirect, get_object_or_404
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from konova.contexts import BaseContext from konova.contexts import BaseContext
from konova.decorators import any_group_check, default_group_required 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 @login_required
@ -128,4 +130,77 @@ def contact_view(request: HttpRequest, id: str):
request, request,
template, template,
context 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