diff --git a/CHANGELOG.md b/CHANGELOG.md index ed50f8400..c4d4d8721 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Topics changes: - Topics creation, update and deletion are now opened to all users [#2898](https://github.com/opendatateam/udata/pull/2898) - Topics are now `db.Owned` and searchable by `id` in dataset search [#2901](https://github.com/opendatateam/udata/pull/2901) +- Add support for a CSW harvester using DCAT format [#2800](https://github.com/opendatateam/udata/pull/2800) - Remove `deleted` api field that does not exist [#2903](https://github.com/opendatateam/udata/pull/2903) - Add `created_at`field to topic's model [#2904](https://github.com/opendatateam/udata/pull/2904) - Topics can now be filtered by `tag` field [#2904](https://github.com/opendatateam/udata/pull/2904) diff --git a/setup.py b/setup.py index 8b1cdc7ca..8182129b1 100644 --- a/setup.py +++ b/setup.py @@ -44,6 +44,7 @@ def pip(filename): ], 'udata.harvesters': [ 'dcat = udata.harvest.backends.dcat:DcatBackend', + 'csw-dcat = udata.harvest.backends.dcat:CswDcatBackend', ], 'udata.avatars': [ 'internal = udata.features.identicon.backends:internal', diff --git a/udata/commands/dcat.py b/udata/commands/dcat.py index 395fc5fe5..7891b4133 100644 --- a/udata/commands/dcat.py +++ b/udata/commands/dcat.py @@ -8,7 +8,7 @@ from udata.commands import cli, green, yellow, cyan, echo, magenta from udata.core.dataset.factories import DatasetFactory from udata.core.dataset.rdf import dataset_from_rdf -from udata.harvest.backends.dcat import DcatBackend +from udata.harvest.backends.dcat import DcatBackend, CswDcatBackend from udata.rdf import namespace_manager log = logging.getLogger(__name__) @@ -24,7 +24,8 @@ def grp(): @click.argument('url') @click.option('-q', '--quiet', is_flag=True, help='Ignore warnings') @click.option('-i', '--rid', help='Inspect specific remote id (contains)') -def parse_url(url, quiet=False, rid=''): +@click.option('-c', '--csw', is_flag=True, help='The target is a CSW endpoint') +def parse_url(url, csw, quiet=False, rid=''): '''Parse the datasets in a DCAT format located at URL (debug)''' if quiet: verbose_loggers = ['rdflib', 'udata.core.dataset'] @@ -46,7 +47,10 @@ def _create(cls, model_class, *args, **kwargs): echo(cyan('Parsing url {}'.format(url))) source = MockSource() source.url = url - backend = DcatBackend(source, dryrun=True) + if csw: + backend = CswDcatBackend(source, dryrun=True) + else: + backend = DcatBackend(source, dryrun=True) backend.job = MockJob() format = backend.get_format() echo(yellow('Detected format: {}'.format(format))) diff --git a/udata/harvest/backends/base.py b/udata/harvest/backends/base.py index 045d77433..ba15b7123 100644 --- a/udata/harvest/backends/base.py +++ b/udata/harvest/backends/base.py @@ -268,8 +268,6 @@ def process(self, item): raise NotImplementedError def add_item(self, identifier, *args, **kwargs): - if identifier is None: - raise ValueError('DCT.identifier is required for all DCAT.Dataset records') item = HarvestItem(remote_id=str(identifier), args=args, kwargs=kwargs) self.job.items.append(item) return item diff --git a/udata/harvest/backends/dcat.py b/udata/harvest/backends/dcat.py index f4d432aa4..7409de563 100644 --- a/udata/harvest/backends/dcat.py +++ b/udata/harvest/backends/dcat.py @@ -2,8 +2,9 @@ import requests -from rdflib import Graph, URIRef, BNode +from rdflib import Graph, URIRef from rdflib.namespace import RDF +import xml.etree.ElementTree as ET from typing import List from udata.rdf import ( @@ -35,6 +36,9 @@ (HYDRA.PagedCollection, HYDRA.nextPage) ) +CSW_NAMESPACE = 'http://www.opengis.net/cat/csw/2.0.2' +OWS_NAMESPACE = 'http://www.opengis.net/ows' + # Useful to patch essential failing URIs URIS_TO_REPLACE = { # See https://github.com/etalab/data.gouv.fr/issues/1151 @@ -119,6 +123,8 @@ def get_node_from_item(self, graph, item): raise ValueError(f'Unable to find dataset with DCT.identifier:{item.remote_id}') def process(self, item): + if item.remote_id == 'None': + raise ValueError('The DCT.identifier is missing on this DCAT.Dataset record') graph = Graph(namespace_manager=namespace_manager) data = self.job.data['graphs'][item.kwargs['page']] format = self.job.data['format'] @@ -129,3 +135,89 @@ def process(self, item): dataset = self.get_dataset(item.remote_id) dataset = dataset_from_rdf(graph, dataset, node=node) return dataset + + +class CswDcatBackend(DcatBackend): + display_name = 'CSW-DCAT' + + DCAT_SCHEMA = 'http://www.w3.org/ns/dcat#' + + def parse_graph(self, url: str, fmt: str) -> List[Graph]: + body = ''' + + full + + + identifier + ASC + + + + ''' + headers = {'Content-Type': 'application/xml'} + + graphs = [] + page = 0 + start = 1 + response = requests.post(url, data=body.format(start=start, schema=self.DCAT_SCHEMA), + headers=headers) + response.raise_for_status() + content = response.text + tree = ET.fromstring(content) + if tree.tag == '{' + OWS_NAMESPACE + '}ExceptionReport': + raise ValueError(f'Failed to query CSW:\n{content}') + while tree: + graph = Graph(namespace_manager=namespace_manager) + search_results = tree.find('csw:SearchResults', {'csw': CSW_NAMESPACE}) + if not search_results: + log.error(f'No search results found for {url} on page {page}') + break + for child in search_results: + subgraph = Graph(namespace_manager=namespace_manager) + subgraph.parse(data=ET.tostring(child), format=fmt) + graph += subgraph + + for node in subgraph.subjects(RDF.type, DCAT.Dataset): + id = subgraph.value(node, DCT.identifier) + kwargs = {'nid': str(node), 'page': page} + kwargs['type'] = 'uriref' if isinstance(node, URIRef) else 'blank' + self.add_item(id, **kwargs) + graphs.append(graph) + page += 1 + + next_record = int(search_results.attrib['nextRecord']) + matched_count = int(search_results.attrib['numberOfRecordsMatched']) + returned_count = int(search_results.attrib['numberOfRecordsReturned']) + + # Break conditions copied gratefully from + # noqa https://github.com/geonetwork/core-geonetwork/blob/main/harvesters/src/main/java/org/fao/geonet/kernel/harvest/harvester/csw/Harvester.java#L338-L369 + break_conditions = ( + # standard CSW: A value of 0 means all records have been returned. + next_record == 0, + + # Misbehaving CSW server returning a next record > matched count + next_record > matched_count, + + # No results returned already + returned_count == 0, + + # Current next record is lower than previous one + next_record < start, + + # Enough items have been harvested already + self.max_items and len(self.job.items) >= self.max_items + ) + + if any(break_conditions): + break + + start = next_record + tree = ET.fromstring( + requests.post(url, data=body.format(start=start, schema=self.DCAT_SCHEMA), + headers=headers).text) + + return graphs diff --git a/udata/harvest/tests/csw_dcat/geonetworkv4-page-1.xml b/udata/harvest/tests/csw_dcat/geonetworkv4-page-1.xml new file mode 100644 index 000000000..0a33f434a --- /dev/null +++ b/udata/harvest/tests/csw_dcat/geonetworkv4-page-1.xml @@ -0,0 +1,217 @@ + + + + + + + + + 2021-02-08 + 2021-02-08 + + + + + application/xml + XML + + + + + + + + + text/html + HTML + + + + + + + https://www.geo2france.fr/2017/accidento + Localisation des accidents de la circulation routière en 2017 + Accidents corporels de la circulation en Hauts de France (2017) + donnée ouverte + accidentologie + accident + + + + + + + + + + + + + Polygon((1.38022461103 48.8372121327, 1.38022461103 51.0890003105, 4.25573382669 51.0890003105, 4.25573382669 48.8372121327, 1.38022461103 48.8372121327)) + + + + 2017-01-01 + + 1000 + + Données issues de data.gouv.fr + + + + Réseaux de transport + + + + accident de la route + + + + HAUTS-DE-FRANCE + + + + NORD + + + + PAS-DE-CALAIS + + + + OISE + + + + SOMME + + + + AISNE + + + https://www.geo2france.fr/geoserver/cr_hdf/ows + accidento_hdf_L93 + OGC:WMS + + + Géo2France + + + + + + + + + + + 2021-02-04 + 2021-02-04 + + + + + application/xml + XML + + + + + + + + + text/html + HTML + + + + + + + https://www.geo2france.fr/chemins_ruraux_tests + Identification des chemins ruraux + Identification des chemins ruraux, sur des secteurs de tests (Houchin, Longfossé, Marck, Mont Bernachon, Saint Hilaire Cottes, Servins) par l'association des chemins ruraux en Hauts-de-France. + Biodiversité + Nature + + + + + + + + Polygon((1.38022461103 48.8372121327, 1.38022461103 51.0890003105, 4.25573382669 51.0890003105, 4.25573382669 48.8372121327, 1.38022461103 48.8372121327)) + + + + 2019-01-01 + + 1000 + + + + + + + + + + + + + + + + + Dénominations géographiques + + + + sentier pedestre + + + + HAUTS-DE-FRANCE + + + https://www.geo2france.fr/geoserver/asso_chemin_ruraux/ows + Secteur_HouchinFINAL + OGC:WMS + + + Géo2France + + + + + + + + \ No newline at end of file diff --git a/udata/harvest/tests/csw_dcat/geonetworkv4-page-3.xml b/udata/harvest/tests/csw_dcat/geonetworkv4-page-3.xml new file mode 100644 index 000000000..d99ba0559 --- /dev/null +++ b/udata/harvest/tests/csw_dcat/geonetworkv4-page-3.xml @@ -0,0 +1,254 @@ + + + + + + + + + 2020-07-21 + 2020-07-21 + + + + + application/xml + XML + + + + + + + + + text/html + HTML + + + + + + + https://www.geo2france.fr/ortho/2018/rvb-ir + Orthophotographie des Hauts-de-France (2018) - rayonnement Infra-Rouge + Orthophotographie numérique Infra-Rouge du territoire de la Région Hauts-de-France en 2017 et 2018, avec une zone tampon de 5 km sur les espaces frontaliers internationaux et de 1 km sur les espaces interrégionaux, hors zone maritime. + DONNEES OUVERTES + ORTHO + ORTHOPHOTO + INFRAROUGE + infrarouge + + + + + + + + + + + + Polygon((1.38022461103 48.8372121327, 1.38022461103 51.0890003105, 4.25573382669 51.0890003105, 4.25573382669 48.8372121327, 1.38022461103 48.8372121327)) + + + + 2018-06-01 + + 1000 + + + + Les prises de vues et l’aérotriangulation ont été réalisées par l’IGN dans le cadre d’un partenariat de co-production avec la Région Hauts-de-France, à une résolution native de 20cm. + + + + Ortho-imagerie + + + + HAUTS-DE-FRANCE + + + + SOMME + + + + OISE + + + + AISNE + + + + PAS-DE-CALAIS + + + + NORD + + + https://www.geo2france.fr/geoserver/geo2france/ows + ortho_regionale_2018_ir + OGC:WMS + + + https://www.geo2france.fr/opendata/ortho/2018/IR + Orthophoto 2018 Hauts-de-France (rayonnement IR) + WWW:LINK-1.0-http--link + + + https://www.geo2france.fr/opendata/ortho/2018/RVB/ + Téléchargement des index et des mosaïques + WWW:LINK-1.0-http--link + + + Géo2France + + + + Géo2France + + + + + + + + + 2019-11-21 + 2019-11-21 + + + + + application/xml + XML + + + + + + + + + text/html + HTML + + + + + + + https://www.geo2france.fr/geonetwork/srv/fre/catalog.search#/metadata/2b2238b7-945f-44c0-8bc0-b680758aba4b + BD Ortho infrarouge de la Picardie de 2013 optimisée par GéoPicardie + BD Ortho infrarouge de la Picardie de 2013. + +Le produit BD ORTHO® V2 est une collection de mosaïques d'orthophotographies numériques en couleurs ou en Infra Rouge couleurs, rectifiées dans la projection adaptée au territoire couvert. + +Les images d'origines fournies par l'IGN ont été modifiées pour y intégrer des aperçus à plusieurs échelles et pour rendre transparentes les zones sans données en vue d'optimiser les performances des services web de GéoPicardie sans en altérer l'aspect graphique. + +Ces données sont cofinancées par l’Union européenne. L’Europe s’engage en Picardie avec le Fonds européen de développement régional. + ortho-imagerie + + + + + + + + + + + Polygon((1.31 48.78, 1.31 50.41, 4.33 50.41, 4.33 48.78, 1.31 48.78)) + + + + + + license + license + + + + BD Ortho V2 infrarouge livrée par département au format ECW. +Encodage au format GEOTIFF avec compression LZW puis pyramidage au format GEOTIFF, compression JPEG, tuilage interne pour de meilleures performance de lecture et ajout d'un canal de transparence pour les zones sans données. + + + + PICARDIE + + + + rayonnement infrarouge + + + + photographie aérienne + + + + photographie + + + + Ortho-imagerie + + + https://www.geo2france.fr/geoserver/geopicardie/ows?service=WMS&request=GetCapabilities + picardie_ortho_ign_2013_ir + OGC:WMS + + + https://www.geo2france.fr/geoserver/geopicardie/ows?service=WCS&request=GetCapabilities + picardie_ortho_ign_2013_ir + OGC:WCS + + + https://www.geo2france.fr/geonetwork/srv/fre/catalog.search#/metadata/2b2238b7-945f-44c0-8bc0-b680758aba4b + Lien vers la fiche de métadonnées d'origine + WWW:LINK-1.0-http--link + + + Géo2France + + + + Institut national de l'information géographique et forestière (IGN-F) + + + + + + + + + + + \ No newline at end of file diff --git a/udata/harvest/tests/csw_dcat/geonetworkv4-page-5.xml b/udata/harvest/tests/csw_dcat/geonetworkv4-page-5.xml new file mode 100644 index 000000000..9a4ad019a --- /dev/null +++ b/udata/harvest/tests/csw_dcat/geonetworkv4-page-5.xml @@ -0,0 +1,268 @@ + + + + + + + + + 2021-02-03 + 2021-02-03 + + + + + application/xml + XML + + + + + + + + + text/html + HTML + + + + + + + fr-238000038-oise-ortho-vis-2008 + Orthophotographie de l'Oise (2008-2009) - rayonnement visible + Photographies aériennes ortho-rectifiées de 2008-2009 couvrant l'ensemble du département de l'Oise (rayonnement dans le domaine visible). + +La résolution des images est de 34 cm. +La taille équivalente des pixels une fois projetés sur le sol est de 20 cm. + +Cette orthophotographie est cofinancée par l’Union européenne. L’Europe s’engage en Picardie avec le Fonds européen de développement régional. + orthophotographies + images aériennes + + + + + + + + + + Polygon((1.64053 49.01243, 1.64053 49.774767884053, 3.19423 49.774767884053, 3.19423 49.01243, 1.64053 49.01243)) + + + + 2009-04-24 + + + license + license + + + + Caractéristiques des images sources : +- capteur : Caméra Vexcel UltracamX - Focale : 100 mm +- prise de vue : 29, 28 septembre 2004 et 24 avril 2009 +- résolution native : 34 cm +- résolution des images livrées : 20 cm (soit 1/800°) +- précision planimétrique : tolérance 2 pixels (centrale inertielle embarquée) +- aérotriangulation avec les points GPS sur la couverture de votre zone +- mosaïquage avec recouvrement 80/30 +- radiométrie : nivellement et homogénéisation des couleurs +- MNT utilisé pour l'orthorectification : production automatique à partir des images + + + + OISE (60) + + + + Ortho-imagerie + + + + photographie aérienne + + + + photographie + + + https://www.geo2france.fr/geoserver/cr_hdf/ows?service=WMS&request=GetCapabilities + oise_ortho_2008_vis + OGC:WMS + + + https://www.geo2france.fr/geoserver/cr_hdf/ows?service=WCS&request=GetCapabilities + cr_hdf:oise_ortho_2008_vis + OGC:WCS + + + https://www.geo2france.fr/portail/espace-documentaire/tableau-des-licences-interatlas + Tableau des licences d'InterAtlas (licence L.O.P 3) + WWW:LINK-1.0-http--link + + + Géo2France + + + + Région Hauts-de-France + + + + + + + + + + + + + + 2019-11-21 + 2019-11-21 + + + + + application/xml + XML + + + + + + + + + text/html + HTML + + + + + + + https://www.geo2france.fr/geonetwork/srv/fre/catalog.search#/metadata/4635b9cc-bc9a-4f25-851e-040cf647d974 + Orthophotographie du Département de l'Aisne (2009) - rayonnement infrarouge + Orthophotographies du Département de l'Aisne réalisée à partir de prises de vues aériennes numériques (pixel 25 cm, rayonnement infrarouge) de 2009 dans le système de projection Lambert 93. + +Ces données sont cofinancées par l’Union européenne. L’Europe s’engage en Picardie avec le Fonds européen de développement régional. + orthophotographies + images aériennes + ortho-imagerie + + + + + + + + + + Polygon((2.9587567567822513 48.838486155984086, 2.9587567567822513 50.06928644841888, 4.2557525169036525 50.06928644841888, 4.2557525169036525 48.838486155984086, 2.9587567567822513 48.838486155984086)) + + + + 2011-01-20 + + + + + fre + license + license + + + + Orthophotographies du Département de l'Aisne réalisée à partir de prises de vues aériennes numériques (pixel 25 cm, rayonnement visible et proche infrarouge) de 2009. + + + + photographie aérienne + + + + photographie + + + + AISNE (02) + + + + Ortho-imagerie + + + https://www.geo2france.fr/geoserver/geopicardie/ows?service=WMS&request=GetCapabilities + aisne_ortho_2009_ir + OGC:WMS + + + https://www.geo2france.fr/geoserver/geopicardie/ows?service=WCS&request=GetCapabilities + aisne_ortho_2009_ir + OGC:WCS + + + https://www.geo2france.fr/geonetwork/srv/fre/catalog.search#/metadata/4635b9cc-bc9a-4f25-851e-040cf647d974 + Lien vers la fiche de métadonnées d'origine + WWW:LINK-1.0-http--link + + + Géo2France + + + + État + + + + Département de l'Aisne + + + + Région Hauts-de-France + + + + + + + + + + + + + + + + + diff --git a/udata/harvest/tests/test_dcat_backend.py b/udata/harvest/tests/test_dcat_backend.py index 8de078aea..f47841b68 100644 --- a/udata/harvest/tests/test_dcat_backend.py +++ b/udata/harvest/tests/test_dcat_backend.py @@ -4,6 +4,7 @@ import pytest from datetime import date +import xml.etree.ElementTree as ET from udata.models import Dataset from udata.core.organization.factories import OrganizationFactory @@ -19,6 +20,7 @@ TEST_DOMAIN = 'data.test.org' # Need to be used in fixture file DCAT_URL_PATTERN = 'http://{domain}/{path}' DCAT_FILES_DIR = os.path.join(os.path.dirname(__file__), 'dcat') +CSW_DCAT_FILES_DIR = os.path.join(os.path.dirname(__file__), 'csw_dcat') def mock_dcat(rmock, filename, path=None): @@ -43,6 +45,19 @@ def callback(request, context): return url +def mock_csw_pagination(rmock, path, pattern): + url = DCAT_URL_PATTERN.format(path=path, domain=TEST_DOMAIN) + + def callback(request, context): + request_tree = ET.fromstring(request.body) + page = int(request_tree.get('startPosition')) + with open(os.path.join(CSW_DCAT_FILES_DIR, pattern.format(page))) as cswdcatfile: + return cswdcatfile.read() + + rmock.post(rmock.ANY, text=callback) + return url + + @pytest.mark.usefixtures('clean_db') @pytest.mark.options(PLUGINS=['dcat']) class DcatBackendTest: @@ -384,7 +399,7 @@ def test_unable_to_detect_format(self, rmock): error = job.errors[0] expected = 'Unable to detect format from extension or mime type' assert error.message == expected - + def test_use_replaced_uris(self, rmock, mocker): mocker.patch.dict( URIS_TO_REPLACE, @@ -408,3 +423,40 @@ def test_use_replaced_uris(self, rmock, mocker): job = source.get_last_job() assert len(job.items) == 0 assert job.status == 'done' + + +@pytest.mark.usefixtures('clean_db') +@pytest.mark.options(PLUGINS=['csw-dcat']) +class CswDcatBackendTest: + + def test_geonetworkv4(self, rmock): + url = mock_csw_pagination(rmock, 'geonetwork/srv/eng/csw.rdf', 'geonetworkv4-page-{}.xml') + org = OrganizationFactory() + source = HarvestSourceFactory(backend='csw-dcat', + url=url, + organization=org) + + actions.run(source.slug) + + source.reload() + + job = source.get_last_job() + assert len(job.items) == 6 + + datasets = {d.harvest.dct_identifier: d for d in Dataset.objects} + + assert len(datasets) == 6 + + # First dataset + dataset = datasets['https://www.geo2france.fr/2017/accidento'] + assert dataset.title == 'Localisation des accidents de la circulation routière en 2017' + assert dataset.description == 'Accidents corporels de la circulation en Hauts de France (2017)' + assert set(dataset.tags) == set([ + 'donnee-ouverte', 'accidentologie', 'accident' + ]) + assert dataset.harvest.created_at.date() == date(2017, 1, 1) + assert len(dataset.resources) == 1 + resource = dataset.resources[0] + assert resource.title == 'accidento_hdf_L93' + assert resource.url == 'https://www.geo2france.fr/geoserver/cr_hdf/ows' + assert resource.format == 'ogc:wms'