diff --git a/config/urls.py b/config/urls.py
index c9fe1f8cd..364207213 100755
--- a/config/urls.py
+++ b/config/urls.py
@@ -71,6 +71,8 @@
path("api/v2/", api_router.urls),
path("users/", include("core.users.urls", namespace="users")),
path("i18n/", include("django.conf.urls.i18n")),
+ # Public reports
+ path("report/", include("report.urls")),
path("", include("allauth.urls")),
path("", include(wagtail_urls)),
)
diff --git a/locale/en/LC_MESSAGES/django.mo b/locale/en/LC_MESSAGES/django.mo
index 9e46af4b1..5ea6b1ef5 100644
Binary files a/locale/en/LC_MESSAGES/django.mo and b/locale/en/LC_MESSAGES/django.mo differ
diff --git a/locale/en/LC_MESSAGES/django.po b/locale/en/LC_MESSAGES/django.po
index 24e9a47e7..66fdc3c5b 100644
--- a/locale/en/LC_MESSAGES/django.po
+++ b/locale/en/LC_MESSAGES/django.po
@@ -5406,3 +5406,84 @@ msgstr ""
#~ msgid "Home"
#~ msgstr "Home"
+
+#: report
+msgctxt "filters"
+msgid "Clear"
+msgstr "Clear"
+
+#: report
+msgid "Reports"
+msgstr "Reports"
+
+#: report
+msgid "Data availability report"
+msgstr "Data availability report"
+
+#: report
+msgid "Data availability"
+msgstr "Data availability"
+
+#: report
+msgid "Articles by data availability status, per year. The online publication year filter applies to the year the article was published on the website, which may differ from the issue year shown in the bibliographic strip."
+msgstr "Articles by data availability status, per year. The online publication year filter applies to the year the article was published on the website, which may differ from the issue year shown in the bibliographic strip."
+
+#: report
+msgid "Articles by data availability status per year, with totals and percentages. Filters: collection, journal and online publication year."
+msgstr "Articles by data availability status per year, with totals and percentages. Filters: collection, journal and online publication year."
+
+#: report
+msgid "Filter"
+msgstr "Filter"
+
+#: report
+msgid "Download CSV"
+msgstr "Download CSV"
+
+#: report
+msgid "Total"
+msgstr "Total"
+
+#: report
+msgid "No year"
+msgstr "No year"
+
+#: report
+msgid "No articles found for the selected filters."
+msgstr "No articles found for the selected filters."
+
+#: report
+msgid "Status legend"
+msgstr "Status legend"
+
+#: report
+msgid "Public reports about the content managed by SciELO Content Manager."
+msgstr "Public reports about the content managed by SciELO Content Manager."
+
+#: report
+msgid "No reports available."
+msgstr "No reports available."
+
+#: report
+msgid "Online publication from"
+msgstr "Online publication from"
+
+#: report
+msgid "Online publication to"
+msgstr "Online publication to"
+
+#: report
+msgid "Group by"
+msgstr "Group by"
+
+#: report
+msgid "Issue year (bibliographic strip)"
+msgstr "Issue year (bibliographic strip)"
+
+#: report
+msgid "Online publication year"
+msgstr "Online publication year"
+
+#: report/templates/report/visual_element_totals.html
+msgid "Clear"
+msgstr "Clear"
diff --git a/locale/es/LC_MESSAGES/django.mo b/locale/es/LC_MESSAGES/django.mo
index d039b314d..9a52271c0 100644
Binary files a/locale/es/LC_MESSAGES/django.mo and b/locale/es/LC_MESSAGES/django.mo differ
diff --git a/locale/es/LC_MESSAGES/django.po b/locale/es/LC_MESSAGES/django.po
index eeade342a..b003d9d02 100644
--- a/locale/es/LC_MESSAGES/django.po
+++ b/locale/es/LC_MESSAGES/django.po
@@ -6054,3 +6054,84 @@ msgstr "Esquema de Publicación de SciELO"
#~ msgid "Message type"
#~ msgstr "Tipo de mensaje"
+
+#: report
+msgctxt "filters"
+msgid "Clear"
+msgstr "Limpiar"
+
+#: report
+msgid "Reports"
+msgstr "Informes"
+
+#: report
+msgid "Data availability report"
+msgstr "Informe de disponibilidad de datos"
+
+#: report
+msgid "Data availability"
+msgstr "Disponibilidad de datos"
+
+#: report
+msgid "Articles by data availability status, per year. The online publication year filter applies to the year the article was published on the website, which may differ from the issue year shown in the bibliographic strip."
+msgstr "Artículos por estado de disponibilidad de datos, por año. El filtro de año de publicación en línea se aplica al año en que el artículo fue publicado en el sitio, que puede ser diferente del año del número indicado en la leyenda bibliográfica."
+
+#: report
+msgid "Articles by data availability status per year, with totals and percentages. Filters: collection, journal and online publication year."
+msgstr "Artículos por estado de disponibilidad de datos por año, con totales y porcentajes. Filtros: colección, revista y año de publicación en línea."
+
+#: report
+msgid "Filter"
+msgstr "Filtrar"
+
+#: report
+msgid "Download CSV"
+msgstr "Descargar CSV"
+
+#: report
+msgid "Total"
+msgstr "Total"
+
+#: report
+msgid "No year"
+msgstr "Sin año"
+
+#: report
+msgid "No articles found for the selected filters."
+msgstr "No se encontraron artículos para los filtros seleccionados."
+
+#: report
+msgid "Status legend"
+msgstr "Leyenda de los estados"
+
+#: report
+msgid "Public reports about the content managed by SciELO Content Manager."
+msgstr "Informes públicos sobre el contenido gestionado por SciELO Content Manager."
+
+#: report
+msgid "No reports available."
+msgstr "No hay informes disponibles."
+
+#: report
+msgid "Online publication from"
+msgstr "Publicación en línea desde"
+
+#: report
+msgid "Online publication to"
+msgstr "Publicación en línea hasta"
+
+#: report
+msgid "Group by"
+msgstr "Agrupar por"
+
+#: report
+msgid "Issue year (bibliographic strip)"
+msgstr "Año del número (leyenda bibliográfica)"
+
+#: report
+msgid "Online publication year"
+msgstr "Año de publicación en línea"
+
+#: report/templates/report/visual_element_totals.html
+msgid "Clear"
+msgstr "Limpiar"
diff --git a/locale/pt_BR/LC_MESSAGES/django.mo b/locale/pt_BR/LC_MESSAGES/django.mo
index fe6ef859f..7cb5b7d50 100644
Binary files a/locale/pt_BR/LC_MESSAGES/django.mo and b/locale/pt_BR/LC_MESSAGES/django.mo differ
diff --git a/locale/pt_BR/LC_MESSAGES/django.po b/locale/pt_BR/LC_MESSAGES/django.po
index bfc77c4d7..44c33575e 100644
--- a/locale/pt_BR/LC_MESSAGES/django.po
+++ b/locale/pt_BR/LC_MESSAGES/django.po
@@ -6068,3 +6068,84 @@ msgstr "Esquema de Publicação SciELO"
#~ msgid "Message type"
#~ msgstr "Tipo de mensagem"
+
+#: report
+msgctxt "filters"
+msgid "Clear"
+msgstr "Limpar"
+
+#: report
+msgid "Reports"
+msgstr "Relatórios"
+
+#: report
+msgid "Data availability report"
+msgstr "Relatório de disponibilidade de dados"
+
+#: report
+msgid "Data availability"
+msgstr "Disponibilidade de dados"
+
+#: report
+msgid "Articles by data availability status, per year. The online publication year filter applies to the year the article was published on the website, which may differ from the issue year shown in the bibliographic strip."
+msgstr "Artigos por status de disponibilidade de dados, por ano. O filtro de ano de publicação online se aplica ao ano em que o artigo foi publicado no site, que pode ser diferente do ano do fascículo indicado na legenda bibliográfica."
+
+#: report
+msgid "Articles by data availability status per year, with totals and percentages. Filters: collection, journal and online publication year."
+msgstr "Artigos por status de disponibilidade de dados por ano, com totais e porcentagens. Filtros: coleção, periódico e ano de publicação online."
+
+#: report
+msgid "Filter"
+msgstr "Filtrar"
+
+#: report
+msgid "Download CSV"
+msgstr "Baixar CSV"
+
+#: report
+msgid "Total"
+msgstr "Total"
+
+#: report
+msgid "No year"
+msgstr "Sem ano"
+
+#: report
+msgid "No articles found for the selected filters."
+msgstr "Nenhum artigo encontrado para os filtros selecionados."
+
+#: report
+msgid "Status legend"
+msgstr "Legenda dos status"
+
+#: report
+msgid "Public reports about the content managed by SciELO Content Manager."
+msgstr "Relatórios públicos sobre o conteúdo gerenciado pelo SciELO Content Manager."
+
+#: report
+msgid "No reports available."
+msgstr "Nenhum relatório disponível."
+
+#: report
+msgid "Online publication from"
+msgstr "Publicação online de"
+
+#: report
+msgid "Online publication to"
+msgstr "Publicação online até"
+
+#: report
+msgid "Group by"
+msgstr "Agrupar por"
+
+#: report
+msgid "Issue year (bibliographic strip)"
+msgstr "Ano do fascículo (legenda bibliográfica)"
+
+#: report
+msgid "Online publication year"
+msgstr "Ano de publicação online"
+
+#: report/templates/report/visual_element_totals.html
+msgid "Clear"
+msgstr "Limpar"
diff --git a/report/data_availability.py b/report/data_availability.py
new file mode 100644
index 000000000..db289d2b4
--- /dev/null
+++ b/report/data_availability.py
@@ -0,0 +1,150 @@
+from django.db.models import Count, Exists, OuterRef
+from django.utils.translation import gettext_lazy as _
+
+from article import choices
+from article.models import Article
+from journal.models import Journal, SciELOJournal
+
+GROUP_BY_ISSUE_YEAR = "issue_year"
+GROUP_BY_PUB_YEAR = "pub_year"
+
+GROUP_BY_OPTIONS = (
+ (GROUP_BY_ISSUE_YEAR, _("Issue year (bibliographic strip)")),
+ (GROUP_BY_PUB_YEAR, _("Online publication year")),
+)
+
+GROUP_BY_FIELDS = {
+ GROUP_BY_ISSUE_YEAR: "issue__year",
+ GROUP_BY_PUB_YEAR: "pub_date_year",
+}
+
+
+def status_columns():
+ return list(choices.DATA_AVAILABILITY_STATUS)
+
+
+def filter_articles(collection=None, journal=None, year_from=None, year_to=None):
+ """
+ Artigos considerados no relatório.
+
+ year_from / year_to referem-se ao ano de publicação online (pub_date_year),
+ independente do ano do fascículo.
+ """
+ qs = Article.objects.exclude(data_status__in=choices.DATA_STATUS_EXCLUSION_LIST)
+ if collection:
+ # Exists evita contagem duplicada de periódicos em mais de uma coleção
+ qs = qs.filter(
+ Exists(
+ SciELOJournal.objects.filter(
+ journal_id=OuterRef("journal_id"),
+ collection=collection,
+ )
+ )
+ )
+ if journal:
+ qs = qs.filter(journal=journal)
+ if year_from:
+ qs = qs.filter(pub_date_year__gte=year_from)
+ if year_to:
+ qs = qs.filter(pub_date_year__lte=year_to)
+ return qs
+
+
+def journals_for(collection=None):
+ journals = Journal.objects.all()
+ if collection:
+ journals = journals.filter(
+ Exists(
+ SciELOJournal.objects.filter(
+ journal_id=OuterRef("pk"),
+ collection=collection,
+ )
+ )
+ )
+ return journals.order_by("title")
+
+
+def pub_years_for(collection=None, journal=None):
+ """Anos de publicação online disponíveis, em ordem decrescente."""
+ return list(
+ filter_articles(collection=collection, journal=journal)
+ .filter(pub_date_year__regex=r"^\d{4}$")
+ .order_by("-pub_date_year")
+ .values_list("pub_date_year", flat=True)
+ .distinct()
+ )
+
+
+def filter_options(collection=None, journal=None):
+ """Opções dependentes: coleção -> periódicos; coleção + periódico -> anos."""
+ return {
+ "journals": [
+ {"id": item.pk, "label": str(item)} for item in journals_for(collection)
+ ],
+ "years": pub_years_for(collection, journal),
+ }
+
+
+def percent(value, total):
+ return round(value * 100 / total, 2) if total else 0
+
+
+def _make_row(year, counts, columns):
+ total = sum(counts.values())
+ return {
+ "year": year,
+ "total": total,
+ "cells": [
+ {
+ "status": status,
+ "count": counts.get(status, 0),
+ "percent": percent(counts.get(status, 0), total),
+ }
+ for status, _label in columns
+ ],
+ }
+
+
+def build_report(queryset, group_by=GROUP_BY_ISSUE_YEAR):
+ """
+ Contabiliza Article.data_availability_status por ano.
+
+ Retorna dict com:
+ columns: [(status, label), ...]
+ rows: [{"year", "total", "cells": [{"status", "count", "percent"}]}]
+ totals: {"year": None, "total", "cells": [...]}
+ """
+ year_field = GROUP_BY_FIELDS.get(group_by, GROUP_BY_FIELDS[GROUP_BY_ISSUE_YEAR])
+ data = (
+ queryset.order_by()
+ .values(year_field, "data_availability_status")
+ .annotate(n=Count("pk"))
+ )
+
+ columns = status_columns()
+ by_year = {}
+ for item in data:
+ year = item[year_field] or None
+ status = (
+ item["data_availability_status"]
+ or choices.DATA_AVAILABILITY_STATUS_NOT_PROCESSED
+ )
+ counts = by_year.setdefault(year, {})
+ counts[status] = counts.get(status, 0) + item["n"]
+
+ # anos em ordem decrescente; sem ano por último
+ years = sorted((year for year in by_year if year), reverse=True)
+ if None in by_year:
+ years.append(None)
+ rows = [_make_row(year, by_year[year], columns) for year in years]
+
+ all_counts = {}
+ for counts in by_year.values():
+ for status, n in counts.items():
+ all_counts[status] = all_counts.get(status, 0) + n
+
+ return {
+ "columns": columns,
+ "rows": rows,
+ "totals": _make_row(None, all_counts, columns),
+ }
diff --git a/report/forms.py b/report/forms.py
index 35e9bf3ad..05bc56894 100644
--- a/report/forms.py
+++ b/report/forms.py
@@ -3,6 +3,7 @@
from collection.models import Collection
from journal.models import Journal
+from report.data_availability import GROUP_BY_ISSUE_YEAR, GROUP_BY_OPTIONS
from thematic_areas.models import ThematicArea
@@ -36,3 +37,48 @@ def __init__(self, *args, **kwargs):
self.fields["journal"].queryset = Journal.objects.order_by("title").prefetch_related(
"scielojournal_set"
)
+
+
+class DataAvailabilityFilterForm(forms.Form):
+ collection = forms.ModelChoiceField(
+ queryset=Collection.objects.order_by("acron3"),
+ required=False,
+ label=_("Collection"),
+ empty_label=_("All"),
+ )
+ journal = forms.ModelChoiceField(
+ queryset=Journal.objects.order_by("title"),
+ required=False,
+ label=_("Journal"),
+ empty_label=_("All"),
+ )
+ year_from = forms.RegexField(
+ regex=r"^\d{4}$",
+ required=False,
+ label=_("Online publication from"),
+ )
+ year_to = forms.RegexField(
+ regex=r"^\d{4}$",
+ required=False,
+ label=_("Online publication to"),
+ )
+ group_by = forms.ChoiceField(
+ choices=GROUP_BY_OPTIONS,
+ required=False,
+ label=_("Group by"),
+ )
+
+ def filters(self):
+ """
+ Filtros válidos; campos inválidos são ignorados em vez de
+ invalidar o relatório inteiro.
+ """
+ self.is_valid()
+ data = getattr(self, "cleaned_data", {})
+ return {
+ "collection": data.get("collection"),
+ "journal": data.get("journal"),
+ "year_from": data.get("year_from") or None,
+ "year_to": data.get("year_to") or None,
+ "group_by": data.get("group_by") or GROUP_BY_ISSUE_YEAR,
+ }
diff --git a/report/templates/report/data_availability.html b/report/templates/report/data_availability.html
new file mode 100644
index 000000000..7cba927d9
--- /dev/null
+++ b/report/templates/report/data_availability.html
@@ -0,0 +1,160 @@
+{% extends "report/public_base.html" %}
+{% load i18n %}
+
+{% block title %}{% translate "Data availability report" %}{% endblock %}
+
+{% block content %}
+
{% translate "Data availability report" %}
+
+ {% translate "Articles by data availability status, per year. The online publication year filter applies to the year the article was published on the website, which may differ from the issue year shown in the bibliographic strip." %}
+
+
+
+
+ {% if report.totals.total %}
+
+
+
+
+
{% translate "Year" %}
+ {% for status, label in report.columns %}
+
{{ status }}
+ {% endfor %}
+
{% translate "Total" %}
+
+
+ {% for status, label in report.columns %}
+
n
%
+ {% endfor %}
+
+
+
+ {% for row in report.rows %}
+
+
{{ row.year|default:_("No year") }}
+ {% for cell in row.cells %}
+
{{ cell.count }}
{{ cell.percent|floatformat:2 }}
+ {% endfor %}
+
{{ row.total }}
+
+ {% endfor %}
+
+
{% translate "Total" %}
+ {% for cell in report.totals.cells %}
+
{{ cell.count }}
{{ cell.percent|floatformat:2 }}
+ {% endfor %}
+
{{ report.totals.total }}
+
+
+
+
+ {% else %}
+
{% translate "No articles found for the selected filters." %}