Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ python-magic==0.4.27
pytz==2024.1
requests==2.32.4
six==1.16.0
django-sortedm2m~=3.1
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we go with the approach I proposed about registering a model that behaves as a linking table, we could also use our M2MOrderedThroughField. It would avoid the additional dependency and also maintain consistency with other ordered many to many relationships in the codebase.

sqlparse==0.4.4
swapper==1.3.0
tqdm==4.66.3
Expand Down
1 change: 1 addition & 0 deletions src/identifiers/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/identifiers/tests/test_logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
16 changes: 16 additions & 0 deletions src/submission/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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]
46 changes: 46 additions & 0 deletions src/submission/migrations/0090_genealogy.py
Original file line number Diff line number Diff line change
@@ -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",
),
),
],
),
]
43 changes: 43 additions & 0 deletions src/submission/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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
Comment on lines +2641 to +2644
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While it makes sense on the context of this PR, section.name is a customizable (and translatable) entry. Since we are adding a new model to register the relationships between articles, it would make sense to codify this (and other relationships) as part of that model, rather than relying on the indirection of the section name.


# 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"
Expand Down Expand Up @@ -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"


Comment on lines +3424 to +3445
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the community are asking for a few more relationship types such as addendum, correction and so on. We also have a model on the hydra plugin for registering relationships such as translations.

Would it work for your use case if we were to port the LinkedArticle model from Hydra plugin instead?

It behaves as a linking table, so there is one less join required when querying both sides of the relationship.

# Signals


Expand Down
28 changes: 26 additions & 2 deletions src/templates/common/identifiers/crossref_article.xml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,31 @@
<item_number item_number_type="article_number">{{ article.object.pk }}</item_number>
</publisher_item>

{% if article.erratum_of %}
<crossmark>
<crossmark_policy>{{ article.object.journal|setting:'crossref_prefix' }}/not-used</crossmark_policy>
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ according to XML specs, when recording an <update>, then a <crossmark_policy> DOI should also be provided, but, according to crossref support, that element is not used and any DOI would do. They suggested to use the article DOI, but I'm hardcoding a 10.11111/no-used fake DOI (I feel it's less confusing...)

<updates>
<update type="erratum" date="{{ now|date:"Y-m-d" }}">{{ article.erratum_of.get_doi }}</update>
</updates>
{% if article.object.funders.exists %}
<custom_metadata>
<fr:program name="fundref">
{% for funder in article.object.funders.all %}
<fr:assertion name="fundgroup">
<fr:assertion name="funder_name">{{ funder.name }}</fr:assertion>
{% if funder.fundref_id %}
<fr:assertion name="funder_identifier">{{ funder.fundref_id }}</fr:assertion>
{% endif %}
{% if funder.funding_id %}
<fr:assertion name="award_number">{{ funder.funding_id }}</fr:assertion>
{% endif %}
</fr:assertion>
{% endfor %}
</fr:program>
</custom_metadata>
{% endif %}
</crossmark>
{% else %}
{% if article.object.funders.exists %}
<fr:program name="fundref">
{% for funder in article.object.funders.all %}
Expand All @@ -69,8 +94,7 @@
{% endfor %}
</fr:program>
{% endif %}


{% endif %}

<doi_data>
<doi>{{ article.doi }}</doi>
Expand Down
Loading