diff --git a/requirements.txt b/requirements.txt
index e26c173b3a..3261dcf172 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/identifiers/logic.py b/src/identifiers/logic.py
index 9b12051b3b..e2df8d0928 100755
--- a/src/identifiers/logic.py
+++ b/src/identifiers/logic.py
@@ -399,6 +399,12 @@ def create_crossref_journal_context(
if journal.doi:
journal_data["doi"] = journal.doi
journal_data["url"] = journal.site_url()
+ if crossmark_policy_doi := setting_handler.get_setting(
+ "Identifiers",
+ "crossref_crossmark_policy_doi",
+ journal,
+ ).processed_value:
+ journal_data["crossmark_policy_doi"] = crossmark_policy_doi
return journal_data
@@ -425,6 +431,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/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,
diff --git a/src/submission/migrations/0090_genealogy.py b/src/submission/migrations/0090_genealogy.py
new file mode 100644
index 0000000000..452c3a253a
--- /dev/null
+++ b/src/submission/migrations/0090_genealogy.py
@@ -0,0 +1,45 @@
+# Generated by Django 4.2.29 on 2026-03-30 15:05
+
+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="Introduction",
+ ),
+ ),
+ ],
+ ),
+ ]
diff --git a/src/submission/models.py b/src/submission/models.py
index 1a749fcaf2..ea2cb72363 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,
)
@@ -2626,6 +2627,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"
@@ -3396,6 +3421,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=_("Introduction"),
+ 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
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 }}
diff --git a/src/utils/install/journal_defaults.json b/src/utils/install/journal_defaults.json
index ee1653bb10..1150b476b0 100644
--- a/src/utils/install/journal_defaults.json
+++ b/src/utils/install/journal_defaults.json
@@ -5672,5 +5672,24 @@
"editor",
"journal-manager"
]
+ },
+ {
+ "group": {
+ "name": "Identifiers"
+ },
+ "setting": {
+ "description": "A DOI which points to a publisher's CrossMark policy document.",
+ "is_translatable": false,
+ "name": "crossref_crossmark_policy_doi",
+ "pretty_name": "Crossmark policy",
+ "type": "char"
+ },
+ "value": {
+ "default": ""
+ },
+ "editable_by": [
+ "editor",
+ "journal-manager"
+ ]
}
]