From a431b3bcb3bb85b75683780e34f7187c44a05231 Mon Sep 17 00:00:00 2001 From: Matteo Gamboz Date: Wed, 25 Mar 2026 10:39:00 +0100 Subject: [PATCH 01/10] add crossmark section to crossref xml deposit for errata --- src/identifiers/logic.py | 1 + src/submission/models.py | 24 ++++++++++++++++ .../common/identifiers/crossref_article.xml | 28 +++++++++++++++++-- 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/identifiers/logic.py b/src/identifiers/logic.py index 6a2d18f3d2..a3b7de7e5e 100755 --- a/src/identifiers/logic.py +++ b/src/identifiers/logic.py @@ -427,6 +427,7 @@ def create_crossref_article_context(article, identifier=None): "other_pages": article.page_numbers, "scheduled": article.scheduled_for_publication, "object": article, + "erratum_of": article.erratum_of(), } # append citations for i4oc compatibility diff --git a/src/submission/models.py b/src/submission/models.py index 2b3a281e83..9351befcd8 100755 --- a/src/submission/models.py +++ b/src/submission/models.py @@ -2617,6 +2617,30 @@ def best_large_image_alt_text(self): ) return default_text + def erratum_of(self): + """ + Return the "parent" article for which this article is an erratum. + + This is intended to be used in templates/common/identifiers/crossref_article.xml + """ + if self.section.name != "Erratum": + return None + + # Most articles do not have a "Genealogy" + if not hasattr(self, "ancestors"): + return None + + if not self.ancestors.exists(): + return None + + # We can safely assume that an erratum refers to only one other paper + # so we just return the first "ancestor". + # + # Also, there is no need to check if the "parent" was published: + # the business logic should ensure that we cannot publish an erratum + # to a non-published paper. + return self.ancestors.first().parent + class FrozenAuthorQueryset(model_utils.AffiliationCompatibleQueryset): AFFILIATION_RELATED_NAME = "frozen_author" diff --git a/src/templates/common/identifiers/crossref_article.xml b/src/templates/common/identifiers/crossref_article.xml index 9c4b073264..da74a92cae 100755 --- a/src/templates/common/identifiers/crossref_article.xml +++ b/src/templates/common/identifiers/crossref_article.xml @@ -54,6 +54,31 @@ {{ article.object.pk }} + {% if article.erratum_of %} + + {{ crossmark_policy_doi }} + + {{ article.erratum_of.get_doi }} + + {% if article.object.funders.exists %} + + + {% for funder in article.object.funders.all %} + + {{ funder.name }} + {% if funder.fundref_id %} + {{ funder.fundref_id }} + {% endif %} + {% if funder.funding_id %} + {{ funder.funding_id }} + {% endif %} + + {% endfor %} + + + {% endif %} + + {% else %} {% if article.object.funders.exists %} {% for funder in article.object.funders.all %} @@ -69,8 +94,7 @@ {% endfor %} {% endif %} - - + {% endif %} {{ article.doi }} From d6928a4a02012d69640382edba75de24d8b7344c Mon Sep 17 00:00:00 2001 From: Matteo Gamboz Date: Wed, 25 Mar 2026 15:50:55 +0100 Subject: [PATCH 02/10] adapt janeway tests --- src/identifiers/tests/test_logic.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/identifiers/tests/test_logic.py b/src/identifiers/tests/test_logic.py index 8155b7948e..ec7261d9e3 100644 --- a/src/identifiers/tests/test_logic.py +++ b/src/identifiers/tests/test_logic.py @@ -241,6 +241,7 @@ def test_create_crossref_article_context_published(self): "date_accepted": None, "date_published": self.article_published.date_published, "doi": f"10.0000/TST.{self.article_published.id}", + "erratum_of": None, "id": self.article_published.id, "license": "", "object": self.article_published, @@ -267,6 +268,7 @@ def test_create_crossref_article_context_not_published(self): "date_accepted": None, "date_published": None, "doi": self.doi_one.identifier, + "erratum_of": None, "id": self.article_one.id, "license": submission_models.Licence.objects.filter( journal=self.journal_one, From 9c6025bff77eeeab4c13b16344f7e699075d1290 Mon Sep 17 00:00:00 2001 From: Matteo Gamboz Date: Mon, 30 Mar 2026 16:22:56 +0200 Subject: [PATCH 03/10] Add Genealogy --- requirements.txt | 1 + src/submission/migrations/0090_genealogy.py | 46 +++++++++++++++++++++ src/submission/models.py | 31 +++++++++++--- 3 files changed, 72 insertions(+), 6 deletions(-) create mode 100644 src/submission/migrations/0090_genealogy.py diff --git a/requirements.txt b/requirements.txt index 5a96ad1154..aafb4227e8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -59,6 +59,7 @@ python-magic==0.4.27 pytz==2024.1 requests==2.32.4 six==1.16.0 +django-sortedm2m~=3.1 sqlparse==0.4.4 swapper==1.3.0 tqdm==4.66.3 diff --git a/src/submission/migrations/0090_genealogy.py b/src/submission/migrations/0090_genealogy.py new file mode 100644 index 0000000000..430fa83665 --- /dev/null +++ b/src/submission/migrations/0090_genealogy.py @@ -0,0 +1,46 @@ +# Generated by Django 4.2.29 on 2026-04-27 11:51 + +from django.db import migrations, models +import django.db.models.deletion +import sortedm2m.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ("submission", "0089_merge_20260226_1524"), + ] + + operations = [ + migrations.CreateModel( + name="Genealogy", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "children", + sortedm2m.fields.SortedManyToManyField( + help_text=None, + related_name="ancestors", + to="submission.article", + ), + ), + ( + "parent", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="genealogy", + to="submission.article", + verbose_name="Original or main paper", + ), + ), + ], + ), + ] diff --git a/src/submission/models.py b/src/submission/models.py index 9351befcd8..08860392d0 100755 --- a/src/submission/models.py +++ b/src/submission/models.py @@ -64,6 +64,7 @@ from utils.orcid import validate_orcid, COMPILED_ORCID_REGEX from utils.forms import plain_text_validator from journal import models as journal_models +from sortedm2m.fields import SortedManyToManyField from review.const import ( ReviewerDecisions as RD, ) @@ -2621,15 +2622,11 @@ def erratum_of(self): """ Return the "parent" article for which this article is an erratum. - This is intended to be used in templates/common/identifiers/crossref_article.xml + This is intended to be used in + templates/common/identifiers/crossref_article.xml """ if self.section.name != "Erratum": return None - - # Most articles do not have a "Genealogy" - if not hasattr(self, "ancestors"): - return None - if not self.ancestors.exists(): return None @@ -3411,6 +3408,28 @@ def handle_defaults(self, article): article.save() +class Genealogy(models.Model): + """ + Maintain relations of type parent/children between articles. + + This can be used, for instance, to link erratum to the original paper. + """ + + parent = models.OneToOneField( + Article, + verbose_name=_("Original or main paper"), + on_delete=models.CASCADE, + related_name="genealogy", + ) + children = SortedManyToManyField( + Article, + related_name="ancestors", + ) + + def __str__(self): + return f"Genealogy: {self.parent} has {self.children.count()} kids" + + # Signals From b9dc017a9f4172b248e780a9dd92ac5f36fed1ad Mon Sep 17 00:00:00 2001 From: Matteo Gamboz Date: Mon, 27 Apr 2026 13:48:12 +0200 Subject: [PATCH 04/10] add admin for Genealogy --- src/submission/admin.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/submission/admin.py b/src/submission/admin.py index 53eb3b3150..e404fe2454 100755 --- a/src/submission/admin.py +++ b/src/submission/admin.py @@ -260,6 +260,21 @@ def _answer(self, obj): return truncatewords_html(obj.answer, 10) if obj else "" +class GenealogyAdmin(admin.ModelAdmin): + list_display = ("pk", "parent_id", "_parent_title") + list_filter = ("parent__journal",) + search_fields = ( + "parent__pk", + "parent__title", + "children__pk", + "children__title", + ) + raw_id_fields = ("parent", "children") + + def _parent_title(self, obj): + return truncatewords_html(obj.parent.title, 10) + + class SubmissionConfigAdmin(admin.ModelAdmin): list_display = ( "pk", @@ -295,6 +310,7 @@ class SubmissionConfigAdmin(admin.ModelAdmin): (models.Keyword, KeywordAdmin), (models.SubmissionConfiguration, SubmissionConfigAdmin), (models.CreditRecord, CreditRecordAdmin), + (models.Genealogy, GenealogyAdmin), ] [admin.site.register(*t) for t in admin_list] From cc4c0bbf07723520131c004da0d5c8b38737c9ad Mon Sep 17 00:00:00 2001 From: Matteo Gamboz Date: Mon, 27 Apr 2026 14:52:54 +0200 Subject: [PATCH 05/10] setting "fake" crossmark policy doi from crossref support staff: > [...] > You can't totally omit the element. For historical reasons, it's strictly required by the schema. But, as a workaround, you can just repeat the DOI of the original paper in that field. It's not used for anything, but it has to have a valid DOI in it. --- src/templates/common/identifiers/crossref_article.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/templates/common/identifiers/crossref_article.xml b/src/templates/common/identifiers/crossref_article.xml index da74a92cae..6bc04c9345 100755 --- a/src/templates/common/identifiers/crossref_article.xml +++ b/src/templates/common/identifiers/crossref_article.xml @@ -56,7 +56,7 @@ {% if article.erratum_of %} - {{ crossmark_policy_doi }} + {{ article.object.journal|setting:'crossref_prefix' }}/not-used {{ article.erratum_of.get_doi }} From b249704c1b8e569c2538751aa8aafba872075cf5 Mon Sep 17 00:00:00 2001 From: Matteo Gamboz Date: Mon, 22 Jun 2026 16:36:31 +0200 Subject: [PATCH 06/10] Revert "add admin for Genealogy" This reverts commit 26e425f3421e04b16ddd4b0f3f85920003ca135b. --- src/submission/admin.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/src/submission/admin.py b/src/submission/admin.py index e404fe2454..53eb3b3150 100755 --- a/src/submission/admin.py +++ b/src/submission/admin.py @@ -260,21 +260,6 @@ def _answer(self, obj): return truncatewords_html(obj.answer, 10) if obj else "" -class GenealogyAdmin(admin.ModelAdmin): - list_display = ("pk", "parent_id", "_parent_title") - list_filter = ("parent__journal",) - search_fields = ( - "parent__pk", - "parent__title", - "children__pk", - "children__title", - ) - raw_id_fields = ("parent", "children") - - def _parent_title(self, obj): - return truncatewords_html(obj.parent.title, 10) - - class SubmissionConfigAdmin(admin.ModelAdmin): list_display = ( "pk", @@ -310,7 +295,6 @@ class SubmissionConfigAdmin(admin.ModelAdmin): (models.Keyword, KeywordAdmin), (models.SubmissionConfiguration, SubmissionConfigAdmin), (models.CreditRecord, CreditRecordAdmin), - (models.Genealogy, GenealogyAdmin), ] [admin.site.register(*t) for t in admin_list] From 7e4db3e85e333874d093373f134e37ac76e3858c Mon Sep 17 00:00:00 2001 From: Matteo Gamboz Date: Mon, 22 Jun 2026 16:36:56 +0200 Subject: [PATCH 07/10] Revert "Add Genealogy" This reverts commit a925e86a66cf62e5a6005d029edda7c77aef1929. --- requirements.txt | 1 - src/submission/migrations/0090_genealogy.py | 46 --------------------- src/submission/models.py | 31 +++----------- 3 files changed, 6 insertions(+), 72 deletions(-) delete mode 100644 src/submission/migrations/0090_genealogy.py diff --git a/requirements.txt b/requirements.txt index aafb4227e8..5a96ad1154 100644 --- a/requirements.txt +++ b/requirements.txt @@ -59,7 +59,6 @@ python-magic==0.4.27 pytz==2024.1 requests==2.32.4 six==1.16.0 -django-sortedm2m~=3.1 sqlparse==0.4.4 swapper==1.3.0 tqdm==4.66.3 diff --git a/src/submission/migrations/0090_genealogy.py b/src/submission/migrations/0090_genealogy.py deleted file mode 100644 index 430fa83665..0000000000 --- a/src/submission/migrations/0090_genealogy.py +++ /dev/null @@ -1,46 +0,0 @@ -# Generated by Django 4.2.29 on 2026-04-27 11:51 - -from django.db import migrations, models -import django.db.models.deletion -import sortedm2m.fields - - -class Migration(migrations.Migration): - - dependencies = [ - ("submission", "0089_merge_20260226_1524"), - ] - - operations = [ - migrations.CreateModel( - name="Genealogy", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "children", - sortedm2m.fields.SortedManyToManyField( - help_text=None, - related_name="ancestors", - to="submission.article", - ), - ), - ( - "parent", - models.OneToOneField( - on_delete=django.db.models.deletion.CASCADE, - related_name="genealogy", - to="submission.article", - verbose_name="Original or main paper", - ), - ), - ], - ), - ] diff --git a/src/submission/models.py b/src/submission/models.py index 08860392d0..9351befcd8 100755 --- a/src/submission/models.py +++ b/src/submission/models.py @@ -64,7 +64,6 @@ from utils.orcid import validate_orcid, COMPILED_ORCID_REGEX from utils.forms import plain_text_validator from journal import models as journal_models -from sortedm2m.fields import SortedManyToManyField from review.const import ( ReviewerDecisions as RD, ) @@ -2622,11 +2621,15 @@ def erratum_of(self): """ Return the "parent" article for which this article is an erratum. - This is intended to be used in - templates/common/identifiers/crossref_article.xml + This is intended to be used in templates/common/identifiers/crossref_article.xml """ if self.section.name != "Erratum": return None + + # Most articles do not have a "Genealogy" + if not hasattr(self, "ancestors"): + return None + if not self.ancestors.exists(): return None @@ -3408,28 +3411,6 @@ def handle_defaults(self, article): article.save() -class Genealogy(models.Model): - """ - Maintain relations of type parent/children between articles. - - This can be used, for instance, to link erratum to the original paper. - """ - - parent = models.OneToOneField( - Article, - verbose_name=_("Original or main paper"), - on_delete=models.CASCADE, - related_name="genealogy", - ) - children = SortedManyToManyField( - Article, - related_name="ancestors", - ) - - def __str__(self): - return f"Genealogy: {self.parent} has {self.children.count()} kids" - - # Signals From bf7bab90534382d99213a1de97534a2259488060 Mon Sep 17 00:00:00 2001 From: Matteo Gamboz Date: Mon, 22 Jun 2026 16:37:36 +0200 Subject: [PATCH 08/10] Revert "adapt janeway tests" This reverts commit 1a9883b43b3c612120ab513268ba7f9ca5d3d161. --- src/identifiers/tests/test_logic.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/identifiers/tests/test_logic.py b/src/identifiers/tests/test_logic.py index ec7261d9e3..8155b7948e 100644 --- a/src/identifiers/tests/test_logic.py +++ b/src/identifiers/tests/test_logic.py @@ -241,7 +241,6 @@ def test_create_crossref_article_context_published(self): "date_accepted": None, "date_published": self.article_published.date_published, "doi": f"10.0000/TST.{self.article_published.id}", - "erratum_of": None, "id": self.article_published.id, "license": "", "object": self.article_published, @@ -268,7 +267,6 @@ def test_create_crossref_article_context_not_published(self): "date_accepted": None, "date_published": None, "doi": self.doi_one.identifier, - "erratum_of": None, "id": self.article_one.id, "license": submission_models.Licence.objects.filter( journal=self.journal_one, From 2d5a07952abe7d075f9ca8954f46cb97ce4cadc3 Mon Sep 17 00:00:00 2001 From: Matteo Gamboz Date: Mon, 22 Jun 2026 18:16:50 +0200 Subject: [PATCH 09/10] rever changes to identifiers.logic (we will use article.object) --- src/identifiers/logic.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/identifiers/logic.py b/src/identifiers/logic.py index a3b7de7e5e..6a2d18f3d2 100755 --- a/src/identifiers/logic.py +++ b/src/identifiers/logic.py @@ -427,7 +427,6 @@ def create_crossref_article_context(article, identifier=None): "other_pages": article.page_numbers, "scheduled": article.scheduled_for_publication, "object": article, - "erratum_of": article.erratum_of(), } # append citations for i4oc compatibility From e64c22ee357d3eba0eecd6e3563515e43e012174 Mon Sep 17 00:00:00 2001 From: Matteo Gamboz Date: Mon, 22 Jun 2026 17:14:51 +0200 Subject: [PATCH 10/10] switch to Hydra and generalize --- src/submission/models.py | 87 +++++++++++++++---- .../common/identifiers/crossref_article.xml | 4 +- 2 files changed, 73 insertions(+), 18 deletions(-) diff --git a/src/submission/models.py b/src/submission/models.py index 9351befcd8..c28586c89e 100755 --- a/src/submission/models.py +++ b/src/submission/models.py @@ -33,6 +33,7 @@ from django.template import Context, Template from django.template.loader import render_to_string from django.templatetags.static import static +from django.db.models import QuerySet from django.db.models.signals import pre_delete, m2m_changed from django.dispatch import receiver from django.core import exceptions @@ -2617,29 +2618,83 @@ def best_large_image_alt_text(self): ) return default_text - def erratum_of(self): + def ancestors(self, link_type: str) -> QuerySet["Article"]: """ - Return the "parent" article for which this article is an erratum. + Return articles related to self, where self is the "to-article". - This is intended to be used in templates/common/identifiers/crossref_article.xml + This can be used, for instance, to refer to corrections in self's landing page. """ - if self.section.name != "Erratum": - return None + return self._related(link_type=link_type, direction="ancestors") - # Most articles do not have a "Genealogy" - if not hasattr(self, "ancestors"): - return None + def descendants(self, link_type: str) -> QuerySet["Article"]: + """ + Return articles related to self, where self is the "from-article". + + This can be used, for instance, to refer to corrections in self's landing page. + """ + return self._related(link_type=link_type, direction="descendants") + + def _related(self, link_type: str, direction: str) -> QuerySet["Article"]: + """ + Return articles related to self. + + Direction: + - descendants -> where self is the "from-article" + - ancenstors -> where self. is the "to-article" + """ + if not link_type: + return Article.objects.none() + + # Silently return nothing if Hydra plugin is not available + try: + from plugins.hydra.models import LinkedArticle # noqa: F401 + except ImportError: + return Article.objects.none() + + if direction == "descendants": + # self is the "from-article"; return the "to-articles" + return Article.objects.filter( + linked_to__from_article=self, linked_to__relationship=link_type + ) + if direction == "ancestors": + # self is the "to-article"; return the "from-articles" + return Article.objects.filter( + linked_from__to_article=self, linked_from__relationship=link_type + ) + + raise ValueError( + f"Unknown relationship direction '{direction}' requested for {self.id}" + ) + + def update_of(self): + """ + Return the LinkedArticle relation whose "from-article" is the + "parent" this article is an "update" of. + + We consider only a well-known list of relations / link-types. + Only the first relation is returned; the idea is that, if, for instance, + self is an erratum of X, then it cannot be an addendum of Y at the same time. - if not self.ancestors.exists(): + This can be used, for instance, to refer to errata in + templates/common/identifiers/crossref_article.xml + """ + # Silently return nothing if Hydra plugin is not available + try: + from plugins.hydra.models import CROSSREF_UPDATES, LinkedArticle + except ImportError: return None - # We can safely assume that an erratum refers to only one other paper - # so we just return the first "ancestor". - # - # Also, there is no need to check if the "parent" was published: - # the business logic should ensure that we cannot publish an erratum - # to a non-published paper. - return self.ancestors.first().parent + relations = LinkedArticle.objects.filter( + to_article=self, + relationship__in=CROSSREF_UPDATES, + ) + if relations.count() > 1: + # If this happens, we prefer to continue the process, but someone should check what's happening + logger.error( + f"Article {self.pk} has multiple personalities: {[(r.from_article.id, r.relationship) for r in relations]}" + ) + + return relations.first() class FrozenAuthorQueryset(model_utils.AffiliationCompatibleQueryset): diff --git a/src/templates/common/identifiers/crossref_article.xml b/src/templates/common/identifiers/crossref_article.xml index 6bc04c9345..2286e36b64 100755 --- a/src/templates/common/identifiers/crossref_article.xml +++ b/src/templates/common/identifiers/crossref_article.xml @@ -54,11 +54,11 @@ {{ article.object.pk }} - {% if article.erratum_of %} + {% if article.object.update_of %} {{ article.object.journal|setting:'crossref_prefix' }}/not-used - {{ article.erratum_of.get_doi }} + {{ article.object.update_of.from_article.get_doi }} {% if article.object.funders.exists %}