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
10 changes: 10 additions & 0 deletions packtools/sps/locale/es/LC_MESSAGES/packtools_sps.po
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,16 @@ msgstr "Agregue <pub-date publication-format=\"electronic\" date-type=\"pub\"> c
msgid "Add <pub-date publication-format=\"electronic\" date-type=\"collection\"> with <year>"
msgstr "Agregue <pub-date publication-format=\"electronic\" date-type=\"collection\"> con <year>"

#: packtools/sps/validation/dates.py
#, python-brace-format
msgid "<pub-date date-type=\"pub\"> ({pub_date}) must not be later than {limit}"
msgstr "<pub-date date-type=\"pub\"> ({pub_date}) no debe ser posterior a {limit}"

#: packtools/sps/validation/dates.py
#, python-brace-format
msgid "<pub-date date-type=\"pub\"> ({pub_date}) must not be more than {tolerance_months} months before <pub-date date-type=\"collection\"> year ({collection_year})"
msgstr "<pub-date date-type=\"pub\"> ({pub_date}) no debe ser más de {tolerance_months} meses anterior al año de <pub-date date-type=\"collection\"> ({collection_year})"

#: packtools/sps/validation/dates.py:411
#, python-brace-format
msgid "Set @publication-format=\"electronic\" in <pub-date date-type=\"{date_type}\">"
Expand Down
10 changes: 10 additions & 0 deletions packtools/sps/locale/pt_BR/LC_MESSAGES/packtools_sps.po
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,16 @@ msgstr "Adicione <pub-date publication-format=\"electronic\" date-type=\"pub\">
msgid "Add <pub-date publication-format=\"electronic\" date-type=\"collection\"> with <year>"
msgstr "Adicione <pub-date publication-format=\"electronic\" date-type=\"collection\"> com <year>"

#: packtools/sps/validation/dates.py
#, python-brace-format
msgid "<pub-date date-type=\"pub\"> ({pub_date}) must not be later than {limit}"
msgstr "<pub-date date-type=\"pub\"> ({pub_date}) não deve ser posterior a {limit}"

#: packtools/sps/validation/dates.py
#, python-brace-format
msgid "<pub-date date-type=\"pub\"> ({pub_date}) must not be more than {tolerance_months} months before <pub-date date-type=\"collection\"> year ({collection_year})"
msgstr "<pub-date date-type=\"pub\"> ({pub_date}) não deve ser mais de {tolerance_months} meses anterior ao ano de <pub-date date-type=\"collection\"> ({collection_year})"

#: packtools/sps/validation/dates.py:411
#, python-brace-format
msgid "Set @publication-format=\"electronic\" in <pub-date date-type=\"{date_type}\">"
Expand Down
89 changes: 88 additions & 1 deletion packtools/sps/validation/dates.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from datetime import date, datetime
from datetime import date, datetime, timedelta

from packtools.sps.models.dates import FulltextDates
from packtools.sps.validation.utils import build_response, get_future_date
Expand Down Expand Up @@ -284,6 +284,12 @@ def _get_default_params(self):
"pub_date_uniqueness_error_level": "ERROR",
"day_value_error_level": "ERROR",
"month_value_error_level": "ERROR",
"pub_date_future_error_level": "CRITICAL",
"pub_date_past_collection_error_level": "CRITICAL",
# pub-date sanity tolerances (issue #1268)
"pub_date_future_tolerance_days": 7,
"pub_date_past_collection_tolerance_months": 12,
"today": None,
# Event lists — alinhados com article_dates_rules.json
"required_events": ["received", "accepted"],
"pre_pub_ordered_events": [
Expand Down Expand Up @@ -329,6 +335,8 @@ def validate(self):
yield from self.validate_pub_date_collection_required_year()
yield from self.validate_pub_date_collection_no_day()
yield from self.validate_day_month_values()
yield from self.validate_pub_date_not_in_future()
yield from self.validate_pub_date_not_too_far_before_collection()
yield from self.validate_article_date()
yield from self.validate_collection_date()
yield from self.validate_history_dates()
Expand Down Expand Up @@ -553,6 +561,85 @@ def validate_day_month_values(self):
error_level=self.params["month_value_error_level"],
)

def validate_pub_date_not_in_future(self):
"""Rule 9: Validate that pub-date[@date-type='pub'] is not later than
today + tolerance (days). Catches typos such as year 2029 instead of
2026, which OPAC silently hides from public access (issue #1268).
Only applies to main article (not sub-articles).
"""
if self.fulltext.tag != "article":
return
epub_date_model = self.fulltext.epub_date_model
pub_date = epub_date_model and epub_date_model.date
if not pub_date:
return

tolerance_days = self.params["pub_date_future_tolerance_days"]
today = self.params.get("today") or date.today()
limit = today + timedelta(days=tolerance_days)
is_valid = pub_date <= limit

yield build_response(
title="pub-date pub not in future",
parent=self.params["parent"],
item="pub-date",
sub_item="pub",
validation_type="value",
is_valid=is_valid,
expected=f'<pub-date date-type="pub"> no later than {limit.isoformat()}',
obtained=pub_date.isoformat(),
advice=f'<pub-date date-type="pub"> ({pub_date.isoformat()}) must not be later than {limit.isoformat()}',
advice_text=i18n._('<pub-date date-type="pub"> ({pub_date}) must not be later than {limit}'),

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.

@Rossi-Luciano suspeito que isso não funciona:

i18n._('<pub-date date-type="pub"> ({pub_date}) must not be later than {limit}')

teria que ser

i18n._('<pub-date date-type="pub"> ({pub_date}) must not be later than {limit}').format(
    pub_date=pub_date, 
    limit=limit
)

@Rossi-Luciano Rossi-Luciano Aug 16, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@robertatakenaka checando com calma, isso segue o mesmo padrão usado em todo o módulo sps/validation/ (não é específico dessas duas linhas): advice_text=i18n._("...{pub_date}...{limit}") é armazenado como template (sem .format()), e os valores ficam separados em advice_params. O build_response() retorna os dois como adv_text (template traduzido) e adv_params (valores), para o consumidor renderizar com adv_text.format(**adv_params), exatamente como msg_text/msg_params já fazem, documentado em tests/sps/validation/test_i18n_message_rendering.py::render_message.

Testei manualmente as duas mensagens (regra 1 e regra 2) chamando adv_text.format(**adv_params) e ambas renderizam corretamente, sem KeyError:

<pub-date date-type="pub"> (2029-07-27) must not be later than 2026-06-15
<pub-date date-type="pub"> (2024-01-01) must not be more than 12 months before <pub-date date-type="collection"> year (2026)

Se eu aplicasse .format() aqui dentro do dates.py, o adv_text passaria a conter a string já interpolada em inglês, quebrando a tradução dinâmica para outros locales (diferente de todos os outros validators do módulo). Por isso preferi manter como está, mas se você já sabia disso e mesmo assim prefere mudar o padrão aqui, me avisa que ajusto.

Também adicionei em test_dates.py a renderização explícita de adv_text.format(**adv_params) nos casos de erro, para isso ficar coberto automaticamente daqui pra frente.

advice_params={
"pub_date": pub_date.isoformat(),
"limit": limit.isoformat(),
},
data=self.fulltext.epub_date,
error_level=self.params["pub_date_future_error_level"],
)

def validate_pub_date_not_too_far_before_collection(self):
"""Rule 10: Validate that pub-date[@date-type='pub'] is not more than
N months before the collection year (issue #1268, regra 2).
Only applies to main article (not sub-articles).
"""
if self.fulltext.tag != "article":
return
epub_date_model = self.fulltext.epub_date_model
pub_date = epub_date_model and epub_date_model.date
collection_date = self.fulltext.collection_date
collection_year = collection_date and collection_date.get("year")
if not pub_date or not collection_year:
return
try:
collection_start = date(int(collection_year), 1, 1)
except (ValueError, TypeError):
return

tolerance_months = self.params["pub_date_past_collection_tolerance_months"]
earliest_allowed = collection_start - timedelta(days=30 * tolerance_months)
is_valid = pub_date >= earliest_allowed

yield build_response(
title="pub-date pub not too far before collection",
parent=self.params["parent"],
item="pub-date",
sub_item="pub",
validation_type="value",
is_valid=is_valid,
expected=f'<pub-date date-type="pub"> no earlier than {earliest_allowed.isoformat()} ({tolerance_months} months before collection year {collection_year})',
obtained=pub_date.isoformat(),
advice=f'<pub-date date-type="pub"> ({pub_date.isoformat()}) must not be more than {tolerance_months} months before <pub-date date-type="collection"> year ({collection_year})',
advice_text=i18n._('<pub-date date-type="pub"> ({pub_date}) must not be more than {tolerance_months} months before <pub-date date-type="collection"> year ({collection_year})'),

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.

@Rossi-Luciano mesmo problema do caso anterior

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@robertatakenaka mesma explicação do comentário na linha 592 (padrão advice_text/advice_paramsadv_text/adv_params, formatado pelo consumidor, igual msg_text/msg_params). Também validei essa mensagem especificamente e renderiza corretamente com adv_text.format(**adv_params).

advice_params={
"pub_date": pub_date.isoformat(),
"tolerance_months": tolerance_months,
"collection_year": collection_year,
},
data=self.fulltext.epub_date,
error_level=self.params["pub_date_past_collection_error_level"],
)

def validate_article_date(self):
"""Validate the main article date."""
if article_date := self.fulltext.article_date:
Expand Down
4 changes: 4 additions & 0 deletions packtools/sps/validation_rules/article_dates_rules.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
"pub_date_uniqueness_error_level":"ERROR",
"day_value_error_level":"ERROR",
"month_value_error_level":"ERROR",
"pub_date_future_error_level":"CRITICAL",
"pub_date_past_collection_error_level":"CRITICAL",
"pub_date_future_tolerance_days":7,
"pub_date_past_collection_tolerance_months":12,
"required_events":[
"received",
"accepted"
Expand Down
175 changes: 174 additions & 1 deletion tests/sps/validation/test_dates.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from datetime import date
from datetime import date, timedelta
from unittest import TestCase
from unittest.mock import Mock, patch

Expand Down Expand Up @@ -1452,3 +1452,176 @@ def test_missing_only_absent_events(self):
"'accepted' deve constar em missing_events")
self.assertNotIn("received", validator.missing_events,
"'received' está presente no histórico e não deve aparecer em missing_events")


class TestPubDateFutureAndCollectionDistanceValidation(TestCase):

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.

@Rossi-Luciano tem muitos testes em que é OK. Gostaria de ver mais testes apresentando a mensagem de erro, pois ajuda a visualizar a lógica.

@Rossi-Luciano Rossi-Luciano Aug 16, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@robertatakenaka aplicado: adicionei assertIn/assertEqual sobre advice e sobre o adv_text.format(**adv_params) renderizado nos 3 casos de erro que antes só checavam o response ("CRITICAL"), cobrindo agora as duas regras e o caso de coleção retrospectiva + futuro. Commit novo no PR.

"""Testes para as regras 9 e 10 (issue #1268):

- pub-date[@date-type="pub"] não pode estar no futuro além de uma
tolerância em dias (regra 1 da issue). Reproduz o bug real: o artigo
0102-6720-abcd-39-e1948 teve pub-date pub=2029 (digitado por engano
no lugar de 2026) e ficou oculto na produção sem gerar erro.
- pub-date pub não pode ser mais de N meses anterior ao ano de
pub-date[@date-type="collection"] (regra 2 da issue).
- Coleções retrospectivas (pub muito posterior ao collection, mas não
no futuro) continuam permitidas (regra 3 da issue) — testado como
guarda de regressão, já que não há checagem que bloqueie esse caso.

O parâmetro "today" é injetado nos params para tornar os testes
determinísticos, sem depender do relógio real da máquina.
"""

TODAY = date(2026, 6, 15)

def _make_params(self, **overrides):
params = {
"parent": {"parent": "article"},
"required_events": [],
"pre_pub_ordered_events": [
"preprint", "received", "rev-request", "rev-recd", "revised", "accepted"
],
"pos_pub_ordered_events": ["pub", "resubmitted", "corrected", "retracted"],
"required_history_events_for_article_type": {},
"required_history_events_for_related_article_type": {},
"today": self.TODAY,
}
params.update(overrides)
return params

def _article_xml(self, pub_date, collection_year=None):
collection_block = ""
if collection_year is not None:
collection_block = f"""
<pub-date publication-format="electronic" date-type="collection">
<year>{collection_year}</year>
</pub-date>"""
return f"""
<article article-type="research-article" xml:lang="pt">
<front>
<article-meta>
<pub-date publication-format="electronic" date-type="pub">
<day>{pub_date.day:02d}</day><month>{pub_date.month:02d}</month><year>{pub_date.year}</year>
</pub-date>{collection_block}
</article-meta>
</front>
</article>
"""

def _results(self, pub_date, collection_year=None, **param_overrides):
tree = etree.fromstring(self._article_xml(pub_date, collection_year))
validator = FulltextDatesValidation(tree, self._make_params(**param_overrides))
results = list(validator.validate())
future = [r for r in results if r["title"] == "pub-date pub not in future"]
distance = [r for r in results if r["title"] == "pub-date pub not too far before collection"]
return future, distance

@staticmethod
def _rendered_advice(result):
"""Renderiza adv_text + adv_params como um consumidor real faria
(ex.: spsvalidator), confirmando que os placeholders do template
i18n batem com as chaves de adv_params."""
return result["adv_text"].format(**result["adv_params"])

# Regra 1: pub não pode estar no futuro -----------------------------

def test_pub_equal_today_is_ok(self):
future, _ = self._results(self.TODAY, collection_year=self.TODAY.year)
self.assertEqual(1, len(future))
self.assertEqual("OK", future[0]["response"])

