diff --git a/api/tests/v1/create/test_api_create.py b/api/tests/v1/create/test_api_create.py index 72ece97..51a82e7 100644 --- a/api/tests/v1/create/test_api_create.py +++ b/api/tests/v1/create/test_api_create.py @@ -109,8 +109,8 @@ class APIV1CreateTestCase(BaseAPIV1TestCase): Returns: """ - self.intervention.share_with(self.superuser) - self.eco_account.share_with(self.superuser) + self.intervention.share_with_user(self.superuser) + self.eco_account.share_with_user(self.superuser) url = reverse("api:v1:deduction") json_file_path = "api/tests/v1/create/deduction_create_post_body.json" diff --git a/api/tests/v1/delete/test_api_delete.py b/api/tests/v1/delete/test_api_delete.py index cd016cf..350fdf2 100644 --- a/api/tests/v1/delete/test_api_delete.py +++ b/api/tests/v1/delete/test_api_delete.py @@ -57,7 +57,7 @@ class APIV1DeleteTestCase(BaseAPIV1TestCase): """ test_intervention = self.create_dummy_intervention() - test_intervention.share_with(self.superuser) + test_intervention.share_with_user(self.superuser) url = reverse("api:v1:intervention", args=(str(test_intervention.id),)) self._test_delete_object(test_intervention, url) @@ -68,7 +68,7 @@ class APIV1DeleteTestCase(BaseAPIV1TestCase): """ test_comp = self.create_dummy_compensation() - test_comp.share_with(self.superuser) + test_comp.share_with_user(self.superuser) url = reverse("api:v1:compensation", args=(str(test_comp.id),)) self._test_delete_object(test_comp, url) @@ -79,7 +79,7 @@ class APIV1DeleteTestCase(BaseAPIV1TestCase): """ test_acc = self.create_dummy_eco_account() - test_acc.share_with(self.superuser) + test_acc.share_with_user(self.superuser) url = reverse("api:v1:ecoaccount", args=(str(test_acc.id),)) self._test_delete_object(test_acc, url) @@ -90,7 +90,7 @@ class APIV1DeleteTestCase(BaseAPIV1TestCase): """ test_ema = self.create_dummy_ema() - test_ema.share_with(self.superuser) + test_ema.share_with_user(self.superuser) url = reverse("api:v1:ema", args=(str(test_ema.id),)) self._test_delete_object(test_ema, url) @@ -101,7 +101,7 @@ class APIV1DeleteTestCase(BaseAPIV1TestCase): """ test_deduction = self.create_dummy_deduction() - test_deduction.intervention.share_with(self.superuser) + test_deduction.intervention.share_with_user(self.superuser) url = reverse("api:v1:deduction", args=(str(test_deduction.id),)) response = self._run_delete_request(url) diff --git a/api/tests/v1/get/test_api_get.py b/api/tests/v1/get/test_api_get.py index 38af864..953b0f6 100644 --- a/api/tests/v1/get/test_api_get.py +++ b/api/tests/v1/get/test_api_get.py @@ -36,7 +36,12 @@ class APIV1GetTestCase(BaseAPIV1TestCase): """ response = self._run_get_request(url) content = json.loads(response.content) - geojson = content[str(obj.id)] + self.assertIn("rpp", content) + self.assertIn("p", content) + self.assertIn("next", content) + self.assertIn("results", content) + paginated_content = content["results"] + geojson = paginated_content[str(obj.id)] self.assertEqual(response.status_code, 200, msg=response.content) return geojson @@ -59,7 +64,7 @@ class APIV1GetTestCase(BaseAPIV1TestCase): Returns: """ - self.intervention.share_with(self.superuser) + self.intervention.share_with_user(self.superuser) url = reverse("api:v1:intervention", args=(str(self.intervention.id),)) geojson = self._test_get_object(self.intervention, url) self._assert_geojson_format(geojson) @@ -80,13 +85,33 @@ class APIV1GetTestCase(BaseAPIV1TestCase): except KeyError as e: self.fail(e) + def test_get_shared(self): + """ Tests api GET on shared info of the intervention + + Returns: + + """ + self.intervention.share_with_user(self.superuser) + self.intervention.share_with_team(self.team) + url = reverse("api:v1:intervention-share", args=(str(self.intervention.id),)) + response = self._run_get_request(url) + content = json.loads(response.content) + self.assertIn("users", content) + self.assertIn(self.superuser.username, content["users"]) + self.assertEqual(1, len(content["users"])) + self.assertIn("teams", content) + self.assertEqual(1, len(content["teams"])) + for team in content["teams"]: + self.assertEqual(team["id"], str(self.team.id)) + self.assertEqual(team["name"], self.team.name) + def test_get_compensation(self): """ Tests api GET Returns: """ - self.intervention.share_with(self.superuser) + self.intervention.share_with_user(self.superuser) self.compensation.intervention = self.intervention self.compensation.save() @@ -114,7 +139,7 @@ class APIV1GetTestCase(BaseAPIV1TestCase): Returns: """ - self.eco_account.share_with(self.superuser) + self.eco_account.share_with_user(self.superuser) url = reverse("api:v1:ecoaccount", args=(str(self.eco_account.id),)) geojson = self._test_get_object(self.eco_account, url) @@ -143,7 +168,7 @@ class APIV1GetTestCase(BaseAPIV1TestCase): Returns: """ - self.ema.share_with(self.superuser) + self.ema.share_with_user(self.superuser) url = reverse("api:v1:ema", args=(str(self.ema.id),)) geojson = self._test_get_object(self.ema, url) @@ -167,7 +192,7 @@ class APIV1GetTestCase(BaseAPIV1TestCase): Returns: """ - self.deduction.intervention.share_with(self.superuser) + self.deduction.intervention.share_with_user(self.superuser) url = reverse("api:v1:deduction", args=(str(self.deduction.id),)) _json = self._test_get_object(self.deduction, url) diff --git a/api/tests/v1/update/intervention_share_update_put_body.json b/api/tests/v1/update/intervention_share_update_put_body.json new file mode 100644 index 0000000..c160aee --- /dev/null +++ b/api/tests/v1/update/intervention_share_update_put_body.json @@ -0,0 +1,8 @@ +{ + "users": [ + "CHANGE_ME" + ], + "teams": [ + "CHANGE_ME" + ] +} \ No newline at end of file diff --git a/api/tests/v1/update/test_api_update.py b/api/tests/v1/update/test_api_update.py index e371188..500aec2 100644 --- a/api/tests/v1/update/test_api_update.py +++ b/api/tests/v1/update/test_api_update.py @@ -52,7 +52,7 @@ class APIV1UpdateTestCase(BaseAPIV1TestCase): Returns: """ - self.intervention.share_with(self.superuser) + self.intervention.share_with_user(self.superuser) modified_on = self.intervention.modified url = reverse("api:v1:intervention", args=(str(self.intervention.id),)) json_file_path = "api/tests/v1/update/intervention_update_put_body.json" @@ -79,7 +79,7 @@ class APIV1UpdateTestCase(BaseAPIV1TestCase): """ self.compensation.intervention = self.intervention self.compensation.save() - self.intervention.share_with(self.superuser) + self.intervention.share_with_user(self.superuser) modified_on = self.compensation.modified url = reverse("api:v1:compensation", args=(str(self.compensation.id),)) @@ -108,7 +108,7 @@ class APIV1UpdateTestCase(BaseAPIV1TestCase): Returns: """ - self.eco_account.share_with(self.superuser) + self.eco_account.share_with_user(self.superuser) modified_on = self.eco_account.modified url = reverse("api:v1:ecoaccount", args=(str(self.eco_account.id),)) @@ -139,7 +139,7 @@ class APIV1UpdateTestCase(BaseAPIV1TestCase): Returns: """ - self.ema.share_with(self.superuser) + self.ema.share_with_user(self.superuser) modified_on = self.ema.modified url = reverse("api:v1:ema", args=(str(self.ema.id),)) @@ -168,8 +168,8 @@ class APIV1UpdateTestCase(BaseAPIV1TestCase): Returns: """ - self.deduction.intervention.share_with(self.superuser) - self.deduction.account.share_with(self.superuser) + self.deduction.intervention.share_with_user(self.superuser) + self.deduction.account.share_with_user(self.superuser) url = reverse("api:v1:deduction", args=(str(self.deduction.id),)) json_file_path = "api/tests/v1/update/deduction_update_put_body.json" @@ -184,3 +184,24 @@ class APIV1UpdateTestCase(BaseAPIV1TestCase): self.assertEqual(put_body["intervention"], str(self.deduction.intervention.id)) self.assertEqual(put_body["eco_account"], str(self.deduction.account.id)) self.assertEqual(put_body["surface"], self.deduction.surface) + + def test_update_share_intervention(self): + self.intervention.share_with_user(self.superuser) + url = reverse("api:v1:intervention-share", args=(str(self.intervention.id),)) + json_file_path = "api/tests/v1/update/intervention_share_update_put_body.json" + with open(json_file_path) as json_file: + put_body = json.load(fp=json_file) + put_body["users"] = [self.user.username] + put_body["teams"] = [self.team.name] + + self.assertFalse(self.intervention.is_shared_with(self.user)) + self.assertEqual(0, self.intervention.shared_teams.count()) + + response = self._run_update_request(url, put_body) + self.assertEqual(response.status_code, 200, msg=response.content) + self.intervention.refresh_from_db() + + self.assertEqual(1, self.intervention.shared_teams.count()) + self.assertEqual(2, self.intervention.shared_users.count()) + self.assertEqual(self.team.name, self.intervention.shared_teams.first().name) + self.assertTrue(self.intervention.is_shared_with(self.user)) diff --git a/api/utils/serializer/serializer.py b/api/utils/serializer/serializer.py index 443e10c..8d618e4 100644 --- a/api/utils/serializer/serializer.py +++ b/api/utils/serializer/serializer.py @@ -10,6 +10,7 @@ from abc import abstractmethod from django.contrib.gis import geos from django.contrib.gis.geos import GEOSGeometry +from django.core.paginator import Paginator from konova.utils.message_templates import DATA_UNSHARED @@ -19,6 +20,10 @@ class AbstractModelAPISerializer: lookup = None properties_data = None + rpp = None + page_number = None + paginator = None + class Meta: abstract = True @@ -80,9 +85,12 @@ class AbstractModelAPISerializer: Returns: serialized_data (dict) """ - entries = self.model.objects.filter(**self.lookup) + entries = self.model.objects.filter(**self.lookup).order_by("id") + self.paginator = Paginator(entries, self.rpp) + requested_entries = self.paginator.page(self.page_number) + serialized_data = {} - for entry in entries: + for entry in requested_entries.object_list: serialized_data[str(entry.id)] = self._model_to_geo_json(entry) return serialized_data diff --git a/api/utils/serializer/v1/serializer.py b/api/utils/serializer/v1/serializer.py index f56db81..caae2de 100644 --- a/api/utils/serializer/v1/serializer.py +++ b/api/utils/serializer/v1/serializer.py @@ -367,7 +367,9 @@ class AbstractCompensationAPISerializerV1Mixin: """ actions = [] for entry in actions_data: - action = entry["action"] + action_types = [ + self._konova_code_from_json(e, CODELIST_COMPENSATION_ACTION_ID) for e in entry["action_types"] + ] action_details = [ self._konova_code_from_json(e, CODELIST_COMPENSATION_ACTION_DETAIL_ID) for e in entry["action_details"] ] @@ -384,7 +386,7 @@ class AbstractCompensationAPISerializerV1Mixin: # If this exact data is already existing, we do not create it new. Instead put it's id in the list of # entries, we will use to set the new actions action_entry = obj.actions.filter( - action_type__atom_id=action, + action_type__in=action_types, amount=amount, unit=unit, comment=comment, @@ -396,13 +398,13 @@ class AbstractCompensationAPISerializerV1Mixin: else: # Create and add id to list action_entry = CompensationAction.objects.create( - action_type=self._konova_code_from_json(action, CODELIST_COMPENSATION_ACTION_ID), amount=amount, unit=unit, comment=comment, ) actions.append(action_entry.id) + action_entry.action_type.set(action_types) action_entry.action_type_details.set(action_details) obj.actions.set(actions) return obj @@ -438,7 +440,9 @@ class AbstractCompensationAPISerializerV1Mixin: """ return [ { - "action": self._konova_code_to_json(entry.action_type), + "action_types": [ + self._konova_code_to_json(action) for action in entry.action_type.all() + ], "action_details": [ self._konova_code_to_json(detail) for detail in entry.action_type_details.all() ], diff --git a/api/views/v1/views.py b/api/views/v1/views.py index 7789680..8da5d49 100644 --- a/api/views/v1/views.py +++ b/api/views/v1/views.py @@ -21,7 +21,6 @@ class AbstractAPIViewV1(AbstractAPIView): """ Holds general serialization functions for API v1 """ - serializer = None def __init__(self, *args, **kwargs): self.lookup = { @@ -45,11 +44,17 @@ class AbstractAPIViewV1(AbstractAPIView): response (JsonResponse) """ try: + self.rpp = int(request.GET.get("rpp", self.rpp)) + self.page_number = int(request.GET.get("p", self.page_number)) + + self.serializer.rpp = self.rpp + self.serializer.page_number = self.page_number + self.serializer.prepare_lookup(id, self.user) data = self.serializer.fetch_and_serialize() except Exception as e: - return self.return_error_response(e, 500) - return JsonResponse(data) + return self._return_error_response(e, 500) + return self._return_response(request, data) def post(self, request: HttpRequest): """ Handles the POST request @@ -67,7 +72,7 @@ class AbstractAPIViewV1(AbstractAPIView): body = json.loads(body) created_id = self.serializer.create_model_from_json(body, self.user) except Exception as e: - return self.return_error_response(e, 500) + return self._return_error_response(e, 500) return JsonResponse({"id": created_id}) def put(self, request: HttpRequest, id=None): @@ -87,7 +92,7 @@ class AbstractAPIViewV1(AbstractAPIView): body = json.loads(body) updated_id = self.serializer.update_model_from_json(id, body, self.user) except Exception as e: - return self.return_error_response(e, 500) + return self._return_error_response(e, 500) return JsonResponse({"id": updated_id}) def delete(self, request: HttpRequest, id=None): @@ -104,7 +109,7 @@ class AbstractAPIViewV1(AbstractAPIView): try: success = self.serializer.delete_entry(id, self.user) except Exception as e: - return self.return_error_response(e, 500) + return self._return_error_response(e, 500) return JsonResponse( { "success": success, diff --git a/api/views/views.py b/api/views/views.py index fb4f6df..75d764e 100644 --- a/api/views/views.py +++ b/api/views/views.py @@ -19,7 +19,7 @@ from ema.models import Ema from intervention.models import Intervention from konova.utils.message_templates import DATA_UNSHARED from konova.utils.user_checks import is_default_group_only -from user.models import User +from user.models import User, Team class AbstractAPIView(View): @@ -31,10 +31,22 @@ class AbstractAPIView(View): """ user = None + serializer = None + rpp = 5 # Results per page default + page_number = 1 # Page number default class Meta: abstract = True + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.response_body_base = { + "rpp": None, + "p": None, + "next": None, + "results": None + } + @csrf_exempt def dispatch(self, request, *args, **kwargs): try: @@ -42,13 +54,14 @@ class AbstractAPIView(View): ksp_token = request.headers.get(KSP_TOKEN_HEADER_IDENTIFIER, None) ksp_user = request.headers.get(KSP_USER_HEADER_IDENTIFIER, None) self.user = APIUserToken.get_user_from_token(ksp_token, ksp_user) + request.user = self.user if not self.user.is_default_user(): raise PermissionError("Default permissions required") except PermissionError as e: - return self.return_error_response(e, 403) + return self._return_error_response(e, 403) return super().dispatch(request, *args, **kwargs) - def return_error_response(self, error, status_code=500): + def _return_error_response(self, error, status_code=500): """ Returns an error as JsonReponse Args: @@ -68,6 +81,31 @@ class AbstractAPIView(View): status=status_code ) + def _return_response(self, request: HttpRequest, data): + """ Returns all important data into a response object + + Args: + request (HttpRequest): The incoming request + data (dict): The serialized data + + Returns: + response (JsonResponse): The response to be returned + """ + response = self.response_body_base + next_page = self.page_number + 1 + next_page = next_page if next_page in self.serializer.paginator.page_range else None + if next_page is not None: + next_url = request.build_absolute_uri( + request.path + f"?rpp={self.rpp}&p={next_page}" + ) + else: + next_url = None + response["rpp"] = self.rpp + response["p"] = self.page_number + response["next"] = next_url + response["results"] = data + return JsonResponse(response) + class InterventionCheckAPIView(AbstractAPIView): @@ -82,14 +120,14 @@ class InterventionCheckAPIView(AbstractAPIView): response (JsonResponse) """ if not self.user.is_zb_user(): - return self.return_error_response("Permission not granted", 403) + return self._return_error_response("Permission not granted", 403) try: obj = Intervention.objects.get( id=id, users__in=[self.user] ) except Exception as e: - return self.return_error_response(e) + return self._return_error_response(e) all_valid, check_details = self.run_quality_checks(obj) @@ -160,13 +198,21 @@ class AbstractModelShareAPIView(AbstractAPIView): """ try: users = self._get_shared_users_of_object(id) + teams = self._get_shared_teams_of_object(id) except Exception as e: - return self.return_error_response(e) + return self._return_error_response(e) data = { "users": [ user.username for user in users - ] + ], + "teams": [ + { + "id": team.id, + "name": team.name, + } + for team in teams + ], } return JsonResponse(data) @@ -185,7 +231,7 @@ class AbstractModelShareAPIView(AbstractAPIView): try: success = self._process_put_body(request.body, id) except Exception as e: - return self.return_error_response(e) + return self._return_error_response(e) data = { "success": success, } @@ -220,6 +266,22 @@ class AbstractModelShareAPIView(AbstractAPIView): users = obj.shared_users return users + def _get_shared_teams_of_object(self, id) -> QuerySet: + """ Check permissions and get the teams + + Args: + id (str): The object's id + + Returns: + users (QuerySet) + """ + obj = self.model.objects.get( + id=id + ) + self._check_user_has_shared_access(obj) + teams = obj.shared_teams + return teams + def _process_put_body(self, body: bytes, id: str): """ Reads the body data, performs validity checks and sets the new users @@ -233,19 +295,26 @@ class AbstractModelShareAPIView(AbstractAPIView): obj = self.model.objects.get(id=id) self._check_user_has_shared_access(obj) - new_users = json.loads(body.decode("utf-8")) - new_users = new_users.get("users", []) + content = json.loads(body.decode("utf-8")) + new_users = content.get("users", []) if len(new_users) == 0: raise ValueError("Shared user list must not be empty!") + new_teams = content.get("teams", []) # Eliminate duplicates new_users = list(dict.fromkeys(new_users)) + new_teams = list(dict.fromkeys(new_teams)) # Make sure each of these names exist as a user new_users_objs = [] for user in new_users: new_users_objs.append(User.objects.get(username=user)) + # Make sure each of these names exist as a user + new_teams_objs = [] + for team_name in new_teams: + new_teams_objs.append(Team.objects.get(name=team_name)) + if is_default_group_only(self.user): # Default only users are not allowed to remove other users from having access. They can only add new ones! new_users_to_be_added = User.objects.filter( @@ -254,7 +323,16 @@ class AbstractModelShareAPIView(AbstractAPIView): id__in=obj.shared_users ) new_users_objs = obj.shared_users.union(new_users_to_be_added) - obj.share_with_list(new_users_objs) + + new_teams_to_be_added = Team.objects.filter( + name__in=new_teams + ).exclude( + id__in=obj.shared_teams + ) + new_teams_objs = obj.shared_teams.union(new_teams_to_be_added) + + obj.share_with_user_list(new_users_objs) + obj.share_with_team_list(new_teams_objs) return True diff --git a/codelist/models.py b/codelist/models.py index d5be5b1..de7a210 100644 --- a/codelist/models.py +++ b/codelist/models.py @@ -65,6 +65,26 @@ class KonovaCode(models.Model): ret_val += ", " + self.parent.long_name return ret_val + def add_children(self): + """ Adds all children (resurcively until leaf) as .children to the KonovaCode + + Returns: + code (KonovaCode): The manipulated KonovaCode instance + """ + if self.is_leaf: + return None + + children = KonovaCode.objects.filter( + code_lists__in=self.code_lists.all(), + parent=self + ).order_by( + "long_name" + ) + self.children = children + for child in children: + child.add_children() + return self + class KonovaCodeList(models.Model): """ diff --git a/codelist/settings.py b/codelist/settings.py index 598a3a6..b4226b5 100644 --- a/codelist/settings.py +++ b/codelist/settings.py @@ -13,7 +13,8 @@ CODELIST_BASE_URL = "https://codelisten.naturschutz.rlp.de/repository/referenzli CODELIST_INTERVENTION_HANDLER_ID = 903 # CLMassnahmeträger CODELIST_CONSERVATION_OFFICE_ID = 907 # CLNaturschutzbehörden CODELIST_REGISTRATION_OFFICE_ID = 1053 # CLZulassungsbehörden -CODELIST_BIOTOPES_ID = 974 # CL_EIV_Biotoptypen +CODELIST_BIOTOPES_ID = 654 # CL_Biotoptypen +CODELIST_AFTER_STATE_BIOTOPES__ID = 974 # CL-KSP_ZielBiotoptypen - USAGE HAS BEEN DROPPED IN 2022 IN FAVOR OF 654 CODELIST_BIOTOPES_EXTRA_CODES_ID = 975 # CLZusatzbezeichnung CODELIST_LAW_ID = 1048 # CLVerfahrensrecht CODELIST_PROCESS_TYPE_ID = 44382 # CLVerfahrenstyp diff --git a/compensation/filters.py b/compensation/filters.py index d028e3c..b637709 100644 --- a/compensation/filters.py +++ b/compensation/filters.py @@ -59,8 +59,9 @@ class CheckboxCompensationTableFilter(CheckboxTableFilter): """ if not value: return queryset.filter( - intervention__users__in=[self.user], # requesting user has access - ) + Q(intervention__users__in=[self.user]) | # requesting user has access + Q(intervention__teams__users__in=[self.user]) + ).distinct() else: return queryset @@ -127,24 +128,6 @@ class CheckboxEcoAccountTableFilter(CheckboxTableFilter): ) ) - def filter_show_all(self, queryset, name, value) -> QuerySet: - """ Filters queryset depending on value of 'show_all' setting - - Args: - queryset (): - name (): - value (): - - Returns: - - """ - if not value: - return queryset.filter( - users__in=[self.user], # requesting user has access - ) - else: - return queryset - def filter_only_show_unrecorded(self, queryset, name, value) -> QuerySet: """ Filters queryset depending on value of 'show_recorded' setting diff --git a/compensation/forms/forms.py b/compensation/forms/forms.py index 72b1a71..46b235f 100644 --- a/compensation/forms/forms.py +++ b/compensation/forms/forms.py @@ -400,7 +400,7 @@ class NewEcoAccountForm(AbstractCompensationForm, CompensationResponsibleFormMix comment=comment, legal=legal ) - acc.share_with(user) + acc.share_with_user(user) # Add the log entry to the main objects log list acc.log.add(action) diff --git a/compensation/forms/modalForms.py b/compensation/forms/modalForms.py index 804ebad..cdcc26e 100644 --- a/compensation/forms/modalForms.py +++ b/compensation/forms/modalForms.py @@ -17,10 +17,11 @@ from codelist.models import KonovaCode from codelist.settings import CODELIST_BIOTOPES_ID, CODELIST_COMPENSATION_ACTION_ID, CODELIST_BIOTOPES_EXTRA_CODES_ID, \ CODELIST_COMPENSATION_ACTION_DETAIL_ID from compensation.models import CompensationDocument, EcoAccountDocument +from intervention.inputs import CompensationActionTreeCheckboxSelectMultiple from konova.contexts import BaseContext from konova.forms import BaseModalForm, NewDocumentModalForm, RemoveModalForm from konova.models import DeadlineType -from konova.utils.message_templates import FORM_INVALID, ADDED_COMPENSATION_STATE, ADDED_DEADLINE, \ +from konova.utils.message_templates import FORM_INVALID, ADDED_COMPENSATION_STATE, \ ADDED_COMPENSATION_ACTION, PAYMENT_EDITED, COMPENSATION_STATE_EDITED, COMPENSATION_ACTION_EDITED, DEADLINE_EDITED @@ -405,22 +406,13 @@ class NewActionModalForm(BaseModalForm): """ from compensation.models import UnitChoices - action_type = forms.ModelChoiceField( + action_type = forms.MultipleChoiceField( label=_("Action Type"), label_suffix="", required=True, - help_text=_("Select the action type"), - queryset=KonovaCode.objects.filter( - is_archived=False, - is_leaf=True, - code_lists__in=[CODELIST_COMPENSATION_ACTION_ID], - ), - widget=autocomplete.ModelSelect2( - url="codes-compensation-action-autocomplete", - attrs={ - "data-placeholder": _("Action"), - } - ), + help_text=_("An action can consist of multiple different action types. All the selected action types are expected to be performed according to the amount and unit below on this form."), + choices=[], + widget=CompensationActionTreeCheckboxSelectMultiple(), ) action_type_details = forms.ModelMultipleChoiceField( label=_("Action Type detail"), @@ -482,6 +474,16 @@ class NewActionModalForm(BaseModalForm): super().__init__(*args, **kwargs) self.form_title = _("New action") self.form_caption = _("Insert data for the new action") + choices =KonovaCode.objects.filter( + code_lists__in=[CODELIST_COMPENSATION_ACTION_ID], + is_archived=False, + is_leaf=True, + ).values_list("id", flat=True) + choices = [ + (choice, choice) + for choice in choices + ] + self.fields["action_type"].choices = choices def save(self): action = self.instance.add_action(self) @@ -496,7 +498,7 @@ class EditCompensationActionModalForm(NewActionModalForm): self.action = kwargs.pop("action", None) super().__init__(*args, **kwargs) form_data = { - "action_type": self.action.action_type, + "action_type": list(self.action.action_type.values_list("id", flat=True)), "action_type_details": self.action.action_type_details.all(), "amount": self.action.amount, "unit": self.action.unit, @@ -506,7 +508,7 @@ class EditCompensationActionModalForm(NewActionModalForm): def save(self): action = self.action - action.action_type = self.cleaned_data.get("action_type", None) + action.action_type.set(self.cleaned_data.get("action_type", [])) action.action_type_details.set(self.cleaned_data.get("action_type_details", [])) action.amount = self.cleaned_data.get("amount", None) action.unit = self.cleaned_data.get("unit", None) diff --git a/compensation/managers.py b/compensation/managers.py index c97cd51..b496eb1 100644 --- a/compensation/managers.py +++ b/compensation/managers.py @@ -8,17 +8,6 @@ Created on: 14.10.21 from django.db import models -class CompensationActionManager(models.Manager): - """ Holds default db fetch setting for this model type - - """ - def get_queryset(self): - return super().get_queryset().select_related( - "action_type", - "action_type__parent" - ) - - class CompensationStateManager(models.Manager): """ Holds default db fetch setting for this model type diff --git a/compensation/migrations/0004_auto_20220210_1402.py b/compensation/migrations/0004_auto_20220210_1402.py new file mode 100644 index 0000000..7cc8126 --- /dev/null +++ b/compensation/migrations/0004_auto_20220210_1402.py @@ -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', + ) + ] diff --git a/compensation/migrations/0005_auto_20220218_0917.py b/compensation/migrations/0005_auto_20220218_0917.py new file mode 100644 index 0000000..43e7db9 --- /dev/null +++ b/compensation/migrations/0005_auto_20220218_0917.py @@ -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'), + ), + ] diff --git a/compensation/migrations/0006_ecoaccount_teams.py b/compensation/migrations/0006_ecoaccount_teams.py new file mode 100644 index 0000000..14cf509 --- /dev/null +++ b/compensation/migrations/0006_ecoaccount_teams.py @@ -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'), + ), + ] diff --git a/compensation/models/action.py b/compensation/models/action.py index a557915..e54f53d 100644 --- a/compensation/models/action.py +++ b/compensation/models/action.py @@ -10,9 +10,7 @@ from django.utils.translation import gettext_lazy as _ from codelist.models import KonovaCode from codelist.settings import CODELIST_COMPENSATION_ACTION_ID, CODELIST_COMPENSATION_ACTION_DETAIL_ID -from compensation.managers import CompensationActionManager from konova.models import BaseResource -from konova.utils.message_templates import COMPENSATION_ACTION_REMOVED class UnitChoices(models.TextChoices): @@ -31,10 +29,8 @@ class CompensationAction(BaseResource): """ Compensations include actions like planting trees, refreshing rivers and so on. """ - action_type = models.ForeignKey( + action_type = models.ManyToManyField( KonovaCode, - on_delete=models.SET_NULL, - null=True, blank=True, limit_choices_to={ "code_lists__in": [CODELIST_COMPENSATION_ACTION_ID], @@ -57,10 +53,8 @@ class CompensationAction(BaseResource): unit = models.CharField(max_length=100, null=True, blank=True, choices=UnitChoices.choices) comment = models.TextField(blank=True, null=True, help_text="Additional comment") - objects = CompensationActionManager() - def __str__(self): - return f"{self.action_type} | {self.amount} {self.unit}" + return f"{self.action_type.all()} | {self.amount} {self.unit}" @property def unit_humanize(self): diff --git a/compensation/models/compensation.py b/compensation/models/compensation.py index 4dd2b4c..ea69cef 100644 --- a/compensation/models/compensation.py +++ b/compensation/models/compensation.py @@ -8,7 +8,7 @@ Created on: 16.11.21 import shutil from django.contrib import messages -from user.models import User +from user.models import User, Team from django.db import models, transaction from django.db.models import QuerySet, Sum from django.http import HttpRequest @@ -104,12 +104,12 @@ class AbstractCompensation(BaseObject, GeoReferencedMixin): with transaction.atomic(): user_action = UserActionLogEntry.get_created_action(user) comp_action = CompensationAction.objects.create( - action_type=form_data["action_type"], amount=form_data["amount"], unit=form_data["unit"], comment=form_data["comment"], created=user_action, ) + comp_action.action_type.set(form_data.get("action_type", [])) comp_action_details = form_data["action_type_details"] comp_action.action_type_details.set(comp_action_details) self.actions.add(comp_action) @@ -299,7 +299,7 @@ class Compensation(AbstractCompensation, CEFMixin, CoherenceMixin): # Compensations inherit their shared state from the interventions return self.intervention.is_shared_with(user) - def share_with(self, user: User): + def share_with_user(self, user: User): """ Adds user to list of shared access users Args: @@ -308,10 +308,9 @@ class Compensation(AbstractCompensation, CEFMixin, CoherenceMixin): Returns: """ - if not self.intervention.is_shared_with(user): - self.intervention.users.add(user) + self.intervention.users.add(user) - def share_with_list(self, user_list: list): + def share_with_user_list(self, user_list: list): """ Sets the list of shared access users Args: @@ -322,6 +321,28 @@ class Compensation(AbstractCompensation, CEFMixin, CoherenceMixin): """ self.intervention.users.set(user_list) + def share_with_team(self, team: Team): + """ Adds team to list of shared access teams + + Args: + team (Team): The team to be added to the object + + Returns: + + """ + self.intervention.teams.add(team) + + def share_with_team_list(self, team_list: list): + """ Sets the list of shared access teams + + Args: + team_list (list): The teams to be added to the object + + Returns: + + """ + self.intervention.teams.set(team_list) + @property def shared_users(self) -> QuerySet: """ Shortcut for fetching the users which have shared access on this object @@ -331,6 +352,15 @@ class Compensation(AbstractCompensation, CEFMixin, CoherenceMixin): """ return self.intervention.users.all() + @property + def shared_teams(self) -> QuerySet: + """ Shortcut for fetching the teams which have shared access on this object + + Returns: + users (QuerySet) + """ + return self.intervention.teams.all() + def get_documents(self) -> QuerySet: """ Getter for all documents of a compensation diff --git a/compensation/templates/compensation/detail/compensation/includes/actions.html b/compensation/templates/compensation/detail/compensation/includes/actions.html index 33037ec..87f4914 100644 --- a/compensation/templates/compensation/detail/compensation/includes/actions.html +++ b/compensation/templates/compensation/detail/compensation/includes/actions.html @@ -47,13 +47,15 @@ {% for action in actions %} - {{ action.action_type }} - {% if action.action_type_details.count > 0 %} -
- {% for detail in action.action_type_details.all %} - {{detail.long_name}} - {% endfor %} - {% endif %} + {% for type in action.action_type.all %} +
{{type.parent.parent.long_name}} {% fa5_icon 'angle-right' %} {{type.parent.long_name}} {% fa5_icon 'angle-right' %} {{type.long_name}}
+
+ {% endfor %} + {% for detail in action.action_type_details.all %} + {{detail.long_name}} + {% empty %} + {% trans 'No action type details' %} + {% endfor %} {{ action.amount|floatformat:2|intcomma }} {{ action.unit_humanize }} diff --git a/compensation/templates/compensation/detail/compensation/includes/states-after.html b/compensation/templates/compensation/detail/compensation/includes/states-after.html index 2c95ca1..7faa0f1 100644 --- a/compensation/templates/compensation/detail/compensation/includes/states-after.html +++ b/compensation/templates/compensation/detail/compensation/includes/states-after.html @@ -48,13 +48,13 @@ {% for state in after_states %} - {{ state.biotope_type }} - {% if state.biotope_type_details.count > 0 %} -
- {% for detail in state.biotope_type_details.all %} - {{detail.long_name}} - {% endfor %} - {% endif %} + {{ state.biotope_type.parent.long_name }} {% fa5_icon 'angle-right' %} {{ state.biotope_type.long_name }} ({{state.biotope_type.short_name}}) +
+ {% for detail in state.biotope_type_details.all %} + {{detail.long_name}} + {% empty %} + {% trans 'No biotope type details' %} + {% endfor %} {{ state.surface|floatformat:2 }} m² diff --git a/compensation/templates/compensation/detail/compensation/includes/states-before.html b/compensation/templates/compensation/detail/compensation/includes/states-before.html index d2ba369..23faed4 100644 --- a/compensation/templates/compensation/detail/compensation/includes/states-before.html +++ b/compensation/templates/compensation/detail/compensation/includes/states-before.html @@ -48,13 +48,13 @@ {% for state in before_states %} - {{ state.biotope_type }} - {% if state.biotope_type_details.count > 0 %} -
- {% for detail in state.biotope_type_details.all %} - {{detail.long_name}} - {% endfor %} - {% endif %} + {{ state.biotope_type.parent.long_name }} {% fa5_icon 'angle-right' %} {{ state.biotope_type.long_name }} ({{state.biotope_type.short_name}}) +
+ {% for detail in state.biotope_type_details.all %} + {{detail.long_name}} + {% empty %} + {% trans 'No biotope type details' %} + {% endfor %} {{ state.surface|floatformat:2 }} m² diff --git a/compensation/templates/compensation/detail/compensation/view.html b/compensation/templates/compensation/detail/compensation/view.html index 3f843c0..e387151 100644 --- a/compensation/templates/compensation/detail/compensation/view.html +++ b/compensation/templates/compensation/detail/compensation/view.html @@ -2,6 +2,7 @@ {% load i18n l10n static fontawesome_5 humanize ksp_filters %} {% block head %} + {% comment %} dal documentation (django-autocomplete-light) states using form.media for adding needed scripts. This does not work properly with modal forms, as the scripts are not loaded properly inside the modal. @@ -97,6 +98,10 @@ {% trans 'Shared with' %} + {% for team in obj.intervention.teams.all %} + {% include 'user/includes/team_data_modal_button.html' %} + {% endfor %} +
{% for user in obj.intervention.users.all %} {% include 'user/includes/contact_modal_button.html' %} {% endfor %} diff --git a/compensation/templates/compensation/detail/eco_account/includes/actions.html b/compensation/templates/compensation/detail/eco_account/includes/actions.html index add698e..8ae7c8f 100644 --- a/compensation/templates/compensation/detail/eco_account/includes/actions.html +++ b/compensation/templates/compensation/detail/eco_account/includes/actions.html @@ -46,13 +46,15 @@ {% for action in actions %} - {{ action.action_type }} - {% if action.action_type_details.count > 0 %} -
- {% for detail in action.action_type_details.all %} - {{detail.long_name}} - {% endfor %} - {% endif %} + {% for type in action.action_type.all %} +
{{type.parent.parent.long_name}} {% fa5_icon 'angle-right' %} {{type.parent.long_name}} {% fa5_icon 'angle-right' %} {{type.long_name}}
+
+ {% endfor %} + {% for detail in action.action_type_details.all %} + {{detail.long_name}} + {% empty %} + {% trans 'No action type details' %} + {% endfor %} {{ action.amount|floatformat:2|intcomma }} {{ action.unit_humanize }} diff --git a/compensation/templates/compensation/detail/eco_account/includes/deductions.html b/compensation/templates/compensation/detail/eco_account/includes/deductions.html index 10f177e..b91fb21 100644 --- a/compensation/templates/compensation/detail/eco_account/includes/deductions.html +++ b/compensation/templates/compensation/detail/eco_account/includes/deductions.html @@ -10,7 +10,7 @@
- {% if is_default_member and has_access %} + {% if is_default_member and obj.recorded %} diff --git a/compensation/templates/compensation/detail/eco_account/includes/states-after.html b/compensation/templates/compensation/detail/eco_account/includes/states-after.html index bead6f2..4fce2f0 100644 --- a/compensation/templates/compensation/detail/eco_account/includes/states-after.html +++ b/compensation/templates/compensation/detail/eco_account/includes/states-after.html @@ -48,13 +48,13 @@ {% for state in after_states %} - {{ state.biotope_type }} - {% if state.biotope_type_details.count > 0 %} -
- {% for detail in state.biotope_type_details.all %} - {{detail.long_name}} - {% endfor %} - {% endif %} + {{ state.biotope_type.parent.long_name }} {% fa5_icon 'angle-right' %} {{ state.biotope_type.long_name }} ({{state.biotope_type.short_name}}) +
+ {% for detail in state.biotope_type_details.all %} + {{detail.long_name}} + {% empty %} + {% trans 'No biotope type details' %} + {% endfor %} {{ state.surface|floatformat:2 }} m² diff --git a/compensation/templates/compensation/detail/eco_account/includes/states-before.html b/compensation/templates/compensation/detail/eco_account/includes/states-before.html index c19b404..1c6311c 100644 --- a/compensation/templates/compensation/detail/eco_account/includes/states-before.html +++ b/compensation/templates/compensation/detail/eco_account/includes/states-before.html @@ -48,13 +48,13 @@ {% for state in before_states %} - {{ state.biotope_type }} - {% if state.biotope_type_details.count > 0 %} -
- {% for detail in state.biotope_type_details.all %} - {{detail.long_name}} - {% endfor %} - {% endif %} + {{ state.biotope_type.parent.long_name }} {% fa5_icon 'angle-right' %} {{ state.biotope_type.long_name }} ({{state.biotope_type.short_name}}) +
+ {% for detail in state.biotope_type_details.all %} + {{detail.long_name}} + {% empty %} + {% trans 'No biotope type details' %} + {% endfor %} {{ state.surface|floatformat:2 }} m² diff --git a/compensation/templates/compensation/detail/eco_account/view.html b/compensation/templates/compensation/detail/eco_account/view.html index f276f93..ee4a0f7 100644 --- a/compensation/templates/compensation/detail/eco_account/view.html +++ b/compensation/templates/compensation/detail/eco_account/view.html @@ -2,6 +2,7 @@ {% load i18n l10n static fontawesome_5 humanize %} {% block head %} + {% comment %} dal documentation (django-autocomplete-light) states using form.media for adding needed scripts. This does not work properly with modal forms, as the scripts are not loaded properly inside the modal. @@ -80,6 +81,10 @@ {% trans 'Shared with' %} + {% for team in obj.teams.all %} + {% include 'user/includes/team_data_modal_button.html' %} + {% endfor %} +
{% for user in obj.users.all %} {% include 'user/includes/contact_modal_button.html' %} {% endfor %} diff --git a/compensation/tests/compensation/test_views.py b/compensation/tests/compensation/test_views.py index 27218f2..1174895 100644 --- a/compensation/tests/compensation/test_views.py +++ b/compensation/tests/compensation/test_views.py @@ -103,7 +103,7 @@ class CompensationViewTestCase(BaseViewTestCase): client = Client() client.login(username=self.superuser.username, password=self.superuser_pw) self.superuser.groups.set([]) - self.intervention.share_with_list([self.superuser]) + self.intervention.share_with_user_list([self.superuser]) # Since the user has no groups, it does not matter that data has been shared. There SHOULD not be any difference # to a user without access, since the important permissions are missing @@ -143,7 +143,7 @@ class CompensationViewTestCase(BaseViewTestCase): client.login(username=self.superuser.username, password=self.superuser_pw) self.superuser.groups.set([]) # Sharing is inherited by base intervention for compensation. Therefore configure the interventions share state - self.intervention.share_with_list([]) + self.intervention.share_with_user_list([]) # Since the user has no groups, it does not matter that data is unshared. There SHOULD not be any difference # to a user having shared access, since all important permissions are missing @@ -185,7 +185,7 @@ class CompensationViewTestCase(BaseViewTestCase): group = self.groups.get(name=DEFAULT_GROUP) self.superuser.groups.set([group]) # Sharing is inherited by base intervention for compensation. Therefore configure the interventions share state - self.intervention.share_with_list([self.superuser]) + self.intervention.share_with_user_list([self.superuser]) success_urls = [ self.index_url, @@ -221,7 +221,7 @@ class CompensationViewTestCase(BaseViewTestCase): group = self.groups.get(name=DEFAULT_GROUP) self.superuser.groups.set([group]) # Sharing is inherited by base intervention for compensation. Therefore configure the interventions share state - self.intervention.share_with_list([]) + self.intervention.share_with_user_list([]) success_urls = [ self.index_url, diff --git a/compensation/tests/compensation/test_workflow.py b/compensation/tests/compensation/test_workflow.py index 7b73be8..5b7decf 100644 --- a/compensation/tests/compensation/test_workflow.py +++ b/compensation/tests/compensation/test_workflow.py @@ -25,7 +25,7 @@ class CompensationWorkflowTestCase(BaseWorkflowTestCase): super().setUp() # Give the user shared access to the dummy intervention -> inherits the access to the compensation - self.intervention.share_with(self.superuser) + self.intervention.share_with_user(self.superuser) # Make sure the intervention itself would be fine with valid data self.intervention = self.fill_out_intervention(self.intervention) diff --git a/compensation/tests/ecoaccount/test_views.py b/compensation/tests/ecoaccount/test_views.py index 617f743..aaa7a4c 100644 --- a/compensation/tests/ecoaccount/test_views.py +++ b/compensation/tests/ecoaccount/test_views.py @@ -78,7 +78,7 @@ class EcoAccountViewTestCase(CompensationViewTestCase): client = Client() client.login(username=self.superuser.username, password=self.superuser_pw) self.superuser.groups.set([]) - self.eco_account.share_with_list([self.superuser]) + self.eco_account.share_with_user_list([self.superuser]) # Since the user has no groups, it does not matter that data has been shared. There SHOULD not be any difference # to a user without access, since the important permissions are missing @@ -119,7 +119,7 @@ class EcoAccountViewTestCase(CompensationViewTestCase): client = Client() client.login(username=self.superuser.username, password=self.superuser_pw) self.superuser.groups.set([]) - self.eco_account.share_with_list([]) + self.eco_account.share_with_user_list([]) # Since the user has no groups, it does not matter that data is unshared. There SHOULD not be any difference # to a user having shared access, since all important permissions are missing @@ -163,7 +163,7 @@ class EcoAccountViewTestCase(CompensationViewTestCase): group = self.groups.get(name=DEFAULT_GROUP) self.superuser.groups.set([group]) # Sharing is inherited by base intervention for compensation. Therefore configure the interventions share state - self.eco_account.share_with_list([self.superuser]) + self.eco_account.share_with_user_list([self.superuser]) success_urls = [ self.index_url, @@ -200,7 +200,7 @@ class EcoAccountViewTestCase(CompensationViewTestCase): client.login(username=self.superuser.username, password=self.superuser_pw) group = self.groups.get(name=DEFAULT_GROUP) self.superuser.groups.set([group]) - self.eco_account.share_with_list([]) + self.eco_account.share_with_user_list([]) success_urls = [ self.index_url, diff --git a/compensation/tests/ecoaccount/test_workflow.py b/compensation/tests/ecoaccount/test_workflow.py index 1cdb030..03b4996 100644 --- a/compensation/tests/ecoaccount/test_workflow.py +++ b/compensation/tests/ecoaccount/test_workflow.py @@ -27,7 +27,7 @@ class EcoAccountWorkflowTestCase(BaseWorkflowTestCase): # Add user to conservation office group and give shared access to the account self.superuser.groups.add(self.groups.get(name=DEFAULT_GROUP)) self.superuser.groups.add(self.groups.get(name=ETS_GROUP)) - self.eco_account.share_with_list([self.superuser]) + self.eco_account.share_with_user_list([self.superuser]) def test_new(self): """ Test the creation of an EcoAccount @@ -73,7 +73,7 @@ class EcoAccountWorkflowTestCase(BaseWorkflowTestCase): Returns: """ - self.eco_account.share_with(self.superuser) + self.eco_account.share_with_user(self.superuser) url = reverse("compensation:acc:edit", args=(self.eco_account.id,)) pre_edit_log_count = self.eco_account.log.count() @@ -129,7 +129,7 @@ class EcoAccountWorkflowTestCase(BaseWorkflowTestCase): """ # Add proper privilege for the user - self.eco_account.share_with(self.superuser) + self.eco_account.share_with_user(self.superuser) pre_record_log_count = self.eco_account.log.count() # Prepare url and form data @@ -178,7 +178,7 @@ class EcoAccountWorkflowTestCase(BaseWorkflowTestCase): """ # Give user shared access to the dummy intervention, which will be needed here - self.intervention.share_with(self.superuser) + self.intervention.share_with_user(self.superuser) pre_deduction_acc_log_count = self.eco_account.log.count() pre_deduction_int_log_count = self.intervention.log.count() @@ -231,7 +231,9 @@ class EcoAccountWorkflowTestCase(BaseWorkflowTestCase): def test_edit_deduction(self): test_surface = self.eco_account.get_available_rest()[0] self.eco_account.set_recorded(self.superuser) + self.intervention.share_with_user(self.superuser) self.eco_account.refresh_from_db() + self.assertTrue(self.superuser, self.intervention.is_shared_with(self.superuser)) deduction = EcoAccountDeduction.objects.create( intervention=self.intervention, @@ -279,8 +281,8 @@ class EcoAccountWorkflowTestCase(BaseWorkflowTestCase): "confirm": True, } - intervention.share_with(self.superuser) - account.share_with(self.superuser) + intervention.share_with_user(self.superuser) + account.share_with_user(self.superuser) pre_edit_intervention_log_count = intervention.log.count() pre_edit_account_log_count = account.log.count() diff --git a/compensation/tests/payment/test_views.py b/compensation/tests/payment/test_views.py index b1eca5a..30ffa00 100644 --- a/compensation/tests/payment/test_views.py +++ b/compensation/tests/payment/test_views.py @@ -64,7 +64,7 @@ class PaymentViewTestCase(BaseViewTestCase): client = Client() client.login(username=self.superuser.username, password=self.superuser_pw) self.superuser.groups.set([]) - self.intervention.share_with_list([self.superuser]) + self.intervention.share_with_user_list([self.superuser]) # Since the user has no groups, it does not matter that data has been shared. There SHOULD not be any difference # to a user without access, since the important permissions are missing @@ -91,7 +91,7 @@ class PaymentViewTestCase(BaseViewTestCase): client.login(username=self.superuser.username, password=self.superuser_pw) self.superuser.groups.set([]) # Sharing is inherited by base intervention for compensation. Therefore configure the interventions share state - self.intervention.share_with_list([]) + self.intervention.share_with_user_list([]) # Since the user has no groups, it does not matter that data is unshared. There SHOULD not be any difference # to a user having shared access, since all important permissions are missing @@ -120,7 +120,7 @@ class PaymentViewTestCase(BaseViewTestCase): group = self.groups.get(name=DEFAULT_GROUP) self.superuser.groups.set([group]) # Sharing is inherited by base intervention for compensation. Therefore configure the interventions share state - self.intervention.share_with_list([self.superuser]) + self.intervention.share_with_user_list([self.superuser]) success_urls = [ self.new_url, @@ -143,7 +143,7 @@ class PaymentViewTestCase(BaseViewTestCase): group = self.groups.get(name=DEFAULT_GROUP) self.superuser.groups.set([group]) # Sharing is inherited by base intervention for compensation. Therefore configure the interventions share state - self.intervention.share_with_list([]) + self.intervention.share_with_user_list([]) success_urls = [ ] diff --git a/compensation/tests/payment/test_workflow.py b/compensation/tests/payment/test_workflow.py index 790fb61..81259b8 100644 --- a/compensation/tests/payment/test_workflow.py +++ b/compensation/tests/payment/test_workflow.py @@ -21,7 +21,7 @@ class PaymentWorkflowTestCase(BaseWorkflowTestCase): def setUp(self) -> None: super().setUp() # Give the user shared access to the dummy intervention - self.intervention.share_with(self.superuser) + self.intervention.share_with_user(self.superuser) self.payment = Payment.objects.get_or_create( intervention=self.intervention, diff --git a/compensation/views/eco_account.py b/compensation/views/eco_account.py index cf5f328..85b1371 100644 --- a/compensation/views/eco_account.py +++ b/compensation/views/eco_account.py @@ -272,7 +272,6 @@ def remove_view(request: HttpRequest, id: str): @login_required @default_group_required -@shared_access_required(EcoAccount, "id") def deduction_remove_view(request: HttpRequest, id: str, deduction_id: str): """ Renders a modal view for removing deductions @@ -287,6 +286,8 @@ def deduction_remove_view(request: HttpRequest, id: str, deduction_id: str): acc = get_object_or_404(EcoAccount, id=id) try: eco_deduction = acc.deductions.get(id=deduction_id) + if not eco_deduction.intervention.is_shared_with(request.user): + raise ObjectDoesNotExist() except ObjectDoesNotExist: raise Http404("Unknown deduction") @@ -300,7 +301,6 @@ def deduction_remove_view(request: HttpRequest, id: str, deduction_id: str): @login_required @default_group_required -@shared_access_required(EcoAccount, "id") def deduction_edit_view(request: HttpRequest, id: str, deduction_id: str): """ Renders a modal view for editing deductions @@ -315,6 +315,8 @@ def deduction_edit_view(request: HttpRequest, id: str, deduction_id: str): acc = get_object_or_404(EcoAccount, id=id) try: eco_deduction = acc.deductions.get(id=deduction_id) + if not eco_deduction.intervention.is_shared_with(request.user): + raise ObjectDoesNotExist except ObjectDoesNotExist: raise Http404("Unknown deduction") @@ -679,7 +681,6 @@ def remove_document_view(request: HttpRequest, id: str, doc_id: str): @login_required @default_group_required -@shared_access_required(EcoAccount, "id") def new_deduction_view(request: HttpRequest, id: str): """ Renders a modal form view for creating deductions @@ -691,6 +692,8 @@ def new_deduction_view(request: HttpRequest, id: str): """ acc = get_object_or_404(EcoAccount, id=id) + if not acc.recorded: + raise Http404() form = NewDeductionModalForm(request.POST or None, instance=acc, request=request) return form.process_request( request, @@ -793,7 +796,7 @@ def share_view(request: HttpRequest, id: str, token: str): request, _("{} has been shared with you").format(obj.identifier) ) - obj.share_with(user) + obj.share_with_user(user) return redirect("compensation:acc:detail", id=id) else: messages.error( diff --git a/ema/forms.py b/ema/forms.py index 2f19360..8e6faab 100644 --- a/ema/forms.py +++ b/ema/forms.py @@ -80,7 +80,7 @@ class NewEmaForm(AbstractCompensationForm, CompensationResponsibleFormMixin): ) # Add the creating user to the list of shared users - acc.share_with(user) + acc.share_with_user(user) # Add the log entry to the main objects log list acc.log.add(action) diff --git a/ema/migrations/0003_ema_teams.py b/ema/migrations/0003_ema_teams.py new file mode 100644 index 0000000..606781d --- /dev/null +++ b/ema/migrations/0003_ema_teams.py @@ -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'), + ), + ] diff --git a/ema/templates/ema/detail/includes/actions.html b/ema/templates/ema/detail/includes/actions.html index 02772b3..0c352c1 100644 --- a/ema/templates/ema/detail/includes/actions.html +++ b/ema/templates/ema/detail/includes/actions.html @@ -44,13 +44,15 @@ {% for action in obj.actions.all %} - {{ action.action_type }} - {% if action.action_type_details.count > 0 %} -
- {% for detail in action.action_type_details.all %} - {{detail.long_name}} - {% endfor %} - {% endif %} + {% for type in action.action_type.all %} +
{{type.parent.parent.long_name}} {% fa5_icon 'angle-right' %} {{type.parent.long_name}} {% fa5_icon 'angle-right' %} {{type.long_name}}
+
+ {% endfor %} + {% for detail in action.action_type_details.all %} + {{detail.long_name}} + {% empty %} + {% trans 'No action type details' %} + {% endfor %} {{ action.amount|floatformat:2|intcomma }} {{ action.unit_humanize }} diff --git a/ema/templates/ema/detail/includes/states-after.html b/ema/templates/ema/detail/includes/states-after.html index e09f4ba..56e87be 100644 --- a/ema/templates/ema/detail/includes/states-after.html +++ b/ema/templates/ema/detail/includes/states-after.html @@ -46,13 +46,13 @@ {% for state in after_states %} - {{ state.biotope_type }} - {% if state.biotope_type_details.count > 0 %} -
- {% for detail in state.biotope_type_details.all %} - {{detail.long_name}} - {% endfor %} - {% endif %} + {{ state.biotope_type.parent.long_name }} {% fa5_icon 'angle-right' %} {{ state.biotope_type.long_name }} ({{state.biotope_type.short_name}}) +
+ {% for detail in state.biotope_type_details.all %} + {{detail.long_name}} + {% empty %} + {% trans 'No biotope type details' %} + {% endfor %} {{ state.surface|floatformat:2 }} m² diff --git a/ema/templates/ema/detail/includes/states-before.html b/ema/templates/ema/detail/includes/states-before.html index 1369829..2fd7c35 100644 --- a/ema/templates/ema/detail/includes/states-before.html +++ b/ema/templates/ema/detail/includes/states-before.html @@ -46,13 +46,13 @@ {% for state in before_states %} - {{ state.biotope_type }} - {% if state.biotope_type_details.count > 0 %} -
- {% for detail in state.biotope_type_details.all %} - {{detail.long_name}} - {% endfor %} - {% endif %} + {{ state.biotope_type.parent.long_name }} {% fa5_icon 'angle-right' %} {{ state.biotope_type.long_name }} ({{state.biotope_type.short_name}}) +
+ {% for detail in state.biotope_type_details.all %} + {{detail.long_name}} + {% empty %} + {% trans 'No biotope type details' %} + {% endfor %} {{ state.surface|floatformat:2 }} m² diff --git a/ema/templates/ema/detail/view.html b/ema/templates/ema/detail/view.html index 32ddd66..7b56703 100644 --- a/ema/templates/ema/detail/view.html +++ b/ema/templates/ema/detail/view.html @@ -74,6 +74,10 @@ {% trans 'Shared with' %} + {% for team in obj.teams.all %} + {% include 'user/includes/team_data_modal_button.html' %} + {% endfor %} +
{% for user in obj.users.all %} {% include 'user/includes/contact_modal_button.html' %} {% endfor %} diff --git a/ema/tests/test_views.py b/ema/tests/test_views.py index 9654e8f..b2c23e1 100644 --- a/ema/tests/test_views.py +++ b/ema/tests/test_views.py @@ -110,7 +110,7 @@ class EmaViewTestCase(CompensationViewTestCase): # Sharing does not have any effect in here, since the default group will prohibit further functionality access # to this user - self.ema.share_with_list([self.superuser]) + self.ema.share_with_user_list([self.superuser]) success_urls = [ self.index_url, @@ -160,7 +160,7 @@ class EmaViewTestCase(CompensationViewTestCase): # Sharing does not have any effect in here, since the default group will prohibit further functionality access # to this user - self.ema.share_with_list([]) + self.ema.share_with_user_list([]) success_urls = [ self.index_url, @@ -203,7 +203,7 @@ class EmaViewTestCase(CompensationViewTestCase): groups = self.groups.filter(Q(name=ETS_GROUP)|Q(name=DEFAULT_GROUP)) self.superuser.groups.set(groups) # Sharing is inherited by base intervention for compensation. Therefore configure the interventions share state - self.ema.share_with_list([self.superuser]) + self.ema.share_with_user_list([self.superuser]) success_urls = [ self.index_url, @@ -243,7 +243,7 @@ class EmaViewTestCase(CompensationViewTestCase): groups = self.groups.filter(Q(name=ETS_GROUP)|Q(name=DEFAULT_GROUP)) self.superuser.groups.set(groups) # Sharing is inherited by base intervention for compensation. Therefore configure the interventions share state - self.ema.share_with_list([]) + self.ema.share_with_user_list([]) success_urls = [ self.index_url, diff --git a/ema/views.py b/ema/views.py index e9d0acb..c145511 100644 --- a/ema/views.py +++ b/ema/views.py @@ -621,7 +621,7 @@ def share_view(request: HttpRequest, id: str, token: str): request, _("{} has been shared with you").format(obj.identifier) ) - obj.share_with(user) + obj.share_with_user(user) return redirect("ema:detail", id=id) else: messages.error( diff --git a/intervention/forms/forms.py b/intervention/forms/forms.py index 1c5cf1c..94ff970 100644 --- a/intervention/forms/forms.py +++ b/intervention/forms/forms.py @@ -253,7 +253,7 @@ class NewInterventionForm(BaseForm): intervention.log.add(action) # Add the performing user as the first user having access to the data - intervention.share_with(user) + intervention.share_with_user(user) return intervention diff --git a/intervention/forms/modalForms.py b/intervention/forms/modalForms.py index a8fa98d..1e5febf 100644 --- a/intervention/forms/modalForms.py +++ b/intervention/forms/modalForms.py @@ -7,11 +7,11 @@ Created on: 27.09.21 """ from dal import autocomplete from django.core.exceptions import ObjectDoesNotExist -from django.db.models.fields.files import FieldFile from konova.utils.message_templates import DEDUCTION_ADDED, REVOCATION_ADDED, DEDUCTION_REMOVED, DEDUCTION_EDITED, \ - REVOCATION_EDITED, FILE_TYPE_UNSUPPORTED, FILE_SIZE_TOO_LARGE -from user.models import User, UserActionLogEntry + REVOCATION_EDITED, ENTRY_REMOVE_MISSING_PERMISSION +from user.models import User, Team +from user.models import UserActionLogEntry from django.db import transaction from django import forms from django.utils.translation import gettext_lazy as _ @@ -37,32 +37,33 @@ class ShareModalForm(BaseModalForm): } ) ) - user_select = forms.ModelMultipleChoiceField( - label=_("Add user to share with"), + teams = forms.ModelMultipleChoiceField( + label=_("Add team to share with"), label_suffix="", - help_text=_("Multiple selection possible - You can only select users which do not already have access. Enter the full username."), + help_text=_("Multiple selection possible - You can only select teams which do not already have access."), required=False, - queryset=User.objects.all(), + queryset=Team.objects.all(), widget=autocomplete.ModelSelect2Multiple( - url="share-user-autocomplete", + url="share-team-autocomplete", attrs={ "data-placeholder": _("Click for selection"), "data-minimum-input-length": 3, }, - forward=["users"] ), ) - users = forms.MultipleChoiceField( - label=_("Shared with"), + users = forms.ModelMultipleChoiceField( + label=_("Add user to share with"), label_suffix="", - required=True, - help_text=_("Remove check to remove access for this user"), - widget=forms.CheckboxSelectMultiple( + help_text=_("Multiple selection possible - You can only select users which do not already have access. Enter the full username."), + required=False, + queryset=User.objects.all(), + widget=autocomplete.ModelSelect2Multiple( + url="share-user-autocomplete", attrs={ - "class": "list-unstyled", - } + "data-placeholder": _("Click for selection"), + "data-minimum-input-length": 3, + }, ), - choices=[] ) def __init__(self, *args, **kwargs): @@ -77,6 +78,48 @@ class ShareModalForm(BaseModalForm): self._init_fields() + def _user_team_valid(self): + """ Checks whether users and teams have been removed by the user and if the user is allowed to do so or not + + Returns: + + """ + users = self.cleaned_data.get("users", User.objects.none()) + teams = self.cleaned_data.get("teams", Team.objects.none()) + + _is_valid = True + if is_default_group_only(self.user): + shared_users = self.instance.shared_users + shared_teams = self.instance.shared_teams + + shared_users_are_removed = not set(shared_users).issubset(users) + shared_teams_are_removed = not set(shared_teams).issubset(teams) + + if shared_users_are_removed: + self.add_error( + "users", + ENTRY_REMOVE_MISSING_PERMISSION + ) + _is_valid = False + if shared_teams_are_removed: + self.add_error( + "teams", + ENTRY_REMOVE_MISSING_PERMISSION + ) + _is_valid = False + return _is_valid + + def is_valid(self): + """ Extended validity check + + Returns: + + """ + super_valid = super().is_valid() + user_team_valid = self._user_team_valid() + _is_valid = super_valid and user_team_valid + return _is_valid + def _init_fields(self): """ Wraps initializing of fields @@ -91,34 +134,14 @@ class ShareModalForm(BaseModalForm): self.share_link ) - # Initialize users field - # Disable field if user is not in registration or conservation group - if is_default_group_only(self.request.user): - self.disable_form_field("users") - - self._add_user_choices_to_field() - - def _add_user_choices_to_field(self): - """ Transforms the instance's sharing users into a list for the form field - - Returns: - - """ - users = self.instance.users.all() - choices = [] - for n in users: - choices.append( - (n.id, n.username) - ) - self.fields["users"].choices = choices - u_ids = list(users.values_list("id", flat=True)) - self.initialize_form_field( - "users", - u_ids - ) + form_data = { + "teams": self.instance.teams.all(), + "users": self.instance.users.all(), + } + self.load_initial_data(form_data) def save(self): - self.instance.update_sharing_user(self) + self.instance.update_shared_access(self) class NewRevocationModalForm(BaseModalForm): @@ -267,7 +290,9 @@ class CheckModalForm(BaseModalForm): Returns: """ - comps = self.instance.compensations.all() + comps = self.instance.compensations.filter( + deleted=None, + ) comps_valid = True for comp in comps: checker = comp.quality_check() diff --git a/intervention/inputs.py b/intervention/inputs.py index 2e84edc..34fe043 100644 --- a/intervention/inputs.py +++ b/intervention/inputs.py @@ -1,4 +1,6 @@ from django import forms +from codelist.models import KonovaCode +from codelist.settings import CODELIST_COMPENSATION_ACTION_ID class DummyFilterInput(forms.HiddenInput): @@ -30,3 +32,51 @@ class GenerateInput(forms.TextInput): """ template_name = "konova/widgets/generate-content-input.html" + + +class TreeCheckboxSelectMultiple(forms.CheckboxSelectMultiple): + """ Provides multiple selection of parent-child data + + """ + template_name = "konova/widgets/checkbox-tree-select.html" + + class meta: + abstract = True + + +class KonovaCodeTreeCheckboxSelectMultiple(TreeCheckboxSelectMultiple): + """ Provides multiple selection of KonovaCodes + + """ + filter = None + + def __init__(self, *args, **kwargs): + self.code_list = kwargs.pop("code_list", None) + self.filter = kwargs.pop("filter", {}) + super().__init__(*args, **kwargs) + + def get_context(self, name, value, attrs): + context = super().get_context(name, value, attrs) + codes = KonovaCode.objects.filter( + **self.filter, + ) + codes = [ + parent_code.add_children() + for parent_code in codes + ] + context["codes"] = codes + return context + + +class CompensationActionTreeCheckboxSelectMultiple(KonovaCodeTreeCheckboxSelectMultiple): + """ Provides multiple selection of CompensationActions + + """ + filter = None + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.filter = { + "code_lists__in": [CODELIST_COMPENSATION_ACTION_ID], + "parent": None, + } \ No newline at end of file diff --git a/intervention/migrations/0003_intervention_teams.py b/intervention/migrations/0003_intervention_teams.py new file mode 100644 index 0000000..8dd7624 --- /dev/null +++ b/intervention/migrations/0003_intervention_teams.py @@ -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'), + ), + ] diff --git a/intervention/templates/intervention/detail/view.html b/intervention/templates/intervention/detail/view.html index 8e7fc6c..f5680cc 100644 --- a/intervention/templates/intervention/detail/view.html +++ b/intervention/templates/intervention/detail/view.html @@ -114,6 +114,10 @@ {% trans 'Shared with' %} + {% for team in obj.teams.all %} + {% include 'user/includes/team_data_modal_button.html' %} + {% endfor %} +
{% for user in obj.users.all %} {% include 'user/includes/contact_modal_button.html' %} {% endfor %} diff --git a/intervention/tests/test_views.py b/intervention/tests/test_views.py index f12ee7a..a049f3e 100644 --- a/intervention/tests/test_views.py +++ b/intervention/tests/test_views.py @@ -144,7 +144,7 @@ class InterventionViewTestCase(BaseViewTestCase): # Add user to default group default_group = Group.objects.get(name=DEFAULT_GROUP) self.superuser.groups.set([default_group]) - self.intervention.share_with_list([self.superuser]) + self.intervention.share_with_user_list([self.superuser]) success_urls = [ self.index_url, @@ -190,7 +190,7 @@ class InterventionViewTestCase(BaseViewTestCase): # Add user to default group default_group = Group.objects.get(name=DEFAULT_GROUP) self.superuser.groups.set([default_group]) - self.intervention.share_with_list([]) + self.intervention.share_with_user_list([]) success_urls = [ self.index_url, @@ -236,7 +236,7 @@ class InterventionViewTestCase(BaseViewTestCase): # Add user to zb group zb_group = self.groups.get(name=ZB_GROUP) self.superuser.groups.set([zb_group]) - self.intervention.share_with_list([self.superuser]) + self.intervention.share_with_user_list([self.superuser]) success_urls = [ self.index_url, @@ -282,7 +282,7 @@ class InterventionViewTestCase(BaseViewTestCase): # Add user to zb group zb_group = self.groups.get(name=ZB_GROUP) self.superuser.groups.set([zb_group]) - self.intervention.share_with_list([]) + self.intervention.share_with_user_list([]) success_urls = [ self.index_url, @@ -328,7 +328,7 @@ class InterventionViewTestCase(BaseViewTestCase): # Add user to ets group ets_group = Group.objects.get(name=ETS_GROUP) self.superuser.groups.set([ets_group]) - self.intervention.share_with_list([self.superuser]) + self.intervention.share_with_user_list([self.superuser]) success_urls = [ self.index_url, @@ -374,7 +374,7 @@ class InterventionViewTestCase(BaseViewTestCase): # Add user to default group ets_group = Group.objects.get(name=ETS_GROUP) self.superuser.groups.set([ets_group]) - self.intervention.share_with_list([]) + self.intervention.share_with_user_list([]) success_urls = [ self.index_url, diff --git a/intervention/tests/test_workflow.py b/intervention/tests/test_workflow.py index 69f606f..c529050 100644 --- a/intervention/tests/test_workflow.py +++ b/intervention/tests/test_workflow.py @@ -30,7 +30,7 @@ class InterventionWorkflowTestCase(BaseWorkflowTestCase): super().setUp() # Recreate a new (bare minimum) intervention before each test self.intervention = self.create_dummy_intervention() - self.intervention.share_with(self.superuser) + self.intervention.share_with_user(self.superuser) def test_new(self): """ @@ -303,7 +303,6 @@ class InterventionWorkflowTestCase(BaseWorkflowTestCase): Reasons for failing are: * EcoAccount does not provide enough 'deductable_surface' * EcoAccount is not recorded (not "approved"), yet - * EcoAccount is not shared with performing user Args: new_url (str): The url to send the post data to @@ -315,7 +314,6 @@ class InterventionWorkflowTestCase(BaseWorkflowTestCase): # Before running fail positive tests, we need to have an account in a (normally) fine working state self.assertIsNotNone(self.eco_account.recorded) # -> is recorded self.assertGreater(self.eco_account.deductable_surface, test_surface) # -> has more deductable surface than we need - self.assertIn(self.superuser, self.eco_account.users.all()) # -> is shared with the performing user # Count the number of already existing deductions in total and for the account for later comparison num_deductions = self.eco_account.deductions.count() @@ -333,20 +331,11 @@ class InterventionWorkflowTestCase(BaseWorkflowTestCase): self.assertEqual(num_deductions, self.eco_account.deductions.count()) self.assertEqual(num_deductions_total, EcoAccountDeduction.objects.count()) - # Now restore the deductable surface to a valid size back again but remove the user from the shared list + # Now restore the deductable surface to a valid size back again self.eco_account.deductable_surface = test_surface + 100.00 - self.eco_account.share_with_list([]) self.eco_account.save() - # Now perform the (expected) failing request (again) - self.client_user.post(new_url, post_data) - - # Expect no changes at all, since the account is not shared - self.assertEqual(num_deductions, self.eco_account.deductions.count()) - self.assertEqual(num_deductions_total, EcoAccountDeduction.objects.count()) - - # Restore the sharing but remove the recording state - self.eco_account.share_with_list([self.superuser]) + # Remove the recording state self.eco_account.recorded.delete() self.eco_account.refresh_from_db() self.eco_account.save() @@ -376,7 +365,7 @@ class InterventionWorkflowTestCase(BaseWorkflowTestCase): if self.eco_account.recorded is None: rec_action = UserActionLogEntry.get_recorded_action(self.superuser) self.eco_account.recorded = rec_action - self.eco_account.share_with_list([self.superuser]) + self.eco_account.share_with_user_list([self.superuser]) self.eco_account.save() num_all_deducs = EcoAccountDeduction.objects.count() diff --git a/intervention/views.py b/intervention/views.py index 00440bb..3004a79 100644 --- a/intervention/views.py +++ b/intervention/views.py @@ -432,7 +432,7 @@ def share_view(request: HttpRequest, id: str, token: str): request, _("{} has been shared with you").format(intervention.identifier) ) - intervention.share_with(user) + intervention.share_with_user(user) return redirect("intervention:detail", id=id) else: messages.error( diff --git a/konova/autocompletes.py b/konova/autocompletes.py index 3a79ab6..5ecc50d 100644 --- a/konova/autocompletes.py +++ b/konova/autocompletes.py @@ -5,8 +5,13 @@ Contact: michel.peltriaux@sgdnord.rlp.de Created on: 07.12.20 """ +import collections + from dal_select2.views import Select2QuerySetView, Select2GroupQuerySetView -from user.models import User +from django.core.exceptions import ImproperlyConfigured + +from konova.utils.message_templates import UNGROUPED +from user.models import User, Team from django.db.models import Q from codelist.models import KonovaCode @@ -20,7 +25,7 @@ from intervention.models import Intervention class EcoAccountAutocomplete(Select2QuerySetView): """ Autocomplete for ecoAccount entries - Only returns entries that are accessible for the requesting user and already are recorded + Only returns entries that are already recorded and not deleted """ def get_queryset(self): @@ -29,7 +34,6 @@ class EcoAccountAutocomplete(Select2QuerySetView): qs = EcoAccount.objects.filter( deleted=None, recorded__isnull=False, - users__in=[self.request.user], ).order_by( "identifier" ) @@ -65,27 +69,40 @@ class InterventionAutocomplete(Select2QuerySetView): class ShareUserAutocomplete(Select2QuerySetView): - """ Autocomplete for intervention entries + """ Autocomplete for share with single users - Only returns entries that are accessible for the requesting user """ def get_queryset(self): if self.request.user.is_anonymous: return User.objects.none() - exclude_user_ids = self.forwarded.get("users", []) - _exclude = {"id__in": exclude_user_ids} - qs = User.objects.all().exclude( - **_exclude - ).order_by( - "username" - ) + qs = User.objects.all() if self.q: # Due to privacy concerns only a full username match will return the proper user entry qs = qs.filter( Q(username=self.q) | Q(email=self.q) ).distinct() + qs = qs.order_by("username") + return qs + + +class ShareTeamAutocomplete(Select2QuerySetView): + """ Autocomplete for share with teams + + """ + def get_queryset(self): + if self.request.user.is_anonymous: + return Team.objects.none() + qs = Team.objects.all() + if self.q: + # Due to privacy concerns only a full username match will return the proper user entry + qs = qs.filter( + name__icontains=self.q + ) + qs = qs.order_by( + "name" + ) return qs @@ -139,6 +156,8 @@ class KonovaCodeAutocomplete(Select2GroupQuerySetView): q_or |= Q(short_name__icontains=keyword) q_or |= Q(parent__long_name__icontains=keyword) q_or |= Q(parent__short_name__icontains=keyword) + q_or |= Q(parent__parent__long_name__icontains=keyword) + q_or |= Q(parent__parent__short_name__icontains=keyword) _filter.add(q_or, Q.AND) qs = qs.filter(_filter).distinct() return qs @@ -181,7 +200,7 @@ class CompensationActionDetailCodeAutocomplete(KonovaCodeAutocomplete): def order_by(self, qs): return qs.order_by( - "parent__long_name" + "long_name" ) @@ -214,6 +233,41 @@ class BiotopeCodeAutocomplete(KonovaCodeAutocomplete): def get_result_label(self, result): return f"{result.long_name} ({result.short_name})" + def get_results(self, context): + """Return the options grouped by a common related model. + + Raises ImproperlyConfigured if self.group_by_name is not configured + """ + if not self.group_by_related: + raise ImproperlyConfigured("Missing group_by_related.") + + super_groups = collections.OrderedDict() + + object_list = context['object_list'] + + for result in object_list: + group = result.parent if result.parent else None + group_name = f"{group.long_name} ({group.short_name})" if group else UNGROUPED + super_group = result.parent.parent if result.parent else None + super_group_name = f"{super_group.long_name} ({super_group.short_name})" if super_group else UNGROUPED + super_groups.setdefault(super_group_name, {}) + super_groups[super_group_name].setdefault(group_name, []) + super_groups[super_group_name][group_name].append(result) + + return [{ + 'id': None, + 'text': super_group, + 'children': [{ + "id": None, + "text": group, + "children": [{ + 'id': self.get_result_value(result), + 'text': self.get_result_label(result), + 'selected_text': self.get_selected_result_label(result), + } for result in results] + } for group, results in groups.items()] + } for super_group, groups in super_groups.items()] + class BiotopeExtraCodeAutocomplete(KonovaCodeAutocomplete): """ @@ -239,7 +293,7 @@ class BiotopeExtraCodeAutocomplete(KonovaCodeAutocomplete): qs (QuerySet): The ordered queryset """ return qs.order_by( - "parent__long_name", + "long_name", ) def get_result_label(self, result): @@ -284,6 +338,11 @@ class RegistrationOfficeCodeAutocomplete(KonovaCodeAutocomplete): self.c = CODELIST_REGISTRATION_OFFICE_ID super().__init__(*args, **kwargs) + def order_by(self, qs): + return qs.order_by( + "parent__long_name" + ) + class ConservationOfficeCodeAutocomplete(KonovaCodeAutocomplete): """ @@ -297,4 +356,4 @@ class ConservationOfficeCodeAutocomplete(KonovaCodeAutocomplete): super().__init__(*args, **kwargs) def get_result_label(self, result): - return f"{result.long_name} ({result.short_name})" \ No newline at end of file + return f"{result.long_name} ({result.short_name})" diff --git a/konova/filters/mixins.py b/konova/filters/mixins.py index 5625ff3..5a07e3c 100644 --- a/konova/filters/mixins.py +++ b/konova/filters/mixins.py @@ -297,8 +297,9 @@ class ShareableTableFilterMixin(django_filters.FilterSet): """ if not value: return queryset.filter( - users__in=[self.user], # requesting user has access - ) + Q(users__in=[self.user]) | # requesting user has access + Q(teams__users__in=[self.user]) + ).distinct() else: return queryset diff --git a/konova/migrations/0005_auto_20220216_0856.py b/konova/migrations/0005_auto_20220216_0856.py new file mode 100644 index 0000000..43c518a --- /dev/null +++ b/konova/migrations/0005_auto_20220216_0856.py @@ -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), + ] diff --git a/konova/models/object.py b/konova/models/object.py index a6164f5..f224b68 100644 --- a/konova/models/object.py +++ b/konova/models/object.py @@ -15,8 +15,10 @@ from django.db.models import QuerySet from konova.sub_settings.lanis_settings import DEFAULT_SRID_RLP, LANIS_ZOOM_LUT, LANIS_LINK_TEMPLATE from konova.tasks import celery_send_mail_shared_access_removed, celery_send_mail_shared_access_given, \ celery_send_mail_shared_data_recorded, celery_send_mail_shared_data_unrecorded, \ - celery_send_mail_shared_data_deleted, celery_send_mail_shared_data_checked -from user.models import User + celery_send_mail_shared_data_deleted, celery_send_mail_shared_data_checked, \ + celery_send_mail_shared_access_given_team, celery_send_mail_shared_access_removed_team, \ + celery_send_mail_shared_data_checked_team, celery_send_mail_shared_data_deleted_team, \ + celery_send_mail_shared_data_unrecorded_team, celery_send_mail_shared_data_recorded_team from django.core.exceptions import ObjectDoesNotExist from django.http import HttpRequest from django.utils.timezone import now @@ -28,7 +30,6 @@ from intervention.settings import INTERVENTION_IDENTIFIER_LENGTH, INTERVENTION_I from konova.utils import generators from konova.utils.generators import generate_random_string from konova.utils.message_templates import CHECKED_RECORDED_RESET, GEOMETRY_CONFLICT_WITH_TEMPLATE -from user.models import UserActionLogEntry, UserAction class UuidModel(models.Model): @@ -50,14 +51,14 @@ class BaseResource(UuidModel): A basic resource model, which defines attributes for every derived model """ created = models.ForeignKey( - UserActionLogEntry, + "user.UserActionLogEntry", on_delete=models.SET_NULL, null=True, blank=True, related_name='+' ) modified = models.ForeignKey( - UserActionLogEntry, + "user.UserActionLogEntry", on_delete=models.SET_NULL, null=True, blank=True, @@ -94,9 +95,9 @@ class BaseObject(BaseResource): """ identifier = models.CharField(max_length=1000, null=True, blank=True) title = models.CharField(max_length=1000, null=True, blank=True) - deleted = models.ForeignKey(UserActionLogEntry, on_delete=models.SET_NULL, null=True, blank=True, related_name='+') + deleted = models.ForeignKey("user.UserActionLogEntry", on_delete=models.SET_NULL, null=True, blank=True, related_name='+') comment = models.TextField(null=True, blank=True) - log = models.ManyToManyField(UserActionLogEntry, blank=True, help_text="Keeps all user actions of an object", editable=False) + log = models.ManyToManyField("user.UserActionLogEntry", blank=True, help_text="Keeps all user actions of an object", editable=False) class Meta: abstract = True @@ -105,7 +106,7 @@ class BaseObject(BaseResource): def set_status_messages(self, request: HttpRequest): raise NotImplementedError - def mark_as_deleted(self, user: User, send_mail: bool = True): + def mark_as_deleted(self, user, send_mail: bool = True): """ Mark an entry as deleted Does not delete from database but sets a timestamp for being deleted on and which user deleted the object @@ -116,6 +117,7 @@ class BaseObject(BaseResource): Returns: """ + from user.models import UserActionLogEntry if self.deleted: # Nothing to do here return @@ -131,9 +133,14 @@ class BaseObject(BaseResource): for user_id in shared_users: celery_send_mail_shared_data_deleted.delay(self.identifier, self.title, user_id) + # Send mail + shared_teams = self.shared_teams.values_list("id", flat=True) + for team_id in shared_teams: + celery_send_mail_shared_data_deleted_team.delay(self.identifier, self.title, team_id) + self.save() - def mark_as_edited(self, performing_user: User, request: HttpRequest = None, edit_comment: str = None): + def mark_as_edited(self, performing_user, request: HttpRequest = None, edit_comment: str = None): """ In case the object or a related object changed the log history needs to be updated Args: @@ -144,13 +151,14 @@ class BaseObject(BaseResource): Returns: """ + from user.models import UserActionLogEntry edit_action = UserActionLogEntry.get_edited_action(performing_user, edit_comment) self.modified = edit_action self.log.add(edit_action) self.save() return edit_action - def add_log_entry(self, action: UserAction, user: User, comment: str): + def add_log_entry(self, action, user, comment: str): """ Wraps adding of UserActionLogEntry to log Args: @@ -161,6 +169,7 @@ class BaseObject(BaseResource): Returns: """ + from user.models import UserActionLogEntry user_action = UserActionLogEntry.objects.create( user=user, action=action, @@ -229,7 +238,7 @@ class RecordableObjectMixin(models.Model): """ # Refers to "verzeichnen" recorded = models.OneToOneField( - UserActionLogEntry, + "user.UserActionLogEntry", on_delete=models.SET_NULL, null=True, blank=True, @@ -240,7 +249,7 @@ class RecordableObjectMixin(models.Model): class Meta: abstract = True - def set_unrecorded(self, user: User): + def set_unrecorded(self, user): """ Perform unrecording Args: @@ -249,6 +258,7 @@ class RecordableObjectMixin(models.Model): Returns: """ + from user.models import UserActionLogEntry if not self.recorded: return None action = UserActionLogEntry.get_unrecorded_action(user) @@ -256,13 +266,18 @@ class RecordableObjectMixin(models.Model): self.save() self.log.add(action) - shared_users = self.users.all().values_list("id", flat=True) + shared_users = self.shared_users.values_list("id", flat=True) + shared_teams = self.shared_teams.values_list("id", flat=True) + for user_id in shared_users: celery_send_mail_shared_data_unrecorded.delay(self.identifier, self.title, user_id) + for team_id in shared_teams: + celery_send_mail_shared_data_unrecorded_team.delay(self.identifier, self.title, team_id) + return action - def set_recorded(self, user: User): + def set_recorded(self, user): """ Perform recording Args: @@ -271,6 +286,7 @@ class RecordableObjectMixin(models.Model): Returns: """ + from user.models import UserActionLogEntry if self.recorded: return None action = UserActionLogEntry.get_recorded_action(user) @@ -278,13 +294,18 @@ class RecordableObjectMixin(models.Model): self.save() self.log.add(action) - shared_users = self.users.all().values_list("id", flat=True) + shared_users = self.shared_users.values_list("id", flat=True) + shared_teams = self.shared_teams.values_list("id", flat=True) + for user_id in shared_users: celery_send_mail_shared_data_recorded.delay(self.identifier, self.title, user_id) + for team_id in shared_teams: + celery_send_mail_shared_data_recorded_team.delay(self.identifier, self.title, team_id) + return action - def unrecord(self, performing_user: User, request: HttpRequest = None): + def unrecord(self, performing_user, request: HttpRequest = None): """ Unrecords a dataset Args: @@ -318,7 +339,7 @@ class RecordableObjectMixin(models.Model): class CheckableObjectMixin(models.Model): # Checks - Refers to "Genehmigen" but optional checked = models.OneToOneField( - UserActionLogEntry, + "user.UserActionLogEntry", on_delete=models.SET_NULL, null=True, blank=True, @@ -346,7 +367,7 @@ class CheckableObjectMixin(models.Model): self.save() return None - def set_checked(self, user: User) -> UserActionLogEntry: + def set_checked(self, user): """ Perform checking Args: @@ -355,6 +376,7 @@ class CheckableObjectMixin(models.Model): Returns: """ + from user.models import UserActionLogEntry if self.checked: # Nothing to do return @@ -363,17 +385,23 @@ class CheckableObjectMixin(models.Model): self.save() # Send mail - shared_users = self.users.all().values_list("id", flat=True) + shared_users = self.shared_users.values_list("id", flat=True) for user_id in shared_users: celery_send_mail_shared_data_checked.delay(self.identifier, self.title, user_id) + # Send mail + shared_teams = self.shared_teams.values_list("id", flat=True) + for team_id in shared_teams: + celery_send_mail_shared_data_checked_team.delay(self.identifier, self.title, team_id) + self.log.add(action) return action class ShareableObjectMixin(models.Model): # Users having access on this object - users = models.ManyToManyField(User, help_text="Users having access (data shared with)") + users = models.ManyToManyField("user.User", help_text="Users having access (data shared with)") + teams = models.ManyToManyField("user.Team", help_text="Teams having access (data shared with)") access_token = models.CharField( max_length=255, null=True, @@ -420,7 +448,7 @@ class ShareableObjectMixin(models.Model): self.access_token = token self.save() - def is_shared_with(self, user: User): + def is_shared_with(self, user): """ Access check Checks whether a given user has access to this object @@ -431,9 +459,36 @@ class ShareableObjectMixin(models.Model): Returns: """ - return self.users.filter(id=user.id) + directly_shared = self.users.filter(id=user.id).exists() + team_shared = self.teams.filter( + users__in=[user] + ).exists() + is_shared = directly_shared or team_shared + return is_shared + + def share_with_team(self, team): + """ Adds team to list of shared access teans + + Args: + team (Team): The team to be added to the object + + Returns: - def share_with(self, user: User): + """ + self.teams.add(team) + + def share_with_team_list(self, team_list: list): + """ Sets the list of shared access teams + + Args: + team_list (list): The teams to be added to the object + + Returns: + + """ + self.teams.set(team_list) + + def share_with_user(self, user): """ Adds user to list of shared access users Args: @@ -445,7 +500,7 @@ class ShareableObjectMixin(models.Model): if not self.is_shared_with(user): self.users.add(user) - def share_with_list(self, user_list: list): + def share_with_user_list(self, user_list: list): """ Sets the list of shared access users Args: @@ -456,8 +511,8 @@ class ShareableObjectMixin(models.Model): """ self.users.set(user_list) - def update_sharing_user(self, form): - """ Adds a new user with shared access to the object + def _update_shared_teams(self, form): + """ Updates shared access on the object for teams Args: form (ShareModalForm): The form holding the data @@ -466,25 +521,65 @@ class ShareableObjectMixin(models.Model): """ form_data = form.cleaned_data + shared_teams = self.shared_teams - keep_accessing_users = form_data["users"] - new_accessing_users = list(form_data["user_select"].values_list("id", flat=True)) - accessing_users = keep_accessing_users + new_accessing_users - users = User.objects.filter( - id__in=accessing_users - ) - removed_users = self.users.all().exclude( + # Fetch selected teams and find out which user IDs are in removed teams -> mails need to be sent + accessing_teams = form_data["teams"] + removed_teams = shared_teams.exclude( + id__in=accessing_teams + ).values_list("id", flat=True) + new_teams = accessing_teams.exclude( + id__in=shared_teams + ).values_list("id", flat=True) + + for team_id in new_teams: + celery_send_mail_shared_access_given_team.delay(self.identifier, self.title, team_id) + for team_id in removed_teams: + celery_send_mail_shared_access_removed_team.delay(self.identifier, self.title, team_id) + + self.share_with_team_list(accessing_teams) + + def _update_shared_users(self, form): + """ Updates shared access on the object for single users + + Args: + form (ShareModalForm): The form holding the data + + Returns: + + """ + form_data = form.cleaned_data + shared_users = self.shared_users + + # Fetch selected users + accessing_users = form_data["users"] + removed_users = shared_users.exclude( id__in=accessing_users - ).values("id") + ).values_list("id", flat=True) + new_users = accessing_users.exclude( + id__in=shared_users + ).values_list("id", flat=True) # Send mails - for user in removed_users: - celery_send_mail_shared_access_removed.delay(self.identifier, self.title, user["id"]) - for user in new_accessing_users: - celery_send_mail_shared_access_given.delay(self.identifier, self.title, user) + for user_id in removed_users: + celery_send_mail_shared_access_removed.delay(self.identifier, self.title, user_id) + for user_id in new_users: + celery_send_mail_shared_access_given.delay(self.identifier, self.title, user_id) # Set new shared users - self.share_with_list(users) + self.share_with_user_list(accessing_users) + + def update_shared_access(self, form): + """ Updates shared access on the object + + Args: + form (ShareModalForm): The form holding the data + + Returns: + + """ + self._update_shared_teams(form) + self._update_shared_users(form) @property def shared_users(self) -> QuerySet: @@ -495,6 +590,15 @@ class ShareableObjectMixin(models.Model): """ return self.users.all() + @property + def shared_teams(self) -> QuerySet: + """ Shortcut for fetching the teams which have shared access on this object + + Returns: + teams (QuerySet) + """ + return self.teams.all() + @abstractmethod def get_share_url(self): """ Returns the share url for the object diff --git a/konova/static/css/konova.css b/konova/static/css/konova.css index 709d3eb..7e7f3fd 100644 --- a/konova/static/css/konova.css +++ b/konova/static/css/konova.css @@ -242,15 +242,24 @@ Similar to bootstraps 'shadow-lg' .select2-results__option--highlighted{ background-color: var(--rlp-red) !important; } +/* .select2-container--default .select2-results__group{ background-color: var(--rlp-gray-light); } .select2-container--default .select2-results__option .select2-results__option{ - padding-left: 2em !important; + padding-left: 1em !important; +} + + */ +.select2-results__options--nested{ + padding-left: 1em !important; } .select2-container--default .select2-results > .select2-results__options{ max-height: 500px !important; } +/* .select2-container--default .select2-results__option .select2-results__option{ padding-left: 2em; -} \ No newline at end of file +} + + */ \ No newline at end of file diff --git a/konova/tasks.py b/konova/tasks.py index c74a2bd..798effb 100644 --- a/konova/tasks.py +++ b/konova/tasks.py @@ -38,6 +38,20 @@ def celery_send_mail_shared_access_given(obj_identifier, obj_title=None, user_id user.send_mail_shared_access_given(obj_identifier, obj_title) +@shared_task +def celery_send_mail_shared_access_removed_team(obj_identifier, obj_title=None, team_id=None): + from user.models import Team + team = Team.objects.get(id=team_id) + team.send_mail_shared_access_removed(obj_identifier, obj_title) + + +@shared_task +def celery_send_mail_shared_access_given_team(obj_identifier, obj_title=None, team_id=None): + from user.models import Team + team = Team.objects.get(id=team_id) + team.send_mail_shared_access_given_team(obj_identifier, obj_title) + + @shared_task def celery_send_mail_shared_data_recorded(obj_identifier, obj_title=None, user_id=None): from user.models import User @@ -52,6 +66,20 @@ def celery_send_mail_shared_data_unrecorded(obj_identifier, obj_title=None, user user.send_mail_shared_data_unrecorded(obj_identifier, obj_title) +@shared_task +def celery_send_mail_shared_data_recorded_team(obj_identifier, obj_title=None, team_id=None): + from user.models import Team + team = Team.objects.get(id=team_id) + team.send_mail_shared_data_recorded(obj_identifier, obj_title) + + +@shared_task +def celery_send_mail_shared_data_unrecorded_team(obj_identifier, obj_title=None, team_id=None): + from user.models import Team + team = Team.objects.get(id=team_id) + team.send_mail_shared_data_unrecorded(obj_identifier, obj_title) + + @shared_task def celery_send_mail_shared_data_deleted(obj_identifier, obj_title=None, user_id=None): from user.models import User @@ -64,3 +92,17 @@ def celery_send_mail_shared_data_checked(obj_identifier, obj_title=None, user_id from user.models import User user = User.objects.get(id=user_id) user.send_mail_shared_data_checked(obj_identifier, obj_title) + + +@shared_task +def celery_send_mail_shared_data_deleted_team(obj_identifier, obj_title=None, team_id=None): + from user.models import Team + team = Team.objects.get(id=team_id) + team.send_mail_shared_data_deleted(obj_identifier, obj_title) + + +@shared_task +def celery_send_mail_shared_data_checked_team(obj_identifier, obj_title=None, team_id=None): + from user.models import Team + team = Team.objects.get(id=team_id) + team.send_mail_shared_data_checked(obj_identifier, obj_title) diff --git a/konova/templates/konova/widgets/checkbox-tree-select-content.html b/konova/templates/konova/widgets/checkbox-tree-select-content.html new file mode 100644 index 0000000..9bf5725 --- /dev/null +++ b/konova/templates/konova/widgets/checkbox-tree-select-content.html @@ -0,0 +1,21 @@ +{% load l10n fontawesome_5 %} + +{% for code in codes %} +
+ + {% if not code.is_leaf %} +
+ {% with code.children as codes %} + {% include 'konova/widgets/checkbox-tree-select-content.html' %} + {% endwith %} +
+ {% endif %} +
+{% endfor %} \ No newline at end of file diff --git a/konova/templates/konova/widgets/checkbox-tree-select.html b/konova/templates/konova/widgets/checkbox-tree-select.html new file mode 100644 index 0000000..c2b107c --- /dev/null +++ b/konova/templates/konova/widgets/checkbox-tree-select.html @@ -0,0 +1,68 @@ +{% load i18n %} + +
+ +
+ +
+ {% include 'konova/widgets/checkbox-tree-select-content.html' %} +
+ + \ No newline at end of file diff --git a/konova/tests/test_autocompletes.py b/konova/tests/test_autocompletes.py index 7d9e508..95a3508 100644 --- a/konova/tests/test_autocompletes.py +++ b/konova/tests/test_autocompletes.py @@ -71,6 +71,7 @@ class AutocompleteTestCase(BaseTestCase): "codes-registration-office-autocomplete", "codes-conservation-office-autocomplete", "share-user-autocomplete", + "share-team-autocomplete", ] for test in tests: self.client.login(username=self.superuser.username, password=self.superuser_pw) diff --git a/konova/tests/test_views.py b/konova/tests/test_views.py index 6218a6c..ff99ea3 100644 --- a/konova/tests/test_views.py +++ b/konova/tests/test_views.py @@ -9,7 +9,7 @@ import datetime from codelist.settings import CODELIST_CONSERVATION_OFFICE_ID from ema.models import Ema -from user.models import User +from user.models import User, Team from django.contrib.auth.models import Group from django.contrib.gis.geos import MultiPolygon, Polygon from django.core.exceptions import ObjectDoesNotExist @@ -65,6 +65,7 @@ class BaseTestCase(TestCase): self.create_dummy_states() self.create_dummy_action() self.codes = self.create_dummy_codes() + self.team = self.create_dummy_team() # Set the default group as only group for the user default_group = self.groups.get(name=DEFAULT_GROUP) @@ -251,6 +252,24 @@ class BaseTestCase(TestCase): ]) return codes + def create_dummy_team(self): + """ Creates a dummy team + + Returns: + + """ + if self.superuser is None: + self.create_users() + + team = Team.objects.get_or_create( + name="Testteam", + description="Testdescription", + admin=self.superuser, + )[0] + team.users.add(self.superuser) + + return team + @staticmethod def create_dummy_geometry() -> MultiPolygon: """ Creates some geometry diff --git a/konova/urls.py b/konova/urls.py index 68256e7..1cd8851 100644 --- a/konova/urls.py +++ b/konova/urls.py @@ -20,7 +20,7 @@ from django.urls import path, include from konova.autocompletes import EcoAccountAutocomplete, \ InterventionAutocomplete, CompensationActionCodeAutocomplete, BiotopeCodeAutocomplete, LawCodeAutocomplete, \ RegistrationOfficeCodeAutocomplete, ConservationOfficeCodeAutocomplete, ProcessTypeCodeAutocomplete, \ - ShareUserAutocomplete, BiotopeExtraCodeAutocomplete, CompensationActionDetailCodeAutocomplete + ShareUserAutocomplete, BiotopeExtraCodeAutocomplete, CompensationActionDetailCodeAutocomplete, ShareTeamAutocomplete from konova.settings import SSO_SERVER, SSO_PUBLIC_KEY, SSO_PRIVATE_KEY, DEBUG from konova.sso.sso import KonovaSSOClient from konova.views import logout_view, home_view @@ -52,6 +52,7 @@ urlpatterns = [ path("atcmplt/codes/reg-off", RegistrationOfficeCodeAutocomplete.as_view(), name="codes-registration-office-autocomplete"), path("atcmplt/codes/cons-off", ConservationOfficeCodeAutocomplete.as_view(), name="codes-conservation-office-autocomplete"), path("atcmplt/share/u", ShareUserAutocomplete.as_view(), name="share-user-autocomplete"), + path("atcmplt/share/t", ShareTeamAutocomplete.as_view(), name="share-team-autocomplete"), ] if DEBUG: diff --git a/konova/utils/mailer.py b/konova/utils/mailer.py index 33907f3..dd8eef1 100644 --- a/konova/utils/mailer.py +++ b/konova/utils/mailer.py @@ -92,6 +92,144 @@ class Mailer: msg ) + def send_mail_shared_access_given_team(self, obj_identifier, obj_title, team): + """ Send a mail if a team just got access to the object + + Args: + obj_identifier (str): The object identifier + + Returns: + + """ + context = { + "team": team, + "obj_identifier": obj_identifier, + "obj_title": obj_title, + "EMAIL_REPLY_TO": EMAIL_REPLY_TO, + } + msg = render_to_string("email/sharing/shared_access_given_team.html", context) + user_mail_address = team.users.values_list("email", flat=True) + self.send( + user_mail_address, + _("{} - Shared access given").format(obj_identifier), + msg + ) + + def send_mail_shared_access_removed_team(self, obj_identifier, obj_title, team): + """ Send a mail if a team just lost access to the object + + Args: + obj_identifier (str): The object identifier + + Returns: + + """ + context = { + "team": team, + "obj_identifier": obj_identifier, + "obj_title": obj_title, + "EMAIL_REPLY_TO": EMAIL_REPLY_TO, + } + msg = render_to_string("email/sharing/shared_access_removed_team.html", context) + user_mail_address = team.users.values_list("email", flat=True) + self.send( + user_mail_address, + _("{} - Shared access removed").format(obj_identifier), + msg + ) + + def send_mail_shared_data_unrecorded_team(self, obj_identifier, obj_title, team): + """ Send a mail if data has just been unrecorded + + Args: + obj_identifier (str): The object identifier + + Returns: + + """ + context = { + "team": team, + "obj_identifier": obj_identifier, + "obj_title": obj_title, + "EMAIL_REPLY_TO": EMAIL_REPLY_TO, + } + msg = render_to_string("email/recording/shared_data_unrecorded_team.html", context) + user_mail_address = team.users.values_list("email", flat=True) + self.send( + user_mail_address, + _("{} - Shared data unrecorded").format(obj_identifier), + msg + ) + + def send_mail_shared_data_recorded_team(self, obj_identifier, obj_title, team): + """ Send a mail if data has just been recorded + + Args: + obj_identifier (str): The object identifier + + Returns: + + """ + context = { + "team": team, + "obj_identifier": obj_identifier, + "obj_title": obj_title, + "EMAIL_REPLY_TO": EMAIL_REPLY_TO, + } + msg = render_to_string("email/recording/shared_data_recorded_team.html", context) + user_mail_address = team.users.values_list("email", flat=True) + self.send( + user_mail_address, + _("{} - Shared data recorded").format(obj_identifier), + msg + ) + + def send_mail_shared_data_checked_team(self, obj_identifier, obj_title, team): + """ Send a mail if data has just been checked + + Args: + obj_identifier (str): The object identifier + + Returns: + + """ + context = { + "team": team, + "obj_identifier": obj_identifier, + "obj_title": obj_title, + "EMAIL_REPLY_TO": EMAIL_REPLY_TO, + } + msg = render_to_string("email/checking/shared_data_checked_team.html", context) + user_mail_address = team.users.values_list("email", flat=True) + self.send( + user_mail_address, + _("{} - Shared data checked").format(obj_identifier), + msg + ) + + def send_mail_shared_data_deleted_team(self, obj_identifier, obj_title, team): + """ Send a mail if data has just been deleted + + Args: + obj_identifier (str): The object identifier + + Returns: + + """ + context = { + "team": team, + "obj_identifier": obj_identifier, + "obj_title": obj_title, + "EMAIL_REPLY_TO": EMAIL_REPLY_TO, + } + msg = render_to_string("email/deleting/shared_data_deleted_team.html", context) + user_mail_address = team.users.values_list("email", flat=True) + self.send( + user_mail_address, + _("{} - Shared data deleted").format(obj_identifier), + msg + ) + def send_mail_shared_data_recorded(self, obj_identifier, obj_title, user): """ Send a mail if the user's shared data has just been unrecorded diff --git a/konova/utils/message_templates.py b/konova/utils/message_templates.py index c7a2be0..6c0c614 100644 --- a/konova/utils/message_templates.py +++ b/konova/utils/message_templates.py @@ -7,17 +7,19 @@ Created on: 02.08.21 """ from django.utils.translation import gettext_lazy as _ - +UNGROUPED = _("Ungrouped") FORM_INVALID = _("There was an error on this form.") PARAMS_INVALID = _("Invalid parameters") INTERVENTION_INVALID = _("There are errors in this intervention.") IDENTIFIER_REPLACED = _("The identifier '{}' had to be changed to '{}' since another entry has been added in the meanwhile, which uses this identifier") -DATA_UNSHARED = _("This data is not shared with you") -DATA_UNSHARED_EXPLANATION = _("Remember: This data has not been shared with you, yet. This means you can only read but can not edit or perform any actions like running a check or recording.") +ENTRY_REMOVE_MISSING_PERMISSION = _("Only conservation or registration office users are allowed to remove entries.") MISSING_GROUP_PERMISSION = _("You need to be part of another user group.") - CHECKED_RECORDED_RESET = _("Status of Checked and Recorded reseted") +# SHARE +DATA_UNSHARED = _("This data is not shared with you") +DATA_UNSHARED_EXPLANATION = _("Remember: This data has not been shared with you, yet. This means you can only read but can not edit or perform any actions like running a check or recording.") + # FILES FILE_TYPE_UNSUPPORTED = _("Unsupported file type") FILE_SIZE_TOO_LARGE = _("File too large") diff --git a/konova/views.py b/konova/views.py index 2a4c8ad..f3c53f0 100644 --- a/konova/views.py +++ b/konova/views.py @@ -9,9 +9,12 @@ from django.contrib.auth import logout from django.contrib.auth.decorators import login_required from django.http import HttpRequest, FileResponse from django.shortcuts import redirect, render, get_object_or_404 +from django.template.loader import render_to_string from django.utils import timezone from django.utils.translation import gettext_lazy as _ +from codelist.models import KonovaCode +from codelist.settings import CODELIST_COMPENSATION_ACTION_ID from compensation.models import Compensation, EcoAccount from intervention.models import Intervention from konova.contexts import BaseContext diff --git a/locale/de/LC_MESSAGES/django.mo b/locale/de/LC_MESSAGES/django.mo index 9905835..9437cb9 100644 Binary files a/locale/de/LC_MESSAGES/django.mo and b/locale/de/LC_MESSAGES/django.mo differ diff --git a/locale/de/LC_MESSAGES/django.po b/locale/de/LC_MESSAGES/django.po index cd7cd88..051894c 100644 --- a/locale/de/LC_MESSAGES/django.po +++ b/locale/de/LC_MESSAGES/django.po @@ -3,21 +3,21 @@ # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # -#: compensation/filters.py:122 compensation/forms/modalForms.py:35 -#: compensation/forms/modalForms.py:46 compensation/forms/modalForms.py:62 -#: compensation/forms/modalForms.py:355 compensation/forms/modalForms.py:471 +#: compensation/filters.py:123 compensation/forms/modalForms.py:36 +#: compensation/forms/modalForms.py:47 compensation/forms/modalForms.py:63 +#: compensation/forms/modalForms.py:356 compensation/forms/modalForms.py:463 #: intervention/forms/forms.py:54 intervention/forms/forms.py:156 -#: intervention/forms/forms.py:168 intervention/forms/modalForms.py:127 -#: intervention/forms/modalForms.py:140 intervention/forms/modalForms.py:153 +#: intervention/forms/forms.py:168 intervention/forms/modalForms.py:150 +#: intervention/forms/modalForms.py:163 intervention/forms/modalForms.py:176 #: konova/filters/mixins.py:53 konova/filters/mixins.py:54 #: konova/filters/mixins.py:81 konova/filters/mixins.py:82 #: konova/filters/mixins.py:94 konova/filters/mixins.py:95 #: konova/filters/mixins.py:107 konova/filters/mixins.py:108 #: konova/filters/mixins.py:120 konova/filters/mixins.py:121 #: konova/filters/mixins.py:134 konova/filters/mixins.py:135 -#: konova/filters/mixins.py:270 konova/filters/mixins.py:315 -#: konova/filters/mixins.py:353 konova/filters/mixins.py:354 -#: konova/filters/mixins.py:385 konova/filters/mixins.py:386 +#: konova/filters/mixins.py:270 konova/filters/mixins.py:316 +#: konova/filters/mixins.py:354 konova/filters/mixins.py:355 +#: konova/filters/mixins.py:386 konova/filters/mixins.py:387 #: konova/forms.py:143 konova/forms.py:244 konova/forms.py:315 #: konova/forms.py:359 konova/forms.py:369 konova/forms.py:382 #: konova/forms.py:394 konova/forms.py:412 user/forms.py:42 @@ -26,7 +26,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-02-10 13:31+0100\n" +"POT-Creation-Date: 2022-02-18 14:50+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -45,14 +45,14 @@ msgid "To" msgstr "Bis" #: analysis/forms.py:47 compensation/forms/forms.py:77 -#: compensation/templates/compensation/detail/eco_account/view.html:58 +#: compensation/templates/compensation/detail/eco_account/view.html:59 #: compensation/templates/compensation/report/eco_account/report.html:16 #: compensation/utils/quality.py:100 ema/templates/ema/detail/view.html:49 #: ema/templates/ema/report/report.html:16 ema/utils/quality.py:26 #: intervention/forms/forms.py:102 #: intervention/templates/intervention/detail/view.html:56 #: intervention/templates/intervention/report/report.html:37 -#: intervention/utils/quality.py:49 konova/filters/mixins.py:395 +#: intervention/utils/quality.py:49 konova/filters/mixins.py:396 msgid "Conservation office" msgstr "Eintragungsstelle" @@ -64,6 +64,7 @@ msgstr "Verantwortliche Stelle" #: compensation/forms/forms.py:165 intervention/forms/forms.py:64 #: intervention/forms/forms.py:81 intervention/forms/forms.py:97 #: intervention/forms/forms.py:113 intervention/forms/modalForms.py:49 +#: intervention/forms/modalForms.py:63 user/forms.py:196 msgid "Click for selection" msgstr "Auswählen..." @@ -95,7 +96,7 @@ msgstr "" #: analysis/templates/analysis/reports/includes/eco_account/amount.html:3 #: analysis/templates/analysis/reports/includes/intervention/amount.html:3 #: analysis/templates/analysis/reports/includes/old_data/amount.html:3 -#: compensation/forms/modalForms.py:455 +#: compensation/forms/modalForms.py:447 #: compensation/templates/compensation/detail/eco_account/includes/deductions.html:34 #: intervention/templates/intervention/detail/includes/deductions.html:31 msgid "Amount" @@ -137,7 +138,7 @@ msgstr "Zuständigkeitsbereich" #: analysis/templates/analysis/reports/includes/intervention/compensated_by.html:8 #: analysis/templates/analysis/reports/includes/intervention/laws.html:17 #: compensation/tables.py:40 -#: compensation/templates/compensation/detail/compensation/view.html:63 +#: compensation/templates/compensation/detail/compensation/view.html:64 #: intervention/tables.py:39 #: intervention/templates/intervention/detail/view.html:68 #: user/models/user_action.py:20 @@ -153,9 +154,9 @@ msgstr "Geprüft" #: analysis/templates/analysis/reports/includes/intervention/laws.html:20 #: analysis/templates/analysis/reports/includes/old_data/amount.html:18 #: compensation/tables.py:46 compensation/tables.py:222 -#: compensation/templates/compensation/detail/compensation/view.html:77 +#: compensation/templates/compensation/detail/compensation/view.html:78 #: compensation/templates/compensation/detail/eco_account/includes/deductions.html:31 -#: compensation/templates/compensation/detail/eco_account/view.html:44 +#: compensation/templates/compensation/detail/eco_account/view.html:45 #: ema/tables.py:44 ema/templates/ema/detail/view.html:35 #: intervention/tables.py:45 #: intervention/templates/intervention/detail/view.html:82 @@ -213,14 +214,14 @@ msgstr "Abbuchungen" #: analysis/templates/analysis/reports/includes/eco_account/deductions.html:9 #: analysis/templates/analysis/reports/includes/eco_account/deductions.html:11 -#: compensation/forms/modalForms.py:193 +#: compensation/forms/modalForms.py:194 #: compensation/templates/compensation/detail/compensation/includes/states-after.html:36 #: compensation/templates/compensation/detail/compensation/includes/states-before.html:36 #: compensation/templates/compensation/detail/eco_account/includes/states-after.html:36 #: compensation/templates/compensation/detail/eco_account/includes/states-before.html:36 #: ema/templates/ema/detail/includes/states-after.html:36 #: ema/templates/ema/detail/includes/states-before.html:36 -#: intervention/forms/modalForms.py:338 +#: intervention/forms/modalForms.py:361 msgid "Surface" msgstr "Fläche" @@ -239,14 +240,14 @@ msgstr "Kompensationsart" #: analysis/templates/analysis/reports/includes/intervention/compensated_by.html:15 #: analysis/templates/analysis/reports/includes/old_data/amount.html:29 -#: compensation/templates/compensation/detail/compensation/view.html:19 +#: compensation/templates/compensation/detail/compensation/view.html:20 #: konova/templates/konova/includes/quickstart/compensations.html:4 #: templates/navbars/navbar.html:28 msgid "Compensation" msgstr "Kompensation" #: analysis/templates/analysis/reports/includes/intervention/compensated_by.html:21 -#: compensation/forms/modalForms.py:75 +#: compensation/forms/modalForms.py:76 msgid "Payment" msgstr "Zahlung" @@ -283,8 +284,8 @@ msgid "Type" msgstr "Typ" #: analysis/templates/analysis/reports/includes/old_data/amount.html:24 -#: compensation/tables.py:89 intervention/forms/modalForms.py:349 -#: intervention/forms/modalForms.py:356 intervention/tables.py:88 +#: compensation/tables.py:89 intervention/forms/modalForms.py:372 +#: intervention/forms/modalForms.py:379 intervention/tables.py:88 #: intervention/templates/intervention/detail/view.html:19 #: konova/templates/konova/includes/quickstart/interventions.html:4 #: templates/navbars/navbar.html:22 @@ -293,8 +294,8 @@ msgstr "Eingriff" #: analysis/templates/analysis/reports/includes/old_data/amount.html:34 #: compensation/tables.py:266 -#: compensation/templates/compensation/detail/eco_account/view.html:19 -#: intervention/forms/modalForms.py:322 intervention/forms/modalForms.py:329 +#: compensation/templates/compensation/detail/eco_account/view.html:20 +#: intervention/forms/modalForms.py:345 intervention/forms/modalForms.py:352 #: konova/templates/konova/includes/quickstart/ecoaccounts.html:4 #: templates/navbars/navbar.html:34 msgid "Eco-account" @@ -308,7 +309,7 @@ msgstr "Altfälle" msgid "Before" msgstr "Vor" -#: compensation/filters.py:121 +#: compensation/filters.py:122 msgid "Show only unrecorded" msgstr "Nur unverzeichnete anzeigen" @@ -327,9 +328,9 @@ msgstr "Automatisch generiert" #: compensation/forms/forms.py:44 compensation/tables.py:30 #: compensation/tables.py:202 #: compensation/templates/compensation/detail/compensation/includes/documents.html:28 -#: compensation/templates/compensation/detail/compensation/view.html:31 +#: compensation/templates/compensation/detail/compensation/view.html:32 #: compensation/templates/compensation/detail/eco_account/includes/documents.html:28 -#: compensation/templates/compensation/detail/eco_account/view.html:31 +#: compensation/templates/compensation/detail/eco_account/view.html:32 #: compensation/templates/compensation/report/compensation/report.html:12 #: compensation/templates/compensation/report/eco_account/report.html:12 #: ema/tables.py:34 ema/templates/ema/detail/includes/documents.html:28 @@ -352,8 +353,8 @@ msgstr "Aussagekräftiger Titel" msgid "Compensation XY; Location ABC" msgstr "Kompensation XY; Flur ABC" -#: compensation/forms/forms.py:57 compensation/forms/modalForms.py:61 -#: compensation/forms/modalForms.py:354 compensation/forms/modalForms.py:470 +#: compensation/forms/forms.py:57 compensation/forms/modalForms.py:62 +#: compensation/forms/modalForms.py:355 compensation/forms/modalForms.py:462 #: compensation/templates/compensation/detail/compensation/includes/actions.html:35 #: compensation/templates/compensation/detail/compensation/includes/deadlines.html:34 #: compensation/templates/compensation/detail/compensation/includes/documents.html:34 @@ -363,7 +364,7 @@ msgstr "Kompensation XY; Flur ABC" #: ema/templates/ema/detail/includes/actions.html:34 #: ema/templates/ema/detail/includes/deadlines.html:34 #: ema/templates/ema/detail/includes/documents.html:34 -#: intervention/forms/forms.py:180 intervention/forms/modalForms.py:152 +#: intervention/forms/forms.py:180 intervention/forms/modalForms.py:175 #: intervention/templates/intervention/detail/includes/documents.html:34 #: intervention/templates/intervention/detail/includes/payments.html:34 #: intervention/templates/intervention/detail/includes/revocation.html:38 @@ -371,13 +372,13 @@ msgstr "Kompensation XY; Flur ABC" msgid "Comment" msgstr "Kommentar" -#: compensation/forms/forms.py:59 compensation/forms/modalForms.py:472 +#: compensation/forms/forms.py:59 compensation/forms/modalForms.py:464 #: intervention/forms/forms.py:182 msgid "Additional comment" msgstr "Zusätzlicher Kommentar" #: compensation/forms/forms.py:93 -#: compensation/templates/compensation/detail/eco_account/view.html:62 +#: compensation/templates/compensation/detail/eco_account/view.html:63 #: compensation/templates/compensation/report/eco_account/report.html:20 #: compensation/utils/quality.py:102 ema/templates/ema/detail/view.html:53 #: ema/templates/ema/report/report.html:20 ema/utils/quality.py:28 @@ -422,7 +423,7 @@ msgid "" msgstr "Optional: Handelt es sich um eine Kohärenzsicherungsmaßnahme?" #: compensation/forms/forms.py:156 -#: compensation/templates/compensation/detail/compensation/view.html:35 +#: compensation/templates/compensation/detail/compensation/view.html:36 #: compensation/templates/compensation/report/compensation/report.html:16 msgid "compensates intervention" msgstr "kompensiert Eingriff" @@ -448,7 +449,7 @@ msgid "The amount that can be used for deductions" msgstr "Die für Abbuchungen zur Verfügung stehende Menge" #: compensation/forms/forms.py:328 -#: compensation/templates/compensation/detail/eco_account/view.html:66 +#: compensation/templates/compensation/detail/eco_account/view.html:67 #: compensation/utils/quality.py:72 msgid "Agreement date" msgstr "Vereinbarungsdatum" @@ -469,175 +470,155 @@ msgstr "Ökokonto XY; Flur ABC" msgid "Edit Eco-Account" msgstr "Ökokonto bearbeiten" -#: compensation/forms/modalForms.py:36 +#: compensation/forms/modalForms.py:37 msgid "in Euro" msgstr "in Euro" -#: compensation/forms/modalForms.py:45 +#: compensation/forms/modalForms.py:46 #: intervention/templates/intervention/detail/includes/payments.html:31 msgid "Due on" msgstr "Fällig am" -#: compensation/forms/modalForms.py:48 +#: compensation/forms/modalForms.py:49 msgid "Due on which date" msgstr "Zahlung wird an diesem Datum erwartet" -#: compensation/forms/modalForms.py:63 compensation/forms/modalForms.py:356 -#: intervention/forms/modalForms.py:154 konova/forms.py:395 +#: compensation/forms/modalForms.py:64 compensation/forms/modalForms.py:357 +#: intervention/forms/modalForms.py:177 konova/forms.py:395 msgid "Additional comment, maximum {} letters" msgstr "Zusätzlicher Kommentar, maximal {} Zeichen" -#: compensation/forms/modalForms.py:76 +#: compensation/forms/modalForms.py:77 msgid "Add a payment for intervention '{}'" msgstr "Neue Ersatzzahlung zu Eingriff '{}' hinzufügen" -#: compensation/forms/modalForms.py:96 +#: compensation/forms/modalForms.py:97 msgid "If there is no date you can enter, please explain why." msgstr "Falls Sie kein Datum angeben können, erklären Sie bitte weshalb." -#: compensation/forms/modalForms.py:157 compensation/forms/modalForms.py:169 +#: compensation/forms/modalForms.py:158 compensation/forms/modalForms.py:170 msgid "Biotope Type" msgstr "Biotoptyp" -#: compensation/forms/modalForms.py:160 +#: compensation/forms/modalForms.py:161 msgid "Select the biotope type" msgstr "Biotoptyp wählen" -#: compensation/forms/modalForms.py:174 compensation/forms/modalForms.py:186 +#: compensation/forms/modalForms.py:175 compensation/forms/modalForms.py:187 msgid "Biotope additional type" msgstr "Zusatzbezeichnung" -#: compensation/forms/modalForms.py:177 +#: compensation/forms/modalForms.py:178 msgid "Select an additional biotope type" msgstr "Zusatzbezeichnung wählen" -#: compensation/forms/modalForms.py:196 intervention/forms/modalForms.py:340 +#: compensation/forms/modalForms.py:197 intervention/forms/modalForms.py:363 msgid "in m²" msgstr "" -#: compensation/forms/modalForms.py:207 +#: compensation/forms/modalForms.py:208 msgid "New state" msgstr "Neuer Zustand" -#: compensation/forms/modalForms.py:208 +#: compensation/forms/modalForms.py:209 msgid "Insert data for the new state" msgstr "Geben Sie die Daten des neuen Zustandes ein" -#: compensation/forms/modalForms.py:215 konova/forms.py:193 +#: compensation/forms/modalForms.py:216 konova/forms.py:193 msgid "Object removed" msgstr "Objekt entfernt" -#: compensation/forms/modalForms.py:326 +#: compensation/forms/modalForms.py:327 msgid "Deadline Type" msgstr "Fristart" -#: compensation/forms/modalForms.py:329 +#: compensation/forms/modalForms.py:330 msgid "Select the deadline type" msgstr "Fristart wählen" -#: compensation/forms/modalForms.py:338 +#: compensation/forms/modalForms.py:339 #: compensation/templates/compensation/detail/compensation/includes/deadlines.html:31 #: compensation/templates/compensation/detail/eco_account/includes/deadlines.html:31 #: ema/templates/ema/detail/includes/deadlines.html:31 -#: intervention/forms/modalForms.py:126 +#: intervention/forms/modalForms.py:149 msgid "Date" msgstr "Datum" -#: compensation/forms/modalForms.py:341 +#: compensation/forms/modalForms.py:342 msgid "Select date" msgstr "Datum wählen" -#: compensation/forms/modalForms.py:368 +#: compensation/forms/modalForms.py:369 msgid "New deadline" msgstr "Neue Frist" -#: compensation/forms/modalForms.py:369 +#: compensation/forms/modalForms.py:370 msgid "Insert data for the new deadline" msgstr "Geben Sie die Daten der neuen Frist ein" -#: compensation/forms/modalForms.py:409 +#: compensation/forms/modalForms.py:410 msgid "Action Type" msgstr "Maßnahmentyp" -#: compensation/forms/modalForms.py:412 -msgid "Select the action type" -msgstr "Maßnahmentyp wählen" - -#: compensation/forms/modalForms.py:421 -#: compensation/templates/compensation/detail/compensation/includes/actions.html:40 -#: compensation/templates/compensation/detail/compensation/includes/deadlines.html:39 -#: compensation/templates/compensation/detail/compensation/includes/documents.html:39 -#: compensation/templates/compensation/detail/compensation/includes/states-after.html:41 -#: compensation/templates/compensation/detail/compensation/includes/states-before.html:41 -#: compensation/templates/compensation/detail/eco_account/includes/actions.html:39 -#: compensation/templates/compensation/detail/eco_account/includes/deadlines.html:38 -#: compensation/templates/compensation/detail/eco_account/includes/deductions.html:41 -#: compensation/templates/compensation/detail/eco_account/includes/documents.html:38 -#: compensation/templates/compensation/detail/eco_account/includes/states-after.html:41 -#: compensation/templates/compensation/detail/eco_account/includes/states-before.html:41 -#: ema/templates/ema/detail/includes/actions.html:38 -#: ema/templates/ema/detail/includes/deadlines.html:38 -#: ema/templates/ema/detail/includes/documents.html:38 -#: ema/templates/ema/detail/includes/states-after.html:40 -#: ema/templates/ema/detail/includes/states-before.html:40 -#: intervention/templates/intervention/detail/includes/compensations.html:38 -#: intervention/templates/intervention/detail/includes/deductions.html:39 -#: intervention/templates/intervention/detail/includes/documents.html:39 -#: intervention/templates/intervention/detail/includes/payments.html:39 -#: intervention/templates/intervention/detail/includes/revocation.html:43 -#: templates/log.html:10 -msgid "Action" -msgstr "Aktionen" +#: compensation/forms/modalForms.py:413 +msgid "" +"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." +msgstr "" +"Eine Maßnahme kann aus mehreren verschiedenen Maßnahmentypen bestehen. Alle " +"hier gewählten Einträge sollen mit der weiter unten angegebenen Einheit und " +"Menge umgesetzt werden. " -#: compensation/forms/modalForms.py:426 compensation/forms/modalForms.py:438 +#: compensation/forms/modalForms.py:418 compensation/forms/modalForms.py:430 msgid "Action Type detail" msgstr "Zusatzmerkmal" -#: compensation/forms/modalForms.py:429 +#: compensation/forms/modalForms.py:421 msgid "Select the action type detail" msgstr "Zusatzmerkmal wählen" -#: compensation/forms/modalForms.py:443 +#: compensation/forms/modalForms.py:435 msgid "Unit" msgstr "Einheit" -#: compensation/forms/modalForms.py:446 +#: compensation/forms/modalForms.py:438 msgid "Select the unit" msgstr "Einheit wählen" -#: compensation/forms/modalForms.py:458 +#: compensation/forms/modalForms.py:450 msgid "Insert the amount" msgstr "Menge eingeben" -#: compensation/forms/modalForms.py:483 +#: compensation/forms/modalForms.py:475 msgid "New action" msgstr "Neue Maßnahme" -#: compensation/forms/modalForms.py:484 +#: compensation/forms/modalForms.py:476 msgid "Insert data for the new action" msgstr "Geben Sie die Daten der neuen Maßnahme ein" -#: compensation/models/action.py:22 +#: compensation/models/action.py:20 msgid "cm" msgstr "" -#: compensation/models/action.py:23 +#: compensation/models/action.py:21 msgid "m" msgstr "" -#: compensation/models/action.py:24 +#: compensation/models/action.py:22 msgid "km" msgstr "" -#: compensation/models/action.py:25 +#: compensation/models/action.py:23 msgid "m²" msgstr "" -#: compensation/models/action.py:26 +#: compensation/models/action.py:24 msgid "ha" msgstr "" -#: compensation/models/action.py:27 +#: compensation/models/action.py:25 msgid "Pieces" msgstr "Stück" @@ -685,9 +666,9 @@ msgid "Checked on {} by {}" msgstr "Am {} von {} geprüft worden" #: compensation/tables.py:160 -#: compensation/templates/compensation/detail/compensation/view.html:80 +#: compensation/templates/compensation/detail/compensation/view.html:81 #: compensation/templates/compensation/detail/eco_account/includes/deductions.html:58 -#: compensation/templates/compensation/detail/eco_account/view.html:47 +#: compensation/templates/compensation/detail/eco_account/view.html:48 #: ema/tables.py:131 ema/templates/ema/detail/view.html:38 #: intervention/tables.py:157 #: intervention/templates/intervention/detail/view.html:85 @@ -710,7 +691,7 @@ msgid "Access not granted" msgstr "Nicht freigegeben - Datensatz nur lesbar" #: compensation/tables.py:212 -#: compensation/templates/compensation/detail/eco_account/view.html:35 +#: compensation/templates/compensation/detail/eco_account/view.html:36 #: konova/templates/konova/widgets/progressbar.html:3 msgid "Available" msgstr "Verfügbar" @@ -750,15 +731,46 @@ msgctxt "Compensation" msgid "Amount" msgstr "Menge" -#: compensation/templates/compensation/detail/compensation/includes/actions.html:66 -#: compensation/templates/compensation/detail/eco_account/includes/actions.html:65 -#: ema/templates/ema/detail/includes/actions.html:63 +#: compensation/templates/compensation/detail/compensation/includes/actions.html:40 +#: compensation/templates/compensation/detail/compensation/includes/deadlines.html:39 +#: compensation/templates/compensation/detail/compensation/includes/documents.html:39 +#: compensation/templates/compensation/detail/compensation/includes/states-after.html:41 +#: compensation/templates/compensation/detail/compensation/includes/states-before.html:41 +#: compensation/templates/compensation/detail/eco_account/includes/actions.html:39 +#: compensation/templates/compensation/detail/eco_account/includes/deadlines.html:38 +#: compensation/templates/compensation/detail/eco_account/includes/deductions.html:41 +#: compensation/templates/compensation/detail/eco_account/includes/documents.html:38 +#: compensation/templates/compensation/detail/eco_account/includes/states-after.html:41 +#: compensation/templates/compensation/detail/eco_account/includes/states-before.html:41 +#: ema/templates/ema/detail/includes/actions.html:38 +#: ema/templates/ema/detail/includes/deadlines.html:38 +#: ema/templates/ema/detail/includes/documents.html:38 +#: ema/templates/ema/detail/includes/states-after.html:40 +#: ema/templates/ema/detail/includes/states-before.html:40 +#: intervention/templates/intervention/detail/includes/compensations.html:38 +#: intervention/templates/intervention/detail/includes/deductions.html:39 +#: intervention/templates/intervention/detail/includes/documents.html:39 +#: intervention/templates/intervention/detail/includes/payments.html:39 +#: intervention/templates/intervention/detail/includes/revocation.html:43 +#: templates/log.html:10 user/templates/user/team/index.html:32 +msgid "Action" +msgstr "Aktionen" + +#: compensation/templates/compensation/detail/compensation/includes/actions.html:57 +#: compensation/templates/compensation/detail/eco_account/includes/actions.html:56 +#: ema/templates/ema/detail/includes/actions.html:54 +msgid "No action type details" +msgstr "Keine Zusatzmerkmale" + +#: compensation/templates/compensation/detail/compensation/includes/actions.html:68 +#: compensation/templates/compensation/detail/eco_account/includes/actions.html:67 +#: ema/templates/ema/detail/includes/actions.html:65 msgid "Edit action" msgstr "Maßnahme bearbeiten" -#: compensation/templates/compensation/detail/compensation/includes/actions.html:69 -#: compensation/templates/compensation/detail/eco_account/includes/actions.html:68 -#: ema/templates/ema/detail/includes/actions.html:66 +#: compensation/templates/compensation/detail/compensation/includes/actions.html:71 +#: compensation/templates/compensation/detail/eco_account/includes/actions.html:70 +#: ema/templates/ema/detail/includes/actions.html:68 msgid "Remove action" msgstr "Maßnahme entfernen" @@ -887,6 +899,15 @@ msgstr "Fehlende Flächenmengen laut Ausgangszustand: " msgid "Biotope type" msgstr "Biotoptyp" +#: compensation/templates/compensation/detail/compensation/includes/states-after.html:56 +#: compensation/templates/compensation/detail/compensation/includes/states-before.html:56 +#: compensation/templates/compensation/detail/eco_account/includes/states-after.html:56 +#: compensation/templates/compensation/detail/eco_account/includes/states-before.html:56 +#: ema/templates/ema/detail/includes/states-after.html:54 +#: ema/templates/ema/detail/includes/states-before.html:54 +msgid "No biotope type details" +msgstr "Keine Zusatzbezeichnungen" + #: compensation/templates/compensation/detail/compensation/includes/states-after.html:62 #: compensation/templates/compensation/detail/compensation/includes/states-before.html:62 #: compensation/templates/compensation/detail/eco_account/includes/states-after.html:62 @@ -924,50 +945,50 @@ msgstr "Neuen Ausgangszustand hinzufügen" msgid "Missing surfaces according to states after: " msgstr "Fehlende Flächenmengen laut Zielzustand: " -#: compensation/templates/compensation/detail/compensation/view.html:43 +#: compensation/templates/compensation/detail/compensation/view.html:44 msgid "Is CEF compensation" msgstr "Ist CEF Maßnahme" -#: compensation/templates/compensation/detail/compensation/view.html:46 -#: compensation/templates/compensation/detail/compensation/view.html:56 +#: compensation/templates/compensation/detail/compensation/view.html:47 +#: compensation/templates/compensation/detail/compensation/view.html:57 #: venv/lib/python3.7/site-packages/django/forms/widgets.py:710 msgid "Yes" msgstr "Ja" -#: compensation/templates/compensation/detail/compensation/view.html:48 -#: compensation/templates/compensation/detail/compensation/view.html:58 +#: compensation/templates/compensation/detail/compensation/view.html:49 +#: compensation/templates/compensation/detail/compensation/view.html:59 #: venv/lib/python3.7/site-packages/django/forms/widgets.py:711 msgid "No" msgstr "Nein" -#: compensation/templates/compensation/detail/compensation/view.html:53 +#: compensation/templates/compensation/detail/compensation/view.html:54 msgid "Is Coherence keeping compensation" msgstr "Ist Kohärenzsicherungsmaßnahme" -#: compensation/templates/compensation/detail/compensation/view.html:70 +#: compensation/templates/compensation/detail/compensation/view.html:71 #: intervention/templates/intervention/detail/view.html:75 msgid "Checked on " msgstr "Geprüft am " -#: compensation/templates/compensation/detail/compensation/view.html:70 -#: compensation/templates/compensation/detail/compensation/view.html:84 +#: compensation/templates/compensation/detail/compensation/view.html:71 +#: compensation/templates/compensation/detail/compensation/view.html:85 #: compensation/templates/compensation/detail/eco_account/includes/deductions.html:56 -#: compensation/templates/compensation/detail/eco_account/view.html:51 +#: compensation/templates/compensation/detail/eco_account/view.html:52 #: ema/templates/ema/detail/view.html:42 #: intervention/templates/intervention/detail/view.html:75 #: intervention/templates/intervention/detail/view.html:89 msgid "by" msgstr "von" -#: compensation/templates/compensation/detail/compensation/view.html:84 -#: compensation/templates/compensation/detail/eco_account/view.html:51 +#: compensation/templates/compensation/detail/compensation/view.html:85 +#: compensation/templates/compensation/detail/eco_account/view.html:52 #: ema/templates/ema/detail/view.html:42 #: intervention/templates/intervention/detail/view.html:89 msgid "Recorded on " msgstr "Verzeichnet am" -#: compensation/templates/compensation/detail/compensation/view.html:91 -#: compensation/templates/compensation/detail/eco_account/view.html:74 +#: compensation/templates/compensation/detail/compensation/view.html:92 +#: compensation/templates/compensation/detail/eco_account/view.html:75 #: compensation/templates/compensation/report/compensation/report.html:24 #: compensation/templates/compensation/report/eco_account/report.html:41 #: ema/templates/ema/detail/view.html:61 @@ -977,16 +998,16 @@ msgstr "Verzeichnet am" msgid "Last modified" msgstr "Zuletzt bearbeitet" -#: compensation/templates/compensation/detail/compensation/view.html:99 -#: compensation/templates/compensation/detail/eco_account/view.html:82 -#: ema/templates/ema/detail/view.html:76 intervention/forms/modalForms.py:56 +#: compensation/templates/compensation/detail/compensation/view.html:100 +#: compensation/templates/compensation/detail/eco_account/view.html:83 +#: ema/templates/ema/detail/view.html:76 #: intervention/templates/intervention/detail/view.html:116 msgid "Shared with" msgstr "Freigegeben für" #: compensation/templates/compensation/detail/eco_account/includes/controls.html:15 #: ema/templates/ema/detail/includes/controls.html:15 -#: intervention/forms/modalForms.py:70 +#: intervention/forms/modalForms.py:71 #: intervention/templates/intervention/detail/includes/controls.html:15 msgid "Share" msgstr "Freigabe" @@ -1037,14 +1058,14 @@ msgstr "Abbuchung bearbeiten" msgid "Remove Deduction" msgstr "Abbuchung entfernen" -#: compensation/templates/compensation/detail/eco_account/view.html:34 +#: compensation/templates/compensation/detail/eco_account/view.html:35 msgid "No surface deductable" msgstr "Keine Flächenmenge für Abbuchungen eingegeben. Bitte bearbeiten." -#: compensation/templates/compensation/detail/eco_account/view.html:57 -#: compensation/templates/compensation/detail/eco_account/view.html:61 -#: compensation/templates/compensation/detail/eco_account/view.html:65 -#: compensation/templates/compensation/detail/eco_account/view.html:69 +#: compensation/templates/compensation/detail/eco_account/view.html:58 +#: compensation/templates/compensation/detail/eco_account/view.html:62 +#: compensation/templates/compensation/detail/eco_account/view.html:66 +#: compensation/templates/compensation/detail/eco_account/view.html:70 #: ema/templates/ema/detail/view.html:48 ema/templates/ema/detail/view.html:52 #: ema/templates/ema/detail/view.html:56 #: intervention/templates/intervention/detail/view.html:30 @@ -1060,7 +1081,7 @@ msgstr "Keine Flächenmenge für Abbuchungen eingegeben. Bitte bearbeiten." msgid "Missing" msgstr "fehlt" -#: compensation/templates/compensation/detail/eco_account/view.html:70 +#: compensation/templates/compensation/detail/eco_account/view.html:71 #: compensation/templates/compensation/report/eco_account/report.html:24 #: ema/templates/ema/detail/view.html:57 #: ema/templates/ema/report/report.html:24 @@ -1121,7 +1142,7 @@ msgstr "Daten zu den verantwortlichen Stellen" msgid "Compensations - Overview" msgstr "Kompensationen - Übersicht" -#: compensation/views/compensation.py:151 konova/utils/message_templates.py:31 +#: compensation/views/compensation.py:151 konova/utils/message_templates.py:33 msgid "Compensation {} edited" msgstr "Kompensation {} bearbeitet" @@ -1130,12 +1151,12 @@ msgstr "Kompensation {} bearbeitet" msgid "Edit {}" msgstr "Bearbeite {}" -#: compensation/views/compensation.py:240 compensation/views/eco_account.py:349 +#: compensation/views/compensation.py:240 compensation/views/eco_account.py:351 #: ema/views.py:194 intervention/views.py:531 msgid "Log" msgstr "Log" -#: compensation/views/compensation.py:584 compensation/views/eco_account.py:716 +#: compensation/views/compensation.py:584 compensation/views/eco_account.py:719 #: ema/views.py:551 intervention/views.py:677 msgid "Report {}" msgstr "Bericht {}" @@ -1156,32 +1177,32 @@ msgstr "Ökokonto {} bearbeitet" msgid "Eco-account removed" msgstr "Ökokonto entfernt" -#: compensation/views/eco_account.py:370 ema/views.py:275 +#: compensation/views/eco_account.py:372 ema/views.py:275 #: intervention/views.py:630 msgid "{} unrecorded" msgstr "{} entzeichnet" -#: compensation/views/eco_account.py:370 ema/views.py:275 +#: compensation/views/eco_account.py:372 ema/views.py:275 #: intervention/views.py:630 msgid "{} recorded" msgstr "{} verzeichnet" -#: compensation/views/eco_account.py:789 ema/views.py:617 +#: compensation/views/eco_account.py:792 ema/views.py:617 #: intervention/views.py:428 msgid "{} has already been shared with you" msgstr "{} wurde bereits für Sie freigegeben" -#: compensation/views/eco_account.py:794 ema/views.py:622 +#: compensation/views/eco_account.py:797 ema/views.py:622 #: intervention/views.py:433 msgid "{} has been shared with you" msgstr "{} ist nun für Sie freigegeben" -#: compensation/views/eco_account.py:801 ema/views.py:629 +#: compensation/views/eco_account.py:804 ema/views.py:629 #: intervention/views.py:440 msgid "Share link invalid" msgstr "Freigabelink ungültig" -#: compensation/views/eco_account.py:824 ema/views.py:652 +#: compensation/views/eco_account.py:827 ema/views.py:652 #: intervention/views.py:463 msgid "Share settings updated" msgstr "Freigabe Einstellungen aktualisiert" @@ -1248,7 +1269,7 @@ msgstr "Mehrfachauswahl möglich" #: intervention/forms/forms.py:86 #: intervention/templates/intervention/detail/view.html:48 #: intervention/templates/intervention/report/report.html:29 -#: intervention/utils/quality.py:46 konova/filters/mixins.py:363 +#: intervention/utils/quality.py:46 konova/filters/mixins.py:364 msgid "Registration office" msgstr "Zulassungsbehörde" @@ -1301,60 +1322,68 @@ msgstr "Freigabelink" #: intervention/forms/modalForms.py:31 msgid "Send this link to users who you want to have writing access on the data" -msgstr "Andere Nutzer erhalten über diesen Link Zugriff auf die Daten" +msgstr "Einzelne Nutzer erhalten über diesen Link Zugriff auf die Daten" #: intervention/forms/modalForms.py:41 -msgid "Add user to share with" -msgstr "Nutzer direkt hinzufügen" +msgid "Add team to share with" +msgstr "Team hinzufügen" #: intervention/forms/modalForms.py:43 msgid "" +"Multiple selection possible - You can only select teams which do not already " +"have access." +msgstr "" +"Mehrfachauswahl möglich - Sie können nur Teams wählen, für die der Eintrag " +"noch nicht freigegeben wurde." + +#: intervention/forms/modalForms.py:55 +msgid "Add user to share with" +msgstr "Nutzer einzeln hinzufügen" + +#: intervention/forms/modalForms.py:57 +msgid "" "Multiple selection possible - You can only select users which do not already " "have access. Enter the full username." msgstr "" "Mehrfachauswahl möglich - Sie können nur Nutzer wählen, für die der Eintrag " "noch nicht freigegeben wurde. Geben Sie den ganzen Nutzernamen an." -#: intervention/forms/modalForms.py:59 -msgid "Remove check to remove access for this user" -msgstr "Wählen Sie die Nutzer ab, die keinen Zugriff mehr haben sollen" - -#: intervention/forms/modalForms.py:71 +#: intervention/forms/modalForms.py:72 msgid "Share settings for {}" msgstr "Freigabe Einstellungen für {}" -#: intervention/forms/modalForms.py:128 +#: intervention/forms/modalForms.py:151 msgid "Date of revocation" msgstr "Datum des Widerspruchs" -#: intervention/forms/modalForms.py:139 +#: intervention/forms/modalForms.py:162 #: intervention/templates/intervention/detail/includes/revocation.html:35 msgid "Document" msgstr "Dokument" -#: intervention/forms/modalForms.py:142 +#: intervention/forms/modalForms.py:165 msgid "Must be smaller than 15 Mb" msgstr "Muss kleiner als 15 Mb sein" -#: intervention/forms/modalForms.py:167 +#: intervention/forms/modalForms.py:190 #: intervention/templates/intervention/detail/includes/revocation.html:18 msgid "Add revocation" msgstr "Widerspruch hinzufügen" -#: intervention/forms/modalForms.py:224 +#: intervention/forms/modalForms.py:247 msgid "Checked intervention data" msgstr "Eingriffsdaten geprüft" -#: intervention/forms/modalForms.py:230 +#: intervention/forms/modalForms.py:253 msgid "Checked compensations data and payments" msgstr "Kompensationen und Zahlungen geprüft" -#: intervention/forms/modalForms.py:239 +#: intervention/forms/modalForms.py:262 #: intervention/templates/intervention/detail/includes/controls.html:19 msgid "Run check" msgstr "Prüfung vornehmen" -#: intervention/forms/modalForms.py:240 konova/forms.py:514 +#: intervention/forms/modalForms.py:263 konova/forms.py:514 msgid "" "I, {} {}, confirm that all necessary control steps have been performed by " "myself." @@ -1362,23 +1391,23 @@ msgstr "" "Ich, {} {}, bestätige, dass die notwendigen Kontrollschritte durchgeführt " "wurden:" -#: intervention/forms/modalForms.py:324 +#: intervention/forms/modalForms.py:347 msgid "Only recorded accounts can be selected for deductions" msgstr "Nur verzeichnete Ökokonten können für Abbuchungen verwendet werden." -#: intervention/forms/modalForms.py:351 +#: intervention/forms/modalForms.py:374 msgid "Only shared interventions can be selected" msgstr "Nur freigegebene Eingriffe können gewählt werden" -#: intervention/forms/modalForms.py:364 +#: intervention/forms/modalForms.py:387 msgid "New Deduction" msgstr "Neue Abbuchung" -#: intervention/forms/modalForms.py:365 +#: intervention/forms/modalForms.py:388 msgid "Enter the information for a new deduction from a chosen eco-account" msgstr "Geben Sie die Informationen für eine neue Abbuchung ein." -#: intervention/forms/modalForms.py:408 +#: intervention/forms/modalForms.py:431 msgid "" "Eco-account {} is not recorded yet. You can only deduct from recorded " "accounts." @@ -1386,7 +1415,7 @@ msgstr "" "Ökokonto {} ist noch nicht verzeichnet. Abbuchungen können nur von " "verzeichneten Ökokonten erfolgen." -#: intervention/forms/modalForms.py:418 +#: intervention/forms/modalForms.py:441 msgid "" "The account {} has not enough surface for a deduction of {} m². There are " "only {} m² left" @@ -1581,15 +1610,15 @@ msgstr "Nach Flurstücknenner suchen" msgid "Show unshared" msgstr "Nicht freigegebene anzeigen" -#: konova/filters/mixins.py:314 +#: konova/filters/mixins.py:315 msgid "Show recorded" msgstr "Verzeichnete anzeigen" -#: konova/filters/mixins.py:364 +#: konova/filters/mixins.py:365 msgid "Search for registration office" msgstr "Nach Zulassungsbehörde suchen" -#: konova/filters/mixins.py:396 +#: konova/filters/mixins.py:397 msgid "Search for conservation office" msgstr "Nch Eintragungsstelle suchen" @@ -1746,6 +1775,11 @@ msgstr "Neu" msgid "Show" msgstr "Anzeigen" +#: konova/templates/konova/widgets/checkbox-tree-select.html:4 +#: templates/generic_index.html:56 +msgid "Search" +msgstr "Suchen" + #: konova/templates/konova/widgets/generate-content-input.html:6 msgid "Generate new" msgstr "Neu generieren" @@ -1762,30 +1796,34 @@ msgstr "In Zwischenablage kopiert" msgid "{} - Shared access removed" msgstr "{} - Zugriff entzogen" -#: konova/utils/mailer.py:91 +#: konova/utils/mailer.py:91 konova/utils/mailer.py:114 msgid "{} - Shared access given" msgstr "{} - Zugriff freigegeben" -#: konova/utils/mailer.py:114 +#: konova/utils/mailer.py:137 msgid "{} - Shared data recorded" msgstr "{} - Freigegebene Daten verzeichnet" -#: konova/utils/mailer.py:137 +#: konova/utils/mailer.py:160 msgid "{} - Shared data unrecorded" msgstr "{} - Freigegebene Daten entzeichnet" -#: konova/utils/mailer.py:160 +#: konova/utils/mailer.py:183 msgid "{} - Shared data deleted" msgstr "{} - Freigegebene Daten gelöscht" -#: konova/utils/mailer.py:183 +#: konova/utils/mailer.py:206 msgid "{} - Shared data checked" msgstr "{} - Freigegebene Daten geprüft" -#: konova/utils/mailer.py:204 templates/email/api/verify_token.html:4 +#: konova/utils/mailer.py:227 templates/email/api/verify_token.html:4 msgid "Request for new API token" msgstr "Anfrage für neuen API Token" +#: konova/utils/message_templates.py:10 +msgid "Ungrouped" +msgstr "Ohne Zuordnung" + #: konova/utils/message_templates.py:11 msgid "There was an error on this form." msgstr "Es gab einen Fehler im Formular." @@ -1807,10 +1845,25 @@ msgstr "" "der Zwischenzeit angelegt wurde, welcher diese Kennung nun bereits verwendet" #: konova/utils/message_templates.py:15 +msgid "" +"Only conservation or registration office users are allowed to remove entries." +msgstr "" +"Nur Mitarbeiter der Naturschutz- oder Zulassungsbehördengruppe dürfen " +"Einträge entfernen" + +#: konova/utils/message_templates.py:16 +msgid "You need to be part of another user group." +msgstr "Hierfür müssen Sie einer anderen Nutzergruppe angehören!" + +#: konova/utils/message_templates.py:17 +msgid "Status of Checked and Recorded reseted" +msgstr "'Geprüft'/'Verzeichnet' wurde zurückgesetzt" + +#: konova/utils/message_templates.py:20 msgid "This data is not shared with you" msgstr "Diese Daten sind für Sie nicht freigegeben" -#: konova/utils/message_templates.py:16 +#: konova/utils/message_templates.py:21 msgid "" "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 " @@ -1820,23 +1873,15 @@ msgstr "" "bedeutet, dass Sie nur lesenden Zugriff hierauf haben und weder bearbeiten, " "noch Prüfungen durchführen oder verzeichnen können." -#: konova/utils/message_templates.py:17 -msgid "You need to be part of another user group." -msgstr "Hierfür müssen Sie einer anderen Nutzergruppe angehören!" - -#: konova/utils/message_templates.py:19 -msgid "Status of Checked and Recorded reseted" -msgstr "'Geprüft'/'Verzeichnet' wurde zurückgesetzt" - -#: konova/utils/message_templates.py:22 +#: konova/utils/message_templates.py:24 msgid "Unsupported file type" msgstr "Dateiformat nicht unterstützt" -#: konova/utils/message_templates.py:23 +#: konova/utils/message_templates.py:25 msgid "File too large" msgstr "Datei zu groß" -#: konova/utils/message_templates.py:26 +#: konova/utils/message_templates.py:28 msgid "" "Action canceled. Eco account is recorded or deductions exist. Only " "conservation office member can perform this action." @@ -1844,119 +1889,119 @@ msgstr "" "Aktion abgebrochen. Ökokonto ist bereits verzeichnet oder Abbuchungen liegen " "vor. Nur Eintragungsstellennutzer können diese Aktion jetzt durchführen." -#: konova/utils/message_templates.py:29 +#: konova/utils/message_templates.py:31 msgid "Compensation {} added" msgstr "Kompensation {} hinzugefügt" -#: konova/utils/message_templates.py:30 +#: konova/utils/message_templates.py:32 msgid "Compensation {} removed" msgstr "Kompensation {} entfernt" -#: konova/utils/message_templates.py:32 +#: konova/utils/message_templates.py:34 msgid "Added compensation action" msgstr "Maßnahme hinzugefügt" -#: konova/utils/message_templates.py:33 +#: konova/utils/message_templates.py:35 msgid "Added compensation state" msgstr "Zustand hinzugefügt" -#: konova/utils/message_templates.py:36 +#: konova/utils/message_templates.py:38 msgid "State removed" msgstr "Zustand gelöscht" -#: konova/utils/message_templates.py:37 +#: konova/utils/message_templates.py:39 msgid "State edited" msgstr "Zustand bearbeitet" -#: konova/utils/message_templates.py:38 +#: konova/utils/message_templates.py:40 msgid "State added" msgstr "Zustand hinzugefügt" -#: konova/utils/message_templates.py:41 +#: konova/utils/message_templates.py:43 msgid "Action added" msgstr "Maßnahme hinzugefügt" -#: konova/utils/message_templates.py:42 +#: konova/utils/message_templates.py:44 msgid "Action edited" msgstr "Maßnahme bearbeitet" -#: konova/utils/message_templates.py:43 +#: konova/utils/message_templates.py:45 msgid "Action removed" msgstr "Maßnahme entfernt" -#: konova/utils/message_templates.py:46 +#: konova/utils/message_templates.py:48 msgid "Deduction added" msgstr "Abbuchung hinzugefügt" -#: konova/utils/message_templates.py:47 +#: konova/utils/message_templates.py:49 msgid "Deduction edited" msgstr "Abbuchung bearbeitet" -#: konova/utils/message_templates.py:48 +#: konova/utils/message_templates.py:50 msgid "Deduction removed" msgstr "Abbuchung entfernt" -#: konova/utils/message_templates.py:51 +#: konova/utils/message_templates.py:53 msgid "Deadline added" msgstr "Frist/Termin hinzugefügt" -#: konova/utils/message_templates.py:52 +#: konova/utils/message_templates.py:54 msgid "Deadline edited" msgstr "Frist/Termin bearbeitet" -#: konova/utils/message_templates.py:53 +#: konova/utils/message_templates.py:55 msgid "Deadline removed" msgstr "Frist/Termin gelöscht" -#: konova/utils/message_templates.py:56 +#: konova/utils/message_templates.py:58 msgid "Payment added" msgstr "Zahlung hinzugefügt" -#: konova/utils/message_templates.py:57 +#: konova/utils/message_templates.py:59 msgid "Payment edited" msgstr "Zahlung bearbeitet" -#: konova/utils/message_templates.py:58 +#: konova/utils/message_templates.py:60 msgid "Payment removed" msgstr "Zahlung gelöscht" -#: konova/utils/message_templates.py:61 +#: konova/utils/message_templates.py:63 msgid "Revocation added" msgstr "Widerspruch hinzugefügt" -#: konova/utils/message_templates.py:62 +#: konova/utils/message_templates.py:64 msgid "Revocation edited" msgstr "Widerspruch bearbeitet" -#: konova/utils/message_templates.py:63 +#: konova/utils/message_templates.py:65 msgid "Revocation removed" msgstr "Widerspruch entfernt" -#: konova/utils/message_templates.py:66 +#: konova/utils/message_templates.py:68 msgid "Document '{}' deleted" msgstr "Dokument '{}' gelöscht" -#: konova/utils/message_templates.py:67 +#: konova/utils/message_templates.py:69 msgid "Document added" msgstr "Dokument hinzugefügt" -#: konova/utils/message_templates.py:68 +#: konova/utils/message_templates.py:70 msgid "Document edited" msgstr "Dokument bearbeitet" -#: konova/utils/message_templates.py:71 +#: konova/utils/message_templates.py:73 msgid "Edited general data" msgstr "Allgemeine Daten bearbeitet" -#: konova/utils/message_templates.py:72 +#: konova/utils/message_templates.py:74 msgid "Added deadline" msgstr "Frist/Termin hinzugefügt" -#: konova/utils/message_templates.py:75 +#: konova/utils/message_templates.py:77 msgid "Geometry conflict detected with {}" msgstr "Geometriekonflikt mit folgenden Einträgen erkannt: {}" -#: konova/utils/message_templates.py:78 +#: konova/utils/message_templates.py:80 msgid "This intervention has {} revocations" msgstr "Dem Eingriff liegen {} Widersprüche vor" @@ -1976,7 +2021,7 @@ msgstr "{} wurde erfolgreich vom Nutzer {} geprüft! {}" msgid "missing" msgstr "fehlt" -#: konova/views.py:96 templates/navbars/navbar.html:16 +#: konova/views.py:99 templates/navbars/navbar.html:16 msgid "Home" msgstr "Home" @@ -2047,7 +2092,9 @@ msgstr "" #: templates/email/recording/shared_data_recorded.html:19 #: templates/email/recording/shared_data_unrecorded.html:19 #: templates/email/sharing/shared_access_given.html:20 +#: templates/email/sharing/shared_access_given_team.html:20 #: templates/email/sharing/shared_access_removed.html:20 +#: templates/email/sharing/shared_access_removed_team.html:20 msgid "Best regards" msgstr "Beste Grüße" @@ -2134,6 +2181,7 @@ msgstr "" "zugehörigen Kompensationen automatisch entzeichnet worden sind." #: templates/email/sharing/shared_access_given.html:4 +#: templates/email/sharing/shared_access_given_team.html:4 msgid "Access shared" msgstr "Zugriff freigegeben" @@ -2142,10 +2190,12 @@ msgid "the following dataset has just been shared with you" msgstr "der folgende Datensatz wurde soeben für Sie freigegeben " #: templates/email/sharing/shared_access_given.html:16 +#: templates/email/sharing/shared_access_given_team.html:16 msgid "This means you can now edit this dataset." msgstr "Das bedeutet, dass Sie diesen Datensatz nun auch bearbeiten können." #: templates/email/sharing/shared_access_given.html:17 +#: templates/email/sharing/shared_access_given_team.html:17 msgid "" "The shared dataset appears now by default on your overview for this dataset " "type." @@ -2154,6 +2204,7 @@ msgstr "" "Datensatztyp im KSP gelistet." #: templates/email/sharing/shared_access_given.html:27 +#: templates/email/sharing/shared_access_given_team.html:27 msgid "" "Please note: Shared access on an intervention means you automatically have " "editing access to related compensations." @@ -2162,7 +2213,17 @@ msgstr "" "Sie automatisch auch Zugriff auf die zugehörigen Kompensationen erhalten " "haben." +#: templates/email/sharing/shared_access_given_team.html:8 +#: templates/email/sharing/shared_access_removed_team.html:8 +msgid "Hello team" +msgstr "Hallo Team" + +#: templates/email/sharing/shared_access_given_team.html:10 +msgid "the following dataset has just been shared with your team" +msgstr "der folgende Datensatz wurde soeben für Ihr Team freigegeben " + #: templates/email/sharing/shared_access_removed.html:4 +#: templates/email/sharing/shared_access_removed_team.html:4 msgid "Shared access removed" msgstr "Freigegebener Zugriff entzogen" @@ -2174,10 +2235,12 @@ msgstr "" "entzogen: " #: templates/email/sharing/shared_access_removed.html:16 +#: templates/email/sharing/shared_access_removed_team.html:16 msgid "However, you are still able to view the dataset content." msgstr "Sie können den Datensatz aber immer noch im KSP einsehen." #: templates/email/sharing/shared_access_removed.html:17 +#: templates/email/sharing/shared_access_removed_team.html:17 msgid "" "Please use the provided search filter on the dataset`s overview pages to " "find them." @@ -2185,6 +2248,14 @@ msgstr "" "Nutzen Sie hierzu einfach die entsprechenden Suchfilter auf den " "Übersichtsseiten" +#: templates/email/sharing/shared_access_removed_team.html:10 +msgid "" +"your teams shared access, including editing, has been revoked for the " +"dataset " +msgstr "" +"Ihrem Team wurde soeben der bearbeitende Zugriff auf den folgenden Datensatz " +"entzogen: " + #: templates/email/signature.html:6 msgid "Please do not reply on this mail." msgstr "Bitte antworten Sie nicht auf diese Mail." @@ -2243,7 +2314,7 @@ msgstr "* sind Pflichtfelder." msgid "New entry" msgstr "Neuer Eintrag" -#: templates/generic_index.html:41 +#: templates/generic_index.html:41 user/templates/user/team/index.html:22 msgid "New" msgstr "Neu" @@ -2251,10 +2322,6 @@ msgstr "Neu" msgid "Search for keywords" msgstr "Nach Schlagwörtern suchen" -#: templates/generic_index.html:56 -msgid "Search" -msgstr "Suchen" - #: templates/generic_index.html:57 msgid "Start search" msgstr "Starte Suche" @@ -2376,6 +2443,62 @@ msgstr "Neuen Token generieren" msgid "A new token needs to be validated by an administrator!" msgstr "Neue Tokens müssen durch Administratoren freigeschaltet werden!" +#: user/forms.py:168 user/forms.py:172 user/forms.py:323 user/forms.py:328 +msgid "Team name" +msgstr "Team Name" + +#: user/forms.py:179 user/forms.py:336 user/templates/user/team/index.html:30 +msgid "Description" +msgstr "Beschreibung" + +#: user/forms.py:188 +msgid "Manage team members" +msgstr "Mitglieder verwalten" + +#: user/forms.py:190 +msgid "" +"Multiple selection possible - You can only select users which are not " +"already a team member. Enter the full username or e-mail." +msgstr "" +"Mehrfachauswahl möglich - Sie können nur Nutzer wählen, die noch nicht " +"Mitglieder dieses Teams sind. Geben Sie den ganzen Nutzernamen an." + +#: user/forms.py:204 +msgid "Create new team" +msgstr "Neues Team anlegen" + +#: user/forms.py:205 +msgid "" +"You will become the administrator for this group by default. You do not need " +"to add yourself to the list of members." +msgstr "" +"Sie werden standardmäßig der Administrator dieses Teams. Sie müssen sich " +"selbst nicht zur Liste der Mitglieder hinzufügen." + +#: user/forms.py:218 user/forms.py:279 +msgid "Name already taken. Try another." +msgstr "Name bereits vergeben. Probieren Sie einen anderen." + +#: user/forms.py:249 +msgid "Admin" +msgstr "Administrator" + +#: user/forms.py:250 +msgid "Administrators manage team details and members" +msgstr "Administratoren verwalten die Teamdaten und Mitglieder" + +#: user/forms.py:263 +msgid "Selected admin ({}) needs to be a member of this team." +msgstr "Gewählter Administrator ({}) muss ein Mitglied des Teams sein." + +#: user/forms.py:291 user/templates/user/team/index.html:51 +msgid "Edit team" +msgstr "Team bearbeiten" + +#: user/forms.py:347 +msgid "Team" +msgstr "Team" + #: user/models/user_action.py:22 msgid "Unrecorded" msgstr "Entzeichnet" @@ -2392,7 +2515,11 @@ msgstr "Gelöscht" msgid "Show contact data" msgstr "Zeige Kontaktdaten" -#: user/templates/user/index.html:13 +#: user/templates/user/includes/team_data_modal_button.html:3 +msgid "Show team data" +msgstr "Zeige Teamdaten" + +#: user/templates/user/index.html:13 user/templates/user/team/index.html:29 msgid "Name" msgstr "" @@ -2437,6 +2564,27 @@ msgstr "API token einsehen oder neu generieren" msgid "API" msgstr "" +#: user/templates/user/index.html:66 +msgid "Manage teams" +msgstr "" + +#: user/templates/user/index.html:69 user/templates/user/team/index.html:18 +#: user/views.py:167 +msgid "Teams" +msgstr "" + +#: user/templates/user/team/index.html:20 +msgid "Add new team" +msgstr "Neues Team hinzufügen" + +#: user/templates/user/team/index.html:31 +msgid "Members" +msgstr "Mitglieder" + +#: user/templates/user/team/index.html:54 +msgid "Remove team" +msgstr "Team entfernen" + #: user/templates/user/token.html:6 msgid "API settings" msgstr "API Einstellungen" @@ -2461,26 +2609,38 @@ msgstr "Token noch nicht freigeschaltet" msgid "Valid until" msgstr "Läuft ab am" -#: user/views.py:31 +#: user/views.py:33 msgid "User settings" msgstr "Einstellungen" -#: user/views.py:57 +#: user/views.py:59 msgid "Notifications edited" msgstr "Benachrichtigungen bearbeitet" -#: user/views.py:69 +#: user/views.py:71 msgid "User notifications" msgstr "Benachrichtigungen" -#: user/views.py:92 +#: user/views.py:94 msgid "New token generated. Administrators need to validate." msgstr "Neuer Token generiert. Administratoren sind informiert." -#: user/views.py:103 +#: user/views.py:105 msgid "User API token" msgstr "API Nutzer Token" +#: user/views.py:178 +msgid "New team added" +msgstr "Neues Team hinzugefügt" + +#: user/views.py:191 +msgid "Team edited" +msgstr "Team bearbeitet" + +#: user/views.py:204 +msgid "Team removed" +msgstr "Team gelöscht" + #: venv/lib/python3.7/site-packages/bootstrap4/components.py:17 #: venv/lib/python3.7/site-packages/bootstrap4/templates/bootstrap4/form_errors.html:3 #: venv/lib/python3.7/site-packages/bootstrap4/templates/bootstrap4/messages.html:4 @@ -3984,6 +4144,15 @@ msgstr "" msgid "Unable to connect to qpid with SASL mechanism %s" msgstr "" +#~ msgid "your teams" +#~ msgstr "Team entfernen" + +#~ msgid "Remove check to remove access for this user" +#~ msgstr "Wählen Sie die Nutzer ab, die keinen Zugriff mehr haben sollen" + +#~ msgid "Select the action type" +#~ msgstr "Maßnahmentyp wählen" + #~ msgid "No revocation" #~ msgstr "Kein Widerspruch" @@ -3993,9 +4162,6 @@ msgstr "" #~ msgid "General data edited" #~ msgstr "Allgemeine Daten bearbeitet" -#~ msgid "Action type details" -#~ msgstr "Zusatzmerkmale" - #~ msgid "On registered data edited" #~ msgstr "Wenn meine freigegebenen Daten bearbeitet wurden" diff --git a/templates/email/checking/shared_data_checked_team.html b/templates/email/checking/shared_data_checked_team.html new file mode 100644 index 0000000..ee81381 --- /dev/null +++ b/templates/email/checking/shared_data_checked_team.html @@ -0,0 +1,28 @@ +{% load i18n %} + +
+

