From a12c2fb57eed403773b43bb066f2efbdbc7ab534 Mon Sep 17 00:00:00 2001 From: mpeltriaux Date: Fri, 12 Sep 2025 09:24:02 +0200 Subject: [PATCH 1/2] # Propagation extension * adds sso_identifier as new User model attribute * refactors minor code snippets on user-propagation data resolving * adds updating of username based on propagation data * adds sso_identifier on admin backend view --- user/admin.py | 1 + user/migrations/0010_user_sso_identifier.py | 18 +++++++ user/models/user.py | 53 ++++++++++++++++++++- user/views/propagate.py | 13 +---- 4 files changed, 73 insertions(+), 12 deletions(-) create mode 100644 user/migrations/0010_user_sso_identifier.py diff --git a/user/admin.py b/user/admin.py index ff848e53..9b4e0c1f 100644 --- a/user/admin.py +++ b/user/admin.py @@ -15,6 +15,7 @@ class UserNotificationAdmin(admin.ModelAdmin): class UserAdmin(admin.ModelAdmin): list_display = [ "id", + "sso_identifier", "username", "first_name", "last_name", diff --git a/user/migrations/0010_user_sso_identifier.py b/user/migrations/0010_user_sso_identifier.py new file mode 100644 index 00000000..dce49c14 --- /dev/null +++ b/user/migrations/0010_user_sso_identifier.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-09-12 06:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('user', '0009_user_oauth_token'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='sso_identifier', + field=models.CharField(blank=True, db_comment='Identifies the account based on an unique identifier from the SSO system', max_length=255, null=True), + ), + ] diff --git a/user/models/user.py b/user/models/user.py index 33390885..4acffb0b 100644 --- a/user/models/user.py +++ b/user/models/user.py @@ -6,6 +6,7 @@ Created on: 15.11.21 """ from django.contrib.auth.models import AbstractUser +from django.core.exceptions import ObjectDoesNotExist from django.db import models @@ -32,6 +33,12 @@ class User(AbstractUser): db_comment="OAuth token for the user", related_name="+" ) + sso_identifier = models.CharField( + blank=True, + null=True, + db_comment="Identifies the account based on an unique identifier from the SSO system", + max_length=255, + ) def is_notification_setting_set(self, notification_enum: UserNotificationEnum): return self.notifications.filter( @@ -264,4 +271,48 @@ class User(AbstractUser): self.oauth_token.delete() self.oauth_token = token self.save() - return self \ No newline at end of file + return self + + @staticmethod + def resolve_user_using_propagation_data(data: dict): + """ Fetches user from db by the given data from propagation process + + Args: + data (dict): json containing user information from the sso system + + Returns: + user (User): The resolved user + """ + username = data.get("username", None) + sso_identifier = data.get("sso_identifier", None) + if not username and not sso_identifier: + raise AssertionError("No username or sso identifier provided") + + try: + user = User.objects.get(username=username) + except ObjectDoesNotExist: + try: + user = User.objects.get(sso_identifier=sso_identifier) + except ObjectDoesNotExist: + raise ObjectDoesNotExist("No user with this username or sso identifier was found") + + return user + + def update_user_using_propagation_data(self, data: dict): + """ Update user data based on propagation data from sso system + + Args: + data (dict): json containing user information from the sso system + + Returns: + user (User): The updated user + """ + skipable_attrs = { + "is_staff", + "is_superuser", + } + for _attr, _val in data.items(): + if _attr in skipable_attrs: + continue + setattr(self, _attr, _val) + return self diff --git a/user/views/propagate.py b/user/views/propagate.py index 3afb6fcf..bb076502 100644 --- a/user/views/propagate.py +++ b/user/views/propagate.py @@ -44,17 +44,8 @@ class PropagateUserView(View): try: status = "updated" - user = User.objects.get(username=body.get('username')) - # Update user data, excluding some changes - skipable_attrs = { - "username", - "is_staff", - "is_superuser", - } - for _attr, _val in body.items(): - if _attr in skipable_attrs: - continue - setattr(user, _attr, _val) + user = User.resolve_user_using_propagation_data(body) + user = user.update_user_using_propagation_data(body) except ObjectDoesNotExist: user = User(**body) status = "created" From 63a824f9d9562746d81a447180ad5a702edcb046 Mon Sep 17 00:00:00 2001 From: mpeltriaux Date: Fri, 12 Sep 2025 13:22:35 +0200 Subject: [PATCH 2/2] # Geometry form fix * fixes bugs in tests * refactors and simplifies geometry merging on GeometryForm --- .../tests/ecoaccount/test_workflow.py | 4 +-- ema/tests/test_workflow.py | 4 +-- intervention/tests/unit/test_forms.py | 2 +- konova/forms/geometry_form.py | 26 ++++++++++--------- konova/models/geometry.py | 2 +- konova/tests/test_views.py | 7 +++-- 6 files changed, 25 insertions(+), 20 deletions(-) diff --git a/compensation/tests/ecoaccount/test_workflow.py b/compensation/tests/ecoaccount/test_workflow.py index 85f7db54..27eb0a99 100644 --- a/compensation/tests/ecoaccount/test_workflow.py +++ b/compensation/tests/ecoaccount/test_workflow.py @@ -86,7 +86,7 @@ class EcoAccountWorkflowTestCase(BaseWorkflowTestCase): new_title = self.create_dummy_string() new_identifier = self.create_dummy_string() new_comment = self.create_dummy_string() - new_geometry = MultiPolygon(srid=4326) # Create an empty geometry + new_geometry = self.create_dummy_geometry() test_conservation_office = self.get_conservation_office_code() test_deductable_surface = self.eco_account.deductable_surface + 100 @@ -103,7 +103,7 @@ class EcoAccountWorkflowTestCase(BaseWorkflowTestCase): "identifier": new_identifier, "title": new_title, "comment": new_comment, - "geom": new_geometry.geojson, + "geom": self.create_geojson(new_geometry), "surface": test_deductable_surface, "conservation_office": test_conservation_office.id } diff --git a/ema/tests/test_workflow.py b/ema/tests/test_workflow.py index c6228112..7fb3c6a3 100644 --- a/ema/tests/test_workflow.py +++ b/ema/tests/test_workflow.py @@ -84,7 +84,7 @@ class EmaWorkflowTestCase(BaseWorkflowTestCase): new_title = self.create_dummy_string() new_identifier = self.create_dummy_string() new_comment = self.create_dummy_string() - new_geometry = MultiPolygon(srid=4326) # Create an empty geometry + new_geometry = self.create_dummy_geometry() # Create an empty geometry test_conservation_office = self.get_conservation_office_code() check_on_elements = { @@ -99,7 +99,7 @@ class EmaWorkflowTestCase(BaseWorkflowTestCase): "identifier": new_identifier, "title": new_title, "comment": new_comment, - "geom": new_geometry.geojson, + "geom": self.create_geojson(new_geometry), "conservation_office": test_conservation_office.id } self.client_user.post(url, post_data) diff --git a/intervention/tests/unit/test_forms.py b/intervention/tests/unit/test_forms.py index 1c4306c2..6435e21e 100644 --- a/intervention/tests/unit/test_forms.py +++ b/intervention/tests/unit/test_forms.py @@ -124,7 +124,7 @@ class EditInterventionFormTestCase(NewInterventionFormTestCase): self.assertIsNotNone(obj.responsible.handler) self.assertEqual(obj.title, data["title"]) self.assertEqual(obj.comment, data["comment"]) - self.assertTrue(test_geom.equals_exact(obj.geometry.geom, 0.000001)) + self.assert_equal_geometries(test_geom, obj.geometry.geom) self.assertEqual(obj.legal.binding_date, today) self.assertEqual(obj.legal.registration_date, today) diff --git a/konova/forms/geometry_form.py b/konova/forms/geometry_form.py index e6ef3998..aadd977c 100644 --- a/konova/forms/geometry_form.py +++ b/konova/forms/geometry_form.py @@ -72,9 +72,8 @@ class SimpleGeomForm(BaseForm): # will be rendered again on failed submit self.initialize_form_field("geom", self.data["geom"]) - # Read geojson into gdal geometry - # HINT: This can be simplified if the geojson format holds data in epsg:4326 (GDAL provides direct creation for - # this case) + # Initialize features list with empty MultiPolygon, so that an empty input will result in a + # proper empty MultiPolygon object features = [] features_json = geom.get("features", []) accepted_ogr_types = [ @@ -102,19 +101,22 @@ class SimpleGeomForm(BaseForm): return is_valid is_valid &= self.__is_area_valid(g) - - polygon = Polygon.from_ewkt(g.ewkt) - is_valid &= polygon.valid - if not polygon.valid: - self.add_error("geom", polygon.valid_reason) + g = Polygon.from_ewkt(g.ewkt) + is_valid &= g.valid + if not g.valid: + self.add_error("geom", g.valid_reason) return is_valid - features.append(polygon) + if isinstance(g, Polygon): + features.append(g) + elif isinstance(g, MultiPolygon): + features.extend(list(g)) # Unionize all geometry features into one new MultiPolygon - form_geom = MultiPolygon(srid=DEFAULT_SRID_RLP) - for feature in features: - form_geom = form_geom.union(feature) + if features: + form_geom = MultiPolygon(*features, srid=DEFAULT_SRID_RLP).unary_union + else: + form_geom = MultiPolygon(srid=DEFAULT_SRID_RLP) # Make sure to convert into a MultiPolygon. Relevant if a single Polygon is provided. form_geom = Geometry.cast_to_multipolygon(form_geom) diff --git a/konova/models/geometry.py b/konova/models/geometry.py index 1839f103..4f8b49d7 100644 --- a/konova/models/geometry.py +++ b/konova/models/geometry.py @@ -395,7 +395,7 @@ class Geometry(BaseResource): output_geom """ output_geom = input_geom - if input_geom.geom_type != "MultiPolygon": + if not isinstance(input_geom, MultiPolygon): output_geom = MultiPolygon(input_geom, srid=DEFAULT_SRID_RLP) return output_geom diff --git a/konova/tests/test_views.py b/konova/tests/test_views.py index 95fb8367..4977bfee 100644 --- a/konova/tests/test_views.py +++ b/konova/tests/test_views.py @@ -469,7 +469,7 @@ class BaseTestCase(TestCase): eco_account.save() return eco_account - def assert_equal_geometries(self, geom1: MultiPolygon, geom2: MultiPolygon, tolerance = 0.001): + def assert_equal_geometries(self, geom1: MultiPolygon, geom2: MultiPolygon, tolerance=0.001): """ Assert for geometries to be equal Transforms the geometries to matching srids before checking @@ -491,7 +491,10 @@ class BaseTestCase(TestCase): # transformation from one coordinate system into the other, which is valid geom1.transform(geom2.srid) geom2.transform(geom1.srid) - self.assertTrue(geom1.equals_exact(geom2, tolerance) or geom2.equals_exact(geom1, tolerance)) + self.assertTrue( + geom1.equals_exact(geom2, tolerance=tolerance), + msg=f"Difference is {abs(geom1.area - geom2.area)} with {geom1.area} and {geom2.area} in a tolerance of {tolerance}" + ) class BaseViewTestCase(BaseTestCase):