From e5f1125c2447116771c89d4805815bfdaa77f335 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 86cb6a724c5a1d634305f6977e6d67b9ed24083b 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] docs(pyproject): uzasadnij pin django-flexible-reports >= 0.5.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin >=0.5.0 trafił na dev zbiorczą aktualizacją dependabota (#809), czyli bez powodu zapisanego w repo. Powód jest twardy: wybór sortowania raportu (FD467) stoi na Report.set_order_by(), które wchodzi dopiero w 0.5.0 (mpasternak/django-flexible-reports#14). Przy 0.4.2 każdy raport z wariantem "wg nazwisk autorów" wywalałby się na AttributeError. Komentarz pilnuje, żeby ktoś nie cofnął pinu przy następnym porządkowaniu zależności. Pierwotnie ten commit robił sam bump — po rebase na dev został z niego wyłącznie zapis uzasadnienia. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01V98ts4xRv3XLUsxXzUeNkc --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 7836e0564..f8749d61b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,8 @@ dependencies = [ "channels[daphne]>=4.3.2,<5", "uvicorn[standard]>=0.52.4", "channels-redis>=4,<5", + # >=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",