Skip to content
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
15 changes: 13 additions & 2 deletions config/api_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@samuelveigarangel troque published_article para publish_article, pois está fazendo uma ação e não uma obtenção de dado

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")

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
68 changes: 68 additions & 0 deletions docs/documentacao-tecnica.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
140 changes: 127 additions & 13 deletions pid_provider/api/v1/views.py
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@samuelveigarangel não ocorre dependÊncia circular?

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,
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@samuelveigarangel fora do estilo recomendado de 3 blocos de import: nativas, externas, internas.

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,
Expand All @@ -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
)


Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@samuelveigarangel manter o padrão, isso fica no arquiv serializers.py

class PidProviderViewSet(
GenericViewSet, # generic view functionality
CreateModelMixin, # handles POSTs
Expand Down Expand Up @@ -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):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@samuelveigarangel Era esperado que o código do endpoint fosse feito em article/api/v1/views.

Além disso, não seria necessário criar uma nova classe. Pode usar a ArticleViewSet. Veja o exemplo hipotético. Por outro lado, se realmente ficar grande no

@action(
        detail=False, 
        methods=["post"], 
        permission_classes=[IsAuthenticated], 
        url_path="publish"
    )
    def publish_article(self, request):
        """
        Busca o XML no pid_provider e realiza a publicação do Article.
        URL: POST /api/v1/article/publish/
        """
        serializer = PublishedArticleRegistrationSerializer(data=request.data)
        if not serializer.is_valid():
            return Response(serializer.errors, status=rest_framework_status.HTTP_400_BAD_REQUEST)

        identifiers = serializer.validated_data
        
        # 1. Busca o XML no pid_provider
        try:
            pp_xml = PidProviderXML.objects.select_related("current_version").get(
                v3=identifiers["pid_v3"],
                pkg_name=identifiers["sps_pkg_name"],
            )
        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)

        # 2. Executa as regras de negócio de publicação do Article
        try:
            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"
            )
            
            # Carrega, salva e valida a disponibilidade do artigo
            article = load_article(request.user, pp_xml=pp_xml)
            pp_xml.collections.set(article.collections)
            article.check_availability(request.user)
            
        except Exception as e:
            logging.error(f"Erro ao publicar artigo: {e}", exc_info=True)
            return Response(
                {"error_type": str(type(e)), "error_message": str(e)},
                status=rest_framework_status.HTTP_400_BAD_REQUEST
            )

        # 3. Log e Retorno
        timestamp = timezone.now().isoformat()
        logging.info(f"Article published: id={article.id} operation={operation} user={request.user.username}")
        
        response_status = (
            rest_framework_status.HTTP_201_CREATED 
            if operation == "created" 
            else rest_framework_status.HTTP_200_OK
        )
        
        return Response({
            "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,
            "timestamp": timestamp
        }, status=response_status)

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,
}
Loading
Loading