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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -279,3 +279,4 @@ core/media/
!.envs/.local/
!.envs/.production/
src/
test.db
54 changes: 54 additions & 0 deletions article/templates/modeladmin/article/article/inspect.html
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,58 @@ <h3>{% trans 'Available packages' %}</h3>
</table>
</div>

<div class="nice-padding">
<h3>{% trans "Crossref DOI Deposit" %}</h3>
{% if has_crossref_config %}
<p>
<a href="{% url 'doi:deposit_article_doi' %}?article_id={{ article_id }}"
class="button bicolor button--icon">
<span class="icon-wrapper">
<svg class="icon icon-upload icon" aria-hidden="true" focusable="false">
<use href="#icon-upload"></use>
</svg>
</span>
{% trans "Deposit DOI to Crossref" %}
</a>
<a href="{% url 'doi:deposit_article_doi' %}?article_id={{ article_id }}&force=true"
class="button button-secondary bicolor button--icon">
<span class="icon-wrapper">
<svg class="icon icon-upload icon" aria-hidden="true" focusable="false">
<use href="#icon-upload"></use>
</svg>
</span>
{% trans "Re-deposit DOI to Crossref" %}
</a>
Comment on lines +73 to +90
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

The new admin buttons trigger deposits via a plain GET link. Once the view is changed to require POST + CSRF (recommended), update this UI to use a <form method="post"> with {% csrf_token %} (and include force as a hidden field for re-deposit) so the action remains usable from the inspect page.

Suggested change
<a href="{% url 'doi:deposit_article_doi' %}?article_id={{ article_id }}"
class="button bicolor button--icon">
<span class="icon-wrapper">
<svg class="icon icon-upload icon" aria-hidden="true" focusable="false">
<use href="#icon-upload"></use>
</svg>
</span>
{% trans "Deposit DOI to Crossref" %}
</a>
<a href="{% url 'doi:deposit_article_doi' %}?article_id={{ article_id }}&force=true"
class="button button-secondary bicolor button--icon">
<span class="icon-wrapper">
<svg class="icon icon-upload icon" aria-hidden="true" focusable="false">
<use href="#icon-upload"></use>
</svg>
</span>
{% trans "Re-deposit DOI to Crossref" %}
</a>
<form method="post" action="{% url 'doi:deposit_article_doi' %}" style="display: inline;">
{% csrf_token %}
<input type="hidden" name="article_id" value="{{ article_id }}">
<button type="submit" class="button bicolor button--icon">
<span class="icon-wrapper">
<svg class="icon icon-upload icon" aria-hidden="true" focusable="false">
<use href="#icon-upload"></use>
</svg>
</span>
{% trans "Deposit DOI to Crossref" %}
</button>
</form>
<form method="post" action="{% url 'doi:deposit_article_doi' %}" style="display: inline;">
{% csrf_token %}
<input type="hidden" name="article_id" value="{{ article_id }}">
<input type="hidden" name="force" value="true">
<button type="submit" class="button button-secondary bicolor button--icon">
<span class="icon-wrapper">
<svg class="icon icon-upload icon" aria-hidden="true" focusable="false">
<use href="#icon-upload"></use>
</svg>
</span>
{% trans "Re-deposit DOI to Crossref" %}
</button>
</form>

Copilot uses AI. Check for mistakes.
</p>
{% else %}
<p class="help-block help-warning">
{% trans "No Crossref configuration found for this journal. Please configure Crossref settings before depositing." %}
</p>
{% endif %}

