diff --git a/.gitignore b/.gitignore index 87ea72f8f0..ffdf62513f 100755 --- a/.gitignore +++ b/.gitignore @@ -154,5 +154,7 @@ install_all/install_all.log /docs/CHANGELOG.html + /contrib/*/frontend/node_modules -Makefile.local \ No newline at end of file +Makefile.local +*.DS_Store diff --git a/backend/geonature/core/gn_monitoring/models.py b/backend/geonature/core/gn_monitoring/models.py index 6ba3a45ca7..a5f0573fc8 100644 --- a/backend/geonature/core/gn_monitoring/models.py +++ b/backend/geonature/core/gn_monitoring/models.py @@ -4,20 +4,25 @@ relatifs aux protocoles de suivis """ +from flask import g +from datetime import datetime + from geoalchemy2 import Geometry -from sqlalchemy import ForeignKey +from sqlalchemy import ForeignKey, or_, false from sqlalchemy.orm import relationship from sqlalchemy.dialects.postgresql import UUID, JSONB from sqlalchemy.sql import select, func +from sqlalchemy.schema import FetchedValue +from sqlalchemy.ext.hybrid import hybrid_property +from pypnnomenclature.models import TNomenclatures from pypnusershub.db.models import User from ref_geo.models import LAreas from utils_flask_sqla.serializers import serializable from utils_flask_sqla_geo.serializers import geoserializable -from pypnnomenclature.models import TNomenclatures -from geonature.core.gn_commons.models import TModules +from geonature.core.gn_commons.models import TModules, TMedias from geonature.core.gn_meta.models import TDatasets from geonature.utils.env import DB @@ -213,6 +218,24 @@ class TBaseSites(DB.Model): ) +corIndividualModule = DB.Table( + "cor_individual_module", + DB.Column( + "id_individual", + DB.Integer, + DB.ForeignKey("gn_monitoring.t_individuals.id_individual", ondelete="CASCADE"), + primary_key=True, + ), + DB.Column( + "id_module", + DB.Integer, + DB.ForeignKey("gn_commons.t_modules.id_module", ondelete="CASCADE"), + primary_key=True, + ), + schema="gn_monitoring", +) + + @serializable class TObservations(DB.Model): __tablename__ = "t_observations" @@ -223,6 +246,178 @@ class TObservations(DB.Model): digitiser = DB.relationship( User, primaryjoin=(User.id_role == id_digitiser), foreign_keys=[id_digitiser] ) - cd_nom = DB.Column(DB.Integer) + cd_nom = DB.Column(DB.Integer, DB.ForeignKey("taxonomie.taxref.cd_nom"), nullable=False) comments = DB.Column(DB.String) uuid_observation = DB.Column(UUID(as_uuid=True), default=select(func.uuid_generate_v4())) + + id_individual = DB.Column(DB.ForeignKey("gn_monitoring.t_individuals.id_individual")) + + +@serializable +class TMarkingEvent(DB.Model): + __tablename__ = "t_marking_events" + __table_args__ = {"schema": "gn_monitoring"} + + id_marking = DB.Column(DB.Integer, primary_key=True, autoincrement=True) + uuid_marking = DB.Column(UUID(as_uuid=True), default=select(func.uuid_generate_v4())) + id_individual = DB.Column( + DB.ForeignKey(f"gn_monitoring.t_individuals.id_individual", ondelete="CASCADE"), + nullable=False, + ) + id_module = DB.Column( + DB.ForeignKey("gn_commons.t_modules.id_module"), + primary_key=True, + nullable=False, + unique=True, + ) + id_digitiser = DB.Column( + DB.ForeignKey("utilisateurs.t_roles.id_role"), + nullable=False, + ) + marking_date = DB.Column(DB.DateTime(timezone=False), nullable=False) + id_operator = DB.Column(DB.ForeignKey("utilisateurs.t_roles.id_role"), nullable=False) + id_base_marking_site = DB.Column(DB.ForeignKey("gn_monitoring.t_base_sites.id_base_site")) + id_nomenclature_marking_type = DB.Column( + DB.ForeignKey("ref_nomenclatures.t_nomenclatures.id_nomenclature"), nullable=False + ) + marking_location = DB.Column(DB.Unicode(255)) + marking_code = DB.Column(DB.Unicode(255)) + marking_details = DB.Column(DB.Text) + data = DB.Column(JSONB) + + operator = DB.relationship(User, lazy="joined", foreign_keys=[id_operator]) + + digitiser = DB.relationship(User, lazy="joined", foreign_keys=[id_digitiser]) + + medias = DB.relationship( + TMedias, + lazy="joined", + primaryjoin=(TMedias.uuid_attached_row == uuid_marking), + foreign_keys=[TMedias.uuid_attached_row], + overlaps="medias,medias", + ) + + @hybrid_property + def organism_actors(self): + # return self.digitiser.id_organisme + actors_organism_list = [] + if isinstance(self.digitiser, User): + actors_organism_list.append(self.digitiser.id_organisme) + if isinstance(self.operator, User): + actors_organism_list.append(self.operator.id_organisme) + return actors_organism_list + + def has_instance_permission(self, scope): + if scope == 0: + return False + elif scope in (1, 2): + if ( + g.current_user.id_role == self.id_digitiser + or g.current_user.id_role == self.id_operator + ): + return True + if scope == 2 and g.current_user.id_organisme in self.organism_actors: + return True + elif scope == 3: + return True + return False + + +@serializable +class TIndividuals(DB.Model): + __tablename__ = "t_individuals" + __table_args__ = {"schema": "gn_monitoring"} + id_individual = DB.Column(DB.Integer, primary_key=True) + uuid_individual = DB.Column(UUID, nullable=False, server_default=DB.text("uuid_generate_v4()")) + individual_name = DB.Column(DB.Unicode(255), nullable=False) + cd_nom = DB.Column(DB.Integer, DB.ForeignKey("taxonomie.taxref.cd_nom"), nullable=False) + id_nomenclature_sex = DB.Column( + DB.ForeignKey("ref_nomenclatures.t_nomenclatures.id_nomenclature"), + server_default=DB.text( + "ref_nomenclatures.get_default_nomenclature_value('SEXE'::character varying)" + ), + ) + active = DB.Column(DB.Boolean, default=True) + comment = DB.Column(DB.Text) + id_digitiser = DB.Column( + DB.ForeignKey("utilisateurs.t_roles.id_role"), + nullable=False, + ) + + meta_create_date = DB.Column( + "meta_create_date", DB.DateTime(timezone=False), server_default=FetchedValue() + ) + meta_update_date = DB.Column( + "meta_update_date", + DB.DateTime(timezone=False), + server_default=FetchedValue(), + onupdate=datetime.now, + ) + + digitiser = DB.relationship( + User, + lazy="joined", + ) + + nomenclature_sex = DB.relationship( + TNomenclatures, + lazy="select", + primaryjoin=(TNomenclatures.id_nomenclature == id_nomenclature_sex), + ) + + modules = DB.relationship( + "TModules", + lazy="joined", + secondary=corIndividualModule, + primaryjoin=(corIndividualModule.c.id_individual == id_individual), + secondaryjoin=(corIndividualModule.c.id_module == TModules.id_module), + foreign_keys=[corIndividualModule.c.id_individual, corIndividualModule.c.id_module], + ) + + markings = DB.relationship( + TMarkingEvent, + primaryjoin=(id_individual == TMarkingEvent.id_individual), + ) + + medias = DB.relationship( + TMedias, + lazy="joined", + primaryjoin=(TMedias.uuid_attached_row == uuid_individual), + foreign_keys=[TMedias.uuid_attached_row], + overlaps="medias", + ) + + @classmethod + def filter_by_scope(cls, query, scope, user): + if scope == 0: + query = query.where(false()) + elif scope in (1, 2): + ors = [ + cls.id_digitiser == user.id_role, + ] + # if organism is None => do not filter on id_organism even if level = 2 + if scope == 2 and user.id_organisme is not None: + ors.append(cls.digitiser.has(id_organisme=user.id_organisme)) + query = query.where(or_(*ors)) + return query + + @hybrid_property + def organism_actors(self): + # return self.digitiser.id_organisme + actors_organism_list = [] + if isinstance(self.digitiser, User): + actors_organism_list.append(self.digitiser.id_organisme) + + return actors_organism_list + + def has_instance_permission(self, scope): + if scope == 0: + return False + elif scope in (1, 2): + if g.current_user.id_role == self.id_digitiser: + return True + if scope == 2 and g.current_user.id_organisme in self.organism_actors: + return True + elif scope == 3: + return True + return False diff --git a/backend/geonature/core/gn_monitoring/routes.py b/backend/geonature/core/gn_monitoring/routes.py index 245ed06047..6998cb47ac 100644 --- a/backend/geonature/core/gn_monitoring/routes.py +++ b/backend/geonature/core/gn_monitoring/routes.py @@ -1,10 +1,24 @@ -from flask import Blueprint, request +from flask import Blueprint, request, g +from geonature.core.gn_monitoring.schema import TIndividualsSchema +from geonature.core.gn_permissions.tools import get_scope +from marshmallow import ValidationError, EXCLUDE +from sqlalchemy.sql import func, select from geojson import FeatureCollection -from geonature.core.gn_monitoring.models import TBaseSites, cor_site_area, cor_site_module +from werkzeug.exceptions import BadRequest, Forbidden, NotFound + +from geonature.core.gn_commons.models import TModules +from geonature.core.gn_permissions.decorators import _forbidden_message, login_required from geonature.utils.env import DB from ref_geo.models import LAreas from sqlalchemy import select from sqlalchemy.sql import func +from geonature.core.gn_monitoring.models import ( + TBaseSites, + TIndividuals, + cor_site_area, + cor_site_module, +) + from utils_flask_sqla.response import json_resp from utils_flask_sqla_geo.generic import get_geojson_feature @@ -98,3 +112,68 @@ def get_site_areas(id_site): feature["id"] = d[1] features.append(feature) return FeatureCollection(features) + + +@routes.route("/individuals/", methods=["GET"]) +@login_required +def get_individuals(id_module): + action = "R" + object_code = "MONITORINGS_INDIVIDUALS" + module = DB.session.get(TModules, id_module) + if module is None: + raise NotFound("Module not found") + module_code = module.module_code + current_user = g.current_user + max_scope = get_scope( + action, id_role=current_user.id_role, module_code=module_code, object_code=object_code + ) + + if not max_scope: + raise Forbidden(description=_forbidden_message(action, module_code, object_code)) + + # FIXME: when all sqlalchemy 2.0 PR are merged, update it to fit the good practices + # like @qfilter etc... + query = select(TIndividuals).where(TIndividuals.modules.any(TModules.id_module == id_module)) + results = ( + DB.session.scalars(TIndividuals.filter_by_scope(query, max_scope, current_user)) + .unique() + .all() + ) + + schema = TIndividualsSchema(exclude=["modules", "digitiser", "markings", "nomenclature_sex"]) + # In the future: paginate the query. But need infinite scroll on + # select frontend side + return schema.jsonify(results, many=True) + + +@routes.route("/individual/", methods=["POST"]) +@login_required +def create_one_individual(id_module: int): + # Id module is an optional parameter to associate an individual + # to a module + action = "C" + object_code = "MONITORINGS_INDIVIDUALS" + module = DB.session.get(TModules, id_module) + if module is None: + raise NotFound("Module not found") + module_code = module.module_code + current_user = g.current_user + max_scope = get_scope( + action, id_role=current_user.id_role, module_code=module_code, object_code=object_code + ) + + if not max_scope: + raise Forbidden(description=_forbidden_message(action, module_code, object_code)) + + # Exclude id_digitiser since it is set by the current user + individual_schema = TIndividualsSchema(exclude=["id_digitiser"], unknown=EXCLUDE) + individual_instance = TIndividuals(id_digitiser=g.current_user.id_role) + try: + individual = individual_schema.load(data=request.get_json(), instance=individual_instance) + except ValidationError as error: + raise BadRequest(error.messages) + + individual.modules = [module] + DB.session.add(individual) + DB.session.commit() + return individual_schema.jsonify(individual) diff --git a/backend/geonature/core/gn_monitoring/schema.py b/backend/geonature/core/gn_monitoring/schema.py new file mode 100644 index 0000000000..787897e840 --- /dev/null +++ b/backend/geonature/core/gn_monitoring/schema.py @@ -0,0 +1,30 @@ +from marshmallow import fields + +from geonature.core.gn_commons.schemas import ModuleSchema, MediaSchema +from geonature.utils.env import MA +from geonature.core.gn_monitoring.models import TIndividuals, TMarkingEvent +from pypnnomenclature.schemas import NomenclatureSchema +from pypnusershub.schemas import UserSchema + + +class TMarkingEventSchema(MA.SQLAlchemyAutoSchema): + class Meta: + model = TMarkingEvent + include_fk = True + load_instance = True + + operator = MA.Nested(UserSchema, dump_only=True) + medias = MA.Nested(MediaSchema, many=True) + + +class TIndividualsSchema(MA.SQLAlchemyAutoSchema): + class Meta: + model = TIndividuals + include_fk = True + load_instance = True + + nomenclature_sex = MA.Nested(NomenclatureSchema, dump_only=True) + digitiser = MA.Nested(UserSchema, dump_only=True) + modules = fields.List(MA.Nested(ModuleSchema, dump_only=True)) + markings = fields.List(MA.Nested(TMarkingEventSchema, dump_only=True)) + medias = MA.Nested(MediaSchema, many=True) diff --git a/backend/geonature/migrations/versions/2894b3c03c66_monitoring_add_id_individual_col_t_observations.py b/backend/geonature/migrations/versions/2894b3c03c66_monitoring_add_id_individual_col_t_observations.py new file mode 100644 index 0000000000..088615fc7b --- /dev/null +++ b/backend/geonature/migrations/versions/2894b3c03c66_monitoring_add_id_individual_col_t_observations.py @@ -0,0 +1,52 @@ +"""[monitoring] add id_individual col t_observations + +Revision ID: 2894b3c03c66 +Revises: 84f40d008640 +Create Date: 2023-11-21 11:06:04.284038 + +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.sql import column + + +# revision identifiers, used by Alembic. +revision = "2894b3c03c66" +down_revision = "84f40d008640" +branch_labels = None +depends_on = None + +monitorings_schema = "gn_monitoring" +table = "t_observations" +column_name = "id_individual" +foreign_schema = monitorings_schema +foreign_table = "t_individuals" +foreign_key = column_name + + +def upgrade(): + op.add_column( + table, + sa.Column( + column_name, + sa.Integer(), + sa.ForeignKey( + f"{foreign_schema}.{foreign_table}.{foreign_key}", + name=f"fk_{table}_{column_name}", + onupdate="CASCADE", + ), + ), + schema=monitorings_schema, + ) + + +def downgrade(): + op.execute( + """ + UPDATE gn_monitoring.t_observations SET cd_nom = ind.cd_nom + FROM gn_monitoring.t_individuals ind + WHERE ind.id_individual = gn_monitoring.t_observations.id_individual; + """ + ) + op.drop_column(table_name=table, column_name=column_name, schema=monitorings_schema) diff --git a/backend/geonature/migrations/versions/5b61bcaa18da_monitoring_add_cd_nom_foreign_key.py b/backend/geonature/migrations/versions/5b61bcaa18da_monitoring_add_cd_nom_foreign_key.py new file mode 100644 index 0000000000..35164b8d34 --- /dev/null +++ b/backend/geonature/migrations/versions/5b61bcaa18da_monitoring_add_cd_nom_foreign_key.py @@ -0,0 +1,40 @@ +"""[monitoring] Add cd_nom foreign key + +Revision ID: 5b61bcaa18da +Revises: 2894b3c03c66 +Create Date: 2025-01-07 14:28:20.475116 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "5b61bcaa18da" +down_revision = "2894b3c03c66" +branch_labels = None +depends_on = None + + +def upgrade(): + # Création clé étrangère sur la table t_observations + op.create_foreign_key( + "fk_t_observations_cd_nom_fkey", + source_schema="gn_monitoring", + source_table="t_observations", + local_cols=["cd_nom"], + referent_schema="taxonomie", + referent_table="taxref", + remote_cols=["cd_nom"], + onupdate=None, + ondelete=None, + ) + + +def downgrade(): + op.drop_constraint( + "fk_t_observations_cd_nom_fkey", + table_name="t_observations", + schema="gn_monitoring", + ) diff --git a/backend/geonature/migrations/versions/84f40d008640_add_individuals.py b/backend/geonature/migrations/versions/84f40d008640_add_individuals.py new file mode 100644 index 0000000000..7a3627bbe0 --- /dev/null +++ b/backend/geonature/migrations/versions/84f40d008640_add_individuals.py @@ -0,0 +1,191 @@ +"""[monitoring] add individuals + +Revision ID: 84f40d008640 +Revises: 6734d8f7eb2a +Create Date: 2023-10-04 09:39:48.879128 + +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID, JSONB + + +# revision identifiers, used by Alembic. +revision = "84f40d008640" +down_revision = "6734d8f7eb2a" +branch_labels = None +depends_on = None + +SCHEMA = "gn_monitoring" + + +def upgrade(): + op.create_table( + "t_individuals", + sa.Column("id_individual", sa.Integer, primary_key=True), + sa.Column( + "uuid_individual", UUID, nullable=False, server_default=sa.text("uuid_generate_v4()") + ), + sa.Column("individual_name", sa.Unicode(255), nullable=False), + sa.Column("cd_nom", sa.Integer, sa.ForeignKey("taxonomie.taxref.cd_nom"), nullable=False), + sa.Column( + "id_nomenclature_sex", + sa.Integer, + sa.ForeignKey("ref_nomenclatures.t_nomenclatures.id_nomenclature"), + server_default=sa.text( + "ref_nomenclatures.get_default_nomenclature_value('SEXE'::character varying)" + ), + ), + sa.Column("active", sa.Boolean, server_default=sa.sql.true()), + sa.Column("comment", sa.Text), + sa.Column( + "id_digitiser", + sa.Integer, + sa.ForeignKey("utilisateurs.t_roles.id_role"), + nullable=False, + ), + sa.Column("meta_create_date", sa.DateTime(timezone=False), server_default=sa.func.now()), + sa.Column("meta_update_date", sa.DateTime(timezone=False), server_default=sa.func.now()), + schema=SCHEMA, + ) + + # Create new nomenclature type to be used as contraint in marking event + op.execute( + """ + INSERT INTO ref_nomenclatures.bib_nomenclatures_types ( + mnemonique, label_default, label_fr, + "source", statut + ) + VALUES + ( + 'TYP_MARQUAGE', 'Type de marquage d''individu', + 'Type de marquage d''individu', + 'GEONATURE', 'Non validé' + ); + """ + ) + + op.create_table( + "t_marking_events", + sa.Column("id_marking", sa.Integer, primary_key=True), + sa.Column( + "uuid_marking", UUID, nullable=False, server_default=sa.text("uuid_generate_v4()") + ), + sa.Column( + "id_module", + sa.Integer, + sa.ForeignKey("gn_commons.t_modules.id_module", ondelete="CASCADE"), + ), + sa.Column( + "id_individual", + sa.Integer, + sa.ForeignKey(f"{SCHEMA}.t_individuals.id_individual", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("marking_date", sa.Date, nullable=False), + sa.Column( + "id_operator", + sa.Integer, + sa.ForeignKey("utilisateurs.t_roles.id_role"), + nullable=False, + ), + sa.Column( + "id_base_marking_site", + sa.Integer, + sa.ForeignKey("gn_monitoring.t_base_sites.id_base_site"), + ), + sa.Column( + "id_nomenclature_marking_type", + sa.Integer, + sa.ForeignKey("ref_nomenclatures.t_nomenclatures.id_nomenclature"), + nullable=False, + ), + sa.Column("marking_location", sa.Unicode(255)), + sa.Column("marking_code", sa.Unicode(255)), + sa.Column("marking_details", sa.Text), + sa.Column("data", JSONB), + sa.Column( + "id_digitiser", + sa.Integer, + sa.ForeignKey("utilisateurs.t_roles.id_role"), + nullable=False, + ), + sa.Column("meta_create_date", sa.DateTime(timezone=False), server_default=sa.func.now()), + sa.Column("meta_update_date", sa.DateTime(timezone=False), server_default=sa.func.now()), + schema=SCHEMA, + ) + + op.create_table( + "cor_individual_module", + sa.Column( + "id_individual", + sa.Integer, + sa.ForeignKey(f"{SCHEMA}.t_individuals.id_individual", ondelete="CASCADE"), + primary_key=True, + ), + sa.Column( + "id_module", + sa.Integer, + sa.ForeignKey("gn_commons.t_modules.id_module", ondelete="CASCADE"), + primary_key=True, + ), + schema=SCHEMA, + ) + + op.execute( + """ + ALTER TABLE gn_monitoring.t_marking_events + ADD CONSTRAINT check_marking_type + CHECK (ref_nomenclatures.check_nomenclature_type_by_mnemonique( + id_nomenclature_marking_type, 'TYP_MARQUAGE'::character varying) + ) NOT VALID; + """ + ) + + op.execute( + """ + INSERT INTO gn_commons.bib_tables_location ( + table_desc, schema_name, table_name, + pk_field, uuid_field_name + ) + VALUES + ('Table centralisant les individus faisant l''objet de protocole de suivis', + 'gn_monitoring','t_individuals','id_individual','uuid_individual'), + ('Table centralisant les marquages réalisés sur les individus dans le cadre + de protocoles de suivis', + 'gn_monitoring','t_marking_events','id_marking_event','uuid_marking'); + """ + ) + + +def downgrade(): + op.drop_table("cor_individual_module", schema=SCHEMA) + op.execute( + """ + DELETE FROM gn_commons.t_medias m + WHERE id_table_location IN ( + SELECT id_table_location FROM gn_commons.bib_tables_location + WHERE table_name IN ('t_individuals', 't_marking_events') + ); + """ + ) + op.execute( + """ + DELETE FROM gn_commons.bib_tables_location + WHERE table_name IN ('t_individuals', 't_marking_events') + AND schema_name='gn_monitoring'; + """ + ) + op.drop_table("t_marking_events", schema=SCHEMA) + op.execute( + """ + DELETE FROM ref_nomenclatures.t_nomenclatures t + USING ref_nomenclatures.bib_nomenclatures_types bnt + WHERE t.id_type = bnt.id_type AND bnt.mnemonique = 'TYP_MARQUAGE'; + """ + ) + op.execute( + "DELETE FROM ref_nomenclatures.bib_nomenclatures_types WHERE mnemonique='TYP_MARQUAGE'" + ) + op.drop_table("t_individuals", schema=SCHEMA) diff --git a/backend/geonature/migrations/versions/bc28b69025b3_monitoring_add_trigger_get_individual_.py b/backend/geonature/migrations/versions/bc28b69025b3_monitoring_add_trigger_get_individual_.py new file mode 100644 index 0000000000..809d67b8e1 --- /dev/null +++ b/backend/geonature/migrations/versions/bc28b69025b3_monitoring_add_trigger_get_individual_.py @@ -0,0 +1,96 @@ +"""[monitoring] Add trigger get individual cd_nom + +Revision ID: bc28b69025b3 +Revises: 5b61bcaa18da +Create Date: 2025-01-07 14:50:55.877316 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "bc28b69025b3" +down_revision = "5b61bcaa18da" +branch_labels = None +depends_on = None + + +def upgrade(): + # Création trigger mise à jour du cd_nom de la table des observations + # lors d'une modification d'un cd_nom d'un individu + op.execute( + """ + CREATE OR REPLACE FUNCTION gn_monitoring.fct_trg_t_individuals_t_observations_cd_nom() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + BEGIN + + -- Mise à jour du cd_nom de la table observation + IF + NEW.id_individual = OLD.id_individual + THEN + UPDATE gn_monitoring.t_observations SET cd_nom = NEW.cd_nom WHERE id_individual = NEW.id_individual; + END IF; + + RETURN NEW; + END; + $function$ + ; + + CREATE TRIGGER trg_update_t_observations_cd_nom + AFTER UPDATE + ON gn_monitoring.t_individuals + FOR EACH ROW + EXECUTE PROCEDURE gn_monitoring.fct_trg_t_individuals_t_observations_cd_nom(); + """ + ) + + # Création d'un trigger qui peuple le champ cd_nom de la table t_observation à partir + # des données de l'individus selectionné + op.execute( + """ + + CREATE OR REPLACE FUNCTION gn_monitoring.fct_trg_t_observations_cd_nom() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + BEGIN + + -- Récupération du cd_nom depuis la table des individus + IF + NOT NEW.id_individual IS NULL + THEN + NEW.cd_nom := (SELECT cd_nom FROM gn_monitoring.t_individuals ti WHERE id_individual = NEW.id_individual); + END IF; + + RETURN NEW; + END; + $function$ + ; + + + CREATE TRIGGER trg_update_cd_nom + BEFORE INSERT OR UPDATE + ON gn_monitoring.t_observations + FOR EACH ROW + EXECUTE PROCEDURE gn_monitoring.fct_trg_t_observations_cd_nom(); +""" + ) + + +def downgrade(): + op.execute( + """ + DROP TRIGGER trg_update_t_observations_cd_nom ON gn_monitoring.t_individuals; + DROP FUNCTION gn_monitoring.fct_trg_t_individuals_t_observations_cd_nom(); + """ + ) + op.execute( + """ + DROP TRIGGER trg_update_cd_nom ON gn_monitoring.t_observations; + DROP FUNCTION gn_monitoring.fct_trg_t_observations_cd_nom(); + """ + ) diff --git a/backend/geonature/tests/test_monitoring.py b/backend/geonature/tests/test_monitoring.py new file mode 100644 index 0000000000..646924fe10 --- /dev/null +++ b/backend/geonature/tests/test_monitoring.py @@ -0,0 +1,278 @@ +from flask import url_for +import pytest +from werkzeug.exceptions import Forbidden +from sqlalchemy import select +from apptax.taxonomie.models import Taxref +from pypnnomenclature.models import BibNomenclaturesTypes, TNomenclatures +from geonature.core.gn_monitoring.models import TIndividuals, TMarkingEvent +from geonature.utils.env import db +from geonature.core.gn_permissions.models import PermAction, PermObject, Permission +from pypnusershub.tests.utils import logged_user_headers, set_logged_user_cookie + + +from .fixtures import * + +CD_NOM = 212 + + +@pytest.fixture +def individuals(users, module): + taxon = Taxref.query.filter_by(cd_nom=CD_NOM).one() + user = users["self_user"] + individuals = [] + for name in ["Test1", "Test2"]: + individuals.append(TIndividuals(individual_name=name, cd_nom=taxon.cd_nom, digitiser=user)) + + with db.session.begin_nested(): + db.session.add_all(individuals) + + # Add individual to module X + with db.session.begin_nested(): + individuals[0].modules = [module] + + return individuals + + +@pytest.fixture +def nomenclature_type_markings(): + typ_marquage = db.session.scalar( + select(BibNomenclaturesTypes).where( + BibNomenclaturesTypes.mnemonique == "TYP_MARQUAGE", + ) + ) + nomenclature = TNomenclatures( + id_type=typ_marquage.id_type, + cd_nomenclature="MARQUAGE PEINTURE", + label_default="MARQUAGE PEINTURE", + label_fr="MARQUAGE PEINTURE", + active=True, + ) + with db.session.begin_nested(): + db.session.add(nomenclature) + + return nomenclature + + +@pytest.fixture +def markings(users, module, individuals, nomenclature_type_markings): + user = users["self_user"] + markings = [] + for individual in individuals: + markings.append( + TMarkingEvent( + id_individual=individual.id_individual, + id_module=module.id_module, + digitiser=user, + operator=user, + marking_date="2025-01-01", + marking_location="Là bas", + marking_code="0007", + marking_details="Super super", + id_nomenclature_marking_type=nomenclature_type_markings.id_nomenclature, + ) + ) + + with db.session.begin_nested(): + db.session.add_all(markings) + + return markings + + +@pytest.fixture +def monitoring_individual_perm_object(): + individuals_object = "MONITORINGS_INDIVIDUALS" + perm_object = db.session.scalar( + select(PermObject).where(PermObject.code_object == individuals_object) + ) + if perm_object is None: + perm_object = PermObject(code_object=individuals_object) + with db.session.begin_nested(): + db.session.add(perm_object) + return perm_object + + +def set_permissions(module, role, scope_value, action="R", **kwargs): + action = PermAction.query.filter_by(code_action=action).one() + perm = Permission( + role=role, + action=action, + module=module, + scope_value=scope_value, + **kwargs, + ) + with db.session.begin_nested(): + db.session.add(perm) + return perm + + +@pytest.mark.usefixtures("client_class", "temporary_transaction") +class TestMonitoring: + def test_get_individuals_forbidden(self, users, module): + set_logged_user_cookie(self.client, users["self_user"]) + + response = self.client.get( + url_for("gn_monitoring.get_individuals", id_module=module.id_module) + ) + assert response.status_code == Forbidden.code + + def test_get_individuals(self, users, individuals, module, monitoring_individual_perm_object): + set_logged_user_cookie(self.client, users["self_user"]) + set_permissions( + module=module, + role=users["self_user"], + scope_value=1, + action="R", + object=monitoring_individual_perm_object, + ) + + response = self.client.get( + url_for("gn_monitoring.get_individuals", id_module=module.id_module) + ) + resp_json = response.json + not_expected_individual_uuid = {individuals[1].uuid_individual} + expected_individual_uuid = {individuals[0].uuid_individual} + actual_individual_uuid = {individual["uuid_individual"] for individual in resp_json} + + assert actual_individual_uuid.isdisjoint(not_expected_individual_uuid) + assert actual_individual_uuid.issubset(expected_individual_uuid) + + def test_get_individuals_no_rights( + self, users, individuals, module, monitoring_individual_perm_object + ): + user = users["noright_user"] + set_logged_user_cookie(self.client, user) + + response = self.client.get( + url_for("gn_monitoring.get_individuals", id_module=module.id_module) + ) + + assert response.status_code == Forbidden.code + expected_msg = f"User {user.id_role} has no permissions to R in {module.module_code} on {monitoring_individual_perm_object.code_object}" + assert response.json["description"] == expected_msg + + def test_get_individuals_rights_organism( + self, users, individuals, module, monitoring_individual_perm_object + ): + set_logged_user_cookie(self.client, users["self_user"]) + set_permissions( + module=module, + role=users["self_user"], + scope_value=2, + action="R", + object=monitoring_individual_perm_object, + ) + + response = self.client.get( + url_for("gn_monitoring.get_individuals", id_module=module.id_module) + ) + resp_json = response.json + not_expected_individual_uuid = {individuals[1].uuid_individual} + expected_individual_uuid = {individuals[0].uuid_individual} + actual_individual_uuid = {individual["uuid_individual"] for individual in resp_json} + + assert actual_individual_uuid.isdisjoint(not_expected_individual_uuid) + assert actual_individual_uuid.issubset(expected_individual_uuid) + + def test_create_individuals_forbidden(self, users, module): + set_logged_user_cookie(self.client, users["self_user"]) + + response = self.client.post( + url_for("gn_monitoring.create_one_individual", id_module=module.id_module), json={} + ) + assert response.status_code == Forbidden.code + + def test_create_one_individual(self, users, module, monitoring_individual_perm_object): + set_logged_user_cookie(self.client, users["self_user"]) + set_permissions( + module=module, + role=users["self_user"], + scope_value=1, + action="C", + object=monitoring_individual_perm_object, + ) + + individual_name = "Test_Post" + individual = {"individual_name": individual_name, "cd_nom": CD_NOM} + + response = self.client.post( + url_for("gn_monitoring.create_one_individual", id_module=module.id_module), + json=individual, + ) + + json_resp = response.json + assert json_resp["cd_nom"] == CD_NOM + assert json_resp["individual_name"] == individual_name + + def test_model_individual_has_instance_permission( + self, app, users, individuals, module, monitoring_individual_perm_object + ): + set_logged_user_cookie(self.client, users["self_user"]) + set_permissions( + module=module, + role=users["self_user"], + scope_value=1, + action="R", + object=monitoring_individual_perm_object, + ) + + individual = individuals[0] + # Scope 0 => toujours Faux + assert individual.has_instance_permission(0) == False + # Scope 1 => toujours vrai + assert individual.has_instance_permission(3) == True + + # Test avec l'utilisateur numérisateur : toujours vrai + with app.test_request_context(headers=logged_user_headers(users["self_user"])): + app.preprocess_request() + assert individual.has_instance_permission(1) == True + assert individual.has_instance_permission(2) == True + + # Test avec un utilisateur de la même structure que le numérisateur + # scope 1 => Faux; scope 2 : vrai + with app.test_request_context(headers=logged_user_headers(users["associate_user"])): + app.preprocess_request() + assert individual.has_instance_permission(1) == False + assert individual.has_instance_permission(2) == True + + # Test avec un utilisateur d'une autre structure que le numérisateur : toujours faux + with app.test_request_context(headers=logged_user_headers(users["stranger_user"])): + app.preprocess_request() + assert individual.has_instance_permission(1) == False + assert individual.has_instance_permission(2) == False + + def test_model_marking_has_instance_permission( + self, app, users, markings, module, monitoring_individual_perm_object + ): + set_logged_user_cookie(self.client, users["self_user"]) + set_permissions( + module=module, + role=users["self_user"], + scope_value=1, + action="R", + object=monitoring_individual_perm_object, + ) + + marking = markings[0] + # Scope 0 => toujours Faux + assert marking.has_instance_permission(0) == False + # Scope 1 => toujours vrai + assert marking.has_instance_permission(3) == True + + # Test avec l'utilisateur numérisateur : toujours vrai + with app.test_request_context(headers=logged_user_headers(users["self_user"])): + app.preprocess_request() + assert marking.has_instance_permission(1) == True + assert marking.has_instance_permission(2) == True + + # Test avec un utilisateur de la même structure que le numérisateur + # scope 1 => Faux; scope 2 : vrai + with app.test_request_context(headers=logged_user_headers(users["associate_user"])): + app.preprocess_request() + assert marking.has_instance_permission(1) == False + assert marking.has_instance_permission(2) == True + + # Test avec un utilisateur d'une autre structure que le numérisateur : toujours faux + with app.test_request_context(headers=logged_user_headers(users["stranger_user"])): + app.preprocess_request() + assert marking.has_instance_permission(1) == False + assert marking.has_instance_permission(2) == False diff --git a/contrib/occtax/backend/occtax/models.py b/contrib/occtax/backend/occtax/models.py index e6988c42c5..52f00d1d45 100644 --- a/contrib/occtax/backend/occtax/models.py +++ b/contrib/occtax/backend/occtax/models.py @@ -79,6 +79,7 @@ class CorCountingOccurrence(DB.Model): foreign_keys=[TMedias.uuid_attached_row], cascade="all", lazy="select", + overlaps="medias", ) diff --git a/frontend/src/app/GN2CommonModule/GN2Common.module.ts b/frontend/src/app/GN2CommonModule/GN2Common.module.ts index a3d13c0a1c..70089621c9 100644 --- a/frontend/src/app/GN2CommonModule/GN2Common.module.ts +++ b/frontend/src/app/GN2CommonModule/GN2Common.module.ts @@ -102,6 +102,9 @@ import { MediaService } from '@geonature_common/service/media.service'; import { NgbDatePeriodParserFormatter } from '@geonature_common/form/date/ngb-date-custom-parser-formatter'; import { SyntheseDataService } from '@geonature_common/form/synthese-form/synthese-data.service'; import { TaxonTreeComponent } from './form/taxon-tree/taxon-tree.component'; +import { IndividualsComponent } from './form/individuals/individuals.component'; +import { IndividualsService } from './form/individuals/individuals.service'; +import { IndividualsCreateComponent } from './form/individuals/create/individuals-create.component'; @NgModule({ imports: [ @@ -194,6 +197,8 @@ import { TaxonTreeComponent } from './form/taxon-tree/taxon-tree.component'; TaxonAdvancedModalComponent, TaxonomyComponent, TaxonTreeComponent, + IndividualsComponent, + IndividualsCreateComponent, ], providers: [ CommonService, @@ -207,6 +212,7 @@ import { TaxonTreeComponent } from './form/taxon-tree/taxon-tree.component'; NgbDatePeriodParserFormatter, SyntheseDataService, TranslateService, + IndividualsService, ], exports: [ AcquisitionFrameworksComponent, @@ -299,6 +305,7 @@ import { TaxonTreeComponent } from './form/taxon-tree/taxon-tree.component'; TaxonomyComponent, TaxonTreeComponent, TranslateModule, + IndividualsComponent, ], }) export class GN2CommonModule { diff --git a/frontend/src/app/GN2CommonModule/form/dynamic-form/dynamic-form.component.html b/frontend/src/app/GN2CommonModule/form/dynamic-form/dynamic-form.component.html index 9d96cd8045..c8fd4a80fb 100644 --- a/frontend/src/app/GN2CommonModule/form/dynamic-form/dynamic-form.component.html +++ b/frontend/src/app/GN2CommonModule/form/dynamic-form/dynamic-form.component.html @@ -377,7 +377,14 @@ [default]="formDefComp['default']" [nullDefault]="formDefComp['nullDefault']" > - + {{ formDefComp['help'] }} diff --git a/frontend/src/app/GN2CommonModule/form/individuals/create/individuals-create.component.html b/frontend/src/app/GN2CommonModule/form/individuals/create/individuals-create.component.html new file mode 100644 index 0000000000..a26f10c161 --- /dev/null +++ b/frontend/src/app/GN2CommonModule/form/individuals/create/individuals-create.component.html @@ -0,0 +1,72 @@ + + + + diff --git a/frontend/src/app/GN2CommonModule/form/individuals/create/individuals-create.component.scss b/frontend/src/app/GN2CommonModule/form/individuals/create/individuals-create.component.scss new file mode 100644 index 0000000000..53e1a36b1e --- /dev/null +++ b/frontend/src/app/GN2CommonModule/form/individuals/create/individuals-create.component.scss @@ -0,0 +1,5 @@ +#createButton { + float: right; + //background-color: #1976D2; + border: none; +} diff --git a/frontend/src/app/GN2CommonModule/form/individuals/create/individuals-create.component.ts b/frontend/src/app/GN2CommonModule/form/individuals/create/individuals-create.component.ts new file mode 100644 index 0000000000..39384854cd --- /dev/null +++ b/frontend/src/app/GN2CommonModule/form/individuals/create/individuals-create.component.ts @@ -0,0 +1,65 @@ +import { Component, Output, EventEmitter, Input, OnInit } from '@angular/core'; +import { FormControl, FormGroup } from '@angular/forms'; +import { Validators } from '@angular/forms'; +import { Taxon } from '@geonature_common/form/taxonomy/taxonomy.component'; +import { NgbTypeaheadSelectItemEvent } from '@ng-bootstrap/ng-bootstrap'; +import { IndividualsService } from '../individuals.service'; +import { Individual } from '../interfaces'; +import { throwError } from 'rxjs'; +@Component({ + selector: 'pnx-individuals-create', + templateUrl: './individuals-create.component.html', + styleUrls: ['./individuals-create.component.scss'], +}) +export class IndividualsCreateComponent implements OnInit { + @Input() idModule: number; + @Input() idList: null | string = null; + @Input() cdNom: null | number = null; + @Output() individualEvent = new EventEmitter(); + @Output() cancelEvent = new EventEmitter(); + + form: FormGroup<{ + individual_name: FormControl; + id_nomenclature_sex: FormControl; + cd_nom: FormControl; + cd_nom_temp: FormControl; + comment: FormControl; + }>; + + constructor(private _individualsService: IndividualsService) {} + + ngOnInit() { + this.form = new FormGroup({ + individual_name: new FormControl(null, { + validators: [Validators.required], + }), + id_nomenclature_sex: new FormControl(null), + cd_nom: new FormControl(this.cdNom, { + validators: [Validators.required], + }), + // Normally we could avoid providing a form to taxonomy + // widget, but it needs it and affecting the entire taxon + // to the form control. Creating a temp one to fix this problem + cd_nom_temp: new FormControl(this.cdNom, { + validators: [Validators.required], + }), + comment: new FormControl(''), + }); + } + + taxonSelected(value: NgbTypeaheadSelectItemEvent) { + this.form.patchValue({ cd_nom: value.item.cd_nom }); + } + + createIndividual() { + const value = this.form.getRawValue(); + delete value.cd_nom_temp; + this._individualsService + .postIndividual(value as Individual, this.idModule) + .subscribe((value) => this.individualEvent.emit(value)); + } + + cancelCreate() { + this.cancelEvent.emit(); + } +} diff --git a/frontend/src/app/GN2CommonModule/form/individuals/individuals.component.html b/frontend/src/app/GN2CommonModule/form/individuals/individuals.component.html new file mode 100644 index 0000000000..69b5a2084d --- /dev/null +++ b/frontend/src/app/GN2CommonModule/form/individuals/individuals.component.html @@ -0,0 +1,46 @@ +{{ label }} + + +
+
+ +
+
+ +
+
+ + + + diff --git a/frontend/src/app/GN2CommonModule/form/individuals/individuals.component.scss b/frontend/src/app/GN2CommonModule/form/individuals/individuals.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/app/GN2CommonModule/form/individuals/individuals.component.ts b/frontend/src/app/GN2CommonModule/form/individuals/individuals.component.ts new file mode 100644 index 0000000000..39aa3e2c79 --- /dev/null +++ b/frontend/src/app/GN2CommonModule/form/individuals/individuals.component.ts @@ -0,0 +1,56 @@ +import { Component, OnInit, Input } from '@angular/core'; +import { UntypedFormControl } from '@angular/forms'; +import { Individual } from './interfaces'; +import { IndividualsService } from './individuals.service'; +import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; +@Component({ + selector: 'pnx-individuals', + templateUrl: './individuals.component.html', + styleUrls: ['./individuals.component.scss'], +}) +export class IndividualsComponent implements OnInit { + @Input() parentFormControl: UntypedFormControl; + @Input() idModule: number; + @Input() label: string; + @Input() idList: null | string = null; + @Input() cdNom: null | number = null; + + keyLabel: string = 'individual_name'; + keyValue: string = 'id_individual'; + values: Individual[] = []; + public modal: NgbModalRef; + + constructor( + private modalService: NgbModal, + private _individualsService: IndividualsService + ) {} + ngOnInit(): void { + this.getIndividuals().subscribe((data) => { + this.values = data.filter(item => item.active); + }); + } + + getIndividuals() { + return this._individualsService.getIndividuals(this.idModule); + } + + openModal(content) { + // if no error : open popup for changing validation status + this.modal = this.modalService.open(content, { + centered: true, + size: 'lg', + }); + } + + closeModal() { + if (this.modal) this.modal.close(); + } + + individualCreated(value: Individual) { + this.closeModal(); + this.getIndividuals().subscribe((data) => { + this.values = data; + this.parentFormControl.setValue(value.id_individual); + }); + } +} diff --git a/frontend/src/app/GN2CommonModule/form/individuals/individuals.service.ts b/frontend/src/app/GN2CommonModule/form/individuals/individuals.service.ts new file mode 100644 index 0000000000..c51155716e --- /dev/null +++ b/frontend/src/app/GN2CommonModule/form/individuals/individuals.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@angular/core'; +import { ConfigService } from '@geonature/services/config.service'; +import { HttpClient } from '@angular/common/http'; +import { Individual } from './interfaces'; + +@Injectable() +export class IndividualsService { + constructor( + private _http: HttpClient, + public config: ConfigService + ) {} + + getIndividuals(idModule: number) { + return this._http.get( + `${this.config.API_ENDPOINT}/gn_monitoring/individuals/${idModule}` + ); + } + + postIndividual(value: Individual, idModule: number) { + return this._http.post( + `${this.config.API_ENDPOINT}/gn_monitoring/individual/${idModule}`, + value + ); + } +} diff --git a/frontend/src/app/GN2CommonModule/form/individuals/interfaces.ts b/frontend/src/app/GN2CommonModule/form/individuals/interfaces.ts new file mode 100644 index 0000000000..c22ab7ca3e --- /dev/null +++ b/frontend/src/app/GN2CommonModule/form/individuals/interfaces.ts @@ -0,0 +1,14 @@ +export interface Individual { + active?: boolean; + cd_nom: number; + comment: string; + id_digitiser?: number; + // missing digitizer + id_individual?: number; + id_nomenclature_sex: number; + individual_name: string; + meta_create_date?: Date; + meta_update_date?: Date; + // Get Nomenclature interface nomenclature_sex?: string; + uuid_individual?: string; +}