From 7eb393f497742506fb618783454eb914e81551f1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Mon, 31 Aug 2026 10:49:06 +0200
Subject: [PATCH 1/2] =?UTF-8?q?feat(pivot):=20stronicowanie=20i=20sortowan?=
=?UTF-8?q?ie=20tabeli=20krzy=C5=BCowej?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Tabela krzyżowa renderowała wszystkie wiersze macierzy naraz — przy
pivot_row=autor to ~2000 wierszy na jednej stronie. Nie było też żadnego
sterowania kolejnością: jedyną dostępną było alfabetyczne _labels().
Sortowanie i stronicowanie działają na JUŻ ZBUDOWANEJ macierzy, nie na
SQL-u: row_totals powstają zanim _labels() posortuje wiersze, więc
sortowanie po sumie nie kosztuje dodatkowego zapytania. Wariant SQL-owy
(LIMIT/OFFSET na kluczach wierszy) wymagałby drugiego przebiegu po sumy
brzegowe i płacił drogi JOIN do autorzy__ dwa razy.
Nowy PivotWidok (sort/kierunek/strona/na_stronie) + parse_widok() żyją
w bpp.pivot.core, a nie w rejestrach — dzięki temu sygnatura
parse_params() i jej pięć miejsc wywołania zostają nietknięte.
as_table() wołane bez argumentu zachowuje się dokładnie jak dotąd.
Szczegóły:
- rozmiar strony walidowany względem whitelisty (25/50/100/250); bez
tego ?pivot_per_page=999999 unieważnia całą zmianę,
- sortowanie po sumie rozstrzyga remisy etykietą — bez porządku
totalnego wiersz o równej sumie mógłby trafić na dwie strony naraz
albo zniknąć z obu,
- col_totals/grand_total NIE są przeliczane per strona (semantyka
Excela); pod tabelą staje o tym adnotacja,
- linki sortujące i pager kasują print=None, bo {% querystring %}
przenosi cały GET, a common-results.html odpala window.print() dla
?print=1,
- ukryte pola pivot_sort/pivot_dir w formularzu kontrolek: selecty
auto-submitują, więc bez nich zmiana wymiaru gubiłaby sortowanie.
pivot_page celowo NIE jest przenoszone — zmiana wymiaru wraca na 1,
- eksport XLSX/CSV respektuje sortowanie, ignoruje stronę (pełna
macierz w pliku) — dlatego UI nie ma opcji „pokaż wszystkie",
- PIVOT_MAX_CELLS 10 000 -> 50 000: po stronicowaniu render przestał
być wąskim gardłem. PIVOT_MAX_PAIRS bez zmian (bezpiecznik pamięci).
Działa identycznie w obu wejściach — multiseek „precyzyjne" i
/zapytanie/ (DjangoQL) dzielą partial multiseek/report-body-pivot.html.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_0132gLD38SwHVWMY1kemmkQt
---
...1-pivot-stronicowanie-sortowanie-design.md | 153 +++++++++
src/bpp/multiseek_registry/pivot.py | 3 +
...pivot-stronicowanie-sortowanie.feature.rst | 6 +
src/bpp/pivot/__init__.py | 3 +
src/bpp/pivot/core.py | 192 ++++++++++-
src/bpp/static/scss/_multiseek-reports.scss | 38 +++
src/bpp/templates/bpp/zapytanie.html | 5 +-
src/bpp/tests/test_multiseek_pivot_view.py | 90 +++++
src/bpp/tests/test_pivot_widok.py | 307 ++++++++++++++++++
src/bpp/tests/test_zapytanie_pivot.py | 140 ++++++++
src/bpp/views/multiseek_export.py | 18 +-
src/bpp/views/mymultiseek.py | 15 +-
src/bpp/views/zapytanie.py | 6 +
src/bpp/views/zapytanie_export.py | 7 +-
.../templates/multiseek/_pivot-pager.html | 58 ++++
.../multiseek/report-body-pivot.html | 50 ++-
16 files changed, 1068 insertions(+), 23 deletions(-)
create mode 100644 docs/superpowers/specs/2026-08-31-pivot-stronicowanie-sortowanie-design.md
create mode 100644 src/bpp/newsfragments/pivot-stronicowanie-sortowanie.feature.rst
create mode 100644 src/bpp/tests/test_pivot_widok.py
create mode 100644 src/django_bpp/templates/multiseek/_pivot-pager.html
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..c5f90c8c3
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-31-pivot-stronicowanie-sortowanie-design.md
@@ -0,0 +1,153 @@
+# 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..a4962a38c 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,90 @@ 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
+):
+ any_ciagle(tytul_oryginalny=f"{PIVOT_TITLE_PREFIX} - a", rok=2020)
+ any_ciagle(tytul_oryginalny=f"{PIVOT_TITLE_PREFIX} - b", rok=2021)
+ any_ciagle(tytul_oryginalny=f"{PIVOT_TITLE_PREFIX} - c", rok=2021)
+ 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_sort=suma&pivot_dir=desc"
+ )
+
+ t = resp.context["pivot_table"]
+ assert [r["label"] for r in t["rows"]] == ["2021", "2020"]
+ assert [r["total"] for r in t["rows"]] == [2, 1]
+
+
+@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..0720b1d08
--- /dev/null
+++ b/src/bpp/tests/test_pivot_widok.py
@@ -0,0 +1,307 @@
+"""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"]
+
+
+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..cd593e2d5 100644
--- a/src/bpp/tests/test_zapytanie_pivot.py
+++ b/src/bpp/tests/test_zapytanie_pivot.py
@@ -626,3 +626,143 @@ 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, wydawnictwo_ciagle, denorms
+):
+ """Wiersze mają iść wg kolumny RAZEM, nie alfabetycznie."""
+ denorms.flush()
+ wspolne = {
+ "model": "rekord",
+ "query": f"rok >= {wydawnictwo_ciagle.rok - 5}",
+ "postac": "pivot",
+ "pivot_row": "charakter_formalny",
+ "pivot_val": "liczba",
+ }
+ res = redaktor.get(
+ reverse("bpp:zapytanie"), {**wspolne, "pivot_sort": "suma", "pivot_dir": "desc"}
+ )
+
+ assert res.status_code == 200
+ t = res.context["pivot_table"]
+ sumy = [r["total"] or 0 for r in t["rows"]]
+ assert sumy == sorted(sumy, reverse=True)
+ assert t["sort"] == "suma"
+ assert t["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..fb7a3a4e0 100644
--- a/src/bpp/views/mymultiseek.py
+++ b/src/bpp/views/mymultiseek.py
@@ -169,11 +169,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"] = pivot_mod.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 +284,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 +296,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). #}
+
+ {% include "multiseek/_pivot-pager.html" %}
+
+ {% if t.is_paginated %}
+
+ {% 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 %}
From d94bda7b0e7f627d3ec0dc54257087a7212ee794 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Pasternak?=
Date: Mon, 31 Aug 2026 11:20:04 +0200
Subject: [PATCH 2/2] =?UTF-8?q?fix(pivot):=20porz=C4=85dek=20totalny=20sor?=
=?UTF-8?q?towania=20+=20wzmocnione=20testy=20sortu?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Self-review (agent fable) znalazł dwa realne defekty w poprzednim commicie.
1. Sortowanie NIE dawało porządku totalnego, gdy dwa RÓŻNE klucze mają
tę samą etykietę. Etykiety nie są unikatowe: dwóch autorów „Kowalski
Jan" ma różne PK i ten sam str(). Przy remisie decydowała wtedy
kolejność wejścia, a ta pochodzi z set(row_keys) wypełnianego wynikiem
GROUP BY bez ORDER BY — Postgres nie gwarantuje jej powtarzalności
między wykonaniami (HashAggregate, parallel workers). Wiersz na
granicy strony mógł się przez to pokazać na dwóch stronach albo na
żadnej — dokładnie defekt, przed którym tie-break miał chronić.
Ostatecznym dyskryminatorem jest teraz sam klucz wymiaru
(_klucz_rozstrzygajacy; str(), bo klucze bywają mieszanych typów).
Poprawka dotyczy OBU warstw: posortowane_wiersze() i _labels() —
dla sort=etykieta cała totalność siedzi w tej drugiej. Gałąź
rok/koszyk sortuje po samym kluczu, więc już była totalna; przy
okazji korzysta z WYMIARY_MALEJACE zamiast powtórzonej krotki.
2. Testy sortowania po sumie w obu widokach przechodziły z niewłaściwego
powodu — dane były ułożone tak, że oczekiwana kolejność pokrywała się
z naturalną (multiseek) albo istniał tylko jeden wiersz (/zapytanie/).
Przy DWÓCH wierszach „suma malejąco" i „odwróć naturalną" dają zresztą
ten sam wynik, więc pierwsza poprawka też by nie wystarczyła. Testy
używają teraz TRZECH lat o różnych licznościach — naturalna, odwrócona
naturalna, suma rosnąco i suma malejąco to cztery różne kolejności.
Zweryfikowane mutacjami: wyzerowanie klucza sortu, zignorowanie
SORT_SUMA i usunięcie dyskryminatora wywalają odpowiednio 6, 6 i 3
testy. Wcześniejsze wersje przechodziły każdą z tych mutacji.
Dodatkowo: shim bpp.multiseek_registry.pivot przestaje re-eksportować
DOZWOLONE_NA_STRONIE. To ta sama pułapka „martwej kopii", przez którą
świadomie nie re-eksportuje PIVOT_MAX_* — podmiana na kopii rozjechałaby
listę opcji w UI z whitelistą sprawdzaną w parse_widok(). Funkcje
zostają, bo re-eksport funkcji to ten SAM obiekt, nie kopia wartości.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_0132gLD38SwHVWMY1kemmkQt
---
...1-pivot-stronicowanie-sortowanie-design.md | 18 +++++--
src/bpp/multiseek_registry/pivot.py | 14 ++---
src/bpp/pivot/core.py | 40 +++++++++++---
src/bpp/tests/test_multiseek_pivot_view.py | 36 +++++++++++--
src/bpp/tests/test_pivot_widok.py | 50 ++++++++++++++++++
src/bpp/tests/test_zapytanie_pivot.py | 52 +++++++++++++++----
src/bpp/views/mymultiseek.py | 3 +-
7 files changed, 179 insertions(+), 34 deletions(-)
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
index c5f90c8c3..c71549c33 100644
--- a/docs/superpowers/specs/2026-08-31-pivot-stronicowanie-sortowanie-design.md
+++ b/docs/superpowers/specs/2026-08-31-pivot-stronicowanie-sortowanie-design.md
@@ -78,10 +78,20 @@ testami kolejności i z osią kolumn, która sortowania nie dostaje).
- `sort=suma`: klucz `(-suma, etykieta.lower())`; `kierunek=asc` daje
`(suma, etykieta.lower())`.
-**Rozstrzyganie remisów po etykiecie jest obowiązkowe, nie kosmetyczne.**
-Bez porządku totalnego wiersze o równych sumach mogą wypaść w różnej
-kolejności między żądaniami — a wtedy przy stronicowaniu wiersz pokazuje
-się na dwóch stronach albo znika z obu.
+**Kolejność musi być porządkiem TOTALNYM, inaczej stronicowanie gubi
+wiersze.** Bez tego wiersze nierozróżnialne przez kryterium główne mogą
+wypaść w różnej kolejności między żądaniami — a wtedy wiersz na granicy
+strony pokazuje się na dwóch stronach albo znika z obu.
+
+Etykieta jako ostatni dyskryminator NIE wystarcza: etykiety nie są
+unikatowe (dwóch autorów „Kowalski Jan" ma różne PK i ten sam `str()`).
+Przy remisie decydowałaby wtedy kolejność wejścia, a ta pochodzi
+z `set(row_keys)` wypełnianego wynikiem `GROUP BY` bez `ORDER BY` —
+Postgres nie gwarantuje jej powtarzalności między wykonaniami
+(HashAggregate, parallel workers). Ostatecznym dyskryminatorem jest więc
+sam klucz wymiaru (`_klucz_rozstrzygajacy`, `str()` bo klucze bywają
+mieszanych typów). To samo dotyczy `_labels` — dla `sort=etykieta` cała
+totalność siedzi właśnie tam.
### 3. Stronicowanie — `as_table(widok=None)`
diff --git a/src/bpp/multiseek_registry/pivot.py b/src/bpp/multiseek_registry/pivot.py
index 8dabf40e2..b41ee5b4f 100644
--- a/src/bpp/multiseek_registry/pivot.py
+++ b/src/bpp/multiseek_registry/pivot.py
@@ -4,16 +4,18 @@
tabelę krzyżową dostała też strona „Wyszukiwanie zapytaniem". Ten moduł
zostaje, żeby nie ruszać call-site'ów w szablonach, widokach i testach.
-Świadomie NIE re-eksportuje PIVOT_MAX_CELLS/PIVOT_MAX_PAIRS: `zbuduj_pivot`
-czyta te progi jako globalne SWOJEGO modułu (bpp.pivot.core), więc kopia
-nazwy pod tą ścieżką byłaby martwa — monkeypatch na niej nie wpłynąłby na
-bramkę, dając złudzenie działającego testu. Kto patchuje progi, patchuje je
-w bpp.pivot.core, gdzie mieszkają.
+Świadomie NIE re-eksportuje STAŁYCH (PIVOT_MAX_CELLS/PIVOT_MAX_PAIRS,
+DOZWOLONE_NA_STRONIE): kod silnika czyta je jako globalne SWOJEGO modułu
+(bpp.pivot.core), więc kopia nazwy pod tą ścieżką byłaby martwa — podmiana
+na niej nie wpłynęłaby na bramkę ani na walidację, dając złudzenie
+działającego testu, a przy DOZWOLONE_NA_STRONIE rozjechałaby listę opcji
+w UI z whitelistą sprawdzaną w parse_widok(). Stałe bierz z bpp.pivot.core,
+gdzie mieszkają. Funkcje (zbuduj_pivot, parse_widok) re-eksportujemy, bo to
+te SAME obiekty, nie kopie wartości.
"""
from bpp.pivot.core import ( # noqa: F401
BRAK,
- DOZWOLONE_NA_STRONIE,
PivotDimension,
PivotMetric,
PivotResult,
diff --git a/src/bpp/pivot/core.py b/src/bpp/pivot/core.py
index 802b1e114..da6f6bba8 100644
--- a/src/bpp/pivot/core.py
+++ b/src/bpp/pivot/core.py
@@ -163,19 +163,24 @@ def posortowane_wiersze(self, widok):
"""`self.rows` (już w kolejności naturalnej z `_labels`) przestawione
wg `widok`.
- Rozstrzyganie remisów po etykiecie NIE jest kosmetyką: bez porządku
- totalnego dwa wiersze o równych sumach mogą 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.
+ Rozstrzyganie remisów NIE jest kosmetyką: bez porządku totalnego dwa
+ wiersze o równej sumie mogą 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.
"""
rosnaco = widok.kierunek_efektywny == "asc"
if widok.sort == SORT_SUMA:
znak = 1 if rosnaco else -1
return sorted(
self.rows,
- key=lambda p: (znak * (self.row_totals.get(p[0]) or 0), p[1].lower()),
+ key=lambda p: (
+ znak * (self.row_totals.get(p[0]) or 0),
+ p[1].lower(),
+ _klucz_rozstrzygajacy(p[0]),
+ ),
)
# SORT_ETYKIETA: kolejność naturalna wymiaru albo jej odwrotność.
+ # `_labels` jest już totalne, więc i `reversed` takie zostaje.
return list(self.rows) if rosnaco else list(reversed(self.rows))
def _naglowki_sortowania(self, widok):
@@ -394,15 +399,34 @@ def _build_matrix(triples, row_dim, col_dim, metric, has_autorzy):
)
+def _klucz_rozstrzygajacy(key):
+ """Ostateczny dyskryminator kolejności — sam klucz wymiaru.
+
+ Etykieta go NIE zastępuje, bo etykiety nie są unikatowe: dwóch autorów
+ „Kowalski Jan" ma różne PK i ten sam `str()`. Przy remisie decydowałaby
+ wtedy kolejność wejścia, a ta pochodzi z `set(row_keys)` wypełnianego
+ wynikiem GROUP BY bez ORDER BY — Postgres nie gwarantuje jej
+ powtarzalności między wykonaniami (HashAggregate, parallel workers).
+ Efekt: wiersz na granicy strony pokazuje się na dwóch stronach albo na
+ żadnej. `str()` zamiast surowej wartości, bo klucze bywają mieszanych
+ typów (int, str, Decimal, bool) i nie da się ich porównać wprost.
+ """
+ return (key is None, str(key))
+
+
def _labels(keys, dim):
"""Zwraca posortowaną listę (key, label). Rok/koszyk malejąco liczbowo,
- słowniki alfabetycznie po etykiecie."""
+ słowniki alfabetycznie po etykiecie. Kolejność jest TOTALNA (patrz
+ `_klucz_rozstrzygajacy`) — inaczej stronicowanie gubi wiersze."""
mapping = _label_mapping(keys, dim)
pairs = [(k, mapping.get(k, BRAK if k is None else str(k))) for k in keys]
- if dim.key in ("rok", "koszyk_pk"):
+ if dim.key in WYMIARY_MALEJACE:
+ # Klucz jest tu jedynym kryterium, więc porządek już jest totalny.
pairs.sort(key=lambda p: (p[0] is None, -(p[0] or 0)))
else:
- pairs.sort(key=lambda p: (p[1] == BRAK, p[1].lower()))
+ pairs.sort(
+ key=lambda p: (p[1] == BRAK, p[1].lower(), _klucz_rozstrzygajacy(p[0]))
+ )
return pairs
diff --git a/src/bpp/tests/test_multiseek_pivot_view.py b/src/bpp/tests/test_multiseek_pivot_view.py
index a4962a38c..fc65aba78 100644
--- a/src/bpp/tests/test_multiseek_pivot_view.py
+++ b/src/bpp/tests/test_multiseek_pivot_view.py
@@ -235,20 +235,46 @@ def test_pivot_multiseek_stronicuje_wiersze(
def test_pivot_multiseek_sortowanie_po_sumie(
logged_in_client, test_user, standard_data, denorms
):
- any_ciagle(tytul_oryginalny=f"{PIVOT_TITLE_PREFIX} - a", rok=2020)
- any_ciagle(tytul_oryginalny=f"{PIVOT_TITLE_PREFIX} - b", rok=2021)
- any_ciagle(tytul_oryginalny=f"{PIVOT_TITLE_PREFIX} - c", rok=2021)
+ # 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", "2020"]
- assert [r["total"] for r in t["rows"]] == [2, 1]
+ 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
diff --git a/src/bpp/tests/test_pivot_widok.py b/src/bpp/tests/test_pivot_widok.py
index 0720b1d08..e5b369886 100644
--- a/src/bpp/tests/test_pivot_widok.py
+++ b/src/bpp/tests/test_pivot_widok.py
@@ -118,6 +118,56 @@ def test_sortowanie_po_sumie_rosnaco():
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
diff --git a/src/bpp/tests/test_zapytanie_pivot.py b/src/bpp/tests/test_zapytanie_pivot.py
index cd593e2d5..d44c0354d 100644
--- a/src/bpp/tests/test_zapytanie_pivot.py
+++ b/src/bpp/tests/test_zapytanie_pivot.py
@@ -697,29 +697,61 @@ def test_pivot_druga_strona_pokazuje_reszte(redaktor, jednostka):
@pytest.mark.django_db
-def test_pivot_sortowanie_po_sumie_zmienia_kolejnosc(
- redaktor, wydawnictwo_ciagle, denorms
-):
- """Wiersze mają iść wg kolumny RAZEM, nie alfabetycznie."""
+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": f"rok >= {wydawnictwo_ciagle.rok - 5}",
+ "query": "rok >= 2020",
"postac": "pivot",
- "pivot_row": "charakter_formalny",
+ "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"}
)
-
- assert res.status_code == 200
t = res.context["pivot_table"]
- sumy = [r["total"] or 0 for r in t["rows"]]
- assert sumy == sorted(sumy, reverse=True)
+ 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):
diff --git a/src/bpp/views/mymultiseek.py b/src/bpp/views/mymultiseek.py
index fb7a3a4e0..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()
@@ -173,7 +174,7 @@ def get_context_data(self, **kwargs):
# prezentacji, nie tego CO policzyć — patrz PivotWidok.
widok = pivot_mod.parse_widok(self.request.GET)
ctx["pivot_widok"] = widok
- ctx["pivot_na_stronie_opcje"] = pivot_mod.DOZWOLONE_NA_STRONIE
+ 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()