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" + ] } ]