diff --git a/.gitignore b/.gitignore
index 72317a801..53ffb5f85 100644
--- a/.gitignore
+++ b/.gitignore
@@ -279,3 +279,4 @@ core/media/
!.envs/.local/
!.envs/.production/
src/
+test.db
diff --git a/article/templates/modeladmin/article/article/inspect.html b/article/templates/modeladmin/article/article/inspect.html
index 5f9a61540..16e928f55 100644
--- a/article/templates/modeladmin/article/article/inspect.html
+++ b/article/templates/modeladmin/article/article/inspect.html
@@ -66,4 +66,58 @@
{% trans 'Available packages' %}
+
+
{% trans "Crossref DOI Deposit" %}
+ {% if has_crossref_config %}
+
+
+
+
+
+ {% trans "Deposit DOI to Crossref" %}
+
+
+
+
+
+ {% trans "Re-deposit DOI to Crossref" %}
+
+
+ {% else %}
+
+ {% trans "No Crossref configuration found for this journal. Please configure Crossref settings before depositing." %}
+
+ {% endif %}
+
+ {% if crossref_deposits %}
+
{% trans "Recent deposits" %}
+
+
+
+ | {% trans 'Date' %} |
+ {% trans 'Status' %} |
+ {% trans 'Batch ID' %} |
+ {% trans 'HTTP Status' %} |
+
+
+
+ {% for deposit in crossref_deposits %}
+
+ | {{ deposit.updated }} |
+ {{ deposit.get_status_display }} |
+ {{ deposit.batch_id|default:"-" }} |
+ {{ deposit.response_status|default:"-" }} |
+
+ {% endfor %}
+
+
+ {% endif %}
+
+
{% endblock %}
\ No newline at end of file
diff --git a/article/views.py b/article/views.py
index 008f835b7..9004c18df 100644
--- a/article/views.py
+++ b/article/views.py
@@ -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)
diff --git a/config/urls.py b/config/urls.py
index 78cae2c62..c52c672c2 100644
--- a/config/urls.py
+++ b/config/urls.py
@@ -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:
diff --git a/doi/controller.py b/doi/controller.py
new file mode 100644
index 000000000..d4a930856
--- /dev/null
+++ b/doi/controller.py
@@ -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
+
+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
diff --git a/doi/forms.py b/doi/forms.py
index 23888ef62..7f2636c18 100644
--- a/doi/forms.py
+++ b/doi/forms.py
@@ -1,5 +1,7 @@
from wagtail.admin.forms import WagtailAdminModelForm
+from core.forms import CoreAdminModelForm
+
class DOIWithLangForm(WagtailAdminModelForm):
def save_all(self, user):
@@ -9,3 +11,8 @@ def save_all(self, user):
self.save()
return doi_with_lang
+
+
+class CrossrefConfigurationForm(CoreAdminModelForm):
+ pass
+
diff --git a/doi/migrations/0003_crossref_configuration_and_deposit.py b/doi/migrations/0003_crossref_configuration_and_deposit.py
new file mode 100644
index 000000000..42ab27275
--- /dev/null
+++ b/doi/migrations/0003_crossref_configuration_and_deposit.py
@@ -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'],
+ },
+ ),
+ ]
diff --git a/doi/models.py b/doi/models.py
index 624d9ba11..d2a34766b 100644
--- a/doi/models.py
+++ b/doi/models.py
@@ -1,13 +1,23 @@
-from django.db import models
+import sys
+import logging
+
+from django.contrib.auth import get_user_model
+from django.core.files.base import ContentFile
+from django.db import IntegrityError, models
from django.utils.translation import gettext_lazy as _
-from wagtail.admin.panels import FieldPanel
+from wagtail.admin.panels import FieldPanel, ObjectList, TabbedInterface
from core.models import CommonControlField
-from .forms import DOIWithLangForm
+from .forms import DOIWithLangForm, CrossrefConfigurationForm
from collection.models import Language
+User = get_user_model()
+
+logger = logging.getLogger(__name__)
+
+
class DOIWithLang(CommonControlField):
doi = models.CharField(_("DOI"), max_length=256, blank=False, null=False)
lang = models.ForeignKey(
@@ -36,3 +46,246 @@ class Meta:
def __str__(self):
return f"{self.uri}"
+
+
+class CrossrefConfiguration(CommonControlField):
+ """
+ Configuração do Crossref por periódico.
+ Armazena os dados necessários para realizar o depósito de DOI no Crossref.
+ """
+
+ journal = models.OneToOneField(
+ "journal.Journal",
+ on_delete=models.CASCADE,
+ related_name="crossref_configuration",
+ verbose_name=_("Journal"),
+ )
+ crossmark_policy_url = models.URLField(
+ _("Crossmark Policy URL"),
+ null=True,
+ blank=True,
+ help_text=_("URL of the journal's crossmark policy page"),
+ )
+ crossmark_policy_doi = models.CharField(
+ _("Crossmark Policy DOI"),
+ max_length=256,
+ null=True,
+ blank=True,
+ help_text=_("DOI of the journal's crossmark policy"),
+ )
+ depositor_name = models.CharField(
+ _("Depositor Name"),
+ max_length=256,
+ null=False,
+ blank=False,
+ help_text=_("Name of the depositor (contact person or organization)"),
+ )
+ depositor_email = models.EmailField(
+ _("Depositor Email"),
+ null=False,
+ blank=False,
+ help_text=_("Email address for deposit notifications"),
+ )
+ registrant = models.CharField(
+ _("Registrant"),
+ max_length=256,
+ null=False,
+ blank=False,
+ help_text=_("Name of the organization registering the DOIs (typically the publisher)"),
+ )
+ login_id = models.CharField(
+ _("Crossref Login ID"),
+ max_length=256,
+ null=True,
+ blank=True,
+ help_text=_("Crossref member account username for API deposit"),
+ )
+ login_password = models.CharField(
+ _("Crossref Login Password"),
+ max_length=256,
+ null=True,
+ blank=True,
+ help_text=_("Crossref member account password for API deposit"),
+ )
+
+ class Meta:
+ verbose_name = _("Crossref Configuration")
+ verbose_name_plural = _("Crossref Configurations")
+
+ base_form_class = CrossrefConfigurationForm
+
+ panels_configuration = [
+ FieldPanel("journal"),
+ FieldPanel("depositor_name"),
+ FieldPanel("depositor_email"),
+ FieldPanel("registrant"),
+ ]
+
+ panels_crossmark = [
+ FieldPanel("crossmark_policy_url"),
+ FieldPanel("crossmark_policy_doi"),
+ ]
+
+ panels_credentials = [
+ FieldPanel("login_id"),
+ FieldPanel("login_password"),
+ ]
+
+ edit_handler = TabbedInterface(
+ [
+ ObjectList(panels_configuration, heading=_("Configuration")),
+ ObjectList(panels_crossmark, heading=_("Crossmark Policy")),
+ ObjectList(panels_credentials, heading=_("Credentials")),
+ ]
+ )
+
+ def __str__(self):
+ return f"CrossrefConfiguration({self.journal})"
+
+ @classmethod
+ def get(cls, journal):
+ return cls.objects.get(journal=journal)
+
+ @classmethod
+ def create_or_update(
+ cls,
+ user,
+ journal,
+ depositor_name,
+ depositor_email,
+ registrant,
+ crossmark_policy_url=None,
+ crossmark_policy_doi=None,
+ login_id=None,
+ login_password=None,
+ ):
+ try:
+ obj = cls.get(journal=journal)
+ obj.updated_by = user
+ except cls.DoesNotExist:
+ obj = cls(creator=user, journal=journal)
+
+ obj.depositor_name = depositor_name
+ obj.depositor_email = depositor_email
+ obj.registrant = registrant
+ obj.crossmark_policy_url = crossmark_policy_url
+ obj.crossmark_policy_doi = crossmark_policy_doi
+ if login_id:
+ obj.login_id = login_id
+ if login_password:
+ obj.login_password = login_password
+ obj.save()
+ return obj
+
+
+class CrossrefDepositStatus:
+ PENDING = "pending"
+ SUBMITTED = "submitted"
+ SUCCESS = "success"
+ ERROR = "error"
+
+
+CROSSREF_DEPOSIT_STATUS = (
+ (CrossrefDepositStatus.PENDING, _("Pending")),
+ (CrossrefDepositStatus.SUBMITTED, _("Submitted")),
+ (CrossrefDepositStatus.SUCCESS, _("Success")),
+ (CrossrefDepositStatus.ERROR, _("Error")),
+)
+
+
+class CrossrefDeposit(CommonControlField):
+ """
+ Registro de depósito de DOI no Crossref para um artigo.
+ """
+
+ article = models.ForeignKey(
+ "article.Article",
+ on_delete=models.CASCADE,
+ related_name="crossref_deposits",
+ verbose_name=_("Article"),
+ )
+ xml_crossref = models.ForeignKey(
+ XMLCrossRef,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ verbose_name=_("Crossref XML"),
+ related_name="deposits",
+ )
+ status = models.CharField(
+ _("Status"),
+ max_length=16,
+ choices=CROSSREF_DEPOSIT_STATUS,
+ default=CrossrefDepositStatus.PENDING,
+ )
+ response_status = models.IntegerField(
+ _("HTTP Response Status"),
+ null=True,
+ blank=True,
+ )
+ response_body = models.TextField(
+ _("Response Body"),
+ null=True,
+ blank=True,
+ )
+ batch_id = models.CharField(
+ _("Batch ID"),
+ max_length=256,
+ null=True,
+ blank=True,
+ )
+
+ class Meta:
+ verbose_name = _("Crossref Deposit")
+ verbose_name_plural = _("Crossref Deposits")
+ ordering = ["-updated"]
+
+ panels = [
+ FieldPanel("article", read_only=True),
+ FieldPanel("status"),
+ FieldPanel("batch_id", read_only=True),
+ FieldPanel("response_status", read_only=True),
+ FieldPanel("response_body", read_only=True),
+ FieldPanel("xml_crossref"),
+ ]
+
+ def __str__(self):
+ return f"CrossrefDeposit({self.article}, {self.status})"
+
+ @classmethod
+ def create(cls, user, article, xml_content=None):
+ xml_crossref = None
+ if xml_content:
+ xml_crossref = XMLCrossRef(creator=user)
+ xml_crossref.file.save(
+ f"crossref_{article.pid_v3}.xml",
+ ContentFile(xml_content.encode("utf-8") if isinstance(xml_content, str) else xml_content),
+ )
+ xml_crossref.save()
+
+ obj = cls(
+ creator=user,
+ article=article,
+ xml_crossref=xml_crossref,
+ status=CrossrefDepositStatus.PENDING,
+ )
+ obj.save()
+ return obj
+
+ def mark_submitted(self, batch_id=None):
+ self.status = CrossrefDepositStatus.SUBMITTED
+ if batch_id:
+ self.batch_id = batch_id
+ self.save()
+
+ def mark_success(self, response_status=None, response_body=None):
+ self.status = CrossrefDepositStatus.SUCCESS
+ self.response_status = response_status
+ self.response_body = response_body
+ self.save()
+
+ def mark_error(self, response_status=None, response_body=None):
+ self.status = CrossrefDepositStatus.ERROR
+ self.response_status = response_status
+ self.response_body = response_body
+ self.save()
diff --git a/doi/tasks.py b/doi/tasks.py
new file mode 100644
index 000000000..a5b97c4d0
--- /dev/null
+++ b/doi/tasks.py
@@ -0,0 +1,255 @@
+"""
+Celery tasks for Crossref DOI deposit operations.
+"""
+
+import logging
+import sys
+
+from django.contrib.auth import get_user_model
+
+from config import celery_app
+from tracker.models import UnexpectedEvent
+
+logger = logging.getLogger(__name__)
+
+User = get_user_model()
+
+
+def _get_user(user_id, username):
+ try:
+ if user_id:
+ return User.objects.get(pk=user_id)
+ if username:
+ return User.objects.get(username=username)
+ except Exception as e:
+ exc_type, exc_value, exc_traceback = sys.exc_info()
+ UnexpectedEvent.create(
+ e=e,
+ exc_traceback=exc_traceback,
+ detail={
+ "task": "doi.tasks._get_user",
+ "user_id": user_id,
+ "username": username,
+ },
+ )
+
+
+@celery_app.task(bind=True, name="Deposit DOI to Crossref")
+def task_deposit_doi_to_crossref(self, user_id, username, article_id, force=False):
+ """
+ Realiza o depósito do DOI de um único artigo no Crossref.
+
+ Parameters
+ ----------
+ user_id : int
+ ID do usuário que disparou a tarefa.
+ username : str
+ Nome do usuário que disparou a tarefa.
+ article_id : int
+ ID do artigo cujo DOI será depositado.
+ force : bool
+ Se True, realiza o depósito mesmo que já tenha sido feito com sucesso anteriormente.
+ """
+ from article.models import Article
+ from doi.controller import (
+ deposit_article_doi,
+ CrossrefDepositError,
+ CrossrefConfigurationNotFoundError,
+ )
+
+ try:
+ user = _get_user(user_id, username)
+ article = Article.objects.get(pk=article_id)
+
+ logger.info(
+ f"Starting Crossref DOI deposit for article {article} "
+ f"(user: {username or user_id})"
+ )
+
+ deposit = deposit_article_doi(user=user, article=article, force=force)
+
+ logger.info(
+ f"Crossref DOI deposit completed for article {article}. "
+ f"Status: {deposit.status}"
+ )
+ return {
+ "article_id": article_id,
+ "deposit_id": deposit.id,
+ "status": deposit.status,
+ }
+
+ except CrossrefConfigurationNotFoundError as e:
+ logger.error(
+ f"Crossref configuration not found for article {article_id}: {e}"
+ )
+ exc_type, exc_value, exc_traceback = sys.exc_info()
+ UnexpectedEvent.create(
+ e=e,
+ exc_traceback=exc_traceback,
+ detail={
+ "task": "task_deposit_doi_to_crossref",
+ "article_id": article_id,
+ "user_id": user_id,
+ "username": username,
+ },
+ )
+ raise
+
+ except CrossrefDepositError as e:
+ logger.error(
+ f"Crossref deposit error for article {article_id}: {e}"
+ )
+ exc_type, exc_value, exc_traceback = sys.exc_info()
+ UnexpectedEvent.create(
+ e=e,
+ exc_traceback=exc_traceback,
+ detail={
+ "task": "task_deposit_doi_to_crossref",
+ "article_id": article_id,
+ "user_id": user_id,
+ "username": username,
+ },
+ )
+ raise
+
+ except Exception as e:
+ exc_type, exc_value, exc_traceback = sys.exc_info()
+ UnexpectedEvent.create(
+ e=e,
+ exc_traceback=exc_traceback,
+ detail={
+ "task": "task_deposit_doi_to_crossref",
+ "article_id": article_id,
+ "user_id": user_id,
+ "username": username,
+ },
+ )
+ raise
+
+
+@celery_app.task(bind=True, name="Batch Deposit DOIs to Crossref")
+def task_batch_deposit_doi_to_crossref(
+ self,
+ user_id,
+ username,
+ journal_id=None,
+ article_ids=None,
+ force=False,
+):
+ """
+ Realiza o depósito em lote de DOIs de artigos no Crossref.
+
+ Parameters
+ ----------
+ user_id : int
+ ID do usuário que disparou a tarefa.
+ username : str
+ Nome do usuário que disparou a tarefa.
+ journal_id : int, optional
+ ID do periódico. Se informado, deposita todos os artigos do periódico
+ que ainda não foram depositados com sucesso (a menos que force=True).
+ article_ids : list, optional
+ Lista de IDs de artigos a depositar. Se informado, deposita apenas
+ os artigos da lista.
+ force : bool
+ Se True, realiza o depósito mesmo que já tenha sido feito com sucesso.
+ """
+ from article.models import Article
+ from doi.controller import (
+ deposit_article_doi,
+ CrossrefDepositError,
+ CrossrefConfigurationNotFoundError,
+ )
+ from doi.models import CrossrefDepositStatus
+
+ results = {
+ "total": 0,
+ "success": 0,
+ "error": 0,
+ "skipped": 0,
+ "errors": [],
+ }
+
+ try:
+ user = _get_user(user_id, username)
+
+ if article_ids:
+ articles = Article.objects.filter(pk__in=article_ids)
+ elif journal_id:
+ articles = Article.objects.filter(journal_id=journal_id)
+ else:
+ logger.warning(
+ "task_batch_deposit_doi_to_crossref: neither journal_id nor "
+ "article_ids were provided. Nothing to process."
+ )
+ return results
+
+ results["total"] = articles.count()
+ logger.info(
+ f"Starting batch Crossref DOI deposit for {results['total']} articles "
+ f"(journal_id={journal_id}, user={username or user_id})"
+ )
+
+ for article in articles.iterator():
+ try:
+ deposit = deposit_article_doi(
+ user=user, article=article, force=force
+ )
+ if deposit.status == CrossrefDepositStatus.SUCCESS:
+ results["success"] += 1
+ elif deposit.status == CrossrefDepositStatus.PENDING:
+ results["skipped"] += 1
+ else:
+ results["error"] += 1
+ results["errors"].append(
+ {
+ "article_id": article.id,
+ "status": deposit.status,
+ "response": deposit.response_body,
+ }
+ )
+ except CrossrefConfigurationNotFoundError as e:
+ results["error"] += 1
+ results["errors"].append(
+ {"article_id": article.id, "error": str(e)}
+ )
+ logger.error(
+ f"Crossref config not found for article {article}: {e}"
+ )
+ break
+ except CrossrefDepositError as e:
+ results["error"] += 1
+ results["errors"].append(
+ {"article_id": article.id, "error": str(e)}
+ )
+ logger.error(
+ f"Crossref deposit error for article {article}: {e}"
+ )
+ except Exception as e:
+ results["error"] += 1
+ results["errors"].append(
+ {"article_id": article.id, "error": str(e)}
+ )
+ logger.error(
+ f"Unexpected error during Crossref deposit for article {article}: {e}"
+ )
+
+ logger.info(
+ f"Batch Crossref DOI deposit finished. Results: {results}"
+ )
+ return results
+
+ except Exception as e:
+ exc_type, exc_value, exc_traceback = sys.exc_info()
+ UnexpectedEvent.create(
+ e=e,
+ exc_traceback=exc_traceback,
+ detail={
+ "task": "task_batch_deposit_doi_to_crossref",
+ "journal_id": journal_id,
+ "article_ids": article_ids,
+ "user_id": user_id,
+ "username": username,
+ },
+ )
+ raise
diff --git a/doi/tests.py b/doi/tests.py
new file mode 100644
index 000000000..0012ec676
--- /dev/null
+++ b/doi/tests.py
@@ -0,0 +1,342 @@
+"""
+Tests for Crossref DOI deposit functionality.
+"""
+
+from unittest.mock import MagicMock, patch
+
+from django.contrib.auth import get_user_model
+from django.test import TestCase
+
+from doi.models import (
+ CrossrefConfiguration,
+ CrossrefDeposit,
+ CrossrefDepositStatus,
+ XMLCrossRef,
+)
+
+User = get_user_model()
+
+
+class CrossrefConfigurationModelTest(TestCase):
+ """Tests for CrossrefConfiguration model."""
+
+ def setUp(self):
+ self.user = User.objects.create_user(
+ username="testuser", email="test@example.com", password="pass"
+ )
+ # Create minimal required objects for Journal
+ from journal.models import Journal, OfficialJournal
+
+ self.official_journal = OfficialJournal.objects.create(
+ title="Test Journal",
+ creator=self.user,
+ )
+ self.journal = Journal.objects.create(
+ official_journal=self.official_journal,
+ title="Test Journal",
+ creator=self.user,
+ )
+
+ def test_create_crossref_configuration(self):
+ """Test creating a CrossrefConfiguration."""
+ config = CrossrefConfiguration.create_or_update(
+ user=self.user,
+ journal=self.journal,
+ depositor_name="Test Depositor",
+ depositor_email="depositor@test.com",
+ registrant="Test Publisher",
+ )
+ self.assertEqual(config.journal, self.journal)
+ self.assertEqual(config.depositor_name, "Test Depositor")
+ self.assertEqual(config.depositor_email, "depositor@test.com")
+ self.assertEqual(config.registrant, "Test Publisher")
+
+ def test_create_or_update_idempotent(self):
+ """Test that create_or_update is idempotent."""
+ config1 = CrossrefConfiguration.create_or_update(
+ user=self.user,
+ journal=self.journal,
+ depositor_name="Depositor 1",
+ depositor_email="depositor1@test.com",
+ registrant="Publisher 1",
+ )
+ config2 = CrossrefConfiguration.create_or_update(
+ user=self.user,
+ journal=self.journal,
+ depositor_name="Depositor 2",
+ depositor_email="depositor2@test.com",
+ registrant="Publisher 2",
+ )
+ self.assertEqual(config1.pk, config2.pk)
+ config2.refresh_from_db()
+ self.assertEqual(config2.depositor_name, "Depositor 2")
+
+ def test_crossref_configuration_str(self):
+ """Test string representation."""
+ config = CrossrefConfiguration.create_or_update(
+ user=self.user,
+ journal=self.journal,
+ depositor_name="Test Depositor",
+ depositor_email="depositor@test.com",
+ registrant="Test Publisher",
+ )
+ self.assertIn("CrossrefConfiguration", str(config))
+
+ def test_get_crossref_configuration(self):
+ """Test getting CrossrefConfiguration by journal."""
+ CrossrefConfiguration.create_or_update(
+ user=self.user,
+ journal=self.journal,
+ depositor_name="Test Depositor",
+ depositor_email="depositor@test.com",
+ registrant="Test Publisher",
+ )
+ config = CrossrefConfiguration.get(journal=self.journal)
+ self.assertEqual(config.journal, self.journal)
+
+ def test_get_nonexistent_raises(self):
+ """Test that getting non-existent config raises DoesNotExist."""
+ with self.assertRaises(CrossrefConfiguration.DoesNotExist):
+ CrossrefConfiguration.get(journal=self.journal)
+
+ def test_create_with_crossmark_fields(self):
+ """Test creating config with crossmark policy fields."""
+ config = CrossrefConfiguration.create_or_update(
+ user=self.user,
+ journal=self.journal,
+ depositor_name="Test Depositor",
+ depositor_email="depositor@test.com",
+ registrant="Test Publisher",
+ crossmark_policy_url="https://example.com/crossmark",
+ crossmark_policy_doi="10.1234/crossmark-policy",
+ )
+ self.assertEqual(config.crossmark_policy_url, "https://example.com/crossmark")
+ self.assertEqual(config.crossmark_policy_doi, "10.1234/crossmark-policy")
+
+ def test_create_with_credentials(self):
+ """Test creating config with Crossref API credentials."""
+ config = CrossrefConfiguration.create_or_update(
+ user=self.user,
+ journal=self.journal,
+ depositor_name="Test Depositor",
+ depositor_email="depositor@test.com",
+ registrant="Test Publisher",
+ login_id="my_crossref_id",
+ login_password="my_crossref_password",
+ )
+ self.assertEqual(config.login_id, "my_crossref_id")
+ self.assertEqual(config.login_password, "my_crossref_password")
+
+
+class CrossrefDepositModelTest(TestCase):
+ """Tests for CrossrefDeposit model."""
+
+ def setUp(self):
+ self.user = User.objects.create_user(
+ username="testuser", email="test@example.com", password="pass"
+ )
+ from journal.models import Journal, OfficialJournal
+ from article.models import Article
+
+ self.official_journal = OfficialJournal.objects.create(
+ title="Test Journal",
+ creator=self.user,
+ )
+ self.journal = Journal.objects.create(
+ official_journal=self.official_journal,
+ title="Test Journal",
+ creator=self.user,
+ )
+ self.article = Article.objects.create(
+ pid_v3="S1234-56782024000100001",
+ creator=self.user,
+ journal=self.journal,
+ )
+
+ def test_create_deposit(self):
+ """Test creating a CrossrefDeposit."""
+ deposit = CrossrefDeposit.create(user=self.user, article=self.article)
+ self.assertEqual(deposit.article, self.article)
+ self.assertEqual(deposit.status, CrossrefDepositStatus.PENDING)
+
+ def test_mark_submitted(self):
+ """Test marking a deposit as submitted."""
+ deposit = CrossrefDeposit.create(user=self.user, article=self.article)
+ deposit.mark_submitted(batch_id="batch_123")
+ deposit.refresh_from_db()
+ self.assertEqual(deposit.status, CrossrefDepositStatus.SUBMITTED)
+ self.assertEqual(deposit.batch_id, "batch_123")
+
+ def test_mark_success(self):
+ """Test marking a deposit as successful."""
+ deposit = CrossrefDeposit.create(user=self.user, article=self.article)
+ deposit.mark_success(response_status=200, response_body="Submitted")
+ deposit.refresh_from_db()
+ self.assertEqual(deposit.status, CrossrefDepositStatus.SUCCESS)
+ self.assertEqual(deposit.response_status, 200)
+
+ def test_mark_error(self):
+ """Test marking a deposit as error."""
+ deposit = CrossrefDeposit.create(user=self.user, article=self.article)
+ deposit.mark_error(response_status=500, response_body="Server error")
+ deposit.refresh_from_db()
+ self.assertEqual(deposit.status, CrossrefDepositStatus.ERROR)
+ self.assertEqual(deposit.response_status, 500)
+ self.assertEqual(deposit.response_body, "Server error")
+
+ def test_deposit_str(self):
+ """Test string representation."""
+ deposit = CrossrefDeposit.create(user=self.user, article=self.article)
+ self.assertIn("CrossrefDeposit", str(deposit))
+
+ def test_create_with_xml_content(self):
+ """Test creating a deposit with XML content."""
+ xml_content = ''
+ deposit = CrossrefDeposit.create(
+ user=self.user, article=self.article, xml_content=xml_content
+ )
+ self.assertIsNotNone(deposit.xml_crossref)
+ self.assertEqual(deposit.xml_crossref.creator, self.user)
+
+
+class CrossrefControllerTest(TestCase):
+ """Tests for doi.controller functions."""
+
+ def setUp(self):
+ self.user = User.objects.create_user(
+ username="testuser", email="test@example.com", password="pass"
+ )
+ from journal.models import Journal, OfficialJournal
+ from article.models import Article
+
+ self.official_journal = OfficialJournal.objects.create(
+ title="Test Journal",
+ creator=self.user,
+ )
+ self.journal = Journal.objects.create(
+ official_journal=self.official_journal,
+ title="Test Journal",
+ journal_acron="testj",
+ creator=self.user,
+ )
+ self.article = Article.objects.create(
+ pid_v3="S1234-56782024000100001",
+ creator=self.user,
+ journal=self.journal,
+ )
+ self.config = CrossrefConfiguration.create_or_update(
+ user=self.user,
+ journal=self.journal,
+ depositor_name="Test Depositor",
+ depositor_email="depositor@test.com",
+ registrant="Test Publisher",
+ login_id="test_login",
+ login_password="test_password",
+ )
+
+ def test_deposit_article_doi_without_sps_pkg(self):
+ """Test that deposit raises error when article has no sps_pkg."""
+ from doi.controller import CrossrefDepositError, deposit_article_doi
+
+ with self.assertRaises(CrossrefDepositError):
+ deposit_article_doi(user=self.user, article=self.article)
+
+ def test_deposit_article_doi_without_config(self):
+ """Test that deposit raises error when no Crossref config exists."""
+ from doi.controller import (
+ CrossrefConfigurationNotFoundError,
+ deposit_article_doi,
+ )
+ from article.models import Article
+
+ article_no_config = Article.objects.create(
+ pid_v3="S9999-99992024000100001",
+ creator=self.user,
+ )
+
+ with self.assertRaises(CrossrefDepositError):
+ deposit_article_doi(user=self.user, article=article_no_config)
+
+ def test_deposit_article_doi_no_journal(self):
+ """Test that deposit raises error when article has no journal."""
+ from doi.controller import CrossrefDepositError, deposit_article_doi
+ from article.models import Article
+
+ article = Article.objects.create(
+ pid_v3="S9999-00002024000100001",
+ creator=self.user,
+ )
+ with self.assertRaises(CrossrefDepositError):
+ deposit_article_doi(user=self.user, article=article)
+
+ @patch("doi.controller.deposit_xml_to_crossref")
+ @patch("doi.controller.get_crossref_xml")
+ def test_deposit_article_doi_success(self, mock_get_xml, mock_deposit):
+ """Test successful DOI deposit."""
+ from doi.controller import deposit_article_doi
+ from package.models import SPSPkg
+
+ mock_get_xml.return_value = ""
+ mock_deposit.return_value = (200, "Submitted successfully")
+
+ sps_pkg = SPSPkg.objects.create(
+ pid_v3="S1234-56782024000100001",
+ sps_pkg_name="test-pkg",
+ creator=self.user,
+ )
+ self.article.sps_pkg = sps_pkg
+ self.article.save()
+
+ deposit = deposit_article_doi(user=self.user, article=self.article)
+
+ self.assertEqual(deposit.status, CrossrefDepositStatus.SUCCESS)
+ self.assertEqual(deposit.response_status, 200)
+ mock_get_xml.assert_called_once_with(sps_pkg, self.config)
+ mock_deposit.assert_called_once()
+
+ @patch("doi.controller.deposit_xml_to_crossref")
+ @patch("doi.controller.get_crossref_xml")
+ def test_deposit_article_doi_no_redeposit_without_force(
+ self, mock_get_xml, mock_deposit
+ ):
+ """Test that successful deposit is not re-deposited without force=True."""
+ from doi.controller import deposit_article_doi
+ from package.models import SPSPkg
+
+ mock_get_xml.return_value = ""
+ mock_deposit.return_value = (200, "Submitted successfully")
+
+ sps_pkg = SPSPkg.objects.create(
+ pid_v3="S1234-56782024000100001",
+ sps_pkg_name="test-pkg",
+ creator=self.user,
+ )
+ self.article.sps_pkg = sps_pkg
+ self.article.save()
+
+ deposit1 = deposit_article_doi(user=self.user, article=self.article)
+ self.assertEqual(deposit1.status, CrossrefDepositStatus.SUCCESS)
+
+ # Re-deposit without force
+ deposit2 = deposit_article_doi(user=self.user, article=self.article)
+ self.assertEqual(deposit2.pk, deposit1.pk)
+
+ # Only called once
+ self.assertEqual(mock_deposit.call_count, 1)
+
+ def test_deposit_xml_without_credentials(self):
+ """Test that deposit_xml raises error without credentials."""
+ from doi.controller import CrossrefDepositError, deposit_xml_to_crossref
+
+ config_no_creds = CrossrefConfiguration.create_or_update(
+ user=self.user,
+ journal=self.journal,
+ depositor_name="Test Depositor",
+ depositor_email="depositor@test.com",
+ registrant="Test Publisher",
+ )
+
+ with self.assertRaises(CrossrefDepositError) as ctx:
+ deposit_xml_to_crossref("", config_no_creds)
+
+ self.assertIn("credentials", str(ctx.exception).lower())
diff --git a/doi/urls.py b/doi/urls.py
new file mode 100644
index 000000000..781a26f61
--- /dev/null
+++ b/doi/urls.py
@@ -0,0 +1,13 @@
+from django.urls import path
+
+from doi import views
+
+app_name = "doi"
+
+urlpatterns = [
+ path(
+ "deposit-article-doi/",
+ view=views.deposit_article_doi,
+ name="deposit_article_doi",
+ ),
+]
diff --git a/doi/views.py b/doi/views.py
new file mode 100644
index 000000000..28a54dec0
--- /dev/null
+++ b/doi/views.py
@@ -0,0 +1,53 @@
+"""
+Views for Crossref DOI deposit operations.
+"""
+
+import logging
+
+from django.contrib import messages
+from django.contrib.auth.decorators import login_required
+from django.http import HttpResponseRedirect
+from django.shortcuts import get_object_or_404
+from django.urls import reverse
+from django.utils.translation import gettext_lazy as _
+
+from article.models import Article
+from doi.tasks import task_deposit_doi_to_crossref
+
+logger = logging.getLogger(__name__)
+
+
+@login_required
+def deposit_article_doi(request):
+ """
+ View para disparar o depósito do DOI de um artigo no Crossref.
+ Acessível via botão no painel de administração do artigo ou pacote.
+ """
+ article_id = request.GET.get("article_id")
+ next_url = request.GET.get("next") or request.META.get("HTTP_REFERER") or "/"
+
+ if not article_id:
+ messages.error(request, _("Article ID is required."))
+ return HttpResponseRedirect(next_url)
+
+ article = get_object_or_404(Article, pk=article_id)
+
+ task_deposit_doi_to_crossref.apply_async(
+ kwargs=dict(
+ user_id=request.user.id,
+ username=request.user.username,
+ article_id=article.id,
+ force=request.GET.get("force", "false").lower() == "true",
+ )
+ )
+
+ messages.success(
+ request,
+ _(
+ "DOI deposit for article '%(article)s' has been queued. "
+ "Check the deposit status in the Crossref Deposits section."
+ )
+ % {"article": str(article)},
+ )
+
+ return HttpResponseRedirect(next_url)
diff --git a/doi/wagtail_hooks.py b/doi/wagtail_hooks.py
new file mode 100644
index 000000000..03b745ca1
--- /dev/null
+++ b/doi/wagtail_hooks.py
@@ -0,0 +1,76 @@
+"""
+Wagtail admin hooks for the DOI app.
+"""
+
+from django.urls import reverse
+from django.utils.translation import gettext_lazy as _
+from wagtail import hooks
+from wagtail.snippets.models import register_snippet
+from wagtail.snippets.views.snippets import SnippetViewSet, SnippetViewSetGroup
+
+from config.menu import get_menu_order
+from core.views import CommonControlFieldViewSet
+from doi.models import CrossrefConfiguration, CrossrefDeposit
+
+
+class CrossrefConfigurationViewSet(CommonControlFieldViewSet):
+ model = CrossrefConfiguration
+ menu_label = _("Crossref Configuration")
+ menu_icon = "cog"
+ menu_order = 100
+ add_to_settings_menu = False
+ inspect_view_enabled = True
+
+ list_display = [
+ "journal",
+ "depositor_name",
+ "depositor_email",
+ "registrant",
+ "updated",
+ ]
+ list_filter = []
+ search_fields = [
+ "journal__title",
+ "journal__journal_acron",
+ "depositor_name",
+ "registrant",
+ ]
+ list_per_page = 20
+
+
+class CrossrefDepositViewSet(CommonControlFieldViewSet):
+ model = CrossrefDeposit
+ menu_label = _("Crossref Deposits")
+ menu_icon = "upload"
+ menu_order = 200
+ add_to_settings_menu = False
+ inspect_view_enabled = True
+
+ list_display = [
+ "article",
+ "status",
+ "batch_id",
+ "response_status",
+ "updated",
+ ]
+ list_filter = ["status"]
+ search_fields = [
+ "article__pid_v3",
+ "article__pid_v2",
+ "batch_id",
+ ]
+ list_per_page = 20
+
+
+class CrossrefViewSetGroup(SnippetViewSetGroup):
+ menu_label = _("Crossref")
+ menu_icon = "site"
+ menu_order = get_menu_order("doi")
+
+ items = [
+ CrossrefConfigurationViewSet,
+ CrossrefDepositViewSet,
+ ]
+
+
+register_snippet(CrossrefViewSetGroup)