Skip to content

Aktualizacja do Django 6.1 - #731

Open
mpasternak wants to merge 12 commits into
devfrom
django-6.1
Open

Aktualizacja do Django 6.1#731
mpasternak wants to merge 12 commits into
devfrom
django-6.1

Conversation

@mpasternak

@mpasternak mpasternak commented Aug 6, 2026

Copy link
Copy Markdown
Member

Django 6.1

Aktualizacja BPP z Django 5.2.16 LTS do Django 6.1 (wydane 2026-08-05).

Co się zmienia

  • Django 6.1, requires-python podniesione do >= 3.12 (wymóg 6.1; 3.11 wypadło razem z 6.0). Obraz Dockera i główne CI już stały na 3.12, więc nic tam nie trzeba ruszać.
  • django-prometheus usunięty. Miał twardy cap Django<6.1. Integracja była w całości opcjonalna (DJANGO_BPP_ENABLE_PROMETHEUS, domyślnie False) — poszła razem z flagą, middleware, wpisem w INSTALLED_APPS i routingiem /metrics.
  • Powrót z django-redis-iplweb na upstreamowy django-redis. Fork był czystym republishem (zero kodu iplweb, ten sam moduł django_redis) i utknął na Django<6.1; upstream 7.0.0 deklaruje Django<7.0,>=5.2. Nazwa backendu w konfiguracji się nie zmienia, więc wdrożenia nie wymagają żadnej akcji.
  • Grappelli bez zmian — 5.0.0 jest już linią dla Django 6.x (numeracja Grappelli jest przesunięta o jeden wstecz względem Django: 5.0.x → Django 6.x, 4.0.x → Django 5.x). Dodany komentarz przy pinie, bo dependabot raz już podbił to w złą stronę.

Naprawione regresje Django 6.1

Każda potwierdzona kontrolą: test przechodzi na 5.2, oblewa na 6.1, przy niezmienionym otoczeniu.

1. ForwardManyToOneDescriptor.get_queryset() wymaga instance (bpp/admin/core.py)
W 6.1 metoda dokłada .fetch_mode() ze stanu instancji, więc ma sygnaturę keyword-only instance. BPP wołało ją w czasie definicji klasy formularza, gdzie instancji nie ma. Zastąpione sięgnięciem po _base_manager wprost — dokładnie to, co deskryptor robił pod spodem. _base_manager, nie _default_manager: pole musi widzieć rekordy odfiltrowane przez managera domyślnego (soft-delete).

2. Aliasy SELECT-a są konsekwentnie cytowane (raport_slotow/core.py)
ProgrammingError: kolumna "col2" nie istnieje. Autor_Dyscyplina.Meta.ordering = ('rok',), a rok to druga kolumna w values(). Wymagało trzech pustych .order_by(): na obu operandach union(), na jego wyniku i na wyniku difference() — ten ostatni dlatego, że raport_slotow/models/uczelnia.py woła na nim values_list(), co klonuje zapytanie razem z flagą domyślnego sortowania.

3. BaseModelForm.validate_constraints() — metoda nowa w 6.1 (bpp/admin/projekt.py)
W 5.2 nie istniała; Model.validate_constraints jest w obu wersjach identyczne. Skutek: podmiana kierownika projektu w jednym zapisie przestawała przechodzić, bo stary wiersz oznaczony DELETE wciąż jest w bazie. Django koordynuje walidację między formularzami tylko dla unikalności (BaseModelFormSet.validate_unique() odsiewa deleted_forms); dla constraintów odpowiednika nie ma. Walidacja per-wiersz wyłączona w dedykowanym Projekt_AutorForm; odsiew robi istniejący Projekt_AutorFormSet.clean(), a constraint w bazie zostaje jako ostatnia linia obrony.

4. Script.__str__ renderuje przez staticfiles storage (bpp/tests/test_zapytanie.py)
Do 6.0 str(Script(...)) nie zawierało content-hasha; teraz zawiera (highlight.jshighlight.f65f9cbe99ea.js). Asercja dopasowuje nazwę tolerując hash, zamiast porównywać literał.

Dług techniczny wprowadzony świadomie