{% trans 'Shared data checked' %}

+

{{obj_identifier}}

+
+
+ {% trans 'Hello team' %} {{team.name}}, +
+ {% trans 'the following dataset has just been checked' %} +
+ {{obj_identifier}} +
+ {{obj_title}} +
+ {% trans 'This means, the responsible registration office just confirmed the correctness of this dataset.' %} +
+
+ {% trans 'Best regards' %} +
+ KSP +
+
+
+ {% include 'email/signature.html' %} +
+
+ diff --git a/templates/email/deleting/shared_data_deleted_team.html b/templates/email/deleting/shared_data_deleted_team.html new file mode 100644 index 0000000..cedb2a4 --- /dev/null +++ b/templates/email/deleting/shared_data_deleted_team.html @@ -0,0 +1,28 @@ +{% load i18n %} + +
+

{% trans 'Shared data deleted' %}

+

{{obj_identifier}}

+
+
+ {% trans 'Hello team' %} {{team.name}}, +
+ {% trans 'the following dataset has just been deleted' %} +
+ {{obj_identifier}} +
+ "{{obj_title}}" +
+ {% trans 'If this should not have been happened, please contact us. See the signature for details.' %} +
+
+ {% trans 'Best regards' %} +
+ KSP +
+
+
+ {% include 'email/signature.html' %} +
+
+ diff --git a/templates/email/recording/shared_data_recorded_team.html b/templates/email/recording/shared_data_recorded_team.html new file mode 100644 index 0000000..12efa8f --- /dev/null +++ b/templates/email/recording/shared_data_recorded_team.html @@ -0,0 +1,33 @@ +{% load i18n %} + +
+

