Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,7 @@ opac/webapp/media/images/*.*

# nodejs/gulp/etc
node_modules/

# pip editable install sources
src/

189 changes: 188 additions & 1 deletion opac/tests/test_restapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from flask import current_app, url_for
from flask_babelex import gettext as _
from opac_schema.v1 import models
from opac_schema.v1.models import CrossmarkPage

from .base import BaseTestCase

Expand Down Expand Up @@ -563,4 +564,190 @@ def test_sync_issue_not_found(self, mock_get_issue_by_iid):
data = resp.get_json()
self.assertEqual(resp.status_code, 404)
self.assertEqual(data.get("failed"), True)
self.assertEqual(data.get("error"), "issue not found")
self.assertEqual(data.get("error"), "issue not found")

class RestAPICrossmarkPageTestCase(BaseTestCase):
def load_json_fixture(self, filename):
with open(os.path.join(FIXTURES_PATH, filename)) as f:
return json.load(f)

def setUp(self):
self.journal_dict = self.load_json_fixture("journal_payload.json")
self.crossmark_payload = {
"doi": "10.1234/example.doi",
"is_doi_active": True,
"language": "pt",
"journal_id": "1678-4464",
"url": "https://example.com/crossmark",
"text": "Crossmark policy text",
}

def _create_journal(self):
with self.client as client:
client.post(
url_for("restapi.journal"),
data=json.dumps(self.journal_dict),
follow_redirects=True,
content_type="application/json",
)

def test_add_crossmarkpolicy(self):
with current_app.app_context():
self._create_journal()
with self.client as client:
response = client.post(
url_for("restapi.crossmarkpolicy"),
data=json.dumps(self.crossmark_payload),
follow_redirects=True,
content_type="application/json",
)
Comment on lines +598 to +603
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

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

The /api/v1/crossmarkpolicy endpoint is decorated with @helper.token_required (token must be provided as a token query param). These tests call url_for("restapi.crossmarkpolicy") without supplying a token, so they will receive 401 responses from the decorator. Update the test requests to include a valid token (e.g., url_for(..., token=<jwt>) or query_string={"token": ...}), consistent with how the endpoint is authenticated.

Copilot uses AI. Check for mistakes.
self.assertEqual(response.status_code, 200)
data = response.get_json()
self.assertFalse(data["failed"])
self.assertEqual(data["doi"], self.crossmark_payload["doi"])
crossmark = CrossmarkPage.objects(doi=self.crossmark_payload["doi"]).first()
self.assertIsNotNone(crossmark)
self.assertEqual(crossmark.doi, self.crossmark_payload["doi"])
self.assertTrue(crossmark.is_doi_active)
self.assertEqual(crossmark.language, "pt")

def test_add_crossmarkpolicy_missing_doi(self):
with current_app.app_context():
self._create_journal()
payload = dict(self.crossmark_payload)
del payload["doi"]
with self.client as client:
response = client.post(
url_for("restapi.crossmarkpolicy"),
data=json.dumps(payload),
follow_redirects=True,
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
data = response.get_json()
self.assertTrue(data["failed"])
self.assertIn("doi", data["error"])

def test_add_crossmarkpolicy_missing_language(self):
with current_app.app_context():
self._create_journal()
payload = dict(self.crossmark_payload)
del payload["language"]
with self.client as client:
response = client.post(
url_for("restapi.crossmarkpolicy"),
data=json.dumps(payload),
follow_redirects=True,
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
data = response.get_json()
self.assertTrue(data["failed"])
self.assertIn("language", data["error"])

def test_add_crossmarkpolicy_missing_journal_id(self):
with current_app.app_context():
payload = dict(self.crossmark_payload)
del payload["journal_id"]
with self.client as client:
response = client.post(
url_for("restapi.crossmarkpolicy"),
data=json.dumps(payload),
follow_redirects=True,
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
data = response.get_json()
self.assertTrue(data["failed"])
self.assertIn("journal_id", data["error"])

def test_add_crossmarkpolicy_journal_not_found(self):
with current_app.app_context():
payload = dict(self.crossmark_payload)
payload["journal_id"] = "9999-9999"
with self.client as client:
response = client.post(
url_for("restapi.crossmarkpolicy"),
data=json.dumps(payload),
follow_redirects=True,
content_type="application/json",
)
self.assertEqual(response.status_code, 404)
data = response.get_json()
self.assertTrue(data["failed"])
self.assertEqual(data["error"], "journal not found")

def test_add_crossmarkpolicy_missing_url(self):
with current_app.app_context():
self._create_journal()
payload = dict(self.crossmark_payload)
del payload["url"]
with self.client as client:
response = client.post(
url_for("restapi.crossmarkpolicy"),
data=json.dumps(payload),
follow_redirects=True,
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
data = response.get_json()
self.assertTrue(data["failed"])
self.assertIn("url", data["error"])

def test_add_crossmarkpolicy_missing_text(self):
with current_app.app_context():
self._create_journal()
payload = dict(self.crossmark_payload)
del payload["text"]
with self.client as client:
response = client.post(
url_for("restapi.crossmarkpolicy"),
data=json.dumps(payload),
follow_redirects=True,
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
data = response.get_json()
self.assertTrue(data["failed"])
self.assertIn("text", data["error"])

def test_update_crossmarkpolicy(self):
with current_app.app_context():
self._create_journal()
with self.client as client:
client.post(
url_for("restapi.crossmarkpolicy"),
data=json.dumps(self.crossmark_payload),
follow_redirects=True,
content_type="application/json",
)
updated_payload = dict(self.crossmark_payload)
updated_payload["doi"] = "10.1234/updated.doi"
updated_payload["is_doi_active"] = False
with self.client as client:
response = client.put(
url_for("restapi.crossmarkpolicy"),
data=json.dumps(updated_payload),
follow_redirects=True,
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
data = response.get_json()
self.assertFalse(data["failed"])
crossmark = CrossmarkPage.objects(doi=updated_payload["doi"]).first()
self.assertFalse(crossmark.is_doi_active)
self.assertEqual(crossmark.language, "pt")
self.assertEqual(CrossmarkPage.objects.count(), 1)

def test_add_crossmarkpolicy_missing_payload(self):
with current_app.app_context():
with self.client as client:
response = client.post(
url_for("restapi.crossmarkpolicy"),
data=None,
follow_redirects=True,
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
data = response.get_json()
self.assertTrue(data["failed"])
45 changes: 45 additions & 0 deletions opac/webapp/controllers.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from opac_schema.v1.models import (
Article,
Collection,
CrossmarkPage,
Issue,
Journal,
News,
Expand Down Expand Up @@ -1944,3 +1945,47 @@ def add_article(
)

return article.save()


# -------- CROSSMARK --------


def add_crossmark_page(doi, is_doi_active, language, journal, url=None, text=None):
"""
Cria ou atualiza um registro de CrossmarkPage.

O registro é identificado pela combinação de ``journal`` e ``language``.

- ``doi``: string, DOI do documento
- ``is_doi_active``: bool, indica se o DOI está ativo
- ``language``: string, código do idioma (máx. 5 caracteres)
- ``journal``: instância de Journal
- ``url``: string, URL da política de atualização
- ``text``: string, texto da política de atualização

Retorna a instância de CrossmarkPage salva.
"""
crossmark = CrossmarkPage.objects(journal=journal, language=language).first()

if crossmark is None:
crossmark = CrossmarkPage(
doi=doi,
is_doi_active=is_doi_active,
language=language,
journal=journal,
)
else:
crossmark.doi = doi
crossmark.is_doi_active = is_doi_active

crossmark.url = url
crossmark.text = text

return crossmark.save()


Comment on lines +1968 to +1986
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

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

This upsert is implemented as a read-then-write (first() then save()), which is not atomic. Under concurrent POST/PUT calls for the same (journal, language) pair it can create duplicate documents and update only one of them. Prefer an atomic modify(..., upsert=True, new=True) / update_one(upsert=True, ...) on the queryset filtered by (journal, language), setting all fields in one operation.

Suggested change
crossmark = CrossmarkPage.objects(journal=journal, language=language).first()
if crossmark is None:
crossmark = CrossmarkPage(
doi=doi,
is_doi_active=is_doi_active,
language=language,
journal=journal,
)
else:
crossmark.doi = doi
crossmark.is_doi_active = is_doi_active
crossmark.url = url
crossmark.text = text
return crossmark.save()
crossmark = CrossmarkPage.objects(journal=journal, language=language).modify(
upsert=True,
new=True,
set__doi=doi,
set__is_doi_active=is_doi_active,
set__url=url,
set__text=text,
set_on_insert__journal=journal,
set_on_insert__language=language,
)
return crossmark

Copilot uses AI. Check for mistakes.
def get_crossmark_page_by_doi(doi):
"""
Retorna um CrossmarkPage pelo seu DOI, ou None se não existir.
"""
return CrossmarkPage.objects(doi=doi).first()
64 changes: 64 additions & 0 deletions opac/webapp/main/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2220,6 +2220,70 @@ def journal_last_issues(*args):
return list(controllers.journal_last_issues() or [])


@restapi.route("/crossmarkpolicy", methods=["POST", "PUT"])
@helper.token_required
def crossmarkpolicy(*args):
"""
Endpoint para criar ou atualizar a política de atualização (CrossmarkPage) de um periódico.

Payload de exemplo:
{
"doi": "10.1234/example.doi",
"is_doi_active": true,
"language": "pt",
"journal_id": "1678-4464",
"url": "https://example.com/crossmark",
"text": "Crossmark policy text"
}

O campo ``journal_id`` pode ser um eISSN, pISSN ou scielo_issn do periódico.
"""
payload = request.get_json()
Copy link
Member

Choose a reason for hiding this comment

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

@copilot payload tem que ter url e text

Copy link
Contributor Author

Choose a reason for hiding this comment

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

url and text are now required fields — both validated with a 400 response if missing. Commit: 5c28a56.

Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

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

request.get_json() can raise a BadRequest (returning Flask’s default 400 HTML) when the request has Content-Type: application/json but an empty/invalid body. That would bypass the intended {"failed": true, "error": "missing payload"} response (and makes response.get_json() unreliable in tests). Use request.get_json(silent=True) (or catch BadRequest) and handle None explicitly to always return a JSON error payload.

Suggested change
payload = request.get_json()
payload = request.get_json(silent=True)

Copilot uses AI. Check for mistakes.

if not payload:
return jsonify({"failed": True, "error": "missing payload"}), 400

doi = payload.get("doi")
if not doi:
return jsonify({"failed": True, "error": "missing field: doi"}), 400

language = payload.get("language")
if not language:
return jsonify({"failed": True, "error": "missing field: language"}), 400

journal_id = payload.get("journal_id")
if not journal_id:
return jsonify({"failed": True, "error": "missing field: journal_id"}), 400

journal = controllers.get_journal_by_issn(issn=journal_id)
if not journal:
return jsonify({"failed": True, "error": "journal not found"}), 404

is_doi_active = payload.get("is_doi_active", True)

url = payload.get("url")
if not url:
return jsonify({"failed": True, "error": "missing field: url"}), 400

text = payload.get("text")
if not text:
return jsonify({"failed": True, "error": "missing field: text"}), 400

try:
crossmark = controllers.add_crossmark_page(
doi=doi,
is_doi_active=is_doi_active,
language=language,
journal=journal,
url=url,
text=text,
)
except Exception as ex:
return jsonify({"failed": True, "error": str(ex)}), 500
else:
return jsonify({"failed": False, "doi": crossmark.doi}), 200


def remover_tags_html(texto):
soup = BeautifulSoup(texto, 'html.parser')
return soup.get_text()
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,6 @@ zope.interface==5.5.2
tox==4.3.5
PyJWT==2.8.0
tenacity==8.2.3
-e git+https://git@github.com/scieloorg/opac_schema@v2.9.0#egg=Opac_Schema
opac-schema==2.10.0
-e git+https://git@github.com/scieloorg/packtools@4.12.4#egg=packtools
-e git+https://github.com/scieloorg/scieloh5m5.git@1.9.5#egg=scieloh5m5