diff --git a/article/api/v1/serializers.py b/article/api/v1/serializers.py index e90146b4d..493af4491 100644 --- a/article/api/v1/serializers.py +++ b/article/api/v1/serializers.py @@ -11,6 +11,15 @@ from vocabulary.api.v1.serializers import KeywordSerializer +class PublishArticleSerializer(serializers.Serializer): + pid_v3 = serializers.CharField( + required=True, allow_blank=False, max_length=23, min_length=23 + ) + sps_pkg_name = serializers.CharField( + required=True, allow_blank=False, max_length=100 + ) + + class FundingsSerializer(serializers.ModelSerializer): funding_source = SponsorSerializer(many=False, read_only=True) diff --git a/article/api/v1/views.py b/article/api/v1/views.py index 15b69bcff..c7349da6c 100644 --- a/article/api/v1/views.py +++ b/article/api/v1/views.py @@ -1,8 +1,17 @@ +import logging + +from django.utils import timezone +from pid_provider.models import PidProviderXML +from rest_framework import status as rest_framework_status from rest_framework import viewsets +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.viewsets import GenericViewSet from article import models +from article.sources.xmlsps import load_article -from .serializers import ArticleSerializer +from .serializers import ArticleSerializer, PublishArticleSerializer class ArticleViewSet(viewsets.ModelViewSet): @@ -16,3 +25,161 @@ def get_queryset(self): if doi_prefix is not None: queryset = queryset.filter(doi__value__startswith=doi_prefix) return queryset + + +class PublishArticleViewSet(GenericViewSet): + """ + Registra a publicação de um artigo a partir de um PidProviderXML existente. + + Usado após o upload do XML via ``pid_provider`` para criar ou atualizar o + ``Article`` no Core e marcá-lo como público. + """ + + http_method_names = [ + "post", + ] + permission_classes = [IsAuthenticated] + serializer_class = PublishArticleSerializer + + def create(self, request): + """ + Registra que um artigo identificado pelo PID Provider foi publicado ou + atualizado no site público. + + Localiza o ``PidProviderXML`` pelo par ``pid_v3`` + ``sps_pkg_name``, + carrega os metadados do XML SPS versionado, cria ou atualiza o + ``Article`` e executa ``check_availability`` para expor o registro. + + Parameters + ---------- + pid_v3 : str, required + PID v3 do artigo (23 caracteres). + sps_pkg_name : str, required + Nome do pacote SPS associado ao XML (ex. ``2236-8906-hoehnea-49-e1082020``). + + # solicita token + curl -X POST http://localhost:8000/api/v2/auth/token/ \ + -d 'username=scms-upload&password=secret' + + # resposta + ``` + {"refresh":"eyJhbGciOi...","access":"eyJhbGciOi..."} + ``` + + # registra publicação do artigo + curl -X POST http://localhost:8000/api/v1/publish_article/ \ + -H 'Authorization: Bearer eyJhbGciOi...' \ + -H 'Content-Type: application/json' \ + -d '{ + "pid_v3": "67CrZnsyZLpV7dyR7dgp6Vt", + "sps_pkg_name": "2236-8906-hoehnea-49-e1082020" + }' + + Return + ------ + dict + Resposta de sucesso (``201 Created`` para criação, ``200 OK`` para atualização):: + + { + "article_id": 123, + "pid_v3": "67CrZnsyZLpV7dyR7dgp6Vt", + "sps_pkg_name": "2236-8906-hoehnea-49-e1082020", + "operation": "created", + "data_status": "PUBLIC", + "is_public": true, + "timestamp": "2026-06-23T15:00:00+00:00" + } + + Errors + ------ + - ``400 Bad Request``: ``pid_v3`` ou ``sps_pkg_name`` ausente, vazio ou inválido. + - ``500 Internal Server Error``: falha inesperada ao converter o XML em ``Article``. + - ``401 Unauthorized``: token JWT ausente, expirado ou inválido. + - ``404 Not Found``: nenhum ``PidProviderXML`` para o par informado. + """ + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + identifiers = serializer.validated_data + + try: + pp_xml = self.get_pid_provider_xml(identifiers) + except PidProviderXML.DoesNotExist: + return Response( + { + "error": "PidProviderXML not found", + "pid_v3": identifiers["pid_v3"], + "sps_pkg_name": identifiers["sps_pkg_name"], + }, + status=rest_framework_status.HTTP_404_NOT_FOUND, + ) + + try: + result = self.publish_article_from_pid_provider_xml(request.user, pp_xml) + except Exception as e: + logging.error( + f"Erro ao registrar artigo. Identificadores: {identifiers}. Exceção: {type(e).__name__}: {e}", + exc_info=True, + ) + return Response( + { + "error_type": str(type(e)), + "error_message": str(e), + }, + status=rest_framework_status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + timestamp = timezone.now().isoformat() + logging.info( + f"Publish article operation={result['operation']} " + f"pid_v3={identifiers['pid_v3']} " + f"sps_pkg_name={identifiers['sps_pkg_name']} " + f"article_id={result['article_id']} " + f"user={request.user.username} timestamp={timestamp}" + ) + return Response( + data=self.build_response_data(result, timestamp), + status=self.get_response_status(result), + ) + + def get_pid_provider_xml(self, identifiers): + return PidProviderXML.objects.select_related("current_version").get( + v3=identifiers["pid_v3"], + pkg_name=identifiers["sps_pkg_name"], + ) + + def build_response(self, data, status=rest_framework_status.HTTP_400_BAD_REQUEST): + return Response(data, status=status) + + def get_response_status(self, result): + if result["operation"] == "created": + return rest_framework_status.HTTP_201_CREATED + return rest_framework_status.HTTP_200_OK + + def build_response_data(self, result, timestamp): + return {key: value for key, value in result.items() if key != "article"} | { + "timestamp": timestamp, + } + + def publish_article_from_pid_provider_xml(self, user, pp_xml): + operation = ( + "updated" + if models.Article.get_by_pid_v3_or_by_sps_pkg_name( + pid_v3=pp_xml.v3, + sps_pkg_name=pp_xml.pkg_name, + ).exists() + else "created" + ) + article = load_article(user, pp_xml=pp_xml) + pp_xml.collections.set(article.collections) + + article.check_availability(user) + + return { + "article": article, + "article_id": article.id, + "pid_v3": article.pid_v3, + "sps_pkg_name": article.sps_pkg_name, + "operation": operation, + "data_status": article.data_status, + "is_public": article.is_public, + } diff --git a/article/sources/xmlsps.py b/article/sources/xmlsps.py index 24624220e..9aea34974 100755 --- a/article/sources/xmlsps.py +++ b/article/sources/xmlsps.py @@ -109,7 +109,7 @@ def load_article(user, pp_xml): try: xml_with_pre = pp_xml.xml_with_pre except Exception as e: - updated = ( + ( Article.objects.filter(pp_xml=pp_xml) .exclude( data_status=choices.DATA_STATUS_INVALID, diff --git a/article/test_publish_article_api.py b/article/test_publish_article_api.py new file mode 100644 index 000000000..70c4588eb --- /dev/null +++ b/article/test_publish_article_api.py @@ -0,0 +1,141 @@ +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from rest_framework import status +from rest_framework.test import APIRequestFactory, APITestCase, force_authenticate + +from article.api.v1.views import PublishArticleViewSet +from pid_provider.models import PidProviderXML + + +User = get_user_model() + + +class PublishArticleViewSetTest(APITestCase): + url = "/api/v1/publish_article/" + pid_v3 = "12345678901234567890123" + sps_pkg_name = "1234-5678-journal-10-01-a01" + + def setUp(self): + self.user = User.objects.create_user( + username="scms-upload", + password="test-password", + ) + self.factory = APIRequestFactory() + self.view = PublishArticleViewSet.as_view({"post": "create"}) + self.authenticated = False + PidProviderXML.objects.filter( + v3=self.pid_v3, + pkg_name=self.sps_pkg_name, + ).delete() + + def authenticate(self): + self.authenticated = True + + def create_pid_provider_xml(self): + return PidProviderXML.objects.create( + v3=self.pid_v3, + pkg_name=self.sps_pkg_name, + ) + + def post(self, data=None, url=None): + request = self.factory.post( + url or self.url, + data or {"pid_v3": self.pid_v3, "sps_pkg_name": self.sps_pkg_name}, + format="json", + ) + if self.authenticated: + force_authenticate(request, user=self.user) + return self.view(request) + + def registration_result(self, operation="created"): + return { + "article": object(), + "article_id": 123, + "pid_v3": self.pid_v3, + "sps_pkg_name": self.sps_pkg_name, + "operation": operation, + "data_status": "PUBLIC", + "is_public": True, + } + + def test_requires_authentication(self): + response = self.post() + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_returns_400_when_required_params_are_missing(self): + self.authenticate() + + response = self.post({"pid_v3": self.pid_v3}) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("sps_pkg_name", response.data) + + def test_returns_400_when_pid_v3_is_invalid(self): + self.authenticate() + + response = self.post({"pid_v3": "invalid", "sps_pkg_name": self.sps_pkg_name}) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("pid_v3", response.data) + + def test_returns_404_when_pid_provider_xml_does_not_exist(self): + self.authenticate() + + response = self.post() + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(response.data["error"], "PidProviderXML not found") + self.assertEqual(response.data["pid_v3"], self.pid_v3) + self.assertEqual(response.data["sps_pkg_name"], self.sps_pkg_name) + + @patch( + "article.api.v1.views.PublishArticleViewSet.publish_article_from_pid_provider_xml" + ) + def test_returns_201_when_article_is_created(self, mocked_register): + self.authenticate() + pp_xml = self.create_pid_provider_xml() + mocked_register.return_value = self.registration_result(operation="created") + + response = self.post() + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["article_id"], 123) + self.assertEqual(response.data["pid_v3"], self.pid_v3) + self.assertEqual(response.data["sps_pkg_name"], self.sps_pkg_name) + self.assertEqual(response.data["operation"], "created") + self.assertEqual(response.data["data_status"], "PUBLIC") + self.assertTrue(response.data["is_public"]) + self.assertIn("timestamp", response.data) + self.assertNotIn("article", response.data) + mocked_register.assert_called_once_with(self.user, pp_xml) + + @patch( + "article.api.v1.views.PublishArticleViewSet.publish_article_from_pid_provider_xml" + ) + def test_returns_200_when_article_is_updated(self, mocked_register): + self.authenticate() + pp_xml = self.create_pid_provider_xml() + mocked_register.return_value = self.registration_result(operation="updated") + + response = self.post() + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["article_id"], 123) + self.assertEqual(response.data["operation"], "updated") + mocked_register.assert_called_once_with(self.user, pp_xml) + + @patch( + "article.api.v1.views.PublishArticleViewSet.publish_article_from_pid_provider_xml" + ) + def test_returns_500_when_registration_fails(self, mocked_register): + self.authenticate() + self.create_pid_provider_xml() + mocked_register.side_effect = RuntimeError("registration failed") + + response = self.post() + + self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) + self.assertEqual(response.data["error_type"], "") + self.assertEqual(response.data["error_message"], "registration failed") diff --git a/config/api_router.py b/config/api_router.py index 629d47391..2b8d26e4b 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -1,9 +1,9 @@ from django.conf import settings from rest_framework.routers import DefaultRouter, SimpleRouter -from article.api.v1.views import ArticleViewSet +from article.api.v1.views import ArticleViewSet, PublishArticleViewSet from issue.api.v1.views import IssueViewSet -from pid_provider.api.v1.views import PidProviderViewSet, FixPidV2ViewSet +from pid_provider.api.v1.views import FixPidV2ViewSet, PidProviderViewSet from journal.api.v1.views import CrossmarkPolicyViewSet, JournalViewSet from xml_validation.api.v1.views import ValidationConfigSerializerView from collection.api.v1.view import CollectionViewSet @@ -19,8 +19,11 @@ router.register("issue", IssueViewSet, basename="Issue") router.register("pid_provider", PidProviderViewSet, basename="pid_provider") router.register("fix_pid_v2", FixPidV2ViewSet, basename="fix_pid_v2") +router.register("publish_article", PublishArticleViewSet, basename="publish_article") router.register("journal", JournalViewSet, basename="journal") -router.register("xml_validation", ValidationConfigSerializerView, basename="xml_validation") +router.register( + "xml_validation", ValidationConfigSerializerView, basename="xml_validation" +) router.register("collection", CollectionViewSet, basename="collection") router.register("crossmarkpolicy", CrossmarkPolicyViewSet, basename="crossmarkpolicy") diff --git a/config/settings/base.py b/config/settings/base.py index aa9360afd..b7e1dc560 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -43,7 +43,6 @@ # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#databases DATABASES = {"default": env.db("DATABASE_URL")} -DATABASES["default"]["ATOMIC_REQUESTS"] = True DATABASES["default"]["ENGINE"] = 'django_prometheus.db.backends.postgresql' # https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-DEFAULT_AUTO_FIELD DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/docs/documentacao-tecnica.md b/docs/documentacao-tecnica.md index 0a5fa2497..ac6a61811 100644 --- a/docs/documentacao-tecnica.md +++ b/docs/documentacao-tecnica.md @@ -343,6 +343,74 @@ consolidadas em [`config/urls.py`](../config/urls.py). `organization`, `pid_provider`, `researcher`, `vocabulary`, `xml_validation`, `doi`. +### Registro de publicação de artigo + +O endpoint `POST /api/v2/pid/published_article/` registra que um artigo já +identificado pelo PID Provider foi publicado ou atualizado no site público. A +operação usa `pid_v3` e `sps_pkg_name` para localizar o `PidProviderXML`, +carrega os metadados do XML SPS versionado, cria ou atualiza o `Article` e +marca o registro como público. + +Autenticação: + +```bash +curl -X POST http://localhost:8000/api/v2/auth/token/ \ + -d 'username=scms-upload&password=secret' +``` + +Resposta: + +```json +{ + "refresh": "eyJhbGciOi...", + "access": "eyJhbGciOi..." +} +``` + +Requisição: + +```bash +curl -X POST http://localhost:8000/api/v1/published_article/ \ + -H 'Authorization: Bearer eyJhbGciOi...' \ + -H 'Content-Type: application/json' \ + -d '{ + "pid_v3": "67CrZnsyZLpV7dyR7dgp6Vt", + "sps_pkg_name": "2236-8906-hoehnea-49-e1082020" + }' +``` + +Resposta para criação (`201 Created`) ou atualização (`200 OK`): + +```json +{ + "article_id": 123, + "pid_v3": "67CrZnsyZLpV7dyR7dgp6Vt", + "sps_pkg_name": "2236-8906-hoehnea-49-e1082020", + "operation": "created", + "data_status": "PUBLIC", + "is_public": true, + "timestamp": "2026-06-23T15:00:00+00:00" +} +``` + +Cenários de erro: + +- `400 Bad Request`: `pid_v3` ou `sps_pkg_name` ausente, vazio ou inválido. +- `400 Bad Request`: o `PidProviderXML` existe, mas o XML não pôde ser + convertido em `Article` por inconsistência de metadados. +- `401 Unauthorized`: token JWT ausente, expirado ou inválido. +- `404 Not Found`: nenhum `PidProviderXML` foi encontrado para o par + `pid_v3` e `sps_pkg_name`. + +Pré-requisitos para exposição OAI-PMH: + +- O `PidProviderXML` precisa existir no Core e possuir XML SPS versionado. +- O XML precisa conter metadados suficientes para localizar periódico e + fascículo e criar o `Article`. +- Após sucesso no endpoint, o `Article` fica com status público e os flags de + publicação usados pelo índice OAI; assim, a exposição passa a depender apenas + do fluxo normal de indexação do Core/Solr. + --- ## Tarefas assíncronas (Celery) diff --git a/pid_provider/api/v1/views.py b/pid_provider/api/v1/views.py index 3e95fe851..8758cefab 100644 --- a/pid_provider/api/v1/views.py +++ b/pid_provider/api/v1/views.py @@ -1,29 +1,27 @@ -import os import logging +import os import sys -from io import BytesIO -from zipfile import ZipFile - from tempfile import NamedTemporaryFile, TemporaryDirectory -from config.settings.base import TASK_EXPIRES, TASK_TIMEOUT, RUN_ASYNC from celery.exceptions import TimeoutError +from config.settings.base import RUN_ASYNC, TASK_EXPIRES, TASK_TIMEOUT +from core.utils.profiling_tools import ( + profile_endpoint, + profile_method, +) # ajuste o import conforme sua estrutura +from pid_provider.provider import PidProvider +from pid_provider.tasks import ( + task_delete_provide_pid_tmp_zip, + task_provide_pid_for_xml_zip, +) from rest_framework import status as rest_framework_status from rest_framework.mixins import CreateModelMixin from rest_framework.parsers import FileUploadParser from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.viewsets import GenericViewSet - -from core.utils.profiling_tools import profile_endpoint, profile_method # ajuste o import conforme sua estrutura -from pid_provider.provider import PidProvider -from pid_provider.tasks import ( - task_delete_provide_pid_tmp_zip, - task_provide_pid_for_xml_zip, -) from tracker.models import UnexpectedEvent - STATUS_MAPPING = { "created": rest_framework_status.HTTP_201_CREATED, "updated": rest_framework_status.HTTP_200_OK,