From 5f44267b8012eb39a5044332acf17f0571cea957 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 24 Jul 2026 16:44:11 +0200 Subject: [PATCH 01/12] =?UTF-8?q?docs(multiseek):=20spec=20grupowania=20wy?= =?UTF-8?q?nik=C3=B3w=20jako=20tabela=20krzy=C5=BCowa=20(pivot)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Projekt fazy 1: pivot (Wiersze × Kolumny × Wartość) w silniku multiseek, pokrywa Szukaj→Multiseek oraz prace autora. Nowy report_type + config w GET, eksport XLSX/CSV, wymiary z Autorzy z adnotacją o dublowaniu. Zapytanie/DjangoQL + drill-down + slot-based punkty = faza 2. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CQBbkTBBkt5ZKNN648gY2h --- ...07-24-multiseek-pivot-grupowanie-design.md | 280 ++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-24-multiseek-pivot-grupowanie-design.md 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..934ab8cfe --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-multiseek-pivot-grupowanie-design.md @@ -0,0 +1,280 @@ +# Grupowanie wyników jako tabela krzyżowa (pivot) — multiseek + +**Data:** 2026-07-24 +**Gałąź:** `feat/multiseek-pivot-grupowanie` +**Status:** projekt zaakceptowany, 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 wyniki multiseek. Dlatego +**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 | + +## 4. Punkt wpięcia — nowy `report_type` + +Multiseek już 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"). + +- **Filtr (zapytanie)** zostaje bez zmian — w sesji pod + `MULTISEEK_SESSION_KEY`, budowany dziś przez + `registry.get_query_for_model(...)`. +- **Konfiguracja pivota** (wiersze / kolumny / metryka) czytana z + **parametrów GET** na URL wyników: + + ``` + /multiseek/results/?pivot_row=rok&pivot_col=charakter_ogolny&pivot_val=liczba + ``` + + Zalety takiego rozdziału: + - przestawienie pivota nie przebudowuje zapytania (tylko zmiana GET); + - pivot jest **linkowalny / bookmarkowalny / współdzielony URL-em**; + - działa identycznie dla prac autora (ten sam widok wyników). + +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; zmiana któregokolwiek przeładowuje wyniki (GET): + +``` +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 +``` + +- **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). +- Komórki puste (brak rekordów na przecięciu) → puste albo `–` + (do ustalenia w implementacji; domyślnie puste dla czytelności). +- Wiersz i kolumna **RAZEM** = sumy brzegowe; przecięcie = suma całkowita. +- Sortowanie wierszy: wg wartości wymiaru (rok malejąco jak dziś dla + Rok; alfabetycznie dla słowników). Kolumny analogicznie. + +## 6. Menu wymiarów i metryk + +### Wymiary — jako **wiersze** (wszystkie): +Rok · Charakter formalny · Charakter ogólny (rodzaj: artykuł / rozdział / +monografia / …) · Typ MNiSW/MEiN (`typ_kbn`) · Koszyk punktów PK · Język · +**Jednostka** · Wydział · Dyscyplina naukowa · Źródło · 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 / Wydział / Źródło / Autor **nie są dostępne jako kolumny** — +> zbyt duża liczność rozsadziłaby szerokość tabeli. Tylko jako wiersze. + +### 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 +| Wymiar | Wyrażenie | Uwaga | +|---|---|---| +| Rok | `rok` | pole rekordu | +| Charakter formalny | `charakter_formalny__nazwa` (+ id do sortowania) | FK | +| Charakter ogólny | `charakter_formalny__charakter_ogolny` | `charakter_formalny.py:104` | +| Typ MNiSW/MEiN | `typ_kbn__nazwa` | FK | +| Koszyk punktów PK | `punkty_kbn` | wartość dyskretna; 0/NULL → „brak" | +| Język | `jezyk__nazwa` | FK | +| Jednostka | `autorzy__jednostka__nazwa` | **JOIN do `Autorzy`** — patrz §7 | +| Wydział | `autorzy__jednostka__wydzial__nazwa` | JOIN do `Autorzy` | +| Dyscyplina | `autorzy__dyscyplina_naukowa__nazwa` | JOIN do `Autorzy` | +| Źródło | `zrodlo__nazwa` | FK | +| Autor | `autorzy__autor` (str) | JOIN do `Autorzy` | + +## 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, sumy się zgadzają. + +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. Grupując po nich: +- „liczba prac" = liczba **powiązań** rekord–jednostka (praca z 2 klinik + policzy się w obu), +- Σ PK **dubluje** punkty rekordu w każdej jednostce. + +Zachowanie v1: +- **Wspieramy** te wymiary od razu (user wprost chce „po klinice"). +- Przy ich wyborze pokazujemy **widoczną adnotację** nad/pod tabelą, np.: + *„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."* +- Dla **prac autora** dublowanie jest naturalne i pożądane (jeden autor, + jego afiliacje) — adnotacja i tak nie szkodzi. +- „Uczciwe" punkty per-dyscyplina ze slotów (`Cache_Punktacja_Dyscypliny`, + `src/bpp/models/cache/punktacja.py:18`, pola `pkd`/`slot`) → **faza 2**. + +Implementacyjnie: gdy wymiar wiersza lub kolumny wymaga JOIN-u do +`autorzy`, queryset dostaje `.filter()`/`values()` po tej relacji i +**świadomie NIE** stosujemy `distinct()` na poziomie rekordu w agregacji +(dublowanie jest zamierzone i zakomunikowane). + +## 8. Backend + +Na **tym samym przefiltrowanym** queryzecie co lista wyników +(`MyMultiseekResults.get_queryset`, `src/bpp/views/mymultiseek.py:90`): + +```python +qs = ( + filtered_qs + .values(row_expr, col_expr) # col_expr pominięty, gdy kolumny=(brak) + .annotate(val=) # Count("id") lub Sum("punkty_kbn") itd. + .order_by(row_expr, col_expr) +) +``` + +Płaska lista trójek `(wiersz, kolumna, wartość)` składana w Pythonie w +strukturę macierzy: +- zbiór unikatowych wierszy (posortowany), +- zbiór unikatowych kolumn (posortowany) — pusty, gdy kolumny=(brak), +- słownik `{(wiersz, kolumna): wartość}`, +- sumy per-wiersz, sumy per-kolumna, suma całkowita. + +Nowy helper (np. `src/bpp/multiseek_registry/pivot.py`) zawiera: +- rejestr dostępnych wymiarów (klucz GET → etykieta + wyrażenie ORM + + flaga „dozwolony jako kolumna" + flaga „wymaga JOIN do autorzy"), +- rejestr metryk (klucz GET → etykieta + wyrażenie agregatu), +- funkcję `zbuduj_pivot(qs, row_key, col_key, val_key) -> PivotResult` + zwracającą wiersze/kolumny/macierz/sumy, +- walidację parametrów GET (nieznany klucz → wartość domyślna / błąd + komunikatem, nie 500). + +Wydajność: jedno `GROUP BY` po stronie bazy; koszt porównywalny z +istniejącym liczeniem sum w stopce (`mymultiseek.py:181`). Twardy limit +25000 rekordów z listy (`common-results.html:10`) dla pivota **nie +obowiązuje** — pivot agreguje, nie renderuje pojedynczych rekordów; można +policzyć bezpiecznie dużo większy zbiór. (Do potwierdzenia: czy nakładać +osobny, znacznie wyższy limit czy żaden.) + +## 9. Eksport (faza 1) + +Multiseek ma eksport w `MyMultiseekExport` (`src/bpp/views/mymultiseek.py:239`) +i formatach w `src/bpp/views/multiseek_export.py` (CSV / XLSX / HTML / +DOCX / BibTeX). Dla pivota: +- eksport respektuje te same parametry GET pivota (row/col/val), +- generuje **XLSX i CSV** z macierzą krzyżową (wiersze × kolumny + sumy + brzegowe). DOCX/HTML — opcjonalnie, jeśli tanie; BibTeX nie dotyczy + pivota (pomijamy). +- Nowy builder eksportu pivota (np. w `multiseek_export.py`) korzysta z + tej samej struktury `PivotResult` co widok — jedno źródło prawdy. + +## 10. Pliki do zmiany (orientacyjnie) + +- `src/bpp/multiseek_registry/reports.py` — nowy typ raportu `pivot`. +- `src/bpp/multiseek_registry/pivot.py` — **nowy**: rejestr wymiarów/metryk + + `zbuduj_pivot()` + `PivotResult`. +- `src/bpp/views/mymultiseek.py` — w `MyMultiseekResults.get_context_data` + (ok. `:181`): gdy `report_type == "pivot"`, policz `PivotResult` z GET; + w `MyMultiseekExport` (`:239`) — gałąź eksportu pivota. +- `src/django_bpp/templates/multiseek/common-results.html` (`:103`) — + gałąź partiala dla `pivot`. +- `src/django_bpp/templates/multiseek/report-body-pivot.html` — **nowy**: + pasek selektorów + tabela krzyżowa + adnotacja o dublowaniu. +- `src/bpp/views/multiseek_export.py` — builder XLSX/CSV pivota. +- SCSS: ewentualny komponent stylu tabeli krzyżowej (sticky nagłówki + wierszy/kolumn, scroll) — bez nadpisywania siatki Foundation. +- Testy: `src/bpp/tests/` (jednostkowe `zbuduj_pivot`) + test widoku + + ewentualnie Playwright dla przełączania selektorów. +- 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()`: znane dane wejściowe → oczekiwana + macierz + sumy brzegowe (w tym degeneracja kolumny=(brak), + puste komórki, „koszyk PK" z 0/NULL → „brak"). +- **Semantyka `Autorzy`**: rekord z autorami w 2 jednostkach → liczy się + w obu; suma brzegowa > liczba unikatowych rekordów (test dokumentujący + zamierzone dublowanie). +- **Widok**: GET z parametrami pivota → poprawny kontekst i status 200; + nieznany klucz wymiaru/metryki → wartość domyślna / czytelny błąd, + nie 500. +- **Eksport**: XLSX/CSV pivota zawiera te same liczby co widok. +- 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`. + Punkt wpięcia: `render_results` w `src/bpp/views/zapytanie.py:366` + (przed `Paginator`). Osobny przepływ (GET zamiast sesji), ale wspólny + komponent `zbuduj_pivot()` da się przenieść. +- **Drill-down** — klik w komórkę/wiersz → lista rekordów tej grupy + (dodaje warunek do zapytania i pokazuje listę). +- **„Uczciwe" punkty per-dyscyplina** ze slotów + (`Cache_Punktacja_Dyscypliny.pkd`/`slot`) zamiast dublowanych + `punkty_kbn` przy grupowaniu po dyscyplinie/jednostce. +- Trzeci wymiar / zagnieżdżanie wielopoziomowe, wykresy z pivota. + +## 13. Ryzyka i otwarte kwestie (do rozstrzygnięcia w planie) + +1. **report_type z GET czy z sesji?** Dziś report_type bywa częścią + formularza (sesja). Aby pasek pivota działał bez przebudowy zapytania, + widok wyników powinien czytać `report_type` (i pivot_*) z GET z + fallbackiem do sesji. Do potwierdzenia w implementacji. +2. **Limit rekordów dla pivota** — znieść twardy limit 25000 (pivot + agreguje) czy nałożyć osobny, wyższy? Rekomendacja: znieść dla pivota, + ewentualnie miękkie ostrzeżenie przy bardzo dużych zbiorach. +3. **Etykiety słowników z i18n / per-uczelnia** — nazwy charakterów/typów + pobierać spójnie z istniejącymi (nie hardkodować). +4. **Puste komórki**: puste vs `–` vs `0` — ustalić w partialu. +5. **Widoczność wymiarów per-uczelnia** — czy respektować + `BppMultiseekVisibility` (widoczność pól) także dla menu pivota, czy + pivot ma własny, stały zestaw wymiarów. Rekomendacja: stały zestaw + pivota niezależny od widoczności pól filtrowania (inne przeznaczenie). + +## 14. Podsumowanie nakładu + +Faza 1 (multiseek: widok pivota + eksport XLSX/CSV, pełne menu wymiarów +w tym Jednostka/Dyscyplina/Autor z adnotacją): **średni** — 1 nowy helper, +1 nowy partial, nowy report_type, gałąź eksportu, kilka parametrów GET. +Zero migracji, zero zmian modeli. Faza 2 (DjangoQL + drill-down + +slot-based punkty) — osobno. From 52ee00201bfc2e325ff040af4c9f6a3da1325285 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 24 Jul 2026 16:54:08 +0200 Subject: [PATCH 02/12] docs(multiseek): adnotacja o dublowaniu renderowana pod pivotem Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CQBbkTBBkt5ZKNN648gY2h --- .../specs/2026-07-24-multiseek-pivot-grupowanie-design.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 index 934ab8cfe..9f97d4265 100644 --- a/docs/superpowers/specs/2026-07-24-multiseek-pivot-grupowanie-design.md +++ b/docs/superpowers/specs/2026-07-24-multiseek-pivot-grupowanie-design.md @@ -92,6 +92,10 @@ Wiersze:[ Rok ▾ ] Kolumny:[ Charakter ogólny ▾ | (brak) ] W komórce:[ - Wiersz i kolumna **RAZEM** = sumy brzegowe; przecięcie = suma całkowita. - Sortowanie wierszy: wg wartości wymiaru (rok malejąco jak dziś dla Rok; alfabetycznie dla słowników). 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 / `
`-style), by nie odciągała od danych. ## 6. Menu wymiarów i metryk @@ -140,7 +144,8 @@ jednostek/dyscyplin. Grupując po nich: Zachowanie v1: - **Wspieramy** te wymiary od razu (user wprost chce „po klinice"). -- Przy ich wyborze pokazujemy **widoczną adnotację** nad/pod tabelą, np.: +- Przy ich wyborze pokazujemy **widoczną adnotację bezpośrednio pod + tabelą krzyżową** (pod pivotem), np.: *„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."* From bcef0a76bbae0ffa83dcc79779ecf9a8f5a6dab1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 24 Jul 2026 17:09:34 +0200 Subject: [PATCH 03/12] docs(multiseek): spec v2 po self-review Fable (agregacja, report_type, eksport) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wciągnięto findingi: hybrydowa strategia agregacji A/B (dedup rekordów pk__in dla wymiarów rekordowych, distinct-pairs dla wymiarów autorskich), czyszczenie order_by przed GROUP BY (K3), report_type jako indeks pozycyjny na końcu listy (W1), obejście gate 25000 + cap 5000 eksportu (W3/W5), mapowanie etykiet i NULL-kubłów, Wydział → faza 2 (dziura NULL-korzenia). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CQBbkTBBkt5ZKNN648gY2h --- ...07-24-multiseek-pivot-grupowanie-design.md | 408 +++++++++++------- 1 file changed, 247 insertions(+), 161 deletions(-) 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 index 9f97d4265..a9ce9d1ec 100644 --- a/docs/superpowers/specs/2026-07-24-multiseek-pivot-grupowanie-design.md +++ b/docs/superpowers/specs/2026-07-24-multiseek-pivot-grupowanie-design.md @@ -2,7 +2,8 @@ **Data:** 2026-07-24 **Gałąź:** `feat/multiseek-pivot-grupowanie` -**Status:** projekt zaakceptowany, do spisania planu implementacji +**Status:** projekt zaakceptowany, uzupełniony po self-review (Fable), do +spisania planu implementacji ## 1. Cel @@ -21,9 +22,12 @@ w danym roku wg charakteru", „prace autora wg kliniki i rodzaju", **„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 wyniki multiseek. Dlatego -**pivot zaimplementowany w multiseek pokrywa jednocześnie „Szukaj → -Multiseek" i „prace autora"** — bez dodatkowego kodu w widoku autora. +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 @@ -40,29 +44,44 @@ redaktorów/superuserów). Jest poza zakresem fazy 1. | 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` +## 4. Punkt wpięcia — nowy `report_type` + parametry GET -Multiseek już ma wybór typu raportu (lista / tabela / bibtex) sterujący +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"). -- **Filtr (zapytanie)** zostaje bez zmian — w sesji pod - `MULTISEEK_SESSION_KEY`, budowany dziś przez - `registry.get_query_for_model(...)`. -- **Konfiguracja pivota** (wiersze / kolumny / metryka) czytana z - **parametrów GET** na URL wyników: +> **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 takiego rozdziału: - - przestawienie pivota nie przebudowuje zapytania (tylko zmiana GET); - - pivot jest **linkowalny / bookmarkowalny / współdzielony URL-em**; - - działa identycznie dla prac autora (ten sam widok wyników). + 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. @@ -70,7 +89,8 @@ Odrzucona alternatywa: osobny widok `/multiseek/pivot/` — dublowałby glue ## 5. Interfejs Gdy typ raportu = „Tabela krzyżowa", nad wynikami pojawia się pasek z -trzema selektorami; zmiana któregokolwiek przeładowuje wyniki (GET): +trzema selektorami (mini-formularz GET); zmiana któregokolwiek +przeładowuje wyniki: ``` Typ raportu: [ Tabela krzyżowa ▾ ] @@ -81,150 +101,199 @@ Wiersze:[ Rok ▾ ] Kolumny:[ Charakter ogólny ▾ | (brak) ] W komórce:[ 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). -- Komórki puste (brak rekordów na przecięciu) → puste albo `–` - (do ustalenia w implementacji; domyślnie puste dla czytelności). +- **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 jak dziś dla - Rok; alfabetycznie dla słowników). Kolumny analogicznie. +- 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 / `
`-style), by nie odciągała od danych. + szary tekst). ## 6. Menu wymiarów i metryk -### Wymiary — jako **wiersze** (wszystkie): -Rok · Charakter formalny · Charakter ogólny (rodzaj: artykuł / rozdział / -monografia / …) · Typ MNiSW/MEiN (`typ_kbn`) · Koszyk punktów PK · Język · -**Jednostka** · Wydział · Dyscyplina naukowa · Źródło · Autor +### 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 / Wydział / Źródło / Autor **nie są dostępne jako kolumny** — -> zbyt duża liczność rozsadziłaby szerokość tabeli. Tylko jako wiersze. +> 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 -| Wymiar | Wyrażenie | Uwaga | +### Mapowanie wymiarów na wyrażenia ORM i etykiety +| Wymiar | Wyrażenie ORM (`values`) | Etykieta / uwaga | |---|---|---| -| Rok | `rok` | pole rekordu | -| Charakter formalny | `charakter_formalny__nazwa` (+ id do sortowania) | FK | -| Charakter ogólny | `charakter_formalny__charakter_ogolny` | `charakter_formalny.py:104` | -| Typ MNiSW/MEiN | `typ_kbn__nazwa` | FK | -| Koszyk punktów PK | `punkty_kbn` | wartość dyskretna; 0/NULL → „brak" | -| Język | `jezyk__nazwa` | FK | -| Jednostka | `autorzy__jednostka__nazwa` | **JOIN do `Autorzy`** — patrz §7 | -| Wydział | `autorzy__jednostka__wydzial__nazwa` | JOIN do `Autorzy` | -| Dyscyplina | `autorzy__dyscyplina_naukowa__nazwa` | JOIN do `Autorzy` | -| Źródło | `zrodlo__nazwa` | FK | -| Autor | `autorzy__autor` (str) | JOIN do `Autorzy` | +| 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, sumy się zgadzają. +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. Grupując po nich: -- „liczba prac" = liczba **powiązań** rekord–jednostka (praca z 2 klinik - policzy się w obu), -- Σ PK **dubluje** punkty rekordu w każdej jednostce. - -Zachowanie v1: -- **Wspieramy** te wymiary od razu (user wprost chce „po klinice"). -- Przy ich wyborze pokazujemy **widoczną adnotację bezpośrednio pod - tabelą krzyżową** (pod pivotem), np.: - *„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."* -- Dla **prac autora** dublowanie jest naturalne i pożądane (jeden autor, - jego afiliacje) — adnotacja i tak nie szkodzi. -- „Uczciwe" punkty per-dyscyplina ze slotów (`Cache_Punktacja_Dyscypliny`, - `src/bpp/models/cache/punktacja.py:18`, pola `pkd`/`slot`) → **faza 2**. - -Implementacyjnie: gdy wymiar wiersza lub kolumny wymaga JOIN-u do -`autorzy`, queryset dostaje `.filter()`/`values()` po tej relacji i -**świadomie NIE** stosujemy `distinct()` na poziomie rekordu w agregacji -(dublowanie jest zamierzone i zakomunikowane). - -## 8. Backend - -Na **tym samym przefiltrowanym** queryzecie co lista wyników -(`MyMultiseekResults.get_queryset`, `src/bpp/views/mymultiseek.py:90`): - +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 -qs = ( - filtered_qs +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(val=) # Count("id") lub Sum("punkty_kbn") itd. - .order_by(row_expr, col_expr) -) + .annotate(licznik=Count("id"), suma=Sum(metric_field)) + .order_by(row_expr, col_expr)) ``` - -Płaska lista trójek `(wiersz, kolumna, wartość)` składana w Pythonie w -strukturę macierzy: -- zbiór unikatowych wierszy (posortowany), -- zbiór unikatowych kolumn (posortowany) — pusty, gdy kolumny=(brak), -- słownik `{(wiersz, kolumna): wartość}`, -- sumy per-wiersz, sumy per-kolumna, suma całkowita. - -Nowy helper (np. `src/bpp/multiseek_registry/pivot.py`) zawiera: -- rejestr dostępnych wymiarów (klucz GET → etykieta + wyrażenie ORM + - flaga „dozwolony jako kolumna" + flaga „wymaga JOIN do autorzy"), -- rejestr metryk (klucz GET → etykieta + wyrażenie agregatu), -- funkcję `zbuduj_pivot(qs, row_key, col_key, val_key) -> PivotResult` - zwracającą wiersze/kolumny/macierz/sumy, -- walidację parametrów GET (nieznany klucz → wartość domyślna / błąd - komunikatem, nie 500). - -Wydajność: jedno `GROUP BY` po stronie bazy; koszt porównywalny z -istniejącym liczeniem sum w stopce (`mymultiseek.py:181`). Twardy limit -25000 rekordów z listy (`common-results.html:10`) dla pivota **nie -obowiązuje** — pivot agreguje, nie renderuje pojedynczych rekordów; można -policzyć bezpiecznie dużo większy zbiór. (Do potwierdzenia: czy nakładać -osobny, znacznie wyższy limit czy żaden.) +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 w `MyMultiseekExport` (`src/bpp/views/mymultiseek.py:239`) -i formatach w `src/bpp/views/multiseek_export.py` (CSV / XLSX / HTML / -DOCX / BibTeX). Dla pivota: -- eksport respektuje te same parametry GET pivota (row/col/val), -- generuje **XLSX i CSV** z macierzą krzyżową (wiersze × kolumny + sumy - brzegowe). DOCX/HTML — opcjonalnie, jeśli tanie; BibTeX nie dotyczy - pivota (pomijamy). -- Nowy builder eksportu pivota (np. w `multiseek_export.py`) korzysta z - tej samej struktury `PivotResult` co widok — jedno źródło prawdy. +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`. -- `src/bpp/multiseek_registry/pivot.py` — **nowy**: rejestr wymiarów/metryk - + `zbuduj_pivot()` + `PivotResult`. +- `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` - (ok. `:181`): gdy `report_type == "pivot"`, policz `PivotResult` z GET; - w `MyMultiseekExport` (`:239`) — gałąź eksportu pivota. -- `src/django_bpp/templates/multiseek/common-results.html` (`:103`) — - gałąź partiala dla `pivot`. + (`~: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 + tabela krzyżowa + adnotacja o dublowaniu. -- `src/bpp/views/multiseek_export.py` — builder XLSX/CSV pivota. -- SCSS: ewentualny komponent stylu tabeli krzyżowej (sticky nagłówki - wierszy/kolumn, scroll) — bez nadpisywania siatki Foundation. -- Testy: `src/bpp/tests/` (jednostkowe `zbuduj_pivot`) + test widoku + - ewentualnie Playwright dla przełączania selektorów. + 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 @@ -233,53 +302,70 @@ DOCX / BibTeX). Dla pivota: ## 11. Testy -- **Jednostkowe** `zbuduj_pivot()`: znane dane wejściowe → oczekiwana - macierz + sumy brzegowe (w tym degeneracja kolumny=(brak), - puste komórki, „koszyk PK" z 0/NULL → „brak"). -- **Semantyka `Autorzy`**: rekord z autorami w 2 jednostkach → liczy się - w obu; suma brzegowa > liczba unikatowych rekordów (test dokumentujący - zamierzone dublowanie). -- **Widok**: GET z parametrami pivota → poprawny kontekst i status 200; - nieznany klucz wymiaru/metryki → wartość domyślna / czytelny błąd, - nie 500. -- **Eksport**: XLSX/CSV pivota zawiera te same liczby co widok. -- Konwencje pytest projektu: funkcje, `@pytest.mark.django_db`, - `model_bakery.baker.make`, `-n auto`. +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`. - Punkt wpięcia: `render_results` w `src/bpp/views/zapytanie.py:366` - (przed `Paginator`). Osobny przepływ (GET zamiast sesji), ale wspólny - komponent `zbuduj_pivot()` da się przenieść. -- **Drill-down** — klik w komórkę/wiersz → lista rekordów tej grupy - (dodaje warunek do zapytania i pokazuje listę). +- **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`) zamiast dublowanych - `punkty_kbn` przy grupowaniu po dyscyplinie/jednostce. -- Trzeci wymiar / zagnieżdżanie wielopoziomowe, wykresy z pivota. - -## 13. Ryzyka i otwarte kwestie (do rozstrzygnięcia w planie) - -1. **report_type z GET czy z sesji?** Dziś report_type bywa częścią - formularza (sesja). Aby pasek pivota działał bez przebudowy zapytania, - widok wyników powinien czytać `report_type` (i pivot_*) z GET z - fallbackiem do sesji. Do potwierdzenia w implementacji. -2. **Limit rekordów dla pivota** — znieść twardy limit 25000 (pivot - agreguje) czy nałożyć osobny, wyższy? Rekomendacja: znieść dla pivota, - ewentualnie miękkie ostrzeżenie przy bardzo dużych zbiorach. -3. **Etykiety słowników z i18n / per-uczelnia** — nazwy charakterów/typów - pobierać spójnie z istniejącymi (nie hardkodować). -4. **Puste komórki**: puste vs `–` vs `0` — ustalić w partialu. -5. **Widoczność wymiarów per-uczelnia** — czy respektować - `BppMultiseekVisibility` (widoczność pól) także dla menu pivota, czy - pivot ma własny, stały zestaw wymiarów. Rekomendacja: stały zestaw - pivota niezależny od widoczności pól filtrowania (inne przeznaczenie). + (`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 -Faza 1 (multiseek: widok pivota + eksport XLSX/CSV, pełne menu wymiarów -w tym Jednostka/Dyscyplina/Autor z adnotacją): **średni** — 1 nowy helper, -1 nowy partial, nowy report_type, gałąź eksportu, kilka parametrów GET. -Zero migracji, zero zmian modeli. Faza 2 (DjangoQL + drill-down + -slot-based punkty) — osobno. +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. From 14a05b86010d719d1e3f747d0e4d02c96af2f990 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 24 Jul 2026 17:25:37 +0200 Subject: [PATCH 04/12] =?UTF-8?q?docs(multiseek):=20plan=20implementacji?= =?UTF-8?q?=20pivota=20(6=20task=C3=B3w,=20TDD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CQBbkTBBkt5ZKNN648gY2h --- .../2026-07-24-multiseek-pivot-grupowanie.md | 1035 +++++++++++++++++ 1 file changed, 1035 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-24-multiseek-pivot-grupowanie.md 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. From cfbbbcb94a461625824fbd10835d9b14bc22b874 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 24 Jul 2026 17:40:22 +0200 Subject: [PATCH 05/12] =?UTF-8?q?feat(multiseek):=20rejestry=20wymiar?= =?UTF-8?q?=C3=B3w/metryk=20pivota=20+=20walidacja=20GET?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 1: PivotDimension/PivotMetric, DIMENSIONS/METRICS, parse_pivot_params. fk_model jako string ścieżki + resolve_model (import-safe). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CQBbkTBBkt5ZKNN648gY2h --- src/bpp/multiseek_registry/pivot.py | 124 ++++++++++++++++++++++++++ src/bpp/tests/test_multiseek_pivot.py | 39 ++++++++ 2 files changed, 163 insertions(+) create mode 100644 src/bpp/multiseek_registry/pivot.py create mode 100644 src/bpp/tests/test_multiseek_pivot.py diff --git a/src/bpp/multiseek_registry/pivot.py b/src/bpp/multiseek_registry/pivot.py new file mode 100644 index 000000000..039b5aa69 --- /dev/null +++ b/src/bpp/multiseek_registry/pivot.py @@ -0,0 +1,124 @@ +from dataclasses import dataclass + + +@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: str | None = None + fk_label_field: str = "nazwa" + + 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="autor", + 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 diff --git a/src/bpp/tests/test_multiseek_pivot.py b/src/bpp/tests/test_multiseek_pivot.py new file mode 100644 index 000000000..436a297cb --- /dev/null +++ b/src/bpp/tests/test_multiseek_pivot.py @@ -0,0 +1,39 @@ +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") From 87cf730aeb3094d84780ea8339ea8e450f1de7d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 24 Jul 2026 17:53:17 +0200 Subject: [PATCH 06/12] =?UTF-8?q?feat(multiseek):=20zbuduj=5Fpivot=20?= =?UTF-8?q?=E2=80=94=20agregacja=20A/B,=20etykiety,=20sumy=20brzegowe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/bpp/multiseek_registry/pivot.py | 150 +++++++++++++++++++++++ src/bpp/tests/test_multiseek_pivot.py | 164 ++++++++++++++++++++++++++ 2 files changed, 314 insertions(+) diff --git a/src/bpp/multiseek_registry/pivot.py b/src/bpp/multiseek_registry/pivot.py index 039b5aa69..f30482d27 100644 --- a/src/bpp/multiseek_registry/pivot.py +++ b/src/bpp/multiseek_registry/pivot.py @@ -1,4 +1,9 @@ from dataclasses import dataclass +from decimal import Decimal + +from django.db.models import Count, Sum + +BRAK = "— brak —" @dataclass(frozen=True) @@ -122,3 +127,148 @@ def parse_pivot_params(GET): 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 _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) + 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 + 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 + + +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 _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 in ("fk", "autor"): + 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/tests/test_multiseek_pivot.py b/src/bpp/tests/test_multiseek_pivot.py index 436a297cb..23262d3fc 100644 --- a/src/bpp/tests/test_multiseek_pivot.py +++ b/src/bpp/tests/test_multiseek_pivot.py @@ -1,6 +1,45 @@ +import pytest +from model_bakery import baker + +from bpp.const import CHARAKTER_OGOLNY_ARTYKUL, CHARAKTER_OGOLNY_ROZDZIAL +from bpp.models import Charakter_Formalny, 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" @@ -37,3 +76,128 @@ def test_parse_pivot_params_valid_crosstab(): {"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() From c445530ef3e55323a9a0e8c68e9444fdfdc50f18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 24 Jul 2026 18:08:58 +0200 Subject: [PATCH 07/12] =?UTF-8?q?test(multiseek):=20domkni=C4=99cie=20pokr?= =?UTF-8?q?ycia=20zbuduj=5Fpivot=20(K2=202=20jednostki,=20join-reuse,=20su?= =?UTF-8?q?my,=20etykiety=20FK)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cztery nowe testy §11.3/K2 (dwie różne jednostki), join-reuse JOIN-a autorzy między filtrem wejściowym a wymiarem, rozwiązywanie etykiety FK na prawdziwym id oraz sumy brzegowe. Fix: col_totals w _build_matrix nie akumuluje już wpisu {None: grand_total} gdy brak wymiaru kolumnowego — guard mirror'uje col_keys (tylko gdy col_dim jest ustawiony). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CQBbkTBBkt5ZKNN648gY2h --- src/bpp/multiseek_registry/pivot.py | 2 +- src/bpp/tests/test_multiseek_pivot.py | 143 +++++++++++++++++++++++++- 2 files changed, 143 insertions(+), 2 deletions(-) diff --git a/src/bpp/multiseek_registry/pivot.py b/src/bpp/multiseek_registry/pivot.py index f30482d27..078892069 100644 --- a/src/bpp/multiseek_registry/pivot.py +++ b/src/bpp/multiseek_registry/pivot.py @@ -208,10 +208,10 @@ def _build_matrix(triples, row_dim, col_dim, metric, has_autorzy): 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_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 [] diff --git a/src/bpp/tests/test_multiseek_pivot.py b/src/bpp/tests/test_multiseek_pivot.py index 23262d3fc..ee55ec768 100644 --- a/src/bpp/tests/test_multiseek_pivot.py +++ b/src/bpp/tests/test_multiseek_pivot.py @@ -2,7 +2,7 @@ from model_bakery import baker from bpp.const import CHARAKTER_OGOLNY_ARTYKUL, CHARAKTER_OGOLNY_ROZDZIAL -from bpp.models import Charakter_Formalny, Wydawnictwo_Ciagle +from bpp.models import Charakter_Formalny, Jednostka, Wydawnictwo_Ciagle from bpp.multiseek_registry import pivot @@ -201,3 +201,144 @@ def test_zbuduj_pivot_null_bucket_i_koszyk_pk( 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 From 279a749a8675bb8aff4a51cb33cb8f96cabcdc78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 24 Jul 2026 18:25:12 +0200 Subject: [PATCH 08/12] =?UTF-8?q?feat(multiseek):=20report=5Ftype=20pivot?= =?UTF-8?q?=20+=20wpi=C4=99cie=20PivotResult=20w=20widok?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co zmienia: - multiseek_report_types: dopisany ReportType("pivot", "tabela krzyżowa") jako OSTATNI element listy (report_type to indeks pozycyjny - dopisanie na końcu nie przesuwa zapisanych formularzy). - MyMultiseekResults.get_context_data: gałąź dla report_type=="pivot" liczy PivotResult (parse_pivot_params + zbuduj_pivot) na przefiltrowanym querysecie i wstawia do kontekstu (pivot/pivot_dimensions/pivot_metrics), omijając cache agregatów i qset.count() (paginator_count=0). - Test src/bpp/tests/test_multiseek_pivot_view.py: widok zwraca 200 i kontekst "pivot" dla report_type=pivot (indeks liczony dynamicznie z registry.get_report_types), pomijanie sumy/count, oraz stabilność pozycji "pivot" na końcu listy (list/table nadal na 0/1). Testy: test_multiseek_pivot_view.py 3 passed; regresja src/bpp/tests/test_views (-k multiseek) 62 passed; test_multiseek_djangoql_{export,endpoint}.py 34 passed. --- src/bpp/multiseek_registry/reports.py | 1 + src/bpp/tests/test_multiseek_pivot_view.py | 104 +++++++++++++++++++++ src/bpp/views/mymultiseek.py | 11 +++ 3 files changed, 116 insertions(+) create mode 100644 src/bpp/tests/test_multiseek_pivot_view.py 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/tests/test_multiseek_pivot_view.py b/src/bpp/tests/test_multiseek_pivot_view.py new file mode 100644 index 000000000..7aad1a53c --- /dev/null +++ b/src/bpp/tests/test_multiseek_pivot_view.py @@ -0,0 +1,104 @@ +"""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 (spec §8) — + paginator_count jest wyzerowany, a klucz "sumy" nie jest ustawiany.""" + 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"] == 0 + assert "sumy" not in resp.context + + +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/mymultiseek.py b/src/bpp/views/mymultiseek.py index 2828615dd..2e9d3db84 100644 --- a/src/bpp/views/mymultiseek.py +++ b/src/bpp/views/mymultiseek.py @@ -181,6 +181,17 @@ def _aggregate_cache_key(self): 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 + + 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 + qset = self.get_queryset_for_current_mode() if self.request.GET.get("print-removed", False): ctx["object_list"] = qset From 53c45e12758e58160c77a863d64b3f3622a1735a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 24 Jul 2026 19:02:10 +0200 Subject: [PATCH 09/12] =?UTF-8?q?feat(multiseek):=20partial=20tabeli=20krz?= =?UTF-8?q?y=C5=BCowej=20+=20branch=20+=20styl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 4: PivotResult.as_table() (render bez indeksowania słownika), report-body-pivot.html (pasek selektorów GET + macierz + adnotacja pod tabelą + linki eksportu), gałąź pivota w common-results.html omijająca gate 25000/paginację, style _multiseek-reports.scss. Testy: as_table + render HTML. 20 pivot + 62 regresja multiseek zielone. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CQBbkTBBkt5ZKNN648gY2h --- src/bpp/multiseek_registry/pivot.py | 25 ++++++ src/bpp/static/scss/_multiseek-reports.scss | 64 +++++++++++++++ src/bpp/tests/test_multiseek_pivot.py | 44 ++++++++++ src/bpp/tests/test_multiseek_pivot_view.py | 23 ++++++ .../templates/multiseek/common-results.html | 4 + .../multiseek/report-body-pivot.html | 82 +++++++++++++++++++ 6 files changed, 242 insertions(+) create mode 100644 src/django_bpp/templates/multiseek/report-body-pivot.html diff --git a/src/bpp/multiseek_registry/pivot.py b/src/bpp/multiseek_registry/pivot.py index 078892069..042b99415 100644 --- a/src/bpp/multiseek_registry/pivot.py +++ b/src/bpp/multiseek_registry/pivot.py @@ -142,6 +142,31 @@ class PivotResult: 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, + } + def _annotate(metric): return Count("id") if metric.field is None else Sum(metric.field) 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 index ee55ec768..7b9b31030 100644 --- a/src/bpp/tests/test_multiseek_pivot.py +++ b/src/bpp/tests/test_multiseek_pivot.py @@ -342,3 +342,47 @@ def test_zbuduj_pivot_sumy_brzegowe_i_col_totals_bez_kolumny(rekordy_pivot): 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] diff --git a/src/bpp/tests/test_multiseek_pivot_view.py b/src/bpp/tests/test_multiseek_pivot_view.py index 7aad1a53c..1bf5ebfcf 100644 --- a/src/bpp/tests/test_multiseek_pivot_view.py +++ b/src/bpp/tests/test_multiseek_pivot_view.py @@ -94,6 +94,29 @@ def test_pivot_results_view_pomija_agregaty_listy( assert "sumy" not in resp.context +@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 + + 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.""" 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..31a7822ae --- /dev/null +++ b/src/django_bpp/templates/multiseek/report-body-pivot.html @@ -0,0 +1,82 @@ +{% comment %} +Tabela krzyżowa (pivot). Kontekst: pivot (PivotResult), pivot_dimensions, +pivot_metrics. Konfiguracja wierszy/kolumn/metryki idzie parametrami GET +(pivot_row / pivot_col / pivot_val) — pasek selektorów niżej. +{% endcomment %} +
+
+ + + + +
+ + {% 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 %} +
From 161b280125dc5b95b14bbef3b1df76c58f79d613 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 24 Jul 2026 19:12:42 +0200 Subject: [PATCH 10/12] =?UTF-8?q?feat(multiseek):=20eksport=20XLSX/CSV=20t?= =?UTF-8?q?abeli=20krzy=C5=BCowej=20(omija=20cap=205000)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 5: pivot_{csv,xlsx}_export_response budują macierz z PivotResult; MyMultiseekExport._export_pivot rozgałęzia przed capem 5000 (wyjście to mała macierz, nie lista rekordów). Testy XLSX/CSV. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CQBbkTBBkt5ZKNN648gY2h --- src/bpp/tests/test_multiseek_pivot_view.py | 31 ++++++++++ src/bpp/views/multiseek_export.py | 67 ++++++++++++++++++++++ src/bpp/views/mymultiseek.py | 28 +++++++++ 3 files changed, 126 insertions(+) diff --git a/src/bpp/tests/test_multiseek_pivot_view.py b/src/bpp/tests/test_multiseek_pivot_view.py index 1bf5ebfcf..127a429d2 100644 --- a/src/bpp/tests/test_multiseek_pivot_view.py +++ b/src/bpp/tests/test_multiseek_pivot_view.py @@ -117,6 +117,37 @@ def test_pivot_renderuje_macierz_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() + + 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.""" 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 2e9d3db84..3e8cb3add 100644 --- a/src/bpp/views/mymultiseek.py +++ b/src/bpp/views/mymultiseek.py @@ -259,6 +259,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: @@ -272,6 +281,25 @@ 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) + pivot_result = pivot_mod.zbuduj_pivot(base_qs, row_dim, col_dim, metric) + 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"}: From cede8f22ea391f0fb5aa71892291da0aafc7b8f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 24 Jul 2026 19:21:34 +0200 Subject: [PATCH 11/12] feat(multiseek): newsfragment pivota + test ukrycia eksportu dla anonima MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 6: anonim widzi tabelę krzyżową, ale nie linki eksportu (LoginRequired). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CQBbkTBBkt5ZKNN648gY2h --- .../newsfragments/multiseek-pivot.feature.rst | 6 ++++++ src/bpp/tests/test_multiseek_pivot_view.py | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 src/bpp/newsfragments/multiseek-pivot.feature.rst 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/tests/test_multiseek_pivot_view.py b/src/bpp/tests/test_multiseek_pivot_view.py index 127a429d2..f1e832ed7 100644 --- a/src/bpp/tests/test_multiseek_pivot_view.py +++ b/src/bpp/tests/test_multiseek_pivot_view.py @@ -148,6 +148,25 @@ def test_pivot_export_csv(logged_in_client, test_user, standard_data, denorms): 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.""" From ecb05fdae4f1c8db677c9131b967b0df6a5de882 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Fri, 24 Jul 2026 19:43:32 +0200 Subject: [PATCH 12/12] fix(multiseek): bezpieczniki rozmiaru pivota + poprawki po review Fable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PivotTooLargeError + bramki PIVOT_MAX_CELLS/PIVOT_MAX_PAIRS (pivot jest publiczny i omija bramkę 25000 — anonim z ?pivot_row=autor na pustym filtrze mógł wybudować macierz z dziesiątkami tys. wierszy i wciągnąć miliony par autorstw do RAM). Strategia B iteruje z .iterator(). - Widok łapie błąd → szablon pokazuje "zawęź zapytanie" (pasek selektorów działa też na ekranie błędu); eksport zwraca 400. - Uczciwy paginator_count dla breadcrumbu (liczba rekordów, nie 0). - Domyślny tytuł sesji także dla pivota (_ensure_default_title). - Dead code: usunięto fk_label_field, zwinięto label_kind "autor"→"fk". Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CQBbkTBBkt5ZKNN648gY2h --- src/bpp/multiseek_registry/pivot.py | 45 ++++++++++++++++--- src/bpp/tests/test_multiseek_pivot.py | 15 +++++++ src/bpp/tests/test_multiseek_pivot_view.py | 34 +++++++++++--- src/bpp/views/mymultiseek.py | 39 ++++++++++++---- .../multiseek/report-body-pivot.html | 23 +++++++--- 5 files changed, 130 insertions(+), 26 deletions(-) diff --git a/src/bpp/multiseek_registry/pivot.py b/src/bpp/multiseek_registry/pivot.py index 042b99415..a7a74d487 100644 --- a/src/bpp/multiseek_registry/pivot.py +++ b/src/bpp/multiseek_registry/pivot.py @@ -13,9 +13,8 @@ class PivotDimension: expr: str allow_column: bool = True autorzy: bool = False - label_kind: str = "raw" # raw|fk|choices_charakter_ogolny|pk_bucket|autor + label_kind: str = "raw" # raw | fk | choices_charakter_ogolny | pk_bucket fk_model: str | None = None - fk_label_field: str = "nazwa" def resolve_model(self): from django.apps import apps @@ -101,7 +100,7 @@ class PivotMetric: "autorzy__autor_id", allow_column=False, autorzy=True, - label_kind="autor", + label_kind="fk", fk_model="bpp.Autor", ), } @@ -168,6 +167,24 @@ def as_table(self): } +# 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) @@ -177,6 +194,22 @@ def zbuduj_pivot(base_qs, row_dim, col_dim, metric): # 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 @@ -211,7 +244,9 @@ def _pairs_strategy(base_qs, row_dim, col_dim, metric): 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: + # .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"] @@ -287,7 +322,7 @@ def _label_mapping(keys, dim): 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"): + 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) diff --git a/src/bpp/tests/test_multiseek_pivot.py b/src/bpp/tests/test_multiseek_pivot.py index 7b9b31030..211edd38a 100644 --- a/src/bpp/tests/test_multiseek_pivot.py +++ b/src/bpp/tests/test_multiseek_pivot.py @@ -386,3 +386,18 @@ def test_as_table_z_kolumnami(): 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 index f1e832ed7..312e24e41 100644 --- a/src/bpp/tests/test_multiseek_pivot_view.py +++ b/src/bpp/tests/test_multiseek_pivot_view.py @@ -79,8 +79,9 @@ def test_pivot_results_view_zwraca_pivot( def test_pivot_results_view_pomija_agregaty_listy( logged_in_client, test_user, standard_data, denorms ): - """Gałąź pivota omija cache agregatów/sumy stopki (spec §8) — - paginator_count jest wyzerowany, a klucz "sumy" nie jest ustawiany.""" + """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) @@ -90,10 +91,33 @@ def test_pivot_results_view_pomija_agregaty_listy( ) assert resp.status_code == 200 - assert resp.context["paginator_count"] == 0 + 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 @@ -158,9 +182,7 @@ def test_pivot_export_ukryty_dla_anonima(client, standard_data, denorms): denorms.flush() _set_multiseek_pivot_filter(client, AnonymousUser()) - resp = client.get( - reverse("multiseek:results") + "?pivot_row=rok&pivot_val=liczba" - ) + resp = client.get(reverse("multiseek:results") + "?pivot_row=rok&pivot_val=liczba") html = resp.content.decode() assert 'class="multiseek-pivot"' in html diff --git a/src/bpp/views/mymultiseek.py b/src/bpp/views/mymultiseek.py index 3e8cb3add..63e6c9f62 100644 --- a/src/bpp/views/mymultiseek.py +++ b/src/bpp/views/mymultiseek.py @@ -178,18 +178,38 @@ 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"] = 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 + # 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() @@ -228,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 @@ -294,7 +309,13 @@ def _export_pivot(self, request, export_format): ) base_qs = self.get_queryset_for_current_mode() row_dim, col_dim, metric = pivot_mod.parse_pivot_params(request.GET) - pivot_result = pivot_mod.zbuduj_pivot(base_qs, row_dim, col_dim, metric) + 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) diff --git a/src/django_bpp/templates/multiseek/report-body-pivot.html b/src/django_bpp/templates/multiseek/report-body-pivot.html index 31a7822ae..ee1512663 100644 --- a/src/django_bpp/templates/multiseek/report-body-pivot.html +++ b/src/django_bpp/templates/multiseek/report-body-pivot.html @@ -1,14 +1,15 @@ {% comment %} -Tabela krzyżowa (pivot). Kontekst: pivot (PivotResult), pivot_dimensions, -pivot_metrics. Konfiguracja wierszy/kolumn/metryki idzie parametrami GET -(pivot_row / pivot_col / pivot_val) — pasek selektorów niżej. +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 %}
@@ -17,7 +18,7 @@ {% for key, dim in pivot_dimensions.items %} {% if dim.allow_column %} - + {% endif %} {% endfor %} @@ -25,13 +26,14 @@
+ {% if pivot %} {% with t=pivot.as_table %}
@@ -79,4 +81,13 @@ 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 %}