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 }}