{% trans 'Shared data recorded' %}

+

{{obj_identifier}}

+
+
+ {% trans 'Hello team' %} {{team.name}}, +
+ {% trans 'the following dataset has just been recorded' %} +
+ {{obj_identifier}} +
+ "{{obj_title}}" +
+ {% trans 'This means the data is now publicly available, e.g. in LANIS' %} +
+
+ {% trans 'Best regards' %} +
+ KSP +
+
+
+ + {% trans 'Please note: Recorded intervention means the compensations are recorded as well.' %} + +
+
+ {% include 'email/signature.html' %} +
+
+ diff --git a/templates/email/recording/shared_data_unrecorded_team.html b/templates/email/recording/shared_data_unrecorded_team.html new file mode 100644 index 0000000..6414155 --- /dev/null +++ b/templates/email/recording/shared_data_unrecorded_team.html @@ -0,0 +1,33 @@ +{% load i18n %} + +
+

{% trans 'Shared data unrecorded' %}

+

{{obj_identifier}}

+
+
+ {% trans 'Hello team' %} {{team.name}}, +
+ {% trans 'the following dataset has just been unrecorded' %} +
+ {{obj_identifier}} +
+ "{{obj_title}}" +
+ {% trans 'This means the data is no longer publicly available.' %} +
+
+ {% trans 'Best regards' %} +
+ KSP +
+
+
+ + {% trans 'Please note: Unrecorded intervention means the compensations are unrecorded as well.' %} + +
+
+ {% include 'email/signature.html' %} +
+
+ diff --git a/templates/email/sharing/shared_access_given_team.html b/templates/email/sharing/shared_access_given_team.html new file mode 100644 index 0000000..990ba2d --- /dev/null +++ b/templates/email/sharing/shared_access_given_team.html @@ -0,0 +1,34 @@ +{% load i18n %} + +
+