def test_pub_within_future_tolerance_is_ok(self):
pub = self.TODAY + timedelta(days=5)
future, _ = self._results(
pub, collection_year=self.TODAY.year, pub_date_future_tolerance_days=5
)
self.assertEqual("OK", future[0]["response"])

def test_pub_far_in_future_is_error(self):
"""Reproduz o bug real da issue: pub 3 anos à frente (2029 vs 2026)."""
pub = date(self.TODAY.year + 3, 1, 1)
future, _ = self._results(pub, collection_year=self.TODAY.year)
self.assertEqual("CRITICAL", future[0]["response"])
self.assertIn("must not be later than", future[0]["advice"])
self.assertEqual(
f'<pub-date date-type="pub"> ({pub.isoformat()}) must not be '
f"later than {(self.TODAY + timedelta(days=7)).isoformat()}",
self._rendered_advice(future[0]),
)

# Regra 4: pub == collection -----------------------------------------

def test_pub_equal_collection_year_is_ok(self):
pub = date(self.TODAY.year, 3, 1)
future, distance = self._results(pub, collection_year=self.TODAY.year)
self.assertEqual("OK", future[0]["response"])
self.assertEqual("OK", distance[0]["response"])

# Regra 2: pub não pode ser muito anterior ao collection -------------

def test_pub_up_to_12_months_before_collection_is_ok(self):
collection_year = self.TODAY.year
pub = date(collection_year - 1, 2, 1) # dentro da tolerância de 12 meses
_, distance = self._results(pub, collection_year=collection_year)
self.assertEqual("OK", distance[0]["response"])

def test_pub_more_than_12_months_before_collection_is_error(self):
collection_year = self.TODAY.year
pub = date(collection_year - 2, 1, 1) # bem além da tolerância de 12 meses
_, distance = self._results(pub, collection_year=collection_year)
self.assertEqual("CRITICAL", distance[0]["response"])
self.assertIn("must not be more than", distance[0]["advice"])

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.

@Rossi-Luciano alguns lugares está more than e later than...

@Rossi-Luciano Rossi-Luciano Aug 16, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@robertatakenaka boa observação, mas não é acidental: são duas comparações diferentes. "later than {limit}" para a regra 1 (pub comparado a uma data-limite) e "more than {N} months before {collection}" para a regra 2 (pub comparado a uma duração/intervalo). Achei que cada frase fica mais natural para o tipo de comparação que descreve, mas se preferir uma redação única para as duas mensagens (facilita grep/padronização), me diga qual formato prefere que eu unifico.

self.assertEqual(
f'<pub-date date-type="pub"> ({pub.isoformat()}) must not be more '
f'than 12 months before <pub-date date-type="collection"> year '
f"({collection_year})",
self._rendered_advice(distance[0]),
)

# Regra 3: coleção retrospectiva (pub muito posterior ao collection) -

def test_pub_many_years_after_collection_but_not_future_is_ok(self):
"""Coleção retrospectiva: pub muito posterior ao collection, mas <= hoje."""
collection_year = self.TODAY.year - 100
future, distance = self._results(self.TODAY, collection_year=collection_year)
self.assertEqual("OK", future[0]["response"])
self.assertEqual("OK", distance[0]["response"])

def test_pub_many_years_after_collection_and_in_future_is_error(self):
"""Mesmo em coleção retrospectiva, pub não pode estar no futuro."""
collection_year = self.TODAY.year - 100
pub = date(self.TODAY.year + 3, 1, 1)
future, distance = self._results(pub, collection_year=collection_year)
self.assertEqual("CRITICAL", future[0]["response"])
self.assertIn("must not be later than", future[0]["advice"])
self.assertEqual(
f'<pub-date date-type="pub"> ({pub.isoformat()}) must not be '
f"later than {(self.TODAY + timedelta(days=7)).isoformat()}",
self._rendered_advice(future[0]),
)
# A distância para trás não é violada (pub é muito posterior ao collection)
self.assertEqual("OK", distance[0]["response"])

# Casos sem collection -------------------------------------------------

def test_no_collection_date_skips_distance_rule(self):
_, distance = self._results(self.TODAY, collection_year=None)
self.assertEqual([], distance)

def test_no_pub_date_skips_both_rules(self):
tree = etree.fromstring("""
<article article-type="research-article" xml:lang="pt">
<front>
<article-meta>
<pub-date publication-format="electronic" date-type="collection">
<year>2026</year>
</pub-date>
</article-meta>
</front>
</article>
""")
validator = FulltextDatesValidation(tree, self._make_params())
results = list(validator.validate())
future = [r for r in results if r["title"] == "pub-date pub not in future"]
distance = [r for r in results if r["title"] == "pub-date pub not too far before collection"]
self.assertEqual([], future)
self.assertEqual([], distance)