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." %} +

+ +
+ + + + + + + {% translate "Clear" context "filters" %} + {% translate "Download CSV" %} +
+ + {% if report.totals.total %} +
+ + + + + {% for status, label in report.columns %} + + {% endfor %} + + + + {% for status, label in report.columns %} + + {% endfor %} + + + + {% for row in report.rows %} + + + {% for cell in row.cells %} + + {% endfor %} + + + {% endfor %} + + + {% for cell in report.totals.cells %} + + {% endfor %} + + + +
{% translate "Year" %}{{ status }}{% translate "Total" %}
n%
{{ row.year|default:_("No year") }}{{ cell.count }}{{ cell.percent|floatformat:2 }}{{ row.total }}
{% translate "Total" %}{{ cell.count }}{{ cell.percent|floatformat:2 }}{{ report.totals.total }}
+
+ {% else %} +
{% translate "No articles found for the selected filters." %}
+ {% endif %} + +

{% translate "Status legend" %}

+
+ {% for status, label in report.columns %} +
{{ status }}
{{ label }}
+ {% endfor %} +
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/report/templates/report/index.html b/report/templates/report/index.html new file mode 100644 index 000000000..a9744e29a --- /dev/null +++ b/report/templates/report/index.html @@ -0,0 +1,23 @@ +{% extends "report/public_base.html" %} +{% load i18n %} + +{% block breadcrumb %}{% endblock %} + +{% block content %} +

{% translate "Reports" %}

+

{% translate "Public reports about the content managed by SciELO Content Manager." %}