{% trans 'Access shared' %}

+

{{obj_identifier}}

+
+
+ {% trans 'Hello team' %} {{team.name}}, +
+ {% trans 'the following dataset has just been shared with your team' %} +
+ {{obj_identifier}} +
+ "{{obj_title}}" +
+ {% trans 'This means you can now edit this dataset.' %} + {% trans 'The shared dataset appears now by default on your overview for this dataset type.' %} +
+
+ {% trans 'Best regards' %} +
+ KSP +
+
+
+ + {% trans 'Please note: Shared access on an intervention means you automatically have editing access to related compensations.' %} + +
+
+ {% include 'email/signature.html' %} +
+
+ diff --git a/templates/email/sharing/shared_access_removed_team.html b/templates/email/sharing/shared_access_removed_team.html new file mode 100644 index 0000000..5472ef7 --- /dev/null +++ b/templates/email/sharing/shared_access_removed_team.html @@ -0,0 +1,29 @@ +{% load i18n %} + +
+

{% trans 'Shared access removed' %}

+

{{obj_identifier}}

+
+
+ {% trans 'Hello team' %} {{team.name}}, +
+ {% trans 'your teams shared access, including editing, has been revoked for the dataset ' %} +
+ {{obj_identifier}} +
+ "{{obj_title}}" +
+ {% 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.' %} +
+
+ {% trans 'Best regards' %} +
+ KSP +
+
+
+ {% include 'email/signature.html' %} +
+
+ diff --git a/user/admin.py b/user/admin.py index 3e60861..1aeacee 100644 --- a/user/admin.py +++ b/user/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from user.models import UserNotification, UserActionLogEntry, User +from user.models import UserNotification, UserActionLogEntry, User, Team class UserNotificationAdmin(admin.ModelAdmin): @@ -64,7 +64,20 @@ class UserActionLogEntryAdmin(admin.ModelAdmin): ] +class TeamAdmin(admin.ModelAdmin): + list_display = [ + "name", + "description", + "admin", + ] + search_fields = [ + "name", + "description", + ] + + admin.site.register(User, UserAdmin) +admin.site.register(Team, TeamAdmin) # Outcommented for a cleaner admin backend on production #admin.site.register(UserNotification, UserNotificationAdmin) diff --git a/user/forms.py b/user/forms.py index fd66a91..34c8fab 100644 --- a/user/forms.py +++ b/user/forms.py @@ -5,17 +5,17 @@ Contact: michel.peltriaux@sgdnord.rlp.de Created on: 08.07.21 """ +from dal import autocomplete from django import forms -from django.db import IntegrityError +from django.db import IntegrityError, transaction from django.urls import reverse, reverse_lazy from django.utils.translation import gettext_lazy as _ from api.models import APIUserToken from intervention.inputs import GenerateInput -from user.models import User +from user.models import User, UserNotification, Team -from konova.forms import BaseForm, BaseModalForm -from user.models import UserNotification +from konova.forms import BaseForm, BaseModalForm, RemoveModalForm class UserNotificationForm(BaseForm): @@ -160,3 +160,201 @@ class UserAPITokenForm(BaseForm): user.api_token = new_token user.save() return new_token + + +class NewTeamModalForm(BaseModalForm): + name = forms.CharField( + label_suffix="", + label=_("Team name"), + max_length=500, + widget=forms.TextInput( + attrs={ + "placeholder": _("Team name"), + "class": "form-control", + } + ) + ) + description = forms.CharField( + label_suffix="", + label=_("Description"), + widget=forms.Textarea( + attrs={ + "rows": 5, + "class": "form-control" + } + ) + ) + members = forms.ModelMultipleChoiceField( + label=_("Manage team members"), + label_suffix="", + help_text=_("Multiple selection possible - You can only select users which are not already a team member. Enter the full username or e-mail."), + required=True, + queryset=User.objects.all(), + widget=autocomplete.ModelSelect2Multiple( + url="share-user-autocomplete", + attrs={ + "data-placeholder": _("Click for selection"), + "data-minimum-input-length": 3, + }, + ), + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.form_title = _("Create new team") + self.form_caption = _("You will become the administrator for this group by default. You do not need to add yourself to the list of members.") + self.action_url = reverse("user:team-new") + self.cancel_redirect = reverse("user:team-index") + + def _is_name_valid(self): + name = self.cleaned_data.get("name", None) + teams_with_same_name = Team.objects.filter( + name=name + ) + name_valid = not teams_with_same_name.exists() + if not name_valid: + self.add_error( + "name", + _("Name already taken. Try another.") + ) + + return name_valid + + def is_valid(self): + super_valid = super().is_valid() + name_valid = self._is_name_valid() + return super_valid and name_valid + + def save(self): + with transaction.atomic(): + team = Team.objects.create( + name=self.cleaned_data.get("name", None), + description=self.cleaned_data.get("description", None), + admin=self.user, + ) + members = self.cleaned_data.get("members", User.objects.none()) + if self.user.id not in members: + members = members.union( + User.objects.filter( + id=self.user.id + ) + ) + team.users.set(members) + return team + + +class EditTeamModalForm(NewTeamModalForm): + admin = forms.ModelChoiceField( + label_suffix="", + label=_("Admin"), + help_text=_("Administrators manage team details and members"), + queryset=User.objects.none(), + empty_label=None, + ) + + def __is_admin_valid(self): + admin = self.cleaned_data.get("admin", None) + members = self.cleaned_data.get("members", None) + _is_valid = admin in members + + if not _is_valid: + self.add_error( + "members", + _("Selected admin ({}) needs to be a member of this team.").format(admin.username) + ) + + return _is_valid + + def _is_name_valid(self): + name = self.cleaned_data.get("name", None) + teams_with_same_name = Team.objects.filter( + name=name + ).exclude( + id=self.instance.id + ) + name_valid = not teams_with_same_name.exists() + if not name_valid: + self.add_error( + "name", + _("Name already taken. Try another.") + ) + + return name_valid + + def is_valid(self): + super_valid = super().is_valid() + admin_valid = self.__is_admin_valid() + return super_valid and admin_valid + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.form_title = _("Edit team") + self.action_url = reverse("user:team-edit", args=(self.instance.id,)) + self.cancel_redirect = reverse("user:team-index") + + members = self.instance.users.all() + self.fields["admin"].queryset = members + + form_data = { + "members": members, + "name": self.instance.name, + "description": self.instance.description, + "admin": self.instance.admin, + } + self.load_initial_data(form_data) + + def save(self): + with transaction.atomic(): + self.instance.name = self.cleaned_data.get("name", None) + self.instance.description = self.cleaned_data.get("description", None) + self.instance.admin = self.cleaned_data.get("admin", None) + self.instance.save() + self.instance.users.set(self.cleaned_data.get("members", [])) + return self.instance + + +class RemoveTeamModalForm(RemoveModalForm): + pass + + +class TeamDataForm(BaseModalForm): + name = forms.CharField( + label_suffix="", + label=_("Team name"), + max_length=500, + required=False, + widget=forms.TextInput( + attrs={ + "placeholder": _("Team name"), + "class": "form-control", + } + ) + ) + description = forms.CharField( + label_suffix="", + required=False, + label=_("Description"), + widget=forms.Textarea( + attrs={ + "rows": 5, + "class": "form-control" + } + ) + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.form_title = _("Team") + self.form_caption = "" + self.render_submit = False + form_data = { + "name": self.instance.name, + "description": self.instance.description, + } + self.load_initial_data( + form_data, + [ + "name", + "description" + ] + ) \ No newline at end of file diff --git a/user/migrations/0003_team.py b/user/migrations/0003_team.py new file mode 100644 index 0000000..6f7ac90 --- /dev/null +++ b/user/migrations/0003_team.py @@ -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, + }, + ), + ] diff --git a/user/models/__init__.py b/user/models/__init__.py index 7788d8e..06dbe73 100644 --- a/user/models/__init__.py +++ b/user/models/__init__.py @@ -5,6 +5,7 @@ Contact: michel.peltriaux@sgdnord.rlp.de Created on: 15.11.21 """ -from .user_action import * -from .user import * -from .notification import * +from .user_action import UserActionLogEntry, UserAction +from .user import User +from .notification import UserNotification, UserNotificationEnum +from .team import Team diff --git a/user/models/team.py b/user/models/team.py new file mode 100644 index 0000000..e36c95b --- /dev/null +++ b/user/models/team.py @@ -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) diff --git a/user/templates/user/includes/contact_modal_button.html b/user/templates/user/includes/contact_modal_button.html index 68ae53a..5cbd87f 100644 --- a/user/templates/user/includes/contact_modal_button.html +++ b/user/templates/user/includes/contact_modal_button.html @@ -1,6 +1,6 @@ {% load fontawesome_5 i18n %} \ No newline at end of file diff --git a/user/templates/user/includes/team_data_modal_button.html b/user/templates/user/includes/team_data_modal_button.html new file mode 100644 index 0000000..b41e63a --- /dev/null +++ b/user/templates/user/includes/team_data_modal_button.html @@ -0,0 +1,6 @@ +{% load fontawesome_5 i18n %} + + \ No newline at end of file diff --git a/user/templates/user/index.html b/user/templates/user/index.html index 1193340..c31de94 100644 --- a/user/templates/user/index.html +++ b/user/templates/user/index.html @@ -62,6 +62,14 @@
+
+ + + +
diff --git a/user/templates/user/team/index.html b/user/templates/user/team/index.html new file mode 100644 index 0000000..d2040a3 --- /dev/null +++ b/user/templates/user/team/index.html @@ -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 %} +
+

