diff --git a/requirements.txt b/requirements.txt
index b7addd541b..6c1bc396ed 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..36d284bbdb 100755
--- a/src/identifiers/logic.py
+++ b/src/identifiers/logic.py
@@ -425,6 +425,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/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]
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 38b98c96d2..a1aa410fda 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,
)
@@ -2630,6 +2631,26 @@ 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
+ 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"
@@ -3400,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=_("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
diff --git a/src/templates/common/identifiers/crossref_article.xml b/src/templates/common/identifiers/crossref_article.xml
index 9c4b073264..6bc04c9345 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 %}
+
+ {{ article.object.journal|setting:'crossref_prefix' }}/not-used
+
+ {{ 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 }}