| {{ pivot.row_dim.label }} | + {% for ck, clabel in pivot.cols %}{{ clabel }} | {% endfor %} +RAZEM | +|
|---|---|---|---|
| {{ rlabel }} | + {% if pivot.cols %} + {% for ck, clabel in pivot.cols %} +{{ pivot.cells|pivot_cell:rk|pivot_cell:ck }} | + {% endfor %} + {% else %} +{{ pivot.cells|pivot_cell:rk|pivot_cell:None }} | + {% endif %} +{{ pivot.row_totals|dict_get:rk }} | +
| RAZEM | + {% for ck, clabel in pivot.cols %}{{ pivot.col_totals|dict_get:ck }} | {% endfor %} +{{ pivot.grand_total }} | +
+ ⓘ Grupowanie po jednostce/dyscyplinie/autorze liczy powiązania, nie unikatowe prace — + praca powiązana z wieloma jednostkami liczona jest w każdej z nich; sumy mogą przewyższać wartości całkowite. +
+{% endif %} +``` + +> **Filtry szablonowe:** Django nie indeksuje krotek/dictów po zmiennym +> kluczu. Dwie opcje (wybierz prostszą dla projektu): +> (a) dołóż mały template-tag/filtr `dict_get` i `pivot_cell` w istniejącej +> bibliotece tagów multiseek (`src/bpp/templatetags/`), LUB +> (b) w `zbuduj_pivot`/widoku przekształć `PivotResult` w gotowe do +> iteracji listy wierszy `[{"label":..., "cells":[...], "total":...}]` + +> nagłówki + stopkę, i renderuj bez indeksowania po kluczu. +> **Rekomendacja: (b)** — czystszy szablon, brak magii filtrów. Jeśli +> wybierzesz (b), dodaj do `PivotResult` metodę/property `as_table()` +> zwracającą tę strukturę i użyj jej w template (zmień test HTML odpowiednio). + +- [ ] **Step 5: SCSS — styl tabeli krzyżowej (sticky nagłówki, scroll)** + +```scss +// w pliku komponentów multiseek (bez nadpisywania siatki Foundation): +.multiseek-pivot-scroll { overflow-x: auto; } +.multiseek-pivot { + border-collapse: collapse; + th, td { border: 1px solid #ccc; padding: 4px 10px; text-align: right; } + thead th, tbody th { text-align: left; background: #f4f4f4; } + .pivot-total { font-weight: bold; background: #eee; } +} +.multiseek-pivot-note { color: #666; font-size: 0.85em; margin-top: 0.5em; } +.multiseek-pivot-controls { margin-bottom: 1em; display: flex; gap: 1em; flex-wrap: wrap; } +``` + +Po zmianie SCSS: `grunt build` (patrz Global Constraints / docs). + +- [ ] **Step 6: Uruchom test HTML + build assetów** + +Run: `uv run pytest src/bpp/tests/test_multiseek_pivot_view.py -p no:cacheprovider -q 2>&1 | tee /tmp/pivot_t4all.log; grep -E "passed|failed|error" /tmp/pivot_t4all.log | tail -3` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/django_bpp/templates/multiseek/report-body-pivot.html \ + src/django_bpp/templates/multiseek/common-results.html \ + src/bpp/tests/test_multiseek_pivot_view.py +# + zmienione pliki SCSS i zbudowane assety, jeśli dotyczy +git commit -m "feat(multiseek): partial tabeli krzyżowej + branch + styl" +``` + +--- + +## Task 5: Eksport pivota (XLSX/CSV) — omija cap 5000 + +**Files:** +- Modify: `src/bpp/views/mymultiseek.py` (`MyMultiseekExport.get` / `_export_data`) +- Modify: `src/bpp/views/multiseek_export.py` (builder z `PivotResult`) +- Test: `src/bpp/tests/test_multiseek_pivot_view.py` + +**Interfaces:** +- Consumes: `parse_pivot_params`, `zbuduj_pivot`, `PivotResult`. +- Produces: `pivot_xlsx_export_response(pivot_result, request, title)` i + `pivot_csv_export_response(pivot_result, request, title)` w + `multiseek_export.py`. + +- [ ] **Step 1: Failing test — eksport XLSX pivota, cap 5000 nie blokuje** + +```python +@pytest.mark.django_db +def test_pivot_export_xlsx_liczby_zgodne_z_widokiem(admin_client, ...): + # ustaw sesję report_type=pivot; GET eksportu + resp = admin_client.get("/multiseek/export/xlsx/?pivot_row=rok&pivot_val=liczba") + assert resp.status_code == 200 + assert resp["Content-Type"].startswith( + "application/vnd.openxmlformats-officedocument.spreadsheetml" + ) + # (opcjonalnie) wczytaj openpyxl i porównaj sumę z grand_total +``` + +- [ ] **Step 2: Uruchom — ma paść (eksport traktuje pivot jak dane listy / cap)** + +Run: `uv run pytest src/bpp/tests/test_multiseek_pivot_view.py -k export_xlsx -p no:cacheprovider -q 2>&1 | tee /tmp/pivot_t5.log; tail -5 /tmp/pivot_t5.log` +Expected: FAIL. + +- [ ] **Step 3: Gałąź pivota w `MyMultiseekExport.get` (przed cap 5000)** + +```python +# src/bpp/views/mymultiseek.py — w MyMultiseekExport.get, na początku, +# PRZED `count = queryset.count()` / cap 5000: + registry = get_registry(self.registry) + report_type = registry.get_report_type( + self.get_multiseek_data(), request=request + ) + if report_type == "pivot": + from bpp.multiseek_registry import pivot as pivot_mod + from bpp.views.multiseek_export import ( + pivot_csv_export_response, + pivot_xlsx_export_response, + ) + + base_qs = self.get_queryset_for_current_mode() + row_dim, col_dim, metric = pivot_mod.parse_pivot_params(request.GET) + pr = pivot_mod.zbuduj_pivot(base_qs, row_dim, col_dim, metric) + title = _multiseek_report_title(request) + if export_format == "csv": + return pivot_csv_export_response(pr, request, title) + if export_format == "xlsx": + return pivot_xlsx_export_response(pr, request, title) + return HttpResponseBadRequest( + "Eksport tabeli krzyżowej dostępny jako XLSX lub CSV." + ) +``` + +- [ ] **Step 4: Buildery w `multiseek_export.py`** + +```python +# src/bpp/views/multiseek_export.py — nowe funkcje budujące płaską macierz +# (nagłówek: [etykieta wiersza] + etykiety kolumn + "RAZEM"; wiersze danych; +# wiersz RAZEM). Wykorzystaj istniejące helpery XLSX/CSV z tego modułu. +def _pivot_rows(pr): + header = [pr.row_dim.label] + [c[1] for c in pr.cols] + ["RAZEM"] + yield header + for rk, rlabel in pr.rows: + row = [rlabel] + if pr.cols: + row += [pr.cells.get((rk, ck), "") for ck, _ in pr.cols] + else: + row += [pr.cells.get((rk, None), "")] + row.append(pr.row_totals.get(rk, 0)) + yield row + footer = ["RAZEM"] + [pr.col_totals.get(ck, 0) for ck, _ in pr.cols] + [pr.grand_total] + yield footer + + +def pivot_csv_export_response(pr, request, report_title): + ... # analogicznie do csv_export_response, ale z _pivot_rows(pr) + + +def pivot_xlsx_export_response(pr, request, report_title): + ... # analogicznie do xlsx_export_response, ale z _pivot_rows(pr) +``` + +> Wykorzystaj istniejące funkcje `csv_export_response` / +> `xlsx_export_response` jako wzorzec (nagłówki HTTP, nazwa pliku, +> openpyxl). `_pivot_rows` daje wiersze; reszta jak w istniejących. + +- [ ] **Step 5: Uruchom test eksportu — ma przejść** + +Run: `uv run pytest src/bpp/tests/test_multiseek_pivot_view.py -k export -p no:cacheprovider -q 2>&1 | tee /tmp/pivot_t5.log; grep -E "passed|failed|error" /tmp/pivot_t5.log | tail -3` +Expected: PASS. + +- [ ] **Step 6: ruff + commit** + +```bash +uv run ruff format src/bpp/views/mymultiseek.py src/bpp/views/multiseek_export.py src/bpp/tests/test_multiseek_pivot_view.py +uv run ruff check src/bpp/views/mymultiseek.py src/bpp/views/multiseek_export.py src/bpp/tests/test_multiseek_pivot_view.py +git add src/bpp/views/mymultiseek.py src/bpp/views/multiseek_export.py src/bpp/tests/test_multiseek_pivot_view.py +git commit -m "feat(multiseek): eksport XLSX/CSV tabeli krzyżowej (omija cap 5000)" +``` + +--- + +## Task 6: Ukryj eksport dla anonima + newsfragment + pełny przebieg + +**Files:** +- Modify: `src/django_bpp/templates/multiseek/report-body-pivot.html` (przycisk eksportu tylko dla zalogowanych) +- Create: `src/bpp/newsfragments/multiseek-pivot.feature.rst` +- Test: `src/bpp/tests/test_multiseek_pivot_view.py` + +- [ ] **Step 1: Dodaj przyciski eksportu (tylko zalogowany) do partiala** + +```django +{# w report-body-pivot.html, pod tabelą (nad/pod adnotacją): #} +{% if request.user.is_authenticated %} + +{% endif %} +``` + +> Eksport pivota jest `LoginRequiredMixin` — dla anonima link i tak dałby +> redirect do logowania, więc ukrywamy go (spec §9.3). + +- [ ] **Step 2: Test — anonim nie widzi eksportu, zalogowany widzi** + +```python +@pytest.mark.django_db +def test_pivot_export_ukryty_dla_anonima(client, admin_client, ...): + anon = client.get("/multiseek/results/?pivot_row=rok").content.decode() + assert "export/xlsx" not in anon + logged = admin_client.get("/multiseek/results/?pivot_row=rok").content.decode() + assert "export/xlsx" in logged +``` + +- [ ] **Step 3: Newsfragment** + +```rst +.. src/bpp/newsfragments/multiseek-pivot.feature.rst +Nowy typ raportu „tabela krzyżowa" w wyszukiwarce: grupowanie wyników +(oraz prac autora) w pivot — wybierany wymiar wierszy i kolumn oraz +metryka w komórce (liczba prac, suma punktów PK, IF, cytowań), z sumami +brzegowymi i eksportem do XLSX/CSV. +``` + +- [ ] **Step 4: Pełny przebieg testów pivota + powiązanych** + +Run: `uv run pytest src/bpp/tests/test_multiseek_pivot.py src/bpp/tests/test_multiseek_pivot_view.py -p no:cacheprovider -q 2>&1 | tee /tmp/pivot_full.log; grep -E "passed|failed|error" /tmp/pivot_full.log | tail -3` +Expected: PASS (wszystkie). + +- [ ] **Step 5: Regresja multiseek (istniejące testy nietknięte)** + +Run: `uv run pytest src/bpp/tests/ -k multiseek -p no:cacheprovider -q 2>&1 | tee /tmp/pivot_reg.log; grep -E "passed|failed|error" /tmp/pivot_reg.log | tail -3` +Expected: PASS (report_type dopisany na końcu nie rusza istniejących indeksów). + +- [ ] **Step 6: Commit** + +```bash +git add src/django_bpp/templates/multiseek/report-body-pivot.html \ + src/bpp/newsfragments/multiseek-pivot.feature.rst \ + src/bpp/tests/test_multiseek_pivot_view.py +git commit -m "feat(multiseek): ukryj eksport pivota dla anonima + newsfragment" +``` + +--- + +## Self-Review (wypełnione) + +**Spec coverage:** +- §4 report_type + GET → Task 3 (report_type), Task 1 (parse GET). ✓ +- §5 UI (selektory, RAZEM, scroll, adnotacja pod tabelą, puste komórki) → Task 4. ✓ +- §6 menu wymiarów/metryk + mapowanie etykiet → Task 1 (rejestry) + Task 2 (`_label_mapping`). ✓ +- §7 semantyka Autorzy (pary, K2, adnotacja) → Task 2 (`_pairs_strategy`), Task 4 (adnotacja). ✓ +- §8 strategia A/B, czyszczenie orderingu, brak count/cache → Task 2 + Task 3. ✓ +- §9 eksport (cap 5000 bypass, kontrakt DANE/DOKUMENT, LoginRequired) → Task 5 + Task 6. ✓ +- §11 testy (K1/K2/K3, NULL, indeksy, degeneracja, eksport, anonim) → rozłożone po Task 2-6. ✓ +- Multi-hosted `ukryte_statusy` — dziedziczone z `get_queryset` (Task 3 używa `get_queryset_for_current_mode`), pokryte istniejącą logiką. ✓ + +**Placeholder scan:** Kod-steps mają realny kod; miejsca oznaczone `...` +to WYŁĄCZNIE dane fixture/asercje zależne od danych testowych oraz dwa +buildery eksportu wzorowane na istniejących funkcjach — z jawną +instrukcją, skąd wziąć wzorzec. Brak „TODO/TBD/handle edge cases". + +**Type consistency:** `PivotDimension`/`PivotMetric`/`PivotResult`, +`parse_pivot_params`, `zbuduj_pivot`, `_dedup_strategy`/`_pairs_strategy`/ +`_build_matrix`/`_labels`/`_label_mapping`, `_pivot_rows`, +`pivot_{csv,xlsx}_export_response` — nazwy spójne między Task 1-6. + +**Znane ryzyko dla implementera (świadome):** fixtury tworzące wpisy w +`bpp_rekord_mat`/`bpp_autorzy_mat` MUSZĄ użyć istniejącego w projekcie +mechanizmu odświeżania cache (Task 2 Step 1 uwaga) — to jedyne miejsce, +gdzie plan celowo odsyła do wzorca projektu zamiast dyktować kod, bo +mechanizm jest projektowo-specyficzny. diff --git a/docs/superpowers/specs/2026-07-24-multiseek-pivot-grupowanie-design.md b/docs/superpowers/specs/2026-07-24-multiseek-pivot-grupowanie-design.md new file mode 100644 index 000000000..a9ce9d1ec --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-multiseek-pivot-grupowanie-design.md @@ -0,0 +1,371 @@ +# Grupowanie wyników jako tabela krzyżowa (pivot) — multiseek + +**Data:** 2026-07-24 +**Gałąź:** `feat/multiseek-pivot-grupowanie` +**Status:** projekt zaakceptowany, uzupełniony po self-review (Fable), do +spisania planu implementacji + +## 1. Cel + +Dodać do wyszukiwarki możliwość **grupowania wyników w tabelę krzyżową +(pivot)** — zbiorcze podsumowanie zamiast płaskiej listy rekordów. User +wybiera wymiar wierszy, wymiar kolumn i metrykę w komórce (model pivota +z Excela: *Rows × Columns × Values*), dostaje krzyżówkę z sumami +brzegowymi i wierszem/kolumną „RAZEM". + +Motywacja użytkownika: szybko zobaczyć np. „ile prac i ile punktów PK +w danym roku wg charakteru", „prace autora wg kliniki i rodzaju", +„rozkład prac wg koszyka punktów PK". + +## 2. Kluczowe ustalenie architektoniczne + +**„Prace autora" renderują się przez ten sam silnik co „Szukaj → +Multiseek".** Strona autora (`AutorView`, `src/bpp/views/browse.py:235`) +to tylko formularz; `BuildSearch` (`src/bpp/views/browse.py:781`) składa +zapytanie multiseek do sesji i przekierowuje na **`multiseek:index`** +(formularz z live-iframe `./live-results/`, `multiseek/index.html:402`) — +czyli user prac autora **ma na tej stronie selektor typu raportu**. +Kluczowe: to **ten sam silnik multiseek** renderuje listę prac, więc +pivot zaimplementowany w multiseek pokrywa jednocześnie „Szukaj → +Multiseek" i „prace autora" — bez dodatkowego kodu w widoku autora. + +Wyszukiwarka „Szukaj → Zapytaniem" (DjangoQL, `ZapytanieView`, +`src/bpp/views/zapytanie.py`) to **osobny silnik** (dostępny tylko dla +redaktorów/superuserów). Jest poza zakresem fazy 1. + +## 3. Decyzje projektowe (podjęte) + +| Decyzja | Wybór | +|---|---| +| Sens „grupowania" | **Pivot / zwinięte podsumowanie** (nie sekcje z pełną listą) | +| Kształt | **Tabela krzyżowa** (Wiersze × Kolumny × Wartość) | +| Zawartość komórki | **Wybierana metryka** (pole „Wartości" jak w Excelu) | +| Zasięg | **Multiseek najpierw** (pokrywa Multiseek + prace autora); zapytanie/DjangoQL = faza 2 | +| Eksport pivota | **W fazie 1** (XLSX/CSV od razu) | +| Drill-down (klik w komórkę → rekordy grupy) | **Faza 2** | +| Wymiary z tabeli `Autorzy` (Jednostka/Dyscyplina/Autor) | **W fazie 1**, z widoczną adnotacją o dublowaniu | +| „Wydział" jako wymiar | **Faza 2** (dziura NULL-korzenia, §6/§13) | + +## 4. Punkt wpięcia — nowy `report_type` + parametry GET + +Multiseek ma wybór typu raportu (lista / tabela / bibtex) sterujący +wyborem partiala w `src/django_bpp/templates/multiseek/common-results.html:103`. +Typy zdefiniowane w `src/bpp/multiseek_registry/reports.py:8`. + +**Dodajemy nowy typ raportu `"pivot"`** („Tabela krzyżowa"). + +> **UWAGA (report_type to indeks pozycyjny, nie string!).** Formularz +> POST-uje `forloop.counter0` (`multiseek/index.html:44`); `get_report_type` +> indeksuje przefiltrowaną per-request listę (`multiseek/logic.py:721`). +> Sesje i zapisane `SearchForm` przechowują **indeks**. Dlatego nowy typ +> `pivot` **musi być dopisany na KOŃCU** `multiseek_report_types` +> (`reports.py:8`) i mieć **`public=True`** (anonim na stronie autora). +> Wstawienie w środek przesunęłoby zapisane formularze. + +**Rozdział odpowiedzialności:** +- **report_type** (który to typ raportu, w tym „pivot") — mechanizmem + istniejącym: **selektor w formularzu → sesja**. Bez GET-override + (musiałby być honorowany spójnie w 3 miejscach — niepotrzebna + komplikacja). +- **Konfiguracja pivota** (wiersze / kolumny / metryka) — **parametry GET** + na URL wyników, czytane w widoku wyników: + + ``` + /multiseek/results/?pivot_row=rok&pivot_col=charakter_ogolny&pivot_val=liczba + ``` + + Zalety: przestawienie pivota nie przebudowuje zapytania (sama zmiana + GET); pivot jest **linkowalny/bookmarkowalny**. Brak parametrów → sensowne + domyślne (`pivot_row=rok`, `pivot_col=` brak, `pivot_val=liczba`). + +> **Znane zachowanie (do udokumentowania, nie bug):** na stronie prac +> autora wyniki są w live-iframe; każda zmiana formularza robi POST do +> iframe i **resetuje** parametry `pivot_*` z GET. Świadome, akceptowalne +> w v1. + +Odrzucona alternatywa: osobny widok `/multiseek/pivot/` — dublowałby glue +„sesja → queryset" i wypychał usera z ekranu wyników. + +## 5. Interfejs + +Gdy typ raportu = „Tabela krzyżowa", nad wynikami pojawia się pasek z +trzema selektorami (mini-formularz GET); zmiana któregokolwiek +przeładowuje wyniki: + +``` +Typ raportu: [ Tabela krzyżowa ▾ ] +Wiersze:[ Rok ▾ ] Kolumny:[ Charakter ogólny ▾ | (brak) ] W komórce:[ Liczba prac ▾ ] + + Artykuł Rozdział Monografia │ RAZEM + 2024 9 4 2 │ 15 + 2023 7 3 2 │ 12 + ──────────────────────────────────────────────── + RAZEM 16 7 4 │ 27 + + ⓘ Grupowanie po jednostce/dyscyplinie/autorze liczy powiązania… (adnotacja pod tabelą) +``` + +- **Kolumny = (brak)** → zwykła płaska tabela zbiorcza (degeneracja + cross-tabu; user dostaje i pivot 2D, i proste podsumowanie 1D). +- Szeroka krzyżówka → poziomy scroll w kontenerze `overflow-x:auto` + (body strony się nie rozjeżdża — wzorzec jak w tabelach importu). +- **Puste komórki** (brak rekordów na przecięciu) → puste (blank) dla + czytelności; sumy brzegowe i tak liczone. +- Wiersz i kolumna **RAZEM** = sumy brzegowe; przecięcie = suma całkowita. +- Sortowanie wierszy: wg wartości wymiaru (rok malejąco; słowniki + alfabetycznie). Kolumny analogicznie. +- **Adnotacja o dublowaniu** (gdy wybrany wymiar z tabeli `Autorzy` — + Jednostka/Dyscyplina/Autor) renderowana **bezpośrednio pod tabelą + krzyżową** (pod pivotem, nie nad nim), stonowana wizualnie (mały, + szary tekst). + +## 6. Menu wymiarów i metryk + +### Wymiary — jako **wiersze**: +Rok · Charakter formalny · Charakter ogólny (rodzaj) · Typ MNiSW/MEiN +(`typ_kbn`) · Koszyk punktów PK · Język · Źródło · **Jednostka** · +**Dyscyplina naukowa** · **Autor** + +### Wymiary — jako **kolumny** (tylko o małej liczności): +Rok · Charakter formalny · Charakter ogólny · Typ MNiSW/MEiN · Koszyk PK · +Język · Dyscyplina + +> Jednostka / Źródło / Autor **nie są dostępne jako kolumny** — zbyt duża +> liczność rozsadziłaby szerokość tabeli. Tylko jako wiersze. +> **„Wydział" — faza 2** (patrz §13, dziura NULL-korzenia). + +### Metryki — „w komórce": +Liczba prac *(domyślna)* · Σ punkty PK (`punkty_kbn`) · Σ Impact Factor · +Σ liczba cytowań · Σ punktacja wewnętrzna + +### Mapowanie wymiarów na wyrażenia ORM i etykiety +| Wymiar | Wyrażenie ORM (`values`) | Etykieta / uwaga | +|---|---|---| +| Rok | `rok` | wartość wprost | +| Charakter formalny | `charakter_formalny_id` | dociągnąć `nazwa` (mapa id→nazwa) | +| Charakter ogólny | `charakter_formalny__charakter_ogolny` | **surowe kody 3-zn.** → mapa `CHARAKTER_OGOLNY_CHOICES` (`charakter_formalny.py:80`) | +| Typ MNiSW/MEiN | `typ_kbn_id` | dociągnąć `nazwa` | +| Koszyk punktów PK | `punkty_kbn` | Decimal (default=0, NOT NULL); 0 → „brak (0)"; formatować etykiety | +| Język | `jezyk_id` | dociągnąć `nazwa` | +| Źródło | `zrodlo_id` | dociągnąć `nazwa`; NULL → „brak" | +| Jednostka | `autorzy__jednostka_id` | **JOIN do `Autorzy`** — §7; dociągnąć `nazwa`; NULL → „brak" | +| Dyscyplina | `autorzy__dyscyplina_naukowa_id` | JOIN do `Autorzy`; dociągnąć `nazwa`; NULL → „brak" | +| Autor | `autorzy__autor_id` | JOIN do `Autorzy`; **zwraca id, nie str** — hurtowo dociągnąć nazwiska, sortować w Pythonie | + +> **Ujednolicenie NULL-kubłów:** dla każdego wymiaru dopuszczającego NULL +> (źródło, dyscyplina, autor, jednostka przy LEFT JOIN) wartość NULL → +> etykieta **„brak"** (spójnie z koszykiem PK). + +## 7. Subtelność semantyczna — wymiary z tabeli `Autorzy` + +Pola **na rekordzie** (rok, charakter, typ, punkty, język, źródło): +grupowanie czyste — każdy rekord liczony raz. + +Pola **na powiązaniu `Autorzy`** (Jednostka, Dyscyplina, Autor) — +`src/bpp/models/cache/autorzy.py:24`: rekord ma wielu autorów z różnych +jednostek/dyscyplin. **Zdefiniowana semantyka v1: pary (rekord, +wartość-wymiaru).** Praca powiązana z 2 klinikami → liczy się **raz w +każdej klinice** (NIE N-krotnie wg liczby autorów z danej kliniki). + +- „liczba prac" w grupie = liczba **unikatowych rekordów** powiązanych z + daną jednostką (dedup po rekordzie w obrębie grupy), +- Σ metryki = suma po **unikatowych parach** (rekord, jednostka) — punkty + rekordu wliczone raz do każdej jednostki, z którą powiązany. +- Dublowanie **między różnymi** jednostkami/dyscyplinami jest zamierzone + i komunikowane adnotacją pod tabelą (§5). +- Dla **prac autora** (filtr `autorzy__autor=X`) join jest **reużywany** + (zweryfikowany SQL) → grupujemy po jednostkach/dyscyplinach *tego* + autora — dokładnie to, o co chodzi. +- „Uczciwe" punkty per-dyscyplina ze slotów (`Cache_Punktacja_Dyscypliny.pkd`/`slot`, + `src/bpp/models/cache/punktacja.py:18`) → **faza 2**. + +## 8. Backend — strategia agregacji (per typ wymiaru) + +Punkt wyjścia: `base_qs` = przefiltrowany queryset (ten sam filtr co lista +wyników), ale **BEZ `.only()`, BEZ `.distinct()` i z WYCZYSZCZONYM +orderingiem** (`base_qs = filtered.order_by()`). + +> **KRYTYCZNE (K3): jawny `order_by` wchodzi do GROUP BY.** Rejestr +> multiseek ZAWSZE aplikuje `.order_by(...)` (`default_ordering=["-rok"]`, +> `src/bpp/multiseek_registry/__init__.py:26`; `apply_ordering_to_queryset`, +> `multiseek/logic.py:744`). Bez `.order_by()` przed `values().annotate()` +> sort formularza (np. „źródło/wyd. nadrzędne" = `CASE WHEN…`) wchodzi do +> `GROUP BY` i **rozbija pivot na mikrogrupy**. Musi być jawnie wyczyszczony +> i pokryty testem (najgroźniejsza cicha regresja). + +Filtry multiseek mogą **mnożyć wiersze** rekordu (JOIN do `bpp_autorzy_mat` +/ `bpp_zewnetrzne_bazy_view`). `.distinct()` z listy wyników **NIE +naprawia** GROUP BY (dedup następuje PO agregacji). Dlatego dwie ścieżki: + +### A) Oba wymiary (wiersz i kolumna) są **rekordowe** +Agregujemy po **zdedupowanym zbiorze rekordów**, żeby filtr mnożący nie +zawyżał (K1): +```python +ids = base_qs.values("pk") +deduped = Rekord.objects.filter(pk__in=ids).order_by() # świeży, czysty +matrix_rows = (deduped + .values(row_expr, col_expr) # col_expr pominięty, gdy kolumny=(brak) + .annotate(licznik=Count("id"), suma=Sum(metric_field)) + .order_by(row_expr, col_expr)) +``` +Każdy rekord raz. `Count("id")` OK (`Rekord.id` = `TupleField`/`int[]`, +PG liczy). `Sum(metric_field)` — metryka raz per rekord. + +### B) Wiersz **lub** kolumna to wymiar **autorski** (jednostka/dyscyplina/autor) +NIE używamy `pk__in`-subquery (zerwałby join-reuse → jednostki wszystkich +współautorów zamiast, dla prac autora, tylko tego autora). Operujemy na +`base_qs` (zachowuje kontekst filtra), pobieramy **unikatowe pary** i +agregujemy w Pythonie: +```python +pairs = (base_qs + .values(row_expr, col_expr, "id", metric_field) # id = rekord + .distinct()) +# w Pythonie, per (wiersz, kolumna): +# licznik = liczba unikatowych rekordów (set po id) +# suma = Σ metric_field po unikatowych parach (wymiar, rekord) +``` +Realizuje „raz per klinika" i punkty rekordu wliczone raz w każdą jednostkę +(§7). (Metryki v1 są zawsze **na poziomie rekordu**, więc `(wymiar, id)` +jednoznacznie wyznacza `metric_field`.) + +### Składanie macierzy +Płaska lista trójek `(wiersz, kolumna, wartość)` → struktura: +- posortowany zbiór wierszy, posortowany zbiór kolumn (pusty gdy + kolumny=(brak)), słownik `{(wiersz, kolumna): wartość}`, sumy per-wiersz, + per-kolumna, suma całkowita. +- surowe klucze (id/kody) → etykiety wg map z §6 (hurtowe `in_bulk` dla + FK, `dict(CHOICES)` dla kodów) — **jeden** dodatkowy query per słownik. + +### Wydajność / liczności +- Dla pivota **nie** liczymy drogiego `qset.count()` (`mymultiseek.py:208`) + ani sum ze stopki listy — pivot ma własne agregaty. +- Gate 25000 rekordów z listy (`common-results.html:10`) **nie obowiązuje** + dla pivota (agreguje, nie renderuje rekordów). Ewentualne miękkie + ostrzeżenie przy bardzo dużym zbiorze — patrz §13. +- **Cache:** w v1 **nie** cache'ujemy pivota (istniejący `_aggregate_cache_key` + nie obejmuje `pivot_*` — cache'owanie bez poprawki klucza dałoby + krzyżowe trafienia). Ewentualny cache z kluczem obejmującym GET — faza 2. + +## 9. Eksport (faza 1) + +Multiseek ma eksport `MyMultiseekExport` (`src/bpp/views/mymultiseek.py:239`), +formaty w `src/bpp/views/multiseek_export.py`. Kolizje z istniejącym +kontraktem (do rozwiązania): + +1. **Twardy cap 5000 rekordów PRZED gałęzią** (`mymultiseek.py:252`) — + dotyczy liczby rekordów źródłowych. Dla pivota **wynik to mała macierz**, + nie per-rekord — cap na rozmiar wyjścia jest bezcelowy. Rozwiązanie: + **dla `report_type==pivot` omijamy cap 5000** (budujemy z `PivotResult`, + nie z listy rekordów). +2. **Kontrakt „csv/xlsx = stałe kolumny, niezależne od report_type"** + (`mymultiseek.py:242`) — macierz pivota łamie to założenie. Rozwiązanie: + **jawna gałąź `if report_type == pivot`** w `MyMultiseekExport.get`, + budująca XLSX/CSV z tej samej struktury `PivotResult` co widok (jedno + źródło prawdy). BibTeX nie dotyczy pivota — pomijamy; DOCX/HTML + opcjonalnie, jeśli tanie. +3. **`LoginRequiredMixin`** na eksporcie — anonim ze strony autora + **widzi** pivot na ekranie, ale **nie wyeksportuje**. W v1: przycisk + eksportu pivota ukryty dla anonima; eksport pozostaje login-only. + (Eksport dla anonima — faza 2, jeśli potrzebny.) + +## 10. Pliki do zmiany (orientacyjnie) + +- `src/bpp/multiseek_registry/reports.py` — nowy typ raportu `pivot` + **na końcu** listy, `public=True`. +- `src/bpp/multiseek_registry/pivot.py` — **nowy**: rejestr wymiarów + (klucz GET → etykieta + wyrażenie ORM + flaga „dozwolony jako kolumna" + + flaga „wymaga JOIN do autorzy" + strategia etykiet) i metryk; + `PivotResult` (macierz + sumy); `zbuduj_pivot(base_qs, row, col, val)` + z rozgałęzieniem A/B (§8), czyszczeniem orderingu i mapowaniem etykiet; + walidacja GET (nieznany klucz → default / czytelny komunikat, nie 500). +- `src/bpp/views/mymultiseek.py` — w `MyMultiseekResults.get_context_data` + (`~:181`): gdy `report_type == pivot`, policz `PivotResult` z GET, + **pomiń** count/sumy listy; w `MyMultiseekExport.get` (`~:239`) — gałąź + eksportu pivota (omija cap 5000). +- `src/django_bpp/templates/multiseek/common-results.html` (`~:10`, `~:103`) + — dla pivota **ominąć gate 25000, pominąć `autopaginate` i oba + paginatory** (nie fetchować 20 rekordów na darmo), wybrać nowy partial. + (Restrukturyzacja większa niż sam `if` przy :103.) +- `src/django_bpp/templates/multiseek/report-body-pivot.html` — **nowy**: + pasek selektorów (GET) + tabela krzyżowa (sticky nagłówki, scroll) + + adnotacja o dublowaniu pod tabelą. +- `src/bpp/views/multiseek_export.py` — builder XLSX/CSV pivota z + `PivotResult`. +- SCSS: komponent stylu tabeli krzyżowej (sticky wiersz/kolumna nagłówków, + `overflow-x`) — bez nadpisywania siatki Foundation. +- Testy: `src/bpp/tests/` (§11). +- Newsfragment: `src/bpp/newsfragments/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 %}
| {{ t.row_header }} | + {% for ch in t.col_headers %}{{ ch }} | {% endfor %} +RAZEM | +
|---|---|---|
| {{ row.label }} | + {% for c in row.cells %}{{ c|default_if_none:"" }} | {% endfor %} +{{ row.total|default_if_none:"" }} | +
| RAZEM | + {% for ct in t.col_totals %}{{ ct|default_if_none:"" }} | {% endfor %} +{{ t.grand_total|default_if_none:"" }} | +
+ {% 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 %} + + {% 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 %} +