Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add custom export views in synthese #2968

Merged
merged 4 commits into from
Mar 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 51 additions & 23 deletions backend/geonature/core/gn_synthese/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -531,15 +531,39 @@ def export_observations_web(permissions):

POST parameters: Use a list of id_synthese (in POST parameters) to filter the v_synthese_for_export_view

:query str export_format: str<'csv', 'geojson', 'shapefiles', 'gpkg'>
:query str export_format: str<'csv', 'geojson', 'shapefiles', 'gpkg'>

"""
params = request.args
# set default to csv
export_format = params.get("export_format", "csv")
view_name_param = params.get("view_name", "gn_synthese.v_synthese_for_export")
# Test export_format
if not export_format in current_app.config["SYNTHESE"]["EXPORT_FORMAT"]:
if export_format not in current_app.config["SYNTHESE"]["EXPORT_FORMAT"]:
raise BadRequest("Unsupported format")
config_view = {
"view_name": "gn_synthese.v_synthese_for_web_app",
"geojson_4326_field": "geojson_4326",
"geojson_local_field": "geojson_local",
}
# Test export view name is config params for security reason
if view_name_param != "gn_synthese.v_synthese_for_export":
try:
config_view = next(
_view
for _view in current_app.config["SYNTHESE"]["EXPORT_OBSERVATIONS_CUSTOM_VIEWS"]
if _view["view_name"] == view_name_param
)
except StopIteration:
raise Forbidden("This view is not available for export")

geojson_4326_field = config_view["geojson_4326_field"]
geojson_local_field = config_view["geojson_local_field"]
try:
schema_name, view_name = view_name_param.split(".")
except ValueError:
raise BadRequest("view_name parameter must be a string with schema dot view_name")

# get list of id synthese from POST
id_list = request.get_json()
Expand All @@ -555,12 +579,18 @@ def export_observations_web(permissions):
# Useful to have geom column so that they can be replaced by blurred geoms
# (only if the user has sensitive permissions)
export_view = GenericTableGeo(
tableName="v_synthese_for_export",
schemaName="gn_synthese",
tableName=view_name,
schemaName=schema_name,
engine=DB.engine,
geometry_field=None,
srid=local_srid,
)
mandatory_columns = {"id_synthese", geojson_4326_field, geojson_local_field}
if not mandatory_columns.issubset(set(map(lambda col: col.name, export_view.db_cols))):
print(set(map(lambda col: col.name, export_view.db_cols)))
raise BadRequest(
f"The view {view_name} miss one of required columns {str(mandatory_columns)}"
)

# If there is no sensitive permissions => same path as before blurring implementation
if not blurring_permissions:
Expand Down Expand Up @@ -591,8 +621,6 @@ def export_observations_web(permissions):
)

# Overwrite geometry columns to compute the blurred geometry from the blurring cte
geojson_4326_col = current_app.config["SYNTHESE"]["EXPORT_GEOJSON_4326_COL"]
geojson_local_col = current_app.config["SYNTHESE"]["EXPORT_GEOJSON_LOCAL_COL"]
columns_with_geom_excluded = [
col
for col in export_view.tableDef.columns
Expand All @@ -601,18 +629,18 @@ def export_observations_web(permissions):
"geometrie_wkt_4326", # FIXME: hardcoded column names?
"x_centroid_4326",
"y_centroid_4326",
geojson_4326_col,
geojson_local_col,
geojson_4326_field,
geojson_local_field,
]
]
# Recomputed the blurred geometries
blurred_geom_columns = [
func.st_astext(cte_synthese_filtered.c.geom).label("geometrie_wkt_4326"),
func.st_x(func.st_centroid(cte_synthese_filtered.c.geom)).label("x_centroid_4326"),
func.st_y(func.st_centroid(cte_synthese_filtered.c.geom)).label("y_centroid_4326"),
func.st_asgeojson(cte_synthese_filtered.c.geom).label(geojson_4326_col),
func.st_asgeojson(cte_synthese_filtered.c.geom).label(geojson_4326_field),
func.st_asgeojson(func.st_transform(cte_synthese_filtered.c.geom, local_srid)).label(
geojson_local_col
geojson_local_field
),
]

Expand All @@ -625,14 +653,10 @@ def export_observations_web(permissions):
.select_from(
export_view.tableDef.join(
cte_synthese_filtered,
cte_synthese_filtered.c.id_synthese == export_view.tableDef.c.id_synthese,
cte_synthese_filtered.c.id_synthese == export_view.tableDef.columns["id_synthese"],
)
)
.where(
export_view.tableDef.columns[
current_app.config["SYNTHESE"]["EXPORT_ID_SYNTHESE_COL"]
].in_(id_list)
)
.where(export_view.tableDef.columns["id_synthese"].in_(id_list))
)

# Get the results for export
Expand All @@ -642,11 +666,17 @@ def export_observations_web(permissions):

db_cols_for_shape = []
columns_to_serialize = []
# loop over synthese config to get the columns for export
# loop over synthese config to exclude columns if its default export
for db_col in export_view.db_cols:
if db_col.key in current_app.config["SYNTHESE"]["EXPORT_COLUMNS"]:
db_cols_for_shape.append(db_col)
columns_to_serialize.append(db_col.key)
if view_name_param == "gn_synthese.v_synthese_for_export":
if db_col.key in current_app.config["SYNTHESE"]["EXPORT_COLUMNS"]:
db_cols_for_shape.append(db_col)
columns_to_serialize.append(db_col.key)
else:
# remove geojson fields of serialization
if db_col.key not in [geojson_4326_field, geojson_local_field]:
db_cols_for_shape.append(db_col)
columns_to_serialize.append(db_col.key)

file_name = datetime.datetime.now().strftime("%Y_%m_%d_%Hh%Mm%S")
file_name = filemanager.removeDisallowedFilenameChars(file_name)
Expand All @@ -657,9 +687,7 @@ def export_observations_web(permissions):
elif export_format == "geojson":
features = []
for r in results:
geometry = json.loads(
getattr(r, current_app.config["SYNTHESE"]["EXPORT_GEOJSON_4326_COL"])
)
geometry = json.loads(getattr(r, geojson_4326_field))
feature = Feature(
geometry=geometry,
properties=export_view.as_dict(r, fields=columns_to_serialize),
Expand All @@ -673,7 +701,7 @@ def export_observations_web(permissions):
export_format=export_format,
export_view=export_view,
db_cols=db_cols_for_shape,
geojson_col=current_app.config["SYNTHESE"]["EXPORT_GEOJSON_LOCAL_COL"],
geojson_col=geojson_local_field,
data=results,
file_name=file_name,
)
Expand Down
30 changes: 30 additions & 0 deletions backend/geonature/tests/test_synthese.py
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,36 @@ def test_export(self, users):
)
assert response.status_code == 200

@pytest.mark.parametrize(
"view_name,response_status_code",
[
("gn_synthese.v_synthese_for_web_app", 200),
("gn_synthese.not_in_config", 403),
("v_synthese_for_web_app", 400), # miss schema name
("gn_synthese.v_metadata_for_export", 400), # miss required columns
],
)
def test_export_observations_custom_view(self, users, app, view_name, response_status_code):
set_logged_user(self.client, users["self_user"])
if view_name != "gn_synthese.not_in_config":
app.config["SYNTHESE"]["EXPORT_OBSERVATIONS_CUSTOM_VIEWS"] = [
{
"label": "Test export custom",
"view_name": view_name,
"geojson_4326_field": "st_asgeojson",
"geojson_local_field": "st_asgeojson",
}
]
response = self.client.post(
url_for("gn_synthese.export_observations_web"),
json=[1, 2, 3],
query_string={
"export_format": "geojson",
"view_name": view_name,
},
)
assert response.status_code == response_status_code

def test_export_observations(self, users, synthese_data, synthese_sensitive_data, modules):
data_synthese = synthese_data.values()
data_synthese_sensitive = synthese_sensitive_data.values()
Expand Down
37 changes: 29 additions & 8 deletions backend/geonature/utils/config_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,14 @@

import os

from marshmallow import (
Schema,
fields,
validates_schema,
ValidationError,
post_load,
)
from warnings import warn

from marshmallow import Schema, fields, validates_schema, ValidationError, post_load, pre_load
from marshmallow.validate import OneOf, Regexp, Email, Length

from geonature.core.gn_synthese.synthese_config import (
DEFAULT_EXPORT_COLUMNS,
DEFAULT_LIST_COLUMN,
DEFAULT_COLUMNS_API_SYNTHESE,
)
from geonature.utils.env import GEONATURE_VERSION, BACKEND_DIR, ROOT_DIR
from geonature.utils.module import iter_modules_dist, get_module_config
Expand Down Expand Up @@ -281,6 +276,13 @@
DISPLAY_EMAIL_DISPLAY_INFO = fields.List(fields.String(), load_default=["NOM_VERN"])


class ExportObservationSchema(Schema):
label = fields.String(required=True)
view_name = fields.String(required=True)
geojson_4326_field = fields.String(load_default="geojson_4326")
geojson_local_field = fields.String(load_default="geojson_local")


class Synthese(Schema):
# --------------------------------------------------------------------
# SYNTHESE - SEARCH FORM
Expand Down Expand Up @@ -364,6 +366,9 @@
# --------------------------------------------------------------------
# SYNTHESE - DOWNLOADS (AKA EXPORTS)
EXPORT_COLUMNS = fields.List(fields.String(), load_default=DEFAULT_EXPORT_COLUMNS)
EXPORT_OBSERVATIONS_CUSTOM_VIEWS = fields.List(
fields.Nested(ExportObservationSchema), load_default=[]
)
# Certaines colonnes sont obligatoires pour effectuer les filtres CRUVED
EXPORT_ID_SYNTHESE_COL = fields.String(load_default="id_synthese")
EXPORT_ID_DATASET_COL = fields.String(load_default="jdd_id")
Expand Down Expand Up @@ -432,6 +437,22 @@
# Activate the blurring of sensitive observations. Otherwise, exclude them
BLUR_SENSITIVE_OBSERVATIONS = fields.Boolean(load_default=True)

@pre_load
def warn_deprecated(self, data, **kwargs):
deprecated = {
"EXPORT_ID_SYNTHESE_COL",
"EXPORT_ID_DIGITISER_COL",
"EXPORT_OBSERVERS_COL",
"EXPORT_GEOJSON_4326_COL",
"EXPORT_GEOJSON_LOCAL_COL",
}
for deprecated_field in deprecated & set(data.keys()):
warn(

Check warning on line 450 in backend/geonature/utils/config_schema.py

View check run for this annotation

Codecov / codecov/patch

backend/geonature/utils/config_schema.py#L450

Added line #L450 was not covered by tests
f"{deprecated_field} is deprecated - "
"Please use `EXPORT_OBSERVATIONS_CUSTOM_VIEWS` parameter to customize your synthese exports "
)
return data


# Map configuration
BASEMAP = [
Expand Down
15 changes: 7 additions & 8 deletions config/default_config.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -325,17 +325,16 @@ MEDIA_CLEAN_CRONTAB = "0 1 * * *"
# Nombre max d'observations dans les exports
NB_MAX_OBS_EXPORT = 50000

# Noms des colonnes obligatoires de la vue ``gn_synthese.v_synthese_for_export``
EXPORT_ID_SYNTHESE_COL = "id_synthese"
EXPORT_ID_DATASET_COL = "jdd_id"
EXPORT_ID_DIGITISER_COL = "id_digitiser"
EXPORT_OBSERVERS_COL = "observateurs"
EXPORT_GEOJSON_4326_COL = "geojson_4326"
EXPORT_GEOJSON_LOCAL_COL = "geojson_local"

# Formats d'export disponibles ["csv", "geojson", "shapefile", "gpkg"]
EXPORT_FORMAT = ["csv", "geojson", "shapefile"]

# Vues d'export personnalisées
EXPORT_OBSERVATIONS_CUSTOM_VIEWS = [
{
label = "format personnalisé",
view_name = "schema_name.view_name"
}
]
# Noms des colonnes obligatoires de la vue ``gn_synthese.v_metadata_for_export``
EXPORT_METADATA_ID_DATASET_COL = "jdd_id"
EXPORT_METADATA_ACTOR_COL = "acteurs"
Expand Down
8 changes: 8 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ CHANGELOG

- [Synthèse] Possibilité d'ajouter des champs supplémentaires à la liste de résultats via le paramètre `ADDITIONAL_COLUMNS_FRONTEND`. Ces champs sont masqués par défaut et controlables depuis l'interface (#2946)

- [Synthèse] Possiblité d'ajouter des exports personnalisés basé sur des vues SQL via le paramètre `EXPORT_OBSERVATIONS_CUSTOM_VIEWS` (#2955)

**⚠️ Notes de version**

- Les paramètres de la synthèse permettant de spécifier le nom de certaines colonnes de la vue d'export sont déprécies (`EXPORT_ID_SYNTHESE_COL`, `EXPORT_ID_DIGITISER_COL`, `EXPORT_OBSERVERS_COL`, `EXPORT_GEOJSON_4326_COL`, `EXPORT_GEOJSON_LOCAL_COL`). Si vous aviez surcouché la vue par défaut `gn_synthese.v_synthese_for_export`, il est recommandé de ne plus le faire et de plutôt utiliser le nouveau paramètre `EXPORT_OBSERVATIONS_CUSTOM_VIEWS` permettant de se créer ses propres vues d'export personnalisées.

2.14.0 - Talpa europaea 👓 (2024-02-28)

2.14.0 - Talpa europaea 👓 (2024-02-28)
---------------------------------------

Expand Down
28 changes: 12 additions & 16 deletions docs/admin-manual.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2194,26 +2194,22 @@ Enlevez la ligne de la colonne que vous souhaitez désactiver. Les noms de colon

L'entête ``[SYNTHESE]`` au dessus ``EXPORT_COLUMNS`` indique simplement que cette variable appartient au bloc de configuration de la synthese. Ne pas rajouter l'entête à chaque paramètre de la synthese mais une seule fois au dessus de toutes les variables de configuration du module.

Il est également possible de personnaliser ses exports en éditant le SQL de la vue ``gn_synthese.v_synthese_for_export`` (niveau SQL et administration GeoNature avancé).
Il est également possible de personnaliser ses exports en créant vos propres vues personnalisées et en remplissant le paramètre suivant avec une ou plusieurs vues d'export spécifiques :

Attention, certains champs sont cependant obligatoires pour assurer la réalisation des fichiers d'export (csv, geojson et shapefile) et des filtres CRUVED.

La vue doit OBLIGATOIREMENT contenir les champs :

- geojson_4326
- geojson_local
- id_synthese,
- jdd_id (l'ID du jeu de données)
- id_digitiser
- observateurs

Ces champs doivent impérativement être présents dans la vue, mais ne seront pas nécessairement dans le fichier d'export si ils ne figurent pas dans la variable ``EXPORT_COLUMNS``. De manière générale, préférez rajouter des champs plutôt que d'en enlever !
::

Le nom de ces champs peut cependant être modifié. Dans ce cas, modifiez le fichier ``geonature_config.toml``, section ``SYNTHESE`` parmis les variables suivantes (``EXPORT_ID_SYNTHESE_COL, EXPORT_ID_DATASET_COL, EXPORT_ID_DIGITISER_COL, EXPORT_OBSERVERS_COL, EXPORT_GEOJSON_4326_COL, EXPORT_GEOJSON_LOCAL_COL``).
[SYNTHESE]
...
EXPORT_OBSERVATIONS_CUSTOM_VIEWS = [
{
label = "format personnalisé",
view_name = "gn_synthese.v_synthese_for_web_app",
}
]

NB : Lorsqu'on effectue une recherche dans la synthèse, on interroge la vue ``gn_synthese.v_synthese_for_web_app``. L'interface web passe ensuite une liste d'``id_synthese`` à la vue ``gn_synthese.v_synthese_for_export`` correspondant à la recherche précedemment effectuée (ce qui permet à cette seconde vue d'être totalement modifiable).

La vue ``gn_synthese.v_synthese_for_web_app`` est taillée pour l'interface web, il ne faut donc PAS la modifier.
Ces vues doivent obligatoirement avoir une colonne `id_synthese`, une colonne `geojson_local` représentant le geojson de la géometrie en projection locale (pour la génération du shapefile) et une colonne `geojson_4326` représentant le geojson de la géométrie en projection 4326 (pour la génération du geojson) (utilisez la fonction `st_asgeojson` - voir la vue par défaut `gn_synthese.v_synthese_for_export`).
Selon les permissions de l'utilisation sur l'action Export du module Synthèse, le floutage pourra s'appliquer automatiquement à la vue d'export sur les même champs géométriques que la vue `gn_synthese.v_synthese_for_export`, à savoir `geometrie_wkt_4326`, `x_centroid_4326`, `y_centroid_4326`, `geojson_local` et `geojson_4326`. Si vous ajoutez des champs représentant la géométrie de l'observation portant des noms différents que les 4 noms précités, ceux-ci ne pourront pas être floutés.

**Export des métadonnées**

Expand Down
2 changes: 1 addition & 1 deletion frontend/cypress/e2e/synthese-spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ describe('Tests gn_synthese', () => {
// });

it('Should download data at the csv format', function () {
cy.intercept('POST', '/synthese/export_observations?export_format=csv').as('exportCall');
cy.intercept('POST', '/synthese/export_observations?export_format=csv**').as('exportCall');

cy.get('[data-qa="synthese-download-btn"]').click();
cy.get('[data-qa="download-csv"]').click({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,10 @@ export class SyntheseDataService {
return this._api.get<any>(`${this.config.API_ENDPOINT}/synthese/taxons_tree`);
}

downloadObservations(idSyntheseList: Array<number>, format: string) {
downloadObservations(idSyntheseList: Array<number>, format: string, view_name: string) {
this.isDownloading = true;
const queryString = new HttpParams().set('export_format', format);

let queryString = new HttpParams().set('export_format', format);
queryString = queryString.set('view_name', view_name);
const source = this._api.post(
`${this.config.API_ENDPOINT}/synthese/export_observations`,
idSyntheseList,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,31 @@ <h5 class="second-color">Télécharger les observations</h5>
style="margin-left: 5px"
*ngFor="let format of syntheseConfig.EXPORT_FORMAT"
mat-raised-button
(click)="downloadObservations(format)"
(click)="downloadObservations(format, 'gn_synthese.v_synthese_for_export')"
type="button"
class="buttonLoad button-success format-btn"
[attr.data-qa]="'download-' + format"
>
Format {{ format }}
</button>
</div>
<div
*ngFor="let export of syntheseConfig.EXPORT_OBSERVATIONS_CUSTOM_VIEWS"
class="my-3 pt-2"
>
<h5 class="second-color">Télécharger les observations - {{ export.label }}</h5>
<button
[disabled]="_dataService.isDownloading"
style="margin-left: 5px"
*ngFor="let format of syntheseConfig.EXPORT_FORMAT"
mat-raised-button
(click)="downloadObservations(format, export.view_name)"
type="button"
class="buttonLoad button-success format-btn"
>
Format {{ format }}
</button>
</div>

<div class="my-3 pt-2">
<h5 class="second-color">Télécharger les taxons</h5>
Expand Down
Loading
Loading