+ {% if reports %} + + {% else %} +
{% translate "No reports available." %}
+ {% endif %} +{% endblock %} diff --git a/report/templates/report/public_base.html b/report/templates/report/public_base.html new file mode 100644 index 000000000..9b5648ef6 --- /dev/null +++ b/report/templates/report/public_base.html @@ -0,0 +1,68 @@ +{% load i18n %} +{% get_current_language as LANGUAGE_CODE %} + + + + + {% block title %}{% translate "Reports" %}{% endblock %} + + + +
+ {% block breadcrumb %} + + {% endblock %} + {% block content %}{% endblock %} +
+{% block extra_js %}{% endblock %} + + diff --git a/report/test_data_availability.py b/report/test_data_availability.py new file mode 100644 index 000000000..3cbc49f0f --- /dev/null +++ b/report/test_data_availability.py @@ -0,0 +1,365 @@ +from django.contrib.auth import get_user_model +from django.core.cache import cache +from django.test import TestCase +from django.urls import reverse +from django.utils import translation + +from article import choices +from article.models import Article +from collection.models import Collection +from issue.models import Issue +from journal.models import Journal, SciELOJournal +from report import data_availability +from report.forms import DataAvailabilityFilterForm +from report.views import PUBLIC_REPORTS + +User = get_user_model() + +NOT_PROCESSED = choices.DATA_AVAILABILITY_STATUS_NOT_PROCESSED + + +class DataAvailabilityDataMixin: + """ + Artigos: + 1. data-available, fascículo 2023, online 2022, periódico X (coleções scl e arg) + 2. absent, fascículo 2023, online 2023, periódico X (coleções scl e arg) + 3. data-available, fascículo 2024, online 2023, periódico Y (coleção scl) + 4. not-processed, fascículo 2024, online 2024, periódico Z (coleção arg) + 5. data-available, fascículo 2024, online 2024, periódico Z, DELETED (ignorado) + """ + + @classmethod + def setUpTestData(cls): + cls.user = User.objects.create_user(username="das-report-user", password="x") + cls.col_scl = Collection.objects.create(acron3="scl", creator=cls.user) + cls.col_arg = Collection.objects.create(acron3="arg", creator=cls.user) + cls.journal_x = Journal.objects.create(title="Journal X", creator=cls.user) + cls.journal_y = Journal.objects.create(title="Journal Y", creator=cls.user) + cls.journal_z = Journal.objects.create(title="Journal Z", creator=cls.user) + for journal, collections in ( + (cls.journal_x, [cls.col_scl, cls.col_arg]), + (cls.journal_y, [cls.col_scl]), + (cls.journal_z, [cls.col_arg]), + ): + for collection in collections: + SciELOJournal.objects.create( + journal=journal, + collection=collection, + journal_acron=journal.title[-1].lower(), + creator=cls.user, + ) + cls.issue_2023 = Issue.objects.create(year="2023", creator=cls.user) + cls.issue_2024 = Issue.objects.create(year="2024", creator=cls.user) + + def make(pid, status, issue, pub_year, journal, data_status=None): + return Article.objects.create( + pid_v3=pid, + creator=cls.user, + issue=issue, + journal=journal, + pub_date_year=pub_year, + data_availability_status=status, + data_status=data_status or choices.DATA_STATUS_PUBLIC, + ) + + cls.articles = [ + make("das-1", choices.DATA_AVAILABILITY_STATUS_AVAILABLE, cls.issue_2023, "2022", cls.journal_x), + make("das-2", choices.DATA_AVAILABILITY_STATUS_ABSENT, cls.issue_2023, "2023", cls.journal_x), + make("das-3", choices.DATA_AVAILABILITY_STATUS_AVAILABLE, cls.issue_2024, "2023", cls.journal_y), + make("das-4", NOT_PROCESSED, cls.issue_2024, "2024", cls.journal_z), + make( + "das-5", + choices.DATA_AVAILABILITY_STATUS_AVAILABLE, + cls.issue_2024, + "2024", + cls.journal_z, + data_status=choices.DATA_STATUS_DELETED, + ), + ] + + def setUp(self): + cache.clear() + + @staticmethod + def cell(row, status): + return next(c for c in row["cells"] if c["status"] == status) + + +class PercentTest(TestCase): + def test_percent(self): + self.assertEqual(33.33, data_availability.percent(1, 3)) + + def test_zero_total(self): + self.assertEqual(0, data_availability.percent(0, 0)) + + +class BuildReportTest(DataAvailabilityDataMixin, TestCase): + def test_group_by_issue_year(self): + report = data_availability.build_report(data_availability.filter_articles()) + self.assertEqual(["2024", "2023"], [r["year"] for r in report["rows"]]) + + row_2023 = report["rows"][1] + self.assertEqual(2, row_2023["total"]) + available = self.cell(row_2023, choices.DATA_AVAILABILITY_STATUS_AVAILABLE) + self.assertEqual(1, available["count"]) + self.assertEqual(50, available["percent"]) + self.assertEqual( + 50, self.cell(row_2023, choices.DATA_AVAILABILITY_STATUS_ABSENT)["percent"] + ) + self.assertEqual(1, self.cell(report["rows"][0], NOT_PROCESSED)["count"]) + + def test_excludes_deleted_articles(self): + report = data_availability.build_report(data_availability.filter_articles()) + self.assertEqual(4, report["totals"]["total"]) + + def test_totals(self): + totals = data_availability.build_report( + data_availability.filter_articles() + )["totals"] + available = self.cell(totals, choices.DATA_AVAILABILITY_STATUS_AVAILABLE) + self.assertEqual(2, available["count"]) + self.assertEqual(50, available["percent"]) + self.assertEqual(25, self.cell(totals, NOT_PROCESSED)["percent"]) + self.assertEqual( + 0, self.cell(totals, choices.DATA_AVAILABILITY_STATUS_INVALID)["count"] + ) + + def test_columns_follow_choices(self): + report = data_availability.build_report(data_availability.filter_articles()) + self.assertEqual( + [status for status, _label in choices.DATA_AVAILABILITY_STATUS], + [cell["status"] for cell in report["totals"]["cells"]], + ) + + def test_null_status_counts_as_not_processed(self): + Article.objects.filter(pk=self.articles[3].pk).update( + data_availability_status=None + ) + totals = data_availability.build_report( + data_availability.filter_articles() + )["totals"] + self.assertEqual(1, self.cell(totals, NOT_PROCESSED)["count"]) + + def test_group_by_pub_year(self): + report = data_availability.build_report( + data_availability.filter_articles(), + group_by=data_availability.GROUP_BY_PUB_YEAR, + ) + self.assertEqual( + ["2024", "2023", "2022"], [r["year"] for r in report["rows"]] + ) + self.assertEqual(2, report["rows"][1]["total"]) + + def test_without_year_goes_last(self): + Article.objects.filter(pk=self.articles[0].pk).update(issue=None) + report = data_availability.build_report(data_availability.filter_articles()) + self.assertEqual(["2024", "2023", None], [r["year"] for r in report["rows"]]) + + def test_collection_does_not_duplicate(self): + report = data_availability.build_report( + data_availability.filter_articles(collection=self.col_scl) + ) + self.assertEqual(3, report["totals"]["total"]) + + def test_journal(self): + report = data_availability.build_report( + data_availability.filter_articles(journal=self.journal_z) + ) + self.assertEqual(1, report["totals"]["total"]) + + def test_pub_year_range_is_independent_of_issue_year(self): + report = data_availability.build_report( + data_availability.filter_articles(year_from="2023", year_to="2023") + ) + self.assertEqual(2, report["totals"]["total"]) + self.assertEqual(["2024", "2023"], [r["year"] for r in report["rows"]]) + + def test_empty(self): + report = data_availability.build_report(Article.objects.none()) + self.assertEqual([], report["rows"]) + self.assertEqual(0, report["totals"]["total"]) + + +class FilterOptionsTest(DataAvailabilityDataMixin, TestCase): + def test_journals_all(self): + titles = [j.title for j in data_availability.journals_for()] + for title in ("Journal X", "Journal Y", "Journal Z"): + self.assertIn(title, titles) + + def test_journals_by_collection(self): + self.assertEqual( + ["Journal X", "Journal Y"], + [j.title for j in data_availability.journals_for(self.col_scl)], + ) + self.assertEqual( + ["Journal X", "Journal Z"], + [j.title for j in data_availability.journals_for(self.col_arg)], + ) + + def test_pub_years(self): + self.assertEqual(["2024", "2023", "2022"], data_availability.pub_years_for()) + self.assertEqual( + ["2023", "2022"], data_availability.pub_years_for(self.col_scl) + ) + self.assertEqual( + ["2023", "2022"], + data_availability.pub_years_for(self.col_arg, self.journal_x), + ) + + def test_filter_options(self): + options = data_availability.filter_options(self.col_arg, self.journal_z) + self.assertEqual( + [ + {"id": self.journal_x.pk, "label": "Journal X"}, + {"id": self.journal_z.pk, "label": "Journal Z"}, + ], + options["journals"], + ) + self.assertEqual(["2024"], options["years"]) + + +class DataAvailabilityFilterFormTest(DataAvailabilityDataMixin, TestCase): + def test_valid(self): + filters = DataAvailabilityFilterForm( + { + "collection": self.col_scl.pk, + "journal": self.journal_x.pk, + "year_from": "2022", + "year_to": "2023", + "group_by": data_availability.GROUP_BY_PUB_YEAR, + } + ).filters() + self.assertEqual(self.col_scl, filters["collection"]) + self.assertEqual(self.journal_x, filters["journal"]) + self.assertEqual("2022", filters["year_from"]) + self.assertEqual(data_availability.GROUP_BY_PUB_YEAR, filters["group_by"]) + + def test_invalid_fields_are_ignored(self): + filters = DataAvailabilityFilterForm( + { + "collection": self.col_scl.pk, + "journal": "abc", + "year_from": "20x", + "group_by": "x", + } + ).filters() + self.assertEqual(self.col_scl, filters["collection"]) + self.assertIsNone(filters["journal"]) + self.assertIsNone(filters["year_from"]) + self.assertEqual(data_availability.GROUP_BY_ISSUE_YEAR, filters["group_by"]) + + +class DataAvailabilityViewTest(DataAvailabilityDataMixin, TestCase): + def url(self): + return reverse("report_data_availability") + + def test_is_public(self): + response = self.client.get(self.url()) + self.assertEqual(200, response.status_code) + self.assertTemplateUsed(response, "report/data_availability.html") + self.assertEqual(4, response.context["report"]["totals"]["total"]) + + def test_filters(self): + response = self.client.get( + self.url(), + { + "collection": self.col_scl.pk, + "year_from": "2023", + "year_to": "2023", + "group_by": data_availability.GROUP_BY_PUB_YEAR, + }, + ) + report = response.context["report"] + self.assertEqual(2, report["totals"]["total"]) + self.assertEqual(["2023"], [r["year"] for r in report["rows"]]) + self.assertEqual( + ["Journal X", "Journal Y"], [j.title for j in response.context["journals"]] + ) + self.assertEqual(["2023", "2022"], response.context["years"]) + + def test_invalid_params(self): + response = self.client.get( + self.url(), {"journal": "abc", "year_from": "x", "group_by": "x"} + ) + self.assertEqual(200, response.status_code) + self.assertEqual(4, response.context["report"]["totals"]["total"]) + + def test_empty_result(self): + response = self.client.get(self.url(), {"year_from": "2099"}) + self.assertEqual(200, response.status_code) + self.assertContains(response, 'class="empty"') + + def test_csv(self): + response = self.client.get(self.url(), {"format": "csv"}) + self.assertEqual(200, response.status_code) + self.assertIn("text/csv", response["Content-Type"]) + lines = response.content.decode().strip().splitlines() + self.assertTrue(lines[0].startswith("year;total;data-available;")) + self.assertTrue(lines[1].startswith("2024;2;")) + self.assertTrue(lines[-1].startswith("total;4;")) + + def test_csv_link_does_not_repeat_format(self): + response = self.client.get(self.url(), {"year_from": "2023"}) + self.assertEqual("year_from=2023", response.context["query_string"]) + + def test_options_url_in_page(self): + response = self.client.get(self.url()) + self.assertContains( + response, + f'data-options-url="{reverse("report_data_availability_options")}"', + ) + + +class DataAvailabilityOptionsViewTest(DataAvailabilityDataMixin, TestCase): + def url(self): + return reverse("report_data_availability_options") + + def test_is_public_json(self): + response = self.client.get(self.url()) + self.assertEqual(200, response.status_code) + self.assertEqual("application/json", response["Content-Type"]) + self.assertEqual(["2024", "2023", "2022"], response.json()["years"]) + + def test_collection_and_journal(self): + data = self.client.get( + self.url(), {"collection": self.col_scl.pk, "journal": self.journal_y.pk} + ).json() + self.assertEqual( + ["Journal X", "Journal Y"], [j["label"] for j in data["journals"]] + ) + self.assertEqual(["2023"], data["years"]) + + +class ReportIndexViewTest(TestCase): + def test_is_public(self): + response = self.client.get(reverse("report_index")) + self.assertEqual(200, response.status_code) + self.assertTemplateUsed(response, "report/index.html") + + def test_unprefixed_url_redirects_to_language(self): + response = self.client.get("/report/", follow=True) + self.assertEqual(200, response.status_code) + self.assertTemplateUsed(response, "report/index.html") + + def test_lists_all_reports(self): + response = self.client.get(reverse("report_index")) + for report in PUBLIC_REPORTS: + self.assertContains(response, f'href="{reverse(report["url_name"])}"') + self.assertContains(response, str(report["title"])) + + def test_report_page_links_back_to_index(self): + cache.clear() + response = self.client.get(reverse("report_data_availability")) + self.assertContains(response, f'href="{reverse("report_index")}"') + + +class DataAvailabilityTranslationTest(TestCase): + def setUp(self): + cache.clear() + + def test_clear_button_in_portuguese(self): + with translation.override("pt-br"): + url = reverse("report_data_availability") + response = self.client.get(url) + self.assertContains(response, ">Limpar") + self.assertNotContains(response, "Claro") diff --git a/report/urls.py b/report/urls.py new file mode 100644 index 000000000..82bb1159f --- /dev/null +++ b/report/urls.py @@ -0,0 +1,21 @@ +from django.urls import path + +from report.views import ( + data_availability_options_view, + data_availability_view, + report_index_view, +) + +urlpatterns = [ + path("", report_index_view, name="report_index"), + path( + "data-availability/", + data_availability_view, + name="report_data_availability", + ), + path( + "data-availability/options/", + data_availability_options_view, + name="report_data_availability_options", + ), +] diff --git a/report/views.py b/report/views.py index 5eab9cd25..1d2f48f67 100644 --- a/report/views.py +++ b/report/views.py @@ -1,10 +1,12 @@ import csv -from django.http import HttpResponse +from django.http import HttpResponse, JsonResponse from django.shortcuts import render from django.utils.translation import gettext_lazy as _ +from django.views.decorators.cache import cache_page -from report.forms import VisualElementTotalsFilterForm +from report import data_availability +from report.forms import DataAvailabilityFilterForm, VisualElementTotalsFilterForm from report.visual_element_totals import ( filtered_article_ids, spreadsheet_rows, @@ -72,3 +74,90 @@ def visual_element_totals_csv_view(request): ] ) return response + + +# Relatórios públicos listados em /report/. +# Para adicionar um relatório: inclua o path em report/urls.py e uma entrada aqui. +PUBLIC_REPORTS = [ + { + "url_name": "report_data_availability", + "title": _("Data availability"), + "description": _( + "Articles by data availability status per year, with totals and " + "percentages. Filters: collection, journal and online publication year." + ), + }, +] + + +def report_index_view(request): + return render(request, "report/index.html", {"reports": PUBLIC_REPORTS}) + + +# relatório público consulta toda a tabela Article; evita recalcular a cada acesso +DATA_AVAILABILITY_CACHE_SECONDS = 60 * 60 + + +@cache_page(DATA_AVAILABILITY_CACHE_SECONDS) +def data_availability_view(request): + """ + Relatório público: Article.data_availability_status por ano, + com total e porcentagem, filtrável por coleção, periódico e + ano de publicação online. + """ + form = DataAvailabilityFilterForm(request.GET) + filters = form.filters() + queryset = data_availability.filter_articles( + collection=filters["collection"], + journal=filters["journal"], + year_from=filters["year_from"], + year_to=filters["year_to"], + ) + report = data_availability.build_report(queryset, group_by=filters["group_by"]) + + if request.GET.get("format") == "csv": + return _data_availability_csv(report) + + query = request.GET.copy() + query.pop("format", None) + return render( + request, + "report/data_availability.html", + { + "form": form, + "filters": filters, + "report": report, + "journals": data_availability.journals_for(filters["collection"]), + "years": data_availability.pub_years_for( + filters["collection"], filters["journal"] + ), + "query_string": query.urlencode(), + }, + ) + + +@cache_page(DATA_AVAILABILITY_CACHE_SECONDS) +def data_availability_options_view(request): + """Opções dos filtros dependentes (JSON): periódicos e anos disponíveis.""" + filters = DataAvailabilityFilterForm(request.GET).filters() + return JsonResponse( + data_availability.filter_options(filters["collection"], filters["journal"]) + ) + + +def _data_availability_csv(report): + response = HttpResponse(content_type="text/csv; charset=utf-8") + response["Content-Disposition"] = ( + 'attachment; filename="data_availability.csv"' + ) + writer = csv.writer(response, delimiter=";") + header = ["year", "total"] + for status, _label in report["columns"]: + header.extend([status, f"{status} (%)"]) + writer.writerow(header) + for row in report["rows"] + [dict(report["totals"], year="total")]: + line = [row["year"] or "", row["total"]] + for cell in row["cells"]: + line.extend([cell["count"], cell["percent"]]) + writer.writerow(line) + return response