From 0eb13e45d3a2b2ce3a5596abd138b58acab74530 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Mon, 17 Aug 2026 00:51:37 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(nowe=5Fraporty):=20wyb=C3=B3r=20sortow?= =?UTF-8?q?ania=20raportu=20=E2=80=94=20wg=20nazwisk=20autor=C3=B3w=20(FD4?= =?UTF-8?q?67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zgłoszenie FD467 (Biblioteka Naukowa IHiT) prosi o wydruk z BPP w formie, w jakiej spis wchodzi do sprawozdania rocznego: uporządkowany alfabetycznie wg nazwisk autorów. Dziś kolejność wierszy jest własnością tabeli flexible_reports (inline ColumnOrder: rok malejąco, potem opis), a więc stałą zapisaną w bazie i wspólną dla wszystkich czterech raportów — jedna tabela obsługuje 4 raporty x 12 sekcji. Formularz raportu dostaje pole "Sortowanie" z dwoma wariantami: dotychczasowy (domyślny, bez zmian) i alfabetyczny wg nazwisk. Wybór jedzie querystringiem tak samo jak _export, więc obejmuje też eksport do DOCX/XLSX, a widok generujący przekłada go na Report.set_order_by() — hook nadpisujący ColumnOrder na jeden render, bez ruszania definicji w bazie. Sortujemy po opis_bibliograficzny_autorzy_cache: to ArrayField ["Nazwisko Imiona", ...] w kolejności autorstwa, a PostgreSQL porównuje tablice element po elemencie, więc wychodzi porządek wg nazwiska PIERWSZEGO autora (z kolejnymi jako rozstrzygnięciem remisu) — dokładnie to, o co prosi zgłoszenie. To jedyne miejsce, w którym "nazwisko pierwszego autora" jest sortowalnym skalarem: przez relację autorzy__autor__nazwisko sortować się nie da, bo JOIN po relacji wielowartościowej zduplikowałby wiersze (a Column.clean() i tak odrzuca taką ścieżkę w dot-notation). Test test_rekord_sortuje_sie_po_nazwisku_pierwszego_autora pilnuje tego założenia o bazie, bo cała funkcja na nim stoi. Warianty i mapowanie na pola ORM siedzą w osobnym module sortowanie.py, na wzór poziomy.py — formularz bierze stamtąd etykiety, widok pola ORM. Gdyby były osobno, formularz mógłby oferować wariant, którego widok nie zna, a raport po prostu wychodziłby posortowany domyślnie. Przy okazji: POLA_ZAAWANSOWANE -> POLA_PRZEKAZYWANE. Lista steruje wyłącznie przekazywaniem do querystringu (o wyglądzie decyduje Layout), a po dodaniu "sortowania", które w układzie stoi w głównym fieldsecie obok formatu wyjściowego, dawna nazwa sugerowałaby nieistniejący związek z sekcją "Opcje zaawansowane". Domyślnej wartości pola nie trzeba nigdzie modelować: RaportFormView stoi na FormDefaultsMixin, a form_class_dla() buduje klasę o stabilnej nazwie per slug raportu, więc redaktor ustawia default per instalacja i per raport z panelu domyślnych wartości formularzy. UWAGA: wymaga django-flexible-reports >= 0.5.0 (Report.set_order_by, mpasternak/django-flexible-reports#14). Bump w pyproject.toml + uv lock dokładam osobnym commitem po wydaniu pakietu — dziś lockfile nie ma czego zrezolwować. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WSUsgzYoDNnXpXGAn5otJg --- src/bpp/newsfragments/fd467.feature.rst | 6 + src/nowe_raporty/forms.py | 25 +++- src/nowe_raporty/sortowanie.py | 51 +++++++++ src/nowe_raporty/tests/test_sortowanie.py | 133 ++++++++++++++++++++++ src/nowe_raporty/views.py | 11 +- 5 files changed, 222 insertions(+), 4 deletions(-) create mode 100644 src/bpp/newsfragments/fd467.feature.rst create mode 100644 src/nowe_raporty/sortowanie.py create mode 100644 src/nowe_raporty/tests/test_sortowanie.py diff --git a/src/bpp/newsfragments/fd467.feature.rst b/src/bpp/newsfragments/fd467.feature.rst new file mode 100644 index 000000000..8fd960153 --- /dev/null +++ b/src/bpp/newsfragments/fd467.feature.rst @@ -0,0 +1,6 @@ +Formularz raportów (autora, jednostki, wydziału, uczelni) ma nowe pole +"Sortowanie". Obok dotychczasowego porządku (rok malejąco, potem opis +bibliograficzny) można wybrać uporządkowanie alfabetyczne wg nazwisk autorów — +przydatne przy spisach do sprawozdań rocznych. Wybór dotyczy także eksportu do +MS Word i MS Excel. Domyślną wartość pola dla swojej instalacji można ustawić +w panelu domyślnych wartości formularzy. diff --git a/src/nowe_raporty/forms.py b/src/nowe_raporty/forms.py index dd540ffc0..c132ab7c3 100644 --- a/src/nowe_raporty/forms.py +++ b/src/nowe_raporty/forms.py @@ -17,6 +17,7 @@ from bpp.models.cache import Rekord from bpp.util import formdefaults_html_after, formdefaults_html_before, year_last_month +from . import sortowanie as sortowanie_raportu from .models import DefinicjaRaportu @@ -51,6 +52,15 @@ class BaseRaportForm(forms.Form): label="Format wyjściowy", choices=OUTPUT_FORMATS, required=True ) + # FD467: porządek spisu. Nie jest to filtr, więc stoi w głównym fieldsecie + # obok formatu wyjściowego, a nie w "Opcjach zaawansowanych". + sortowanie = forms.ChoiceField( + label="Sortowanie", + choices=sortowanie_raportu.WYBORY, + initial=sortowanie_raportu.DOMYSLNE, + required=True, + ) + # Wspólny default; podklasy per-poziom mogą nadpisać label/help_text. tylko_z_jednostek_uczelni = forms.BooleanField( initial=True, @@ -71,8 +81,13 @@ class BaseRaportForm(forms.Form): required=False, label="Tylko prace punktowane (pkt MNiSW > 0)" ) - # nazwy pól zaawansowanych przekazywanych w querystringu do widoku generuj - POLA_ZAAWANSOWANE = [ + # Nazwy pól przekazywanych w querystringu do widoku generującego. Lista + # steruje WYŁĄCZNIE przekazywaniem — o wyglądzie decyduje ``Layout`` niżej, + # dlatego jest tu ``sortowanie``, które w układzie stoi zupełnie gdzie + # indziej niż filtry zaawansowane. (Stąd nazwa ``POLA_PRZEKAZYWANE``, a nie + # dawna ``POLA_ZAAWANSOWANE``: ta druga sugerowała związek z sekcją "Opcje + # zaawansowane", którego nigdy nie było.) + POLA_PRZEKAZYWANE = [ "punkty_mnisw_od", "punkty_mnisw_do", "if_od", @@ -80,6 +95,7 @@ class BaseRaportForm(forms.Form): "punktacja_wewnetrzna_od", "punktacja_wewnetrzna_do", "tylko_punktowane", + "sortowanie", ] def clean(self): @@ -140,7 +156,10 @@ def __init__(self, *args, request=None, **kwargs): Column("od_roku", css_class="large-6 medium-6 small-12"), Column("do_roku", css_class="large-6 medium-6 small-12"), ), - Row(Column("_export")), + Row( + Column("_export", css_class="large-6 medium-6 small-12"), + Column("sortowanie", css_class="large-6 medium-6 small-12"), + ), Row(Column("tylko_z_jednostek_uczelni")), # "Opcje zaawansowane" jako rozwijana sekcja WEWNĄTRZ fieldsetu # (analogicznie do "Filtry ..." na ranking-autorow). Natywny diff --git a/src/nowe_raporty/sortowanie.py b/src/nowe_raporty/sortowanie.py new file mode 100644 index 000000000..b904b5a4c --- /dev/null +++ b/src/nowe_raporty/sortowanie.py @@ -0,0 +1,51 @@ +"""Porządki sortowania raportu — wybierane przez użytkownika w formularzu. + +Domyślnie kolejność wierszy jest własnością tabeli ``flexible_reports.Table`` +(inline ``ColumnOrder``: rok malejąco, potem opis). FD467 poprosił o spis +uporządkowany wg nazwisk autorów — czyli o wybór w czasie generowania, nie o +drugą tabelę. Realizuje to ``Report.set_order_by()``, który nadpisuje +``ColumnOrder`` na jeden render. + +Moduł jest wspólnym źródłem prawdy dla formularza (etykiety wyborów) i widoku +(pola ORM), analogicznie do ``poziomy.py``. Rozdzielenie ich groziłoby cichym +rozjechaniem: formularz oferowałby wariant, którego widok nie zna, i raport po +prostu wychodziłby posortowany domyślnie. +""" + +DOMYSLNE = "domyslne" +WG_AUTOROW = "autorzy" + +WYBORY = [ + (DOMYSLNE, "Rok (malejąco), potem opis bibliograficzny"), + (WG_AUTOROW, "Nazwiska autorów (alfabetycznie)"), +] + +# ``opis_bibliograficzny_autorzy_cache`` to ArrayField ["Nazwisko Imiona", ...] +# w kolejności autorstwa, a PostgreSQL porównuje tablice element po elemencie — +# sortowanie po nim daje więc porządek wg nazwiska PIERWSZEGO autora, z +# kolejnymi autorami jako rozstrzygnięciem remisu. +# +# To jedyne miejsce, w którym "nazwisko pierwszego autora" jest sortowalnym +# skalarem. Przez relację (``autorzy__autor__nazwisko``) sortować się NIE da: +# JOIN po relacji wielowartościowej zduplikowałby wiersze, a ``Column.clean()`` +# i tak odrzuca taką ścieżkę w dot-notation. +# +# ``tytul_oryginalny_sort`` na końcu nie jest ozdobą: bez niego dwie prace tego +# samego autora z tego samego roku mają kolejność niezdeterminowaną, więc ten +# sam raport wygenerowany dwa razy potrafiłby się różnić. +WG_AUTOROW_POLA = ( + "opis_bibliograficzny_autorzy_cache", + "rok", + "tytul_oryginalny_sort", +) + +# Tylko warianty nadpisujące. Brak klucza (w tym ``DOMYSLNE``) = zostaw +# ``ColumnOrder`` tabeli w spokoju. +PORZADKI = { + WG_AUTOROW: WG_AUTOROW_POLA, +} + + +def pola_dla(wybor): + """Pola ORM dla wariantu z querystringa, albo ``None`` gdy bez nadpisania.""" + return PORZADKI.get(wybor) diff --git a/src/nowe_raporty/tests/test_sortowanie.py b/src/nowe_raporty/tests/test_sortowanie.py new file mode 100644 index 000000000..efaafe180 --- /dev/null +++ b/src/nowe_raporty/tests/test_sortowanie.py @@ -0,0 +1,133 @@ +"""Wybór porządku sortowania raportu (FD467). + +Raport ma domyślnie kolejność zapisaną w ``ColumnOrder`` tabeli (rok malejąco, +potem opis). Zgłoszenie FD467 prosi o spis uporządkowany wg nazwisk autorów — +stąd wybór w formularzu, przekazywany querystringiem do widoku generującego. +""" + +import pytest +from model_bakery import baker + +from nowe_raporty import sortowanie +from nowe_raporty.forms import form_class_dla +from nowe_raporty.models import DefinicjaRaportu +from nowe_raporty.seeding import seed_default_reports +from nowe_raporty.views import RaportGenerujView, _redirect_do_generuj + +from bpp.models.autor import Autor +from bpp.models.cache import Rekord +from bpp.models.struktura import Jednostka +from bpp.models.wydawnictwo_ciagle import Wydawnictwo_Ciagle + + +@pytest.fixture +def prace_dwoch_autorow(typy_odpowiedzialnosci, denorms): + """Dwie prace w jednej jednostce: Zielińskiego i Abackiego. + + Nazwiska dobrane tak, żeby porządek alfabetyczny był ODWROTNY do kolejności + wstawiania — inaczej test przechodziłby także bez sortowania. + + ``denorms.flush()`` jest konieczny: ``opis_bibliograficzny_autorzy_cache`` + to pole denormalizowane, więc bez przeliczenia zostaje puste i sortowanie + po nim nie miałoby czego porównywać. + """ + jednostka = baker.make(Jednostka, skupia_pracownikow=True) + autorzy = [] + for nazwisko, imiona in (("Zieliński", "Jan"), ("Abacki", "Adam")): + autor = baker.make(Autor, nazwisko=nazwisko, imiona=imiona) + praca = baker.make(Wydawnictwo_Ciagle, rok=2020, punkty_kbn=10) + praca.dodaj_autora(autor, jednostka, zapisany_jako=nazwisko) + autorzy.append(autor) + denorms.flush() + return jednostka, autorzy + + +def _widok(definicja, obiekt, request): + v = RaportGenerujView() + v.kwargs = dict(slug=definicja.slug, od_roku=2020, do_roku=2020) + v.object = obiekt + v.request = request + return v + + +@pytest.mark.django_db +def test_sortowanie_po_autorach_ustawia_order_by_raportu(rf, prace_dwoch_autorow): + jednostka, _autorzy = prace_dwoch_autorow + seed_default_reports() + definicja = DefinicjaRaportu.objects.get(slug="raport-jednostek") + + v = _widok( + definicja, + jednostka, + rf.get("/", data={"sortowanie": sortowanie.WG_AUTOROW}), + ) + ret = v.get_context_data() + + assert ret["report"].order_by == sortowanie.WG_AUTOROW_POLA + + +@pytest.mark.django_db +def test_bez_parametru_raport_zachowuje_kolejnosc_z_columnorder( + rf, prace_dwoch_autorow +): + # Regresja dla istniejących instalacji: brak parametru nie może nadpisywać + # sortowania zapisanego w tabeli. + jednostka, _autorzy = prace_dwoch_autorow + seed_default_reports() + definicja = DefinicjaRaportu.objects.get(slug="raport-jednostek") + + v = _widok(definicja, jednostka, rf.get("/")) + ret = v.get_context_data() + + assert not ret["report"].order_by + + +@pytest.mark.django_db +def test_rekord_sortuje_sie_po_nazwisku_pierwszego_autora(prace_dwoch_autorow): + # Założenie, na którym stoi cała funkcja: PostgreSQL porównuje ArrayField + # element po elemencie, więc ORDER BY po opis_bibliograficzny_autorzy_cache + # daje porządek wg nazwiska PIERWSZEGO autora. + jednostka, _autorzy = prace_dwoch_autorow + + posortowane = Rekord.objects.prace_jednostki(jednostka).order_by( + "opis_bibliograficzny_autorzy_cache" + ) + + nazwiska = [r.opis_bibliograficzny_autorzy_cache[0] for r in posortowane] + assert nazwiska == ["Abacki Adam", "Zieliński Jan"] + + +@pytest.mark.django_db +def test_formularz_domyslnie_nie_zmienia_sortowania(prace_dwoch_autorow): + jednostka, _autorzy = prace_dwoch_autorow + seed_default_reports() + definicja = DefinicjaRaportu.objects.get(slug="raport-jednostek") + + form = form_class_dla(definicja)() + + assert form.fields["sortowanie"].initial == sortowanie.DOMYSLNE + + +@pytest.mark.django_db +def test_formularz_przekazuje_wybor_sortowania_do_url(prace_dwoch_autorow): + # Wybór musi przeżyć skok formularz -> redirect -> widok generujący, bo to + # querystring jest jedynym nośnikiem parametrów raportu (tak samo niesie + # ``_export``, więc eksport do DOCX/XLSX dostaje ten sam porządek). + jednostka, _autorzy = prace_dwoch_autorow + seed_default_reports() + definicja = DefinicjaRaportu.objects.get(slug="raport-jednostek") + + form = form_class_dla(definicja)( + data={ + "obiekt": jednostka.pk, + "od_roku": 2020, + "do_roku": 2020, + "_export": "html", + "sortowanie": sortowanie.WG_AUTOROW, + } + ) + assert form.is_valid(), form.errors + + response = _redirect_do_generuj(form.cleaned_data, form.POLA_PRZEKAZYWANE) + + assert f"sortowanie={sortowanie.WG_AUTOROW}" in response.url diff --git a/src/nowe_raporty/views.py b/src/nowe_raporty/views.py index 85b305be2..95180e909 100644 --- a/src/nowe_raporty/views.py +++ b/src/nowe_raporty/views.py @@ -24,6 +24,7 @@ from .forms import form_class_dla from .models import DefinicjaRaportu from .poziomy import POZIOMY +from .sortowanie import pola_dla def zastosuj_filtry_zaawansowane(queryset, params): @@ -154,6 +155,14 @@ def get_context_data(self, **kwargs): } ) + # Wariant sortowania z formularza (FD467). Wołamy setter TYLKO dla + # wariantu nadpisującego — dla domyślnego zostawiamy ``ColumnOrder`` + # tabeli nietknięty, żeby instalacje, które go sobie przestawiły, + # zachowały swoją kolejność. + pola_sortowania = pola_dla(self.request.GET.get("sortowanie")) + if pola_sortowania: + report.set_order_by(*pola_sortowania) + kwargs["report"] = report kwargs["od_roku"] = self.kwargs["od_roku"] kwargs["do_roku"] = self.kwargs["do_roku"] @@ -268,7 +277,7 @@ def get_initial(self): def form_valid(self, form): return _redirect_do_generuj( - form.cleaned_data, getattr(form, "POLA_ZAAWANSOWANE", []) + form.cleaned_data, getattr(form, "POLA_PRZEKAZYWANE", []) ) def get_context_data(self, **kwargs): From e59c97681b8e141a95656ba3941a4dfd6154a79a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Wed, 19 Aug 2026 00:24:50 +0200 Subject: [PATCH 2/2] chore: django-flexible-reports >= 0.5.0 (Report.set_order_by) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wybór sortowania raportu stoi na Report.set_order_by(), który wchodzi dopiero w 0.5.0 (mpasternak/django-flexible-reports#14). Bez tego pinu instalacja z 0.4.2 wywalałaby się na AttributeError przy każdym raporcie generowanym z wariantem "wg nazwisk autorów". Bump siedzi w osobnym commicie, bo w chwili pisania funkcji pakiet nie był jeszcze wydany i `uv lock` nie miał czego zrezolwować. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WSUsgzYoDNnXpXGAn5otJg --- pyproject.toml | 4 +++- uv.lock | 8 ++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 57264473c..b1760b1f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,9 @@ dependencies = [ "channels[daphne]>=4.3.2,<5", "uvicorn[standard]>=0.52.1", "channels-redis>=4,<5", - "django-flexible-reports>=0.4.2", + # >=0.5.0 wnosi Report.set_order_by() — bez niego wybór sortowania w + # formularzu raportów (FD467) nie ma jak nadpisać ColumnOrder tabeli. + "django-flexible-reports>=0.5.0", "django-dsl>=0.1.15", "django-group-by==0.3.1", "django-columns==0.1.0", diff --git a/uv.lock b/uv.lock index e6bcace18..718b2dfc7 100644 --- a/uv.lock +++ b/uv.lock @@ -600,7 +600,7 @@ requires-dist = [ { name = "django-favicon-plus-reloaded", specifier = ">=1.2,<2" }, { name = "django-filter", specifier = ">=26.1,<26.2" }, { name = "django-first-run-wizard", specifier = ">=0.2.1" }, - { name = "django-flexible-reports", specifier = ">=0.4.2" }, + { name = "django-flexible-reports", specifier = ">=0.5.0" }, { name = "django-formdefaults", specifier = ">=0.6.3" }, { name = "django-formtools", specifier = ">=2.7,<3" }, { name = "django-fsm-2", specifier = ">=4,<5" }, @@ -1878,7 +1878,7 @@ wheels = [ [[package]] name = "django-flexible-reports" -version = "0.4.2" +version = "0.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "bleach" }, @@ -1890,9 +1890,9 @@ dependencies = [ { name = "pypandoc" }, { name = "tablib" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2f/77/17615f52ea5f5c046fa0581f0cbecccc4d0d146330b676b702d209922466/django_flexible_reports-0.4.2.tar.gz", hash = "sha256:20f63f3442d29ff14ec6a608a2fbadcbc838ce3fb63d6c4ba5b78f20199ac3ba", size = 34824, upload-time = "2026-08-07T08:21:41.137Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/50/704b363c4f2040f0ffd557f8e6e1e969a291aef073781a45bf20a73c6e5a/django_flexible_reports-0.5.0.tar.gz", hash = "sha256:181e9042d95de7282d1d94b09200a8ea2ae37a0eb0032d10693a2ab8ec07c4ec", size = 35580, upload-time = "2026-08-18T22:21:39.736Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/5d/5e3bc1e9375d47b810e8263c16addce8bf13bde57d76636923dfec2786c7/django_flexible_reports-0.4.2-py3-none-any.whl", hash = "sha256:8fa2f8b68f5f82eaeaa62195961210cefb649f4270c6bc2edbf7d1662df8f75a", size = 52413, upload-time = "2026-08-07T08:21:39.766Z" }, + { url = "https://files.pythonhosted.org/packages/62/cf/9706122019a583c6238970657442edb10cc03ac933199a783421b2bbca0d/django_flexible_reports-0.5.0-py3-none-any.whl", hash = "sha256:e9cb14dcd1e61e3256f67b0dddbcd3399ba7c5c1d4e009ad1975713035e942b3", size = 53171, upload-time = "2026-08-18T22:21:38.241Z" }, ] [[package]]