Co Dlaczego Status
djangorestframework z gita (80d535dd) DRF 3.17.2 (najnowsze na PyPI) wciąż importuje cc_delim_re, usunięte w 6.1 → ImportError na całym api_v1. Fix jest na main upstreamu, ale 3.18.0 nie zostało wydane. ZOSTAJE — decyzja świadoma. Nie czekamy na 3.18.0. Konsekwencja: DRF wypada z pip-audit (URL requirements) i buduje się ze źródeł. Pin wskazuje na SHA na main, więc jest trwały (w odróżnieniu od gałęzi feature'owej). Wymiana na >=3.18 to jedna linijka, gdy wydanie się pojawi.
django-pbn-client z gita PyPI 0.2.2 miało cap django<5.3. ZDJĘTE — 0.2.3 wydane 2026-08-07, wraca z rejestru PyPI (ee5e81a35).
Shim django.utils.itercompat w django_bpp/compat.py django-admin-tools 0.9.3 (ostatnie wydanie: sierpień 2023, klasyfikatory do Django 4.0) ma martwy import usuniętego modułu. Zostaje — do zdjęcia razem z odejściem od django-admin-tools albo po forku z usuniętą tą linią.

Dodatkowo podniesiony floor h2>=4.4.1 (CVE-2026-71554) w constraint-dependencies. CVE dotyczyło tak samo dev — tam h2 również było 4.3.0; złapane przy okazji, bo dev nie miał przebiegu pip-audit od publikacji CVE.

Przed produkcją warto obejrzeć panel admina — Django 6.1 przebudowało układ formularzy admina (usunięte wide, object-tools poza blokiem content), a w repo jest 64 nadpisanych szablonów. Testy sprawdzają zachowanie, nie layout. Szczegóły w komentarzu do PR-a.

Czego świadomie NIE ma w tym PR

  • Sprzątania deprecacji 6.1 (BLANK_CHOICE_DASH, transaction.savepoint(), gołe select_related(), action_location, EMAIL_*MAILERS). To warningi, nie błędy — filterwarnings = default, więc nic nie blokują. Osobny PR, żeby ten miał jeden czytelny sygnał.
  • Zmian w docs.yml (Python 3.11): ten workflow nie instaluje projektu, tylko docs/requirements.txt + mkdocs build, więc requires-python go nie dotyczy.

Weryfikacja

  • manage.py check — czysto.
  • makemigrations --check — dryf istnieje (favicon, flexible_reports, siteblog, wewnątrz .venv), ale identyczny na 5.2: stan zastany, nie skutek tego PR-a.
  • Audyt uv.lock: z 365 pakietów zniknęło sześć — pięć backportów dla Pythona <3.12 (async-timeout, backports-tarfile, importlib-metadata, tomli, zipp) plus podmieniony fork Redisa. Reszta dużego diffu to metadane gałęzi rozwiązania dla 3.10/3.11.

mpasternak and others added 2 commits August 6, 2026 19:45
…ppelli

Przygotowanie pod Django 6.1.

django-prometheus 2.5.0 ma twardy cap `Django<6.1`. Integracja byla
w calosci opcjonalna (flaga DJANGO_BPP_ENABLE_PROMETHEUS, domyslnie
False), wiec usuwamy pakiet wraz z obsluga flagi, middleware, wpisem
w INSTALLED_APPS i routingiem /metrics.

Grappelli nie wymaga zmiany wersji — 5.0.0 jest juz linia dla Django
6.x. Dodany komentarz przy pinie, bo numeracja Grappelli jest
przesunieta o jeden wstecz wzgledem Django (5.0.x -> Django 6.x,
4.0.x -> Django 5.x) i dependabot juz raz podbil to w zla strone
(29f0ecd), zostawiajac skorke admina dla Django 6.x na Django 5.2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F134BWb3YoYPQ6zjzqoqds
Podnosi Django z 5.2.16 LTS do 6.1 i `requires-python` do >=3.12
(wymaganie 6.1; obraz Dockera i glowne CI juz stoja na 3.12).

Zaleznosci:

* `django-redis-iplweb` -> upstreamowy `django-redis` 7.x. Fork byl czystym
  republishem (zero kodu iplweb, ten sam modul `django_redis`) i utknal na
  `Django<6.1`. Nazwa backendu w konfiguracji sie NIE zmienia, wiec
  wdrozenia nie wymagaja akcji.
* `djangorestframework` i `django-pbn-client` TYMCZASOWO z gita — oba maja
  fix na glownej galezi, ale nie na PyPI. Warunki wyjscia opisane przy
  pinach w [tool.uv.sources].

Naprawione regresje 6.1 (kazda potwierdzona kontrola: przechodzi na 5.2,
oblewa na 6.1 przy niezmienionym otoczeniu):

* `ForwardManyToOneDescriptor.get_queryset()` wymaga teraz keyword-only
  `instance` (dokłada `.fetch_mode()` z jej stanu). Wolane bylo w czasie
  definicji klasy formularza — zastapione siegnieciem po `_base_manager`
  wprost, czyli tym, co deskryptor robil pod spodem.
* Aliasy SELECT-a sa konsekwentnie cytowane, przez co odziedziczone
  `Meta.ordering` w zapytaniu zlozonym kompilowalo sie do `ORDER BY "col2"`
  -> ProgrammingError. Puste `.order_by()` na operandach `union()`, na jego
  wyniku i na wyniku `difference()`.
* `BaseModelForm.validate_constraints()` to metoda NOWA w 6.1 (w 5.2 nie
  istniala). Psula podmiane kierownika projektu w jednym zapisie: wiersz
  oznaczony DELETE wciaz jest w bazie. Django koordynuje walidacje miedzy
  formularzami tylko dla unikalnosci; dla constraintow odpowiednika nie ma.
* `Script.__str__` renderuje teraz przez staticfiles storage, wiec URL
  zawiera content-hash — asercja w tescie toleruje go zamiast porownywac
  literal.

Dodatkowo: shim `django.utils.itercompat` dla `django-admin-tools` 0.9.3
(martwy import usunietego modulu), usuniete martwe
`FORMS_URLFIELD_ASSUME_HTTPS` wraz z jego wyciszaczem w pytest.ini.

Weryfikacja: 9308 zdanych, 0 porazek (bez Playwrighta). `manage.py check`
czysto. Dryf `makemigrations --check` identyczny jak na 5.2 (stan zastany).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F134BWb3YoYPQ6zjzqoqds
@mpasternak mpasternak closed this Aug 7, 2026
@mpasternak mpasternak reopened this Aug 7, 2026
mpasternak and others added 2 commits August 7, 2026 10:11
pip-audit na PR-ze zglosil h2 4.3.0 (fix w 4.4.1). CVE dotyczy tak samo
gałęzi `dev` — tam h2 rowniez jest 4.3.0 — zlapane przy okazji tego PR-a,
bo `dev` nie mial przebiegu pip-audit od publikacji CVE.

h2 przychodzi tranzytywnie przez twisted[http2]/daphne, wiec floor ladnie
pasuje do istniejacej sekcji `constraint-dependencies`. Podnosi rowniez
hpack 4.1.0 -> 4.2.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F134BWb3YoYPQ6zjzqoqds
PR iplweb/django-pbn-client#5 zostal zmergowany (2026-08-07 07:16Z),
a 0.2.3 trafilo na PyPI (08:22Z) — z `django>=5.2` zamiast dawnego capu
`django<5.3` i klasyfikatorem `Framework :: Django :: 6.1`.

Wraca wiec z rejestru zamiast z galezi feature'owej. Floor podniesiony do
0.2.3, bo ponizej tej wersji Django 6.1 sie nie rozwiaze.

Poza porzadkiem to takze zdjecie realnego ryzyka: pin wskazywal na commit
NIEZMERGOWANEJ galezi, ktora po merge'u zwykle znika — build produkcyjny
przestalby sie budowac. Dodatkowo pakiet wraca do zakresu `pip-audit`,
ktory pomija zaleznosci z URL-i.

Zostaje jeden tymczasowy pin git: djangorestframework (3.18.0 wciaz
niewydane, PR encode/django-rest-framework#10015 otwarty).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F134BWb3YoYPQ6zjzqoqds
@mpasternak

Copy link
Copy Markdown
Member Author

Gotowość do merge'u — co jest sprawdzone, a co nie

Co merge do dev faktycznie uruchamia

Push do dev odpala wyłącznie Docs, Refresh baseline pg_dump i Tests. Żadnego deploya, żadnego budowania obrazów produkcyjnych. Produkcja idzie osobno i ręcznie: make release-candidate:staging → test → make release-promote:latest. Merge tego PR-a niczego nie wysyła użytkownikom i jest odwracalny zwykłym git revert.

Co jest zweryfikowane

  • Pełna suita z Playwrightem włącznie — shardy CI pokrywają cały zestaw (tests.yml:282: „Single sharded job covering the full suite (playwright + non-playwright + serial)"), więc testy przeglądarkowe też przeszły na 6.1.
  • 12/12 shardów, Build test-runner image, pip-audit, uv.lock in sync, Check baseline freshness — zielone. 24 checki, zero nie-pass.
  • Lokalnie: 9308 zdanych, manage.py check czysto.
  • Dryf makemigrations --check identyczny jak na 5.2 — stan zastany, nie skutek tego PR-a.
  • django-redis 6.0.1 (fork) → 7.0.0 (upstream) przez major: sprawdzone, że BPP używa wyłącznie stringa BACKEND, bez get_redis_connection i bez metod hash/sorted-set, które 7.0 przemianowało. Nazwa backendu w konfiguracji się nie zmienia → wdrożenia nie wymagają akcji.

Czego testy NIE sprawdzają — jedyna istotna luka

Wygląd panelu admina. Django 6.1 usunęło klasę CSS wide, wyniosło object-tools poza blok content i przebudowało układ pól formularza (etykiety / help / błędy). W repo jest 64 nadpisanych szablonów admina, a Grappelli 5.0.0 dopiero teraz trafiło na Django, dla którego było pisane (linia 5.0.x = Django 6.x).

Testy sprawdzają zachowanie, nie layout — rozjechany formularz przejdzie przez zielone CI niezauważony. To warto obejrzeć okiem: run-site run --from-dump <dump>, wejść w wydawnictwo ciągłe z inline'ami autorów, autora i źródło.

Koszt dla zespołu

Po merge'u każda żywa gałąź feature'owa musi się przerebase'ować na Django 6.1 i przesynchronizować własny venv — requires-python skoczyło do 3.12, a z locka wypadło pięć backportów dla starszych Pythonów. To nie ryzyko techniczne, tylko tarcie, ale warto uprzedzić zainteresowanych przed merge'em.

Decyzja: djangorestframework zostaje na pinie z gita

Pierwotnie ten PR zakładał zdjęcie pinu po wydaniu DRF 3.18.0 (PR encode/django-rest-framework#10015, wciąż otwarty). Świadoma decyzja: nie czekamy — wchodzimy z pinem na commit 80d535dd z gałęzi main upstreamu.

Uczciwie o konsekwencjach, żeby nikt nie odkrył ich później przypadkiem:

  • pip-audit nie skanuje DRF, dopóki jest z URL-a (URL requirements cannot be pinned to a specific package version). DRF to duża zależność sieciowa — to realna, choć zaakceptowana, dziura w audycie. Dopóki tak jest, CVE w DRF nie zostanie wykryte automatycznie.
  • Git-source buduje ze źródeł, czyli wykonuje setup.py przy instalacji — jawny wyjątek od polityki wheel-only opisanej w pyproject.toml. Trzeci taki wyjątek w projekcie, obok django-import-export.
  • Trwałość pinu jest tu jednak dobra, inaczej niż w zdjętym już przypadku django-pbn-client: tamten wskazywał na commit niezmergowanej gałęzi feature'owej, która po merge'u znika i psuje build. Ten wskazuje na SHA na main, który pozostaje osiągalny — więc build się nie rozsypie.

Do zrobienia przy okazji, gdy DRF 3.18.0 się pojawi: zamienić wpis w [tool.uv.sources] na djangorestframework>=3.18 i uv lock. Jedna linijka; komentarz przy pinie opisuje warunek wyjścia.

Znany flake, niezwiązany z tym PR-em

komparator_pbn_udzialy::test_problem_wrapper_for_rozbieznosc padł raz na IntegrityError (kod=3.314), po czym przeszedł przy ponownym uruchomieniu; lokalnie 15 powtórzeń pod rząd zielonych. Przyczyna: baker.make("bpp.Dyscyplina_Naukowa", ...) bez jawnego kod przy unikalnym polu — pięć miejsc (test_models.py 158/159/193, test_views.py 47/48). git diff dev...django-6.1 -- src/komparator_pbn_udzialy/ jest pusty, więc to nie regresja z tej migracji. Do naprawy osobnym PR-em.

mpasternak and others added 8 commits August 7, 2026 14:06
* fix(admin): filtr "Wydział" na liście autorów listował 504 jednostki zamiast 7 wydziałów

`AutorAdmin.list_filter` miał goły string "aktualna_jednostka__wydzial",
z którego Django budowało `RelatedFieldListFilter`, a ten woła
`field.get_choices()`. Po Fazie B (#438) denorm `Jednostka.wydzial` jest
self-FK na `Jednostka`, więc `get_choices()` enumerowało CAŁĄ tabelę
jednostek: na kopii bazy produkcyjnej 504 opcje w dropdownie zamiast
7 jednostek-korzeni ("wydziałów"), plus 504 zapytania na każdy request
changelisty (każde `Jednostka.__str__` czyta `self.uczelnia`).

Faza B naprawiła to `WydzialFilter`-em, ale tylko dla `JednostkaAdmin` —
`AutorAdmin` został przeoczony. Dokładamy `WydzialAutoraFilter`: podklasę
`WydzialFilter`, która dziedziczy bez zmian `lookups()` (tylko korzenie,
zawężone do uczelni z requestu) i `has_output()` (bramka
`uzywaj_wydzialow`), a nadpisuje jedynie `queryset()` — bo tu zawężamy
`Autor`, więc do korzenia trzeba dojść przez `aktualna_jednostka__`
(`Q(aktualna_jednostka__wydzial_id=v) | Q(aktualna_jednostka_id=v)`).

Świadoma zmiana kontraktu URL: parametr filtra to teraz `?wydzial=<id>`
zamiast `?aktualna_jednostka__wydzial__id__exact=<id>`. Filtry admina to
ulotny stan UI, nie trwałe linki, więc to akceptujemy; stary querystring
degraduje się do 400 (`DisallowedModelAdminLookup`, zmierzone testem),
a nie do 500.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWWemKMkQMQmHZZPSmCdid

* fix(admin): listy filtrow admina jednym zapytaniem zamiast N+1

Dwa niezalezne defekty w `bpp/admin/filters.py`, oba wykryte pomiarem na
kopii bazy produkcyjnej (504 jednostki, 68 tys. autorow):

1. `JednostkaFilter.lookups()` mial `select_related("wydzial")`, ale
   `Jednostka.__str__` czyta DWA FK -- takze `self.uczelnia` (bramka
   `uzywaj_wydzialow`). Kazda pozycja listy kosztowala osobny SELECT:
   504 zapytania na KAZDE wejscie na changeliste autorow. Cacheops
   zamienial je na trafienia w Redis (`bpp.uczelnia` jest w regulach),
   wiec licznik zapytan SQL tego nie pokazywal -- ale 504 round-tripy
   do Redisa nadal kosztowaly ~200 ms na request.

2. `LogEntryFilterBase.lookups()` zawezal queryset przez
   `.only("pk", "username")`, a `BppUser.__str__` czyta jeszcze
   `last_name` i `first_name`. Kazde pole odroczone to osobny
   `refresh_from_db()` per uzytkownik, czyli DWA dodatkowe SELECT-y na
   wiersz -- "optymalizacja", ktora kosztowala zamiast oszczedzac. Na
   produkcji 156 zapytan na wejscie na changeliste wydawnictw ciaglych,
   i w przeciwienstwie do slownikow `bpp.bppuser` NIE jest cache'owany
   przez cacheops, wiec szly wprost do PostgreSQL.

Zmierzony efekt (kopia produkcji, produkcyjne reguly CACHEOPS):
changelist wyd. ciaglych 190 -> 34 zapytania, changelist autorow
620 -> 452 ms (przy tej samej liczbie zapytan SQL -- zniknely
round-tripy do Redisa).

Testy pilnuja sedna: obie listy powstaja DOKLADNIE jednym zapytaniem,
niezaleznie od liczby pozycji. Drugi test dodatkowo asertuje, ze imie
i nazwisko sa w etykiecie -- inaczej `only()` znow by je pominelo, a
`__str__` po cichu degradowalby do samego `username`.

Uporzadkowana tez kolejnosc importow w `test_filters.py` (plik nie
przechodzil `ruff check` juz przed ta zmiana; pre-commit sprawdza tylko
pliki zmieniane, wiec nikt tego nie zauwazyl).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWWemKMkQMQmHZZPSmCdid

* fix(browse): liczba autorow na indeksie jednostek jednym agregatem

`browse/jednostki.html` wolal `item.aktualna_jednostka.count` -- relacja
ODWROTNA (Autor -> Jednostka), wiec kazde uzycie to osobny `COUNT(*)`.
Szablon uzywal go 3-4 razy na wiersz (warunek `> 0`, sama liczba, dwa
warunki odmiany), a strona pokazuje domyslnie 150 jednostek. Na kopii
bazy produkcyjnej dawalo to 514 zapytan na JEDNO wejscie na indeks
jednostek -- najwiekszy pojedynczy koszt zapytan w calej czesci
publicznej.

Ani cacheops, ani fetch modes z Django 6.1 tego NIE lapaly: queryset
dotyczy `bpp.autor`, ktorego nie ma w regulach CACHEOPS, a `FETCH_PEERS`
obsluguje leniwe FK i pola odroczone -- nie `RelatedManager.count()`.
Zmierzone: z globalnym FETCH_PEERS bylo 514 -> 514 zapytan, czyli zero
zmiany. Lekarstwem jest adnotacja, nie tryb pobierania.

Zmierzony efekt (kopia produkcji, produkcyjne reguly CACHEOPS):
514 -> 3 zapytania, 137 -> 20 ms.

Adnotacja jest tu bezpieczna (nie zawyza liczb przez zdublowane wiersze),
bo -- jak dokumentuje komentarz w `Browser.get_queryset` -- zadna sciezka
filtrowania `JednostkiView` nie mnozy wierszy: filtr literki to
`istartswith` na wlasnej kolumnie, fulltext dla `Jednostka` to predykat
na jednokolumnowym tsvectorze bez JOIN-a, a `scope_jednostki_do_uczelni`
porownuje skalarny FK. Sprawdzone tez wyczerpujaco: dla WSZYSTKICH 504
jednostek z bazy produkcyjnej adnotacja dala te same liczby, co
`.count()` per wiersz (0 roznic, suma 60 711 autorow), a wyrenderowany
HTML jest identyczny bajt w bajt.

Dwa testy, bo zmiana ma dwa rozne ryzyka:
* `test_JednostkiView_liczba_autorow_jednym_zapytaniem` -- sedno, czyli
  brak zapytania per wiersz,
* `test_browse_jednostki_pokazuje_liczbe_autorow` -- literowka w nazwie
  adnotacji NIE wywalilaby wyjatku (Django renderuje nieistniejaca
  zmienna jako pusty lancuch), wiec strona po cichu przestalaby pokazywac
  liczby.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWWemKMkQMQmHZZPSmCdid

* feat(admin): wlacz FETCH_PEERS (Django 6.1) dla querysetow adminow

Django 6.1 wprowadzilo *fetch modes*. `FETCH_PEERS` sprawia, ze PIERWSZE
leniwe dotkniecie relacji (albo pola odroczonego) na obiekcie z querysetu
dociaga ja HURTEM dla calego rodzenstwa z tego samego pobrania
(`prefetch_related_objects` pod spodem) -- N+1 zamienia sie w 2 zapytania,
bez zgadywania z gory, ktore FK dotknie szablon.

Changelisty admina to najgestsze w BPP skupisko tego wzorca: `list_display`
i `__str__` modeli siegaja po FK, ktorych nikt nie zadeklarowal w
`list_select_related`. Ustawiamy tryb w `BaseBppAdminMixin.get_queryset`,
czyli w jednym miejscu dla 31 adminow.

Zmierzone na kopii bazy produkcyjnej (produkcyjne reguly CACHEOPS),
zapytania i mediana czasu na request:

  changelist jednostek        66 -> 17 zapytan, 185 -> 120 ms
  changelist wyd. zwartych   220 ->  40 zapytan, 227 -> 184 ms
  changelist wyd. ciaglych   190 ->  38 zapytan, 221 -> 190 ms

Dlaczego TU, a nie globalnie (podstawienie `DEFAULT_FETCH_MODE`):
`track_peers` trzyma `weakref` do kazdej instancji z pobrania, wiec koszt
ponosilby KAZDY queryset w aplikacji, a zysk jest skoncentrowany w
adminie. Samo `get_queryset` wystarcza, bo `QuerySet._clone()` przenosi
`_fetch_mode` -- tryb przezywa filtry, sortowanie i slicing dokladane
przez dalsze mixiny i przez sam `ChangeList`. Test pilnuje obu tych
wlasnosci osobno.

Semantyka sie NIE zmienia: `fetch_one` i `fetch_many` ida ta sama sciezka
managera (`_base_manager`) -- tryb nie zaczyna nagle odfiltrowywac
rekordow, co jest istotne przy soft-delete. Sprawdzone tez empirycznie na
danych produkcyjnych: 15 stron (publiczne, admin, API) zwrocilo wynik
identyczny bajt w bajt w obu trybach.

WYMAGA Django >= 6.1, dlatego ta gałąź celuje w `django-6.1`, a nie w `dev`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWWemKMkQMQmHZZPSmCdid

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Audyt wydajnosci pod Django 6.1 zostal zrobiony na kopii bazy produkcyjnej
przy pomocy harnessu, ktory dotad zyl jako nietrackowane pliki na jednej
maszynie. To czynilo wyniki nieodtwarzalnymi, a wnioski niesprawdzalnymi --
wiec harness wchodzi do repo razem z dokumentacja.

`bench/` (4 skrypty):

* `bench_orm.py`     -- `--inwentarz` (detekcja N+1 przez wlasny FetchMode,
                        ktory liczy leniwe pobrania ze stosem wywolan)
                        oraz `--pomiar [--tryb peers]`,
* `bench_rownowaznosc.py`  -- dowod, ze FETCH_PEERS nie zmienia tego, co
                        strona zwraca (porownanie bajt w bajt, po
                        wyzerowaniu tokenow CSRF i znacznika czasu),
* `bench_atrybucja.py`     -- rozbicie pobran JEDNEGO pola na miejsca
                        wywolania (tym rozwiazalem rozbieznosc 1058 pobran
                        przy 504 jednostkach: dwa rozne filtry admina),
* `bench_licznik_wywolan.py` -- licznik budow ChangeList i konsumpcji
                        generatora filtra (obalil moja hipoteze o
                        podwojnym ChangeList -- memoizacja dziala).

`src/django_bpp/settings/bench.py` + `bench_prod.py`: DWA warianty
konfiguracji, i to rozroznienie jest sedno rzetelnosci pomiaru. `local.py`
NIE definiuje `CACHEOPS` wcale, a produkcja cache'uje `bpp.uczelnia`,
`bpp.jednostka`, `bpp.tytul` i pozostale slowniki -- czyli dokladnie te
modele, w ktore uderzaja znalezione N+1. Pomiar tylko na `local.py`
ZAWYZA zysk: changelist autorow pokazywal 1102 -> 96 zapytan bez cacheops,
a z produkcyjnymi regulami 27 -> 27, czyli zysk ZEROWY. `bench_prod.py`
czyta reguly z `production.py` przez AST (bez wykonywania modulu), zeby
nie mierzyc wlasnej, rozjezdzajacej sie kopii.

`docs/deweloper/benchmark-orm.md` opisuje procedure, oba warianty, pulapki
(dump jest w formacie katalogowym `pg_dump -Fd`, wiec `run-site --from-dump`
go nie przyjmie; cacheops pikluje instancje, wiec przy zmianie wersji
Django trzeba FLUSHDB) i -- najwazniejsze -- jak NIE dac sie oszukac
czasom: scenariusz kontrolny `admin: zrodlo (changelist)` nie jest tkniety
zadna optymalizacja i ma stale 16 zapytan, wiec jego rozrzut miedzy
przebiegami to zmierzona podloga szumu hosta.

`HANDOFF-pomiar-wydajnosci-2026-08-07.md`: liczby zapytan sa zmierzone
i pewne (powtorzyly sie w pieciu przebiegach), ale CZASOW nie udalo sie
zmierzyc rzetelnie -- host jest stale obciazony rownoleglymi sesjami
i suitami testowymi. Kontroler pokazal skok 320 -> 420 ms na NIEZMIENIONEJ
sciezce kodu, co dowodzi, ze roznice czasowe pochodza z hosta, a nie
z kodu. Handoff opisuje, co zostalo do zrobienia, jakie jest kryterium
sukcesu i siedem pulapek, w ktore juz wpadlem.

Nic z tego nie wplywa na kod produkcyjny: `bench/` jest poza
`testpaths = src`, a moduly settings dziedzicza po `local.py`, ktory
w produkcji sie nie laduje.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWWemKMkQMQmHZZPSmCdid
Zbiera to, czego migracja na 6.1 NIE objela, a co warto rozwazyc —
z dowodami z kodu, szacunkiem kosztu i warunkami wyjscia.

Nie jest to lista przepisana z release notes: kazda pozycja byla
sprawdzona w tym repozytorium. Osobna sekcja notuje tematy SPRAWDZONE
I WYKLUCZONE (podpisane ciasteczka, PBKDF2, deprecacja
list_select_related=True, GeneratedField), zeby nie wracaly w dyskusji.

Zawiera tez: dlug techniczny z warunkami zdjecia (pin DRF z gita, shim
itercompat, usuniety django-prometheus), deprecacje 6.1 trafiajace w kod
z terminem na Django 7.0 wraz z lista miejsc, oraz opis znanego flake'a
w komparator_pbn_udzialy.

Dokument nie jest w nav mkdocs — tak samo jak pozostale dokumenty
planowe i audytowe w docs/deweloper/. `mkdocs build --strict` przechodzi.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F134BWb3YoYPQ6zjzqoqds
* fix(admin): brakujace JOIN-y na listach jednostek i wydawnictw zwartych

Dwie deklaracje `list_select_related` byly martwe — kod je deklarowal,
a Django ich do zapytania nie wstawialo. Objaw: N+1 na changelistach,
maskowany od #733 przez FETCH_PEERS (2 zapytania zamiast 2N), ale nadal
falszywa deklaracja.

1. JednostkaAdmin — pulapka warunkowego `apply_select_related`.

`ChangeList.get_queryset` (django/contrib/admin/views/main.py) aplikuje
deklaracje WARUNKOWO:

    if not qs.query.select_related:
        qs = self.apply_select_related(qs)

czyli TYLKO gdy queryset bazowy admina nie ma jeszcze zadnego
`select_related`. A `JednostkaManager.get_queryset()` dokłada
`.select_related("wydzial")` (zdenormalizowany self-FK), wiec warunek
jest falszywy i CALA deklaracja admina przepada — do zapytania szedl sam
`wydzial`. Leniwie, per wiersz, leciały wiec:

  * `rodzaj`   — kolumna `list_display`,
  * `uczelnia` — czytana przez `Jednostka.__str__` (sprawdza
    `uzywaj_wydzialow`), czyli w KAZDYM wierszu,
  * `parent`   — kolumna `parent_nazwa` (`item.parent.nazwa`), ktorej
    w deklaracji w ogole nie bylo.

Nie ma tu zadnego bledu ani ostrzezenia — z samego diffa/kodu admina
tego nie widac, bo deklaracja wyglada poprawnie. Nadpisujemy wiec
`get_queryset` i dokladamy `select_related` wprost (select_related sie
scala, wiec `wydzial` z managera nie ginie) oraz dopisujemy `parent`.

Przeglad calego `src/`: to JEDYNY manager w projekcie, ktory dokłada
domyslny `select_related`, wiec pulapka wystepuje tylko tutaj. Pozostale
`get_queryset` z `select_related` siedza na `ModelResource`
(django-import-export) albo na adminach bez `list_select_related` —
tam nie ma czego zgubic.

2. Wydawnictwo_ZwarteAdmin — deklaracja pod zla nazwa kolumny.

BPP uzywa slownikowego dialektu `list_select_related`
(django-dynamic-admin-columns): JOIN wchodzi tylko, gdy kolumna o danej
nazwie jest widoczna. Wpis `"wydawca": ["wydawca"]` celowal w kolumne
`wydawca`, ktorej NIE MA w `list_display_default`. Tymczasem po wydawce
siega domyslnie widoczna kolumna `wydawnictwo` — property modelu
`get_wydawnictwo()` sklada `self.wydawca.nazwa` z `wydawca_opis`. JOIN
wchodzil wiec tylko przy recznie wlaczonej kolumnie `wydawca`, czyli
praktycznie nigdy. Dokladamy `"wydawnictwo": ["wydawca"]`.

Oba przypadki znalezione przez bramke FETCH_RAISE (kolejny commit).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F134BWb3YoYPQ6zjzqoqds

* test(admin): bramka regresyjna na N+1 oparta o FETCH_RAISE (Django 6.1)

Istniejacy `test_fetch_peers.py` sprawdza wylacznie, ze tryb FETCH_PEERS
jest ustawiony i przezywa `_clone()`. To dowodzi, ze mechanizm jest
podpiety — ale NIE dowodzi, ze N+1 faktycznie zniklo, i nie zatrzyma
jego powrotu. Ktos dokłada kolumne do `list_display` albo relacje do
`__str__`, liczba zapytan rosnie liniowo z liczba wierszy, a testy
zostaja zielone.

Nowy plik przybija KSZTALT ZAPYTAN czterech najgoretszych changelist
(wydawnictwa ciagle i zwarte, autorzy, jednostki) trzema bramkami:

1. FETCH_RAISE. Renderujemy prawdziwa changeliste przez klienta HTTP,
   podmieniajac `bpp.admin.core.FETCH_PEERS` na FETCH_RAISE (monkeypatch
   na czas jednego testu — kod produkcyjny i DEFAULT_FETCH_MODE zostaja
   nietkniete). Wg kodu Django 6.1 tryb wedruje z `QuerySet._fetch_mode`
   przez `Model.from_db(fetch_mode=...)` do `instance._state.fetch_mode`
   (dziedzicza go tez obiekty z select_related i prefetch_related),
   a trzy deskryptory — DeferredAttribute, ForwardManyToOneDescriptor
   i ReverseOneToOneDescriptor — zamiast dociagac dane wołaja
   `fetch_mode.fetch(...)`. FetchRaise rzuca tam FieldFetchBlocked
   (podklasa FieldError) z komunikatem "Fetching of <Model>.<pole>
   blocked." Efekt: kazde leniwe dotkniecie relacji wywala test
   natychmiast, z nazwa pola — zamiast po cichu dolozyc N SELECT-ow.

2. Liczba zapytan niezalezna od liczby wierszy. Ta sama changelista
   z 2 i z 8 wierszami musi kosztowac DOKLADNIE tyle samo zapytan.
   To operacyjna definicja braku N+1 i — w odroznieniu od przybicia
   konkretnej liczby — nie wymaga aktualizacji przy niewinnych zmianach.
   Lapie tez to, czego FETCH_RAISE z zasady nie widzi: menedzery relacji
   odwrotnych (`obj.cos_set.all()`) i M2M tryb tylko DZIEDZICZA, nie sa
   przez niego blokowane.

3. Deklaracja `list_select_related` naprawde trafia do zapytania.
   Django aplikuje ja warunkowo (`if not qs.query.select_related`), wiec
   sama jej obecnosc w kodzie niczego nie gwarantuje — to wlasnie ta
   pulapka zjadla trzy JOIN-y na liscie jednostek (poprzedni commit).
   Test przechodzi po wszystkich adminach `bpp` i flaguje kazdego,
   ktorego queryset bazowy ma wlasny select_related, a zadeklarowanych
   sciezek w nim brakuje.

Dane testowe maja WYPELNIONE FK widocznych kolumn — FETCH_RAISE odpala
sie tylko dla relacji o niepustym kluczu (`has_value`), wiec rekordy
z samymi NULL-ami przepuscilyby bramke na pusto.

Bramka pilnuje tez samej siebie: `test_bramka_fetch_raise_faktycznie
_gryzie` kasuje deklaracje `list_select_related` i wymaga, zeby
FieldFetchBlocked FAKTYCZNIE poleciał. Bez tego caly plik moglby zrobic
sie zielony na pusto, gdyby podmiana trybu przestala dzialac.

Autouse fixture czysci `DynamicColumnsMixin._modeladmin_enabled` —
to `cached_property` na singletonie admina, wiec przezywa rollback bazy
i sprawia, ze kolejny test w module widzi UBOZSZY uklad kolumn, niz
sadzi (czyli bramka cicho sie rozbraja).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F134BWb3YoYPQ6zjzqoqds

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…o 6.1) (#734)

Strona potwierdzenia kasowania w adminie enumeruje KAŻDY obiekt, który
poleci kaskadą — z linkiem do zmiany, jeden `<li>` na sztukę. Dla `Autor`,
`Zrodlo` czy masowego `delete_selected` na przefiltrowanej changeliście to
dziesiątki tysięcy pozycji: kilkanaście MB HTML-a, którego przeglądarka nie
renderuje sensownie, a człowiek i tak nie czyta. Django 6.1 dorzuciło na to
`ModelAdmin.delete_confirmation_max_display`.

Ustawiamy GLOBALNIE w `BaseBppAdminMixin` (100), a nie punktowo na paru
„ciężkich" adminach: eksplozja listy nie bierze się z samego modelu, tylko
z ogona jego kaskad, a ten potrafi urosnąć po dodaniu jednego FK w zupełnie
innej aplikacji. Limit punktowy z definicji nie chroni tam, gdzie nikt
problemu nie przewidział. 100 = wartość z przykładu w docstringu filtra
Django i 2× `list_per_page`, więc cała strona changelisty zaznaczona do
kasowania nadal wypisuje się co do sztuki.

Sam atrybut NIE WYSTARCZA — i to jest istota tej zmiany. Django tylko wkłada
tę wartość do kontekstu; o obcięciu decyduje szablon, wołając filtr
`truncated_unordered_list`. Aktywną skórką admina jest grappelli, której
`admin/delete_confirmation.html` i `admin/delete_selected_confirmation.html`
renderują listę przez `|unordered_list` (bez obcinania) — a to grappelli, nie
Django, wygrywa u nas rozwiązywanie nazwy szablonu. Dlatego oba nadpisania
w `src/django_bpp/templates/admin/` przejmują blok `content` (grappelli trzyma
tam całą treść i nie wystawia mniejszego bloku) i wołają filtr obcinający.

Świadome odstępstwa od szablonów upstreamu:

* `protected` i `perms_lacking` renderujemy BEZ obcinania. W BPP ta lista
  niesie ręcznie pisane komunikaty (`JednostkaAdmin.get_deleted_objects`
  dokłada je na KOŃCU), więc obcięcie ukryłoby dokładnie tę informację, po
  którą użytkownik na tę stronę przyszedł.
* Sekcja „Podsumowanie" (`model_count`) zostaje nietknięta — pełne liczniki
  per model są nad listą, więc obcięcie zabiera tylko wyliczankę
  pojedynczych obiektów, a nie wiedzę o skali kasowania.

Czego to NIE załatwia (żeby nie było złudzeń): opcja działa wyłącznie na
etapie renderowania. `get_deleted_objects` nadal zbiera i formatuje komplet
obiektów (`NestedObjects.collect` + `reverse()` per obiekt), więc zapytań do
bazy ani szczytowego zużycia pamięci po stronie Pythona to nie zmniejsza.
Zyskiem jest rozmiar odpowiedzi i to, że przeglądarka nie umiera.

Istniejący `JednostkaAdmin.get_deleted_objects` nie koliduje: woła `super()`
i dokłada wpisy do `protected`, a nowa opcja żyje wyłącznie po stronie
szablonu.

Testy idą przez klienta HTTP na realnym łańcuchu szablonów, bo asercja na
samym atrybucie przechodziłaby również wtedy, gdyby ktoś skasował nadpisania
szablonów, a strona dalej wypisywałaby wszystko.


Claude-Session: https://claude.ai/code/session_01F134BWb3YoYPQ6zjzqoqds

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pomiar wykonany na cichym hoscie, na kopii bazy produkcyjnej. Kryterium
z handoffu spelnione: kontroler "admin: zrodlo (changelist)" ma 16 zapytan
we wszystkich czterech przebiegach, rozrzut 3,2 ms = 1,1 % mediany, zero
flag SZUM. Na mac-mini ten sam kontroler skakal 320 -> 420 ms, przez co
tamten przebieg byl do wyrzucenia.

Docstring BaseBppAdminMixin.get_queryset podawal 40 i 38 zapytan oraz
czasy opisane jako rzad wielkosci -- podmienione na zmierzone (36 i 34).
Dopisane dwie rzeczy, ktore pomiar ujawnil, a ktore zmieniaja uzasadnienie
FETCH_PEERS:

* changelist autorow ma 27 zapytan przed i po, a czas spada o 68 % --
  bo dotykane relacje sa cache'owane przez CACHEOPS i koszt to round-tripy
  do Redisa, niewidoczne dla licznika SQL. Sam ten spadek pochodzi zreszta
  glownie z poprawek filtrow na dev (5ffc83f), nie z FETCH_PEERS.
* PR #738 dokłada na dev jawne select_related dla tych samych relacji
  i schodzi do 16 i 35 zapytan -- o JEDNO mniej niz FETCH_PEERS, bo ten
  musi dorzucic zapytanie hurtowe na relacje, a JOIN nie. Po scaleniu te
  pozycje przestana byc argumentem za FETCH_PEERS; zostaje wlasciwy:
  siatka bezpieczenstwa na relacje, ktorych nikt nie zadeklarowal.

Handoff przestawiony na ZAMKNIETY, z tabela czasow, rozbiciem 5.2 vs 6.1
i nowa pulapka: bench.py ustawia porty przez os.environ.setdefault(), co
NIE nadpisuje zmiennej juz wyeksportowanej przez profil shella -- benchmark
po cichu uderza wtedy w dev-owe kontenery zamiast w odtworzony dump.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjoMtF6KAaJydhGEGibgV7
# Conflicts:
#	pyproject.toml
#	src/bpp/admin/jednostka.py
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant