From 61ff3d46d55d1dd5c09dd4652155cba92b1e057d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89lie=20Bouttier?= Date: Thu, 3 Oct 2024 19:27:02 +0200 Subject: [PATCH 01/14] chore(permissions): add comment and help code --- backend/geonature/core/gn_permissions/admin.py | 9 +++++++++ backend/geonature/tests/test_permissions.py | 7 +++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/backend/geonature/core/gn_permissions/admin.py b/backend/geonature/core/gn_permissions/admin.py index 885d2d7836..d3ffcebbf0 100644 --- a/backend/geonature/core/gn_permissions/admin.py +++ b/backend/geonature/core/gn_permissions/admin.py @@ -288,6 +288,15 @@ def iter_choices(self): class UserAjaxModelLoader(QueryAjaxModelLoader): def format(self, user): + """ + Instead of returning a list of tuple (id, label), we return a list of tuple (id, label, excluded_availabilities). + The third element of each tuple is the list of type of permissions the user already have, so it is useless + to add this permission to the user, and they will be not available in the front select. + Two remarks: + - We only consider active permissions of the user + - If the type of the permission allows two or more filters, we do not exclude it as it makes sens to add several + permissions of the same type with differents set of filters. + """ if not user: return None diff --git a/backend/geonature/tests/test_permissions.py b/backend/geonature/tests/test_permissions.py index 5656506010..b0bec82f9e 100644 --- a/backend/geonature/tests/test_permissions.py +++ b/backend/geonature/tests/test_permissions.py @@ -129,6 +129,7 @@ def _permissions(role, cruved, *, module=module_gn, **kwargs): scope_type = db.session.execute( select(PermFilterType).filter_by(code_filter_type="SCOPE") ).scalar_one() + perms = {} with db.session.begin_nested(): for a, s in zip("CRUVED", cruved): if s == "-": @@ -137,9 +138,11 @@ def _permissions(role, cruved, *, module=module_gn, **kwargs): s = None else: s = int(s) - db.session.add( - Permission(role=role, action=actions[a], module=module, scope_value=s, **kwargs) + perms[a] = Permission( + role=role, action=actions[a], module=module, scope_value=s, **kwargs ) + db.session.add(perms[a]) + return perms return _permissions From 8bc29a5577d8098d1090581645fc0f3b486c7250 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89lie=20Bouttier?= Date: Fri, 4 Oct 2024 23:30:01 +0200 Subject: [PATCH 02/14] chore(permissions): change filters_fields type --- .../geonature/core/gn_permissions/admin.py | 4 +-- .../geonature/core/gn_permissions/models.py | 29 ++++++++++--------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/backend/geonature/core/gn_permissions/admin.py b/backend/geonature/core/gn_permissions/admin.py index d3ffcebbf0..45e4a86c98 100644 --- a/backend/geonature/core/gn_permissions/admin.py +++ b/backend/geonature/core/gn_permissions/admin.py @@ -188,7 +188,7 @@ def permissions_formatter(view, context, model, name): o += """" o += """""" @@ -308,7 +308,7 @@ def format_availability(availability): def filter_availability(availability): filters_count = sum( [ - getattr(availability, field.name) + getattr(availability, field) for field in PermissionAvailable.filters_fields.values() ] ) diff --git a/backend/geonature/core/gn_permissions/models.py b/backend/geonature/core/gn_permissions/models.py index cc6d825cd0..25f38d8b0f 100644 --- a/backend/geonature/core/gn_permissions/models.py +++ b/backend/geonature/core/gn_permissions/models.py @@ -134,13 +134,13 @@ class PermissionAvailable(db.Model): sensitivity_filter = db.Column(db.Boolean, server_default=sa.false(), nullable=False) filters_fields = { - "SCOPE": scope_filter, - "SENSITIVITY": sensitivity_filter, + "SCOPE": "scope_filter", + "SENSITIVITY": "sensitivity_filter", } @property def filters(self): - return [k for k, v in self.filters_fields.items() if getattr(self, v.name)] + return [k for k, v in self.filters_fields.items() if getattr(self, v)] def __str__(self): s = self.module.module_label @@ -219,8 +219,8 @@ class Permission(db.Model): ) filters_fields = { - "SCOPE": scope_value, - "SENSITIVITY": sensitivity_filter, + "SCOPE": "scope_value", + "SENSITIVITY": "sensitivity_filter", } @staticmethod @@ -244,7 +244,7 @@ def __le__(self, other): for name, field in self.filters_fields.items(): # Get filter comparison function or use default comparison function __le_fct__ = getattr(self, f"__{name}_le__", Permission.__default_le__) - self_value, other_value = getattr(self, field.name), getattr(other, field.name) + self_value, other_value = getattr(self, field), getattr(other, field) if not __le_fct__(self_value, other_value): return False return True @@ -253,13 +253,16 @@ def __le__(self, other): def filters(self): filters = [] for name, field in self.filters_fields.items(): - value = getattr(self, field.name) - if field.nullable: - if value is None: - continue - if field.type.python_type == bool: - if not value: - continue + value = getattr(self, field) + mapper = self.__mapper__ + if field in mapper.columns: + column = mapper.columns[field] + if column.nullable: + if value is None: + continue + if column.type.python_type is bool: + if not value: + continue filters.append(PermFilter(name, value)) return filters From 74d3447ac83d8fe0d93eddbff816dffe8764cda0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89lie=20Bouttier?= Date: Sat, 5 Oct 2024 15:05:36 +0200 Subject: [PATCH 03/14] chore(synthese): more readable permissions check --- backend/geonature/core/gn_synthese/models.py | 50 ++++++++++---------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/backend/geonature/core/gn_synthese/models.py b/backend/geonature/core/gn_synthese/models.py index 9a572c9a75..030b5e098a 100644 --- a/backend/geonature/core/gn_synthese/models.py +++ b/backend/geonature/core/gn_synthese/models.py @@ -51,6 +51,7 @@ TMedias, TModules, ) +from geonature.core.gn_permissions.models import Permission from geonature.utils.env import DB, db @@ -187,6 +188,7 @@ def join_nomenclatures(self): return self.options(*[joinedload(n) for n in Synthese.nomenclature_fields]) def lateraljoin_last_validation(self): + # FIXME missing order by! subquery = ( select(TValidations) .where(TValidations.uuid_attached_row == Synthese.unique_id_sinp) @@ -444,10 +446,8 @@ class Synthese(DB.Model): cor_observers = DB.relationship(User, secondary=cor_observer_synthese) - def _has_scope_grant(self, scope): - if scope == 0: - return False - elif scope in (1, 2): + def _has_scope_grant(self, scope) -> bool: + if scope in (1, 2): if g.current_user == self.digitiser: return True if g.current_user in self.cor_observers: @@ -455,38 +455,36 @@ def _has_scope_grant(self, scope): return self.dataset.has_instance_permission(scope) elif scope == 3: return True + return False - def _has_permissions_grant(self, permissions): - blur_sensitive_observations = current_app.config["SYNTHESE"]["BLUR_SENSITIVE_OBSERVATIONS"] + def _has_permissions_grant(self, permissions) -> bool: if not permissions: return False for perm in permissions: - if perm.has_other_filters_than("SCOPE", "SENSITIVITY"): + if perm.has_other_filters_than("SCOPE", "SENSITIVITY", "GEOGRAPHIC"): continue # unsupported filters if perm.sensitivity_filter: - if ( - blur_sensitive_observations - and self.nomenclature_sensitivity.cd_nomenclature == "4" - ): - continue - if ( - not blur_sensitive_observations - and self.nomenclature_sensitivity.cd_nomenclature != "0" - ): - continue + if current_app.config["SYNTHESE"]["BLUR_SENSITIVE_OBSERVATIONS"]: + # refuse access to obs with no diffusion sensitivity level + # (lower sensitivity level will trigger blurring) + if self.nomenclature_sensitivity.cd_nomenclature == "4": + continue + else: + # refuse access to obs with any sensitivity level (as blurring is disabled) + if self.nomenclature_sensitivity.cd_nomenclature != "0": + continue if perm.scope_value: - if g.current_user == self.digitiser: - return True - if g.current_user in self.cor_observers: - return True - if self.dataset.has_instance_permission(perm.scope_value): - return True - continue # scope filter denied access, check next permission + if not ( + g.current_user == self.digitiser + or g.current_user in self.cor_observers + or self.dataset.has_instance_permission(perm.scope_value) + ): + continue # scope filter denied access, check next permission return True # no filter exclude this permission return False - def has_instance_permission(self, permissions): - if type(permissions) == int: + def has_instance_permission(self, permissions) -> bool: + if type(permissions) is int: return self._has_scope_grant(permissions) else: return self._has_permissions_grant(permissions) From 7320bd526d3d2443464f574900cd6d8eab551419 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89lie=20Bouttier?= Date: Sat, 5 Oct 2024 12:25:32 +0200 Subject: [PATCH 04/14] test(permissions): test sensitivity filter --- backend/geonature/tests/test_permissions.py | 58 ++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/backend/geonature/tests/test_permissions.py b/backend/geonature/tests/test_permissions.py index b0bec82f9e..e63b115eef 100644 --- a/backend/geonature/tests/test_permissions.py +++ b/backend/geonature/tests/test_permissions.py @@ -13,7 +13,11 @@ Permission, PermissionAvailable, ) -from geonature.core.gn_permissions.tools import get_scopes_by_action, has_any_permissions_by_action +from geonature.core.gn_permissions.tools import ( + get_permissions, + get_scopes_by_action, + has_any_permissions_by_action, +) from geonature.utils.env import db from pypnusershub.db.models import User @@ -185,6 +189,27 @@ def _assert_cruved(role, cruved, module=None, object=None): return _assert_cruved +@pytest.fixture() +def assert_permissions(roles): + def _assert_permissions(role, action_code, expected_perms, module=None, object=None): + role = roles[role] + module_code = module.module_code if module else None + object_code = object.code_object if object else None + perms = get_permissions( + id_role=role.id_role, + action_code=action_code, + module_code=module_code, + object_code=object_code, + ) + perms = set((p.scope_value, p.sensitivity_filter) for p in perms) + expected_perms = set( + (p.get("SCOPE", None), p.get("SENSITIVITY", False)) for p in expected_perms + ) + assert perms == expected_perms + + return _assert_permissions + + @pytest.fixture(scope="class") def g_permissions(): """ @@ -294,3 +319,34 @@ def test_has_any_perms( assert has_any_permissions_by_action( id_role=roles["r2"].id_role, module_code=module_a.module_code ) == b_cruved("111111") + + +@pytest.mark.usefixtures("temporary_transaction", "g_permissions") +class TestPermissionsFilters: + def test_sensitivity_filter(self, roles, permissions, assert_permissions): + permissions("r1", "1-----") + permissions("r1", "-1----", sensitivity_filter=False) + permissions("r1", "--1---", sensitivity_filter=True) + + assert_permissions("r1", "C", [{"SCOPE": 1, "SENSITIVITY": False}]) + assert_permissions("r1", "R", [{"SCOPE": 1, "SENSITIVITY": False}]) + assert_permissions("r1", "U", [{"SCOPE": 1, "SENSITIVITY": True}]) + + def test_sensitivity_filter_overlap(self, permissions, assert_permissions): + permissions("g1", "1-----", sensitivity_filter=True) + permissions("g2", "1-----", sensitivity_filter=False) + permissions("g1", "-1----", sensitivity_filter=True) + permissions("g2", "-2----", sensitivity_filter=False) + permissions("g1", "--2---", sensitivity_filter=True) + permissions("g2", "--1---", sensitivity_filter=False) + + # g2 permisson is superior + assert_permissions("g12_r1", "C", [{"SCOPE": 1, "SENSITIVITY": False}]) + + # g2 permisson is superior + assert_permissions("g12_r1", "R", [{"SCOPE": 2, "SENSITIVITY": False}]) + + # g1 and g2 permissions can not be simplified + assert_permissions( + "g12_r1", "U", [{"SCOPE": 2, "SENSITIVITY": True}, {"SCOPE": 1, "SENSITIVITY": False}] + ) From 81511ccd3b2ee1289be097dac07da0ae94afccf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89lie=20Bouttier?= Date: Thu, 3 Oct 2024 19:24:51 +0200 Subject: [PATCH 05/14] feat(permissions): active permissions filter --- .../geonature/core/gn_permissions/admin.py | 46 ++++++++++--------- .../geonature/core/gn_permissions/models.py | 8 ++++ .../geonature/core/gn_permissions/tools.py | 1 + 3 files changed, 34 insertions(+), 21 deletions(-) diff --git a/backend/geonature/core/gn_permissions/admin.py b/backend/geonature/core/gn_permissions/admin.py index 45e4a86c98..bddd229479 100644 --- a/backend/geonature/core/gn_permissions/admin.py +++ b/backend/geonature/core/gn_permissions/admin.py @@ -132,10 +132,11 @@ def permissions_formatter(view, context, model, name): o += "" + "".join([f"{col}" for col in columns]) + "" o += "" for ap in available_permissions: + permissions = [p for p in model.permissions if p.is_active] own_permissions = list( filter( lambda p: p.module == ap.module and p.object == ap.object and p.action == ap.action, - model.permissions, + permissions, ) ) permissions = [(own_permissions, True)] @@ -243,7 +244,8 @@ def permissions_formatter(view, context, model, name): def permissions_count_formatter(view, context, model, name): url = url_for("permissions/permission.index_view", flt1_rle_equals=model.id_role) - return Markup(f'{len(model.permissions)}') + permissions_count = len([p for p in model.permissions if p.is_active]) + return Markup(f'{permissions_count}') ### Widgets @@ -314,7 +316,9 @@ def filter_availability(availability): ) return filters_count < 2 - availabilities = {p.availability for p in user.permissions if p.availability} + availabilities = { + p.availability for p in user.permissions if p.availability and p.is_active + } excluded_availabilities = filter(filter_availability, availabilities) excluded_availabilities = map(format_availability, excluded_availabilities) return super().format(user) + (list(excluded_availabilities),) @@ -426,6 +430,12 @@ class PermissionAdmin(CruvedProtectedMixin, ModelView): ), } + def get_query(self): + return super().get_query().where(Permission.active_filter()) + + def get_count_query(self): + return super().get_count_query().where(Permission.active_filter()) + def render(self, template, **kwargs): self.extra_js = [url_for("static", filename="js/hide_unnecessary_filters.js")] return super().render(template, **kwargs) @@ -513,6 +523,14 @@ class RolePermAdmin(CruvedProtectedMixin, ModelView): "permissions_count": permissions_count_formatter, } + def get_query(self): + # TODO : change to sqla2.0 query when flask admin update to sqla2 + return db.session.query(User).where(User.filter_by_app()) + + def get_count_query(self): + # TODO : change to sqla2.0 query when flask admin update to sqla2 + return db.session.query(sa.func.count("*")).select_from(User).where(User.filter_by_app()) + class GroupPermAdmin(RolePermAdmin): column_list = ( @@ -522,17 +540,10 @@ class GroupPermAdmin(RolePermAdmin): column_details_list = ("nom_role", "permissions_count", "permissions") def get_query(self): - # TODO : change to sqla2.0 query when flask admin update to sqla2 - return db.session.query(User).filter_by(groupe=True).where(User.filter_by_app()) + return super().get_query().where(User.groupe.is_(sa.true())) def get_count_query(self): - # TODO : change to sqla2.0 query when flask admin update to sqla2 - return ( - db.session.query(sa.func.count("*")) - .select_from(User) - .where(User.groupe == True) - .where(User.filter_by_app()) - ) + return super().get_count_query().where(User.groupe.is_(sa.true())) class UserPermAdmin(RolePermAdmin): @@ -557,17 +568,10 @@ class UserPermAdmin(RolePermAdmin): ) def get_query(self): - # TODO : change to sqla2.0 query when flask admin update to sqla2 - return db.session.query(User).filter_by(groupe=False).where(User.filter_by_app()) + return super().get_query().where(User.groupe.is_(sa.false())) def get_count_query(self): - # TODO : change to sqla2.0 query when flask admin update to sqla2 - return ( - db.session.query(sa.func.count("*")) - .select_from(User) - .where(User.groupe == False) - .where(User.filter_by_app()) - ) + return super().get_count_query().where(User.groupe.is_(sa.false())) admin.add_view( diff --git a/backend/geonature/core/gn_permissions/models.py b/backend/geonature/core/gn_permissions/models.py index 25f38d8b0f..527484323b 100644 --- a/backend/geonature/core/gn_permissions/models.py +++ b/backend/geonature/core/gn_permissions/models.py @@ -275,3 +275,11 @@ def has_other_filters_than(self, *expected_filters): @qfilter(query=True) def nice_order(cls, **kwargs): return _nice_order(cls, kwargs["query"]) + + @property + def is_active(self): + return True + + @classmethod + def active_filter(cls): + return sa.true() diff --git a/backend/geonature/core/gn_permissions/tools.py b/backend/geonature/core/gn_permissions/tools.py index 4d1d86257e..8df59dbcc8 100644 --- a/backend/geonature/core/gn_permissions/tools.py +++ b/backend/geonature/core/gn_permissions/tools.py @@ -29,6 +29,7 @@ def _get_user_permissions(id_role): joinedload(Permission.action), ) .where( + Permission.active_filter(), sa.or_( # direct permissions Permission.id_role == id_role, From aa00db425b2eea3b538adbd6abe2acecadabf642 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89lie=20Bouttier?= Date: Thu, 3 Oct 2024 19:35:16 +0200 Subject: [PATCH 06/14] feat(permissions): add expiration date --- .../geonature/core/gn_permissions/admin.py | 1 + .../geonature/core/gn_permissions/models.py | 6 ++-- ...c722fe_permissions_add_extended_filters.py | 33 +++++++++++++++++++ backend/geonature/tests/test_permissions.py | 13 ++++++++ 4 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 backend/geonature/migrations/versions/707390c722fe_permissions_add_extended_filters.py diff --git a/backend/geonature/core/gn_permissions/admin.py b/backend/geonature/core/gn_permissions/admin.py index bddd229479..4b5b34f2f9 100644 --- a/backend/geonature/core/gn_permissions/admin.py +++ b/backend/geonature/core/gn_permissions/admin.py @@ -368,6 +368,7 @@ class PermissionAdmin(CruvedProtectedMixin, ModelView): "role.identifiant": "identifiant du rôle", "role.nom_complet": "nom du rôle", "availability": "Permission", + "expire_on": "Date d’expiration", "scope": "Filtre sur l'appartenance des données", "sensitivity_filter": ( "Flouter" if config["SYNTHESE"]["BLUR_SENSITIVE_OBSERVATIONS"] else "Exclure" diff --git a/backend/geonature/core/gn_permissions/models.py b/backend/geonature/core/gn_permissions/models.py index 527484323b..025da5ebc4 100644 --- a/backend/geonature/core/gn_permissions/models.py +++ b/backend/geonature/core/gn_permissions/models.py @@ -3,6 +3,7 @@ """ from packaging import version +from datetime import datetime import sqlalchemy as sa from sqlalchemy import ForeignKey, ForeignKeyConstraint @@ -202,6 +203,7 @@ class Permission(db.Model): ForeignKey(PermObject.id_object), default=select(PermObject.id_object).where(PermObject.code_object == "ALL"), ) + expire_on = db.Column(db.DateTime) role = db.relationship(User, backref=db.backref("permissions", cascade_backrefs=False)) action = db.relationship(PermAction) @@ -278,8 +280,8 @@ def nice_order(cls, **kwargs): @property def is_active(self): - return True + return self.expire_on is None or self.expire_on > datetime.now() @classmethod def active_filter(cls): - return sa.true() + return sa.or_(cls.expire_on.is_(sa.null()), cls.expire_on > sa.func.now()) diff --git a/backend/geonature/migrations/versions/707390c722fe_permissions_add_extended_filters.py b/backend/geonature/migrations/versions/707390c722fe_permissions_add_extended_filters.py new file mode 100644 index 0000000000..230ac8e1e7 --- /dev/null +++ b/backend/geonature/migrations/versions/707390c722fe_permissions_add_extended_filters.py @@ -0,0 +1,33 @@ +"""permissions: add extended filters + +Revision ID: 707390c722fe +Revises: ebbe0f7ed866 +Create Date: 2024-09-30 17:13:44.650757 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "707390c722fe" +down_revision = "7b6a578eccd7" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + schema="gn_permissions", + table_name="t_permissions", + column=sa.Column("expire_on", sa.DateTime), + ) + + +def downgrade(): + op.drop_column( + schema="gn_permissions", + table_name="t_permissions", + column_name="expire_on", + ) diff --git a/backend/geonature/tests/test_permissions.py b/backend/geonature/tests/test_permissions.py index e63b115eef..dc5ced8887 100644 --- a/backend/geonature/tests/test_permissions.py +++ b/backend/geonature/tests/test_permissions.py @@ -1,4 +1,5 @@ from collections import ChainMap +from datetime import datetime, timedelta from itertools import product import pytest @@ -320,6 +321,18 @@ def test_has_any_perms( id_role=roles["r2"].id_role, module_code=module_a.module_code ) == b_cruved("111111") + def test_expired_perm(self, permissions, assert_cruved): + """ + Expired permissions should not been taken into account. + Permissons with expire_on=NULL should be considered active. + """ + permissions("r1", "1-----") + permissions("r1", "-1----", expire_on=None) + permissions("r1", "--1---", expire_on=datetime.now() - timedelta(days=1)) + permissions("r1", "---1--", expire_on=datetime.now() + timedelta(days=1)) + + assert_cruved("r1", "110100") + @pytest.mark.usefixtures("temporary_transaction", "g_permissions") class TestPermissionsFilters: From 00f73ed286377f0ccf23b287fb8af9c0e2c68b9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89lie=20Bouttier?= Date: Wed, 2 Oct 2024 17:07:21 +0200 Subject: [PATCH 07/14] feat(permissions): add validation fields --- .../geonature/core/gn_permissions/models.py | 11 ++++++-- ...c722fe_permissions_add_extended_filters.py | 25 +++++++++++++------ backend/geonature/tests/test_permissions.py | 11 +++++++- 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/backend/geonature/core/gn_permissions/models.py b/backend/geonature/core/gn_permissions/models.py index 025da5ebc4..e7468cdce3 100644 --- a/backend/geonature/core/gn_permissions/models.py +++ b/backend/geonature/core/gn_permissions/models.py @@ -203,7 +203,9 @@ class Permission(db.Model): ForeignKey(PermObject.id_object), default=select(PermObject.id_object).where(PermObject.code_object == "ALL"), ) + created_on = db.Column(sa.DateTime, server_default=sa.func.now()) expire_on = db.Column(db.DateTime) + validated = db.Column(sa.Boolean, server_default=sa.true()) role = db.relationship(User, backref=db.backref("permissions", cascade_backrefs=False)) action = db.relationship(PermAction) @@ -280,8 +282,13 @@ def nice_order(cls, **kwargs): @property def is_active(self): - return self.expire_on is None or self.expire_on > datetime.now() + return ( + self.expire_on is None or self.expire_on > datetime.now() + ) and self.validated is True @classmethod def active_filter(cls): - return sa.or_(cls.expire_on.is_(sa.null()), cls.expire_on > sa.func.now()) + return sa.and_( + sa.or_(cls.expire_on.is_(sa.null()), cls.expire_on > sa.func.now()), + cls.validated.is_(True), + ) diff --git a/backend/geonature/migrations/versions/707390c722fe_permissions_add_extended_filters.py b/backend/geonature/migrations/versions/707390c722fe_permissions_add_extended_filters.py index 230ac8e1e7..d196d15df0 100644 --- a/backend/geonature/migrations/versions/707390c722fe_permissions_add_extended_filters.py +++ b/backend/geonature/migrations/versions/707390c722fe_permissions_add_extended_filters.py @@ -18,16 +18,27 @@ def upgrade(): - op.add_column( + with op.batch_alter_table(schema="gn_permissions", table_name="t_permissions") as batch_op: + batch_op.add_column( + column=sa.Column("created_on", sa.DateTime), + ) + batch_op.add_column( + column=sa.Column("expire_on", sa.DateTime), + ) + batch_op.add_column( + column=sa.Column("validated", sa.Boolean, server_default=sa.true()), + ) + # We set server_default after column creation to initialialize existing rows with NULL value + op.alter_column( schema="gn_permissions", table_name="t_permissions", - column=sa.Column("expire_on", sa.DateTime), + column_name="created_on", + server_default=sa.func.now(), ) def downgrade(): - op.drop_column( - schema="gn_permissions", - table_name="t_permissions", - column_name="expire_on", - ) + with op.batch_alter_table(schema="gn_permissions", table_name="t_permissions") as batch_op: + batch_op.drop_column(column_name="validated") + batch_op.drop_column(column_name="created_on") + batch_op.drop_column(column_name="expire_on") diff --git a/backend/geonature/tests/test_permissions.py b/backend/geonature/tests/test_permissions.py index dc5ced8887..31faaba6a4 100644 --- a/backend/geonature/tests/test_permissions.py +++ b/backend/geonature/tests/test_permissions.py @@ -23,7 +23,7 @@ from pypnusershub.db.models import User -from sqlalchemy import select +from sqlalchemy import select, null @pytest.fixture(scope="class") @@ -333,6 +333,15 @@ def test_expired_perm(self, permissions, assert_cruved): assert_cruved("r1", "110100") + def test_validation_perm(self, permissions, assert_cruved): + """ + Permission not yet validated or refused should be ignored. + """ + permissions("r1", "1-----") # validation status default to True + permissions("r1", "-1----", validated=null()) # validation pending + permissions("r1", "--1---", validated=False) # permission refused + permissions("r1", "---1--", validated=True) # permission granted + @pytest.mark.usefixtures("temporary_transaction", "g_permissions") class TestPermissionsFilters: From ef80afe39e01dc3c3348acdd21abb2f258b21eea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89lie=20Bouttier?= Date: Fri, 4 Oct 2024 16:20:25 +0200 Subject: [PATCH 08/14] feat(permissions): add geographic filter type --- .../geonature/core/gn_permissions/admin.py | 45 ++++++++++-- .../geonature/core/gn_permissions/models.py | 31 +++++++++ .../geonature/core/gn_permissions/tools.py | 32 +++++++-- ...c722fe_permissions_add_extended_filters.py | 24 +++++++ backend/geonature/tests/test_permissions.py | 68 ++++++++++++++++++- backend/geonature/utils/config_schema.py | 5 ++ backend/static/js/hide_unnecessary_filters.js | 6 ++ 7 files changed, 198 insertions(+), 13 deletions(-) diff --git a/backend/geonature/core/gn_permissions/admin.py b/backend/geonature/core/gn_permissions/admin.py index 4b5b34f2f9..8d524a5a17 100644 --- a/backend/geonature/core/gn_permissions/admin.py +++ b/backend/geonature/core/gn_permissions/admin.py @@ -1,6 +1,7 @@ from flask import url_for, has_app_context, request from flask_admin.contrib.sqla import ModelView from flask_admin.contrib.sqla.filters import FilterEqual +from ref_geo.models import BibAreasTypes, LAreas import sqlalchemy as sa from flask_admin.contrib.sqla.tools import get_primary_key from flask_admin.contrib.sqla.fields import QuerySelectField @@ -332,6 +333,27 @@ def get_query(self): ) +class AreaAjaxModelLoader(QueryAjaxModelLoader): + def format(self, area): + return (area.id_area, f"{area.area_name} ({area.area_type.type_name})") + + def get_one(self, pk): + # prevent autoflush from occuring during populate_obj + with self.session.no_autoflush: + return self.session.get(self.model, pk) + + def get_query(self): + return ( + super() + .get_query() + .join(LAreas.area_type) + .where( + BibAreasTypes.type_code.in_(config["PERMISSIONS"]["GEOGRAPHIC_FILTER_AREA_TYPES"]) + ) + .order_by(BibAreasTypes.id_type, LAreas.area_name) + ) + + ### ModelViews @@ -374,6 +396,7 @@ class PermissionAdmin(CruvedProtectedMixin, ModelView): "Flouter" if config["SYNTHESE"]["BLUR_SENSITIVE_OBSERVATIONS"] else "Exclure" ) + " les données sensibles", + "areas_filter": "Filtre géographique", } column_select_related_list = ("availability",) column_searchable_list = ("role.identifiant", "role.nom_complet") @@ -404,23 +427,23 @@ class PermissionAdmin(CruvedProtectedMixin, ModelView): ("object.code_object", False), ("id_action", False), ] - form_columns = ("role", "availability", "scope", "sensitivity_filter") + form_columns = ("role", "availability", "scope", "sensitivity_filter", "areas_filter") form_overrides = dict( availability=OptionQuerySelectField, ) form_args = dict( availability=dict( query_factory=lambda: PermissionAvailable.nice_order(), - options_additional_values=["sensitivity_filter", "scope_filter"], + options_additional_values=["sensitivity_filter", "scope_filter", "areas_filter"], ), ) create_template = "admin/hide_select2_options_create.html" edit_template = "admin/hide_select2_options_edit.html" form_ajax_refs = { "role": UserAjaxModelLoader( - "role", - db.session, - User, + name="role", + session=db.session, + model=User, fields=( "identifiant", "nom_role", @@ -429,6 +452,15 @@ class PermissionAdmin(CruvedProtectedMixin, ModelView): placeholder="Veuillez sélectionner un utilisateur ou un groupe", minimum_input_length=0, ), + "areas_filter": AreaAjaxModelLoader( + name="areas_filter", + session=db.session, + model=LAreas, + fields=(LAreas.area_name, LAreas.area_code), + page_size=25, + placeholder="Sélectionnez une ou plusieurs zones géographiques", + minimum_input_length=1, + ), } def get_query(self): @@ -475,6 +507,7 @@ class PermissionAvailableAdmin(CruvedProtectedMixin, ModelView): "object": "Objet", "scope_filter": "Filtre appartenance", "sensitivity_filter": "Filtre sensibilité", + "areas_filter": "Filtre géographique", } column_formatters = { "module": lambda v, c, m, p: m.module.module_code, @@ -491,7 +524,7 @@ class PermissionAvailableAdmin(CruvedProtectedMixin, ModelView): ("object.code_object", False), ("id_action", False), ] - form_columns = ("scope_filter", "sensitivity_filter") + form_columns = ("scope_filter", "sensitivity_filter", "areas_filter") class RolePermAdmin(CruvedProtectedMixin, ModelView): diff --git a/backend/geonature/core/gn_permissions/models.py b/backend/geonature/core/gn_permissions/models.py index e7468cdce3..b7809ed42f 100644 --- a/backend/geonature/core/gn_permissions/models.py +++ b/backend/geonature/core/gn_permissions/models.py @@ -5,6 +5,7 @@ from packaging import version from datetime import datetime +from ref_geo.models import LAreas import sqlalchemy as sa from sqlalchemy import ForeignKey, ForeignKeyConstraint from sqlalchemy.sql import select @@ -133,10 +134,12 @@ class PermissionAvailable(db.Model): scope_filter = db.Column(db.Boolean, server_default=sa.false()) sensitivity_filter = db.Column(db.Boolean, server_default=sa.false(), nullable=False) + areas_filter = db.Column(db.Boolean, server_default=sa.false(), nullable=False) filters_fields = { "SCOPE": "scope_filter", "SENSITIVITY": "sensitivity_filter", + "GEOGRAPHIC": "areas_filter", } @property @@ -177,6 +180,25 @@ def __str__(self): return """ non sensible""" else: return """ sensible et non sensible""" + elif self.name == "GEOGRAPHIC": + if self.value: + areas_names = ", ".join([a.area_name for a in self.value]) + return f""" {areas_names}""" + else: + return """ partout""" + + +cor_permission_area = db.Table( + "cor_permission_area", + sa.Column( + "id_permission", + sa.Integer, + sa.ForeignKey("gn_permissions.t_permissions.id_permission"), + primary_key=True, + ), + sa.Column("id_area", sa.Integer, sa.ForeignKey("ref_geo.l_areas.id_area"), primary_key=True), + schema="gn_permissions", +) @serializable @@ -215,6 +237,7 @@ class Permission(db.Model): scope_value = db.Column(db.Integer, ForeignKey(PermScope.value), nullable=True) scope = db.relationship(PermScope) sensitivity_filter = db.Column(db.Boolean, server_default=sa.false(), nullable=False) + areas_filter = db.relationship(LAreas, secondary=cor_permission_area) availability = db.relationship( PermissionAvailable, @@ -225,6 +248,7 @@ class Permission(db.Model): filters_fields = { "SCOPE": "scope_value", "SENSITIVITY": "sensitivity_filter", + "GEOGRAPHIC": "areas_filter", } @staticmethod @@ -236,6 +260,10 @@ def __SENSITIVITY_le__(a, b): # False only if: A is False and b is True return (not a) <= (not b) + @staticmethod + def __GEOGRAPHIC_le__(a, b): + return (a and set(a).issubset(b)) or not b + @staticmethod def __default_le__(a, b): return a == b or b is None @@ -267,6 +295,9 @@ def filters(self): if column.type.python_type is bool: if not value: continue + elif field in mapper.relationships: + if value == []: + continue filters.append(PermFilter(name, value)) return filters diff --git a/backend/geonature/core/gn_permissions/tools.py b/backend/geonature/core/gn_permissions/tools.py index 8df59dbcc8..bce0454273 100644 --- a/backend/geonature/core/gn_permissions/tools.py +++ b/backend/geonature/core/gn_permissions/tools.py @@ -3,7 +3,8 @@ from itertools import groupby, permutations import sqlalchemy as sa -from sqlalchemy.orm import joinedload +from sqlalchemy.orm import joinedload, selectinload +from sqlalchemy.dialects.postgresql import array_agg, aggregate_order_by from flask import has_request_context, g from geonature.core.gn_commons.models import TModules @@ -12,6 +13,7 @@ PermObject, PermScope, Permission, + cor_permission_area, ) from geonature.utils.env import db @@ -21,13 +23,26 @@ def _get_user_permissions(id_role): - return db.session.scalars( + # This subquery create a list of areas which is used to identify duplicated permissions. + areas_filter_query = ( + sa.select( + cor_permission_area.c.id_permission, + array_agg( + aggregate_order_by(cor_permission_area.c.id_area, cor_permission_area.c.id_area), + ).label("areas_filter"), + ) + .group_by(cor_permission_area.c.id_permission) + .subquery() + ) + query = ( sa.select(Permission) .options( joinedload(Permission.module), joinedload(Permission.object), joinedload(Permission.action), + selectinload(Permission.areas_filter), ) + .outerjoin(areas_filter_query) .where( Permission.active_filter(), sa.or_( @@ -39,13 +54,20 @@ def _get_user_permissions(id_role): ), ) .order_by(Permission.id_module, Permission.id_object, Permission.id_action) + # Remove duplicate permissions (typically overlapping user-level and group-level permissions) .distinct( Permission.id_module, Permission.id_object, Permission.id_action, - *Permission.filters_fields.values(), + *[ + getattr(Permission, v) + for v in Permission.filters_fields.values() + if v in Permission.__mapper__.columns + ], + areas_filter_query.c.areas_filter, ) - ).all() + ) + return db.session.scalars(query).all() def get_user_permissions(id_role=None): @@ -69,6 +91,8 @@ def _get_permissions(id_role, module_code, object_code, action_code): } # Remove all permissions supersed by another permission + # /!\ if p1 == p2, both will be removed! + # Ensure to eliminate all duplicates when querying permissions for p1, p2 in permutations(permissions, 2): if p1 in permissions and p1 <= p2: permissions.remove(p1) diff --git a/backend/geonature/migrations/versions/707390c722fe_permissions_add_extended_filters.py b/backend/geonature/migrations/versions/707390c722fe_permissions_add_extended_filters.py index d196d15df0..beea6d6e6a 100644 --- a/backend/geonature/migrations/versions/707390c722fe_permissions_add_extended_filters.py +++ b/backend/geonature/migrations/versions/707390c722fe_permissions_add_extended_filters.py @@ -35,9 +35,33 @@ def upgrade(): column_name="created_on", server_default=sa.func.now(), ) + op.create_table( + "cor_permission_area", + sa.Column( + "id_permission", + sa.Integer, + sa.ForeignKey("gn_permissions.t_permissions.id_permission"), + primary_key=True, + ), + sa.Column( + "id_area", sa.Integer, sa.ForeignKey("ref_geo.l_areas.id_area"), primary_key=True + ), + schema="gn_permissions", + ) + with op.batch_alter_table( + schema="gn_permissions", table_name="t_permissions_available" + ) as batch_op: + batch_op.add_column( + column=sa.Column("areas_filter", sa.Boolean, nullable=False, server_default=sa.false()) + ) def downgrade(): + with op.batch_alter_table( + schema="gn_permissions", table_name="t_permissions_available" + ) as batch_op: + batch_op.drop_column(column_name="areas_filter") + op.drop_table(schema="gn_permissions", table_name="cor_permission_area") with op.batch_alter_table(schema="gn_permissions", table_name="t_permissions") as batch_op: batch_op.drop_column(column_name="validated") batch_op.drop_column(column_name="created_on") diff --git a/backend/geonature/tests/test_permissions.py b/backend/geonature/tests/test_permissions.py index 31faaba6a4..b83dd4d0da 100644 --- a/backend/geonature/tests/test_permissions.py +++ b/backend/geonature/tests/test_permissions.py @@ -3,8 +3,8 @@ from itertools import product import pytest - from flask import g +import sqlalchemy as sa from geonature.core.gn_commons.models import TModules from geonature.core.gn_permissions.models import ( @@ -23,6 +23,7 @@ from pypnusershub.db.models import User +from ref_geo.models import BibAreasTypes, LAreas from sqlalchemy import select, null @@ -202,9 +203,10 @@ def _assert_permissions(role, action_code, expected_perms, module=None, object=N module_code=module_code, object_code=object_code, ) - perms = set((p.scope_value, p.sensitivity_filter) for p in perms) + perms = set((p.scope_value, p.sensitivity_filter, frozenset(p.areas_filter)) for p in perms) expected_perms = set( - (p.get("SCOPE", None), p.get("SENSITIVITY", False)) for p in expected_perms + (p.get("SCOPE", None), p.get("SENSITIVITY", False), frozenset(p.get("AREAS", []))) + for p in expected_perms ) assert perms == expected_perms @@ -372,3 +374,63 @@ def test_sensitivity_filter_overlap(self, permissions, assert_permissions): assert_permissions( "g12_r1", "U", [{"SCOPE": 2, "SENSITIVITY": True}, {"SCOPE": 1, "SENSITIVITY": False}] ) + + def test_geographic_filter(self, roles, permissions, assert_permissions): + grenoble = db.session.execute( + sa.select(LAreas).where( + LAreas.area_type.has(BibAreasTypes.type_code == "COM"), + LAreas.area_name == "Grenoble", + ) + ).scalar_one() + + permissions("r1", "1-----") + permissions("r1", "-1----", areas_filter=[]) + permissions("r1", "--1---", areas_filter=[grenoble]) + + assert_permissions("r1", "C", [{"SCOPE": 1, "AREAS": []}]) + assert_permissions("r1", "R", [{"SCOPE": 1, "AREAS": []}]) + assert_permissions("r1", "U", [{"SCOPE": 1, "AREAS": [grenoble]}]) + + def test_geographic_filter_overlap(self, roles, permissions, assert_permissions): + grenoble = db.session.execute( + sa.select(LAreas).where( + LAreas.area_type.has(BibAreasTypes.type_code == "COM"), + LAreas.area_name == "Grenoble", + ) + ).scalar_one() + gap = db.session.execute( + sa.select(LAreas).where( + LAreas.area_type.has(BibAreasTypes.type_code == "COM"), + LAreas.area_name == "Gap", + ) + ).scalar_one() + + assert Permission(areas_filter=[gap]) <= Permission(areas_filter=[gap]) + assert not Permission(areas_filter=[gap]) <= Permission(areas_filter=[grenoble]) + assert not Permission(areas_filter=[grenoble]) <= Permission(areas_filter=[gap]) + + permissions("g1", "1-----", areas_filter=[grenoble]) + permissions("g2", "1-----", areas_filter=[]) + permissions("g1", "-1----", areas_filter=[grenoble]) + permissions("g2", "-2----", areas_filter=[]) + permissions("g1", "--2---", areas_filter=[grenoble]) + permissions("g2", "--1---", areas_filter=[]) + permissions("g1", "---1--", areas_filter=[grenoble]) + permissions("g2", "---2--", areas_filter=[grenoble]) + permissions("g1", "----1-", areas_filter=[grenoble, gap]) + permissions("g2", "----2-", areas_filter=[grenoble]) + permissions("g1", "-----1", areas_filter=[grenoble]) + permissions("g2", "-----2", areas_filter=[grenoble, gap]) + + assert_permissions("g12_r1", "C", [{"SCOPE": 1, "AREAS": []}]) + assert_permissions("g12_r1", "R", [{"SCOPE": 2, "AREAS": []}]) + assert_permissions( + "g12_r1", "U", [{"SCOPE": 1, "AREAS": []}, {"SCOPE": 2, "AREAS": [grenoble]}] + ) + assert_permissions("g12_r1", "V", [{"SCOPE": 2, "AREAS": [grenoble]}]) + assert_permissions( + "g12_r1", + "E", + [{"SCOPE": 1, "AREAS": [grenoble, gap]}, {"SCOPE": 2, "AREAS": [grenoble]}], + ) + assert_permissions("g12_r1", "D", [{"SCOPE": 2, "AREAS": [grenoble, gap]}]) diff --git a/backend/geonature/utils/config_schema.py b/backend/geonature/utils/config_schema.py index 4d21312bba..079acb42b9 100644 --- a/backend/geonature/utils/config_schema.py +++ b/backend/geonature/utils/config_schema.py @@ -458,6 +458,10 @@ def warn_deprecated(self, data, **kwargs): return data +class PermissionConfig(Schema): + GEOGRAPHIC_FILTER_AREA_TYPES = fields.List(fields.String(), load_default=["COM", "DEP", "REG"]) + + # Map configuration BASEMAP = [ { @@ -559,6 +563,7 @@ class GnGeneralSchemaConf(Schema): FRONTEND = fields.Nested(GnFrontEndConf, load_default=GnFrontEndConf().load({})) SYNTHESE = fields.Nested(Synthese, load_default=Synthese().load({})) IMPORT = fields.Nested(ImportConfigSchema, load_default=ImportConfigSchema().load({})) + PERMISSIONS = fields.Nested(PermissionConfig, load_default=PermissionConfig().load({})) MAPCONFIG = fields.Nested(MapConfig, load_default=MapConfig().load({})) # Ajoute la surchouche 'taxonomique' sur l'API nomenclature ENABLE_NOMENCLATURE_TAXONOMIC_FILTERS = fields.Boolean(load_default=True) diff --git a/backend/static/js/hide_unnecessary_filters.js b/backend/static/js/hide_unnecessary_filters.js index da149eb099..831781a63c 100644 --- a/backend/static/js/hide_unnecessary_filters.js +++ b/backend/static/js/hide_unnecessary_filters.js @@ -15,6 +15,12 @@ $('#availability').on('change', function() { $("#scope").parent().hide(); $("#scope").val("__None").trigger("change"); } + + if (selected && selected.hasAttribute("areas_filter")) + $("#areas_filter").parent().show(); + else { + $("#areas_filter").parent().hide(); + } }); $('#availability').trigger('change'); From f7fb36ad8c463f9cadd81a7b5ddb1a7047fde50d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89lie=20Bouttier?= Date: Fri, 4 Oct 2024 16:31:27 +0200 Subject: [PATCH 09/14] feat(synthese): implement geographic filters --- backend/geonature/core/gn_synthese/models.py | 11 ++++ .../gn_synthese/utils/query_select_sqla.py | 7 ++- ...c722fe_permissions_add_extended_filters.py | 20 +++++++ backend/geonature/tests/test_synthese.py | 52 +++++++++++++++++++ 4 files changed, 89 insertions(+), 1 deletion(-) diff --git a/backend/geonature/core/gn_synthese/models.py b/backend/geonature/core/gn_synthese/models.py index 030b5e098a..ec59bb37b7 100644 --- a/backend/geonature/core/gn_synthese/models.py +++ b/backend/geonature/core/gn_synthese/models.py @@ -480,6 +480,10 @@ def _has_permissions_grant(self, permissions) -> bool: or self.dataset.has_instance_permission(perm.scope_value) ): continue # scope filter denied access, check next permission + if perm.areas_filter: + if set(perm.areas_filter).isdisjoint(self.areas): + # the permission does not allows any area overlapping the observation areas + continue return True # no filter exclude this permission return False @@ -650,6 +654,13 @@ class VSyntheseForWebApp(DB.Model): url_source = DB.Column(DB.Unicode) st_asgeojson = DB.Column(DB.Unicode) + areas = relationship( + LAreas, + secondary=corAreaSynthese, + primaryjoin=corAreaSynthese.c.id_synthese == id_synthese, + secondaryjoin=corAreaSynthese.c.id_area == LAreas.id_area, + overlaps="areas,synthese_obs", + ) medias = relationship( TMedias, primaryjoin=(TMedias.uuid_attached_row == foreign(unique_id_sinp)), uselist=True ) diff --git a/backend/geonature/core/gn_synthese/utils/query_select_sqla.py b/backend/geonature/core/gn_synthese/utils/query_select_sqla.py index 732d0b5df2..9e89b6cd27 100644 --- a/backend/geonature/core/gn_synthese/utils/query_select_sqla.py +++ b/backend/geonature/core/gn_synthese/utils/query_select_sqla.py @@ -147,7 +147,7 @@ def build_permissions_filter(self, user, permissions): permissions_filters = [] excluded_sensitivity = None for perm in permissions: - if perm.has_other_filters_than("SCOPE", "SENSITIVITY"): + if perm.has_other_filters_than("SCOPE", "SENSITIVITY", "GEOGRAPHIC"): continue perm_filters = [] if perm.sensitivity_filter: @@ -188,6 +188,11 @@ def build_permissions_filter(self, user, permissions): ), # user is dataset (or parent af) actor ] perm_filters.append(or_(*scope_filters)) + if perm.areas_filter: + where_clause = self.model.areas.any( + LAreas.id_area.in_([a.id_area for a in perm.areas_filter]) + ) + perm_filters.append(where_clause) if perm_filters: permissions_filters.append(and_(*perm_filters)) else: diff --git a/backend/geonature/migrations/versions/707390c722fe_permissions_add_extended_filters.py b/backend/geonature/migrations/versions/707390c722fe_permissions_add_extended_filters.py index beea6d6e6a..f50d050de1 100644 --- a/backend/geonature/migrations/versions/707390c722fe_permissions_add_extended_filters.py +++ b/backend/geonature/migrations/versions/707390c722fe_permissions_add_extended_filters.py @@ -54,6 +54,26 @@ def upgrade(): batch_op.add_column( column=sa.Column("areas_filter", sa.Boolean, nullable=False, server_default=sa.false()) ) + op.execute( + """ + UPDATE + gn_permissions.t_permissions_available pa + SET + areas_filter = True + FROM + gn_commons.t_modules m, + gn_permissions.t_objects o, + gn_permissions.bib_actions a + WHERE + pa.id_module = m.id_module + AND + pa.id_object = o.id_object + AND + pa.id_action = a.id_action + AND + m.module_code = 'SYNTHESE' AND o.code_object = 'ALL' and a.code_action IN ('R','E') + """ + ) def downgrade(): diff --git a/backend/geonature/tests/test_synthese.py b/backend/geonature/tests/test_synthese.py index 84f604d07b..bbdd17a093 100644 --- a/backend/geonature/tests/test_synthese.py +++ b/backend/geonature/tests/test_synthese.py @@ -37,6 +37,7 @@ from apptax.taxonomie.models import Taxref from ref_geo.models import BibAreasTypes, LAreas from apptax.tests.fixtures import noms_example, attribut_example, liste +from pypnusershub.db.models import User from pypnusershub.tests.utils import logged_user_headers, set_logged_user from utils_flask_sqla_geo.schema import GeoModelConverter, GeoAlchemyAutoSchema @@ -1836,3 +1837,54 @@ def test_export_observations_sensitive_excluded( # No feature accessible because sensitive data excluded if # the user has no right to see it assert len(response.json["features"]) == 0 + + +@pytest.mark.usefixtures("client_class", "temporary_transaction") +class TestSyntheseGeographicFilter: + def test_geographic_filter_get_obs(self, synthese_data, synthese_read_permissions): + with db.session.begin_nested(): + user = User() + db.session.add(user) + chambery = db.session.execute( + sa.select(LAreas).where(LAreas.area_name == "Chambéry") + ).scalar_one() + guirec = db.session.execute( + sa.select(LAreas).where(LAreas.area_name == "Perros-Guirec") + ).scalar_one() + synthese_read_permissions(user, scope_value=None, areas_filter=[chambery, guirec]) + set_logged_user(self.client, user) + response = self.client.get( + url_for("gn_synthese.get_one_synthese", id_synthese=synthese_data["obs1"].id_synthese) + ) + assert response.status_code == 200, response.data + response = self.client.get( + url_for("gn_synthese.get_one_synthese", id_synthese=synthese_data["obs2"].id_synthese) + ) + assert response.status_code == Forbidden.code, response.data + response = self.client.get( + url_for("gn_synthese.get_one_synthese", id_synthese=synthese_data["obs3"].id_synthese) + ) + assert response.status_code == 200, response.data + + def test_geographic_filter_list_obs(self, synthese_data, synthese_read_permissions): + with db.session.begin_nested(): + user = User() + db.session.add(user) + chambery = db.session.execute( + sa.select(LAreas).where(LAreas.area_name == "Chambéry") + ).scalar_one() + guirec = db.session.execute( + sa.select(LAreas).where(LAreas.area_name == "Perros-Guirec") + ).scalar_one() # FIXME une requète + synthese_read_permissions(user, scope_value=None, areas_filter=[chambery, guirec]) + set_logged_user(self.client, user) + response = self.client.get( + url_for( + "gn_synthese.get_observations_for_web", + ) + ) + assert response.status_code == 200, response.data + response_ids = [f["properties"]["id_synthese"] for f in response.json["features"]] + assert synthese_data["obs1"].id_synthese in response_ids + assert synthese_data["obs2"].id_synthese not in response_ids + assert synthese_data["obs3"].id_synthese in response_ids From 3872afe11c78846857a2ed304644f8be923a74b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89lie=20Bouttier?= Date: Sat, 5 Oct 2024 16:46:28 +0200 Subject: [PATCH 10/14] feat(permissions): add taxonomic filter type --- .../geonature/core/gn_permissions/admin.py | 43 ++++++++++- .../geonature/core/gn_permissions/models.py | 29 +++++++ .../geonature/core/gn_permissions/tools.py | 15 ++++ ...c722fe_permissions_add_extended_filters.py | 16 ++++ backend/geonature/tests/test_permissions.py | 75 ++++++++++++++++++- backend/static/js/hide_unnecessary_filters.js | 6 ++ 6 files changed, 178 insertions(+), 6 deletions(-) diff --git a/backend/geonature/core/gn_permissions/admin.py b/backend/geonature/core/gn_permissions/admin.py index 8d524a5a17..041eca79fc 100644 --- a/backend/geonature/core/gn_permissions/admin.py +++ b/backend/geonature/core/gn_permissions/admin.py @@ -2,6 +2,7 @@ from flask_admin.contrib.sqla import ModelView from flask_admin.contrib.sqla.filters import FilterEqual from ref_geo.models import BibAreasTypes, LAreas +from apptax.taxonomie.models import Taxref import sqlalchemy as sa from flask_admin.contrib.sqla.tools import get_primary_key from flask_admin.contrib.sqla.fields import QuerySelectField @@ -354,6 +355,14 @@ def get_query(self): ) +class TaxrefAjaxModelLoader(QueryAjaxModelLoader): + def format(self, taxref): + label = f"[{taxref.cd_nom}] {taxref.nom_valide}" + if taxref.nom_vern: + label += f" ({taxref.nom_vern})" + return (taxref.cd_nom, label) + + ### ModelViews @@ -397,6 +406,7 @@ class PermissionAdmin(CruvedProtectedMixin, ModelView): ) + " les données sensibles", "areas_filter": "Filtre géographique", + "taxons_filter": "Filtre taxonomique", } column_select_related_list = ("availability",) column_searchable_list = ("role.identifiant", "role.nom_complet") @@ -427,14 +437,26 @@ class PermissionAdmin(CruvedProtectedMixin, ModelView): ("object.code_object", False), ("id_action", False), ] - form_columns = ("role", "availability", "scope", "sensitivity_filter", "areas_filter") + form_columns = ( + "role", + "availability", + "scope", + "sensitivity_filter", + "areas_filter", + "taxons_filter", + ) form_overrides = dict( availability=OptionQuerySelectField, ) form_args = dict( availability=dict( query_factory=lambda: PermissionAvailable.nice_order(), - options_additional_values=["sensitivity_filter", "scope_filter", "areas_filter"], + options_additional_values=[ + "sensitivity_filter", + "scope_filter", + "areas_filter", + "taxons_filter", + ], ), ) create_template = "admin/hide_select2_options_create.html" @@ -461,6 +483,20 @@ class PermissionAdmin(CruvedProtectedMixin, ModelView): placeholder="Sélectionnez une ou plusieurs zones géographiques", minimum_input_length=1, ), + "taxons_filter": TaxrefAjaxModelLoader( + name="taxons_filter", + session=db.session, + model=Taxref, + fields=( + Taxref.cd_nom, + Taxref.nom_vern, + Taxref.nom_valide, + Taxref.nom_complet, + ), + page_size=25, + placeholder="Sélectionnez un ou plusieurs taxons", + minimum_input_length=1, + ), } def get_query(self): @@ -508,6 +544,7 @@ class PermissionAvailableAdmin(CruvedProtectedMixin, ModelView): "scope_filter": "Filtre appartenance", "sensitivity_filter": "Filtre sensibilité", "areas_filter": "Filtre géographique", + "taxons_filter": "Filtre taxonomique", } column_formatters = { "module": lambda v, c, m, p: m.module.module_code, @@ -524,7 +561,7 @@ class PermissionAvailableAdmin(CruvedProtectedMixin, ModelView): ("object.code_object", False), ("id_action", False), ] - form_columns = ("scope_filter", "sensitivity_filter", "areas_filter") + form_columns = ("scope_filter", "sensitivity_filter", "areas_filter", "taxons_filter") class RolePermAdmin(CruvedProtectedMixin, ModelView): diff --git a/backend/geonature/core/gn_permissions/models.py b/backend/geonature/core/gn_permissions/models.py index b7809ed42f..6098a1d4a3 100644 --- a/backend/geonature/core/gn_permissions/models.py +++ b/backend/geonature/core/gn_permissions/models.py @@ -15,6 +15,7 @@ from utils_flask_sqla.serializers import serializable from pypnusershub.db.models import User +from apptax.taxonomie.models import Taxref from geonature.utils.env import db from geonature.core.gn_commons.models.base import TModules @@ -135,11 +136,13 @@ class PermissionAvailable(db.Model): scope_filter = db.Column(db.Boolean, server_default=sa.false()) sensitivity_filter = db.Column(db.Boolean, server_default=sa.false(), nullable=False) areas_filter = db.Column(db.Boolean, server_default=sa.false(), nullable=False) + taxons_filter = db.Column(db.Boolean, server_default=sa.false(), nullable=False) filters_fields = { "SCOPE": "scope_filter", "SENSITIVITY": "sensitivity_filter", "GEOGRAPHIC": "areas_filter", + "TAXONOMIC": "taxons_filter", } @property @@ -186,6 +189,12 @@ def __str__(self): return f""" {areas_names}""" else: return """ partout""" + elif self.name == "TAXONOMIC": + if self.value: + taxons_names = ", ".join([t.nom_vern_or_lb_nom for t in self.value]) + return f""" {taxons_names}""" + else: + return """ le vivant""" cor_permission_area = db.Table( @@ -201,6 +210,19 @@ def __str__(self): ) +cor_permission_taxref = db.Table( + "cor_permission_taxref", + sa.Column( + "id_permission", + sa.Integer, + sa.ForeignKey("gn_permissions.t_permissions.id_permission"), + primary_key=True, + ), + sa.Column("cd_nom", sa.Integer, sa.ForeignKey("taxonomie.taxref.cd_nom"), primary_key=True), + schema="gn_permissions", +) + + @serializable class Permission(db.Model): __tablename__ = "t_permissions" @@ -238,6 +260,7 @@ class Permission(db.Model): scope = db.relationship(PermScope) sensitivity_filter = db.Column(db.Boolean, server_default=sa.false(), nullable=False) areas_filter = db.relationship(LAreas, secondary=cor_permission_area) + taxons_filter = db.relationship(Taxref, secondary=cor_permission_taxref) availability = db.relationship( PermissionAvailable, @@ -249,6 +272,7 @@ class Permission(db.Model): "SCOPE": "scope_value", "SENSITIVITY": "sensitivity_filter", "GEOGRAPHIC": "areas_filter", + "TAXONOMIC": "taxons_filter", } @staticmethod @@ -264,6 +288,11 @@ def __SENSITIVITY_le__(a, b): def __GEOGRAPHIC_le__(a, b): return (a and set(a).issubset(b)) or not b + @staticmethod + def __TAXONOMIC_le__(a, b): + # True if *all* taxons of a is included in *any* taxons of b + return (a and any(all((_a <= _b for _a in a)) for _b in b)) or not b + @staticmethod def __default_le__(a, b): return a == b or b is None diff --git a/backend/geonature/core/gn_permissions/tools.py b/backend/geonature/core/gn_permissions/tools.py index bce0454273..cbf0b5e829 100644 --- a/backend/geonature/core/gn_permissions/tools.py +++ b/backend/geonature/core/gn_permissions/tools.py @@ -14,10 +14,12 @@ PermScope, Permission, cor_permission_area, + cor_permission_taxref, ) from geonature.utils.env import db from pypnusershub.db.models import User +from apptax.taxonomie.models import Taxref log = logging.getLogger() @@ -34,6 +36,16 @@ def _get_user_permissions(id_role): .group_by(cor_permission_area.c.id_permission) .subquery() ) + taxons_filter_query = ( + sa.select( + cor_permission_taxref.c.id_permission, + array_agg( + aggregate_order_by(cor_permission_taxref.c.cd_nom, cor_permission_taxref.c.cd_nom), + ).label("taxons_filter"), + ) + .group_by(cor_permission_taxref.c.id_permission) + .subquery() + ) query = ( sa.select(Permission) .options( @@ -41,8 +53,10 @@ def _get_user_permissions(id_role): joinedload(Permission.object), joinedload(Permission.action), selectinload(Permission.areas_filter), + selectinload(Permission.taxons_filter).joinedload(Taxref.tree), ) .outerjoin(areas_filter_query) + .outerjoin(taxons_filter_query) .where( Permission.active_filter(), sa.or_( @@ -65,6 +79,7 @@ def _get_user_permissions(id_role): if v in Permission.__mapper__.columns ], areas_filter_query.c.areas_filter, + taxons_filter_query.c.taxons_filter, ) ) return db.session.scalars(query).all() diff --git a/backend/geonature/migrations/versions/707390c722fe_permissions_add_extended_filters.py b/backend/geonature/migrations/versions/707390c722fe_permissions_add_extended_filters.py index f50d050de1..7c92027044 100644 --- a/backend/geonature/migrations/versions/707390c722fe_permissions_add_extended_filters.py +++ b/backend/geonature/migrations/versions/707390c722fe_permissions_add_extended_filters.py @@ -48,12 +48,26 @@ def upgrade(): ), schema="gn_permissions", ) + op.create_table( + "cor_permission_taxref", + sa.Column( + "id_permission", + sa.Integer, + sa.ForeignKey("gn_permissions.t_permissions.id_permission"), + primary_key=True, + ), + sa.Column("cd_nom", sa.Integer, sa.ForeignKey("taxonomie.taxref.cd_nom"), primary_key=True), + schema="gn_permissions", + ) with op.batch_alter_table( schema="gn_permissions", table_name="t_permissions_available" ) as batch_op: batch_op.add_column( column=sa.Column("areas_filter", sa.Boolean, nullable=False, server_default=sa.false()) ) + batch_op.add_column( + column=sa.Column("taxons_filter", sa.Boolean, nullable=False, server_default=sa.false()) + ) op.execute( """ UPDATE @@ -80,7 +94,9 @@ def downgrade(): with op.batch_alter_table( schema="gn_permissions", table_name="t_permissions_available" ) as batch_op: + batch_op.drop_column(column_name="taxons_filter") batch_op.drop_column(column_name="areas_filter") + op.drop_table(schema="gn_permissions", table_name="cor_permission_taxref") op.drop_table(schema="gn_permissions", table_name="cor_permission_area") with op.batch_alter_table(schema="gn_permissions", table_name="t_permissions") as batch_op: batch_op.drop_column(column_name="validated") diff --git a/backend/geonature/tests/test_permissions.py b/backend/geonature/tests/test_permissions.py index b83dd4d0da..a5a8fbdc30 100644 --- a/backend/geonature/tests/test_permissions.py +++ b/backend/geonature/tests/test_permissions.py @@ -22,6 +22,7 @@ from geonature.utils.env import db from pypnusershub.db.models import User +from apptax.taxonomie.models import Taxref from ref_geo.models import BibAreasTypes, LAreas from sqlalchemy import select, null @@ -203,10 +204,25 @@ def _assert_permissions(role, action_code, expected_perms, module=None, object=N module_code=module_code, object_code=object_code, ) - perms = set((p.scope_value, p.sensitivity_filter, frozenset(p.areas_filter)) for p in perms) + perms = set( + ( + p.scope_value, + p.sensitivity_filter, + frozenset(p.areas_filter), + frozenset(p.taxons_filter), + ) + for p in perms + ) expected_perms = set( - (p.get("SCOPE", None), p.get("SENSITIVITY", False), frozenset(p.get("AREAS", []))) - for p in expected_perms + ( + ( + p.get("SCOPE", None), + p.get("SENSITIVITY", False), + frozenset(p.get("AREAS", [])), + frozenset(p.get("TAXONS", [])), + ) + for p in expected_perms + ) ) assert perms == expected_perms @@ -434,3 +450,56 @@ def test_geographic_filter_overlap(self, roles, permissions, assert_permissions) [{"SCOPE": 1, "AREAS": [grenoble, gap]}, {"SCOPE": 2, "AREAS": [grenoble]}], ) assert_permissions("g12_r1", "D", [{"SCOPE": 2, "AREAS": [grenoble, gap]}]) + + def test_taxonomic_filter(self, roles, permissions, assert_permissions): + animalia = db.session.execute(sa.select(Taxref).where(Taxref.cd_nom == 183716)).scalar_one() + + permissions("r1", "1-----") + permissions("r1", "-1----", taxons_filter=[]) + permissions("r1", "--1---", taxons_filter=[animalia]) + + assert_permissions("r1", "C", [{"SCOPE": 1, "TAXONS": []}]) + assert_permissions("r1", "R", [{"SCOPE": 1, "TAXONS": []}]) + assert_permissions("r1", "U", [{"SCOPE": 1, "TAXONS": [animalia]}]) + + def test_taxonomic_filter_overlap(self, roles, permissions, assert_permissions): + animalia = db.session.execute(sa.select(Taxref).where(Taxref.cd_nom == 183716)).scalar_one() + capra_ibex = db.session.execute( + sa.select(Taxref).where(Taxref.cd_nom == 61098) + ).scalar_one() + cinnamon = db.session.execute(sa.select(Taxref).where(Taxref.cd_nom == 706584)).scalar_one() + + assert Permission(taxons_filter=[animalia]) <= Permission(taxons_filter=[animalia]) + assert Permission(taxons_filter=[capra_ibex]) <= Permission(taxons_filter=[animalia]) + assert not Permission(taxons_filter=[animalia]) <= Permission(taxons_filter=[capra_ibex]) + assert not Permission(taxons_filter=[cinnamon]) <= Permission(taxons_filter=[animalia]) + assert not Permission(taxons_filter=[cinnamon]) <= Permission(taxons_filter=[capra_ibex]) + assert not Permission(taxons_filter=[cinnamon, capra_ibex]) <= Permission( + taxons_filter=[animalia] + ) + + permissions("g1", "1-----", taxons_filter=[capra_ibex]) + permissions("g2", "1-----", taxons_filter=[]) + permissions("g1", "-1----", taxons_filter=[capra_ibex]) + permissions("g2", "-2----", taxons_filter=[]) + permissions("g1", "--2---", taxons_filter=[capra_ibex]) + permissions("g2", "--1---", taxons_filter=[]) + permissions("g1", "---1--", taxons_filter=[capra_ibex]) + permissions("g2", "---2--", taxons_filter=[capra_ibex]) + permissions("g1", "----1-", taxons_filter=[capra_ibex]) + permissions("g2", "----2-", taxons_filter=[animalia]) + permissions("g1", "-----1", taxons_filter=[capra_ibex, cinnamon]) + permissions("g2", "-----2", taxons_filter=[animalia]) + + assert_permissions("g12_r1", "C", [{"SCOPE": 1, "TAXONS": []}]) + assert_permissions("g12_r1", "R", [{"SCOPE": 2, "TAXONS": []}]) + assert_permissions( + "g12_r1", "U", [{"SCOPE": 1, "TAXONS": []}, {"SCOPE": 2, "TAXONS": [capra_ibex]}] + ) + assert_permissions("g12_r1", "V", [{"SCOPE": 2, "TAXONS": [capra_ibex]}]) + assert_permissions("g12_r1", "E", [{"SCOPE": 2, "TAXONS": [animalia]}]) + assert_permissions( + "g12_r1", + "D", + [{"SCOPE": 1, "TAXONS": [capra_ibex, cinnamon]}, {"SCOPE": 2, "TAXONS": [animalia]}], + ) diff --git a/backend/static/js/hide_unnecessary_filters.js b/backend/static/js/hide_unnecessary_filters.js index 831781a63c..b1c1d4750c 100644 --- a/backend/static/js/hide_unnecessary_filters.js +++ b/backend/static/js/hide_unnecessary_filters.js @@ -21,6 +21,12 @@ $('#availability').on('change', function() { else { $("#areas_filter").parent().hide(); } + + if (selected && selected.hasAttribute("taxons_filter")) + $("#taxons_filter").parent().show(); + else { + $("#taxons_filter").parent().hide(); + } }); $('#availability').trigger('change'); From f72bf04deab93580a5dcc97717cc4444d977be45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89lie=20Bouttier?= Date: Sat, 5 Oct 2024 16:59:45 +0200 Subject: [PATCH 11/14] feat(synthese): implement taxonomic filters --- backend/geonature/core/gn_synthese/models.py | 17 +++++++- .../gn_synthese/utils/query_select_sqla.py | 13 +++++- ...c722fe_permissions_add_extended_filters.py | 20 +++++++++ backend/geonature/tests/test_synthese.py | 43 +++++++++++++++++++ 4 files changed, 90 insertions(+), 3 deletions(-) diff --git a/backend/geonature/core/gn_synthese/models.py b/backend/geonature/core/gn_synthese/models.py index ec59bb37b7..9f0036af4a 100644 --- a/backend/geonature/core/gn_synthese/models.py +++ b/backend/geonature/core/gn_synthese/models.py @@ -9,6 +9,7 @@ relationship, column_property, foreign, + remote, joinedload, contains_eager, deferred, @@ -39,7 +40,7 @@ from utils_flask_sqla_geo.serializers import geoserializable, shapeserializable from utils_flask_sqla_geo.mixins import GeoFeatureCollectionMixin from pypn_habref_api.models import Habref -from apptax.taxonomie.models import Taxref +from apptax.taxonomie.models import Taxref, TaxrefTree from geonature.core.imports.models import TImports as Import from ref_geo.models import LAreas @@ -401,6 +402,9 @@ class Synthese(DB.Model): count_max = DB.Column(DB.Integer) cd_nom = DB.Column(DB.Integer, ForeignKey(Taxref.cd_nom)) taxref = relationship(Taxref) + taxref_tree = relationship( + TaxrefTree, primaryjoin=foreign(cd_nom) == remote(TaxrefTree.cd_nom), viewonly=True + ) cd_hab = DB.Column(DB.Integer, ForeignKey(Habref.cd_hab)) habitat = relationship(Habref) nom_cite = DB.Column(DB.Unicode(length=1000), nullable=False) @@ -461,7 +465,7 @@ def _has_permissions_grant(self, permissions) -> bool: if not permissions: return False for perm in permissions: - if perm.has_other_filters_than("SCOPE", "SENSITIVITY", "GEOGRAPHIC"): + if perm.has_other_filters_than("SCOPE", "SENSITIVITY", "GEOGRAPHIC", "TAXONOMIC"): continue # unsupported filters if perm.sensitivity_filter: if current_app.config["SYNTHESE"]["BLUR_SENSITIVE_OBSERVATIONS"]: @@ -484,6 +488,11 @@ def _has_permissions_grant(self, permissions) -> bool: if set(perm.areas_filter).isdisjoint(self.areas): # the permission does not allows any area overlapping the observation areas continue + if perm.taxons_filter: + if set(map(int, self.taxref_tree.path.split("."))).isdisjoint( + [t.cd_ref for t in perm.taxons_filter] + ): + continue return True # no filter exclude this permission return False @@ -661,6 +670,10 @@ class VSyntheseForWebApp(DB.Model): secondaryjoin=corAreaSynthese.c.id_area == LAreas.id_area, overlaps="areas,synthese_obs", ) + taxref = relationship(Taxref, primaryjoin=foreign(cd_nom) == Taxref.cd_nom) + taxref_tree = relationship( + TaxrefTree, primaryjoin=foreign(cd_nom) == remote(TaxrefTree.cd_nom), viewonly=True + ) medias = relationship( TMedias, primaryjoin=(TMedias.uuid_attached_row == foreign(unique_id_sinp)), uselist=True ) diff --git a/backend/geonature/core/gn_synthese/utils/query_select_sqla.py b/backend/geonature/core/gn_synthese/utils/query_select_sqla.py index 9e89b6cd27..8d75e4ae41 100644 --- a/backend/geonature/core/gn_synthese/utils/query_select_sqla.py +++ b/backend/geonature/core/gn_synthese/utils/query_select_sqla.py @@ -37,6 +37,7 @@ from geonature.utils.errors import GeonatureApiError from apptax.taxonomie.models import ( Taxref, + TaxrefTree, CorTaxonAttribut, TaxrefBdcStatutTaxon, bdc_statut_cor_text_area, @@ -147,7 +148,7 @@ def build_permissions_filter(self, user, permissions): permissions_filters = [] excluded_sensitivity = None for perm in permissions: - if perm.has_other_filters_than("SCOPE", "SENSITIVITY", "GEOGRAPHIC"): + if perm.has_other_filters_than("SCOPE", "SENSITIVITY", "GEOGRAPHIC", "TAXONOMIC"): continue perm_filters = [] if perm.sensitivity_filter: @@ -193,6 +194,16 @@ def build_permissions_filter(self, user, permissions): LAreas.id_area.in_([a.id_area for a in perm.areas_filter]) ) perm_filters.append(where_clause) + if perm.taxons_filter: + # Does obs taxon path is an descendant of any path of taxons_filter? + where_clause = self.model.taxref_tree.has( + TaxrefTree.path.op("<@")( + sa.select(sa.func.array_agg(TaxrefTree.path)).where( + TaxrefTree.cd_nom.in_([t.cd_nom for t in perm.taxons_filter]) + ) + ) + ) + perm_filters.append(where_clause) if perm_filters: permissions_filters.append(and_(*perm_filters)) else: diff --git a/backend/geonature/migrations/versions/707390c722fe_permissions_add_extended_filters.py b/backend/geonature/migrations/versions/707390c722fe_permissions_add_extended_filters.py index 7c92027044..ca38590543 100644 --- a/backend/geonature/migrations/versions/707390c722fe_permissions_add_extended_filters.py +++ b/backend/geonature/migrations/versions/707390c722fe_permissions_add_extended_filters.py @@ -88,6 +88,26 @@ def upgrade(): m.module_code = 'SYNTHESE' AND o.code_object = 'ALL' and a.code_action IN ('R','E') """ ) + op.execute( + """ + UPDATE + gn_permissions.t_permissions_available pa + SET + taxons_filter = True + FROM + gn_commons.t_modules m, + gn_permissions.t_objects o, + gn_permissions.bib_actions a + WHERE + pa.id_module = m.id_module + AND + pa.id_object = o.id_object + AND + pa.id_action = a.id_action + AND + m.module_code = 'SYNTHESE' AND o.code_object = 'ALL' and a.code_action IN ('R','E') + """ + ) def downgrade(): diff --git a/backend/geonature/tests/test_synthese.py b/backend/geonature/tests/test_synthese.py index bbdd17a093..8fd1011557 100644 --- a/backend/geonature/tests/test_synthese.py +++ b/backend/geonature/tests/test_synthese.py @@ -1888,3 +1888,46 @@ def test_geographic_filter_list_obs(self, synthese_data, synthese_read_permissio assert synthese_data["obs1"].id_synthese in response_ids assert synthese_data["obs2"].id_synthese not in response_ids assert synthese_data["obs3"].id_synthese in response_ids + + +@pytest.mark.usefixtures("client_class", "temporary_transaction") +class TestSyntheseTaxonomicFilter: + def test_taxonomic_filter_get_obs(self, synthese_data, synthese_read_permissions): + with db.session.begin_nested(): + user = User() + db.session.add(user) + taxon1 = synthese_data["obs1"].taxref + taxon2 = synthese_data["obs2"].taxref.parent + synthese_read_permissions(user, scope_value=None, taxons_filter=[taxon1, taxon2]) + set_logged_user(self.client, user) + response = self.client.get( + url_for("gn_synthese.get_one_synthese", id_synthese=synthese_data["obs1"].id_synthese) + ) + assert response.status_code == 200, response.data + response = self.client.get( + url_for("gn_synthese.get_one_synthese", id_synthese=synthese_data["obs2"].id_synthese) + ) + assert response.status_code == 200, response.data + response = self.client.get( + url_for("gn_synthese.get_one_synthese", id_synthese=synthese_data["obs3"].id_synthese) + ) + assert response.status_code == Forbidden.code, response.data + + def test_taxonomic_filter_list_obs(self, synthese_data, synthese_read_permissions): + with db.session.begin_nested(): + user = User() + db.session.add(user) + taxon1 = synthese_data["obs1"].taxref + taxon2 = synthese_data["obs2"].taxref.parent + synthese_read_permissions(user, scope_value=None, taxons_filter=[taxon1, taxon2]) + set_logged_user(self.client, user) + response = self.client.get( + url_for( + "gn_synthese.get_observations_for_web", + ) + ) + assert response.status_code == 200, response.data + response_ids = [f["properties"]["id_synthese"] for f in response.json["features"]] + assert synthese_data["obs1"].id_synthese in response_ids + assert synthese_data["obs2"].id_synthese in response_ids + assert synthese_data["obs3"].id_synthese not in response_ids From 5c36a03910ea011c051532b7f11032167981e3ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89lie=20Bouttier?= Date: Thu, 3 Oct 2024 19:29:08 +0200 Subject: [PATCH 12/14] feat(permissions): add PermissionSchema --- .../geonature/core/gn_permissions/models.py | 7 +- .../geonature/core/gn_permissions/schemas.py | 82 ++++++++++++++++++- 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/backend/geonature/core/gn_permissions/models.py b/backend/geonature/core/gn_permissions/models.py index 6098a1d4a3..96b95b6ab8 100644 --- a/backend/geonature/core/gn_permissions/models.py +++ b/backend/geonature/core/gn_permissions/models.py @@ -239,13 +239,14 @@ class Permission(db.Model): ) id_permission = db.Column(db.Integer, primary_key=True) - id_role = db.Column(db.Integer, ForeignKey("utilisateurs.t_roles.id_role")) - id_action = db.Column(db.Integer, ForeignKey(PermAction.id_action)) - id_module = db.Column(db.Integer, ForeignKey("gn_commons.t_modules.id_module")) + id_role = db.Column(db.Integer, ForeignKey("utilisateurs.t_roles.id_role"), nullable=False) + id_action = db.Column(db.Integer, ForeignKey(PermAction.id_action), nullable=False) + id_module = db.Column(db.Integer, ForeignKey("gn_commons.t_modules.id_module"), nullable=False) id_object = db.Column( db.Integer, ForeignKey(PermObject.id_object), default=select(PermObject.id_object).where(PermObject.code_object == "ALL"), + nullable=False, ) created_on = db.Column(sa.DateTime, server_default=sa.func.now()) expire_on = db.Column(db.DateTime) diff --git a/backend/geonature/core/gn_permissions/schemas.py b/backend/geonature/core/gn_permissions/schemas.py index 293054c562..8f369590af 100644 --- a/backend/geonature/core/gn_permissions/schemas.py +++ b/backend/geonature/core/gn_permissions/schemas.py @@ -1,10 +1,88 @@ -from marshmallow import fields, validates_schema, EXCLUDE +import sqlalchemy as sa +from marshmallow import validate, validates +from marshmallow.exceptions import ValidationError +from marshmallow_sqlalchemy.fields import Nested from geonature.utils.env import db, ma -from geonature.core.gn_permissions.models import PermObject +from geonature.core.gn_permissions.models import ( + Permission, + PermAction, + PermObject, + PermissionAvailable, +) + +from pypnusershub.schemas import UserSchema +from ref_geo.schemas import AreaSchema +from apptax.taxonomie.schemas import TaxrefSchema +from utils_flask_sqla.schema import SmartRelationshipsMixin + + +class PermActionSchema(ma.SQLAlchemyAutoSchema): + class Meta: + model = PermAction + include_fk = True class PermObjectSchema(ma.SQLAlchemyAutoSchema): class Meta: model = PermObject include_fk = True + + +class PermissionSchema(SmartRelationshipsMixin, ma.SQLAlchemyAutoSchema): + """ + Marchmallow-sqlalchemy behavior is to search object in database, + and if not found, to create a new one. As this schema is not means to create + any related object, nested fields are dump only (use the FK to set the value). + For m2m fields, as it is not possible to load the FK which is in another table, + we let the user provide m2m models PK, but we have validation hooks which verify + that related models exists and have not been created by marchmallow-sqlalchemy. + """ + + class Meta: + model = Permission + include_fk = True + load_instance = True + sqla_session = db.session + dump_only = ("role", "action", "module", "object") + + role = Nested(UserSchema) + action = Nested(PermActionSchema) + module = Nested("ModuleSchema") + object = Nested(PermObjectSchema) + + scope_value = ma.auto_field(validate=validate.Range(min=0, max=3), strict=True) + areas_filter = Nested(AreaSchema, many=True) + taxons_filter = Nested(TaxrefSchema, many=True) + + @validates("areas_filter") + def validate_areas_filter(self, data, **kwargs): + errors = {} + for i, area in enumerate(data): + if not sa.inspect(area).persistent: + errors[i] = "Area does not exist" + if errors: + raise ValidationError(errors, field_name="areas_filter") + return data + + @validates("taxons_filter") + def validate_taxons_filter(self, data, **kwargs): + errors = {} + for i, taxon in enumerate(data): + if not sa.inspect(taxon).persistent: + errors[i] = "Taxon does not exist" + if errors: + raise ValidationError(errors, field_name="taxons_filter") + return data + + +class PermissionAvailableSchema(SmartRelationshipsMixin, ma.SQLAlchemyAutoSchema): + class Meta: + model = PermissionAvailable + include_fk = True + load_instance = True + sqla_session = db.session + + action = Nested(PermActionSchema) + module = Nested("ModuleSchema") + object = Nested(PermObjectSchema) From ef4808082cd5ee40b6a257d261f42781514958e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89lie=20Bouttier?= Date: Wed, 16 Oct 2024 18:14:55 +0200 Subject: [PATCH 13/14] feat(permissions): route to list available perms --- .../geonature/core/gn_permissions/routes.py | 20 +++++++++++++------ backend/geonature/tests/test_gn_permission.py | 13 ++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/backend/geonature/core/gn_permissions/routes.py b/backend/geonature/core/gn_permissions/routes.py index 5674ae65bc..88b8e15b90 100644 --- a/backend/geonature/core/gn_permissions/routes.py +++ b/backend/geonature/core/gn_permissions/routes.py @@ -2,16 +2,16 @@ Routes of the gn_permissions blueprint """ -import json from copy import copy -from flask import Blueprint, request, Response, render_template, session +from flask import Blueprint, Response, session +import sqlalchemy as sa -from geonature.utils.env import DB +from geonature.utils.env import db from sqlalchemy.orm import joinedload -from utils_flask_sqla.response import json_resp -from geonature.core.gn_commons.models import TModules -from geonature.core.gn_permissions import decorators as permissions +from geonature.core.gn_permissions.models import PermissionAvailable +from geonature.core.gn_permissions.schemas import PermissionAvailableSchema +from geonature.core.gn_permissions.decorators import login_required from geonature.core.gn_permissions.commands import supergrant @@ -37,3 +37,11 @@ def logout(): for key in copy_session_key: session.pop(key) return Response("Logout", 200) + + +@routes.route("/availables", methods=["GET"]) +@login_required +def list_permissions_availables(): + pa = db.session.execute(sa.select(PermissionAvailable)).scalars() + schema = PermissionAvailableSchema(only=["action", "module", "object"]) + return schema.dump(pa, many=True) diff --git a/backend/geonature/tests/test_gn_permission.py b/backend/geonature/tests/test_gn_permission.py index 1b049f998a..4a95b181dc 100644 --- a/backend/geonature/tests/test_gn_permission.py +++ b/backend/geonature/tests/test_gn_permission.py @@ -1,6 +1,9 @@ import pytest from flask import url_for +from pypnusershub.tests.utils import set_logged_user +from werkzeug.exceptions import Unauthorized + @pytest.mark.usefixtures("client_class") class TestGnPermissionsRoutes: @@ -9,3 +12,13 @@ def test_logout(self): assert response.status_code == 200 assert response.data == b"Logout" + + def test_list_permissions_availables(self, users): + url = url_for("gn_permissions.list_permissions_availables") + + response = self.client.get(url) + assert response.status_code == Unauthorized.code + + set_logged_user(self.client, users["user"]) + response = self.client.get(url) + assert response.status_code == 200 From 3914bece9ff6baf6d1e510150b29f45a0ad7c4d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89lie=20Bouttier?= Date: Wed, 20 Nov 2024 16:21:50 +0100 Subject: [PATCH 14/14] feat(permissions): route to get available perm --- .../geonature/core/gn_permissions/routes.py | 22 ++++++++++++++- backend/geonature/tests/test_gn_permission.py | 27 ++++++++++++++++++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/backend/geonature/core/gn_permissions/routes.py b/backend/geonature/core/gn_permissions/routes.py index 88b8e15b90..1c863b9117 100644 --- a/backend/geonature/core/gn_permissions/routes.py +++ b/backend/geonature/core/gn_permissions/routes.py @@ -5,14 +5,17 @@ from copy import copy from flask import Blueprint, Response, session +from geonature.core.gn_commons.models.base import TModules import sqlalchemy as sa from geonature.utils.env import db +from sqlalchemy.exc import NoResultFound from sqlalchemy.orm import joinedload -from geonature.core.gn_permissions.models import PermissionAvailable +from geonature.core.gn_permissions.models import PermAction, PermissionAvailable, TObjects from geonature.core.gn_permissions.schemas import PermissionAvailableSchema from geonature.core.gn_permissions.decorators import login_required from geonature.core.gn_permissions.commands import supergrant +from werkzeug.exceptions import NotFound routes = Blueprint( @@ -45,3 +48,20 @@ def list_permissions_availables(): pa = db.session.execute(sa.select(PermissionAvailable)).scalars() schema = PermissionAvailableSchema(only=["action", "module", "object"]) return schema.dump(pa, many=True) + + +@routes.route("/availables///", methods=["GET"]) +@login_required +def get_permission_available(module_code, code_object, code_action): + try: + pa = db.session.execute( + sa.select(PermissionAvailable).where( + PermissionAvailable.module.has(TModules.module_code == module_code), + PermissionAvailable.object.has(TObjects.code_object == code_object), + PermissionAvailable.action.has(PermAction.code_action == code_action), + ) + ).scalar_one() + except NoResultFound: + raise NotFound + schema = PermissionAvailableSchema(only=["action", "module", "object"]) + return schema.dump(pa) diff --git a/backend/geonature/tests/test_gn_permission.py b/backend/geonature/tests/test_gn_permission.py index 4a95b181dc..37f5742bd8 100644 --- a/backend/geonature/tests/test_gn_permission.py +++ b/backend/geonature/tests/test_gn_permission.py @@ -2,7 +2,7 @@ from flask import url_for from pypnusershub.tests.utils import set_logged_user -from werkzeug.exceptions import Unauthorized +from werkzeug.exceptions import NotFound, Unauthorized @pytest.mark.usefixtures("client_class") @@ -22,3 +22,28 @@ def test_list_permissions_availables(self, users): set_logged_user(self.client, users["user"]) response = self.client.get(url) assert response.status_code == 200 + + def test_get_permission_available(self, users): + url = url_for( + "gn_permissions.get_permission_available", + module_code="METADATA", + code_object="ALL", + code_action="R", + ) + + response = self.client.get(url) + assert response.status_code == Unauthorized.code + + set_logged_user(self.client, users["user"]) + response = self.client.get(url) + assert response.status_code == 200 + + response = self.client.get( + url_for( + "gn_permissions.get_permission_available", + module_code="METADATA", + code_object="ALL", + code_action="UNEXISTING", + ) + ) + assert response.status_code == NotFound.code