{% if crossref_deposits %}
<h4>{% trans "Recent deposits" %}</h4>
<table class="listing">
<thead>
<tr>
<th>{% trans 'Date' %}</th>
<th>{% trans 'Status' %}</th>
<th>{% trans 'Batch ID' %}</th>
<th>{% trans 'HTTP Status' %}</th>
</tr>
</thead>
<tbody>
{% for deposit in crossref_deposits %}
<tr>
<td>{{ deposit.updated }}</td>
<td>{{ deposit.get_status_display }}</td>
<td>{{ deposit.batch_id|default:"-" }}</td>
<td>{{ deposit.response_status|default:"-" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>

{% endblock %}
17 changes: 17 additions & 0 deletions article/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,23 @@ def get_context_data(self):
for rac in self.instance.requestarticlechange_set.all():
data["requested_changes"].append(rac)

try:
from doi.models import CrossrefDeposit, CrossrefConfiguration

data["crossref_deposits"] = list(
CrossrefDeposit.objects.filter(article=self.instance).order_by(
"-updated"
)[:5]
)
data["has_crossref_config"] = CrossrefConfiguration.objects.filter(
journal=self.instance.journal
).exists()
except Exception:
data["crossref_deposits"] = []
data["has_crossref_config"] = False

data["article_id"] = self.instance.id

return super().get_context_data(**data)


Expand Down
1 change: 1 addition & 0 deletions config/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
# API V1 endpoint to custom models
path("api/v1/", include("config.api_router")),
# Your stuff: custom urls includes go here
path("doi/", include("doi.urls", namespace="doi")),
# For anything not caught by a more specific rule above, hand over to
# Wagtail’s page serving mechanism. This should be the last pattern in
# the list:
Expand Down
214 changes: 214 additions & 0 deletions doi/controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
"""
Controller for Crossref DOI deposit operations.
"""

import logging
import sys

import requests
from lxml import etree
from packtools.sps.formats import crossref as crossref_format
from packtools.sps.pid_provider.xml_sps_lib import XMLWithPre

from tracker.models import UnexpectedEvent

Comment on lines +9 to +14
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

There are multiple unused imports in this module (etree, XMLWithPre, UnexpectedEvent). Please remove them to avoid lint issues and keep dependencies minimal.

Suggested change
from lxml import etree
from packtools.sps.formats import crossref as crossref_format
from packtools.sps.pid_provider.xml_sps_lib import XMLWithPre
from tracker.models import UnexpectedEvent
from packtools.sps.formats import crossref as crossref_format

Copilot uses AI. Check for mistakes.
logger = logging.getLogger(__name__)

CROSSREF_DEPOSIT_URL = "https://doi.crossref.org/servlet/deposit"


class CrossrefDepositError(Exception):
pass


class CrossrefConfigurationNotFoundError(Exception):
pass


def get_crossref_xml(sps_pkg, crossref_config):
"""
Gera o XML no formato Crossref a partir de um SPSPkg.

Parameters
----------
sps_pkg : SPSPkg
O pacote SPS do artigo.
crossref_config : CrossrefConfiguration
A configuração Crossref do periódico.

Returns
-------
str
O XML gerado no formato Crossref como string.

Raises
------
CrossrefDepositError
Se não for possível gerar o XML.
"""
try:
xml_with_pre = sps_pkg.xml_with_pre
if xml_with_pre is None:
raise CrossrefDepositError(
f"Could not get XML from package {sps_pkg}"
)

xml_tree = xml_with_pre.xmltree

data = {
"depositor_name": crossref_config.depositor_name,
"depositor_email_address": crossref_config.depositor_email,
"registrant": crossref_config.registrant,
}

if crossref_config.crossmark_policy_doi:
data["crossmark_policy_doi"] = crossref_config.crossmark_policy_doi

if crossref_config.crossmark_policy_url:
data["crossmark_policy_url"] = crossref_config.crossmark_policy_url

xml_crossref_str = crossref_format.pipeline_crossref(xml_tree, data)
return xml_crossref_str

except CrossrefDepositError:
raise
except Exception as e:
exc_type, exc_value, exc_traceback = sys.exc_info()
raise CrossrefDepositError(
f"Failed to generate Crossref XML for {sps_pkg}: {e}"
) from e


def deposit_xml_to_crossref(xml_content, crossref_config):
"""
Realiza o depósito do XML no sistema do Crossref via HTTP.

Parameters
----------
xml_content : str
O conteúdo do XML Crossref a ser depositado.
crossref_config : CrossrefConfiguration
A configuração Crossref do periódico (inclui credenciais).

Returns
-------
tuple
(status_code: int, response_body: str)

Raises
------
CrossrefDepositError
Se não houver credenciais configuradas ou ocorrer erro de rede.
"""
if not crossref_config.login_id or not crossref_config.login_password:
raise CrossrefDepositError(
f"Crossref login credentials are not configured for {crossref_config.journal}"
)

try:
filename = f"crossref_{crossref_config.journal.journal_acron}.xml"
files = {
"fname": (
filename,
xml_content.encode("utf-8") if isinstance(xml_content, str) else xml_content,
"text/xml",
)
}
data = {
"operation": "doMDUpload",
"login_id": crossref_config.login_id,
"login_passwd": crossref_config.login_password,
}

response = requests.post(
CROSSREF_DEPOSIT_URL,
data=data,
files=files,
timeout=60,
)
return response.status_code, response.text

except requests.RequestException as e:
raise CrossrefDepositError(
f"Network error during Crossref deposit: {e}"
) from e


def deposit_article_doi(user, article, force=False):
"""
Deposita o DOI de um artigo no Crossref.

Parameters
----------
user : User
O usuário que está realizando o depósito.
article : Article
O artigo cujo DOI será depositado.
force : bool
Se True, realiza o depósito mesmo que já tenha sido feito com sucesso.

Returns
-------
CrossrefDeposit
O registro de depósito criado/atualizado.

Raises
------
CrossrefConfigurationNotFoundError
Se não houver configuração Crossref para o periódico do artigo.
CrossrefDepositError
Se ocorrer erro durante o processo de depósito.
"""
from doi.models import CrossrefConfiguration, CrossrefDeposit, CrossrefDepositStatus

if not article.journal:
raise CrossrefDepositError(
f"Article {article} has no associated journal"
)

try:
crossref_config = CrossrefConfiguration.get(journal=article.journal)
except CrossrefConfiguration.DoesNotExist:
raise CrossrefConfigurationNotFoundError(
f"No Crossref configuration found for journal {article.journal}"
)

if not article.sps_pkg:
raise CrossrefDepositError(
f"Article {article} has no associated SPS package"
)

if not force:
existing = CrossrefDeposit.objects.filter(
article=article,
status=CrossrefDepositStatus.SUCCESS,
).first()
if existing:
logger.info(
f"Article {article} already has a successful Crossref deposit. "
f"Use force=True to re-deposit."
)
return existing

xml_content = get_crossref_xml(article.sps_pkg, crossref_config)

deposit = CrossrefDeposit.create(user=user, article=article, xml_content=xml_content)

try:
status_code, response_body = deposit_xml_to_crossref(xml_content, crossref_config)

if status_code in (200, 202):
deposit.mark_success(
response_status=status_code,
response_body=response_body,
)
else:
deposit.mark_error(
response_status=status_code,
response_body=response_body,
)
except CrossrefDepositError as e:
deposit.mark_error(response_body=str(e))
raise

return deposit
7 changes: 7 additions & 0 deletions doi/forms.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from wagtail.admin.forms import WagtailAdminModelForm

from core.forms import CoreAdminModelForm


class DOIWithLangForm(WagtailAdminModelForm):
def save_all(self, user):
Expand All @@ -9,3 +11,8 @@ def save_all(self, user):
self.save()

return doi_with_lang


class CrossrefConfigurationForm(CoreAdminModelForm):
pass

61 changes: 61 additions & 0 deletions doi/migrations/0003_crossref_configuration_and_deposit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Generated by Django 5.2.3 on 2026-03-05 18:48

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('article', '0007_alter_article_options_article_first_pubdate_iso'),
('doi', '0002_alter_doiwithlang_doi_alter_doiwithlang_lang'),
('journal', '0014_alter_journal_title_alter_officialjournal_title'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='CrossrefConfiguration',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='Creation date')),
('updated', models.DateTimeField(auto_now=True, verbose_name='Last update date')),
('crossmark_policy_url', models.URLField(blank=True, help_text="URL of the journal's crossmark policy page", null=True, verbose_name='Crossmark Policy URL')),
('crossmark_policy_doi', models.CharField(blank=True, help_text="DOI of the journal's crossmark policy", max_length=256, null=True, verbose_name='Crossmark Policy DOI')),
('depositor_name', models.CharField(help_text='Name of the depositor (contact person or organization)', max_length=256, verbose_name='Depositor Name')),
('depositor_email', models.EmailField(help_text='Email address for deposit notifications', max_length=254, verbose_name='Depositor Email')),
('registrant', models.CharField(help_text='Name of the organization registering the DOIs (typically the publisher)', max_length=256, verbose_name='Registrant')),
('login_id', models.CharField(blank=True, help_text='Crossref member account username for API deposit', max_length=256, null=True, verbose_name='Crossref Login ID')),
('login_password', models.CharField(blank=True, help_text='Crossref member account password for API deposit', max_length=256, null=True, verbose_name='Crossref Login Password')),
('creator', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_creator', to=settings.AUTH_USER_MODEL, verbose_name='Creator')),
('journal', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='crossref_configuration', to='journal.journal', verbose_name='Journal')),
('updated_by', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_last_mod_user', to=settings.AUTH_USER_MODEL, verbose_name='Updater')),
],
options={
'verbose_name': 'Crossref Configuration',
'verbose_name_plural': 'Crossref Configurations',
},
),
migrations.CreateModel(
name='CrossrefDeposit',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='Creation date')),
('updated', models.DateTimeField(auto_now=True, verbose_name='Last update date')),
('status', models.CharField(choices=[('pending', 'Pending'), ('submitted', 'Submitted'), ('success', 'Success'), ('error', 'Error')], default='pending', max_length=16, verbose_name='Status')),
('response_status', models.IntegerField(blank=True, null=True, verbose_name='HTTP Response Status')),
('response_body', models.TextField(blank=True, null=True, verbose_name='Response Body')),
('batch_id', models.CharField(blank=True, max_length=256, null=True, verbose_name='Batch ID')),
('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='crossref_deposits', to='article.article', verbose_name='Article')),
('creator', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_creator', to=settings.AUTH_USER_MODEL, verbose_name='Creator')),
('updated_by', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_last_mod_user', to=settings.AUTH_USER_MODEL, verbose_name='Updater')),
('xml_crossref', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deposits', to='doi.xmlcrossref', verbose_name='Crossref XML')),
],
options={
'verbose_name': 'Crossref Deposit',
'verbose_name_plural': 'Crossref Deposits',
'ordering': ['-updated'],
},
),
]
Loading