diff --git a/articlemeta/export_crossref.py b/articlemeta/export_crossref.py index a965ba1..8f5e77d 100644 --- a/articlemeta/export_crossref.py +++ b/articlemeta/export_crossref.py @@ -1,14 +1,14 @@ # coding: utf-8 -from lxml import etree as ET -import re import os +import re import uuid from copy import deepcopy from datetime import datetime from itertools import product -from xylose.scielodocument import UnavailableMetadataException import plumber +from lxml import etree as ET +from xylose.scielodocument import UnavailableMetadataException SUPPLBEG_REGEX = re.compile(r'^0 ') SUPPLEND_REGEX = re.compile(r' 0$') @@ -204,7 +204,7 @@ def precond(data): try: if raw.issue.is_ahead_of_print: raise plumber.UnmetPrecondition() - except UnavailableMetadataException as e: + except UnavailableMetadataException: raise plumber.UnmetPrecondition() @plumber.precondition(precond) @@ -748,7 +748,7 @@ def precond(data): try: if not raw.scielo_domain or not raw.publisher_id: raise plumber.UnmetPrecondition() - except: + except Exception: raise plumber.UnmetPrecondition() @plumber.precondition(precond) @@ -1127,124 +1127,170 @@ def transform(self, data): class XMLProgramRelatedItemPipe(plumber.Pipe): + RELATIONS_NAMESPACE = 'http://www.crossref.org/relations.xsd' + + # Relações derivadas do atributo `related-article-type` do + # (vocabulário SciELO/JATS, o mesmo da tabela do manual + # de marcação). Cada valor mapeia para (elemento_crossref, relationship-type). + # + # Os valores "commentary" e "letter" se repetem na especificação SciELO e + # são desambiguados pelo `document_type` do documento CORRENTE (valores de + # choices.article_types do xylose, além de "reply" quando aplicável). + # Somente combinações conhecidas/documentadas são emitidas; demais + # combinações são ignoradas até haver casos reais. + # Tipos ausentes deste dicionário não geram related_item. + RELATED_ARTICLE_TYPE_RELATIONS = { + 'commentary-article': ('inter_work_relation', 'isCommentOn'), + 'reply': ('inter_work_relation', 'isReplyTo'), + 'reviewed-article': ('inter_work_relation', 'isReviewOf'), + 'peer-reviewed-material': ('inter_work_relation', 'isReviewOf'), + 'reviewer-report': ('inter_work_relation', 'hasReview'), + 'preprint': ('intra_work_relation', 'hasPreprint'), + 'commentary': { + 'article-commentary': ('inter_work_relation', 'isCommentOn'), + 'research-article': ('inter_work_relation', 'hasComment'), + 'reply': ('inter_work_relation', 'isReplyTo'), + }, + 'letter': { + 'article-commentary': ('inter_work_relation', 'isCommentOn'), + 'reply': ('inter_work_relation', 'isReplyTo'), + }, + 'article-commentary': ('inter_work_relation', 'isCommentOn'), + } + + @classmethod + def _resolve_relation(cls, related_article, current_document_type): + """Resolve (elemento, relationship-type) do Crossref para um documento + relacionado. + + Usa o `related-article-type` do documento relacionado como fonte + primária e o `current_document_type` do documento corrente para desambiguar + os valores que se repetem na especificação SciELO (ex.: "letter" e + "commentary"). Combinações não mapeadas retornam ``None``. + """ + related_article_type = related_article.get('related_article_type') + if not related_article_type: + return None + + relation = cls.RELATED_ARTICLE_TYPE_RELATIONS.get(related_article_type) + if relation is None: + return None + + if isinstance(relation, dict): + return relation.get(current_document_type) + + return relation def transform(self, data): raw, xml = data data = self._transform_original(data) + data = self._transform_related_articles(data) data = self._transform_translations(data) return data + @classmethod + def _create_program(cls): + program_node = ET.Element('program') + program_node.set('xmlns', cls.RELATIONS_NAMESPACE) + return program_node + + @classmethod + def _get_or_create_program(cls, journal_article_node): + program_node = journal_article_node.find('program') + if program_node is None: + program_node = cls._create_program() + journal_article_node.append(program_node) + return program_node + @staticmethod - def _get_preprint_relations(raw): - """Return the list of related-article entries marked as preprint. - - SciELO stores related-article info in ISIS field ``v241``, with - subfields ``i`` (identifier/href), ``t`` (related-article-type) and - ``n`` (ext-link-type). Only entries whose type is ``preprint`` and - whose link type is ``doi`` (or unspecified) carry a usable DOI for - the Crossref ``hasPreprint`` relation. - """ - try: - related = raw.data['article'].get('v241') or [] - except (AttributeError, KeyError, TypeError): - return [] + def _create_related_item( + relation_element, + relationship_type, + identifier, + identifier_type='doi', + description=None): + related_item_node = ET.Element('related_item') - preprints = [] - for item in related: - if not isinstance(item, dict): - continue - if item.get('t') != 'preprint': - continue - identifier = item.get('i') or item.get('_') - if not identifier: - continue - ext_link_type = item.get('n') - if ext_link_type and ext_link_type != 'doi': - continue - preprints.append(identifier) - return preprints + if description is not None: + description_node = ET.Element('description') + description_node.text = description + related_item_node.append(description_node) + + relation_node = ET.Element(relation_element) + relation_node.set('relationship-type', relationship_type) + relation_node.set('identifier-type', identifier_type) + relation_node.text = identifier + related_item_node.append(relation_node) + + return related_item_node def _transform_original(self, data): raw, xml = data - # first journal_article (main) journal_article_node = xml.find('.//journal_article') - # program - program_node = ET.Element("program") - program_node.set('xmlns', 'http://www.crossref.org/relations.xsd') - original_language = raw.original_language() translated_titles = raw.translated_titles() or {} + program_node = None for lang, doi in raw.doi_and_lang: if lang == original_language: continue - # program/related_item - related_item_node = ET.Element('related_item') - - # program/related_item/description - description_node = ET.Element('description') - description_node.text = translated_titles.get(lang) - related_item_node.append(description_node) - - # program/related_item/intra_work_relation - intra_work_relation_node = ET.Element('intra_work_relation') - intra_work_relation_node.set( - 'relationship-type', 'isTranslationOf') - intra_work_relation_node.set('identifier-type', 'doi') - intra_work_relation_node.text = doi - related_item_node.append(intra_work_relation_node) - - program_node.append(related_item_node) + program_node = self._get_or_create_program(journal_article_node) - # program/related_item (hasPreprint) - for preprint_doi in self._get_preprint_relations(raw): - related_item_node = ET.Element('related_item') + program_node.append(self._create_related_item( + 'intra_work_relation', + 'hasTranslation', + doi, + description=translated_titles.get(lang), + )) - intra_work_relation_node = ET.Element('intra_work_relation') - intra_work_relation_node.set( - 'relationship-type', 'hasPreprint') - intra_work_relation_node.set('identifier-type', 'doi') - intra_work_relation_node.text = preprint_doi - related_item_node.append(intra_work_relation_node) + return data - program_node.append(related_item_node) + def _transform_translations(self, data): + raw, xml = data - journal_article_node.append(program_node) + for journal_article_node in xml.findall('.//journal_article')[1:]: + program_node = self._create_program() + program_node.append(self._create_related_item( + 'intra_work_relation', + 'isTranslationOf', + raw.doi, + description=raw.original_title(), + )) + journal_article_node.append(program_node) return data - def _transform_translations(self, data): + def _transform_related_articles(self, data): raw, xml = data + related_articles = getattr(raw, 'related_documents', None) - # program - program_node = ET.Element("program") - program_node.set('xmlns', 'http://www.crossref.org/relations.xsd') - - # program/related_item - related_item_node = ET.Element('related_item') + if not related_articles: + return data - # program/related_item/description - description_node = ET.Element('description') - description_node.text = raw.original_title() - related_item_node.append(description_node) + current_document_type = getattr(raw, 'document_type', None) - # program/related_item/intra_work_relation - intra_work_relation_node = ET.Element('intra_work_relation') - intra_work_relation_node.set( - 'relationship-type', 'hasTranslation') - intra_work_relation_node.set('identifier-type', 'doi') - intra_work_relation_node.text = raw.doi - related_item_node.append(intra_work_relation_node) + journal_article_node = xml.find('.//journal_article') - program_node.append(related_item_node) + for related_article in related_articles: + relation_data = self._resolve_relation( + related_article, current_document_type) + identifier = related_article.get('id') + if not relation_data or not identifier: + continue - for journal_article_node in xml.findall('.//journal_article')[1:]: - journal_article_node.append(deepcopy(program_node)) + program_node = self._get_or_create_program(journal_article_node) + program_node.append(self._create_related_item( + relation_element=relation_data[0], + relationship_type=relation_data[1], + identifier=identifier, + identifier_type=related_article.get('ext_link_type') or 'doi', + )) return data + class XMLFundingDataPipe(plumber.Pipe): def precond(data): raw, _ = data @@ -1256,7 +1302,7 @@ def create_assertion(name, text): element = ET.Element("{http://www.crossref.org/fundref.xsd}assertion") element.set("name", name) element.text = text - + return element @staticmethod @@ -1290,12 +1336,12 @@ def append_funding_data(self, program, sponsors=None, award_ids=None): @plumber.precondition(precond) def transform(self, data): raw, xml = data - + program = ET.Element( "{http://www.crossref.org/fundref.xsd}program" ) program.set("name", "fundref") - + self.append_funding_data( program=program, sponsors=raw.project_sponsor, diff --git a/requirements.txt b/requirements.txt index 7db6fac..18d0fb3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,7 +21,7 @@ thriftpy2==0.5.0 urllib3==1.26.19 venusian==1.1.0 WebOb==1.8.7 --e git+https://github.com/scieloorg/xylose.git@1.35.13#egg=xylose +-e git+https://github.com/scieloorg/xylose.git@1.35.15#egg=xylose zope.deprecation==4.3.0 zope.interface==6.1 crossrefapi==1.3.0 diff --git a/tests/test_export_crossref.py b/tests/test_export_crossref.py index 67e47ae..d3acbe7 100644 --- a/tests/test_export_crossref.py +++ b/tests/test_export_crossref.py @@ -3,6 +3,7 @@ import json import os import io +from unittest.mock import patch, PropertyMock from lxml import etree as ET @@ -704,6 +705,48 @@ def test_validating_against_schema(self): self.assertTrue(schema.validate(xmlio)) self.assertEqual(None, schema.assertValid(xmlio)) + def test_related_articles_validating_against_schema(self): + related_documents = [ + { + 'id': '10.1590/S2237-96222025v34e20240180.a', + 'related_article_type': 'reviewed-article', + 'ext_link_type': 'doi', + }, + { + 'id': '10.1590/S2237-96222025v34e20240180.b', + 'related_article_type': 'commentary-article', + 'ext_link_type': 'doi', + }, + ] + with patch.object( + Article, + 'related_documents', + new_callable=PropertyMock, + return_value=related_documents): + xml = export.Export(self._raw_json).pipeline_crossref() + + xmlio = ET.parse(io.BytesIO(xml)) + schema_path = ( + os.path.dirname(__file__) + + '/xsd/scielo_crossref/crossref4.4.0.xsd' + ) + with open(schema_path) as fp: + schema = ET.XMLSchema(ET.parse(fp)) + + schema.assertValid(xmlio) + relations = xmlio.findall( + './/{http://www.crossref.org/relations.xsd}' + 'inter_work_relation' + ) + self.assertEqual(2, len(relations)) + self.assertEqual( + ['isReviewOf', 'isCommentOn'], + [ + relation.attrib.get('relationship-type') + for relation in relations + ] + ) + def test_journal_article_should_contain_item_number_with_elocation_id(self): xmlcrossref = ET.Element("doi_batch") publisher_item = ET.Element("publisher_item") @@ -1300,25 +1343,25 @@ def test_related_item_for_multilingue_document(self): "Epidemiological profile of patients on" " renal replacement therapy in Brazil, 2000-2004", 0, - "isTranslationOf", + "hasTranslation", ), ('10.1590/ID.es', "Perfil epidemiológico de los pacientes en terapia" " renal substitutiva en Brasil, 2000-2004", 1, - "isTranslationOf", + "hasTranslation", ), ('10.1590/S0034-89102010000400007', "Perfil epidemiológico dos pacientes em terapia" " renal substitutiva no Brasil, 2000-2004", 2, - "hasTranslation", + "isTranslationOf", ), ('10.1590/S0034-89102010000400007', "Perfil epidemiológico dos pacientes em terapia" " renal substitutiva no Brasil, 2000-2004", 3, - "hasTranslation", + "isTranslationOf", ), ] self.assertEqual( @@ -1342,6 +1385,390 @@ def test_related_item_for_multilingue_document(self): content[3], intra_work_relation.attrib.get('relationship-type')) + + def test_related_item_for_supported_related_articles(self): + xmlcrossref = create_xmlcrossref_with_n_journal_article_element( + ['pt', 'en', 'es']) + related_documents = [ + { + 'id': '10.1590/commentary', + 'related_article_type': 'commentary-article', + 'ext_link_type': 'doi', + }, + { + 'id': '10.1590/reply', + 'related_article_type': 'reply', + 'ext_link_type': 'doi', + }, + { + 'id': '10.1590/reviewed', + 'related_article_type': 'reviewed-article', + 'ext_link_type': 'doi', + }, + { + 'id': '10.1590/peer-reviewed', + 'related_article_type': 'peer-reviewed-material', + 'ext_link_type': 'doi', + }, + { + 'id': '10.1590/reviewer-report', + 'related_article_type': 'reviewer-report', + 'ext_link_type': 'doi', + }, + { + 'id': '10.1590/preprint', + 'related_article_type': 'preprint', + 'ext_link_type': 'doi', + }, + { + 'id': '10.1590/corrected', + 'related_article_type': 'corrected-article', + 'ext_link_type': 'doi', + }, + ] + + data = [self._article, xmlcrossref] + xmlcrossref = export_crossref.XMLProgramRelatedItemPipe() + with patch.object( + Article, + 'related_documents', + new_callable=PropertyMock, + return_value=related_documents): + raw, xml = xmlcrossref.transform(data) + + journal_articles = xml.findall('.//journal_article') + original_program = journal_articles[0].find('program') + related_items = original_program.findall('./related_item') + + self.assertEqual(3, len(xml.findall('.//program'))) + self.assertEqual( + 8, len(related_items)) + + expected_content = [ + ( + '10.1590/commentary', + 'inter_work_relation', + 'isCommentOn', + ), + ( + '10.1590/reply', + 'inter_work_relation', + 'isReplyTo', + ), + ( + '10.1590/reviewed', + 'inter_work_relation', + 'isReviewOf', + ), + ( + '10.1590/peer-reviewed', + 'inter_work_relation', + 'isReviewOf', + ), + ( + '10.1590/reviewer-report', + 'inter_work_relation', + 'hasReview', + ), + ( + '10.1590/preprint', + 'intra_work_relation', + 'hasPreprint', + ), + ] + for related_item, content in zip(related_items[2:], expected_content): + with self.subTest(identifier=content[0]): + self.assertIsNone(related_item.find('description')) + relation = related_item.find(content[1]) + self.assertEqual(content[0], relation.text) + self.assertEqual( + 'doi', relation.attrib.get('identifier-type')) + self.assertEqual( + content[2], + relation.attrib.get('relationship-type')) + + def test_related_item_for_peer_reviewed_material_is_review_of(self): + # peer-reviewed-material aponta para o material revisado → isReviewOf + related_documents = [ + { + 'id': '10.1590/reviewed-material', + 'related_article_type': 'peer-reviewed-material', + 'ext_link_type': 'doi', + }, + ] + xmlcrossref = create_xmlcrossref_with_n_journal_article_element(['pt']) + article = _get_article({ + 'v337': [{ + 'l': 'pt', + 'd': '10.1590/S0034-89102010000400007', + }], + }) + + data = [article, xmlcrossref] + pipe = export_crossref.XMLProgramRelatedItemPipe() + with patch.object( + Article, + 'related_documents', + new_callable=PropertyMock, + return_value=related_documents): + raw, xml = pipe.transform(data) + + relation = xml.find('.//program/related_item/inter_work_relation') + self.assertIsNotNone(relation) + self.assertEqual('10.1590/reviewed-material', relation.text) + self.assertEqual('doi', relation.attrib.get('identifier-type')) + self.assertEqual('isReviewOf', relation.attrib.get('relationship-type')) + self.assertEqual(1, len(xml.findall('.//program/related_item'))) + + def test_related_item_for_reviewer_report_has_review(self): + # reviewer-report é o outro lado da relação de peer review → hasReview + related_documents = [ + { + 'id': '10.1590/reviewer-report', + 'related_article_type': 'reviewer-report', + 'ext_link_type': 'doi', + }, + ] + xmlcrossref = create_xmlcrossref_with_n_journal_article_element(['pt']) + article = _get_article({ + 'v337': [{ + 'l': 'pt', + 'd': '10.1590/S0034-89102010000400007', + }], + }) + + data = [article, xmlcrossref] + pipe = export_crossref.XMLProgramRelatedItemPipe() + with patch.object( + Article, + 'related_documents', + new_callable=PropertyMock, + return_value=related_documents): + raw, xml = pipe.transform(data) + + relation = xml.find('.//program/related_item/inter_work_relation') + self.assertIsNotNone(relation) + self.assertEqual('10.1590/reviewer-report', relation.text) + self.assertEqual('doi', relation.attrib.get('identifier-type')) + self.assertEqual('hasReview', relation.attrib.get('relationship-type')) + self.assertEqual(1, len(xml.findall('.//program/related_item'))) + + def test_related_item_disambiguates_repeated_related_article_types(self): + # "letter" e "commentary" se repetem na especificação SciELO e são + # desambiguados pelo document_type do documento corrente. Somente + # combinações conhecidas são emitidas. + cases = [ + # (código v71 do corrente, related_article_type, relationship-type) + ('co', 'commentary', 'isCommentOn'), + ('ct', 'commentary', 'hasComment'), + ('co', 'letter', 'isCommentOn'), + ] + for type_code, related_article_type, expected in cases: + with self.subTest( + type_code=type_code, + related_article_type=related_article_type): + article = _get_article({'v71': [{'_': type_code}]}) + related_documents = [ + { + 'id': '10.1590/target', + 'related_article_type': related_article_type, + 'ext_link_type': 'doi', + }, + ] + xmlcrossref = create_xmlcrossref_with_n_journal_article_element( + ['pt']) + + data = [article, xmlcrossref] + pipe = export_crossref.XMLProgramRelatedItemPipe() + with patch.object( + Article, + 'related_documents', + new_callable=PropertyMock, + return_value=related_documents): + raw, xml = pipe.transform(data) + + relation = xml.find( + './/program/related_item/inter_work_relation') + self.assertIsNotNone(relation) + self.assertEqual('10.1590/target', relation.text) + self.assertEqual( + expected, relation.attrib.get('relationship-type')) + + def test_related_item_when_main_document_is_reply(self): + # Documento principal do tipo reply + related commentary/letter → + # isReplyTo. xylose não mapeia v71 para "reply", então document_type + # é mockado. + cases = ['commentary', 'letter'] + for related_article_type in cases: + with self.subTest(related_article_type=related_article_type): + article = _get_article({ + 'v337': [{ + 'l': 'pt', + 'd': '10.1590/S0034-89102010000400007', + }], + }) + related_documents = [ + { + 'id': '10.1590/target', + 'related_article_type': related_article_type, + 'ext_link_type': 'doi', + }, + ] + xmlcrossref = create_xmlcrossref_with_n_journal_article_element( + ['pt']) + + data = [article, xmlcrossref] + pipe = export_crossref.XMLProgramRelatedItemPipe() + with patch.object( + Article, + 'document_type', + new_callable=PropertyMock, + return_value='reply'), \ + patch.object( + Article, + 'related_documents', + new_callable=PropertyMock, + return_value=related_documents): + raw, xml = pipe.transform(data) + + relation = xml.find( + './/program/related_item/inter_work_relation') + self.assertIsNotNone(relation) + self.assertEqual('10.1590/target', relation.text) + self.assertEqual( + 'isReplyTo', relation.attrib.get('relationship-type')) + + def test_related_item_ignores_unknown_commentary_letter_combinations(self): + # Combinações de commentary/letter com document_type não documentado + # não geram related_item até haver casos reais. + cases = [ + ('le', 'commentary'), + ('le', 'letter'), + ('ct', 'letter'), + ('ra', 'commentary'), + ('ed', 'letter'), + ] + for type_code, related_article_type in cases: + with self.subTest( + type_code=type_code, + related_article_type=related_article_type): + article = _get_article({ + 'v71': [{'_': type_code}], + 'v337': [{ + 'l': 'pt', + 'd': '10.1590/S0034-89102010000400007', + }], + }) + related_documents = [ + { + 'id': '10.1590/target', + 'related_article_type': related_article_type, + 'ext_link_type': 'doi', + }, + ] + xmlcrossref = create_xmlcrossref_with_n_journal_article_element( + ['pt']) + + data = [article, xmlcrossref] + pipe = export_crossref.XMLProgramRelatedItemPipe() + with patch.object( + Article, + 'related_documents', + new_callable=PropertyMock, + return_value=related_documents): + raw, xml = pipe.transform(data) + + self.assertIsNone( + xml.find('.//program/related_item/inter_work_relation')) + self.assertIsNone(xml.find('.//program')) + + def test_related_item_ignores_unmapped_related_article_types(self): + # Tipos ausentes de RELATED_ARTICLE_TYPE_RELATIONS não geram related_item + # nem um vazio. + related_documents = [ + {'id': '10.1590/a', 'related_article_type': 'corrected-article'}, + {'id': '10.1590/b', 'related_article_type': 'retracted-article'}, + {'id': '10.1590/c', 'related_article_type': 'partial-retraction'}, + {'id': '10.1590/d', 'related_article_type': 'addendum'}, + {'id': '10.1590/e', 'related_article_type': 'expression-of-concern'}, + ] + xmlcrossref = create_xmlcrossref_with_n_journal_article_element(['pt']) + article = _get_article({ + 'v337': [{ + 'l': 'pt', + 'd': '10.1590/S0034-89102010000400007', + }], + }) + + data = [article, xmlcrossref] + pipe = export_crossref.XMLProgramRelatedItemPipe() + with patch.object( + Article, + 'related_documents', + new_callable=PropertyMock, + return_value=related_documents): + raw, xml = pipe.transform(data) + + self.assertEqual( + 0, + len(xml.findall('.//program/related_item/inter_work_relation'))) + self.assertIsNone(xml.find('.//program')) + + def test_related_item_ignores_missing_related_article_type(self): + related_documents = [ + { + 'id': '10.1590/target', + 'ext_link_type': 'doi', + }, + ] + xmlcrossref = create_xmlcrossref_with_n_journal_article_element(['pt']) + article = _get_article({ + 'v337': [{ + 'l': 'pt', + 'd': '10.1590/S0034-89102010000400007', + }], + }) + + data = [article, xmlcrossref] + pipe = export_crossref.XMLProgramRelatedItemPipe() + with patch.object( + Article, + 'related_documents', + new_callable=PropertyMock, + return_value=related_documents): + raw, xml = pipe.transform(data) + + self.assertEqual( + 0, + len(xml.findall('.//program/related_item/inter_work_relation'))) + self.assertIsNone(xml.find('.//program')) + + def test_related_item_without_related_documents(self): + xmlcrossref = create_xmlcrossref_with_n_journal_article_element( + ['pt', 'en', 'es']) + + data = [self._article, xmlcrossref] + xmlcrossref = export_crossref.XMLProgramRelatedItemPipe() + with patch.object( + Article, + 'related_documents', + new_callable=PropertyMock, + return_value=[]): + raw, xml = xmlcrossref.transform(data) + + self.assertEqual( + 0, + len(xml.findall( + './/program/related_item/inter_work_relation' + )) + ) + self.assertEqual( + 4, + len(xml.findall( + './/program/related_item/intra_work_relation' + )) + ) + def test_related_item_includes_has_preprint_relation(self): self._article.data['article']['v241'] = [ { @@ -1405,11 +1832,13 @@ def test_related_item_ignores_non_preprint_related_articles(self): 'n': 'doi', } ] + xmlcrossref = create_xmlcrossref_with_n_journal_article_element( ['pt', 'en', 'es']) data = [self._article, xmlcrossref] xmlcrossref = export_crossref.XMLProgramRelatedItemPipe() + raw, xml = xmlcrossref.transform(data) relation_types = [ @@ -1419,6 +1848,7 @@ def test_related_item_ignores_non_preprint_related_articles(self): ] self.assertNotIn('hasPreprint', relation_types) + def test_collection_for_multilingue_document(self): xmlcrossref = create_xmlcrossref_with_n_journal_article_element( ['pt', 'en', 'es'], 'doi_data') @@ -1954,13 +2384,13 @@ def test_related_item_for_multilingue_document(self): "Perfil epidemiológico de los pacientes en terapia" " renal substitutiva en Brasil, 2000-2004", 0, - "isTranslationOf", + "hasTranslation", ), ('10.1590/S0034-89102010000400007', "Perfil epidemiológico dos pacientes em terapia" " renal substitutiva no Brasil, 2000-2004", 1, - "hasTranslation", + "isTranslationOf", ), ] self.assertEqual(