diff --git a/docs/superpowers/specs/2026-08-31-pivot-stronicowanie-sortowanie-design.md b/docs/superpowers/specs/2026-08-31-pivot-stronicowanie-sortowanie-design.md new file mode 100644 index 000000000..c71549c33 --- /dev/null +++ b/docs/superpowers/specs/2026-08-31-pivot-stronicowanie-sortowanie-design.md @@ -0,0 +1,163 @@ +# Tabela krzyżowa: stronicowanie i sortowanie + +Data: 2026-08-31 +Branch: `feat-pivot-stronicowanie-sortowanie` + +## Problem + +Tabela krzyżowa (`postac=pivot` na `/zapytanie/`, `report_type=pivot` +w multiseeku) renderuje **wszystkie** wiersze macierzy naraz. Przy +`pivot_row=autor` to ~2000 wierszy na jednej stronie — nieużywalne. + +Brakuje też jakiegokolwiek sterowania kolejnością: `_labels()` sortuje +alfabetycznie po etykiecie (albo malejąco po roku dla `rok`/`koszyk_pk`) +i to jedyna dostępna kolejność. Typowe pytanie użytkownika brzmi „kto ma +najwięcej prac" — dziś wymaga eksportu do XLSX i posortowania w Excelu. + +## Zakres + +1. Stronicowanie wierszy macierzy — **obie** ścieżki wejścia (multiseek + „precyzyjne" i `/zapytanie/` DjangoQL), bo obie renderują ten sam + partial `multiseek/report-body-pivot.html`. +2. Sortowanie wierszy: po etykiecie (dzisiejsze, domyślne) albo po sumie + wiersza (RAZEM), oba kierunki. + +Poza zakresem: sortowanie kolumn (kolumn jest z definicji mało — bramka +`PIVOT_MAX_CELLS` i tak by nie przepuściła szerokiej macierzy), sortowanie +po konkretnej kolumnie, stronicowanie kolumn. + +## Rozważone podejścia + +| | Opis | Werdykt | +|---|---|---| +| A | Sortuj + stronicuj **już zbudowaną** macierz w pamięci | **wybrane** | +| B | `LIMIT/OFFSET` na kluczach wierszy w SQL-u | odrzucone | +| C | Sortowanie/stronicowanie po stronie klienta (DataTables) | odrzucone | + +**B** wymagałoby drugiego przebiegu po zbiorze, żeby policzyć sumy kolumn +i sumę całkowitą (te muszą obejmować cały dataset, nie widoczną stronę), +a przy wymiarze idącym przez `autorzy__` płaciłoby ten sam drogi JOIN +dwa razy. Bramka `PIVOT_MAX_PAIRS` i tak ogranicza rozmiar tego, co wchodzi +do RAM-u, więc oszczędność pamięci byłaby iluzoryczna. + +**C** przeczy sednu zgłoszenia: 2000 wierszy nadal poleciałoby do +przeglądarki. Strona musi też działać bez JS (partial ma `
{% endif %} {% if postac == "pivot" %} - {# Tabela krzyżowa — NIE stronicowana (rozmiar macierzy nie #} - {# zalezy od liczby stron rekordow), wiec bez pagera. #} + {# Tabela krzyżowa ma WŁASNY pager (parametr pivot_page, #} + {# wewnątrz partiala) — stronicuje wiersze macierzy, nie #} + {# rekordy, więc _zapytanie_pager.html tu nie pasuje. #} {% include "multiseek/report-body-pivot.html" %} {% else %} {% include "bpp/_zapytanie_pager.html" %} diff --git a/src/bpp/tests/test_multiseek_pivot_view.py b/src/bpp/tests/test_multiseek_pivot_view.py index 4b52f743d..fc65aba78 100644 --- a/src/bpp/tests/test_multiseek_pivot_view.py +++ b/src/bpp/tests/test_multiseek_pivot_view.py @@ -1,6 +1,9 @@ """Widok Multiseek z report_type=pivot (tabela krzyżowa) — patrz Task 3.""" +import csv +import io import json +import re import pytest from django.conf import settings @@ -200,3 +203,116 @@ def test_pivot_report_type_na_koncu_listy(): # dotychczasowe typy zachowują pozycje (list/table na 0/1) assert multiseek_report_types[0].id == "list" assert multiseek_report_types[1].id == "table" + + +# --- stronicowanie i sortowanie macierzy (multiseek) ------------------------ + + +@pytest.mark.django_db +def test_pivot_multiseek_stronicuje_wiersze( + logged_in_client, test_user, standard_data, denorms +): + """Ta sama funkcja co na /zapytanie/ — body pivota to WSPÓLNY partial, + ale kontekst (pivot_widok/pivot_table) buduje każdy widok osobno.""" + for rok in range(2000, 2030): + any_ciagle(tytul_oryginalny=f"{PIVOT_TITLE_PREFIX} - {rok}", rok=rok) + denorms.flush() + _set_multiseek_pivot_filter(logged_in_client, test_user) + + resp = logged_in_client.get( + reverse("multiseek:results") + + "?pivot_row=rok&pivot_val=liczba&pivot_per_page=25" + ) + + t = resp.context["pivot_table"] + assert t["wszystkich_wierszy"] == 30 + assert len(t["rows"]) == 25 + assert t["is_paginated"] is True + assert "pivot_page=2" in resp.content.decode().replace("&", "&") + + +@pytest.mark.django_db +def test_pivot_multiseek_sortowanie_po_sumie( + logged_in_client, test_user, standard_data, denorms +): + # TRZY lata o różnych licznościach. Przy dwóch wierszach „sortuj po + # sumie malejąco" i „odwróć kolejność naturalną" dają ten sam wynik, + # więc test przechodziłby także przy zignorowanym sortowaniu. Tu: + # naturalna (rok malejąco): 2022, 2021, 2020 + # odwrócona naturalna: 2020, 2021, 2022 + # suma malejąco: 2021 (3), 2022 (2), 2020 (1) + # suma rosnąco: 2020, 2022, 2021 + # — cztery różne kolejności, żadnej nie da się pomylić z inną. + for nr, rok in enumerate([2020, 2021, 2021, 2021, 2022, 2022]): + any_ciagle(tytul_oryginalny=f"{PIVOT_TITLE_PREFIX} - {nr}", rok=rok) + denorms.flush() + _set_multiseek_pivot_filter(logged_in_client, test_user) + + naturalna = logged_in_client.get( + reverse("multiseek:results") + "?pivot_row=rok&pivot_val=liczba" + ) + assert [r["label"] for r in naturalna.context["pivot_table"]["rows"]] == [ + "2022", + "2021", + "2020", + ] + + resp = logged_in_client.get( + reverse("multiseek:results") + + "?pivot_row=rok&pivot_val=liczba&pivot_sort=suma&pivot_dir=desc" + ) + + t = resp.context["pivot_table"] + assert [r["label"] for r in t["rows"]] == ["2021", "2022", "2020"] + assert [r["total"] for r in t["rows"]] == [3, 2, 1] + + rosnaco = logged_in_client.get( + reverse("multiseek:results") + + "?pivot_row=rok&pivot_val=liczba&pivot_sort=suma&pivot_dir=asc" + ) + assert [r["label"] for r in rosnaco.context["pivot_table"]["rows"]] == [ + "2020", + "2022", + "2021", + ] + + +@pytest.mark.django_db +def test_pivot_multiseek_linki_sortowania_gasza_tryb_wydruku( + logged_in_client, test_user, standard_data, denorms +): + """{% querystring %} przenosi CAŁY bieżący GET — a common-results.html + odpala window.print() dla ?print=1. Bez print=None klik w sortowanie + otwierałby okno drukowania.""" + any_ciagle(tytul_oryginalny=f"{PIVOT_TITLE_PREFIX} - print", rok=2024) + denorms.flush() + _set_multiseek_pivot_filter(logged_in_client, test_user) + + resp = logged_in_client.get( + reverse("multiseek:results") + "?pivot_row=rok&pivot_val=liczba&print=1" + ) + html = resp.content.decode().replace("&", "&") + + linki = re.findall(r'class="multiseek-pivot-sort"\s+href="([^"]+)"', html) + assert linki, "brak linków sortujących w wyrenderowanej macierzy" + assert all("print=1" not in link for link in linki) + assert any("pivot_sort=suma" in link for link in linki) + + +@pytest.mark.django_db +def test_pivot_multiseek_eksport_ignoruje_strone( + logged_in_client, test_user, standard_data, denorms +): + for rok in range(2000, 2030): + any_ciagle(tytul_oryginalny=f"{PIVOT_TITLE_PREFIX} - {rok}", rok=rok) + denorms.flush() + _set_multiseek_pivot_filter(logged_in_client, test_user) + + resp = logged_in_client.get( + reverse("multiseek-export", args=["csv"]) + + "?pivot_row=rok&pivot_val=liczba&pivot_per_page=25&pivot_page=2" + ) + + wiersze = list(csv.reader(io.StringIO(resp.content.decode()))) + # nagłówek + 30 lat + RAZEM + assert len(wiersze) == 32 diff --git a/src/bpp/tests/test_pivot_widok.py b/src/bpp/tests/test_pivot_widok.py new file mode 100644 index 000000000..e5b369886 --- /dev/null +++ b/src/bpp/tests/test_pivot_widok.py @@ -0,0 +1,357 @@ +"""PivotWidok — sortowanie i stronicowanie już policzonej macierzy. + +Testy są czysto pamięciowe (bez bazy): `PivotResult` budujemy ręcznie, bo +sprawdzamy warstwę PREZENTACJI, nie agregację. Za agregację odpowiadają +test_multiseek_pivot.py i test_pivot_autor.py. +""" + +import pytest + +from bpp.pivot import core + + +def _dim(key="jednostka", label="Jednostka"): + return core.PivotDimension(key=key, label=label, expr=key) + + +def _metric(): + return core.PivotMetric(key="liczba", label="Liczba prac", field=None) + + +def _wynik(pary, row_dim=None): + """PivotResult bez kolumn: `pary` to lista (etykieta, suma_wiersza).""" + row_dim = row_dim or _dim() + rows = [(label, label) for label, _ in pary] + row_totals = {label: total for label, total in pary} + return core.PivotResult( + rows=rows, + cols=[], + cells={(label, None): total for label, total in pary}, + row_totals=row_totals, + col_totals={}, + grand_total=sum(t for _, t in pary), + row_dim=row_dim, + col_dim=None, + metric=_metric(), + has_autorzy_dim=False, + ) + + +# --- parse_widok ----------------------------------------------------------- + + +def test_parse_widok_domyslne_gdy_pusto(): + w = core.parse_widok({}) + assert w.sort == core.SORT_ETYKIETA + assert w.kierunek is None + assert w.strona == 1 + assert w.na_stronie == core.DOMYSLNIE_NA_STRONIE + + +@pytest.mark.parametrize("wartosc", ["999999", "0", "-5", "", "abc", "51"]) +def test_parse_widok_odrzuca_rozmiar_strony_spoza_whitelisty(wartosc): + """Bez tej walidacji ?pivot_per_page=999999 unieważnia całą zmianę — + wracamy do renderowania dwóch tysięcy wierszy naraz.""" + assert ( + core.parse_widok({"pivot_per_page": wartosc}).na_stronie + == core.DOMYSLNIE_NA_STRONIE + ) + + +@pytest.mark.parametrize("ile", core.DOZWOLONE_NA_STRONIE) +def test_parse_widok_przyjmuje_dozwolone_rozmiary(ile): + assert core.parse_widok({"pivot_per_page": str(ile)}).na_stronie == ile + + +@pytest.mark.parametrize("wartosc", ["abc", "", "0", "-3", None]) +def test_parse_widok_smieciowa_strona_daje_pierwsza(wartosc): + assert core.parse_widok({"pivot_page": wartosc}).strona == 1 + + +def test_parse_widok_smieciowy_sort_i_kierunek_daja_domyslne(): + w = core.parse_widok({"pivot_sort": "xxx", "pivot_dir": "yyy"}) + assert w.sort == core.SORT_ETYKIETA + assert w.kierunek is None + + +def test_kierunek_efektywny_zalezy_od_sortu(): + """Naturalny kierunek etykiety jest rosnący, sumy — malejący + („kto ma najwięcej" to jedyne pytanie, które ludzie zadają).""" + assert core.PivotWidok().kierunek_efektywny == "asc" + assert core.PivotWidok(sort=core.SORT_SUMA).kierunek_efektywny == "desc" + assert ( + core.PivotWidok(sort=core.SORT_SUMA, kierunek="asc").kierunek_efektywny == "asc" + ) + + +def test_bez_stronicowania_zeruje_strone_i_rozmiar(): + w = core.PivotWidok(sort=core.SORT_SUMA, strona=7, na_stronie=25) + b = w.bez_stronicowania() + assert b.na_stronie == 0 + assert b.strona == 1 + assert b.sort == core.SORT_SUMA # sortowanie zostaje + + +# --- sortowanie ------------------------------------------------------------ + + +def test_as_table_bez_widoku_zachowuje_sie_jak_dawniej(): + """Wywołanie bezargumentowe (używa go m.in. eksport) nie może dokładać + kluczy stronicowania ani zmieniać kolejności.""" + w = _wynik([("Beta", 5), ("Alfa", 9)]) + t = w.as_table() + assert [r["label"] for r in t["rows"]] == ["Beta", "Alfa"] + assert "page_obj" not in t + assert "is_paginated" not in t + + +def test_sortowanie_po_sumie_malejaco_domyslnie(): + w = _wynik([("Alfa", 5), ("Beta", 9), ("Gamma", 1)]) + t = w.as_table(core.PivotWidok(sort=core.SORT_SUMA)) + assert [r["label"] for r in t["rows"]] == ["Beta", "Alfa", "Gamma"] + assert [r["total"] for r in t["rows"]] == [9, 5, 1] + + +def test_sortowanie_po_sumie_rosnaco(): + w = _wynik([("Alfa", 5), ("Beta", 9), ("Gamma", 1)]) + t = w.as_table(core.PivotWidok(sort=core.SORT_SUMA, kierunek="asc")) + assert [r["label"] for r in t["rows"]] == ["Gamma", "Alfa", "Beta"] + + +@pytest.mark.parametrize("sort", [core.SORT_ETYKIETA, core.SORT_SUMA]) +def test_kolejnosc_jest_totalna_gdy_etykiety_sie_powtarzaja(sort, monkeypatch): + """Etykiety NIE są unikatowe — dwóch autorów „Kowalski Jan" ma różne PK + i ten sam `str()`. Gdyby remis rozstrzygała sama etykieta, o kolejności + decydowałaby kolejność wejścia — a ta pochodzi z GROUP BY bez ORDER BY, + więc nie jest powtarzalna między wykonaniami. Wiersz na granicy strony + pokazałby się wtedy na dwóch stronach albo na żadnej. + + Wiersze budujemy przez `_labels`, tak jak robi to `_build_matrix`: + dla sort=etykieta cała totalność siedzi właśnie tam. + """ + dim = _dim() + etykiety = {1: "Kowalski Jan", 2: "Kowalski Jan", 3: "Nowak Ewa"} + sumy = {1: 5, 2: 5, 3: 5} # remis także po sumie + monkeypatch.setattr( + core, "_label_mapping", lambda keys, d: {k: etykiety[k] for k in keys} + ) + + def wynik(kolejnosc_wejscia): + rows = core._labels(kolejnosc_wejscia, dim) + return core.PivotResult( + rows=rows, + cols=[], + cells={(k, None): sumy[k] for k, _ in rows}, + row_totals=dict(sumy), + col_totals={}, + grand_total=sum(sumy.values()), + row_dim=dim, + col_dim=None, + metric=_metric(), + has_autorzy_dim=False, + ) + + widok = core.PivotWidok(sort=sort) + a = [p[0] for p in wynik([1, 2, 3]).posortowane_wiersze(widok)] + b = [p[0] for p in wynik([3, 2, 1]).posortowane_wiersze(widok)] + assert a == b == [1, 2, 3] + + +def test_labels_jest_totalne_przy_powtorzonych_etykietach(monkeypatch): + """Warstwa `_labels` osobno: `keys` przychodzi tam jako `set`, a klucze + o wspólnej etykiecie nie mogą się gubić ani przestawiać.""" + dim = _dim() + monkeypatch.setattr( + core, "_label_mapping", lambda keys, d: dict.fromkeys(keys, "Kowalski Jan") + ) + assert core._labels([3, 1, 2], dim) == core._labels([2, 3, 1], dim) + assert [k for k, _ in core._labels([3, 1, 2], dim)] == [1, 2, 3] + + +def test_sortowanie_po_sumie_rozstrzyga_remisy_etykieta(): + """Bez porządku totalnego wiersze o równych sumach mogłyby wypaść w innej + kolejności przy kolejnym żądaniu — a wtedy przy stronicowaniu ten sam + wiersz pokazuje się na dwóch stronach albo znika z obu.""" + w = _wynik([("Gamma", 5), ("Alfa", 5), ("Beta", 5)]) + kolejnosc = [ + r["label"] for r in w.as_table(core.PivotWidok(sort=core.SORT_SUMA))["rows"] + ] + assert kolejnosc == ["Alfa", "Beta", "Gamma"] + # deterministycznie także przy odwróconym wejściu + w2 = _wynik([("Beta", 5), ("Gamma", 5), ("Alfa", 5)]) + assert [ + r["label"] for r in w2.as_table(core.PivotWidok(sort=core.SORT_SUMA))["rows"] + ] == kolejnosc + + +def test_sortowanie_po_etykiecie_desc_odwraca_kolejnosc_naturalna(): + w = _wynik([("Alfa", 1), ("Beta", 2), ("Gamma", 3)]) + t = w.as_table(core.PivotWidok(sort=core.SORT_ETYKIETA, kierunek="desc")) + assert [r["label"] for r in t["rows"]] == ["Gamma", "Beta", "Alfa"] + + +def test_sortowanie_po_sumie_traktuje_brak_sumy_jak_zero(): + w = _wynik([("Alfa", 3), ("Beta", 1)]) + w.row_totals.pop("Alfa") + t = w.as_table(core.PivotWidok(sort=core.SORT_SUMA)) + assert [r["label"] for r in t["rows"]] == ["Beta", "Alfa"] + + +# --- strzałki i cele linków ------------------------------------------------ + + +def test_strzalka_sumy_odzwierciedla_kierunek(): + w = _wynik([("Alfa", 1)]) + t = w.as_table(core.PivotWidok(sort=core.SORT_SUMA)) + assert t["sort_suma_strzalka"] == "▼" + assert t["sort_suma_rosnaco"] is False + assert t["sort_etykieta_strzalka"] == "" # nieaktywna kolumna bez strzałki + + +def test_strzalka_etykiety_odwrocona_dla_roku(): + """Dla roku kolejność naturalna jest MALEJĄCA (najnowszy u góry), więc + ▲ nad listą 2025, 2024, 2023 byłoby kłamstwem.""" + rok = _dim(key="rok", label="Rok") + t = _wynik([("2025", 1)], row_dim=rok).as_table(core.PivotWidok()) + assert t["sort_etykieta_strzalka"] == "▼" + assert t["sort_etykieta_rosnaco"] is False + + jednostka = _wynik([("Alfa", 1)]).as_table(core.PivotWidok()) + assert jednostka["sort_etykieta_strzalka"] == "▲" + + +def test_link_aktywnego_naglowka_odwraca_kierunek(): + w = _wynik([("Alfa", 1)]) + t = w.as_table(core.PivotWidok(sort=core.SORT_SUMA)) # aktywna suma, desc + assert t["sort_suma_link"] == "asc" # klik odwraca + assert t["sort_etykieta_link"] == "asc" # nieaktywna bierze swój domyślny + + t2 = w.as_table(core.PivotWidok(sort=core.SORT_ETYKIETA)) + assert t2["sort_etykieta_link"] == "desc" + assert t2["sort_suma_link"] == "desc" + + +# --- stronicowanie --------------------------------------------------------- + + +def _duzy_wynik(n=120): + return _wynik([(f"Wiersz {i:03d}", i) for i in range(n)]) + + +def test_stronicowanie_tnie_wiersze(): + t = _duzy_wynik(120).as_table(core.PivotWidok(na_stronie=50)) + assert len(t["rows"]) == 50 + assert t["is_paginated"] is True + assert t["paginator"].num_pages == 3 + assert t["wszystkich_wierszy"] == 120 + + +def test_stronicowanie_druga_strona_ma_inne_wiersze(): + widok = core.PivotWidok(na_stronie=50) + pierwsza = _duzy_wynik(120).as_table(widok) + druga = _duzy_wynik(120).as_table(core.PivotWidok(na_stronie=50, strona=2)) + assert pierwsza["rows"][0]["label"] != druga["rows"][0]["label"] + assert druga["page_obj"].number == 2 + assert druga["page_obj"].start_index() == 51 + + +def test_strona_poza_zakresem_daje_ostatnia_nie_404(): + """get_page(), nie page(): user potrafi zostać na ?pivot_page=40 po + zawężeniu filtra i nie ma powodu pokazywać mu błędu.""" + t = _duzy_wynik(120).as_table(core.PivotWidok(na_stronie=50, strona=999)) + assert t["page_obj"].number == 3 + assert len(t["rows"]) == 20 + + +def test_sumy_brzegowe_dotycza_calego_zbioru_nie_strony(): + """Semantyka Excela: RAZEM podsumowuje cały wynik. Szablon musi to + napisać pod tabelą (t.is_paginated → adnotacja).""" + pelny = _duzy_wynik(120) + calosc = sum(range(120)) + t = pelny.as_table(core.PivotWidok(na_stronie=25, strona=3)) + assert t["grand_total"] == calosc + assert sum(r["total"] for r in t["rows"]) < calosc + + +def test_jedna_strona_nie_jest_stronicowana(): + t = _wynik([("Alfa", 1), ("Beta", 2)]).as_table(core.PivotWidok(na_stronie=50)) + assert t["is_paginated"] is False + assert len(t["rows"]) == 2 + + +def test_na_stronie_zero_wylacza_stronicowanie(): + """Tego wariantu używa eksport — plik ma mieć pełną macierz.""" + t = _duzy_wynik(120).as_table(core.PivotWidok(na_stronie=0)) + assert len(t["rows"]) == 120 + assert t["paginator"] is None + assert t["is_paginated"] is False + + +def test_zakres_stron_zawiera_wielokropek_jako_none(): + t = _duzy_wynik(2000).as_table(core.PivotWidok(na_stronie=25, strona=20)) + assert None in t["page_range"] + assert 20 in t["page_range"] + + +# --- eksport --------------------------------------------------------------- + + +def test_eksport_respektuje_sortowanie_ale_ignoruje_strone(): + from bpp.views.multiseek_export import _pivot_export_rows + + widok = core.PivotWidok( + sort=core.SORT_SUMA, strona=3, na_stronie=25 + ).bez_stronicowania() + wiersze = list(_pivot_export_rows(_duzy_wynik(120), widok)) + # nagłówek + 120 wierszy + RAZEM + assert len(wiersze) == 122 + assert wiersze[1][0] == "Wiersz 119" # największa suma na górze + + +def test_eksport_bez_widoku_dziala_jak_dawniej(): + from bpp.views.multiseek_export import _pivot_export_rows + + wiersze = list(_pivot_export_rows(_wynik([("Beta", 5), ("Alfa", 9)]))) + assert [w[0] for w in wiersze] == ["Jednostka", "Beta", "Alfa", "RAZEM"] + + +# --- render pagera --------------------------------------------------------- + + +def _render_pager(t, url="/multiseek/results/?pivot_row=autor&print=1"): + """Render partiala pagera. Wymaga bazy: render_to_string z `request` + odpala context processory projektu (m.in. czytający Uczelnię).""" + from django.contrib.auth.models import AnonymousUser + from django.template.loader import render_to_string + from django.test import RequestFactory + + request = RequestFactory().get(url) + request.user = AnonymousUser() # context processory projektu jej wymagają + return render_to_string("multiseek/_pivot-pager.html", {"t": t}, request=request) + + +@pytest.mark.django_db +def test_pager_renderuje_wielokropek_i_numery(): + """Wielokropek jedzie do szablonu jako None — sprawdzamy, że `{% if + numer is None %}` naprawdę go łapie, a nie renderuje słowa "None".""" + t = _duzy_wynik(2000).as_table(core.PivotWidok(na_stronie=25, strona=20)) + html = _render_pager(t) + assert 'class="ellipsis"' in html + assert "None" not in html + assert "pivot_page=21" in html.replace("&", "&") + + +@pytest.mark.django_db +def test_pager_linki_gasza_tryb_wydruku(): + t = _duzy_wynik(120).as_table(core.PivotWidok(na_stronie=25)) + html = _render_pager(t).replace("&", "&") + assert "pivot_page=" in html + assert "print=1" not in html + # bieżący filtr macierzy jest przenoszony + assert "pivot_row=autor" in html + + +@pytest.mark.django_db +def test_pager_milczy_gdy_jedna_strona(): + t = _wynik([("Alfa", 1)]).as_table(core.PivotWidok(na_stronie=50)) + assert _render_pager(t).strip() == "" diff --git a/src/bpp/tests/test_zapytanie_pivot.py b/src/bpp/tests/test_zapytanie_pivot.py index 7973ac815..d44c0354d 100644 --- a/src/bpp/tests/test_zapytanie_pivot.py +++ b/src/bpp/tests/test_zapytanie_pivot.py @@ -626,3 +626,175 @@ def test_presety_autora_sa_wykonalne_w_swojej_bazie(): assert metric.key == metric_key, opis assert row.key == row_key, opis assert col is not None and col.key == col_key, opis + + +# --- stronicowanie i sortowanie macierzy (/zapytanie/) --------------------- + + +def _autorzy_nowak(ile, jednostka): + from model_bakery import baker + + from bpp.models import Autor + + return [ + baker.make( + Autor, + nazwisko="Nowak", + imiona=f"Jan {i:03d}", + aktualna_jednostka=jednostka, + ) + for i in range(ile) + ] + + +@pytest.mark.django_db +def test_pivot_autorow_stronicuje_wiersze(redaktor, jednostka): + """Sedno zgłoszenia: pivot_row=autor przy 2 tys. autorów wypluwał + wszystkie wiersze naraz.""" + _autorzy_nowak(30, jednostka) + + res = redaktor.get( + reverse("bpp:zapytanie"), + { + "model": "autor", + "query": 'nazwisko = "Nowak"', + "postac": "pivot", + "pivot_row": "autor", + "pivot_val": "liczba_autorow", + "pivot_per_page": "25", + }, + ) + + assert res.status_code == 200 + t = res.context["pivot_table"] + assert t["wszystkich_wierszy"] == 30 + assert len(t["rows"]) == 25 + assert t["is_paginated"] is True + assert "pivot_page=2" in res.content.decode().replace("&", "&") + + +@pytest.mark.django_db +def test_pivot_druga_strona_pokazuje_reszte(redaktor, jednostka): + _autorzy_nowak(30, jednostka) + + wspolne = { + "model": "autor", + "query": 'nazwisko = "Nowak"', + "postac": "pivot", + "pivot_row": "autor", + "pivot_val": "liczba_autorow", + "pivot_per_page": "25", + } + pierwsza = redaktor.get(reverse("bpp:zapytanie"), wspolne) + druga = redaktor.get(reverse("bpp:zapytanie"), {**wspolne, "pivot_page": "2"}) + + assert len(druga.context["pivot_table"]["rows"]) == 5 + etykiety_1 = {r["label"] for r in pierwsza.context["pivot_table"]["rows"]} + etykiety_2 = {r["label"] for r in druga.context["pivot_table"]["rows"]} + assert not (etykiety_1 & etykiety_2) + # RAZEM nadal dotyczy całego zbioru, nie widocznej strony + assert druga.context["pivot_table"]["grand_total"] == 30 + + +@pytest.mark.django_db +def test_pivot_sortowanie_po_sumie_zmienia_kolejnosc(redaktor, denorms): + """Wiersze mają iść wg kolumny RAZEM, nie wg kolejności naturalnej. + + Dane są ułożone tak, by obie kolejności były RÓŻNE (starszy rok ma + więcej prac, a naturalna kolejność roku jest malejąca) — inaczej test + przechodziłby także przy całkowicie zignorowanym sortowaniu. + """ + from bpp.tests.util import any_ciagle + + # TRZY lata o różnych licznościach. Przy dwóch wierszach „sortuj po + # sumie malejąco" i „odwróć kolejność naturalną" dają ten sam wynik, + # więc test przechodziłby także przy zignorowanym sortowaniu. Tu: + # naturalna (rok malejąco): 2022, 2021, 2020 + # odwrócona naturalna: 2020, 2021, 2022 + # suma malejąco: 2021 (3), 2022 (2), 2020 (1) + # suma rosnąco: 2020, 2022, 2021 + # — cztery różne kolejności, żadnej nie da się pomylić z inną. + for nr, rok in enumerate([2020, 2021, 2021, 2021, 2022, 2022]): + any_ciagle(tytul_oryginalny=f"Sort {nr}", rok=rok) + denorms.flush() + + wspolne = { + "model": "rekord", + "query": "rok >= 2020", + "postac": "pivot", + "pivot_row": "rok", + "pivot_val": "liczba", + } + + naturalna = redaktor.get(reverse("bpp:zapytanie"), wspolne) + assert [r["label"] for r in naturalna.context["pivot_table"]["rows"]] == [ + "2022", + "2021", + "2020", + ] + + res = redaktor.get( + reverse("bpp:zapytanie"), {**wspolne, "pivot_sort": "suma", "pivot_dir": "desc"} + ) + t = res.context["pivot_table"] + assert [r["label"] for r in t["rows"]] == ["2021", "2022", "2020"] + assert [r["total"] for r in t["rows"]] == [3, 2, 1] + assert t["sort"] == "suma" + assert t["sort_suma_strzalka"] == "▼" + + rosnaco = redaktor.get( + reverse("bpp:zapytanie"), {**wspolne, "pivot_sort": "suma", "pivot_dir": "asc"} + ) + assert [r["label"] for r in rosnaco.context["pivot_table"]["rows"]] == [ + "2020", + "2022", + "2021", + ] + assert rosnaco.context["pivot_table"]["sort_suma_strzalka"] == "▲" + + +@pytest.mark.django_db +def test_pivot_formularz_przenosi_sortowanie(redaktor, wydawnictwo_ciagle, denorms): + """Selecty auto-submitują; bez ukrytych pól zmiana wymiaru cicho + wracałaby do kolejności domyślnej.""" + denorms.flush() + res = redaktor.get( + reverse("bpp:zapytanie"), + { + "model": "rekord", + "query": f"rok = {wydawnictwo_ciagle.rok}", + "postac": "pivot", + "pivot_row": "rok", + "pivot_val": "liczba", + "pivot_sort": "suma", + "pivot_dir": "asc", + }, + ) + content = res.content.decode() + assert 'name="pivot_sort" value="suma"' in content + assert 'value="asc"' in content + assert 'name="pivot_per_page"' in content + + +@pytest.mark.django_db +def test_pivot_eksport_ignoruje_strone_ale_bierze_sortowanie(redaktor, jednostka): + """Plik ma zawierać CAŁĄ macierz — dlatego UI nie ma „pokaż wszystkie".""" + _autorzy_nowak(30, jednostka) + + res = redaktor.get( + reverse("bpp:zapytanie_eksport", kwargs={"export_format": "csv"}), + { + "model": "autor", + "query": 'nazwisko = "Nowak"', + "postac": "pivot", + "pivot_row": "autor", + "pivot_val": "liczba_autorow", + "pivot_per_page": "25", + "pivot_page": "2", + "pivot_sort": "suma", + }, + ) + + wiersze = _wiersze_csv(res) + # nagłówek + 30 autorów + RAZEM — strona 2 nie ma na to wpływu + assert len(wiersze) == 32 diff --git a/src/bpp/views/multiseek_export.py b/src/bpp/views/multiseek_export.py index e3f8c00da..b9811373d 100644 --- a/src/bpp/views/multiseek_export.py +++ b/src/bpp/views/multiseek_export.py @@ -646,11 +646,15 @@ def autor_xlsx_export_response(queryset, request, report_title): return response -def _pivot_export_rows(pivot_result): +def _pivot_export_rows(pivot_result, widok=None): """Wiersze eksportu macierzy pivota: nagłówek (etykieta wiersza + etykiety kolumn + RAZEM), wiersze danych, wiersz RAZEM. Puste komórki - → "" (pusty string), nie None.""" - t = pivot_result.as_table() + → "" (pusty string), nie None. + + `widok` (PivotWidok) steruje wyłącznie KOLEJNOŚCIĄ — wywołujący ma + obowiązek podać go po `bez_stronicowania()`, bo plik ma zawierać pełną + macierz, nie widoczną stronę.""" + t = pivot_result.as_table(widok) yield [t["row_header"], *t["col_headers"], "RAZEM"] for row in t["rows"]: cells = ["" if c is None else c for c in row["cells"]] @@ -659,11 +663,11 @@ def _pivot_export_rows(pivot_result): yield ["RAZEM", *col_totals, t["grand_total"]] -def pivot_csv_export_response(pivot_result, request, report_title): +def pivot_csv_export_response(pivot_result, request, report_title, widok=None): """Eksport CSV tabeli krzyżowej (macierz, nie lista rekordów).""" output = io.StringIO() writer = csv.writer(output) - for row in _pivot_export_rows(pivot_result): + for row in _pivot_export_rows(pivot_result, widok): writer.writerow(_sanitize_spreadsheet_row(row)) response = HttpResponse(output.getvalue(), content_type="text/csv; charset=utf-8") @@ -674,7 +678,7 @@ def pivot_csv_export_response(pivot_result, request, report_title): return response -def pivot_xlsx_export_response(pivot_result, request, report_title): +def pivot_xlsx_export_response(pivot_result, request, report_title, widok=None): """Eksport XLSX tabeli krzyżowej (macierz z sumami brzegowymi).""" from openpyxl import Workbook from openpyxl.styles import Alignment, Font, PatternFill @@ -684,7 +688,7 @@ def pivot_xlsx_export_response(pivot_result, request, report_title): workbook = Workbook() worksheet = workbook.active worksheet.title = _xlsx_worksheet_title(report_title) - for row in _pivot_export_rows(pivot_result): + for row in _pivot_export_rows(pivot_result, widok): worksheet.append(sanitize_xlsx_row(row)) header_fill = PatternFill("solid", fgColor="1F4E78") diff --git a/src/bpp/views/mymultiseek.py b/src/bpp/views/mymultiseek.py index f7fce780c..11f9aa69a 100644 --- a/src/bpp/views/mymultiseek.py +++ b/src/bpp/views/mymultiseek.py @@ -158,6 +158,7 @@ def get_context_data(self, **kwargs): if ctx.get("report_type") == "pivot": from bpp.multiseek_registry import pivot as pivot_mod + from bpp.pivot.core import DOZWOLONE_NA_STRONIE self._ensure_default_title() base_qs = self.get_queryset_for_current_mode() @@ -169,11 +170,19 @@ def get_context_data(self, **kwargs): ctx["pivot_row_dim"] = row_dim ctx["pivot_col_dim"] = col_dim ctx["pivot_metric"] = metric + # Widok (sortowanie + strona) idzie osobnym parametrem, bo dotyczy + # prezentacji, nie tego CO policzyć — patrz PivotWidok. + widok = pivot_mod.parse_widok(self.request.GET) + ctx["pivot_widok"] = widok + ctx["pivot_na_stronie_opcje"] = DOZWOLONE_NA_STRONIE # Uczciwy licznik dla breadcrumbu: liczba rekordów, które pivot # podsumowuje (nie 0). ctx["paginator_count"] = base_qs.values("pk").distinct().count() try: ctx["pivot"] = pivot_mod.zbuduj_pivot(base_qs, row_dim, col_dim, metric) + # as_table() z widokiem musi powstać tutaj — szablon nie umie + # wywołać metody z argumentem. + ctx["pivot_table"] = ctx["pivot"].as_table(widok) except pivot_mod.PivotTooLargeError as exc: ctx["pivot"] = None ctx["pivot_error"] = exc @@ -276,6 +285,9 @@ def _export_pivot(self, request, export_format): ) base_qs = self.get_queryset_for_current_mode() row_dim, col_dim, metric = pivot_mod.parse_pivot_params(request.GET) + # Plik ma zawierać CAŁĄ macierz — sortowanie z ekranu przenosimy, + # ale stronicowanie zdejmujemy (bez_stronicowania()). + widok = pivot_mod.parse_widok(request.GET).bez_stronicowania() try: pivot_result = pivot_mod.zbuduj_pivot(base_qs, row_dim, col_dim, metric) except pivot_mod.PivotTooLargeError: @@ -285,8 +297,8 @@ def _export_pivot(self, request, export_format): ) report_title = _multiseek_report_title(request) if export_format == "csv": - return pivot_csv_export_response(pivot_result, request, report_title) - return pivot_xlsx_export_response(pivot_result, request, report_title) + return pivot_csv_export_response(pivot_result, request, report_title, widok) + return pivot_xlsx_export_response(pivot_result, request, report_title, widok) def _export_data(self, request, export_format, queryset, report_title): wariant = request.GET.get("wariant", "dane") diff --git a/src/bpp/views/zapytanie.py b/src/bpp/views/zapytanie.py index faa4b542b..9480d66d6 100644 --- a/src/bpp/views/zapytanie.py +++ b/src/bpp/views/zapytanie.py @@ -658,6 +658,7 @@ def _pivot_context(self, model_key, queryset): rejestr = wybierz_rejestr_pivota(model_key) row_dim, col_dim, metric = rejestr.parse_params(self.request.GET) + widok = pivot_core.parse_widok(self.request.GET) dostepne_wymiary = { key: dim for key, dim in rejestr.DIMENSIONS.items() @@ -669,6 +670,8 @@ def _pivot_context(self, model_key, queryset): "pivot_row_dim": row_dim, "pivot_col_dim": col_dim, "pivot_metric": metric, + "pivot_widok": widok, + "pivot_na_stronie_opcje": pivot_core.DOZWOLONE_NA_STRONIE, "pivot_form_action": reverse("bpp:zapytanie"), "pivot_form_hidden": [ ("model", model_key), @@ -681,6 +684,9 @@ def _pivot_context(self, model_key, queryset): } try: ctx["pivot"] = rejestr.zbuduj(queryset, row_dim, col_dim, metric) + # as_table() z widokiem musi powstać tutaj — szablon nie umie + # wywołać metody z argumentem. + ctx["pivot_table"] = ctx["pivot"].as_table(widok) except pivot_core.PivotTooLargeError as exc: ctx["pivot"] = None ctx["pivot_error"] = exc diff --git a/src/bpp/views/zapytanie_export.py b/src/bpp/views/zapytanie_export.py index 79f600212..e6afdbf81 100644 --- a/src/bpp/views/zapytanie_export.py +++ b/src/bpp/views/zapytanie_export.py @@ -142,6 +142,9 @@ def _eksport_pivota(request, export_format, model_key, queryset, report_title): return _blad("Eksport tabeli krzyżowej dostępny jako XLSX lub CSV.") rejestr = wybierz_rejestr_pivota(model_key) row_dim, col_dim, metric = rejestr.parse_params(request.GET) + # Sortowanie z ekranu przenosi się do pliku, stronicowanie nie — + # eksport zawsze zapisuje pełną macierz. + widok = pivot_core.parse_widok(request.GET).bez_stronicowania() try: pivot_result = rejestr.zbuduj(queryset, row_dim, col_dim, metric) except pivot_core.PivotTooLargeError: @@ -150,8 +153,8 @@ def _eksport_pivota(request, export_format, model_key, queryset, report_title): "zawęź zapytanie lub wybierz mniej liczny wymiar." ) if export_format == "csv": - return pivot_csv_export_response(pivot_result, request, report_title) - return pivot_xlsx_export_response(pivot_result, request, report_title) + return pivot_csv_export_response(pivot_result, request, report_title, widok) + return pivot_xlsx_export_response(pivot_result, request, report_title, widok) @staticmethod def _eksport_dokumentu(request, export_format, postac, queryset, report_title): diff --git a/src/django_bpp/templates/multiseek/_pivot-pager.html b/src/django_bpp/templates/multiseek/_pivot-pager.html new file mode 100644 index 000000000..aba6185c8 --- /dev/null +++ b/src/django_bpp/templates/multiseek/_pivot-pager.html @@ -0,0 +1,58 @@ +{% comment %} +Pager wierszy tabeli krzyżowej. Kontekst: t (wynik PivotResult.as_table +z widokiem) — t.page_obj, t.paginator, t.page_range, t.wszystkich_wierszy. + +Parametr strony to pivot_page, NIE page: multiseek i /zapytanie/ używają +`page` do stronicowania listy rekordów i w gałęzi pivota ten parametr wciąż +może siedzieć w URL-u po przełączeniu postaci wyniku. + +Linki budowane wbudowanym tagiem querystring (Django 5.1+), bo muszą +przenieść CAŁY bieżący filtr (pivot_row/col/val, +pivot_sort/dir, a na /zapytanie/ także model i query). `print=None` kasuje +tryb wydruku — inaczej klik w pager na stronie otwartej z ?print=1 +otwierałby okno drukowania (patrz multiseek/common-results.html). +{% endcomment %} +{% if t.is_paginated %} + +{% endif %} diff --git a/src/django_bpp/templates/multiseek/report-body-pivot.html b/src/django_bpp/templates/multiseek/report-body-pivot.html index 2bb79f16e..c76119acd 100644 --- a/src/django_bpp/templates/multiseek/report-body-pivot.html +++ b/src/django_bpp/templates/multiseek/report-body-pivot.html @@ -2,7 +2,9 @@ Tabela krzyżowa (pivot). Kontekst: pivot (PivotResult lub None gdy za duża), pivot_error (PivotTooLargeError lub brak), pivot_row_dim / pivot_col_dim / pivot_metric (aktualny wybór — dostępne też przy błędzie), pivot_dimensions, -pivot_metrics. Konfiguracja idzie parametrami GET (pivot_row/col/val). +pivot_metrics, pivot_widok (PivotWidok: sortowanie + strona). +Konfiguracja idzie parametrami GET (pivot_row/col/val, pivot_sort/pivot_dir, +pivot_page/pivot_per_page). pivot_form_action / pivot_form_hidden / pivot_export_base to opcjonalne haki konsumenta (domyślne wartości = zachowanie multiseeka bez zmian): strona zapytania trzyma filtr w URL-u (nie w sesji jak multiseek), więc @@ -17,6 +19,13 @@ {% for name, value in pivot_form_hidden %} {% endfor %} + {# Selecty niżej auto-submitują, więc formularz musi przenieść #} + {# wybrane sortowanie — inaczej zmiana wymiaru cicho wracałaby #} + {# do kolejności domyślnej. pivot_page CELOWO nie jest tu #} + {# przenoszone: zmiana wymiaru/metryki ma wracać na stronę 1. #} + + + {% if pivot and request.user.is_authenticated %} {# Prefiks eksportu jest parametrem konsumenta: "../export/" dziala #} @@ -58,14 +74,30 @@ {% if pivot %} - {% with t=pivot.as_table %} + {# pivot_table = pivot.as_table(pivot_widok) — zbudowane w widoku, bo #} + {# szablon Django nie umie wywołać metody z argumentem. #} + {% with t=pivot_table %} + {% include "multiseek/_pivot-pager.html" %}| {{ t.row_header }} | + {# Nagłówki wymiaru wierszy i RAZEM sortują. querystring #} + {# przenosi caly biezacy filtr; pivot_page=None wraca na #} + {# stronę 1, print=None gasi tryb wydruku (patrz pager). #} ++ + {{ t.row_header }} + + | {% for ch in t.col_headers %}{{ ch }} | {% endfor %} -RAZEM | ++ + RAZEM + + |
|---|
+ {% comment %} Bez tego redaktor zsumuje widoczną kolumnę i uzna, że liczby się nie zgadzają. {% endcomment %} + + Wiersz i kolumna „RAZEM" podsumowują cały wynik, nie + tylko widoczną stronę. +
+ {% endif %} {% endwith %} {% if pivot.has_autorzy_dim %}