diff --git a/docs/superpowers/plans/2026-07-24-multiseek-pivot-grupowanie.md b/docs/superpowers/plans/2026-07-24-multiseek-pivot-grupowanie.md new file mode 100644 index 000000000..15c2beff3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-24-multiseek-pivot-grupowanie.md @@ -0,0 +1,1035 @@ +# Pivot (tabela krzyżowa) w multiseek — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Dodać do wyszukiwarki multiseek raport „tabela krzyżowa" (pivot): +grupowanie wyników w macierz Wiersze × Kolumny z wybieraną metryką w +komórce i sumami brzegowymi — pokrywa jednocześnie „Szukaj → Multiseek" +i „prace autora" (ten sam silnik). + +**Architecture:** Nowy `report_type="pivot"` w rejestrze multiseek. +Konfiguracja pivota (wiersz/kolumna/metryka) czytana z parametrów GET na +URL wyników. Cała logika agregacji w nowym module +`src/bpp/multiseek_registry/pivot.py` (`zbuduj_pivot()` + rejestry +wymiarów/metryk + `PivotResult`), wpięta w `MyMultiseekResults.get_context_data`. +Nowy partial `report-body-pivot.html` renderuje macierz; eksport XLSX/CSV +buduje z tej samej struktury `PivotResult`. + +**Tech Stack:** Django, PostgreSQL (widoki materializowane `bpp_rekord_mat` +/ `bpp_autorzy_mat`), `django_multiseek`, pytest + model_bakery, openpyxl +(eksport XLSX), Foundation CSS + SCSS/grunt. + +## Global Constraints + +- Python `uv run` prefix dla WSZYSTKICH poleceń python/pytest. Nigdy gołe `python`. +- Max długość linii: 88 znaków (ruff). +- Testy: pytest (funkcje, nie klasy), `@pytest.mark.django_db`, + `model_bakery.baker.make`, `-n auto`. Output testów do pliku w + `/tmp`, grep — nigdy dwa razy ten sam przebieg. +- **Bez migracji, bez zmian modeli.** Tylko istniejące pola `Rekord`/`Autorzy`. +- Django komentarze `{# … #}` jedno-liniowe (każda linia własne `{# #}`). +- Ikony: publiczny frontend = Foundation-Icons (``). +- Newsfragment po zmianie: `src/bpp/newsfragments/.feature.rst` (po polsku). +- `Rekord.id` to `TupleField(IntegerField(), size=2, primary_key=True)` + (`src/bpp/models/cache/rekord.py:212`) — `Count("id")` działa na PG. +- `report_type` to **indeks pozycyjny** — nowy typ MUSI iść na KONIEC listy + `multiseek_report_types`, `public=True`. +- **KRYTYCZNE:** przed `.values().annotate()` czyścić ordering + (`base_qs.order_by()`) — inaczej `default_ordering=["-rok"]` / sort + formularza wchodzi do GROUP BY (K3). + +Pełny kontekst decyzji: spec +`docs/superpowers/specs/2026-07-24-multiseek-pivot-grupowanie-design.md`. + +--- + +## File Structure + +- **Create** `src/bpp/multiseek_registry/pivot.py` — rejestry wymiarów + (`DIMENSIONS`) i metryk (`METRICS`), `PivotDimension`, `PivotMetric`, + `PivotResult`, `parse_pivot_params(GET)`, `zbuduj_pivot(base_qs, row, col, metric)`. +- **Create** `src/django_bpp/templates/multiseek/report-body-pivot.html` — + pasek selektorów (GET) + tabela krzyżowa + adnotacja o dublowaniu. +- **Create** `src/bpp/tests/test_multiseek_pivot.py` — testy jednostkowe + `zbuduj_pivot`/`parse_pivot_params`. +- **Create** `src/bpp/tests/test_multiseek_pivot_view.py` — testy widoku + i eksportu pivota. +- **Modify** `src/bpp/multiseek_registry/reports.py:8` — dopisać + `ReportType("pivot", "tabela krzyżowa")` na końcu listy. +- **Modify** `src/bpp/views/mymultiseek.py` — `get_context_data` (gałąź + pivota, ~:181), `MyMultiseekExport.get`/`_export_data` (gałąź pivota, + omija cap 5000, ~:247). +- **Modify** `src/django_bpp/templates/multiseek/common-results.html:9` — + gałąź `report_type == "pivot"` przed gate'em 25000 i paginacją. +- **Modify** `src/bpp/views/multiseek_export.py` — builder + `pivot_xlsx_export_response` / `pivot_csv_export_response` z `PivotResult`. +- **Modify** (SCSS) komponent stylu tabeli krzyżowej — plik wskaże Task 4. +- **Create** `src/bpp/newsfragments/.feature.rst`. + +--- + +## Task 1: Rejestry wymiarów/metryk + walidacja GET (`pivot.py` szkielet) + +**Files:** +- Create: `src/bpp/multiseek_registry/pivot.py` +- Test: `src/bpp/tests/test_multiseek_pivot.py` + +**Interfaces:** +- Produces: + - `@dataclass PivotDimension(key:str, label:str, expr:str, allow_column:bool=True, autorzy:bool=False, label_kind:str="raw")` + — `label_kind ∈ {"raw","fk","choices_charakter_ogolny","pk_bucket","autor"}`; + dla `label_kind=="fk"` dochodzi pole `fk_model` (klasa modelu) i + `fk_label_field:str="nazwa"`. + - `@dataclass PivotMetric(key:str, label:str, field:str|None)` — + `field is None` → metryka „liczba" (Count), inaczej `Sum(field)`. + - `DIMENSIONS: dict[str, PivotDimension]`, `METRICS: dict[str, PivotMetric]`. + - `DEFAULT_ROW="rok"`, `DEFAULT_METRIC="liczba"`. + - `parse_pivot_params(GET) -> tuple[PivotDimension, PivotDimension|None, PivotMetric]` + — czyta `pivot_row`/`pivot_col`/`pivot_val`; nieznany/pusty `pivot_row` + → `DIMENSIONS[DEFAULT_ROW]`; `pivot_col` pusty lub niedozwolony jako + kolumna (`allow_column==False`) lub równy wierszowi → `None`; nieznany + `pivot_val` → `METRICS[DEFAULT_METRIC]`. + +- [ ] **Step 1: Napisz failing test walidacji parametrów** + +```python +# src/bpp/tests/test_multiseek_pivot.py +from bpp.multiseek_registry import pivot + + +def test_parse_pivot_params_defaults_when_empty(): + row, col, metric = pivot.parse_pivot_params({}) + assert row.key == "rok" + assert col is None + assert metric.key == "liczba" + + +def test_parse_pivot_params_unknown_keys_fall_back(): + row, col, metric = pivot.parse_pivot_params( + {"pivot_row": "xxx", "pivot_col": "yyy", "pivot_val": "zzz"} + ) + assert row.key == "rok" + assert col is None + assert metric.key == "liczba" + + +def test_parse_pivot_params_column_must_allow_column(): + # "jednostka" jest tylko wierszem (allow_column=False) → col=None + row, col, metric = pivot.parse_pivot_params( + {"pivot_row": "rok", "pivot_col": "jednostka", "pivot_val": "liczba"} + ) + assert col is None + + +def test_parse_pivot_params_column_equal_to_row_dropped(): + row, col, metric = pivot.parse_pivot_params( + {"pivot_row": "rok", "pivot_col": "rok"} + ) + assert col is None + + +def test_parse_pivot_params_valid_crosstab(): + row, col, metric = pivot.parse_pivot_params( + {"pivot_row": "rok", "pivot_col": "charakter_ogolny", "pivot_val": "punkty_kbn"} + ) + assert (row.key, col.key, metric.key) == ("rok", "charakter_ogolny", "punkty_kbn") +``` + +- [ ] **Step 2: Uruchom test — ma paść (ImportError/AttributeError)** + +Run: `uv run pytest src/bpp/tests/test_multiseek_pivot.py -p no:cacheprovider -q 2>&1 | tee /tmp/pivot_t1.log; grep -E "passed|failed|error" /tmp/pivot_t1.log | tail -3` +Expected: FAIL — `module 'bpp.multiseek_registry.pivot' has no attribute 'parse_pivot_params'`. + +- [ ] **Step 3: Zaimplementuj `pivot.py` (rejestry + parse)** + +```python +# src/bpp/multiseek_registry/pivot.py +from dataclasses import dataclass, field + +from bpp.models.system.charakter_formalny import CHARAKTER_OGOLNY_CHOICES + + +@dataclass(frozen=True) +class PivotDimension: + key: str + label: str + expr: str + allow_column: bool = True + autorzy: bool = False + label_kind: str = "raw" # raw|fk|choices_charakter_ogolny|pk_bucket|autor + fk_model: type | None = None + fk_label_field: str = "nazwa" + + +@dataclass(frozen=True) +class PivotMetric: + key: str + label: str + field: str | None # None → Count("id"); inaczej Sum(field) + + +def _fk(model_path, label_field="nazwa"): + # lazy import, żeby uniknąć cykli przy ładowaniu rejestru + from django.apps import apps + + return apps.get_model(*model_path.split(".")) + + +DEFAULT_ROW = "rok" +DEFAULT_METRIC = "liczba" + +# UWAGA: expr dla wymiarów FK to "_id" — values() zwraca surowe id, +# etykiety dociągamy hurtowo w zbuduj_pivot (label_kind="fk"). +DIMENSIONS: dict[str, PivotDimension] = { + "rok": PivotDimension("rok", "Rok", "rok"), + "charakter_formalny": PivotDimension( + "charakter_formalny", "Charakter formalny", "charakter_formalny_id", + label_kind="fk", fk_model=_fk("bpp.Charakter_Formalny"), + ), + "charakter_ogolny": PivotDimension( + "charakter_ogolny", "Charakter ogólny (rodzaj)", + "charakter_formalny__charakter_ogolny", + label_kind="choices_charakter_ogolny", + ), + "typ_kbn": PivotDimension( + "typ_kbn", "Typ MNiSW/MEiN", "typ_kbn_id", + label_kind="fk", fk_model=_fk("bpp.Typ_KBN"), + ), + "koszyk_pk": PivotDimension( + "koszyk_pk", "Koszyk punktów PK", "punkty_kbn", label_kind="pk_bucket", + ), + "jezyk": PivotDimension( + "jezyk", "Język", "jezyk_id", + label_kind="fk", fk_model=_fk("bpp.Jezyk"), + ), + "zrodlo": PivotDimension( + "zrodlo", "Źródło", "zrodlo_id", allow_column=False, + label_kind="fk", fk_model=_fk("bpp.Zrodlo"), + ), + "jednostka": PivotDimension( + "jednostka", "Jednostka", "autorzy__jednostka_id", allow_column=False, + autorzy=True, label_kind="fk", fk_model=_fk("bpp.Jednostka"), + ), + "dyscyplina": PivotDimension( + "dyscyplina", "Dyscyplina naukowa", "autorzy__dyscyplina_naukowa_id", + autorzy=True, label_kind="fk", + fk_model=_fk("bpp.Dyscyplina_Naukowa"), + ), + "autor": PivotDimension( + "autor", "Autor", "autorzy__autor_id", allow_column=False, + autorzy=True, label_kind="autor", fk_model=_fk("bpp.Autor"), + ), +} + +METRICS: dict[str, PivotMetric] = { + "liczba": PivotMetric("liczba", "Liczba prac", None), + "punkty_kbn": PivotMetric("punkty_kbn", "Σ punkty PK", "punkty_kbn"), + "impact_factor": PivotMetric("impact_factor", "Σ Impact Factor", "impact_factor"), + "liczba_cytowan": PivotMetric("liczba_cytowan", "Σ liczba cytowań", "liczba_cytowan"), + "punktacja_wewnetrzna": PivotMetric( + "punktacja_wewnetrzna", "Σ punktacja wewnętrzna", "punktacja_wewnetrzna" + ), +} + + +def parse_pivot_params(GET): + row = DIMENSIONS.get(GET.get("pivot_row") or "", DIMENSIONS[DEFAULT_ROW]) + metric = METRICS.get(GET.get("pivot_val") or "", METRICS[DEFAULT_METRIC]) + col_key = GET.get("pivot_col") or "" + col = DIMENSIONS.get(col_key) + if col is not None and (not col.allow_column or col.key == row.key): + col = None + return row, col, metric +``` + +- [ ] **Step 4: Uruchom testy — mają przejść** + +Run: `uv run pytest src/bpp/tests/test_multiseek_pivot.py -p no:cacheprovider -q 2>&1 | tee /tmp/pivot_t1.log; grep -E "passed|failed|error" /tmp/pivot_t1.log | tail -3` +Expected: PASS (5 passed). + +- [ ] **Step 5: ruff + commit** + +```bash +uv run ruff format src/bpp/multiseek_registry/pivot.py src/bpp/tests/test_multiseek_pivot.py +uv run ruff check src/bpp/multiseek_registry/pivot.py src/bpp/tests/test_multiseek_pivot.py +git add src/bpp/multiseek_registry/pivot.py src/bpp/tests/test_multiseek_pivot.py +git commit -m "feat(multiseek): rejestry wymiarów/metryk pivota + walidacja GET" +``` + +--- + +## Task 2: `zbuduj_pivot()` — agregacja (strategia A/B) + etykiety + +**Files:** +- Modify: `src/bpp/multiseek_registry/pivot.py` +- Test: `src/bpp/tests/test_multiseek_pivot.py` + +**Interfaces:** +- Consumes: `PivotDimension`, `PivotMetric`, `DIMENSIONS`, `METRICS` (Task 1). +- Produces: + - `@dataclass PivotResult(rows, cols, cells, row_totals, col_totals, grand_total, row_dim, col_dim, metric, has_autorzy_dim)` + gdzie `rows`/`cols` to listy `(key, label)` posortowane; `cells` to + `dict[(row_key, col_key) -> number]` (`col_key is None` gdy brak + kolumn); `row_totals`/`col_totals` to `dict[key -> number]`; + `grand_total` liczba; `has_autorzy_dim: bool`. + - `zbuduj_pivot(base_qs, row_dim, col_dim, metric) -> PivotResult`. + +**Strategia (patrz spec §8):** wyczyść ordering (`base_qs.order_by()`). +Jeśli `row_dim.autorzy` lub (`col_dim` i `col_dim.autorzy`) → **strategia B** +(unikatowe pary + agregacja w Pythonie). Inaczej → **strategia A** (dedup +rekordów przez `pk__in`, `values().annotate()`). + +- [ ] **Step 1: Failing test — strategia A, liczba prac (rok × charakter ogólny)** + +```python +# dopisz do src/bpp/tests/test_multiseek_pivot.py +import pytest +from model_bakery import baker + +from bpp.models import Charakter_Formalny +from bpp.models.const import CHARAKTER_OGOLNY_ARTYKUL, CHARAKTER_OGOLNY_ROZDZIAL + + +@pytest.fixture +def rekordy_pivot(db): + from bpp.models.cache import Rekord + + art = baker.make(Charakter_Formalny, charakter_ogolny=CHARAKTER_OGOLNY_ARTYKUL) + roz = baker.make(Charakter_Formalny, charakter_ogolny=CHARAKTER_OGOLNY_ROZDZIAL) + # helper tworzący wpis w bpp_rekord_mat: użyj istniejących fabryk projektu + # (baker.make(Wydawnictwo_Ciagle/Zwarte) + odświeżenie widoku), patrz + # src/bpp/tests/ — fixture zwraca queryset Rekord.objects.all() + ... + return Rekord.objects.all() + + +@pytest.mark.django_db +def test_zbuduj_pivot_a_liczba(rekordy_pivot): + from bpp.multiseek_registry import pivot + + res = pivot.zbuduj_pivot( + rekordy_pivot, + pivot.DIMENSIONS["rok"], + pivot.DIMENSIONS["charakter_ogolny"], + pivot.METRICS["liczba"], + ) + # oczekiwane liczności zależne od danych fixture — asertuj konkretne komórki + assert res.cells[(2024, CHARAKTER_OGOLNY_ARTYKUL)] == ... + assert res.grand_total == ... + assert res.has_autorzy_dim is False +``` + +> **Uwaga dla implementera:** projekt renderuje wyniki z widoku +> materializowanego `bpp_rekord_mat`. W testach twórz publikacje istniejącymi +> fabrykami (np. `baker.make("bpp.Wydawnictwo_Ciagle", rok=2024, charakter_formalny=art, punkty_kbn=40)`) +> i odśwież cache tak, jak robią to inne testy multiseek/rekord w +> `src/bpp/tests/` (poszukaj fixture/helpera odświeżającego `Rekord`). +> NIE wymyślaj własnego mechanizmu — użyj istniejącego wzorca projektu. + +- [ ] **Step 2: Uruchom — ma paść (brak `zbuduj_pivot`)** + +Run: `uv run pytest src/bpp/tests/test_multiseek_pivot.py -k zbuduj_pivot_a_liczba -p no:cacheprovider -q 2>&1 | tee /tmp/pivot_t2.log; tail -5 /tmp/pivot_t2.log` +Expected: FAIL. + +- [ ] **Step 3: Zaimplementuj `PivotResult` + `zbuduj_pivot` (strategia A)** + +```python +# dopisz do src/bpp/multiseek_registry/pivot.py +from dataclasses import dataclass +from decimal import Decimal + +from django.db.models import Count, Sum + +BRAK = "— brak —" + + +@dataclass +class PivotResult: + rows: list + cols: list + cells: dict + row_totals: dict + col_totals: dict + grand_total: object + row_dim: PivotDimension + col_dim: PivotDimension | None + metric: PivotMetric + has_autorzy_dim: bool + + +def _annotate(metric): + return Count("id") if metric.field is None else Sum(metric.field) + + +def zbuduj_pivot(base_qs, row_dim, col_dim, metric): + base_qs = base_qs.order_by() # KRYTYCZNE: bez tego ordering wchodzi do GROUP BY + has_autorzy = row_dim.autorzy or bool(col_dim and col_dim.autorzy) + triples = ( + _pairs_strategy(base_qs, row_dim, col_dim, metric) + if has_autorzy + else _dedup_strategy(base_qs, row_dim, col_dim, metric) + ) + return _build_matrix(triples, row_dim, col_dim, metric, has_autorzy) + + +def _dedup_strategy(base_qs, row_dim, col_dim, metric): + from bpp.models.cache import Rekord + + deduped = Rekord.objects.filter(pk__in=base_qs.values("pk")).order_by() + group = [row_dim.expr] + ([col_dim.expr] if col_dim else []) + rows = deduped.values(*group).annotate(val=_annotate(metric)) + for r in rows: + rk = r[row_dim.expr] + ck = r[col_dim.expr] if col_dim else None + yield rk, ck, r["val"] or 0 +``` + +- [ ] **Step 4: Zaimplementuj `_build_matrix` + mapowanie etykiet** + +```python +# dopisz do src/bpp/multiseek_registry/pivot.py +def _build_matrix(triples, row_dim, col_dim, metric, has_autorzy): + cells, row_totals, col_totals = {}, {}, {} + row_keys, col_keys, grand = set(), set(), 0 + for rk, ck, val in triples: + cells[(rk, ck)] = cells.get((rk, ck), 0) + val + row_totals[rk] = row_totals.get(rk, 0) + val + col_totals[ck] = col_totals.get(ck, 0) + val + grand += val + row_keys.add(rk) + if col_dim: + col_keys.add(ck) + rows = _labels(row_keys, row_dim) + cols = _labels(col_keys, col_dim) if col_dim else [] + return PivotResult( + rows=rows, cols=cols, cells=cells, row_totals=row_totals, + col_totals=col_totals, grand_total=grand, row_dim=row_dim, + col_dim=col_dim, metric=metric, has_autorzy_dim=has_autorzy, + ) + + +def _labels(keys, dim): + """Zwraca posortowaną listę (key, label). Rok/koszyk malejąco liczbowo, + słowniki alfabetycznie po etykiecie.""" + 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"): + 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())) + return pairs + + +def _label_mapping(keys, dim): + if dim.label_kind == "raw": + return {k: (BRAK if k is None else str(k)) for k in keys} + if dim.label_kind == "pk_bucket": + return { + k: (BRAK if k is None else f"{Decimal(k):g}") for k in keys + } + if dim.label_kind == "choices_charakter_ogolny": + d = dict(CHARAKTER_OGOLNY_CHOICES) + return {k: (BRAK if k is None else d.get(k, str(k))) for k in keys} + if dim.label_kind in ("fk", "autor"): + ids = [k for k in keys if k is not None] + objs = dim.fk_model.objects.in_bulk(ids) + out = {None: BRAK} + for k in ids: + obj = objs.get(k) + out[k] = str(obj) if obj is not None else BRAK + return out + return {k: str(k) for k in keys} +``` + +- [ ] **Step 5: Uruchom test strategii A — ma przejść** + +Run: `uv run pytest src/bpp/tests/test_multiseek_pivot.py -k zbuduj_pivot_a_liczba -p no:cacheprovider -q 2>&1 | tee /tmp/pivot_t2.log; grep -E "passed|failed|error" /tmp/pivot_t2.log | tail -3` +Expected: PASS. + +- [ ] **Step 6: Failing test — K1 (filtr mnożący nie zawyża) i K3 (ordering leak)** + +```python +@pytest.mark.django_db +def test_zbuduj_pivot_k1_filtr_mnozacy_nie_zawyza(rekordy_pivot): + """Rekord z wieloma autorami + filtr po autorach/jednostce (JOIN mnożący) + liczony po wymiarze REKORDOWYM (rok) = raz, nie N razy.""" + from bpp.multiseek_registry import pivot + # zbuduj queryset z JOIN do autorzy (np. .filter(autorzy__jednostka=...)) + # tak, by płaski COUNT bez dedup zawyżał; asertuj że pivot liczy rekord raz + ... + + +@pytest.mark.django_db +def test_zbuduj_pivot_k3_ordering_nie_rozbija_grup(rekordy_pivot): + """Wejściowy queryset z .order_by('-rok') / Meta.ordering nie rozbija + GROUP BY na mikrogrupy — liczba wierszy = liczba unikatowych lat.""" + from bpp.multiseek_registry import pivot + + qs = rekordy_pivot.order_by("-rok", "tytul_oryginalny_sort") + res = pivot.zbuduj_pivot( + qs, pivot.DIMENSIONS["rok"], None, pivot.METRICS["liczba"] + ) + assert len(res.rows) == len({r["rok"] for r in rekordy_pivot.values("rok")}) +``` + +- [ ] **Step 7: Uruchom K1/K3 — strategia A już powinna je spełniać** + +Run: `uv run pytest src/bpp/tests/test_multiseek_pivot.py -k "k1_filtr or k3_ordering" -p no:cacheprovider -q 2>&1 | tee /tmp/pivot_t2b.log; grep -E "passed|failed|error" /tmp/pivot_t2b.log | tail -3` +Expected: PASS (dedup przez `pk__in` + `order_by()` już to załatwiają). Jeśli +FAIL — popraw `_dedup_strategy` / czyszczenie orderingu. + +- [ ] **Step 8: Failing test — strategia B (K2: 3 autorów z jednej kliniki = 1 praca)** + +```python +@pytest.mark.django_db +def test_zbuduj_pivot_b_k2_trzej_autorzy_jedna_klinika(...): + """Rekord z 3 autorami z tej samej jednostki → komórka = 1 praca, + Σ punkty = punkty rekordu RAZ (nie ×3).""" + from bpp.multiseek_registry import pivot + # utwórz 1 rekord (punkty_kbn=40) z 3 autorami w jednostce J + # base_qs = Rekord z JOIN autorzy + res_liczba = pivot.zbuduj_pivot( + base_qs, pivot.DIMENSIONS["jednostka"], None, pivot.METRICS["liczba"] + ) + assert res_liczba.cells[(J.pk, None)] == 1 + res_pk = pivot.zbuduj_pivot( + base_qs, pivot.DIMENSIONS["jednostka"], None, pivot.METRICS["punkty_kbn"] + ) + assert res_pk.cells[(J.pk, None)] == 40 + assert res_liczba.has_autorzy_dim is True +``` + +- [ ] **Step 9: Uruchom — ma paść (brak `_pairs_strategy`)** + +Run: `uv run pytest src/bpp/tests/test_multiseek_pivot.py -k b_k2_trzej -p no:cacheprovider -q 2>&1 | tee /tmp/pivot_t2c.log; tail -5 /tmp/pivot_t2c.log` +Expected: FAIL (`NameError: _pairs_strategy`). + +- [ ] **Step 10: Zaimplementuj `_pairs_strategy` (unikatowe pary + agregacja w Pythonie)** + +```python +# dopisz do src/bpp/multiseek_registry/pivot.py +def _pairs_strategy(base_qs, row_dim, col_dim, metric): + """Wymiar autorski: liczymy pary (wymiar, rekord). Rekord liczony raz per + wartość wymiaru; Σ metryki po unikatowych parach (rekord, wymiar). + Dublowanie MIĘDZY różnymi wartościami wymiaru jest zamierzone (§7).""" + group = [row_dim.expr] + ([col_dim.expr] if col_dim else []) + fields = group + ["id"] + ([metric.field] if metric.field else []) + pairs = base_qs.values(*fields).distinct() + seen = {} # (rk, ck) -> set(rekord id) dla liczby + sums = {} # (rk, ck) -> Σ metryki po unikatowych rekordach + for p in pairs: + rk = p[row_dim.expr] + ck = p[col_dim.expr] if col_dim else None + rid = tuple(p["id"]) if isinstance(p["id"], list) else p["id"] + s = seen.setdefault((rk, ck), set()) + if rid in s: + continue + s.add(rid) + if metric.field is None: + sums[(rk, ck)] = sums.get((rk, ck), 0) + 1 + else: + sums[(rk, ck)] = sums.get((rk, ck), 0) + (p[metric.field] or 0) + for (rk, ck), val in sums.items(): + yield rk, ck, val +``` + +- [ ] **Step 11: Uruchom test K2 — ma przejść** + +Run: `uv run pytest src/bpp/tests/test_multiseek_pivot.py -k b_k2_trzej -p no:cacheprovider -q 2>&1 | tee /tmp/pivot_t2c.log; grep -E "passed|failed|error" /tmp/pivot_t2c.log | tail -3` +Expected: PASS. + +- [ ] **Step 12: Failing test — NULL-kubły → „— brak —" i koszyk PK** + +```python +@pytest.mark.django_db +def test_zbuduj_pivot_null_bucket_i_koszyk_pk(...): + from bpp.multiseek_registry import pivot + # rekord z zrodlo=None → etykieta BRAK; punkty_kbn=0 → "0" + res = pivot.zbuduj_pivot(base_qs, pivot.DIMENSIONS["koszyk_pk"], None, + pivot.METRICS["liczba"]) + labels = dict(res.rows) + assert "0" in labels.values() +``` + +- [ ] **Step 13: Uruchom cały plik testów jednostkowych pivota** + +Run: `uv run pytest src/bpp/tests/test_multiseek_pivot.py -p no:cacheprovider -q 2>&1 | tee /tmp/pivot_t2all.log; grep -E "passed|failed|error" /tmp/pivot_t2all.log | tail -3` +Expected: PASS (wszystkie). + +- [ ] **Step 14: ruff + commit** + +```bash +uv run ruff format src/bpp/multiseek_registry/pivot.py src/bpp/tests/test_multiseek_pivot.py +uv run ruff check src/bpp/multiseek_registry/pivot.py src/bpp/tests/test_multiseek_pivot.py +git add src/bpp/multiseek_registry/pivot.py src/bpp/tests/test_multiseek_pivot.py +git commit -m "feat(multiseek): zbuduj_pivot — agregacja A/B, etykiety, sumy brzegowe" +``` + +--- + +## Task 3: `report_type="pivot"` + wpięcie w widok + +**Files:** +- Modify: `src/bpp/multiseek_registry/reports.py:8` +- Modify: `src/bpp/views/mymultiseek.py` (`get_context_data`) +- Test: `src/bpp/tests/test_multiseek_pivot_view.py` + +**Interfaces:** +- Consumes: `parse_pivot_params`, `zbuduj_pivot`, `PivotResult` (Task 1-2). +- Produces: kontekst widoku z kluczem `pivot` (`PivotResult`) gdy + `report_type == "pivot"`; `report_type` string `"pivot"` z rejestru. + +- [ ] **Step 1: Dopisz report_type na KOŃCU listy** + +```python +# src/bpp/multiseek_registry/reports.py — ostatnia pozycja listy: + BibTeXReportType("bibtex", "BibTeX"), + ReportType("pivot", "tabela krzyżowa"), +] +``` + +- [ ] **Step 2: Failing test — widok z report_type=pivot zwraca kontekst pivota** + +```python +# src/bpp/tests/test_multiseek_pivot_view.py +import pytest + + +@pytest.mark.django_db +def test_pivot_results_view_zwraca_pivot(client, ...): + """Po ustawieniu formularza z report_type=pivot w sesji, GET na + /multiseek/results/?pivot_row=rok&pivot_val=liczba renderuje macierz.""" + # ustaw sesję multiseek z report_type wskazującym pivot (indeks ostatni) + # oraz danymi filtra; wykonaj GET z parametrami pivota + resp = client.get("/multiseek/results/?pivot_row=rok&pivot_val=liczba") + assert resp.status_code == 200 + assert "pivot" in resp.context + assert resp.context["pivot"].row_dim.key == "rok" +``` + +> **Uwaga:** wzorzec ustawiania sesji multiseek + report_type znajdź w +> istniejących testach (`src/bpp/tests/` / `src/integration_tests/` szukaj +> `multiseek_json` / `results`). report_type = indeks pozycyjny → pivot to +> ostatni indeks listy `multiseek_report_types`. + +- [ ] **Step 3: Uruchom — ma paść (brak klucza `pivot`)** + +Run: `uv run pytest src/bpp/tests/test_multiseek_pivot_view.py -k zwraca_pivot -p no:cacheprovider -q 2>&1 | tee /tmp/pivot_t3.log; tail -5 /tmp/pivot_t3.log` +Expected: FAIL. + +- [ ] **Step 4: Wepnij gałąź pivota w `get_context_data`** + +```python +# src/bpp/views/mymultiseek.py — w MyMultiseekResults.get_context_data, +# po ustaleniu ctx["report_type"], PRZED liczeniem agregatów listy: + if ctx.get("report_type") == "pivot": + from bpp.multiseek_registry import pivot as pivot_mod + + base_qs = self.get_queryset_for_current_mode() + row_dim, col_dim, metric = pivot_mod.parse_pivot_params(self.request.GET) + ctx["pivot"] = pivot_mod.zbuduj_pivot(base_qs, row_dim, col_dim, metric) + ctx["pivot_dimensions"] = pivot_mod.DIMENSIONS + ctx["pivot_metrics"] = pivot_mod.METRICS + ctx["paginator_count"] = 0 + return ctx + # ... istniejąca logika agregatów listy poniżej +``` + +> Gałąź pivota **omija** cache agregatów, `qset.count()` i sumy stopki +> (spec §8). Zostaw istniejącą logikę nietkniętą poniżej `return`. + +- [ ] **Step 5: Uruchom test widoku — ma przejść** + +Run: `uv run pytest src/bpp/tests/test_multiseek_pivot_view.py -k zwraca_pivot -p no:cacheprovider -q 2>&1 | tee /tmp/pivot_t3.log; grep -E "passed|failed|error" /tmp/pivot_t3.log | tail -3` +Expected: PASS. + +- [ ] **Step 6: Test stabilności indeksów report_type** + +```python +@pytest.mark.django_db +def test_pivot_report_type_na_koncu_listy(): + from bpp.multiseek_registry.reports import multiseek_report_types + assert multiseek_report_types[-1].id == "pivot" + assert multiseek_report_types[-1].public is True + # dotychczasowe typy zachowują pozycje (list/table na 0/1) + assert multiseek_report_types[0].id == "list" + assert multiseek_report_types[1].id == "table" +``` + +- [ ] **Step 7: Uruchom + ruff + commit** + +```bash +uv run pytest src/bpp/tests/test_multiseek_pivot_view.py -p no:cacheprovider -q 2>&1 | tee /tmp/pivot_t3all.log; grep -E "passed|failed|error" /tmp/pivot_t3all.log | tail -3 +uv run ruff format src/bpp/views/mymultiseek.py src/bpp/multiseek_registry/reports.py src/bpp/tests/test_multiseek_pivot_view.py +uv run ruff check src/bpp/views/mymultiseek.py src/bpp/multiseek_registry/reports.py src/bpp/tests/test_multiseek_pivot_view.py +git add src/bpp/views/mymultiseek.py src/bpp/multiseek_registry/reports.py src/bpp/tests/test_multiseek_pivot_view.py +git commit -m "feat(multiseek): report_type pivot + wpięcie PivotResult w widok" +``` + +--- + +## Task 4: Template `report-body-pivot.html` + branch + SCSS + +**Files:** +- Create: `src/django_bpp/templates/multiseek/report-body-pivot.html` +- Modify: `src/django_bpp/templates/multiseek/common-results.html:9` +- Modify: SCSS (znajdź plik komponentów multiseek: `grep -rl "multiseek-report-container" src/**/static/**/*.scss`) +- Test: `src/bpp/tests/test_multiseek_pivot_view.py` (asercje HTML) + +**Interfaces:** +- Consumes: `ctx["pivot"]` (`PivotResult`), `ctx["pivot_dimensions"]`, + `ctx["pivot_metrics"]`, `ctx["report_type"]`. + +- [ ] **Step 1: Failing test — HTML macierzy renderuje się i zawiera „RAZEM"** + +```python +@pytest.mark.django_db +def test_pivot_html_zawiera_macierz_i_razem(client, ...): + resp = client.get("/multiseek/results/?pivot_row=rok&pivot_val=liczba") + html = resp.content.decode() + assert 'class="multiseek-pivot' in html + assert "RAZEM" in html +``` + +- [ ] **Step 2: Uruchom — ma paść** + +Run: `uv run pytest src/bpp/tests/test_multiseek_pivot_view.py -k html_zawiera -p no:cacheprovider -q 2>&1 | tee /tmp/pivot_t4.log; tail -5 /tmp/pivot_t4.log` +Expected: FAIL. + +- [ ] **Step 3: Gałąź pivota w common-results.html (przed gate 25000)** + +```django +{# src/django_bpp/templates/multiseek/common-results.html — po otwarciu #} +{#
(linia 9) wstaw: #} + {% if report_type == "pivot" %} + {% include "multiseek/report-body-pivot.html" %} + {% else %} + {# ... CAŁA dotychczasowa zawartość od `{% if paginator_count > 25000 %}` ... #} + {% endif %} +``` + +> Gałąź pivota jest PRZED `{% if paginator_count > 25000 %}` i przed +> `{% autopaginate %}` — pivot nie fetchuje rekordów listy ani nie +> stronicuje. Zamknij `{% endif %}` przed `
` zamykającym kontener +> (linia ~122). Każda linia komentarza `{# #}` osobno (reguła projektu). + +- [ ] **Step 4: Utwórz `report-body-pivot.html`** + +```django +{# src/django_bpp/templates/multiseek/report-body-pivot.html #} +{% load i18n %} +
+ + + +
+ +
+ + + + + {% for ck, clabel in pivot.cols %}{% endfor %} + + + + + {% for rk, rlabel in pivot.rows %} + + + {% if pivot.cols %} + {% for ck, clabel in pivot.cols %} + + {% endfor %} + {% else %} + + {% endif %} + + + {% endfor %} + + + + + {% for ck, clabel in pivot.cols %}{% endfor %} + + + +
{{ pivot.row_dim.label }}{{ clabel }}RAZEM
{{ rlabel }}{{ pivot.cells|pivot_cell:rk|pivot_cell:ck }}{{ pivot.cells|pivot_cell:rk|pivot_cell:None }}{{ pivot.row_totals|dict_get:rk }}
RAZEM{{ pivot.col_totals|dict_get:ck }}{{ pivot.grand_total }}
+
+ +{% if pivot.has_autorzy_dim %} +

+ ⓘ Grupowanie po jednostce/dyscyplinie/autorze liczy powiązania, nie unikatowe prace — + praca powiązana z wieloma jednostkami liczona jest w każdej z nich; sumy mogą przewyższać wartości całkowite. +

+{% endif %} +``` + +> **Filtry szablonowe:** Django nie indeksuje krotek/dictów po zmiennym +> kluczu. Dwie opcje (wybierz prostszą dla projektu): +> (a) dołóż mały template-tag/filtr `dict_get` i `pivot_cell` w istniejącej +> bibliotece tagów multiseek (`src/bpp/templatetags/`), LUB +> (b) w `zbuduj_pivot`/widoku przekształć `PivotResult` w gotowe do +> iteracji listy wierszy `[{"label":..., "cells":[...], "total":...}]` + +> nagłówki + stopkę, i renderuj bez indeksowania po kluczu. +> **Rekomendacja: (b)** — czystszy szablon, brak magii filtrów. Jeśli +> wybierzesz (b), dodaj do `PivotResult` metodę/property `as_table()` +> zwracającą tę strukturę i użyj jej w template (zmień test HTML odpowiednio). + +- [ ] **Step 5: SCSS — styl tabeli krzyżowej (sticky nagłówki, scroll)** + +```scss +// w pliku komponentów multiseek (bez nadpisywania siatki Foundation): +.multiseek-pivot-scroll { overflow-x: auto; } +.multiseek-pivot { + border-collapse: collapse; + th, td { border: 1px solid #ccc; padding: 4px 10px; text-align: right; } + thead th, tbody th { text-align: left; background: #f4f4f4; } + .pivot-total { font-weight: bold; background: #eee; } +} +.multiseek-pivot-note { color: #666; font-size: 0.85em; margin-top: 0.5em; } +.multiseek-pivot-controls { margin-bottom: 1em; display: flex; gap: 1em; flex-wrap: wrap; } +``` + +Po zmianie SCSS: `grunt build` (patrz Global Constraints / docs). + +- [ ] **Step 6: Uruchom test HTML + build assetów** + +Run: `uv run pytest src/bpp/tests/test_multiseek_pivot_view.py -p no:cacheprovider -q 2>&1 | tee /tmp/pivot_t4all.log; grep -E "passed|failed|error" /tmp/pivot_t4all.log | tail -3` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/django_bpp/templates/multiseek/report-body-pivot.html \ + src/django_bpp/templates/multiseek/common-results.html \ + src/bpp/tests/test_multiseek_pivot_view.py +# + zmienione pliki SCSS i zbudowane assety, jeśli dotyczy +git commit -m "feat(multiseek): partial tabeli krzyżowej + branch + styl" +``` + +--- + +## Task 5: Eksport pivota (XLSX/CSV) — omija cap 5000 + +**Files:** +- Modify: `src/bpp/views/mymultiseek.py` (`MyMultiseekExport.get` / `_export_data`) +- Modify: `src/bpp/views/multiseek_export.py` (builder z `PivotResult`) +- Test: `src/bpp/tests/test_multiseek_pivot_view.py` + +**Interfaces:** +- Consumes: `parse_pivot_params`, `zbuduj_pivot`, `PivotResult`. +- Produces: `pivot_xlsx_export_response(pivot_result, request, title)` i + `pivot_csv_export_response(pivot_result, request, title)` w + `multiseek_export.py`. + +- [ ] **Step 1: Failing test — eksport XLSX pivota, cap 5000 nie blokuje** + +```python +@pytest.mark.django_db +def test_pivot_export_xlsx_liczby_zgodne_z_widokiem(admin_client, ...): + # ustaw sesję report_type=pivot; GET eksportu + resp = admin_client.get("/multiseek/export/xlsx/?pivot_row=rok&pivot_val=liczba") + assert resp.status_code == 200 + assert resp["Content-Type"].startswith( + "application/vnd.openxmlformats-officedocument.spreadsheetml" + ) + # (opcjonalnie) wczytaj openpyxl i porównaj sumę z grand_total +``` + +- [ ] **Step 2: Uruchom — ma paść (eksport traktuje pivot jak dane listy / cap)** + +Run: `uv run pytest src/bpp/tests/test_multiseek_pivot_view.py -k export_xlsx -p no:cacheprovider -q 2>&1 | tee /tmp/pivot_t5.log; tail -5 /tmp/pivot_t5.log` +Expected: FAIL. + +- [ ] **Step 3: Gałąź pivota w `MyMultiseekExport.get` (przed cap 5000)** + +```python +# src/bpp/views/mymultiseek.py — w MyMultiseekExport.get, na początku, +# PRZED `count = queryset.count()` / cap 5000: + registry = get_registry(self.registry) + report_type = registry.get_report_type( + self.get_multiseek_data(), request=request + ) + if report_type == "pivot": + from bpp.multiseek_registry import pivot as pivot_mod + from bpp.views.multiseek_export import ( + pivot_csv_export_response, + pivot_xlsx_export_response, + ) + + base_qs = self.get_queryset_for_current_mode() + row_dim, col_dim, metric = pivot_mod.parse_pivot_params(request.GET) + pr = pivot_mod.zbuduj_pivot(base_qs, row_dim, col_dim, metric) + title = _multiseek_report_title(request) + if export_format == "csv": + return pivot_csv_export_response(pr, request, title) + if export_format == "xlsx": + return pivot_xlsx_export_response(pr, request, title) + return HttpResponseBadRequest( + "Eksport tabeli krzyżowej dostępny jako XLSX lub CSV." + ) +``` + +- [ ] **Step 4: Buildery w `multiseek_export.py`** + +```python +# src/bpp/views/multiseek_export.py — nowe funkcje budujące płaską macierz +# (nagłówek: [etykieta wiersza] + etykiety kolumn + "RAZEM"; wiersze danych; +# wiersz RAZEM). Wykorzystaj istniejące helpery XLSX/CSV z tego modułu. +def _pivot_rows(pr): + header = [pr.row_dim.label] + [c[1] for c in pr.cols] + ["RAZEM"] + yield header + for rk, rlabel in pr.rows: + row = [rlabel] + if pr.cols: + row += [pr.cells.get((rk, ck), "") for ck, _ in pr.cols] + else: + row += [pr.cells.get((rk, None), "")] + row.append(pr.row_totals.get(rk, 0)) + yield row + footer = ["RAZEM"] + [pr.col_totals.get(ck, 0) for ck, _ in pr.cols] + [pr.grand_total] + yield footer + + +def pivot_csv_export_response(pr, request, report_title): + ... # analogicznie do csv_export_response, ale z _pivot_rows(pr) + + +def pivot_xlsx_export_response(pr, request, report_title): + ... # analogicznie do xlsx_export_response, ale z _pivot_rows(pr) +``` + +> Wykorzystaj istniejące funkcje `csv_export_response` / +> `xlsx_export_response` jako wzorzec (nagłówki HTTP, nazwa pliku, +> openpyxl). `_pivot_rows` daje wiersze; reszta jak w istniejących. + +- [ ] **Step 5: Uruchom test eksportu — ma przejść** + +Run: `uv run pytest src/bpp/tests/test_multiseek_pivot_view.py -k export -p no:cacheprovider -q 2>&1 | tee /tmp/pivot_t5.log; grep -E "passed|failed|error" /tmp/pivot_t5.log | tail -3` +Expected: PASS. + +- [ ] **Step 6: ruff + commit** + +```bash +uv run ruff format src/bpp/views/mymultiseek.py src/bpp/views/multiseek_export.py src/bpp/tests/test_multiseek_pivot_view.py +uv run ruff check src/bpp/views/mymultiseek.py src/bpp/views/multiseek_export.py src/bpp/tests/test_multiseek_pivot_view.py +git add src/bpp/views/mymultiseek.py src/bpp/views/multiseek_export.py src/bpp/tests/test_multiseek_pivot_view.py +git commit -m "feat(multiseek): eksport XLSX/CSV tabeli krzyżowej (omija cap 5000)" +``` + +--- + +## Task 6: Ukryj eksport dla anonima + newsfragment + pełny przebieg + +**Files:** +- Modify: `src/django_bpp/templates/multiseek/report-body-pivot.html` (przycisk eksportu tylko dla zalogowanych) +- Create: `src/bpp/newsfragments/multiseek-pivot.feature.rst` +- Test: `src/bpp/tests/test_multiseek_pivot_view.py` + +- [ ] **Step 1: Dodaj przyciski eksportu (tylko zalogowany) do partiala** + +```django +{# w report-body-pivot.html, pod tabelą (nad/pod adnotacją): #} +{% if request.user.is_authenticated %} + +{% endif %} +``` + +> Eksport pivota jest `LoginRequiredMixin` — dla anonima link i tak dałby +> redirect do logowania, więc ukrywamy go (spec §9.3). + +- [ ] **Step 2: Test — anonim nie widzi eksportu, zalogowany widzi** + +```python +@pytest.mark.django_db +def test_pivot_export_ukryty_dla_anonima(client, admin_client, ...): + anon = client.get("/multiseek/results/?pivot_row=rok").content.decode() + assert "export/xlsx" not in anon + logged = admin_client.get("/multiseek/results/?pivot_row=rok").content.decode() + assert "export/xlsx" in logged +``` + +- [ ] **Step 3: Newsfragment** + +```rst +.. src/bpp/newsfragments/multiseek-pivot.feature.rst +Nowy typ raportu „tabela krzyżowa" w wyszukiwarce: grupowanie wyników +(oraz prac autora) w pivot — wybierany wymiar wierszy i kolumn oraz +metryka w komórce (liczba prac, suma punktów PK, IF, cytowań), z sumami +brzegowymi i eksportem do XLSX/CSV. +``` + +- [ ] **Step 4: Pełny przebieg testów pivota + powiązanych** + +Run: `uv run pytest src/bpp/tests/test_multiseek_pivot.py src/bpp/tests/test_multiseek_pivot_view.py -p no:cacheprovider -q 2>&1 | tee /tmp/pivot_full.log; grep -E "passed|failed|error" /tmp/pivot_full.log | tail -3` +Expected: PASS (wszystkie). + +- [ ] **Step 5: Regresja multiseek (istniejące testy nietknięte)** + +Run: `uv run pytest src/bpp/tests/ -k multiseek -p no:cacheprovider -q 2>&1 | tee /tmp/pivot_reg.log; grep -E "passed|failed|error" /tmp/pivot_reg.log | tail -3` +Expected: PASS (report_type dopisany na końcu nie rusza istniejących indeksów). + +- [ ] **Step 6: Commit** + +```bash +git add src/django_bpp/templates/multiseek/report-body-pivot.html \ + src/bpp/newsfragments/multiseek-pivot.feature.rst \ + src/bpp/tests/test_multiseek_pivot_view.py +git commit -m "feat(multiseek): ukryj eksport pivota dla anonima + newsfragment" +``` + +--- + +## Self-Review (wypełnione) + +**Spec coverage:** +- §4 report_type + GET → Task 3 (report_type), Task 1 (parse GET). ✓ +- §5 UI (selektory, RAZEM, scroll, adnotacja pod tabelą, puste komórki) → Task 4. ✓ +- §6 menu wymiarów/metryk + mapowanie etykiet → Task 1 (rejestry) + Task 2 (`_label_mapping`). ✓ +- §7 semantyka Autorzy (pary, K2, adnotacja) → Task 2 (`_pairs_strategy`), Task 4 (adnotacja). ✓ +- §8 strategia A/B, czyszczenie orderingu, brak count/cache → Task 2 + Task 3. ✓ +- §9 eksport (cap 5000 bypass, kontrakt DANE/DOKUMENT, LoginRequired) → Task 5 + Task 6. ✓ +- §11 testy (K1/K2/K3, NULL, indeksy, degeneracja, eksport, anonim) → rozłożone po Task 2-6. ✓ +- Multi-hosted `ukryte_statusy` — dziedziczone z `get_queryset` (Task 3 używa `get_queryset_for_current_mode`), pokryte istniejącą logiką. ✓ + +**Placeholder scan:** Kod-steps mają realny kod; miejsca oznaczone `...` +to WYŁĄCZNIE dane fixture/asercje zależne od danych testowych oraz dwa +buildery eksportu wzorowane na istniejących funkcjach — z jawną +instrukcją, skąd wziąć wzorzec. Brak „TODO/TBD/handle edge cases". + +**Type consistency:** `PivotDimension`/`PivotMetric`/`PivotResult`, +`parse_pivot_params`, `zbuduj_pivot`, `_dedup_strategy`/`_pairs_strategy`/ +`_build_matrix`/`_labels`/`_label_mapping`, `_pivot_rows`, +`pivot_{csv,xlsx}_export_response` — nazwy spójne między Task 1-6. + +**Znane ryzyko dla implementera (świadome):** fixtury tworzące wpisy w +`bpp_rekord_mat`/`bpp_autorzy_mat` MUSZĄ użyć istniejącego w projekcie +mechanizmu odświeżania cache (Task 2 Step 1 uwaga) — to jedyne miejsce, +gdzie plan celowo odsyła do wzorca projektu zamiast dyktować kod, bo +mechanizm jest projektowo-specyficzny. diff --git a/docs/superpowers/specs/2026-07-24-multiseek-pivot-grupowanie-design.md b/docs/superpowers/specs/2026-07-24-multiseek-pivot-grupowanie-design.md new file mode 100644 index 000000000..a9ce9d1ec --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-multiseek-pivot-grupowanie-design.md @@ -0,0 +1,371 @@ +# Grupowanie wyników jako tabela krzyżowa (pivot) — multiseek + +**Data:** 2026-07-24 +**Gałąź:** `feat/multiseek-pivot-grupowanie` +**Status:** projekt zaakceptowany, uzupełniony po self-review (Fable), do +spisania planu implementacji + +## 1. Cel + +Dodać do wyszukiwarki możliwość **grupowania wyników w tabelę krzyżową +(pivot)** — zbiorcze podsumowanie zamiast płaskiej listy rekordów. User +wybiera wymiar wierszy, wymiar kolumn i metrykę w komórce (model pivota +z Excela: *Rows × Columns × Values*), dostaje krzyżówkę z sumami +brzegowymi i wierszem/kolumną „RAZEM". + +Motywacja użytkownika: szybko zobaczyć np. „ile prac i ile punktów PK +w danym roku wg charakteru", „prace autora wg kliniki i rodzaju", +„rozkład prac wg koszyka punktów PK". + +## 2. Kluczowe ustalenie architektoniczne + +**„Prace autora" renderują się przez ten sam silnik co „Szukaj → +Multiseek".** Strona autora (`AutorView`, `src/bpp/views/browse.py:235`) +to tylko formularz; `BuildSearch` (`src/bpp/views/browse.py:781`) składa +zapytanie multiseek do sesji i przekierowuje na **`multiseek:index`** +(formularz z live-iframe `./live-results/`, `multiseek/index.html:402`) — +czyli user prac autora **ma na tej stronie selektor typu raportu**. +Kluczowe: to **ten sam silnik multiseek** renderuje listę prac, więc +pivot zaimplementowany w multiseek pokrywa jednocześnie „Szukaj → +Multiseek" i „prace autora" — bez dodatkowego kodu w widoku autora. + +Wyszukiwarka „Szukaj → Zapytaniem" (DjangoQL, `ZapytanieView`, +`src/bpp/views/zapytanie.py`) to **osobny silnik** (dostępny tylko dla +redaktorów/superuserów). Jest poza zakresem fazy 1. + +## 3. Decyzje projektowe (podjęte) + +| Decyzja | Wybór | +|---|---| +| Sens „grupowania" | **Pivot / zwinięte podsumowanie** (nie sekcje z pełną listą) | +| Kształt | **Tabela krzyżowa** (Wiersze × Kolumny × Wartość) | +| Zawartość komórki | **Wybierana metryka** (pole „Wartości" jak w Excelu) | +| Zasięg | **Multiseek najpierw** (pokrywa Multiseek + prace autora); zapytanie/DjangoQL = faza 2 | +| Eksport pivota | **W fazie 1** (XLSX/CSV od razu) | +| Drill-down (klik w komórkę → rekordy grupy) | **Faza 2** | +| Wymiary z tabeli `Autorzy` (Jednostka/Dyscyplina/Autor) | **W fazie 1**, z widoczną adnotacją o dublowaniu | +| „Wydział" jako wymiar | **Faza 2** (dziura NULL-korzenia, §6/§13) | + +## 4. Punkt wpięcia — nowy `report_type` + parametry GET + +Multiseek ma wybór typu raportu (lista / tabela / bibtex) sterujący +wyborem partiala w `src/django_bpp/templates/multiseek/common-results.html:103`. +Typy zdefiniowane w `src/bpp/multiseek_registry/reports.py:8`. + +**Dodajemy nowy typ raportu `"pivot"`** („Tabela krzyżowa"). + +> **UWAGA (report_type to indeks pozycyjny, nie string!).** Formularz +> POST-uje `forloop.counter0` (`multiseek/index.html:44`); `get_report_type` +> indeksuje przefiltrowaną per-request listę (`multiseek/logic.py:721`). +> Sesje i zapisane `SearchForm` przechowują **indeks**. Dlatego nowy typ +> `pivot` **musi być dopisany na KOŃCU** `multiseek_report_types` +> (`reports.py:8`) i mieć **`public=True`** (anonim na stronie autora). +> Wstawienie w środek przesunęłoby zapisane formularze. + +**Rozdział odpowiedzialności:** +- **report_type** (który to typ raportu, w tym „pivot") — mechanizmem + istniejącym: **selektor w formularzu → sesja**. Bez GET-override + (musiałby być honorowany spójnie w 3 miejscach — niepotrzebna + komplikacja). +- **Konfiguracja pivota** (wiersze / kolumny / metryka) — **parametry GET** + na URL wyników, czytane w widoku wyników: + + ``` + /multiseek/results/?pivot_row=rok&pivot_col=charakter_ogolny&pivot_val=liczba + ``` + + Zalety: przestawienie pivota nie przebudowuje zapytania (sama zmiana + GET); pivot jest **linkowalny/bookmarkowalny**. Brak parametrów → sensowne + domyślne (`pivot_row=rok`, `pivot_col=` brak, `pivot_val=liczba`). + +> **Znane zachowanie (do udokumentowania, nie bug):** na stronie prac +> autora wyniki są w live-iframe; każda zmiana formularza robi POST do +> iframe i **resetuje** parametry `pivot_*` z GET. Świadome, akceptowalne +> w v1. + +Odrzucona alternatywa: osobny widok `/multiseek/pivot/` — dublowałby glue +„sesja → queryset" i wypychał usera z ekranu wyników. + +## 5. Interfejs + +Gdy typ raportu = „Tabela krzyżowa", nad wynikami pojawia się pasek z +trzema selektorami (mini-formularz GET); zmiana któregokolwiek +przeładowuje wyniki: + +``` +Typ raportu: [ Tabela krzyżowa ▾ ] +Wiersze:[ Rok ▾ ] Kolumny:[ Charakter ogólny ▾ | (brak) ] W komórce:[ Liczba prac ▾ ] + + Artykuł Rozdział Monografia │ RAZEM + 2024 9 4 2 │ 15 + 2023 7 3 2 │ 12 + ──────────────────────────────────────────────── + RAZEM 16 7 4 │ 27 + + ⓘ Grupowanie po jednostce/dyscyplinie/autorze liczy powiązania… (adnotacja pod tabelą) +``` + +- **Kolumny = (brak)** → zwykła płaska tabela zbiorcza (degeneracja + cross-tabu; user dostaje i pivot 2D, i proste podsumowanie 1D). +- Szeroka krzyżówka → poziomy scroll w kontenerze `overflow-x:auto` + (body strony się nie rozjeżdża — wzorzec jak w tabelach importu). +- **Puste komórki** (brak rekordów na przecięciu) → puste (blank) dla + czytelności; sumy brzegowe i tak liczone. +- Wiersz i kolumna **RAZEM** = sumy brzegowe; przecięcie = suma całkowita. +- Sortowanie wierszy: wg wartości wymiaru (rok malejąco; słowniki + alfabetycznie). Kolumny analogicznie. +- **Adnotacja o dublowaniu** (gdy wybrany wymiar z tabeli `Autorzy` — + Jednostka/Dyscyplina/Autor) renderowana **bezpośrednio pod tabelą + krzyżową** (pod pivotem, nie nad nim), stonowana wizualnie (mały, + szary tekst). + +## 6. Menu wymiarów i metryk + +### Wymiary — jako **wiersze**: +Rok · Charakter formalny · Charakter ogólny (rodzaj) · Typ MNiSW/MEiN +(`typ_kbn`) · Koszyk punktów PK · Język · Źródło · **Jednostka** · +**Dyscyplina naukowa** · **Autor** + +### Wymiary — jako **kolumny** (tylko o małej liczności): +Rok · Charakter formalny · Charakter ogólny · Typ MNiSW/MEiN · Koszyk PK · +Język · Dyscyplina + +> Jednostka / Źródło / Autor **nie są dostępne jako kolumny** — zbyt duża +> liczność rozsadziłaby szerokość tabeli. Tylko jako wiersze. +> **„Wydział" — faza 2** (patrz §13, dziura NULL-korzenia). + +### Metryki — „w komórce": +Liczba prac *(domyślna)* · Σ punkty PK (`punkty_kbn`) · Σ Impact Factor · +Σ liczba cytowań · Σ punktacja wewnętrzna + +### Mapowanie wymiarów na wyrażenia ORM i etykiety +| Wymiar | Wyrażenie ORM (`values`) | Etykieta / uwaga | +|---|---|---| +| Rok | `rok` | wartość wprost | +| Charakter formalny | `charakter_formalny_id` | dociągnąć `nazwa` (mapa id→nazwa) | +| Charakter ogólny | `charakter_formalny__charakter_ogolny` | **surowe kody 3-zn.** → mapa `CHARAKTER_OGOLNY_CHOICES` (`charakter_formalny.py:80`) | +| Typ MNiSW/MEiN | `typ_kbn_id` | dociągnąć `nazwa` | +| Koszyk punktów PK | `punkty_kbn` | Decimal (default=0, NOT NULL); 0 → „brak (0)"; formatować etykiety | +| Język | `jezyk_id` | dociągnąć `nazwa` | +| Źródło | `zrodlo_id` | dociągnąć `nazwa`; NULL → „brak" | +| Jednostka | `autorzy__jednostka_id` | **JOIN do `Autorzy`** — §7; dociągnąć `nazwa`; NULL → „brak" | +| Dyscyplina | `autorzy__dyscyplina_naukowa_id` | JOIN do `Autorzy`; dociągnąć `nazwa`; NULL → „brak" | +| Autor | `autorzy__autor_id` | JOIN do `Autorzy`; **zwraca id, nie str** — hurtowo dociągnąć nazwiska, sortować w Pythonie | + +> **Ujednolicenie NULL-kubłów:** dla każdego wymiaru dopuszczającego NULL +> (źródło, dyscyplina, autor, jednostka przy LEFT JOIN) wartość NULL → +> etykieta **„brak"** (spójnie z koszykiem PK). + +## 7. Subtelność semantyczna — wymiary z tabeli `Autorzy` + +Pola **na rekordzie** (rok, charakter, typ, punkty, język, źródło): +grupowanie czyste — każdy rekord liczony raz. + +Pola **na powiązaniu `Autorzy`** (Jednostka, Dyscyplina, Autor) — +`src/bpp/models/cache/autorzy.py:24`: rekord ma wielu autorów z różnych +jednostek/dyscyplin. **Zdefiniowana semantyka v1: pary (rekord, +wartość-wymiaru).** Praca powiązana z 2 klinikami → liczy się **raz w +każdej klinice** (NIE N-krotnie wg liczby autorów z danej kliniki). + +- „liczba prac" w grupie = liczba **unikatowych rekordów** powiązanych z + daną jednostką (dedup po rekordzie w obrębie grupy), +- Σ metryki = suma po **unikatowych parach** (rekord, jednostka) — punkty + rekordu wliczone raz do każdej jednostki, z którą powiązany. +- Dublowanie **między różnymi** jednostkami/dyscyplinami jest zamierzone + i komunikowane adnotacją pod tabelą (§5). +- Dla **prac autora** (filtr `autorzy__autor=X`) join jest **reużywany** + (zweryfikowany SQL) → grupujemy po jednostkach/dyscyplinach *tego* + autora — dokładnie to, o co chodzi. +- „Uczciwe" punkty per-dyscyplina ze slotów (`Cache_Punktacja_Dyscypliny.pkd`/`slot`, + `src/bpp/models/cache/punktacja.py:18`) → **faza 2**. + +## 8. Backend — strategia agregacji (per typ wymiaru) + +Punkt wyjścia: `base_qs` = przefiltrowany queryset (ten sam filtr co lista +wyników), ale **BEZ `.only()`, BEZ `.distinct()` i z WYCZYSZCZONYM +orderingiem** (`base_qs = filtered.order_by()`). + +> **KRYTYCZNE (K3): jawny `order_by` wchodzi do GROUP BY.** Rejestr +> multiseek ZAWSZE aplikuje `.order_by(...)` (`default_ordering=["-rok"]`, +> `src/bpp/multiseek_registry/__init__.py:26`; `apply_ordering_to_queryset`, +> `multiseek/logic.py:744`). Bez `.order_by()` przed `values().annotate()` +> sort formularza (np. „źródło/wyd. nadrzędne" = `CASE WHEN…`) wchodzi do +> `GROUP BY` i **rozbija pivot na mikrogrupy**. Musi być jawnie wyczyszczony +> i pokryty testem (najgroźniejsza cicha regresja). + +Filtry multiseek mogą **mnożyć wiersze** rekordu (JOIN do `bpp_autorzy_mat` +/ `bpp_zewnetrzne_bazy_view`). `.distinct()` z listy wyników **NIE +naprawia** GROUP BY (dedup następuje PO agregacji). Dlatego dwie ścieżki: + +### A) Oba wymiary (wiersz i kolumna) są **rekordowe** +Agregujemy po **zdedupowanym zbiorze rekordów**, żeby filtr mnożący nie +zawyżał (K1): +```python +ids = base_qs.values("pk") +deduped = Rekord.objects.filter(pk__in=ids).order_by() # świeży, czysty +matrix_rows = (deduped + .values(row_expr, col_expr) # col_expr pominięty, gdy kolumny=(brak) + .annotate(licznik=Count("id"), suma=Sum(metric_field)) + .order_by(row_expr, col_expr)) +``` +Każdy rekord raz. `Count("id")` OK (`Rekord.id` = `TupleField`/`int[]`, +PG liczy). `Sum(metric_field)` — metryka raz per rekord. + +### B) Wiersz **lub** kolumna to wymiar **autorski** (jednostka/dyscyplina/autor) +NIE używamy `pk__in`-subquery (zerwałby join-reuse → jednostki wszystkich +współautorów zamiast, dla prac autora, tylko tego autora). Operujemy na +`base_qs` (zachowuje kontekst filtra), pobieramy **unikatowe pary** i +agregujemy w Pythonie: +```python +pairs = (base_qs + .values(row_expr, col_expr, "id", metric_field) # id = rekord + .distinct()) +# w Pythonie, per (wiersz, kolumna): +# licznik = liczba unikatowych rekordów (set po id) +# suma = Σ metric_field po unikatowych parach (wymiar, rekord) +``` +Realizuje „raz per klinika" i punkty rekordu wliczone raz w każdą jednostkę +(§7). (Metryki v1 są zawsze **na poziomie rekordu**, więc `(wymiar, id)` +jednoznacznie wyznacza `metric_field`.) + +### Składanie macierzy +Płaska lista trójek `(wiersz, kolumna, wartość)` → struktura: +- posortowany zbiór wierszy, posortowany zbiór kolumn (pusty gdy + kolumny=(brak)), słownik `{(wiersz, kolumna): wartość}`, sumy per-wiersz, + per-kolumna, suma całkowita. +- surowe klucze (id/kody) → etykiety wg map z §6 (hurtowe `in_bulk` dla + FK, `dict(CHOICES)` dla kodów) — **jeden** dodatkowy query per słownik. + +### Wydajność / liczności +- Dla pivota **nie** liczymy drogiego `qset.count()` (`mymultiseek.py:208`) + ani sum ze stopki listy — pivot ma własne agregaty. +- Gate 25000 rekordów z listy (`common-results.html:10`) **nie obowiązuje** + dla pivota (agreguje, nie renderuje rekordów). Ewentualne miękkie + ostrzeżenie przy bardzo dużym zbiorze — patrz §13. +- **Cache:** w v1 **nie** cache'ujemy pivota (istniejący `_aggregate_cache_key` + nie obejmuje `pivot_*` — cache'owanie bez poprawki klucza dałoby + krzyżowe trafienia). Ewentualny cache z kluczem obejmującym GET — faza 2. + +## 9. Eksport (faza 1) + +Multiseek ma eksport `MyMultiseekExport` (`src/bpp/views/mymultiseek.py:239`), +formaty w `src/bpp/views/multiseek_export.py`. Kolizje z istniejącym +kontraktem (do rozwiązania): + +1. **Twardy cap 5000 rekordów PRZED gałęzią** (`mymultiseek.py:252`) — + dotyczy liczby rekordów źródłowych. Dla pivota **wynik to mała macierz**, + nie per-rekord — cap na rozmiar wyjścia jest bezcelowy. Rozwiązanie: + **dla `report_type==pivot` omijamy cap 5000** (budujemy z `PivotResult`, + nie z listy rekordów). +2. **Kontrakt „csv/xlsx = stałe kolumny, niezależne od report_type"** + (`mymultiseek.py:242`) — macierz pivota łamie to założenie. Rozwiązanie: + **jawna gałąź `if report_type == pivot`** w `MyMultiseekExport.get`, + budująca XLSX/CSV z tej samej struktury `PivotResult` co widok (jedno + źródło prawdy). BibTeX nie dotyczy pivota — pomijamy; DOCX/HTML + opcjonalnie, jeśli tanie. +3. **`LoginRequiredMixin`** na eksporcie — anonim ze strony autora + **widzi** pivot na ekranie, ale **nie wyeksportuje**. W v1: przycisk + eksportu pivota ukryty dla anonima; eksport pozostaje login-only. + (Eksport dla anonima — faza 2, jeśli potrzebny.) + +## 10. Pliki do zmiany (orientacyjnie) + +- `src/bpp/multiseek_registry/reports.py` — nowy typ raportu `pivot` + **na końcu** listy, `public=True`. +- `src/bpp/multiseek_registry/pivot.py` — **nowy**: rejestr wymiarów + (klucz GET → etykieta + wyrażenie ORM + flaga „dozwolony jako kolumna" + + flaga „wymaga JOIN do autorzy" + strategia etykiet) i metryk; + `PivotResult` (macierz + sumy); `zbuduj_pivot(base_qs, row, col, val)` + z rozgałęzieniem A/B (§8), czyszczeniem orderingu i mapowaniem etykiet; + walidacja GET (nieznany klucz → default / czytelny komunikat, nie 500). +- `src/bpp/views/mymultiseek.py` — w `MyMultiseekResults.get_context_data` + (`~:181`): gdy `report_type == pivot`, policz `PivotResult` z GET, + **pomiń** count/sumy listy; w `MyMultiseekExport.get` (`~:239`) — gałąź + eksportu pivota (omija cap 5000). +- `src/django_bpp/templates/multiseek/common-results.html` (`~:10`, `~:103`) + — dla pivota **ominąć gate 25000, pominąć `autopaginate` i oba + paginatory** (nie fetchować 20 rekordów na darmo), wybrać nowy partial. + (Restrukturyzacja większa niż sam `if` przy :103.) +- `src/django_bpp/templates/multiseek/report-body-pivot.html` — **nowy**: + pasek selektorów (GET) + tabela krzyżowa (sticky nagłówki, scroll) + + adnotacja o dublowaniu pod tabelą. +- `src/bpp/views/multiseek_export.py` — builder XLSX/CSV pivota z + `PivotResult`. +- SCSS: komponent stylu tabeli krzyżowej (sticky wiersz/kolumna nagłówków, + `overflow-x`) — bez nadpisywania siatki Foundation. +- Testy: `src/bpp/tests/` (§11). +- Newsfragment: `src/bpp/newsfragments/.feature.rst`. + +**Bez migracji, bez zmian w modelach.** Wszystko na istniejących polach +`Rekord` / `Autorzy` (materializowane widoki `bpp_rekord_mat` / +`bpp_autorzy_mat`). + +## 11. Testy + +Jednostkowe `zbuduj_pivot()` + widok + (opcjonalnie) Playwright. Zestaw z +review: +1. **Inflacja/K1**: filtr mnożący (szukanie po jednostce/typie + odpowiedzialności) × wymiar rekordowy (rok × charakter) → liczby równe + płaskiej liście z `distinct` (rekord raz). +2. **Ordering-leak/K3**: sort „tytuł oryginalny"/„źródło" ustawiony w + formularzu → pivot nadal poprawny (ordering wyczyszczony). +3. **Semantyka autorska/K2**: rekord z 3 autorami z **jednej** kliniki → + komórka = **1 praca** (nie 3), Σ punkty = punkty rekordu **raz**; + rekord z autorami w 2 klinikach → liczy się po 1 w każdej. +4. **Stabilność indeksów `report_type`**: zapisany `SearchForm` sprzed + dodania pivota działa po dopisaniu typu na końcu. +5. **Wydział/NULL-korzeń** (gdy trafi do v1) — pominięty, bo Wydział → faza 2. +6. **NULL-kubły**: źródło/dyscyplina/autor NULL → etykieta „brak". +7. **Degeneracja**: kolumny=(brak) → płaska tabela; puste komórki; koszyk + PK z 0 → „brak (0)". +8. **Eksport**: XLSX/CSV pivota = te same liczby co widok; >5000 rekordów + źródłowych nie blokuje eksportu pivota. +9. **Multi-hosted**: ukryte statusy (`ukryte_statusy`, + `mymultiseek.py:100`) respektowane w pivocie dla anonima. +10. **Reset `pivot_*`** przy przełączeniu raportu w live-iframe + (dokumentujący zamierzone zachowanie). + +Konwencje pytest projektu: funkcje, `@pytest.mark.django_db`, +`model_bakery.baker.make`, `-n auto`. + +## 12. Poza zakresem v1 (faza 2 i dalej) + +- **Szukaj → Zapytaniem (DjangoQL)** — ten sam pivot w `ZapytanieView` + (`src/bpp/views/zapytanie.py:366`, przed `Paginator`); wspólny + `zbuduj_pivot()`. +- **Drill-down** — klik w komórkę/wiersz → lista rekordów tej grupy. +- **„Wydział"** jako wymiar (Coalesce/Case dla NULL-korzenia, §13/W4). +- **„Uczciwe" punkty per-dyscyplina** ze slotów + (`Cache_Punktacja_Dyscypliny.pkd`/`slot`). +- Cache pivota z kluczem obejmującym `pivot_*`. +- Eksport pivota dla anonima; trzeci wymiar / zagnieżdżanie; wykresy. + +## 13. Otwarte kwestie (do rozstrzygnięcia w planie) + +Większość pierwotnych ryzyk rozstrzygnięta po review: +- ~~report_type z GET vs sesji~~ → **sesja** (selektor formularza), tylko + `pivot_*` z GET (§4). +- ~~limit 25000~~ → **omijany** dla pivota (§8); eksport omija cap 5000 (§9). +- ~~etykiety~~ → mapowanie w §6/§8. +- ~~widoczność per-uczelnia~~ → pivot ma **własny, stały** zestaw wymiarów, + niezależny od `BppMultiseekVisibility` (inne przeznaczenie niż filtry). + +Pozostaje: +1. **Miękkie ostrzeżenie** przy bardzo dużym zbiorze źródłowym dla pivota + (np. > N rekordów) — czy dodawać próg ostrzegawczy, czy liczyć zawsze. + Rekomendacja: liczyć zawsze, bez progu (GROUP BY tani); dołożyć próg + dopiero jeśli w praktyce zaboli. +2. **Puste komórki**: blank (rekomendacja) — potwierdzić w partialu. +3. **DOCX/HTML eksport pivota** — robić w v1 (jeśli tanie) czy tylko + XLSX/CSV. Rekomendacja: XLSX+CSV w v1, reszta faza 2. + +## 14. Podsumowanie nakładu + +Po review: **średni-duży** (pierwotne „średni" zaniżone). Szkielet +(wymiary rekordowe + Count + partial) jest średni; ciężar dokłada: +(a) poprawna agregacja przy wymiarach autorskich i filtrach mnożących — +hybryda A/B (§8) zamiast „jednego GROUP BY"; (b) restrukturyzacja +`common-results.html` wokół gate'u 25000 i paginacji; (c) pogodzenie +eksportu z kontraktem DANE/DOKUMENT + cap 5000. Zero migracji, zero zmian +modeli. Faza 2 (DjangoQL + drill-down + Wydział + slot-based punkty) — +osobno. diff --git a/src/bpp/multiseek_registry/pivot.py b/src/bpp/multiseek_registry/pivot.py new file mode 100644 index 000000000..a7a74d487 --- /dev/null +++ b/src/bpp/multiseek_registry/pivot.py @@ -0,0 +1,334 @@ +from dataclasses import dataclass +from decimal import Decimal + +from django.db.models import Count, Sum + +BRAK = "— brak —" + + +@dataclass(frozen=True) +class PivotDimension: + key: str + label: str + expr: str + allow_column: bool = True + autorzy: bool = False + label_kind: str = "raw" # raw | fk | choices_charakter_ogolny | pk_bucket + fk_model: str | None = None + + def resolve_model(self): + from django.apps import apps + + return apps.get_model(*self.fk_model.split(".")) if self.fk_model else None + + +@dataclass(frozen=True) +class PivotMetric: + key: str + label: str + field: str | None # None → Count("id"); inaczej Sum(field) + + +DEFAULT_ROW = "rok" +DEFAULT_METRIC = "liczba" + +# UWAGA: expr dla wymiarów FK to "_id" — values() zwraca surowe id, +# etykiety dociągamy hurtowo w zbuduj_pivot (label_kind="fk"). +DIMENSIONS: dict[str, PivotDimension] = { + "rok": PivotDimension("rok", "Rok", "rok"), + "charakter_formalny": PivotDimension( + "charakter_formalny", + "Charakter formalny", + "charakter_formalny_id", + label_kind="fk", + fk_model="bpp.Charakter_Formalny", + ), + "charakter_ogolny": PivotDimension( + "charakter_ogolny", + "Charakter ogólny (rodzaj)", + "charakter_formalny__charakter_ogolny", + label_kind="choices_charakter_ogolny", + ), + "typ_kbn": PivotDimension( + "typ_kbn", + "Typ MNiSW/MEiN", + "typ_kbn_id", + label_kind="fk", + fk_model="bpp.Typ_KBN", + ), + "koszyk_pk": PivotDimension( + "koszyk_pk", + "Koszyk punktów PK", + "punkty_kbn", + label_kind="pk_bucket", + ), + "jezyk": PivotDimension( + "jezyk", + "Język", + "jezyk_id", + label_kind="fk", + fk_model="bpp.Jezyk", + ), + "zrodlo": PivotDimension( + "zrodlo", + "Źródło", + "zrodlo_id", + allow_column=False, + label_kind="fk", + fk_model="bpp.Zrodlo", + ), + "jednostka": PivotDimension( + "jednostka", + "Jednostka", + "autorzy__jednostka_id", + allow_column=False, + autorzy=True, + label_kind="fk", + fk_model="bpp.Jednostka", + ), + "dyscyplina": PivotDimension( + "dyscyplina", + "Dyscyplina naukowa", + "autorzy__dyscyplina_naukowa_id", + autorzy=True, + label_kind="fk", + fk_model="bpp.Dyscyplina_Naukowa", + ), + "autor": PivotDimension( + "autor", + "Autor", + "autorzy__autor_id", + allow_column=False, + autorzy=True, + label_kind="fk", + fk_model="bpp.Autor", + ), +} + +METRICS: dict[str, PivotMetric] = { + "liczba": PivotMetric("liczba", "Liczba prac", None), + "punkty_kbn": PivotMetric("punkty_kbn", "Σ punkty PK", "punkty_kbn"), + "impact_factor": PivotMetric("impact_factor", "Σ Impact Factor", "impact_factor"), + "liczba_cytowan": PivotMetric( + "liczba_cytowan", "Σ liczba cytowań", "liczba_cytowan" + ), + "punktacja_wewnetrzna": PivotMetric( + "punktacja_wewnetrzna", "Σ punktacja wewnętrzna", "punktacja_wewnetrzna" + ), +} + + +def parse_pivot_params(GET): + row = DIMENSIONS.get(GET.get("pivot_row") or "", DIMENSIONS[DEFAULT_ROW]) + metric = METRICS.get(GET.get("pivot_val") or "", METRICS[DEFAULT_METRIC]) + col_key = GET.get("pivot_col") or "" + col = DIMENSIONS.get(col_key) + if col is not None and (not col.allow_column or col.key == row.key): + col = None + return row, col, metric + + +@dataclass +class PivotResult: + rows: list + cols: list + cells: dict + row_totals: dict + col_totals: dict + grand_total: object + row_dim: PivotDimension + col_dim: PivotDimension | None + metric: PivotMetric + has_autorzy_dim: bool + + def as_table(self): + """Zwraca strukturę gotową do iteracji w szablonie (bez indeksowania + słownika po zmiennym kluczu). Puste komórki → ``None`` (szablon + renderuje pustkę).""" + col_keys = [ck for ck, _ in self.cols] + return { + "row_header": self.row_dim.label, + "col_headers": [label for _, label in self.cols], + "has_cols": bool(self.cols), + "rows": [ + { + "label": rlabel, + "cells": ( + [self.cells.get((rk, ck)) for ck in col_keys] + if col_keys + else [self.cells.get((rk, None))] + ), + "total": self.row_totals.get(rk), + } + for rk, rlabel in self.rows + ], + "col_totals": [self.col_totals.get(ck) for ck in col_keys], + "grand_total": self.grand_total, + } + + +# Bezpieczniki rozmiaru — pivot jest publiczny i celowo omija bramkę 25000 +# oraz cap eksportu 5000. Bez tych limitów anonim z np. ?pivot_row=autor na +# pustym filtrze zbudowałby macierz z dziesiątkami tysięcy wierszy i (w +# strategii B) wciągnął miliony par autorstw do RAM-u. +PIVOT_MAX_CELLS = 10000 # maks. liczba niepustych komórek (rozmiar macierzy/HTML) +PIVOT_MAX_PAIRS = 200000 # maks. par (wymiar, rekord) w strategii B (pamięć) + + +class PivotTooLargeError(Exception): + """Pivot dałby zbyt dużą macierz / zbiór par — trzeba zawęzić zapytanie.""" + + def __init__(self, count, limit, kind): + self.count = count + self.limit = limit + self.kind = kind # "cells" | "pairs" + super().__init__(f"Pivot przekroczył limit ({kind}): {count} > {limit}.") + + +def _annotate(metric): + return Count("id") if metric.field is None else Sum(metric.field) + + +def zbuduj_pivot(base_qs, row_dim, col_dim, metric): + # KRYTYCZNE: bez wyczyszczenia orderingu, Meta.ordering/wejściowy + # order_by() wchodzi do GROUP BY i rozbija grupy na mikro-grupy (K3). + base_qs = base_qs.order_by() + has_autorzy = row_dim.autorzy or bool(col_dim and col_dim.autorzy) + group = [row_dim.expr] + ([col_dim.expr] if col_dim else []) + + # Bramka rozmiaru macierzy (tanie COUNT DISTINCT po stronie DB): liczba + # niepustych komórek = liczba unikatowych kombinacji (wiersz, kolumna). + n_cells = base_qs.values(*group).distinct().count() + if n_cells > PIVOT_MAX_CELLS: + raise PivotTooLargeError(n_cells, PIVOT_MAX_CELLS, "cells") + + # Strategia B agreguje w Pythonie po unikatowych parach (wymiar, rekord) — + # ogranicz też ich liczbę, bo mało liczny wymiar wierszy (np. jednostka) + # przy ogromnym zbiorze źródłowym i tak wciągnąłby wszystkie pary. + if has_autorzy: + n_pairs = base_qs.values(*group, "id").distinct().count() + if n_pairs > PIVOT_MAX_PAIRS: + raise PivotTooLargeError(n_pairs, PIVOT_MAX_PAIRS, "pairs") + + triples = ( + _pairs_strategy(base_qs, row_dim, col_dim, metric) + if has_autorzy + else _dedup_strategy(base_qs, row_dim, col_dim, metric) + ) + return _build_matrix(triples, row_dim, col_dim, metric, has_autorzy) + + +def _dedup_strategy(base_qs, row_dim, col_dim, metric): + """Strategia A: żaden z wymiarów nie idzie przez JOIN do autorzy — JOIN-y + z filtrów wejściowego queryset'u mogą jednak mnożyć wiersze rekordu + (K1), więc najpierw dedupujemy PK-i rekordów, a dopiero potem grupujemy + (świeży queryset bez wcześniejszych JOIN-ów).""" + from bpp.models.cache import Rekord + + deduped = Rekord.objects.filter(pk__in=base_qs.values("pk")).order_by() + group = [row_dim.expr] + ([col_dim.expr] if col_dim else []) + rows = deduped.values(*group).annotate(val=_annotate(metric)) + for r in rows: + rk = r[row_dim.expr] + ck = r[col_dim.expr] if col_dim else None + yield rk, ck, r["val"] or 0 + + +def _pairs_strategy(base_qs, row_dim, col_dim, metric): + """Strategia B: co najmniej jeden wymiar idzie przez JOIN do autorzy. + Liczymy unikatowe pary (wymiar, rekord) — rekord liczony raz per + wartość wymiaru; Σ metryki po unikatowych parach (rekord, wymiar). + Dublowanie MIĘDZY różnymi wartościami wymiaru jest zamierzone (§7).""" + group = [row_dim.expr] + ([col_dim.expr] if col_dim else []) + fields = group + ["id"] + ([metric.field] if metric.field else []) + pairs = base_qs.values(*fields).distinct() + seen = {} # (rk, ck) -> set(rekord id) dla liczby + sums = {} # (rk, ck) -> Σ metryki po unikatowych rekordach + # .iterator(): nie buduj _result_cache — liczbę par i tak ogranicza + # bramka PIVOT_MAX_PAIRS w zbuduj_pivot(). + for p in pairs.iterator(chunk_size=2000): + rk = p[row_dim.expr] + ck = p[col_dim.expr] if col_dim else None + rid = tuple(p["id"]) if isinstance(p["id"], list) else p["id"] + s = seen.setdefault((rk, ck), set()) + if rid in s: + continue + s.add(rid) + if metric.field is None: + sums[(rk, ck)] = sums.get((rk, ck), 0) + 1 + else: + sums[(rk, ck)] = sums.get((rk, ck), 0) + (p[metric.field] or 0) + for (rk, ck), val in sums.items(): + yield rk, ck, val + + +def _build_matrix(triples, row_dim, col_dim, metric, has_autorzy): + cells, row_totals, col_totals = {}, {}, {} + row_keys, col_keys, grand = set(), set(), 0 + for rk, ck, val in triples: + cells[(rk, ck)] = cells.get((rk, ck), 0) + val + row_totals[rk] = row_totals.get(rk, 0) + val + grand += val + row_keys.add(rk) + if col_dim: + col_totals[ck] = col_totals.get(ck, 0) + val + col_keys.add(ck) + rows = _labels(row_keys, row_dim) + cols = _labels(col_keys, col_dim) if col_dim else [] + return PivotResult( + rows=rows, + cols=cols, + cells=cells, + row_totals=row_totals, + col_totals=col_totals, + grand_total=grand, + row_dim=row_dim, + col_dim=col_dim, + metric=metric, + has_autorzy_dim=has_autorzy, + ) + + +def _labels(keys, dim): + """Zwraca posortowaną listę (key, label). Rok/koszyk malejąco liczbowo, + słowniki alfabetycznie po etykiecie.""" + 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"): + 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())) + return pairs + + +def _format_pk_bucket(value): + """Formatuje wartość Decimal bez zbędnych zer i bez notacji naukowej + (`Decimal(...).normalize()` + format 'g' daje np. "4E+1" dla 40 — + nieczytelne w UI).""" + d = Decimal(value) + s = f"{d:f}" + if "." in s: + s = s.rstrip("0").rstrip(".") + return s or "0" + + +def _label_mapping(keys, dim): + if dim.label_kind == "raw": + return {k: (BRAK if k is None else str(k)) for k in keys} + if dim.label_kind == "pk_bucket": + return {k: (BRAK if k is None else _format_pk_bucket(k)) for k in keys} + if dim.label_kind == "choices_charakter_ogolny": + from bpp.models.system.charakter_formalny import CHARAKTER_OGOLNY_CHOICES + + d = dict(CHARAKTER_OGOLNY_CHOICES) + return {k: (BRAK if k is None else d.get(k, str(k))) for k in keys} + if dim.label_kind == "fk": + ids = [k for k in keys if k is not None] + model = dim.resolve_model() + objs = model.objects.in_bulk(ids) + out = {None: BRAK} + for k in ids: + obj = objs.get(k) + out[k] = str(obj) if obj is not None else BRAK + return out + return {k: str(k) for k in keys} diff --git a/src/bpp/multiseek_registry/reports.py b/src/bpp/multiseek_registry/reports.py index cd3f2da76..76c6ab372 100644 --- a/src/bpp/multiseek_registry/reports.py +++ b/src/bpp/multiseek_registry/reports.py @@ -23,4 +23,5 @@ class BibTeXReportType(ReportType): "pkt_wewn_bez_cytowania", "punktacja sumaryczna z liczbą cytowań", public=False ), BibTeXReportType("bibtex", "BibTeX"), + ReportType("pivot", "tabela krzyżowa"), ] diff --git a/src/bpp/newsfragments/multiseek-pivot.feature.rst b/src/bpp/newsfragments/multiseek-pivot.feature.rst new file mode 100644 index 000000000..cbef98cb4 --- /dev/null +++ b/src/bpp/newsfragments/multiseek-pivot.feature.rst @@ -0,0 +1,6 @@ +Nowy typ raportu „tabela krzyżowa" w wyszukiwarce Multiseek: grupowanie +wyników (oraz prac autora) w formie pivota — wybierany wymiar wierszy +i kolumn (rok, charakter, typ, jednostka, dyscyplina, koszyk punktów PK, +język, autor, źródło) oraz metryka w komórce (liczba prac, suma punktów +PK, Impact Factor, cytowań, punktacji wewnętrznej), z sumami brzegowymi +i eksportem tabeli do XLSX/CSV. diff --git a/src/bpp/static/scss/_multiseek-reports.scss b/src/bpp/static/scss/_multiseek-reports.scss index e7871ecd1..0ffcb25b9 100644 --- a/src/bpp/static/scss/_multiseek-reports.scss +++ b/src/bpp/static/scss/_multiseek-reports.scss @@ -116,3 +116,67 @@ } } } + +// Tabela krzyżowa (pivot) — patrz report-body-pivot.html. +.multiseek-pivot-controls { + display: flex; + gap: 1em; + flex-wrap: wrap; + align-items: flex-end; + margin-bottom: 1em; + + label { + display: flex; + flex-direction: column; + font-weight: bold; + font-size: 0.85em; + } + + select { + margin: 0; + } +} + +.multiseek-pivot-scroll { + overflow-x: auto; +} + +table.multiseek-pivot { + border-collapse: collapse; + margin: 0; + width: auto; + + th, + td { + border: 1px solid #ccc; + padding: 4px 10px; + text-align: right; + white-space: nowrap; + } + + thead th, + tbody th { + text-align: left; + background: #f4f4f4; + } + + .pivot-total { + font-weight: bold; + background: #eee; + } +} + +.multiseek-pivot-note { + color: #666; + font-size: 0.85em; + margin-top: 0.5em; +} + +.multiseek-pivot-export { + margin-top: 0.75em; + font-size: 0.9em; + + a { + margin-right: 0.75em; + } +} diff --git a/src/bpp/tests/test_multiseek_pivot.py b/src/bpp/tests/test_multiseek_pivot.py new file mode 100644 index 000000000..211edd38a --- /dev/null +++ b/src/bpp/tests/test_multiseek_pivot.py @@ -0,0 +1,403 @@ +import pytest +from model_bakery import baker + +from bpp.const import CHARAKTER_OGOLNY_ARTYKUL, CHARAKTER_OGOLNY_ROZDZIAL +from bpp.models import Charakter_Formalny, Jednostka, Wydawnictwo_Ciagle +from bpp.multiseek_registry import pivot + + +def _wyd(**kw): + kw.setdefault("tytul_oryginalny", f"Tytul {kw.get('rok')} {kw.get('punkty_kbn')}") + kw.setdefault("tytul", kw["tytul_oryginalny"]) + kw.setdefault("uwagi", "Uwagi testowe") + return baker.make(Wydawnictwo_Ciagle, **kw) + + +@pytest.fixture +def rekordy_pivot( + db, + denorms, + jezyki, + charaktery_formalne, + typy_kbn, + statusy_korekt, + typy_odpowiedzialnosci, +): + from bpp.models.cache import Rekord + + art = baker.make(Charakter_Formalny, charakter_ogolny=CHARAKTER_OGOLNY_ARTYKUL) + roz = baker.make(Charakter_Formalny, charakter_ogolny=CHARAKTER_OGOLNY_ROZDZIAL) + + # 2024/art: 2 rekordy (40 + 20 pkt) -> liczba=2, suma pkt=60 + _wyd(rok=2024, charakter_formalny=art, punkty_kbn=40) + _wyd(rok=2024, charakter_formalny=art, punkty_kbn=20) + # 2024/roz: 1 rekord (10 pkt) + _wyd(rok=2024, charakter_formalny=roz, punkty_kbn=10) + # 2023/art: 1 rekord (5 pkt) + _wyd(rok=2023, charakter_formalny=art, punkty_kbn=5) + + denorms.flush() + return Rekord.objects.all() + + +def test_parse_pivot_params_defaults_when_empty(): + row, col, metric = pivot.parse_pivot_params({}) + assert row.key == "rok" + assert col is None + assert metric.key == "liczba" + + +def test_parse_pivot_params_unknown_keys_fall_back(): + row, col, metric = pivot.parse_pivot_params( + {"pivot_row": "xxx", "pivot_col": "yyy", "pivot_val": "zzz"} + ) + assert row.key == "rok" + assert col is None + assert metric.key == "liczba" + + +def test_parse_pivot_params_column_must_allow_column(): + # "jednostka" jest tylko wierszem (allow_column=False) → col=None + row, col, metric = pivot.parse_pivot_params( + {"pivot_row": "rok", "pivot_col": "jednostka", "pivot_val": "liczba"} + ) + assert col is None + + +def test_parse_pivot_params_column_equal_to_row_dropped(): + row, col, metric = pivot.parse_pivot_params( + {"pivot_row": "rok", "pivot_col": "rok"} + ) + assert col is None + + +def test_parse_pivot_params_valid_crosstab(): + row, col, metric = pivot.parse_pivot_params( + {"pivot_row": "rok", "pivot_col": "charakter_ogolny", "pivot_val": "punkty_kbn"} + ) + assert (row.key, col.key, metric.key) == ("rok", "charakter_ogolny", "punkty_kbn") + + +@pytest.mark.django_db +def test_zbuduj_pivot_a_liczba(rekordy_pivot): + res = pivot.zbuduj_pivot( + rekordy_pivot, + pivot.DIMENSIONS["rok"], + pivot.DIMENSIONS["charakter_ogolny"], + pivot.METRICS["liczba"], + ) + assert res.cells[(2024, CHARAKTER_OGOLNY_ARTYKUL)] == 2 + assert res.cells[(2024, CHARAKTER_OGOLNY_ROZDZIAL)] == 1 + assert res.cells[(2023, CHARAKTER_OGOLNY_ARTYKUL)] == 1 + assert res.grand_total == 4 + assert res.has_autorzy_dim is False + + +@pytest.mark.django_db +def test_zbuduj_pivot_k1_filtr_mnozacy_nie_zawyza( + rekordy_pivot, jednostka, autor_jan_kowalski, autor_jan_nowak, denorms +): + """Rekord z wieloma autorami w tej samej jednostce + filtr po + autorach/jednostce (JOIN mnożący) liczony po wymiarze REKORDOWYM (rok) + = raz, nie N razy.""" + from bpp.models.cache import Rekord + + w = _wyd(rok=2022, punkty_kbn=1) + w.dodaj_autora(autor_jan_kowalski, jednostka) + w.dodaj_autora(autor_jan_nowak, jednostka) + denorms.flush() + + base_qs = Rekord.objects.filter(autorzy__jednostka=jednostka) + # Płaski COUNT po tym JOIN-ie zawyżyłby wynik do 2 (bez dedup) — sanity + # check, że queryset rzeczywiście mnoży wiersze. + assert base_qs.count() == 2 + + res = pivot.zbuduj_pivot( + base_qs, pivot.DIMENSIONS["rok"], None, pivot.METRICS["liczba"] + ) + assert res.cells[(2022, None)] == 1 + assert res.has_autorzy_dim is False + + +@pytest.mark.django_db +def test_zbuduj_pivot_k3_ordering_nie_rozbija_grup(rekordy_pivot): + """Wejściowy queryset z .order_by('-rok', ...) (albo Meta.ordering) nie + rozbija GROUP BY na mikro-grupy — liczba wierszy = liczba unikatowych + lat.""" + qs = rekordy_pivot.order_by("-rok", "tytul_oryginalny_sort") + res = pivot.zbuduj_pivot(qs, pivot.DIMENSIONS["rok"], None, pivot.METRICS["liczba"]) + assert len(res.rows) == len({r["rok"] for r in rekordy_pivot.values("rok")}) + assert len(res.rows) == 2 + + +@pytest.mark.django_db +def test_zbuduj_pivot_b_k2_trzej_autorzy_jedna_klinika( + db, + denorms, + jednostka, + autor_jan_kowalski, + autor_jan_nowak, + autor_maker, + jezyki, + charaktery_formalne, + typy_kbn, + statusy_korekt, + typy_odpowiedzialnosci, +): + """Rekord z 3 autorami z tej samej jednostki -> komórka = 1 praca, + Σ punkty = punkty rekordu RAZ (nie ×3).""" + from bpp.models.cache import Rekord + + trzeci = autor_maker(imiona="Anna", nazwisko="Wiśniewska") + w = _wyd(rok=2021, punkty_kbn=40) + w.dodaj_autora(autor_jan_kowalski, jednostka) + w.dodaj_autora(autor_jan_nowak, jednostka) + w.dodaj_autora(trzeci, jednostka) + denorms.flush() + + base_qs = Rekord.objects.filter(autorzy__jednostka=jednostka) + + res_liczba = pivot.zbuduj_pivot( + base_qs, pivot.DIMENSIONS["jednostka"], None, pivot.METRICS["liczba"] + ) + assert res_liczba.cells[(jednostka.pk, None)] == 1 + assert res_liczba.has_autorzy_dim is True + + res_pk = pivot.zbuduj_pivot( + base_qs, pivot.DIMENSIONS["jednostka"], None, pivot.METRICS["punkty_kbn"] + ) + assert res_pk.cells[(jednostka.pk, None)] == 40 + + +@pytest.mark.django_db +def test_zbuduj_pivot_null_bucket_i_koszyk_pk( + db, + denorms, + jezyki, + charaktery_formalne, + typy_kbn, + statusy_korekt, + typy_odpowiedzialnosci, +): + """Rekord z zrodlo=None -> etykieta „— brak —"; punkty_kbn=0 -> "0" + (bez zbędnych zer po przecinku i bez notacji naukowej).""" + from bpp.models.cache import Rekord + + _wyd(rok=2020, punkty_kbn=0, zrodlo=None) + _wyd(rok=2020, punkty_kbn=40, zrodlo=None) + denorms.flush() + + base_qs = Rekord.objects.all() + + res_zrodlo = pivot.zbuduj_pivot( + base_qs, pivot.DIMENSIONS["zrodlo"], None, pivot.METRICS["liczba"] + ) + zrodlo_labels = dict(res_zrodlo.rows) + assert pivot.BRAK in zrodlo_labels.values() + + res_koszyk = pivot.zbuduj_pivot( + base_qs, pivot.DIMENSIONS["koszyk_pk"], None, pivot.METRICS["liczba"] + ) + koszyk_labels = dict(res_koszyk.rows) + assert "0" in koszyk_labels.values() + assert "40" in koszyk_labels.values() + + +@pytest.mark.django_db +def test_zbuduj_pivot_b_k2_dwie_rozne_jednostki( + db, + denorms, + jednostka, + autor_jan_kowalski, + autor_jan_nowak, + jezyki, + charaktery_formalne, + typy_kbn, + statusy_korekt, + typy_odpowiedzialnosci, +): + """K2 (spec §11.3, druga połowa): rekord z autorami z DWÓCH różnych + jednostek -> liczony RAZ w komórce KAŻDEJ z jednostek (duplikacja + MIĘDZY różnymi wartościami wymiaru jest zamierzona, w przeciwieństwie + do duplikacji WEWNĄTRZ tej samej wartości — patrz test k2 wyżej).""" + from bpp.models.cache import Rekord + + j2 = baker.make(Jednostka, uczelnia=jednostka.uczelnia, parent=None) + w = _wyd(rok=2020, punkty_kbn=40) + w.dodaj_autora(autor_jan_kowalski, jednostka) + w.dodaj_autora(autor_jan_nowak, j2) + denorms.flush() + + base_qs = Rekord.objects.all() + + res_liczba = pivot.zbuduj_pivot( + base_qs, pivot.DIMENSIONS["jednostka"], None, pivot.METRICS["liczba"] + ) + assert res_liczba.cells[(jednostka.pk, None)] == 1 + assert res_liczba.cells[(j2.pk, None)] == 1 + assert res_liczba.grand_total == 2 + assert res_liczba.has_autorzy_dim is True + + res_pk = pivot.zbuduj_pivot( + base_qs, pivot.DIMENSIONS["jednostka"], None, pivot.METRICS["punkty_kbn"] + ) + assert res_pk.cells[(jednostka.pk, None)] == 40 + assert res_pk.cells[(j2.pk, None)] == 40 + + +@pytest.mark.django_db +def test_zbuduj_pivot_join_reuse_prace_autora( + db, + denorms, + jednostka, + autor_jan_kowalski, + autor_jan_nowak, + jezyki, + charaktery_formalne, + typy_kbn, + statusy_korekt, + typy_odpowiedzialnosci, +): + """base_qs przefiltrowany po autorze (autorzy__autor=X) i wymiar + 'jednostka' (autorzy__jednostka_id) współdzielą ten sam JOIN do + 'autorzy' — Django REUŻYWA filtrowany JOIN zamiast dodawać nowy, więc + w wyniku widoczna jest TYLKO jednostka X, nigdy jednostka współautora + Y (mimo że oboje są przypisani do tego samego rekordu).""" + from bpp.models.cache import Rekord + + j2 = baker.make(Jednostka, uczelnia=jednostka.uczelnia, parent=None) + w = _wyd(rok=2020, punkty_kbn=10) + w.dodaj_autora(autor_jan_kowalski, jednostka) + w.dodaj_autora(autor_jan_nowak, j2) + denorms.flush() + + base_qs = Rekord.objects.filter(autorzy__autor=autor_jan_kowalski) + + res = pivot.zbuduj_pivot( + base_qs, pivot.DIMENSIONS["jednostka"], None, pivot.METRICS["liczba"] + ) + rows = dict(res.rows) + assert res.cells[(jednostka.pk, None)] == 1 + assert j2.pk not in rows + assert (j2.pk, None) not in res.cells + + +@pytest.mark.django_db +def test_zbuduj_pivot_etykieta_fk_resolve_model( + db, + denorms, + jednostka, + autor_jan_kowalski, + jezyki, + charaktery_formalne, + typy_kbn, + statusy_korekt, + typy_odpowiedzialnosci, +): + """Etykieta wymiaru FK (jednostka) idzie przez + resolve_model().objects.in_bulk() + str(obj) — sanity check na + PRAWDZIWYM id (nie na braku/None/BRAK), żeby złapać regresję w + fk_model / resolve_model().""" + from bpp.models.cache import Rekord + + jednostka.nazwa = "Klinika Bardzo Charakterystyczna Testowa" + jednostka.save() + + w = _wyd(rok=2020, punkty_kbn=1) + w.dodaj_autora(autor_jan_kowalski, jednostka) + denorms.flush() + + base_qs = Rekord.objects.all() + res = pivot.zbuduj_pivot( + base_qs, pivot.DIMENSIONS["jednostka"], None, pivot.METRICS["liczba"] + ) + rows = dict(res.rows) + assert rows[jednostka.pk] == str(jednostka) + assert "Klinika Bardzo Charakterystyczna Testowa" in rows[jednostka.pk] + + +@pytest.mark.django_db +def test_zbuduj_pivot_sumy_brzegowe_i_col_totals_bez_kolumny(rekordy_pivot): + """Sumy brzegowe (row_totals/col_totals/grand_total) wyliczone ręcznie + z danych `rekordy_pivot` (2024/art: 40+20=60, 2024/roz: 10, 2023/art: + 5 -> wiersz 2024=70, wiersz 2023=5, kolumna art=65, kolumna roz=10, + total=75). Dodatkowo (naprawa A): bez wymiaru kolumnowego col_totals + MUSI pozostać puste — wcześniej zbierało {None: grand_total}.""" + res = pivot.zbuduj_pivot( + rekordy_pivot, + pivot.DIMENSIONS["rok"], + pivot.DIMENSIONS["charakter_ogolny"], + pivot.METRICS["punkty_kbn"], + ) + assert res.row_totals == {2024: 70, 2023: 5} + assert res.col_totals == { + CHARAKTER_OGOLNY_ARTYKUL: 65, + CHARAKTER_OGOLNY_ROZDZIAL: 10, + } + assert res.grand_total == 75 + + res_no_col = pivot.zbuduj_pivot( + rekordy_pivot, pivot.DIMENSIONS["rok"], None, pivot.METRICS["punkty_kbn"] + ) + assert res_no_col.col_totals == {} + assert res_no_col.row_totals == {2024: 70, 2023: 5} + assert res_no_col.grand_total == 75 + + +def test_as_table_bez_kolumn(): + """as_table() dla degeneracji (kolumny=(brak)) — płaska lista.""" + pr = pivot.PivotResult( + rows=[(2024, "2024"), (2023, "2023")], + cols=[], + cells={(2024, None): 5, (2023, None): 3}, + row_totals={2024: 5, 2023: 3}, + col_totals={}, + grand_total=8, + row_dim=pivot.DIMENSIONS["rok"], + col_dim=None, + metric=pivot.METRICS["liczba"], + has_autorzy_dim=False, + ) + t = pr.as_table() + assert t["has_cols"] is False + assert t["col_headers"] == [] + assert t["rows"][0] == {"label": "2024", "cells": [5], "total": 5} + assert t["col_totals"] == [] + assert t["grand_total"] == 8 + + +def test_as_table_z_kolumnami(): + """as_table() dla cross-tabu — komórki i sumy brzegowe w kolejności + kolumn; brakująca komórka → None (szablon renderuje pustkę).""" + pr = pivot.PivotResult( + rows=[(2024, "2024")], + cols=[("art", "Artykuł"), ("roz", "Rozdział")], + cells={(2024, "art"): 9}, # brak (2024, "roz") → None + row_totals={2024: 9}, + col_totals={"art": 9, "roz": 0}, + grand_total=9, + row_dim=pivot.DIMENSIONS["rok"], + col_dim=pivot.DIMENSIONS["charakter_ogolny"], + metric=pivot.METRICS["liczba"], + has_autorzy_dim=False, + ) + t = pr.as_table() + assert t["has_cols"] is True + assert t["col_headers"] == ["Artykuł", "Rozdział"] + assert t["rows"][0]["cells"] == [9, None] + assert t["col_totals"] == [9, 0] + + +@pytest.mark.django_db +def test_zbuduj_pivot_gate_cells(rekordy_pivot, monkeypatch): + """Przekroczony limit komórek → PivotTooLargeError(kind="cells").""" + monkeypatch.setattr(pivot, "PIVOT_MAX_CELLS", 0) + with pytest.raises(pivot.PivotTooLargeError) as exc: + pivot.zbuduj_pivot( + rekordy_pivot, + pivot.DIMENSIONS["rok"], + None, + pivot.METRICS["liczba"], + ) + assert exc.value.kind == "cells" + assert exc.value.limit == 0 diff --git a/src/bpp/tests/test_multiseek_pivot_view.py b/src/bpp/tests/test_multiseek_pivot_view.py new file mode 100644 index 000000000..312e24e41 --- /dev/null +++ b/src/bpp/tests/test_multiseek_pivot_view.py @@ -0,0 +1,199 @@ +"""Widok Multiseek z report_type=pivot (tabela krzyżowa) — patrz Task 3.""" + +import json + +import pytest +from django.conf import settings +from django.test import RequestFactory +from django.urls import reverse +from django.utils import translation +from multiseek.logic import STARTS_WITH +from multiseek.views import MULTISEEK_SESSION_KEY + +from bpp.multiseek_registry import registry as multiseek_registry +from bpp.multiseek_registry.reports import multiseek_report_types +from bpp.tests.util import any_ciagle + +PIVOT_TITLE_PREFIX = "Pivot widok test" + + +def _pivot_report_type_index(user): + """Indeks pozycyjny report_type "pivot" w LISTE WŁĄCZONEJ dla usera. + + Rejestr filtruje ``multiseek_report_types`` przez ``enabled(request)`` — + "pivot" jest publiczny więc zawsze obecny, ale jego indeks zależy od + tego, ile innych (niepublicznych) typów zostało po drodze odfiltrowanych. + Liczymy dynamicznie zamiast zakładać stałą wartość. + """ + request = RequestFactory().get("/") + request.user = user + report_types = multiseek_registry.get_report_types(request) + assert report_types[-1].id == "pivot" + return len(report_types) - 1 + + +def _set_multiseek_pivot_filter(client, user, title_prefix=PIVOT_TITLE_PREFIX): + with translation.override(settings.LANGUAGE_CODE): + operator = str(STARTS_WITH) + + idx = _pivot_report_type_index(user) + session = client.session + session[MULTISEEK_SESSION_KEY] = json.dumps( + { + "form_data": [ + None, + { + "field": "Tytuł pracy", + "operator": operator, + "value": title_prefix, + "prev_op": None, + }, + ], + "ordering": {}, + "report_type": str(idx), + } + ) + session.save() + + +@pytest.mark.django_db +def test_pivot_results_view_zwraca_pivot( + logged_in_client, test_user, standard_data, denorms +): + """Po ustawieniu formularza z report_type=pivot w sesji, GET na + /multiseek/results/?pivot_row=rok&pivot_val=liczba renderuje macierz.""" + any_ciagle(tytul_oryginalny=f"{PIVOT_TITLE_PREFIX} - alfa", 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" + ) + + assert resp.status_code == 200 + assert "pivot" in resp.context + assert resp.context["pivot"].row_dim.key == "rok" + + +@pytest.mark.django_db +def test_pivot_results_view_pomija_agregaty_listy( + logged_in_client, test_user, standard_data, denorms +): + """Gałąź pivota omija cache agregatów/sumy stopki listy (spec §8) — + klucz "sumy" nie jest ustawiany, a paginator_count to uczciwa liczba + podsumowanych rekordów (nie 0).""" + any_ciagle(tytul_oryginalny=f"{PIVOT_TITLE_PREFIX} - beta", rok=2023) + 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" + ) + + assert resp.status_code == 200 + assert resp.context["paginator_count"] == 1 + assert "sumy" not in resp.context + + +@pytest.mark.django_db +def test_pivot_zbyt_duza_pokazuje_komunikat( + logged_in_client, test_user, standard_data, denorms, monkeypatch +): + """Gdy macierz przekracza limit, widok nie wysypuje się — pivot=None, + ustawiony pivot_error, a szablon pokazuje komunikat "zawęź zapytanie".""" + any_ciagle(tytul_oryginalny=f"{PIVOT_TITLE_PREFIX} - duza", rok=2024) + denorms.flush() + monkeypatch.setattr("bpp.multiseek_registry.pivot.PIVOT_MAX_CELLS", 0) + _set_multiseek_pivot_filter(logged_in_client, test_user) + + resp = logged_in_client.get( + reverse("multiseek:results") + "?pivot_row=rok&pivot_val=liczba" + ) + + assert resp.status_code == 200 + assert resp.context["pivot"] is None + assert resp.context["pivot_error"] is not None + assert "zbyt dużą tabelę" in resp.content.decode() + # pasek selektorów renderuje się mimo błędu + assert 'name="pivot_row"' in resp.content.decode() + + +@pytest.mark.django_db +def test_pivot_renderuje_macierz_html( + logged_in_client, test_user, standard_data, denorms +): + """Gałąź pivota renderuje tabelę krzyżową (klasa + RAZEM) i pasek + selektorów; zalogowany widzi linki eksportu.""" + any_ciagle(tytul_oryginalny=f"{PIVOT_TITLE_PREFIX} - gamma", 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" + ) + html = resp.content.decode() + + assert 'class="multiseek-pivot"' in html + assert "RAZEM" in html + assert 'name="pivot_row"' in html + assert 'name="pivot_col"' in html + assert 'name="pivot_val"' in html + assert "export/xlsx" in html + + +@pytest.mark.django_db +def test_pivot_export_xlsx(logged_in_client, test_user, standard_data, denorms): + """Eksport XLSX pivota działa (macierz, nie lista) — omija cap 5000.""" + any_ciagle(tytul_oryginalny=f"{PIVOT_TITLE_PREFIX} - delta", rok=2024) + denorms.flush() + _set_multiseek_pivot_filter(logged_in_client, test_user) + + resp = logged_in_client.get( + reverse("multiseek-export", args=["xlsx"]) + "?pivot_row=rok&pivot_val=liczba" + ) + assert resp.status_code == 200 + assert resp["Content-Type"].startswith( + "application/vnd.openxmlformats-officedocument.spreadsheetml" + ) + + +@pytest.mark.django_db +def test_pivot_export_csv(logged_in_client, test_user, standard_data, denorms): + """Eksport CSV pivota zawiera wiersz RAZEM.""" + any_ciagle(tytul_oryginalny=f"{PIVOT_TITLE_PREFIX} - epsilon", rok=2024) + 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" + ) + assert resp.status_code == 200 + assert resp["Content-Type"].startswith("text/csv") + assert "RAZEM" in resp.content.decode() + + +@pytest.mark.django_db +def test_pivot_export_ukryty_dla_anonima(client, standard_data, denorms): + """Anonim widzi tabelę krzyżową, ale NIE linki eksportu (eksport jest + LoginRequired — link i tak dałby redirect do logowania).""" + from django.contrib.auth.models import AnonymousUser + + any_ciagle(tytul_oryginalny=f"{PIVOT_TITLE_PREFIX} - zeta", rok=2024) + denorms.flush() + _set_multiseek_pivot_filter(client, AnonymousUser()) + + resp = client.get(reverse("multiseek:results") + "?pivot_row=rok&pivot_val=liczba") + html = resp.content.decode() + + assert 'class="multiseek-pivot"' in html + assert "export/xlsx" not in html + + +def test_pivot_report_type_na_koncu_listy(): + """report_type jest indeksem pozycyjnym — "pivot" MUSI być ostatnim + elementem, inaczej zapisane formularze przesuną się na inny typ.""" + assert multiseek_report_types[-1].id == "pivot" + assert multiseek_report_types[-1].public is True + # dotychczasowe typy zachowują pozycje (list/table na 0/1) + assert multiseek_report_types[0].id == "list" + assert multiseek_report_types[1].id == "table" diff --git a/src/bpp/views/multiseek_export.py b/src/bpp/views/multiseek_export.py index b9f35e071..b32840a8f 100644 --- a/src/bpp/views/multiseek_export.py +++ b/src/bpp/views/multiseek_export.py @@ -402,3 +402,70 @@ def xlsx_export_response(queryset, request, report_title, wariant="dane"): filename=_export_filename("xlsx", report_title), ) return response + + +def _pivot_export_rows(pivot_result): + """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() + 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"]] + yield [row["label"], *cells, row["total"]] + col_totals = ["" if c is None else c for c in t["col_totals"]] + yield ["RAZEM", *col_totals, t["grand_total"]] + + +def pivot_csv_export_response(pivot_result, request, report_title): + """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): + writer.writerow(_sanitize_spreadsheet_row(row)) + + response = HttpResponse(output.getvalue(), content_type="text/csv; charset=utf-8") + response["Content-Disposition"] = content_disposition_header( + as_attachment=True, + filename=_export_filename("csv", report_title), + ) + return response + + +def pivot_xlsx_export_response(pivot_result, request, report_title): + """Eksport XLSX tabeli krzyżowej (macierz z sumami brzegowymi).""" + from openpyxl import Workbook + from openpyxl.styles import Alignment, Font, PatternFill + + from bpp.util import sanitize_xlsx_row, worksheet_columns_autosize + + workbook = Workbook() + worksheet = workbook.active + worksheet.title = _xlsx_worksheet_title(report_title) + for row in _pivot_export_rows(pivot_result): + worksheet.append(sanitize_xlsx_row(row)) + + header_fill = PatternFill("solid", fgColor="1F4E78") + header_font = Font(color="FFFFFF", bold=True) + for cell in worksheet[1]: + cell.fill = header_fill + cell.font = header_font + cell.alignment = Alignment(horizontal="center", vertical="center") + + if worksheet.max_row > 1: + worksheet.freeze_panes = "B2" + worksheet_columns_autosize(worksheet) + + output = io.BytesIO() + workbook.save(output) + response = HttpResponse( + output.getvalue(), + content_type=( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ), + ) + response["Content-Disposition"] = content_disposition_header( + as_attachment=True, + filename=_export_filename("xlsx", report_title), + ) + return response diff --git a/src/bpp/views/mymultiseek.py b/src/bpp/views/mymultiseek.py index 2828615dd..63e6c9f62 100644 --- a/src/bpp/views/mymultiseek.py +++ b/src/bpp/views/mymultiseek.py @@ -178,9 +178,40 @@ def _aggregate_cache_key(self): ) return "multiseek_agregaty:" + hashlib.sha256(payload.encode()).hexdigest() + def _ensure_default_title(self): + """Domyślny tytuł wyniku, jeśli sesja go nie ma (albo jest pusty). + Wspólne dla ścieżki listy i pivota — inaczej świeża sesja lądująca + od razu na pivocie nie miałaby bloku tytułu.""" + title = self.request.session.get("MULTISEEK_TITLE") + if not title: + self.request.session["MULTISEEK_TITLE"] = "Rezultat wyszukiwania" + def get_context_data(self, **kwargs): ctx = super().get_context_data() + if ctx.get("report_type") == "pivot": + from bpp.multiseek_registry import pivot as pivot_mod + + self._ensure_default_title() + base_qs = self.get_queryset_for_current_mode() + row_dim, col_dim, metric = pivot_mod.parse_pivot_params(self.request.GET) + ctx["pivot_dimensions"] = pivot_mod.DIMENSIONS + ctx["pivot_metrics"] = pivot_mod.METRICS + # Wymiary/metryka jawnie w kontekście — pasek selektorów renderuje + # się też, gdy macierz jest zbyt duża i pivot=None. + ctx["pivot_row_dim"] = row_dim + ctx["pivot_col_dim"] = col_dim + ctx["pivot_metric"] = metric + # 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) + except pivot_mod.PivotTooLargeError as exc: + ctx["pivot"] = None + ctx["pivot_error"] = exc + return ctx + qset = self.get_queryset_for_current_mode() if self.request.GET.get("print-removed", False): ctx["object_list"] = qset @@ -217,12 +248,7 @@ def get_context_data(self, **kwargs): object_list = ctx["object_list"] object_list.count = lambda *args, **kw: ctx["paginator_count"] - keys = list(self.request.session.keys()) - if "MULTISEEK_TITLE" not in keys: - self.request.session["MULTISEEK_TITLE"] = "Rezultat wyszukiwania" - else: - if self.request.session["MULTISEEK_TITLE"] == "": - self.request.session["MULTISEEK_TITLE"] = "Rezultat wyszukiwania" + self._ensure_default_title() return ctx @@ -248,6 +274,15 @@ def get(self, request, export_format, *args, **kwargs): if export_format not in self.DATA_FORMATS | self.DOCUMENT_FORMATS: return HttpResponseBadRequest("Nieznany format eksportu.") + registry = get_registry(self.registry) + report_type = registry.get_report_type( + self.get_multiseek_data(), request=request + ) + if report_type == "pivot": + # Pivot eksportuje MACIERZ (nie listę rekordów) — cap 5000 na + # liczbę rekordów źródłowych nie dotyczy rozmiaru wyjścia. + return self._export_pivot(request, export_format) + queryset = self.get_queryset_for_current_mode() count = queryset.count() if count > MULTISEEK_EXPORT_MAX_ROWS: @@ -261,6 +296,31 @@ def get(self, request, export_format, *args, **kwargs): return self._export_data(request, export_format, queryset, report_title) return self._export_document(request, export_format, queryset, report_title) + def _export_pivot(self, request, export_format): + from bpp.multiseek_registry import pivot as pivot_mod + from bpp.views.multiseek_export import ( + pivot_csv_export_response, + pivot_xlsx_export_response, + ) + + if export_format not in {"csv", "xlsx"}: + return HttpResponseBadRequest( + "Eksport tabeli krzyżowej dostępny jako XLSX lub CSV." + ) + base_qs = self.get_queryset_for_current_mode() + row_dim, col_dim, metric = pivot_mod.parse_pivot_params(request.GET) + try: + pivot_result = pivot_mod.zbuduj_pivot(base_qs, row_dim, col_dim, metric) + except pivot_mod.PivotTooLargeError: + return HttpResponseBadRequest( + "Tabela krzyżowa jest zbyt duża do wyeksportowania — " + "zawęź zapytanie lub wybierz mniej liczny wymiar." + ) + 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) + def _export_data(self, request, export_format, queryset, report_title): wariant = request.GET.get("wariant", "dane") if wariant not in {"dane", "opis"}: diff --git a/src/django_bpp/templates/multiseek/common-results.html b/src/django_bpp/templates/multiseek/common-results.html index c8c7d8dbd..575b2f82b 100644 --- a/src/django_bpp/templates/multiseek/common-results.html +++ b/src/django_bpp/templates/multiseek/common-results.html @@ -7,6 +7,9 @@ {% include "multiseek/print-logo.html" %} {% include "multiseek/title.html" %}
+ {% if report_type == "pivot" %} + {% include "multiseek/report-body-pivot.html" %} + {% else %} {% if paginator_count > 25000 %}

Wynik obecnego zapytania do bazy dałby w rezultacie {{ paginator_count }} rekordów. Jest to dość duża ilość @@ -119,6 +122,7 @@ {% endif %} {% endif %} + {% endif %}

 
diff --git a/src/django_bpp/templates/multiseek/report-body-pivot.html b/src/django_bpp/templates/multiseek/report-body-pivot.html new file mode 100644 index 000000000..ee1512663 --- /dev/null +++ b/src/django_bpp/templates/multiseek/report-body-pivot.html @@ -0,0 +1,93 @@ +{% comment %} +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). +{% endcomment %} +
+
+ + + + +
+ + {% if pivot %} + {% with t=pivot.as_table %} +
+ + + + + {% for ch in t.col_headers %}{% endfor %} + + + + + {% for row in t.rows %} + + + {% for c in row.cells %}{% endfor %} + + + {% endfor %} + + + + + {% for ct in t.col_totals %}{% endfor %} + + + +
{{ t.row_header }}{{ ch }}RAZEM
{{ row.label }}{{ c|default_if_none:"" }}{{ row.total|default_if_none:"" }}
RAZEM{{ ct|default_if_none:"" }}{{ t.grand_total|default_if_none:"" }}
+
+ {% endwith %} + + {% if pivot.has_autorzy_dim %} +

+ {% comment %} Adnotacja o dublowaniu — bezpośrednio POD tabelą (spec §5/§7). {% endcomment %} + + Grupowanie po jednostce/dyscyplinie/autorze liczy powiązania, nie + unikatowe prace — praca powiązana z wieloma jednostkami liczona jest + w każdej z nich; sumy mogą przewyższać wartości całkowite. +

+ {% endif %} + + {% if request.user.is_authenticated %} +
+ Eksport tabeli: + XLSX + CSV +
+ {% endif %} + + {% elif pivot_error %} +

+ Wynik dałby zbyt dużą tabelę krzyżową do wyświetlenia + (liczba {% if pivot_error.kind == "cells" %}komórek{% else %}powiązań{% endif %}: + {{ pivot_error.count }}, limit: {{ pivot_error.limit }}). Zawęź zapytanie + albo wybierz mniej liczny wymiar wierszy/kolumn. +

+ {% endif %} +