{% trans 'Teams' %}

+
+ +
+
+ + + + + + + + + + + {% for team in teams %} + + + + + + + {% endfor %} + +
{% trans 'Name' %}{% trans 'Description' %}{% trans 'Members' %}{% trans 'Action' %}
{{team.name}} +
+ {{team.description}} +
+
+ {% for member in team.users.all %} + {{member.username}} + {% endfor %} + + {% if team.admin == user %} + + + {% endif %} +
+
+
+ +{% with 'btn-modal' as btn_class %} + {% include 'modal/modal_form_script.html' %} +{% endwith %} + +{% endblock %} \ No newline at end of file diff --git a/user/urls.py b/user/urls.py index e312a63..5c33427 100644 --- a/user/urls.py +++ b/user/urls.py @@ -15,5 +15,10 @@ urlpatterns = [ path("notifications/", notifications_view, name="notifications"), path("token/api", api_token_view, name="api-token"), path("contact/", contact_view, name="contact"), + path("team/", index_team_view, name="team-index"), + path("team/", data_team_view, name="team-data"), + path("team/new", new_team_view, name="team-new"), + path("team//edit", edit_team_view, name="team-edit"), + path("team//remove", remove_team_view, name="team-remove"), ] \ No newline at end of file diff --git a/user/views.py b/user/views.py index ee6aee5..9afede1 100644 --- a/user/views.py +++ b/user/views.py @@ -1,17 +1,19 @@ from django.contrib import messages from django.contrib.auth.decorators import login_required +from django.urls import reverse from konova.sub_settings.context_settings import TAB_TITLE_IDENTIFIER from konova.utils.mailer import Mailer from konova.utils.message_templates import FORM_INVALID -from user.models import User -from django.http import HttpRequest +from user.models import User, Team +from django.http import HttpRequest, Http404 from django.shortcuts import render, redirect, get_object_or_404 from django.utils.translation import gettext_lazy as _ from konova.contexts import BaseContext from konova.decorators import any_group_check, default_group_required -from user.forms import UserNotificationForm, UserContactForm, UserAPITokenForm +from user.forms import UserNotificationForm, UserContactForm, UserAPITokenForm, NewTeamModalForm, EditTeamModalForm, \ + RemoveTeamModalForm, TeamDataForm @login_required @@ -128,4 +130,77 @@ def contact_view(request: HttpRequest, id: str): request, template, context - ) \ No newline at end of file + ) + + +@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") + )