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

+ + + + + + + + + + + {% for deposit in crossref_deposits %} + + + + + + + {% endfor %} + +
{% trans 'Date' %}{% trans 'Status' %}{% trans 'Batch ID' %}{% trans 'HTTP Status' %}
{{ deposit.updated }}{{ deposit.get_status_display }}{{ deposit.batch_id|default:"-" }}{{ deposit.response_status|default:"-" }}
+ {% 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)