diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/admin.py b/api/admin.py new file mode 100644 index 0000000..c5e1fba --- /dev/null +++ b/api/admin.py @@ -0,0 +1,16 @@ +from django.contrib import admin + +from api.models.token import APIUserToken + + +class APITokenAdmin(admin.ModelAdmin): + list_display = [ + "token", + "valid_until", + "is_active", + ] + readonly_fields = [ + "token" + ] + +admin.site.register(APIUserToken, APITokenAdmin) diff --git a/api/apps.py b/api/apps.py new file mode 100644 index 0000000..d87006d --- /dev/null +++ b/api/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + name = 'api' diff --git a/api/models/__init__.py b/api/models/__init__.py new file mode 100644 index 0000000..d43f0c4 --- /dev/null +++ b/api/models/__init__.py @@ -0,0 +1,8 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 21.01.22 + +""" +from .token import * \ No newline at end of file diff --git a/api/models/token.py b/api/models/token.py new file mode 100644 index 0000000..e0ad664 --- /dev/null +++ b/api/models/token.py @@ -0,0 +1,50 @@ +from django.core.exceptions import ObjectDoesNotExist +from django.db import models +from django.utils import timezone + +from konova.utils.generators import generate_token + + +class APIUserToken(models.Model): + token = models.CharField( + primary_key=True, + max_length=1000, + default=generate_token, + ) + valid_until = models.DateField( + blank=True, + null=True, + help_text="Token is only valid until this date", + ) + is_active = models.BooleanField( + default=False, + help_text="Must be activated by an admin" + ) + + def __str__(self): + return self.token + + @staticmethod + def get_user_from_token(token: str, username: str): + """ Getter for the related user object + + Args: + token (str): The used token + username (str): The username + + Returns: + user (User): Otherwise None + """ + _today = timezone.now().date() + try: + token_obj = APIUserToken.objects.get( + token=token, + user__username=username + ) + if not token_obj.is_active: + raise PermissionError("Token unverified") + if token_obj.valid_until is not None and token_obj.valid_until < _today: + raise PermissionError("Token validity expired") + except ObjectDoesNotExist: + raise PermissionError("Credentials invalid") + return token_obj.user diff --git a/api/settings.py b/api/settings.py new file mode 100644 index 0000000..27cd8e5 --- /dev/null +++ b/api/settings.py @@ -0,0 +1,9 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 21.01.22 + +""" +KSP_TOKEN_HEADER_IDENTIFIER = "Ksptoken" +KSP_USER_HEADER_IDENTIFIER = "Kspuser" \ No newline at end of file diff --git a/api/tests/__init__.py b/api/tests/__init__.py new file mode 100644 index 0000000..d49ff47 --- /dev/null +++ b/api/tests/__init__.py @@ -0,0 +1,7 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 21.01.22 + +""" diff --git a/api/tests/v1/__init__.py b/api/tests/v1/__init__.py new file mode 100644 index 0000000..c5acaba --- /dev/null +++ b/api/tests/v1/__init__.py @@ -0,0 +1,7 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 27.01.22 + +""" diff --git a/api/tests/v1/create/__init__.py b/api/tests/v1/create/__init__.py new file mode 100644 index 0000000..c5acaba --- /dev/null +++ b/api/tests/v1/create/__init__.py @@ -0,0 +1,7 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 27.01.22 + +""" diff --git a/api/tests/v1/create/compensation_create_post_body.json b/api/tests/v1/create/compensation_create_post_body.json new file mode 100644 index 0000000..b0d21e8 --- /dev/null +++ b/api/tests/v1/create/compensation_create_post_body.json @@ -0,0 +1,19 @@ +{ + "type": "MultiPolygon", + "coordinates": [ + ], + "properties": { + "title": "Test_compensation", + "is_cef": false, + "is_coherence_keeping": false, + "intervention": "MUST_BE_SET_IN_TEST", + "before_states": [ + ], + "after_states": [ + ], + "actions": [ + ], + "deadlines": [ + ] + } +} \ No newline at end of file diff --git a/api/tests/v1/create/deduction_create_post_body.json b/api/tests/v1/create/deduction_create_post_body.json new file mode 100644 index 0000000..b467637 --- /dev/null +++ b/api/tests/v1/create/deduction_create_post_body.json @@ -0,0 +1,5 @@ +{ + "eco_account": "CHANGE_BEFORE_RUN!!!", + "surface": 500.0, + "intervention": "CHANGE_BEFORE_RUN!!!" +} \ No newline at end of file diff --git a/api/tests/v1/create/ecoaccount_create_post_body.json b/api/tests/v1/create/ecoaccount_create_post_body.json new file mode 100644 index 0000000..742c0fb --- /dev/null +++ b/api/tests/v1/create/ecoaccount_create_post_body.json @@ -0,0 +1,25 @@ +{ + "type": "MultiPolygon", + "coordinates": [ + ], + "properties": { + "title": "Test_ecoaccount", + "deductable_surface": 10000.0, + "responsible": { + "conservation_office": null, + "conservation_file_number": null, + "handler": null + }, + "legal": { + "agreement_date": null + }, + "before_states": [ + ], + "after_states": [ + ], + "actions": [ + ], + "deadlines": [ + ] + } +} \ No newline at end of file diff --git a/api/tests/v1/create/ema_create_post_body.json b/api/tests/v1/create/ema_create_post_body.json new file mode 100644 index 0000000..d9081fb --- /dev/null +++ b/api/tests/v1/create/ema_create_post_body.json @@ -0,0 +1,21 @@ +{ + "type": "MultiPolygon", + "coordinates": [ + ], + "properties": { + "title": "Test_ema", + "responsible": { + "conservation_office": null, + "conservation_file_number": null, + "handler": null + }, + "before_states": [ + ], + "after_states": [ + ], + "actions": [ + ], + "deadlines": [ + ] + } +} \ No newline at end of file diff --git a/api/tests/v1/create/intervention_create_post_body.json b/api/tests/v1/create/intervention_create_post_body.json new file mode 100644 index 0000000..2d39919 --- /dev/null +++ b/api/tests/v1/create/intervention_create_post_body.json @@ -0,0 +1,21 @@ +{ + "type": "MultiPolygon", + "coordinates": [ + ], + "properties": { + "title": "Test_intervention", + "responsible": { + "registration_office": null, + "registration_file_number": null, + "conservation_office": null, + "conservation_file_number": null, + "handler": null + }, + "legal": { + "registration_date": null, + "binding_date": null, + "process_type": null, + "laws": [] + } + } +} \ No newline at end of file diff --git a/api/tests/v1/create/test_api_create.py b/api/tests/v1/create/test_api_create.py new file mode 100644 index 0000000..72ece97 --- /dev/null +++ b/api/tests/v1/create/test_api_create.py @@ -0,0 +1,122 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 27.01.22 + +""" +import json + +from django.urls import reverse + +from api.tests.v1.share.test_api_sharing import BaseAPIV1TestCase + + +class APIV1CreateTestCase(BaseAPIV1TestCase): + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + def _run_create_request(self, url, data): + data = json.dumps(data) + response = self.client.post( + url, + data=data, + content_type="application/json", + **self.header_data + ) + return response + + def _test_create_object(self, url, post_body): + """ Tests the API creation of a new data object. + + Post body data stored in a local json file + + Args: + url (str): The api creation url + post_body (dict): The post body content as dict + + Returns: + + """ + response = self._run_create_request(url, post_body) + self.assertEqual(response.status_code, 200, msg=response.content) + content = json.loads(response.content) + self.assertIsNotNone(content.get("id", None), msg=response.content) + + def test_create_intervention(self): + """ Tests api creation + + Returns: + + """ + url = reverse("api:v1:intervention") + json_file_path = "api/tests/v1/create/intervention_create_post_body.json" + with open(json_file_path) as json_file: + post_body = json.load(fp=json_file) + self._test_create_object(url, post_body) + + def test_create_compensation(self): + """ Tests api creation + + Returns: + + """ + url = reverse("api:v1:compensation") + json_file_path = "api/tests/v1/create/compensation_create_post_body.json" + with open(json_file_path) as json_file: + post_body = json.load(fp=json_file) + post_body["properties"]["intervention"] = str(self.intervention.id) + + # Expect this first request to fail, since user has no shared access on the intervention, we want to create + # a compensation for + response = self._run_create_request(url, post_body) + self.assertEqual(response.status_code, 500, msg=response.content) + content = json.loads(response.content) + self.assertGreater(len(content.get("errors", [])), 0, msg=response.content) + + # Add the user to the shared users of the intervention and try again! Now everything should work as expected. + self.intervention.users.add(self.superuser) + self._test_create_object(url, post_body) + + def test_create_eco_account(self): + """ Tests api creation + + Returns: + + """ + url = reverse("api:v1:ecoaccount") + json_file_path = "api/tests/v1/create/ecoaccount_create_post_body.json" + with open(json_file_path) as json_file: + post_body = json.load(fp=json_file) + self._test_create_object(url, post_body) + + def test_create_ema(self): + """ Tests api creation + + Returns: + + """ + url = reverse("api:v1:ema") + json_file_path = "api/tests/v1/create/ema_create_post_body.json" + with open(json_file_path) as json_file: + post_body = json.load(fp=json_file) + self._test_create_object(url, post_body) + + def test_create_deduction(self): + """ Tests api creation + + Returns: + + """ + self.intervention.share_with(self.superuser) + self.eco_account.share_with(self.superuser) + + url = reverse("api:v1:deduction") + json_file_path = "api/tests/v1/create/deduction_create_post_body.json" + with open(json_file_path) as json_file: + post_body = json.load(fp=json_file) + post_body["intervention"] = str(self.intervention.id) + post_body["eco_account"] = str(self.eco_account.id) + self._test_create_object(url, post_body) + diff --git a/api/tests/v1/delete/__init__.py b/api/tests/v1/delete/__init__.py new file mode 100644 index 0000000..3659b8c --- /dev/null +++ b/api/tests/v1/delete/__init__.py @@ -0,0 +1,7 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 28.01.22 + +""" diff --git a/api/tests/v1/delete/test_api_delete.py b/api/tests/v1/delete/test_api_delete.py new file mode 100644 index 0000000..cd016cf --- /dev/null +++ b/api/tests/v1/delete/test_api_delete.py @@ -0,0 +1,118 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 28.01.22 + +""" + +import json + +from django.core.exceptions import ObjectDoesNotExist +from django.urls import reverse + +from api.tests.v1.share.test_api_sharing import BaseAPIV1TestCase + + +class APIV1DeleteTestCase(BaseAPIV1TestCase): + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + def _run_delete_request(self, url): + response = self.client.delete( + url, + **self.header_data + ) + return response + + def _test_delete_object(self, obj, url): + """ Tests the API DELETE of a data object. + + Args: + url (str): The api delete url + + Returns: + + """ + obj.refresh_from_db() + _id = obj.id + self.assertIsNotNone(_id) + self.assertIsNone(obj.deleted) + + response = self._run_delete_request(url) + content = json.loads(response.content) + + self.assertEqual(response.status_code, 200, msg=response.content) + self.assertTrue(content.get("success", False), msg=response.content) + + obj.refresh_from_db() + self.assertIsNotNone(obj.deleted) + self.assertEqual(obj.deleted.user, self.superuser) + + def test_delete_intervention(self): + """ Tests api creation of bare minimum interventions + + Returns: + + """ + test_intervention = self.create_dummy_intervention() + test_intervention.share_with(self.superuser) + url = reverse("api:v1:intervention", args=(str(test_intervention.id),)) + self._test_delete_object(test_intervention, url) + + def test_delete_compensation(self): + """ Tests api creation of bare minimum interventions + + Returns: + + """ + test_comp = self.create_dummy_compensation() + test_comp.share_with(self.superuser) + url = reverse("api:v1:compensation", args=(str(test_comp.id),)) + self._test_delete_object(test_comp, url) + + def test_delete_eco_account(self): + """ Tests api creation of bare minimum interventions + + Returns: + + """ + test_acc = self.create_dummy_eco_account() + test_acc.share_with(self.superuser) + url = reverse("api:v1:ecoaccount", args=(str(test_acc.id),)) + self._test_delete_object(test_acc, url) + + def test_delete_ema(self): + """ Tests api creation of bare minimum interventions + + Returns: + + """ + test_ema = self.create_dummy_ema() + test_ema.share_with(self.superuser) + url = reverse("api:v1:ema", args=(str(test_ema.id),)) + self._test_delete_object(test_ema, url) + + def test_delete_deduction(self): + """ Tests api creation of bare minimum interventions + + Returns: + + """ + test_deduction = self.create_dummy_deduction() + test_deduction.intervention.share_with(self.superuser) + url = reverse("api:v1:deduction", args=(str(test_deduction.id),)) + + response = self._run_delete_request(url) + content = json.loads(response.content) + + self.assertEqual(response.status_code, 200, msg=response.content) + self.assertTrue(content.get("success", False), msg=response.content) + + try: + test_deduction.refresh_from_db() + self.fail("Deduction is not deleted from db!") + except ObjectDoesNotExist: + pass + diff --git a/api/tests/v1/get/__init__.py b/api/tests/v1/get/__init__.py new file mode 100644 index 0000000..3659b8c --- /dev/null +++ b/api/tests/v1/get/__init__.py @@ -0,0 +1,7 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 28.01.22 + +""" diff --git a/api/tests/v1/get/test_api_get.py b/api/tests/v1/get/test_api_get.py new file mode 100644 index 0000000..38af864 --- /dev/null +++ b/api/tests/v1/get/test_api_get.py @@ -0,0 +1,187 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 28.01.22 + +""" + +import json + +from django.urls import reverse + +from api.tests.v1.share.test_api_sharing import BaseAPIV1TestCase + + +class APIV1GetTestCase(BaseAPIV1TestCase): + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + def _run_get_request(self, url): + response = self.client.get( + url, + **self.header_data + ) + return response + + def _test_get_object(self, obj, url): + """ Tests the API GET of a data object. + + Args: + url (str): The api get url + + Returns: + + """ + response = self._run_get_request(url) + content = json.loads(response.content) + geojson = content[str(obj.id)] + self.assertEqual(response.status_code, 200, msg=response.content) + return geojson + + def _assert_geojson_format(self, geojson): + try: + geojson["type"] + geojson["coordinates"] + props = geojson["properties"] + props["id"] + props["identifier"] + props["title"] + props["created_on"] + props["modified_on"] + except KeyError as e: + self.fail(e) + + def test_get_intervention(self): + """ Tests api GET + + Returns: + + """ + self.intervention.share_with(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) + try: + props = geojson["properties"] + props["responsible"] + props["responsible"]["registration_office"] + props["responsible"]["registration_file_number"] + props["responsible"]["conservation_office"] + props["responsible"]["conservation_file_number"] + props["legal"]["registration_date"] + props["legal"]["binding_date"] + props["legal"]["process_type"] + props["legal"]["laws"] + props["compensations"] + props["payments"] + props["deductions"] + except KeyError as e: + self.fail(e) + + def test_get_compensation(self): + """ Tests api GET + + Returns: + + """ + self.intervention.share_with(self.superuser) + self.compensation.intervention = self.intervention + self.compensation.save() + + url = reverse("api:v1:compensation", args=(str(self.compensation.id),)) + geojson = self._test_get_object(self.compensation, url) + self._assert_geojson_format(geojson) + try: + props = geojson["properties"] + props["is_cef"] + props["is_coherence_keeping"] + props["intervention"] + props["intervention"]["id"] + props["intervention"]["identifier"] + props["intervention"]["title"] + props["before_states"] + props["after_states"] + props["actions"] + props["deadlines"] + except KeyError as e: + self.fail(e) + + def test_get_eco_account(self): + """ Tests api GET + + Returns: + + """ + self.eco_account.share_with(self.superuser) + + url = reverse("api:v1:ecoaccount", args=(str(self.eco_account.id),)) + geojson = self._test_get_object(self.eco_account, url) + self._assert_geojson_format(geojson) + try: + props = geojson["properties"] + props["deductable_surface"] + props["deductable_surface_available"] + props["responsible"] + props["responsible"]["conservation_office"] + props["responsible"]["conservation_file_number"] + props["responsible"]["handler"] + props["legal"] + props["legal"]["agreement_date"] + props["before_states"] + props["after_states"] + props["actions"] + props["deadlines"] + props["deductions"] + except KeyError as e: + self.fail(e) + + def test_get_ema(self): + """ Tests api GET + + Returns: + + """ + self.ema.share_with(self.superuser) + + url = reverse("api:v1:ema", args=(str(self.ema.id),)) + geojson = self._test_get_object(self.ema, url) + self._assert_geojson_format(geojson) + try: + props = geojson["properties"] + props["responsible"] + props["responsible"]["conservation_office"] + props["responsible"]["conservation_file_number"] + props["responsible"]["handler"] + props["before_states"] + props["after_states"] + props["actions"] + props["deadlines"] + except KeyError as e: + self.fail(e) + + def test_get_deduction(self): + """ Tests api GET + + Returns: + + """ + self.deduction.intervention.share_with(self.superuser) + + url = reverse("api:v1:deduction", args=(str(self.deduction.id),)) + _json = self._test_get_object(self.deduction, url) + try: + _json["id"] + _json["eco_account"] + _json["eco_account"]["id"] + _json["eco_account"]["identifier"] + _json["eco_account"]["title"] + _json["surface"] + _json["intervention"] + _json["intervention"]["id"] + _json["intervention"]["identifier"] + _json["intervention"]["title"] + except KeyError as e: + self.fail(e) + diff --git a/api/tests/v1/share/__init__.py b/api/tests/v1/share/__init__.py new file mode 100644 index 0000000..3659b8c --- /dev/null +++ b/api/tests/v1/share/__init__.py @@ -0,0 +1,7 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 28.01.22 + +""" diff --git a/api/tests/v1/share/test_api_sharing.py b/api/tests/v1/share/test_api_sharing.py new file mode 100644 index 0000000..9e7c9ee --- /dev/null +++ b/api/tests/v1/share/test_api_sharing.py @@ -0,0 +1,153 @@ +import json + +from django.urls import reverse + +from konova.settings import DEFAULT_GROUP +from konova.tests.test_views import BaseTestCase +from konova.utils.user_checks import is_default_group_only + + +class BaseAPIV1TestCase(BaseTestCase): + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + cls.superuser.get_API_token() + cls.superuser.api_token.is_active = True + cls.superuser.api_token.save() + default_group = cls.groups.get(name=DEFAULT_GROUP) + cls.superuser.groups.add(default_group) + + cls.header_data = { + "HTTP_ksptoken": cls.superuser.api_token.token, + "HTTP_kspuser": cls.superuser.username, + } + + +class APIV1SharingTestCase(BaseAPIV1TestCase): + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + def _run_share_request(self, url, user_list: list): + data = { + "users": user_list + } + data = json.dumps(data) + response = self.client.put( + url, + data, + **self.header_data + ) + return response + + def _test_api_sharing(self, obj, url): + """ Generic test for testing sharing of a ShareableObjectMixin object + + Args: + obj (ShareableObjectMixin): The object + url (str): The url to be used for a request + + Returns: + + """ + self.assertEqual(obj.users.count(), 0) + user_list = [ + self.superuser.username, + self.user.username, + ] + + response = self._run_share_request(url, user_list) + + # Must fail, since performing user has no access on requested object + self.assertEqual(response.status_code, 500) + self.assertTrue(len(json.loads(response.content.decode("utf-8")).get("errors", [])) > 0) + + # Add performing user to shared access users and rerun the request + obj.users.add(self.superuser) + response = self._run_share_request(url, user_list) + + shared_users = obj.shared_users + self.assertEqual(response.status_code, 200) + self.assertEqual(shared_users.count(), 2) + self.assertIn(self.superuser, shared_users) + self.assertIn(self.user, shared_users) + + def test_api_token_invalid(self): + """ Tests that a request with an invalid token won't be successfull + + Returns: + + """ + share_url = reverse("api:v1:intervention-share", args=(self.intervention.id,)) + # Expect the first request to work properly + self.intervention.users.add(self.superuser) + response = self._run_share_request(share_url, [self.superuser.username]) + self.assertEqual(response.status_code, 200) + + # Change the token + self.header_data["HTTP_ksptoken"] = f"{self.superuser.api_token.token}__X" + + # Expect the request to fail now + response = self._run_share_request(share_url, [self.superuser.username]) + self.assertEqual(response.status_code, 403) + + def test_api_intervention_sharing(self): + """ Tests proper sharing of intervention + + Returns: + + """ + share_url = reverse("api:v1:intervention-share", args=(self.intervention.id,)) + self._test_api_sharing(self.intervention, share_url) + + def test_api_eco_account_sharing(self): + """ Tests proper sharing of eco account + + Returns: + + """ + share_url = reverse("api:v1:ecoaccount-share", args=(self.eco_account.id,)) + self._test_api_sharing(self.eco_account, share_url) + + def test_api_ema_sharing(self): + """ Tests proper sharing of ema + + Returns: + + """ + share_url = reverse("api:v1:ema-share", args=(self.ema.id,)) + self._test_api_sharing(self.ema, share_url) + + def test_api_sharing_as_default_group_only(self): + """ Tests that sharing using the API as an only default group user works as expected. + + Expected: + Default only user can only add new users, having shared access. Removing them from the list of users + having shared access is only possible if the user has further rights, e.g. being part of a registration + or conservation office group. + + Returns: + + """ + share_url = reverse("api:v1:intervention-share", args=(self.intervention.id,)) + + # Give the user only default group rights + default_group = self.groups.get(name=DEFAULT_GROUP) + self.superuser.groups.set([default_group]) + self.assertTrue(is_default_group_only(self.superuser)) + + # Add only him as shared_users an object + self.intervention.users.set([self.superuser]) + self.assertEqual(self.intervention.users.count(), 1) + + # Try to add another user via API -> must work! + response = self._run_share_request(share_url, [self.superuser.username, self.user.username]) + self.assertEqual(response.status_code, 200) + self.assertEqual(self.intervention.users.count(), 2) + + # Now try to remove the user again -> expect no changes at all to the shared user list + response = self._run_share_request(share_url, [self.superuser.username]) + self.assertEqual(response.status_code, 200) + self.assertEqual(self.intervention.users.count(), 2) diff --git a/api/tests/v1/update/__init__.py b/api/tests/v1/update/__init__.py new file mode 100644 index 0000000..3659b8c --- /dev/null +++ b/api/tests/v1/update/__init__.py @@ -0,0 +1,7 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 28.01.22 + +""" diff --git a/api/tests/v1/update/compensation_update_put_body.json b/api/tests/v1/update/compensation_update_put_body.json new file mode 100644 index 0000000..57ad5ed --- /dev/null +++ b/api/tests/v1/update/compensation_update_put_body.json @@ -0,0 +1,61 @@ +{ + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 7.845568656921382, + 50.79829702304368 + ], + [ + 7.837371826171871, + 50.80155187891526 + ], + [ + 7.835698127746578, + 50.805267562209806 + ], + [ + 7.841062545776364, + 50.806623577403386 + ], + [ + 7.848916053771969, + 50.808359219420474 + ], + [ + 7.855696678161618, + 50.807057493952975 + ], + [ + 7.854666709899899, + 50.80423696434001 + ], + [ + 7.850461006164548, + 50.80217570040005 + ], + [ + 7.845568656921382, + 50.79829702304368 + ] + ] + ] + ], + "properties": { + "title": "TEST_compensation_CHANGED", + "is_cef": true, + "is_coherence_keeping": true, + "intervention": "CHANGE_BEFORE_RUN!!!", + "before_states": [], + "after_states": [], + "actions": [], + "deadlines": [ + { + "type": "finished", + "date": "2022-01-31", + "comment": "TEST_CHANGED" + } + ] + } +} \ No newline at end of file diff --git a/api/tests/v1/update/deduction_update_put_body.json b/api/tests/v1/update/deduction_update_put_body.json new file mode 100644 index 0000000..4968bf1 --- /dev/null +++ b/api/tests/v1/update/deduction_update_put_body.json @@ -0,0 +1,5 @@ +{ + "eco_account": "CHANGE_BEFORE_RUN!!!", + "surface": 523400.0, + "intervention": "CHANGE_BEFORE_RUN!!!" +} \ No newline at end of file diff --git a/api/tests/v1/update/ecoaccount_update_put_body.json b/api/tests/v1/update/ecoaccount_update_put_body.json new file mode 100644 index 0000000..7b62a12 --- /dev/null +++ b/api/tests/v1/update/ecoaccount_update_put_body.json @@ -0,0 +1,70 @@ +{ + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 7.845568656921382, + 50.79829702304368 + ], + [ + 7.837371826171871, + 50.80155187891526 + ], + [ + 7.835698127746578, + 50.805267562209806 + ], + [ + 7.841062545776364, + 50.806623577403386 + ], + [ + 7.848916053771969, + 50.808359219420474 + ], + [ + 7.855696678161618, + 50.807057493952975 + ], + [ + 7.854666709899899, + 50.80423696434001 + ], + [ + 7.850461006164548, + 50.80217570040005 + ], + [ + 7.845568656921382, + 50.79829702304368 + ] + ] + ] + ], + "properties": { + "title": "TEST_account_CHANGED", + "deductable_surface": "100000.0", + "responsible": { + "conservation_office": null, + "conservation_file_number": "123-TEST", + "handler": "TEST_HANDLER_CHANGED" + }, + "legal": { + "agreement_date": "2022-01-11" + }, + "before_states": [ + ], + "after_states": [ + ], + "actions": [ + ], + "deadlines": [ + { + "type": "finished", + "date": "2022-01-31", + "comment": "TEST_CHANGED" + } + ] + } +} \ No newline at end of file diff --git a/api/tests/v1/update/ema_update_put_body.json b/api/tests/v1/update/ema_update_put_body.json new file mode 100644 index 0000000..933c4fc --- /dev/null +++ b/api/tests/v1/update/ema_update_put_body.json @@ -0,0 +1,63 @@ +{ + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 7.845568656921382, + 50.79829702304368 + ], + [ + 7.837371826171871, + 50.80155187891526 + ], + [ + 7.835698127746578, + 50.805267562209806 + ], + [ + 7.841062545776364, + 50.806623577403386 + ], + [ + 7.848916053771969, + 50.808359219420474 + ], + [ + 7.855696678161618, + 50.807057493952975 + ], + [ + 7.854666709899899, + 50.80423696434001 + ], + [ + 7.850461006164548, + 50.80217570040005 + ], + [ + 7.845568656921382, + 50.79829702304368 + ] + ] + ] + ], + "properties": { + "title": "TEST_EMA_CHANGED", + "responsible": { + "conservation_office": null, + "conservation_file_number": "TEST_CHANGED", + "handler": "TEST_HANDLER_CHANGED" + }, + "before_states": [], + "after_states": [], + "actions": [], + "deadlines": [ + { + "type": "finished", + "date": "2022-01-31", + "comment": "TEST_CHANGED" + } + ] + } +} \ No newline at end of file diff --git a/api/tests/v1/update/intervention_update_put_body.json b/api/tests/v1/update/intervention_update_put_body.json new file mode 100644 index 0000000..0376424 --- /dev/null +++ b/api/tests/v1/update/intervention_update_put_body.json @@ -0,0 +1,61 @@ +{ + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 7.845568656921382, + 50.79829702304368 + ], + [ + 7.837371826171871, + 50.80155187891526 + ], + [ + 7.835698127746578, + 50.805267562209806 + ], + [ + 7.841062545776364, + 50.806623577403386 + ], + [ + 7.848916053771969, + 50.808359219420474 + ], + [ + 7.855696678161618, + 50.807057493952975 + ], + [ + 7.854666709899899, + 50.80423696434001 + ], + [ + 7.850461006164548, + 50.80217570040005 + ], + [ + 7.845568656921382, + 50.79829702304368 + ] + ] + ] + ], + "properties": { + "title": "Test_intervention_CHANGED", + "responsible": { + "registration_office": null, + "registration_file_number": "CHANGED", + "conservation_office": null, + "conservation_file_number": "CHANGED", + "handler": null + }, + "legal": { + "registration_date": "2022-02-01", + "binding_date": "2022-02-01", + "process_type": null, + "laws": [] + } + } +} \ 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 new file mode 100644 index 0000000..e371188 --- /dev/null +++ b/api/tests/v1/update/test_api_update.py @@ -0,0 +1,186 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 28.01.22 + +""" + +import json + +from django.contrib.gis import geos +from django.urls import reverse + +from api.tests.v1.share.test_api_sharing import BaseAPIV1TestCase + + +class APIV1UpdateTestCase(BaseAPIV1TestCase): + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + def _run_update_request(self, url, data): + data = json.dumps(data) + response = self.client.put( + url, + data=data, + content_type="application/json", + **self.header_data + ) + return response + + def _test_update_object(self, url, put_body): + """ Tests the API update of a data object. + + Put body data stored in a local json file + + Args: + url (str): The api creation url + put_body (dict): The put body content as dict + + Returns: + + """ + response = self._run_update_request(url, put_body) + self.assertEqual(response.status_code, 200, msg=response.content) + content = json.loads(response.content) + self.assertIsNotNone(content.get("id", None), msg=response.content) + + def test_update_intervention(self): + """ Tests api update + + Returns: + + """ + self.intervention.share_with(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" + with open(json_file_path) as json_file: + put_body = json.load(fp=json_file) + self._test_update_object(url, put_body) + self.intervention.refresh_from_db() + + put_props = put_body["properties"] + put_geom = geos.fromstr(json.dumps(put_body)) + self.assertEqual(put_geom, self.intervention.geometry.geom) + self.assertEqual(put_props["title"], self.intervention.title) + self.assertNotEqual(modified_on, self.intervention.modified) + self.assertEqual(put_props["responsible"]["registration_file_number"], self.intervention.responsible.registration_file_number) + self.assertEqual(put_props["responsible"]["conservation_file_number"], self.intervention.responsible.conservation_file_number) + self.assertEqual(put_props["legal"]["registration_date"], str(self.intervention.legal.registration_date)) + self.assertEqual(put_props["legal"]["binding_date"], str(self.intervention.legal.binding_date)) + + def test_update_compensation(self): + """ Tests api update + + Returns: + + """ + self.compensation.intervention = self.intervention + self.compensation.save() + self.intervention.share_with(self.superuser) + + modified_on = self.compensation.modified + url = reverse("api:v1:compensation", args=(str(self.compensation.id),)) + json_file_path = "api/tests/v1/update/compensation_update_put_body.json" + with open(json_file_path) as json_file: + put_body = json.load(fp=json_file) + put_body["properties"]["intervention"] = str(self.intervention.id) + self._test_update_object(url, put_body) + self.compensation.refresh_from_db() + + put_props = put_body["properties"] + put_geom = geos.fromstr(json.dumps(put_body)) + self.assertEqual(put_geom, self.compensation.geometry.geom) + self.assertEqual(put_props["title"], self.compensation.title) + self.assertNotEqual(modified_on, self.compensation.modified) + self.assertEqual(put_props["is_cef"], self.compensation.is_cef) + self.assertEqual(put_props["is_coherence_keeping"], self.compensation.is_coherence_keeping) + self.assertEqual(len(put_props["actions"]), self.compensation.actions.count()) + self.assertEqual(len(put_props["before_states"]), self.compensation.before_states.count()) + self.assertEqual(len(put_props["after_states"]), self.compensation.after_states.count()) + self.assertEqual(len(put_props["deadlines"]), self.compensation.deadlines.count()) + + def test_update_ecoaccount(self): + """ Tests api update + + Returns: + + """ + self.eco_account.share_with(self.superuser) + + modified_on = self.eco_account.modified + url = reverse("api:v1:ecoaccount", args=(str(self.eco_account.id),)) + json_file_path = "api/tests/v1/update/ecoaccount_update_put_body.json" + with open(json_file_path) as json_file: + put_body = json.load(fp=json_file) + self._test_update_object(url, put_body) + self.eco_account.refresh_from_db() + + put_props = put_body["properties"] + put_geom = geos.fromstr(json.dumps(put_body)) + self.assertEqual(put_geom, self.eco_account.geometry.geom) + self.assertEqual(put_props["title"], self.eco_account.title) + self.assertNotEqual(modified_on, self.eco_account.modified) + self.assertEqual(put_props["deductable_surface"], str(self.eco_account.deductable_surface)) + self.assertEqual(put_props["responsible"]["conservation_office"], self.eco_account.responsible.conservation_office) + self.assertEqual(put_props["responsible"]["conservation_file_number"], self.eco_account.responsible.conservation_file_number) + self.assertEqual(put_props["responsible"]["handler"], self.eco_account.responsible.handler) + self.assertEqual(put_props["legal"]["agreement_date"], str(self.eco_account.legal.registration_date)) + self.assertEqual(len(put_props["actions"]), self.eco_account.actions.count()) + self.assertEqual(len(put_props["before_states"]), self.eco_account.before_states.count()) + self.assertEqual(len(put_props["after_states"]), self.eco_account.after_states.count()) + self.assertEqual(len(put_props["deadlines"]), self.eco_account.deadlines.count()) + + def test_update_ema(self): + """ Tests api update + + Returns: + + """ + self.ema.share_with(self.superuser) + + modified_on = self.ema.modified + url = reverse("api:v1:ema", args=(str(self.ema.id),)) + json_file_path = "api/tests/v1/update/ema_update_put_body.json" + with open(json_file_path) as json_file: + put_body = json.load(fp=json_file) + self._test_update_object(url, put_body) + self.ema.refresh_from_db() + + put_props = put_body["properties"] + put_geom = geos.fromstr(json.dumps(put_body)) + self.assertEqual(put_geom, self.ema.geometry.geom) + self.assertEqual(put_props["title"], self.ema.title) + self.assertNotEqual(modified_on, self.ema.modified) + self.assertEqual(put_props["responsible"]["conservation_office"], self.ema.responsible.conservation_office) + self.assertEqual(put_props["responsible"]["conservation_file_number"], self.ema.responsible.conservation_file_number) + self.assertEqual(put_props["responsible"]["handler"], self.ema.responsible.handler) + self.assertEqual(len(put_props["actions"]), self.ema.actions.count()) + self.assertEqual(len(put_props["before_states"]), self.ema.before_states.count()) + self.assertEqual(len(put_props["after_states"]), self.ema.after_states.count()) + self.assertEqual(len(put_props["deadlines"]), self.ema.deadlines.count()) + + def test_update_deduction(self): + """ Tests api update + + Returns: + + """ + self.deduction.intervention.share_with(self.superuser) + self.deduction.account.share_with(self.superuser) + + url = reverse("api:v1:deduction", args=(str(self.deduction.id),)) + json_file_path = "api/tests/v1/update/deduction_update_put_body.json" + with open(json_file_path) as json_file: + put_body = json.load(fp=json_file) + put_body["intervention"] = str(self.deduction.intervention.id) + put_body["eco_account"] = str(self.deduction.account.id) + + self._test_update_object(url, put_body) + self.deduction.refresh_from_db() + + 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) diff --git a/api/urls/__init__.py b/api/urls/__init__.py new file mode 100644 index 0000000..14cc6f5 --- /dev/null +++ b/api/urls/__init__.py @@ -0,0 +1,8 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 21.01.22 + +""" +from .urls import * diff --git a/api/urls/urls.py b/api/urls/urls.py new file mode 100644 index 0000000..abe9eac --- /dev/null +++ b/api/urls/urls.py @@ -0,0 +1,17 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 21.01.22 + +""" +from django.urls import path, include + +from api.views.method_views import generate_new_token_view + +app_name = "api" + +urlpatterns = [ + path("v1/", include("api.urls.v1.urls", namespace="v1")), + path("token/generate", generate_new_token_view, name="generate-new-token"), +] \ No newline at end of file diff --git a/api/urls/v1/__init__.py b/api/urls/v1/__init__.py new file mode 100644 index 0000000..c6636d5 --- /dev/null +++ b/api/urls/v1/__init__.py @@ -0,0 +1,7 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 21.01.22 + +""" \ No newline at end of file diff --git a/api/urls/v1/urls.py b/api/urls/v1/urls.py new file mode 100644 index 0000000..24439a4 --- /dev/null +++ b/api/urls/v1/urls.py @@ -0,0 +1,34 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 21.01.22 + +""" +from django.urls import path + +from api.views.v1.views import EmaAPIViewV1, EcoAccountAPIViewV1, CompensationAPIViewV1, InterventionAPIViewV1, \ + DeductionAPIViewV1 +from api.views.views import InterventionCheckAPIView, InterventionAPIShareView, EcoAccountAPIShareView, EmaAPIShareView + +app_name = "v1" +urlpatterns = [ + path("intervention//check", InterventionCheckAPIView.as_view(), name="intervention-check"), + path("intervention//share", InterventionAPIShareView.as_view(), name="intervention-share"), + path("intervention/", InterventionAPIViewV1.as_view(), name="intervention"), + path("intervention/", InterventionAPIViewV1.as_view(), name="intervention"), + + path("compensation/", CompensationAPIViewV1.as_view(), name="compensation"), + path("compensation/", CompensationAPIViewV1.as_view(), name="compensation"), + + path("ecoaccount//share", EcoAccountAPIShareView.as_view(), name="ecoaccount-share"), + path("ecoaccount/", EcoAccountAPIViewV1.as_view(), name="ecoaccount"), + path("ecoaccount/", EcoAccountAPIViewV1.as_view(), name="ecoaccount"), + + path("deduction/", DeductionAPIViewV1.as_view(), name="deduction"), + path("deduction/", DeductionAPIViewV1.as_view(), name="deduction"), + + path("ema//share", EmaAPIShareView.as_view(), name="ema-share"), + path("ema/", EmaAPIViewV1.as_view(), name="ema"), + path("ema/", EmaAPIViewV1.as_view(), name="ema"), +] diff --git a/api/utils/__init__.py b/api/utils/__init__.py new file mode 100644 index 0000000..71a67bb --- /dev/null +++ b/api/utils/__init__.py @@ -0,0 +1,7 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 24.01.22 + +""" diff --git a/api/utils/serializer/__init__.py b/api/utils/serializer/__init__.py new file mode 100644 index 0000000..71a67bb --- /dev/null +++ b/api/utils/serializer/__init__.py @@ -0,0 +1,7 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 24.01.22 + +""" diff --git a/api/utils/serializer/serializer.py b/api/utils/serializer/serializer.py new file mode 100644 index 0000000..443e10c --- /dev/null +++ b/api/utils/serializer/serializer.py @@ -0,0 +1,166 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 24.01.22 + +""" +import json +from abc import abstractmethod + +from django.contrib.gis import geos +from django.contrib.gis.geos import GEOSGeometry + +from konova.utils.message_templates import DATA_UNSHARED + + +class AbstractModelAPISerializer: + model = None + lookup = None + properties_data = None + + class Meta: + abstract = True + + def __init__(self, *args, **kwargs): + self.lookup = { + "id": None, # must be set + "deleted__isnull": True, + "users__in": [], # must be set + } + super().__init__(*args, **kwargs) + + @abstractmethod + def _model_to_geo_json(self, entry): + """ Defines the model as geo json + + Args: + entry (): The found entry from the database + + Returns: + + """ + raise NotImplementedError("Must be implemented in subclasses") + + @abstractmethod + def _extend_properties_data(self, entry): + """ Defines the 'properties' part of geo json + + Args: + entry (): The found entry from the database + + Returns: + + """ + raise NotImplementedError("Must be implemented in subclasses") + + def prepare_lookup(self, _id, user): + """ Updates lookup dict for db fetching + + Args: + _id (str): The object's id + user (User): The user requesting for + + Returns: + + """ + if _id is None: + # Return all objects + del self.lookup["id"] + else: + # Return certain object + self.lookup["id"] = _id + self.lookup["users__in"] = [user] + + def fetch_and_serialize(self): + """ Serializes the model entry according to the given lookup data + + Args: + + Returns: + serialized_data (dict) + """ + entries = self.model.objects.filter(**self.lookup) + serialized_data = {} + for entry in entries: + serialized_data[str(entry.id)] = self._model_to_geo_json(entry) + return serialized_data + + @abstractmethod + def update_model_from_json(self, id, json_model, user): + """ Updates an instance from given json data + + Args: + id (str): The instance's to be updated + json_model (dict): JSON data + user (User): The performing user + + Returns: + + """ + raise NotImplementedError("Must be implemented in subclasses") + + @abstractmethod + def create_model_from_json(self, json_model, user): + """ Creates a new instance from given json data + + Args: + json_model (dict): JSON data + user (User): The performing user + + Returns: + + """ + raise NotImplementedError("Must be implemented in subclasses") + + def _create_geometry_from_json(self, geojson) -> GEOSGeometry: + """ Creates a GEOSGeometry object based on the given geojson + + Args: + geojson (str|dict): The geojson as str or dict + + Returns: + geometry (GEOSGeometry) + """ + if isinstance(geojson, dict): + geojson = json.dumps(geojson) + geometry = geos.fromstr(geojson) + if geometry.empty: + geometry = None + return geometry + + def _get_obj_from_db(self, id, user): + """ Returns the object from database + + Fails if id not found or user does not have shared access + + Args: + id (str): The object's id + user (User): The API user + + Returns: + + """ + obj = self.model.objects.get( + id=id, + deleted__isnull=True, + ) + is_shared = obj.is_shared_with(user) + if not is_shared: + raise PermissionError(DATA_UNSHARED) + return obj + + @abstractmethod + def _initialize_objects(self, json_model, user): + """ Initializes all needed objects from the json_model data + + Does not persist data to the DB! + + Args: + json_model (dict): The json data + user (User): The API user + + Returns: + obj (Intervention) + """ + raise NotImplementedError("Must be implemented in subclasses") diff --git a/api/utils/serializer/v1/__init__.py b/api/utils/serializer/v1/__init__.py new file mode 100644 index 0000000..71a67bb --- /dev/null +++ b/api/utils/serializer/v1/__init__.py @@ -0,0 +1,7 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 24.01.22 + +""" diff --git a/api/utils/serializer/v1/compensation.py b/api/utils/serializer/v1/compensation.py new file mode 100644 index 0000000..24e499e --- /dev/null +++ b/api/utils/serializer/v1/compensation.py @@ -0,0 +1,169 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 24.01.22 + +""" +from django.db import transaction + +from api.utils.serializer.v1.serializer import AbstractModelAPISerializerV1, AbstractCompensationAPISerializerV1Mixin +from compensation.models import Compensation +from intervention.models import Intervention +from konova.models import Geometry +from konova.tasks import celery_update_parcels +from konova.utils.message_templates import DATA_UNSHARED +from user.models import UserActionLogEntry + + +class CompensationAPISerializerV1(AbstractModelAPISerializerV1, AbstractCompensationAPISerializerV1Mixin): + model = Compensation + + def prepare_lookup(self, id, user): + super().prepare_lookup(id, user) + del self.lookup["users__in"] + self.lookup["intervention__users__in"] = [user] + + def intervention_to_json(self, entry): + return { + "id": entry.pk, + "identifier": entry.identifier, + "title": entry.title, + } + + def _extend_properties_data(self, entry): + self.properties_data["is_cef"] = entry.is_cef + self.properties_data["is_coherence_keeping"] = entry.is_coherence_keeping + self.properties_data["intervention"] = self.intervention_to_json(entry.intervention) + self.properties_data["before_states"] = self._compensation_state_to_json(entry.before_states.all()) + self.properties_data["after_states"] = self._compensation_state_to_json(entry.after_states.all()) + self.properties_data["actions"] = self._compensation_actions_to_json(entry.actions.all()) + self.properties_data["deadlines"] = self._deadlines_to_json(entry.deadlines.all()) + + def _initialize_objects(self, json_model, user): + """ Initializes all needed objects from the json_model data + + Does not persist data to the DB! + + Args: + json_model (dict): The json data + user (User): The API user + + Returns: + obj (Compensation) + """ + create_action = UserActionLogEntry.get_created_action(user, comment="API Import") + # Create geometry + json_geom = self._create_geometry_from_json(json_model) + geometry = Geometry() + geometry.geom = json_geom + geometry.created = create_action + + # Create linked objects + obj = Compensation() + created = create_action + obj.created = created + obj.geometry = geometry + return obj + + def set_intervention(self, obj, intervention_id, user): + """ Sets the linked compensation according to the given id + + Fails if no such intervention found or user has no shared access + + Args: + obj (Compensation): The Compensation object + intervention_id (str): The intervention's id + user (User): The API user + + Returns: + obj (Compensation) + """ + if obj.intervention is not None and obj.intervention.id == intervention_id: + # Nothing to do here + return obj + + intervention = Intervention.objects.get( + id=intervention_id, + ) + is_shared = intervention.is_shared_with(user) + + if not is_shared: + raise PermissionError(DATA_UNSHARED) + + obj.intervention = intervention + return obj + + def create_model_from_json(self, json_model, user): + """ Creates a new entry for the model based on the contents of json_model + + Args: + json_model (dict): The json containing data + user (User): The API user + + Returns: + created_id (str): The id of the newly created Compensation entry + """ + with transaction.atomic(): + obj = self._initialize_objects(json_model, user) + + # Fill in data to objects + properties = json_model["properties"] + obj.identifier = obj.generate_new_identifier() + obj.title = properties["title"] + obj.is_cef = properties["is_cef"] + obj.is_coherence_keeping = properties["is_coherence_keeping"] + obj = self.set_intervention(obj, properties["intervention"], user) + + obj.geometry.save() + obj.save() + + obj = self._set_compensation_actions(obj, properties["actions"]) + obj = self._set_compensation_states(obj, properties["before_states"], obj.before_states) + obj = self._set_compensation_states(obj, properties["after_states"], obj.after_states) + obj = self._set_deadlines(obj, properties["deadlines"]) + + obj.log.add(obj.created) + + celery_update_parcels.delay(obj.geometry.id) + + return obj.id + + def update_model_from_json(self, id, json_model, user): + """ Updates an entry for the model based on the contents of json_model + + Args: + id (str): The object's id + json_model (dict): The json containing data + user (User): The API user + + Returns: + created_id (str): The id of the newly created Compensation entry + """ + with transaction.atomic(): + update_action = UserActionLogEntry.get_edited_action(user, "API update") + obj = self._get_obj_from_db(id, user) + + # Fill in data to objects + properties = json_model["properties"] + obj.title = properties["title"] + obj.is_cef = properties["is_cef"] + obj.is_coherence_keeping = properties["is_coherence_keeping"] + obj.modified = update_action + obj.geometry.geom = self._create_geometry_from_json(json_model) + obj.geometry.modified = update_action + obj = self.set_intervention(obj, properties["intervention"], user) + + obj.geometry.save() + obj.save() + + obj = self._set_compensation_actions(obj, properties["actions"]) + obj = self._set_compensation_states(obj, properties["before_states"], obj.before_states) + obj = self._set_compensation_states(obj, properties["after_states"], obj.after_states) + obj = self._set_deadlines(obj, properties["deadlines"]) + + obj.log.add(update_action) + + celery_update_parcels.delay(obj.geometry.id) + + return obj.id diff --git a/api/utils/serializer/v1/deduction.py b/api/utils/serializer/v1/deduction.py new file mode 100644 index 0000000..c66a212 --- /dev/null +++ b/api/utils/serializer/v1/deduction.py @@ -0,0 +1,166 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 28.01.22 + +""" +from django.core.exceptions import ObjectDoesNotExist + +from api.utils.serializer.v1.serializer import DeductableAPISerializerV1Mixin, AbstractModelAPISerializerV1 +from compensation.models import EcoAccountDeduction, EcoAccount +from intervention.models import Intervention +from konova.utils.message_templates import DATA_UNSHARED + + +class DeductionAPISerializerV1(AbstractModelAPISerializerV1, + DeductableAPISerializerV1Mixin): + model = EcoAccountDeduction + + def prepare_lookup(self, _id, user): + """ Updates lookup dict for db fetching + + Args: + _id (str): The object's id + user (User): The user requesting for + + Returns: + + """ + super().prepare_lookup(_id, user) + del self.lookup["users__in"] + del self.lookup["deleted__isnull"] + self.lookup["intervention__users__in"] = [user] + + def _model_to_geo_json(self, entry): + """ Adds the basic data + + Args: + entry (): The data entry + + Returns: + + """ + return self._single_deduction_to_json(entry) + + def create_model_from_json(self, json_model, user): + """ Creates a new entry for the model based on the contents of json_model + + Args: + json_model (dict): The json containing data + user (User): The API user + + Returns: + created_id (str): The id of the newly created Intervention entry + """ + acc_id = json_model["eco_account"] + intervention_id = json_model["intervention"] + surface = float(json_model["surface"]) + if surface <= 0: + raise ValueError("Surface must be > 0 m²") + + acc = EcoAccount.objects.get( + id=acc_id, + deleted__isnull=True, + ) + intervention = Intervention.objects.get( + id=intervention_id, + deleted__isnull=True, + ) + acc_shared = acc.is_shared_with(user) + intervention_shared = intervention.is_shared_with(user) + if not acc_shared: + raise PermissionError(f"Account: {DATA_UNSHARED}") + if not intervention_shared: + raise PermissionError(f"Intervention: {DATA_UNSHARED}") + + deduction = self.model.objects.create( + intervention=intervention, + account=acc, + surface=surface + ) + deduction.intervention.mark_as_edited(user) + return str(deduction.id) + + def _get_obj_from_db(self, id, user): + """ Returns the object from database + + Fails if id not found or user does not have shared access + + Args: + id (str): The object's id + user (User): The API user + + Returns: + + """ + obj = self.model.objects.get( + id=id, + ) + shared_with = obj.intervention.is_shared_with(user) + if not shared_with: + raise PermissionError(f"Intervention: {DATA_UNSHARED}") + return obj + + def update_model_from_json(self, id, json_model, user): + """ Updates an entry for the model based on the contents of json_model + + Args: + id (str): The object's id + json_model (dict): The json containing data + user (User): The API user + + Returns: + created_id (str): The id of the newly created Intervention entry + """ + deduction = self._get_obj_from_db(id, user) + + acc_id = json_model["eco_account"] + intervention_id = json_model["intervention"] + surface = float(json_model["surface"]) + if surface <= 0: + raise ValueError("Surface must be > 0 m²") + + acc = EcoAccount.objects.get( + id=acc_id, + deleted__isnull=True, + ) + intervention = Intervention.objects.get( + id=intervention_id, + deleted__isnull=True, + ) + acc_shared = acc.is_shared_with(user) + intervention_shared = intervention.is_shared_with(user) + if not acc_shared: + raise PermissionError(f"Account: {DATA_UNSHARED}") + if not intervention_shared: + raise PermissionError(f"Intervention: {DATA_UNSHARED}") + + deduction.intervention = intervention + deduction.account = acc + deduction.surface = surface + deduction.save() + + deduction.intervention.mark_as_edited(user) + + return str(deduction.id) + + def delete_entry(self, id, user): + """ Deletes the entry + + Args: + id (str): The entry's id + user (User): The API user + + Returns: + + """ + entry = self._get_obj_from_db(id, user) + entry.intervention.mark_as_edited(user) + entry.delete() + try: + entry.refresh_from_db() + success = False + except ObjectDoesNotExist: + success = True + return success diff --git a/api/utils/serializer/v1/ecoaccount.py b/api/utils/serializer/v1/ecoaccount.py new file mode 100644 index 0000000..0c3e139 --- /dev/null +++ b/api/utils/serializer/v1/ecoaccount.py @@ -0,0 +1,186 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 24.01.22 + +""" +from django.db import transaction + +from api.utils.serializer.v1.serializer import AbstractModelAPISerializerV1, AbstractCompensationAPISerializerV1Mixin, \ + LegalAPISerializerV1Mixin, ResponsibilityAPISerializerV1Mixin, DeductableAPISerializerV1Mixin +from codelist.settings import CODELIST_CONSERVATION_OFFICE_ID +from compensation.models import EcoAccount +from intervention.models import Legal, Responsibility +from konova.models import Geometry +from konova.tasks import celery_update_parcels +from user.models import UserActionLogEntry + + +class EcoAccountAPISerializerV1(AbstractModelAPISerializerV1, + AbstractCompensationAPISerializerV1Mixin, + LegalAPISerializerV1Mixin, + ResponsibilityAPISerializerV1Mixin, + DeductableAPISerializerV1Mixin): + model = EcoAccount + + def _extend_properties_data(self, entry): + self.properties_data["deductable_surface"] = entry.deductable_surface + self.properties_data["deductable_surface_available"] = entry.deductable_surface - entry.get_deductions_surface() + self.properties_data["responsible"] = self._responsible_to_json(entry.responsible) + self.properties_data["legal"] = self._legal_to_json(entry.legal) + self.properties_data["before_states"] = self._compensation_state_to_json(entry.before_states.all()) + self.properties_data["after_states"] = self._compensation_state_to_json(entry.after_states.all()) + self.properties_data["actions"] = self._compensation_actions_to_json(entry.actions.all()) + self.properties_data["deadlines"] = self._deadlines_to_json(entry.deadlines.all()) + self.properties_data["deductions"] = self._deductions_to_json(entry.deductions.all()) + + def _legal_to_json(self, legal: Legal): + return { + "agreement_date": legal.registration_date, + } + + def _responsible_to_json(self, responsible: Responsibility): + return { + "conservation_office": self._konova_code_to_json(responsible.conservation_office), + "conservation_file_number": responsible.conservation_file_number, + "handler": responsible.handler, + } + + def _set_responsibility(self, obj, responsibility_data: dict): + """ Sets the responsible data contents to the provided responsibility_data dict + + Args: + obj (Intervention): The intervention object + responsibility_data (dict): The new data + + Returns: + obj + """ + if responsibility_data is None: + return obj + obj.responsible.conservation_office = self._konova_code_from_json( + responsibility_data["conservation_office"], + CODELIST_CONSERVATION_OFFICE_ID, + ) + obj.responsible.conservation_file_number = responsibility_data["conservation_file_number"] + obj.responsible.handler = responsibility_data["handler"] + return obj + + def _set_legal(self, obj, legal_data): + obj.legal.registration_date = legal_data.get("agreement_date", None) + return obj + + def _initialize_objects(self, json_model, user): + """ Initializes all needed objects from the json_model data + + Does not persist data to the DB! + + Args: + json_model (dict): The json data + user (User): The API user + + Returns: + obj (Compensation) + """ + create_action = UserActionLogEntry.get_created_action(user, comment="API Import") + # Create geometry + json_geom = self._create_geometry_from_json(json_model) + geometry = Geometry() + geometry.geom = json_geom + geometry.created = create_action + + # Create linked objects + obj = EcoAccount() + obj.responsible = Responsibility() + obj.legal = Legal() + created = create_action + obj.created = created + obj.geometry = geometry + return obj + + def create_model_from_json(self, json_model, user): + """ Creates a new entry for the model based on the contents of json_model + + Args: + json_model (dict): The json containing data + user (User): The API user + + Returns: + created_id (str): The id of the newly created EcoAccount entry + """ + with transaction.atomic(): + obj = self._initialize_objects(json_model, user) + + # Fill in data to objects + properties = json_model["properties"] + obj.identifier = obj.generate_new_identifier() + obj.title = properties["title"] + + try: + obj.deductable_surface = float(properties["deductable_surface"]) + except TypeError: + raise ValueError("Deductable surface (m²) must be a number >= 0") + if obj.deductable_surface < 0: + raise ValueError("Deductable surface (m²) must be greater or equal 0") + + obj = self._set_responsibility(obj, properties["responsible"]) + obj = self._set_legal(obj, properties["legal"]) + + obj.geometry.save() + obj.responsible.save() + obj.legal.save() + obj.save() + + obj = self._set_compensation_actions(obj, properties["actions"]) + obj = self._set_compensation_states(obj, properties["before_states"], obj.before_states) + obj = self._set_compensation_states(obj, properties["after_states"], obj.after_states) + obj = self._set_deadlines(obj, properties["deadlines"]) + + obj.log.add(obj.created) + obj.users.add(user) + + celery_update_parcels.delay(obj.geometry.id) + + return obj.id + + def update_model_from_json(self, id, json_model, user): + """ Updates an entry for the model based on the contents of json_model + + Args: + id (str): The object's id + json_model (dict): The json containing data + user (User): The API user + + Returns: + created_id (str): The id of the newly created EcoAccount entry + """ + with transaction.atomic(): + update_action = UserActionLogEntry.get_edited_action(user, "API update") + obj = self._get_obj_from_db(id, user) + + # Fill in data to objects + properties = json_model["properties"] + obj.title = properties["title"] + obj.deductable_surface = float(properties["deductable_surface"]) + obj.modified = update_action + obj.geometry.geom = self._create_geometry_from_json(json_model) + obj.geometry.modified = update_action + obj = self._set_responsibility(obj, properties["responsible"]) + obj = self._set_legal(obj, properties["legal"]) + + obj.geometry.save() + obj.responsible.save() + obj.legal.save() + obj.save() + + obj = self._set_compensation_actions(obj, properties["actions"]) + obj = self._set_compensation_states(obj, properties["before_states"], obj.before_states) + obj = self._set_compensation_states(obj, properties["after_states"], obj.after_states) + obj = self._set_deadlines(obj, properties["deadlines"]) + + obj.log.add(update_action) + + celery_update_parcels.delay(obj.geometry.id) + + return obj.id diff --git a/api/utils/serializer/v1/ema.py b/api/utils/serializer/v1/ema.py new file mode 100644 index 0000000..547f223 --- /dev/null +++ b/api/utils/serializer/v1/ema.py @@ -0,0 +1,155 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 24.01.22 + +""" +from django.db import transaction + +from api.utils.serializer.v1.serializer import AbstractModelAPISerializerV1, AbstractCompensationAPISerializerV1Mixin, \ + ResponsibilityAPISerializerV1Mixin +from codelist.settings import CODELIST_CONSERVATION_OFFICE_ID +from ema.models import Ema +from intervention.models import Responsibility +from konova.models import Geometry +from konova.tasks import celery_update_parcels +from user.models import UserActionLogEntry + + +class EmaAPISerializerV1(AbstractModelAPISerializerV1, AbstractCompensationAPISerializerV1Mixin, ResponsibilityAPISerializerV1Mixin): + model = Ema + + def _extend_properties_data(self, entry): + self.properties_data["responsible"] = self._responsible_to_json(entry.responsible) + self.properties_data["before_states"] = self._compensation_state_to_json(entry.before_states.all()) + self.properties_data["after_states"] = self._compensation_state_to_json(entry.after_states.all()) + self.properties_data["actions"] = self._compensation_actions_to_json(entry.actions.all()) + self.properties_data["deadlines"] = self._deadlines_to_json(entry.deadlines.all()) + + def _responsible_to_json(self, responsible: Responsibility): + return { + "conservation_office": self._konova_code_to_json(responsible.conservation_office), + "conservation_file_number": responsible.conservation_file_number, + "handler": responsible.handler, + } + + def _set_responsibility(self, obj, responsibility_data: dict): + """ Sets the responsible data contents to the provided responsibility_data dict + + Args: + obj (Intervention): The intervention object + responsibility_data (dict): The new data + + Returns: + obj + """ + if responsibility_data is None: + return obj + obj.responsible.conservation_office = self._konova_code_from_json( + responsibility_data["conservation_office"], + CODELIST_CONSERVATION_OFFICE_ID, + ) + obj.responsible.conservation_file_number = responsibility_data["conservation_file_number"] + obj.responsible.handler = responsibility_data["handler"] + return obj + + def _initialize_objects(self, json_model, user): + """ Initializes all needed objects from the json_model data + + Does not persist data to the DB! + + Args: + json_model (dict): The json data + user (User): The API user + + Returns: + obj (Compensation) + """ + create_action = UserActionLogEntry.get_created_action(user, comment="API Import") + # Create geometry + json_geom = self._create_geometry_from_json(json_model) + geometry = Geometry() + geometry.geom = json_geom + geometry.created = create_action + + # Create linked objects + obj = Ema() + obj.responsible = Responsibility() + created = create_action + obj.created = created + obj.geometry = geometry + return obj + + def create_model_from_json(self, json_model, user): + """ Creates a new entry for the model based on the contents of json_model + + Args: + json_model (dict): The json containing data + user (User): The API user + + Returns: + created_id (str): The id of the newly created Ema entry + """ + with transaction.atomic(): + obj = self._initialize_objects(json_model, user) + + # Fill in data to objects + properties = json_model["properties"] + obj.identifier = obj.generate_new_identifier() + obj.title = properties["title"] + obj = self._set_responsibility(obj, properties["responsible"]) + + obj.geometry.save() + obj.responsible.save() + obj.save() + + obj = self._set_compensation_actions(obj, properties["actions"]) + obj = self._set_compensation_states(obj, properties["before_states"], obj.before_states) + obj = self._set_compensation_states(obj, properties["after_states"], obj.after_states) + obj = self._set_deadlines(obj, properties["deadlines"]) + + obj.log.add(obj.created) + obj.users.add(user) + + celery_update_parcels.delay(obj.geometry.id) + + return obj.id + + def update_model_from_json(self, id, json_model, user): + """ Updates an entry for the model based on the contents of json_model + + Args: + id (str): The object's id + json_model (dict): The json containing data + user (User): The API user + + Returns: + created_id (str): The id of the newly created Ema entry + """ + with transaction.atomic(): + update_action = UserActionLogEntry.get_edited_action(user, "API update") + obj = self._get_obj_from_db(id, user) + + # Fill in data to objects + properties = json_model["properties"] + obj.title = properties["title"] + obj.modified = update_action + obj.geometry.geom = self._create_geometry_from_json(json_model) + obj.geometry.modified = update_action + obj = self._set_responsibility(obj, properties["responsible"]) + + obj.geometry.save() + obj.responsible.save() + obj.save() + + obj = self._set_compensation_actions(obj, properties["actions"]) + obj = self._set_compensation_states(obj, properties["before_states"], obj.before_states) + obj = self._set_compensation_states(obj, properties["after_states"], obj.after_states) + obj = self._set_deadlines(obj, properties["deadlines"]) + + obj.log.add(update_action) + + celery_update_parcels.delay(obj.geometry.id) + + return obj.id diff --git a/api/utils/serializer/v1/intervention.py b/api/utils/serializer/v1/intervention.py new file mode 100644 index 0000000..9fa7b71 --- /dev/null +++ b/api/utils/serializer/v1/intervention.py @@ -0,0 +1,200 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 24.01.22 + +""" +from django.db import transaction +from django.db.models import QuerySet + +from api.utils.serializer.v1.serializer import AbstractModelAPISerializerV1, \ + ResponsibilityAPISerializerV1Mixin, LegalAPISerializerV1Mixin, DeductableAPISerializerV1Mixin +from compensation.models import Payment +from intervention.models import Intervention, Responsibility, Legal +from konova.models import Geometry +from konova.tasks import celery_update_parcels +from user.models import UserActionLogEntry + + +class InterventionAPISerializerV1(AbstractModelAPISerializerV1, + ResponsibilityAPISerializerV1Mixin, + LegalAPISerializerV1Mixin, + DeductableAPISerializerV1Mixin): + model = Intervention + + def _compensations_to_json(self, qs: QuerySet): + return list( + qs.values( + "id", "identifier", "title" + ) + ) + + def _payments_to_json(self, qs: QuerySet): + """ Serializes payments into json + + Args: + qs (QuerySet): A queryset of Payment entries + + Returns: + serialized_json (list) + """ + return list(qs.values("amount", "due_on", "comment")) + + def _extend_properties_data(self, entry): + self.properties_data["responsible"] = self._responsible_to_json(entry.responsible) + self.properties_data["legal"] = self._legal_to_json(entry.legal) + self.properties_data["compensations"] = self._compensations_to_json(entry.compensations.all()) + self.properties_data["payments"] = self._payments_to_json(entry.payments.all()) + self.properties_data["deductions"] = self._deductions_to_json(entry.deductions.all()) + + def _initialize_objects(self, json_model, user): + """ Initializes all needed objects from the json_model data + + Does not persist data to the DB! + + Args: + json_model (dict): The json data + user (User): The API user + + Returns: + obj (Intervention) + """ + create_action = UserActionLogEntry.get_created_action(user, comment="API Import") + # Create geometry + json_geom = self._create_geometry_from_json(json_model) + geometry = Geometry() + geometry.geom = json_geom + geometry.created = create_action + + # Create linked objects + obj = Intervention() + resp = Responsibility() + legal = Legal() + created = create_action + obj.legal = legal + obj.created = created + obj.geometry = geometry + obj.responsible = resp + return obj + + def _set_payments(self, obj, payment_data): + """ Sets the linked Payment data according to the given payment_data + + + Args: + obj (Compensation): The Compensation object + payment_data (dict): The posted payment_data + + Returns: + obj (intervention) + """ + if payment_data is None: + return obj + payments = [] + for entry in payment_data: + due_on = entry["due_on"] + amount = float(entry["amount"]) + comment = entry["comment"] + + # Check on validity + if amount <= 0: + raise ValueError("Payment amount must be > 0") + + no_due_on = due_on is None or len(due_on) == 0 + no_comment = comment is None or len(comment) == 0 + + if no_due_on and no_comment: + raise ValueError("If no due_on can be provided, you need to explain why using the comment") + + # 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 + pre_existing_payment = obj.payments.filter( + amount=amount, + due_on=due_on, + comment=comment, + ).exclude( + id__in=payments + ).first() + if pre_existing_payment is not None: + payments.append(pre_existing_payment.id) + else: + # Create and add id to list + new_payment = Payment.objects.create( + amount=amount, + due_on=due_on, + comment=comment, + ) + payments.append(new_payment.id) + payments = Payment.objects.filter( + id__in=payments + ) + obj.payments.set(payments) + return obj + + def create_model_from_json(self, json_model, user): + """ Creates a new entry for the model based on the contents of json_model + + Args: + json_model (dict): The json containing data + user (User): The API user + + Returns: + created_id (str): The id of the newly created Intervention entry + """ + with transaction.atomic(): + obj = self._initialize_objects(json_model, user) + + # Fill in data to objects + properties = json_model["properties"] + obj.identifier = obj.generate_new_identifier() + obj.title = properties["title"] + self._set_responsibility(obj, properties["responsible"]) + self._set_legal(obj, properties["legal"]) + + obj.responsible.save() + obj.geometry.save() + obj.legal.save() + obj.save() + + obj.users.add(user) + obj.log.add(obj.created) + + celery_update_parcels.delay(obj.geometry.id) + + return obj.id + + def update_model_from_json(self, id, json_model, user): + """ Updates an entry for the model based on the contents of json_model + + Args: + id (str): The object's id + json_model (dict): The json containing data + user (User): The API user + + Returns: + created_id (str): The id of the newly created Intervention entry + """ + with transaction.atomic(): + update_action = UserActionLogEntry.get_edited_action(user, "API update") + obj = self._get_obj_from_db(id, user) + + # Fill in data to objects + properties = json_model["properties"] + obj.title = properties["title"] + self._set_responsibility(obj, properties.get("responsible", None)) + self._set_legal(obj, properties.get("legal", None)) + self._set_payments(obj, properties.get("payments", None)) + obj.geometry.geom = self._create_geometry_from_json(json_model) + obj.geometry.modified = update_action + + obj.responsible.save() + obj.geometry.save() + obj.legal.save() + obj.save() + + obj.mark_as_edited(user) + + celery_update_parcels.delay(obj.geometry.id) + + return obj.id diff --git a/api/utils/serializer/v1/serializer.py b/api/utils/serializer/v1/serializer.py new file mode 100644 index 0000000..6642170 --- /dev/null +++ b/api/utils/serializer/v1/serializer.py @@ -0,0 +1,450 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 24.01.22 + +""" + +import json + +from django.contrib.gis.geos import MultiPolygon +from django.db.models import QuerySet + +from api.utils.serializer.serializer import AbstractModelAPISerializer +from codelist.models import KonovaCode +from codelist.settings import CODELIST_COMPENSATION_ACTION_ID, CODELIST_BIOTOPES_ID, CODELIST_PROCESS_TYPE_ID, \ + CODELIST_LAW_ID, CODELIST_REGISTRATION_OFFICE_ID, CODELIST_CONSERVATION_OFFICE_ID +from compensation.models import CompensationAction, UnitChoices, CompensationState +from intervention.models import Responsibility, Legal +from konova.models import Deadline, DeadlineType +from konova.utils.message_templates import DATA_UNSHARED + + +class AbstractModelAPISerializerV1(AbstractModelAPISerializer): + def _model_to_geo_json(self, entry): + """ Adds the basic data, which all elements hold + + Args: + entry (): The data entry + + Returns: + + """ + if entry.geometry.geom is not None: + geom = entry.geometry.geom.geojson + else: + geom = MultiPolygon().geojson + geo_json = json.loads(geom) + self.properties_data = { + "id": entry.id, + "identifier": entry.identifier, + "title": entry.title, + "created_on": self._created_on_to_json(entry), + "modified_on": self._modified_on_to_json(entry), + } + self._extend_properties_data(entry) + geo_json["properties"] = self.properties_data + return geo_json + + def _konova_code_to_json(self, konova_code: KonovaCode): + """ Serializes KonovaCode model into json + + Args: + konova_code (KonovaCode): The KonovaCode entry + + Returns: + serialized_json (dict) + """ + if konova_code is None: + return None + return { + "atom_id": konova_code.atom_id, + "long_name": konova_code.long_name, + "short_name": konova_code.short_name, + } + + def _konova_code_from_json(self, json_str, code_list_identifier): + """ Returns a konova code instance + + Args: + json_str (str): The value for the code (atom id) + code_list_identifier (str): From which konova code list this code is supposed to be from + + Returns: + + """ + if json_str is None or len(json_str) == 0: + return None + code = KonovaCode.objects.get( + atom_id=json_str, + code_lists__in=[code_list_identifier] + ) + return code + + def _created_on_to_json(self, entry): + """ Serializes the created_on into json + + Args: + entry (BaseObject): The entry + + Returns: + created_on (timestamp) + """ + return entry.created.timestamp if entry.created is not None else None + + def _modified_on_to_json(self, entry): + """ Serializes the modified_on into json + + Args: + entry (BaseObject): The entry + + Returns: + modified_on (timestamp) + """ + modified_on = entry.modified or entry.created + modified_on = modified_on.timestamp if modified_on is not None else None + return modified_on + + def delete_entry(self, id, user): + """ Marks an entry as deleted + + Args: + id (str): The entry's id + user (User): The API user + + Returns: + + """ + entry = self._get_obj_from_db(id, user) + is_shared = entry.is_shared_with(user) + if not is_shared: + raise PermissionError(DATA_UNSHARED) + # Do not send mails if entry is deleting using API. THere could be hundreds of deletion resulting in hundreds of + # mails at once. + entry.mark_as_deleted(user, send_mail=False) + entry.refresh_from_db() + success = entry.deleted is not None + return success + + +class DeductableAPISerializerV1Mixin: + class Meta: + abstract = True + + def _single_deduction_to_json(self, entry): + """ Serializes a single eco account deduction into json + + Args: + entry (EcoAccountDeduction): An EcoAccountDeduction + + Returns: + serialized_json (dict) + """ + return { + "id": entry.pk, + "eco_account": { + "id": entry.account.pk, + "identifier": entry.account.identifier, + "title": entry.account.title, + }, + "surface": entry.surface, + "intervention": { + "id": entry.intervention.pk, + "identifier": entry.intervention.identifier, + "title": entry.intervention.title, + } + } + + def _deductions_to_json(self, qs: QuerySet): + """ Serializes eco account deductions into json + + Args: + qs (QuerySet): A queryset of EcoAccountDeduction entries + + Returns: + serialized_json (list) + """ + return [ + self._single_deduction_to_json(entry) + for entry in qs + ] + + +class ResponsibilityAPISerializerV1Mixin: + class Meta: + abstract = True + + def _responsible_to_json(self, responsible: Responsibility): + """ Serializes Responsibility model into json + + Args: + responsible (Responsibility): The Responsibility entry + + Returns: + serialized_json (dict) + """ + return { + "registration_office": self._konova_code_to_json(responsible.registration_office), + "registration_file_number": responsible.registration_file_number, + "conservation_office": self._konova_code_to_json(responsible.conservation_office), + "conservation_file_number": responsible.conservation_file_number, + "handler": responsible.handler, + } + + def _set_responsibility(self, obj, responsibility_data: dict): + """ Sets the responsible data contents to the provided responsibility_data dict + + Args: + obj (Intervention): The intervention object + responsibility_data (dict): The new data + + Returns: + obj + """ + if responsibility_data is None: + return obj + obj.responsible.registration_office = self._konova_code_from_json( + responsibility_data["registration_office"], + CODELIST_REGISTRATION_OFFICE_ID + ) + obj.responsible.registration_file_number = responsibility_data["registration_file_number"] + obj.responsible.conservation_office = self._konova_code_from_json( + responsibility_data["conservation_office"], + CODELIST_CONSERVATION_OFFICE_ID, + ) + obj.responsible.conservation_file_number = responsibility_data["conservation_file_number"] + obj.responsible.handler = responsibility_data["handler"] + return obj + + +class LegalAPISerializerV1Mixin: + class Meta: + abstract = True + + def _legal_to_json(self, legal: Legal): + """ Serializes Legal model into json + + Args: + legal (Legal): The Legal entry + + Returns: + serialized_json (dict) + """ + return { + "registration_date": legal.registration_date, + "binding_date": legal.binding_date, + "process_type": self._konova_code_to_json(legal.process_type), + "laws": [self._konova_code_to_json(law) for law in legal.laws.all()], + } + + def _set_legal(self, obj, legal_data): + """ Sets the legal data contents to the provided legal_data dict + + Args: + obj (Intervention): The intervention object + legal_data (dict): The new data + + Returns: + obj + """ + if legal_data is None: + return obj + obj.legal.registration_date = legal_data.get("registration_date", None) + obj.legal.binding_date = legal_data.get("binding_date", None) + obj.legal.process_type = self._konova_code_from_json( + legal_data.get("process_type", None), + CODELIST_PROCESS_TYPE_ID, + ) + laws = [self._konova_code_from_json(law, CODELIST_LAW_ID) for law in legal_data.get("laws", [])] + obj.legal.laws.set(laws) + return obj + + +class AbstractCompensationAPISerializerV1Mixin: + class Meta: + abstract = True + + def _set_deadlines(self, obj, deadline_data): + """ Sets the linked deadline data according to the given deadline_data + + + Args: + obj (Compensation): The Compensation object + deadline_data (dict): The posted deadline_data + + Returns: + obj (Compensation) + """ + deadlines = [] + for entry in deadline_data: + deadline_type = entry["type"] + date = entry["date"] + comment = entry["comment"] + + # Check on validity + if deadline_type not in DeadlineType: + raise ValueError(f"Invalid deadline type. Choices are {DeadlineType.values}") + + # 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 + pre_existing_deadline = obj.deadlines.filter( + type=deadline_type, + date=date, + comment=comment, + ).exclude( + id__in=deadlines + ).first() + if pre_existing_deadline is not None: + deadlines.append(pre_existing_deadline.id) + else: + # Create and add id to list + new_deadline = Deadline.objects.create( + type=deadline_type, + date=date, + comment=comment, + ) + deadlines.append(new_deadline.id) + obj.deadlines.set(deadlines) + return obj + + def _set_compensation_states(self, obj, states_data, states_manager): + """ Sets the linked compensation state data according to the given states_data + + + Args: + obj (Compensation): The Compensation object + states_data (dict): The posted states_data + states_manager (Manager): The before_states or after_states manager + + Returns: + obj (Compensation) + """ + states = [] + for entry in states_data: + biotope_type = entry["biotope"] + surface = float(entry["surface"]) + + # Check on validity + if surface <= 0: + raise ValueError("State surfaces must be > 0") + + # 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 + pre_existing_state = states_manager.filter( + biotope_type__atom_id=biotope_type, + surface=surface, + ).exclude( + id__in=states + ).first() + if pre_existing_state is not None: + states.append(pre_existing_state.id) + else: + # Create and add id to list + new_state = CompensationState.objects.create( + biotope_type=self._konova_code_from_json(biotope_type, CODELIST_BIOTOPES_ID), + surface=surface + ) + states.append(new_state.id) + + states_manager.set(states) + return obj + + def _set_compensation_actions(self, obj, actions_data): + """ Sets the linked compensation action data according to the given actions_data + + + Args: + obj (Compensation): The Compensation object + actions_data (dict): The posted actions_data + + Returns: + obj (Compensation) + """ + actions = [] + for entry in actions_data: + action = entry["action"] + amount = float(entry["amount"]) + unit = entry["unit"] + comment = entry["comment"] + + # Check on validity + if amount <= 0: + raise ValueError("Action amount must be > 0") + if unit not in UnitChoices: + raise ValueError(f"Invalid unit. Choices are {UnitChoices.values}") + + # 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 + pre_existing_action = obj.actions.filter( + action_type__atom_id=action, + amount=amount, + unit=unit, + comment=comment, + ).exclude( + id__in=actions + ).first() + if pre_existing_action is not None: + actions.append(pre_existing_action.id) + else: + # Create and add id to list + new_action = CompensationAction.objects.create( + action_type=self._konova_code_from_json(action, CODELIST_COMPENSATION_ACTION_ID), + amount=amount, + unit=unit, + comment=comment, + ) + actions.append(new_action.id) + obj.actions.set(actions) + return obj + + def _compensation_state_to_json(self, qs: QuerySet): + """ Serializes compensation states into json + + Args: + qs (QuerySet): A queryset of CompensationState entries + + Returns: + serialized_json (list) + """ + return [ + { + "biotope": self._konova_code_to_json(entry.biotope_type), + "surface": entry.surface, + } + for entry in qs + ] + + def _compensation_actions_to_json(self, qs: QuerySet): + """ Serializes CompensationActions into json + + Args: + qs (QuerySet): A queryset of CompensationAction entries + + Returns: + serialized_json (list) + """ + return [ + { + "action": self._konova_code_to_json(entry.action_type), + "amount": entry.amount, + "unit": entry.unit, + "comment": entry.comment, + } + for entry in qs + ] + + def _deadlines_to_json(self, qs: QuerySet): + """ Serializes deadlines into json + + Args: + qs (QuerySet): A queryset of Deadline entries + + Returns: + serialized_json (list) + """ + return list(qs.values( + "type", + "date", + "comment", + )) \ No newline at end of file diff --git a/api/views/__init__.py b/api/views/__init__.py new file mode 100644 index 0000000..8d87c34 --- /dev/null +++ b/api/views/__init__.py @@ -0,0 +1,8 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 21.01.22 + +""" +from .v1 import * diff --git a/api/views/method_views.py b/api/views/method_views.py new file mode 100644 index 0000000..7c6904a --- /dev/null +++ b/api/views/method_views.py @@ -0,0 +1,35 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 27.01.22 + +""" +from django.contrib.auth.decorators import login_required +from django.http import HttpRequest, JsonResponse + +from api.models import APIUserToken + + +@login_required +def generate_new_token_view(request: HttpRequest): + """ Handles request for fetching + + Args: + request (HttpRequest): The incoming request + + Returns: + + """ + + if request.method == "GET": + token = APIUserToken() + while APIUserToken.objects.filter(token=token.token).exists(): + token = APIUserToken() + return JsonResponse( + data={ + "gen_data": token.token + } + ) + else: + raise NotImplementedError \ No newline at end of file diff --git a/api/views/v1/__init__.py b/api/views/v1/__init__.py new file mode 100644 index 0000000..d49ff47 --- /dev/null +++ b/api/views/v1/__init__.py @@ -0,0 +1,7 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 21.01.22 + +""" diff --git a/api/views/v1/views.py b/api/views/v1/views.py new file mode 100644 index 0000000..7789680 --- /dev/null +++ b/api/views/v1/views.py @@ -0,0 +1,132 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 21.01.22 + +""" +import json + +from django.http import JsonResponse, HttpRequest + +from api.utils.serializer.v1.compensation import CompensationAPISerializerV1 +from api.utils.serializer.v1.deduction import DeductionAPISerializerV1 +from api.utils.serializer.v1.ecoaccount import EcoAccountAPISerializerV1 +from api.utils.serializer.v1.ema import EmaAPISerializerV1 +from api.utils.serializer.v1.intervention import InterventionAPISerializerV1 +from api.views.views import AbstractAPIView + + +class AbstractAPIViewV1(AbstractAPIView): + """ Holds general serialization functions for API v1 + + """ + serializer = None + + def __init__(self, *args, **kwargs): + self.lookup = { + "id": None, # must be set in subclasses + "deleted__isnull": True, + "users__in": [], # must be set in subclasses + } + super().__init__(*args, **kwargs) + self.serializer = self.serializer() + + def get(self, request: HttpRequest, id=None): + """ Handles the GET request + + Performs the fetching and serialization of the data + + Args: + request (HttpRequest): The incoming request + id (str): The entries id (optional) + + Returns: + response (JsonResponse) + """ + try: + 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) + + def post(self, request: HttpRequest): + """ Handles the POST request + + Performs creation of new data + + Args: + request (HttpRequest): The incoming request + + Returns: + response (JsonResponse) + """ + try: + body = request.body.decode("utf-8") + 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 JsonResponse({"id": created_id}) + + def put(self, request: HttpRequest, id=None): + """ Handles the PUT request + + Performs updating + + Args: + request (HttpRequest): The incoming request + id (str): The entries id + + Returns: + response (JsonResponse) + """ + try: + body = request.body.decode("utf-8") + 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 JsonResponse({"id": updated_id}) + + def delete(self, request: HttpRequest, id=None): + """ Handles a DELETE request + + Args: + request (HttpRequest): The incoming request + id (str): The object's id + + Returns: + response (JsonResponse) + """ + + try: + success = self.serializer.delete_entry(id, self.user) + except Exception as e: + return self.return_error_response(e, 500) + return JsonResponse( + { + "success": success, + } + ) + + +class InterventionAPIViewV1(AbstractAPIViewV1): + serializer = InterventionAPISerializerV1 + + +class CompensationAPIViewV1(AbstractAPIViewV1): + serializer = CompensationAPISerializerV1 + + +class EcoAccountAPIViewV1(AbstractAPIViewV1): + serializer = EcoAccountAPISerializerV1 + + +class EmaAPIViewV1(AbstractAPIViewV1): + serializer = EmaAPISerializerV1 + + +class DeductionAPIViewV1(AbstractAPIViewV1): + serializer = DeductionAPISerializerV1 diff --git a/api/views/views.py b/api/views/views.py new file mode 100644 index 0000000..fb4f6df --- /dev/null +++ b/api/views/views.py @@ -0,0 +1,270 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 21.01.22 + +""" +import json + +from django.db.models import QuerySet +from django.http import JsonResponse, HttpRequest +from django.views import View +from django.views.decorators.csrf import csrf_exempt + +from api.models import APIUserToken +from api.settings import KSP_TOKEN_HEADER_IDENTIFIER, KSP_USER_HEADER_IDENTIFIER +from compensation.models import EcoAccount +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 + + +class AbstractAPIView(View): + """ Base class for API views + + The API must follow the GeoJSON Specification RFC 7946 + https://geojson.org/ + https://datatracker.ietf.org/doc/html/rfc7946 + + """ + user = None + + class Meta: + abstract = True + + @csrf_exempt + def dispatch(self, request, *args, **kwargs): + try: + # Fetch the proper user from the given request header token + 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) + if not self.user.is_default_user(): + raise PermissionError("Default permissions required") + except PermissionError as e: + return self.return_error_response(e, 403) + return super().dispatch(request, *args, **kwargs) + + def return_error_response(self, error, status_code=500): + """ Returns an error as JsonReponse + + Args: + error (): The error/exception + status_code (): The desired status code + + Returns: + + """ + content = [error.__str__()] + if hasattr(error, "messages"): + content = error.messages + return JsonResponse( + { + "errors": content + }, + status=status_code + ) + + +class InterventionCheckAPIView(AbstractAPIView): + + def get(self, request: HttpRequest, id): + """ Takes the GET request + + Args: + request (HttpRequest): The incoming request + id (str): The intervention's id + + Returns: + response (JsonResponse) + """ + if not self.user.is_zb_user(): + 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) + + all_valid, check_details = self.run_quality_checks(obj) + + if all_valid: + log_entry = obj.set_checked(self.user) + obj.log.add(log_entry) + + data = { + "success": all_valid, + "details": check_details + } + return JsonResponse(data) + + def run_quality_checks(self, obj: Intervention) -> (bool, dict): + """ Performs a check for intervention and related compensations + + Args: + obj (Intervention): The intervention + + Returns: + all_valid (boold): Whether an error occured or not + check_details (dict): A dict containg details on which elements have errors + """ + # Run quality check for Intervention + all_valid = True + intervention_checker = obj.quality_check() + all_valid = intervention_checker.valid and all_valid + + # Run quality checks for linked compensations + comps = obj.compensations.all() + comp_checkers = [] + for comp in comps: + comp_checker = comp.quality_check() + comp_checkers.append(comp_checker) + all_valid = comp_checker.valid and all_valid + + check_details = { + "intervention": { + "id": obj.id, + "errors": intervention_checker.messages + }, + "compensations": [ + { + "id": comp_checker.obj.id, + "errors": comp_checker.messages + } + for comp_checker in comp_checkers + ] + } + return all_valid, check_details + + +class AbstractModelShareAPIView(AbstractAPIView): + model = None + + class Meta: + abstract = True + + def get(self, request: HttpRequest, id): + """ Performs the GET request handling + + Args: + request (HttpRequest): The incoming request + id (str): The object's id + + Returns: + + """ + try: + users = self._get_shared_users_of_object(id) + except Exception as e: + return self.return_error_response(e) + + data = { + "users": [ + user.username for user in users + ] + } + + return JsonResponse(data) + + def put(self, request: HttpRequest, id): + """ Performs the PUT request handling + + Args: + request (HttpRequest): The incoming request + id (str): The object's id + + Returns: + + """ + + try: + success = self._process_put_body(request.body, id) + except Exception as e: + return self.return_error_response(e) + data = { + "success": success, + } + return JsonResponse(data) + + def _check_user_has_shared_access(self, obj): + """ Raises a PermissionError if user has no shared access + + Args: + obj (BaseObject): The object + + Returns: + + """ + is_shared = obj.is_shared_with(self.user) + if not is_shared: + raise PermissionError(DATA_UNSHARED) + + def _get_shared_users_of_object(self, id) -> QuerySet: + """ Check permissions and get the users + + Args: + id (str): The object's id + + Returns: + users (QuerySet) + """ + obj = self.model.objects.get( + id=id + ) + self._check_user_has_shared_access(obj) + users = obj.shared_users + return users + + def _process_put_body(self, body: bytes, id: str): + """ Reads the body data, performs validity checks and sets the new users + + Args: + body (bytes): The request.body + id (str): The object's id + + Returns: + success (bool) + """ + 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", []) + if len(new_users) == 0: + raise ValueError("Shared user list must not be empty!") + + # Eliminate duplicates + new_users = list(dict.fromkeys(new_users)) + + # 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)) + + 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( + username__in=new_users + ).exclude( + id__in=obj.shared_users + ) + new_users_objs = obj.shared_users.union(new_users_to_be_added) + obj.share_with_list(new_users_objs) + return True + + +class InterventionAPIShareView(AbstractModelShareAPIView): + model = Intervention + + +class EcoAccountAPIShareView(AbstractModelShareAPIView): + model = EcoAccount + + +class EmaAPIShareView(AbstractModelShareAPIView): + model = Ema diff --git a/compensation/admin.py b/compensation/admin.py index ced9bfe..735ffb4 100644 --- a/compensation/admin.py +++ b/compensation/admin.py @@ -29,6 +29,7 @@ class CompensationAdmin(BaseObjectAdmin): "identifier", "title", "created", + "deleted", ] diff --git a/compensation/managers.py b/compensation/managers.py index 61933ee..c97cd51 100644 --- a/compensation/managers.py +++ b/compensation/managers.py @@ -35,9 +35,7 @@ class CompensationManager(models.Manager): """ def get_queryset(self): - return super().get_queryset().filter( - deleted__isnull=True, - ).select_related( + return super().get_queryset().select_related( "modified", "intervention", "intervention__recorded", diff --git a/compensation/models/compensation.py b/compensation/models/compensation.py index 6ebed85..89d6dc2 100644 --- a/compensation/models/compensation.py +++ b/compensation/models/compensation.py @@ -245,6 +245,38 @@ 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): + """ Adds user to list of shared access users + + Args: + user (User): The user to be added to the object + + Returns: + + """ + if not self.intervention.is_shared_with(user): + self.intervention.users.add(user) + + def share_with_list(self, user_list: list): + """ Sets the list of shared access users + + Args: + user_list (list): The users to be added to the object + + Returns: + + """ + self.intervention.users.set(user_list) + + @property + def shared_users(self) -> QuerySet: + """ Shortcut for fetching the users which have shared access on this object + + Returns: + users (QuerySet) + """ + return self.intervention.users.all() + def get_LANIS_link(self) -> str: """ Generates a link for LANIS depending on the geometry diff --git a/compensation/views/compensation.py b/compensation/views/compensation.py index 323921e..009eaf8 100644 --- a/compensation/views/compensation.py +++ b/compensation/views/compensation.py @@ -108,7 +108,7 @@ def new_id_view(request: HttpRequest): identifier = tmp.generate_new_identifier() return JsonResponse( data={ - "identifier": identifier + "gen_data": identifier } ) diff --git a/compensation/views/eco_account.py b/compensation/views/eco_account.py index 5c061b9..59b82a7 100644 --- a/compensation/views/eco_account.py +++ b/compensation/views/eco_account.py @@ -118,7 +118,7 @@ def new_id_view(request: HttpRequest): identifier = tmp.generate_new_identifier() return JsonResponse( data={ - "identifier": identifier + "gen_data": identifier } ) diff --git a/ema/views.py b/ema/views.py index 1e8b089..8a3454e 100644 --- a/ema/views.py +++ b/ema/views.py @@ -108,7 +108,7 @@ def new_id_view(request: HttpRequest): identifier = tmp.generate_new_identifier() return JsonResponse( data={ - "identifier": identifier + "gen_data": identifier } ) diff --git a/intervention/models/intervention.py b/intervention/models/intervention.py index 766346f..d1a11e4 100644 --- a/intervention/models/intervention.py +++ b/intervention/models/intervention.py @@ -116,7 +116,7 @@ class Intervention(BaseObject, ShareableObjectMixin, RecordableObjectMixin, Chec z_l = v_zoom break zoom_lvl = z_l - except AttributeError: + except (AttributeError, IndexError) as e: # If no geometry has been added, yet. x = 1 y = 1 @@ -154,6 +154,7 @@ class Intervention(BaseObject, ShareableObjectMixin, RecordableObjectMixin, Chec def set_unrecorded(self, user: User): log_entry = super().set_unrecorded(user) self.add_log_entry_to_compensations(log_entry) + return log_entry def set_recorded(self, user: User) -> UserActionLogEntry: log_entry = super().set_recorded(user) @@ -259,11 +260,6 @@ class Intervention(BaseObject, ShareableObjectMixin, RecordableObjectMixin, Chec Returns: """ - user_action_edit = UserActionLogEntry.get_edited_action(performing_user, comment=edit_comment) - self.log.add(user_action_edit) - self.modified = user_action_edit - self.save() - super().mark_as_edited(performing_user, request) if self.checked: self.set_unchecked() diff --git a/intervention/tests/test_workflow.py b/intervention/tests/test_workflow.py index 23a3c11..005c6f5 100644 --- a/intervention/tests/test_workflow.py +++ b/intervention/tests/test_workflow.py @@ -360,19 +360,21 @@ class InterventionWorkflowTestCase(BaseWorkflowTestCase): self.eco_account.recorded = rec_action self.eco_account.share_with_list([self.superuser]) self.eco_account.save() + num_all_deducs = EcoAccountDeduction.objects.count() # Run the request self.client_user.post(new_url, post_data) # Expect the deduction to be created, since all constraints are fulfilled self.assertEqual(1, self.eco_account.deductions.count()) - self.assertEqual(1, EcoAccountDeduction.objects.count()) + self.assertEqual(num_all_deducs + 1, EcoAccountDeduction.objects.count()) # Make sure the deduction contains the expected data - deduction = EcoAccountDeduction.objects.first() + deduction = EcoAccountDeduction.objects.get( + account=self.eco_account, + intervention=self.intervention + ) self.assertEqual(deduction.surface, test_surface) - self.assertEqual(deduction.intervention, self.intervention) - self.assertEqual(deduction.account, self.eco_account) # Return deduction for further usage in tests return deduction diff --git a/intervention/views.py b/intervention/views.py index 50856df..3d65b7d 100644 --- a/intervention/views.py +++ b/intervention/views.py @@ -108,7 +108,7 @@ def new_id_view(request: HttpRequest): identifier = tmp_intervention.generate_new_identifier() return JsonResponse( data={ - "identifier": identifier + "gen_data": identifier } ) diff --git a/konova/decorators.py b/konova/decorators.py index 4edf554..583afbe 100644 --- a/konova/decorators.py +++ b/konova/decorators.py @@ -75,9 +75,7 @@ def default_group_required(function): @wraps(function) def wrap(request, *args, **kwargs): user = request.user - has_group = user.groups.filter( - name=DEFAULT_GROUP - ).exists() + has_group = user.is_default_user() if has_group: return function(request, *args, **kwargs) else: @@ -95,9 +93,7 @@ def registration_office_group_required(function): @wraps(function) def wrap(request, *args, **kwargs): user = request.user - has_group = user.groups.filter( - name=ZB_GROUP - ).exists() + has_group = user.is_zb_user() if has_group: return function(request, *args, **kwargs) else: @@ -115,9 +111,7 @@ def conservation_office_group_required(function): @wraps(function) def wrap(request, *args, **kwargs): user = request.user - has_group = user.groups.filter( - name=ETS_GROUP - ).exists() + has_group = user.is_ets_user() if has_group: return function(request, *args, **kwargs) else: diff --git a/konova/management/commands/sanitize_db.py b/konova/management/commands/sanitize_db.py index 0eecdc0..b5be834 100644 --- a/konova/management/commands/sanitize_db.py +++ b/konova/management/commands/sanitize_db.py @@ -10,7 +10,7 @@ from ema.models import Ema from intervention.models import Intervention from konova.management.commands.setup import BaseKonovaCommand from konova.models import Deadline, Geometry, Parcel, District -from user.models import UserActionLogEntry +from user.models import UserActionLogEntry, UserAction class Command(BaseKonovaCommand): @@ -55,7 +55,11 @@ class Command(BaseKonovaCommand): """ self._write_warning("=== Sanitize log entries ===") - all_log_entries = UserActionLogEntry.objects.all() + # Exclude created log entries from being cleaned, since they can be part of objects which do not have logs + # Being in a log (or not) is essential for this cleanup + all_log_entries = UserActionLogEntry.objects.all().exclude( + action=UserAction.CREATED + ) intervention_log_entries_ids = self.get_all_log_entries_ids(Intervention) attached_log_entries_id = intervention_log_entries_ids.union( diff --git a/konova/models/object.py b/konova/models/object.py index 0a83a48..78672b4 100644 --- a/konova/models/object.py +++ b/konova/models/object.py @@ -10,6 +10,7 @@ import uuid from abc import abstractmethod from django.contrib import messages +from django.db.models import QuerySet 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, \ @@ -103,7 +104,7 @@ class BaseObject(BaseResource): def set_status_messages(self, request: HttpRequest): raise NotImplementedError - def mark_as_deleted(self, user: User): + def mark_as_deleted(self, user: 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 @@ -123,10 +124,11 @@ class BaseObject(BaseResource): self.deleted = action self.log.add(action) - # Send mail - shared_users = self.users.all().values_list("id", flat=True) - for user_id in shared_users: - celery_send_mail_shared_data_deleted.delay(self.identifier, user_id) + if send_mail: + # Send mail + shared_users = self.shared_users.values_list("id", flat=True) + for user_id in shared_users: + celery_send_mail_shared_data_deleted.delay(self.identifier, user_id) self.save() @@ -276,7 +278,8 @@ class RecordableObjectMixin(models.Model): self.save() if self.recorded: - self.set_unrecorded(performing_user) + action = self.set_unrecorded(performing_user) + self.log.add(action) if request: messages.info( request, @@ -464,6 +467,15 @@ class ShareableObjectMixin(models.Model): # Set new shared users self.share_with_list(users) + @property + def shared_users(self) -> QuerySet: + """ Shortcut for fetching the users which have shared access on this object + + Returns: + users (QuerySet) + """ + return self.users.all() + class GeoReferencedMixin(models.Model): geometry = models.ForeignKey("konova.Geometry", null=True, blank=True, on_delete=models.SET_NULL) diff --git a/konova/sub_settings/django_settings.py b/konova/sub_settings/django_settings.py index bc1a751..84039b5 100644 --- a/konova/sub_settings/django_settings.py +++ b/konova/sub_settings/django_settings.py @@ -70,6 +70,7 @@ INSTALLED_APPS = [ 'ema', 'codelist', 'analysis', + 'api', ] if DEBUG: INSTALLED_APPS += [ @@ -212,6 +213,7 @@ EMAIL_FILE_PATH = '/tmp/app-messages' # change this to a proper location DEFAULT_FROM_EMAIL = "service@ksp.de" # The default email address for the 'from' element EMAIL_HOST = "localhost" EMAIL_REPLY_TO = "ksp-servicestelle@sgdnord.rlp.de" +SUPPORT_MAIL_RECIPIENT = EMAIL_REPLY_TO EMAIL_PORT = "25" #EMAIL_HOST_USER = "" #EMAIL_HOST_PASSWORD = "" diff --git a/konova/templates/konova/widgets/generate-content-input.html b/konova/templates/konova/widgets/generate-content-input.html index 66c9198..9d2f983 100644 --- a/konova/templates/konova/widgets/generate-content-input.html +++ b/konova/templates/konova/widgets/generate-content-input.html @@ -1,9 +1,9 @@ {% load i18n fontawesome_5 %}
- +
- {% fa5_icon 'dice' %} + {% fa5_icon 'dice' %}