Skip to content
9 changes: 9 additions & 0 deletions article/api/v1/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
169 changes: 168 additions & 1 deletion article/api/v1/views.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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,
}
2 changes: 1 addition & 1 deletion article/sources/xmlsps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
141 changes: 141 additions & 0 deletions article/test_publish_article_api.py
Original file line number Diff line number Diff line change
@@ -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"], "<class 'RuntimeError'>")
self.assertEqual(response.data["error_message"], "registration failed")
9 changes: 6 additions & 3 deletions config/api_router.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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")

Expand Down
1 change: 0 additions & 1 deletion config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading