From d7d8adf18bf4db0cc396544be616dfac87567cea Mon Sep 17 00:00:00 2001 From: Samuel Veiga Rangel Date: Thu, 25 Jun 2026 11:01:47 -0300 Subject: [PATCH 01/10] =?UTF-8?q?remove=20variavel=20que=20n=C3=A3o=20esta?= =?UTF-8?q?=20sendo=20utilizada?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- article/sources/xmlsps.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/article/sources/xmlsps.py b/article/sources/xmlsps.py index 24624220..9aea3497 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, From 62edbcbc8e8b83c8e9215f4706a45202626cdacc Mon Sep 17 00:00:00 2001 From: Samuel Veiga Rangel Date: Thu, 25 Jun 2026 11:09:36 -0300 Subject: [PATCH 02/10] Add view PublishedArticleRegistrationViewSet --- pid_provider/api/v1/views.py | 140 +++++++++++++++++++++++++++++++---- 1 file changed, 127 insertions(+), 13 deletions(-) diff --git a/pid_provider/api/v1/views.py b/pid_provider/api/v1/views.py index 3e95fe85..d4431b02 100644 --- a/pid_provider/api/v1/views.py +++ b/pid_provider/api/v1/views.py @@ -1,29 +1,32 @@ -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 article.models import Article +from article.sources.xmlsps import load_article 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 django.utils import timezone +from pid_provider.models import PidProviderXML +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 serializers 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, @@ -36,6 +39,15 @@ # TASK_QUEUE = "pid_provider" +class PublishedArticleRegistrationSerializer(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 PidProviderViewSet( GenericViewSet, # generic view functionality CreateModelMixin, # handles POSTs @@ -294,3 +306,105 @@ def create(self, request): {"error_type": str(type(e)), "error_message": str(e)}, status=rest_framework_status.HTTP_400_BAD_REQUEST, ) + + +class PublishedArticleRegistrationViewSet(GenericViewSet): + http_method_names = [ + "post", + ] + permission_classes = [IsAuthenticated] + serializer_class = PublishedArticleRegistrationSerializer + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + if not serializer.is_valid(): + return self.build_response(serializer.errors) + + identifiers = serializer.validated_data + pp_xml = self.get_pid_provider_xml(identifiers) + if pp_xml is None: + return self.build_response( + data={ + "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.register_published_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 self.build_response( + { + "error_type": str(type(e)), + "error_message": str(e), + }, + ) + + timestamp = timezone.now().isoformat() + logging.info( + f"Published article registration 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 self.build_response( + data=self.build_response_data(result, timestamp), + status=self.get_response_status(result), + ) + + def get_pid_provider_xml(self, identifiers): + try: + return PidProviderXML.objects.select_related("current_version").get( + v3=identifiers["pid_v3"], + pkg_name=identifiers["sps_pkg_name"], + ) + except PidProviderXML.DoesNotExist: + return None + + 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 register_published_article_from_pid_provider_xml(self, user, pp_xml): + pid_v3 = pp_xml.v3 + sps_pkg_name = pp_xml.pkg_name + operation = ( + "updated" + if Article.get_by_pid_v3_or_by_sps_pkg_name( + pid_v3=pid_v3, + sps_pkg_name=sps_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, + } From 64165c3d0f752c07ec03042fb60c8ad5c5495bd5 Mon Sep 17 00:00:00 2001 From: Samuel Veiga Rangel Date: Thu, 25 Jun 2026 11:46:50 -0300 Subject: [PATCH 03/10] Remove DATABASES ATOMIC_REQUESTS --- config/settings/base.py | 1 - 1 file changed, 1 deletion(-) diff --git a/config/settings/base.py b/config/settings/base.py index aa9360af..b7e1dc56 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" From 77df7fcf87ffc9e3e761945ced7920e90cd17460 Mon Sep 17 00:00:00 2001 From: Samuel Veiga Rangel Date: Thu, 25 Jun 2026 11:48:05 -0300 Subject: [PATCH 04/10] add router published_article --- config/api_router.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/config/api_router.py b/config/api_router.py index 629d4739..a6a8fbdd 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -3,7 +3,11 @@ from article.api.v1.views import ArticleViewSet 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, + PublishedArticleRegistrationViewSet, +) 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 +23,15 @@ 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( + "published_article", + PublishedArticleRegistrationViewSet, + basename="published_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") From 20960e329263c9ce244ed94561ff5e3c07caa5cf Mon Sep 17 00:00:00 2001 From: Samuel Veiga Rangel Date: Thu, 25 Jun 2026 11:48:17 -0300 Subject: [PATCH 05/10] =?UTF-8?q?add=20=20Registro=20de=20publica=C3=A7?= =?UTF-8?q?=C3=A3o=20de=20artigo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/documentacao-tecnica.md | 68 ++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/docs/documentacao-tecnica.md b/docs/documentacao-tecnica.md index 0a5fa249..ac6a6181 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) From 82e16b05b5e1f76cbe58a40e77f90421f58e0ac8 Mon Sep 17 00:00:00 2001 From: Samuel Veiga Rangel Date: Thu, 25 Jun 2026 11:48:22 -0300 Subject: [PATCH 06/10] add testes --- pid_provider/test_api.py | 141 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 pid_provider/test_api.py diff --git a/pid_provider/test_api.py b/pid_provider/test_api.py new file mode 100644 index 00000000..92a701ac --- /dev/null +++ b/pid_provider/test_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 pid_provider.api.v1.views import PublishedArticleRegistrationViewSet +from pid_provider.models import PidProviderXML + + +User = get_user_model() + + +class PublishedArticleRegistrationViewSetTest(APITestCase): + url = "/api/v1/published_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 = PublishedArticleRegistrationViewSet.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( + "pid_provider.api.v1.views.PublishedArticleRegistrationViewSet.register_published_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( + "pid_provider.api.v1.views.PublishedArticleRegistrationViewSet.register_published_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( + "pid_provider.api.v1.views.PublishedArticleRegistrationViewSet.register_published_article_from_pid_provider_xml" + ) + def test_returns_400_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_400_BAD_REQUEST) + self.assertEqual(response.data["error_type"], "") + self.assertEqual(response.data["error_message"], "registration failed") From c9f8426b459c6c66233a5a98b4de94b20ef4f169 Mon Sep 17 00:00:00 2001 From: Samuel Veiga Rangel Date: Fri, 26 Jun 2026 11:44:51 -0300 Subject: [PATCH 07/10] Remove endpoint published_article do app pid_provider --- pid_provider/api/v1/views.py | 116 ---------------------------- pid_provider/test_api.py | 141 ----------------------------------- 2 files changed, 257 deletions(-) delete mode 100644 pid_provider/test_api.py diff --git a/pid_provider/api/v1/views.py b/pid_provider/api/v1/views.py index d4431b02..8758cefa 100644 --- a/pid_provider/api/v1/views.py +++ b/pid_provider/api/v1/views.py @@ -3,22 +3,17 @@ import sys from tempfile import NamedTemporaryFile, TemporaryDirectory -from article.models import Article -from article.sources.xmlsps import load_article 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 django.utils import timezone -from pid_provider.models import PidProviderXML 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 serializers from rest_framework import status as rest_framework_status from rest_framework.mixins import CreateModelMixin from rest_framework.parsers import FileUploadParser @@ -39,15 +34,6 @@ # TASK_QUEUE = "pid_provider" -class PublishedArticleRegistrationSerializer(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 PidProviderViewSet( GenericViewSet, # generic view functionality CreateModelMixin, # handles POSTs @@ -306,105 +292,3 @@ def create(self, request): {"error_type": str(type(e)), "error_message": str(e)}, status=rest_framework_status.HTTP_400_BAD_REQUEST, ) - - -class PublishedArticleRegistrationViewSet(GenericViewSet): - http_method_names = [ - "post", - ] - permission_classes = [IsAuthenticated] - serializer_class = PublishedArticleRegistrationSerializer - - def create(self, request, *args, **kwargs): - serializer = self.get_serializer(data=request.data) - if not serializer.is_valid(): - return self.build_response(serializer.errors) - - identifiers = serializer.validated_data - pp_xml = self.get_pid_provider_xml(identifiers) - if pp_xml is None: - return self.build_response( - data={ - "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.register_published_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 self.build_response( - { - "error_type": str(type(e)), - "error_message": str(e), - }, - ) - - timestamp = timezone.now().isoformat() - logging.info( - f"Published article registration 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 self.build_response( - data=self.build_response_data(result, timestamp), - status=self.get_response_status(result), - ) - - def get_pid_provider_xml(self, identifiers): - try: - return PidProviderXML.objects.select_related("current_version").get( - v3=identifiers["pid_v3"], - pkg_name=identifiers["sps_pkg_name"], - ) - except PidProviderXML.DoesNotExist: - return None - - 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 register_published_article_from_pid_provider_xml(self, user, pp_xml): - pid_v3 = pp_xml.v3 - sps_pkg_name = pp_xml.pkg_name - operation = ( - "updated" - if Article.get_by_pid_v3_or_by_sps_pkg_name( - pid_v3=pid_v3, - sps_pkg_name=sps_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/pid_provider/test_api.py b/pid_provider/test_api.py deleted file mode 100644 index 92a701ac..00000000 --- a/pid_provider/test_api.py +++ /dev/null @@ -1,141 +0,0 @@ -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 pid_provider.api.v1.views import PublishedArticleRegistrationViewSet -from pid_provider.models import PidProviderXML - - -User = get_user_model() - - -class PublishedArticleRegistrationViewSetTest(APITestCase): - url = "/api/v1/published_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 = PublishedArticleRegistrationViewSet.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( - "pid_provider.api.v1.views.PublishedArticleRegistrationViewSet.register_published_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( - "pid_provider.api.v1.views.PublishedArticleRegistrationViewSet.register_published_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( - "pid_provider.api.v1.views.PublishedArticleRegistrationViewSet.register_published_article_from_pid_provider_xml" - ) - def test_returns_400_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_400_BAD_REQUEST) - self.assertEqual(response.data["error_type"], "") - self.assertEqual(response.data["error_message"], "registration failed") From c1e81fdadd6339f844568a2e59235547d5c72432 Mon Sep 17 00:00:00 2001 From: Samuel Veiga Rangel Date: Fri, 26 Jun 2026 11:56:51 -0300 Subject: [PATCH 08/10] Add PublishArticleViewSet --- article/api/v1/serializers.py | 9 ++ article/api/v1/views.py | 169 +++++++++++++++++++++++++++++++++- config/api_router.py | 14 +-- 3 files changed, 180 insertions(+), 12 deletions(-) diff --git a/article/api/v1/serializers.py b/article/api/v1/serializers.py index e90146b4..493af449 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 15b69bcf..c7349da6 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/config/api_router.py b/config/api_router.py index a6a8fbdd..2b8d26e4 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -1,13 +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 ( - FixPidV2ViewSet, - PidProviderViewSet, - PublishedArticleRegistrationViewSet, -) +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 @@ -23,11 +19,7 @@ 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( - "published_article", - PublishedArticleRegistrationViewSet, - basename="published_article", -) +router.register("publish_article", PublishArticleViewSet, basename="publish_article") router.register("journal", JournalViewSet, basename="journal") router.register( "xml_validation", ValidationConfigSerializerView, basename="xml_validation" From b5b8dd1d72b759a3629464ef90248ccb5389a00c Mon Sep 17 00:00:00 2001 From: Samuel Veiga Rangel Date: Fri, 26 Jun 2026 11:56:59 -0300 Subject: [PATCH 09/10] Add PublishArticleViewSetTest --- article/test_publish_article_api.py | 141 ++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 article/test_publish_article_api.py diff --git a/article/test_publish_article_api.py b/article/test_publish_article_api.py new file mode 100644 index 00000000..70c4588e --- /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") From 0427d5a785e28699290dd733c93e345064714318 Mon Sep 17 00:00:00 2001 From: Samuel Veiga Rangel <82840278+samuelveigarangel@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:53:43 -0300 Subject: [PATCH 10/10] Update documentacao-tecnica.md --- docs/documentacao-tecnica.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/documentacao-tecnica.md b/docs/documentacao-tecnica.md index ac6a6181..3e88d530 100644 --- a/docs/documentacao-tecnica.md +++ b/docs/documentacao-tecnica.md @@ -345,7 +345,7 @@ consolidadas em [`config/urls.py`](../config/urls.py). ### Registro de publicação de artigo -O endpoint `POST /api/v2/pid/published_article/` registra que um artigo já +O endpoint `POST /api/v1/publish_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 @@ -370,7 +370,7 @@ Resposta: Requisição: ```bash -curl -X POST http://localhost:8000/api/v1/published_article/ \ +curl -X POST http://localhost:8000/api/v1/publish_article/ \ -H 'Authorization: Bearer eyJhbGciOi...' \ -H 'Content-Type: application/json' \ -d '{